mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 17:50:32 -10:00
Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5409c03d58 | ||
|
|
a941930ea7 | ||
|
|
74572101ac | ||
|
|
7b050cae3f | ||
|
|
d193860668 | ||
|
|
1da0be8529 | ||
|
|
08b4346620 | ||
|
|
4d6d9b1fc4 | ||
|
|
59f9add53c | ||
|
|
1946562247 | ||
|
|
24a65ad80f | ||
|
|
37ea60c20c | ||
|
|
99cbcae249 | ||
|
|
a426042784 | ||
|
|
1cb06a6cb2 | ||
|
|
5236871b0b | ||
|
|
ceb20c9dd6 | ||
|
|
83fa42c179 | ||
|
|
7727b9c347 | ||
|
|
b015c0b226 | ||
|
|
882687818f | ||
|
|
37f5163872 | ||
|
|
df2c81d131 | ||
|
|
236398a823 | ||
|
|
eebe02eef2 | ||
|
|
4b1e5a76e2 | ||
|
|
74292d2ace | ||
|
|
b0b7cad316 | ||
|
|
926887d14e | ||
|
|
2e6bb2d276 | ||
|
|
30656509ac | ||
|
|
66e3f1ad7b | ||
|
|
9aadf2d467 | ||
|
|
ba80e57753 | ||
|
|
50c76f41e3 | ||
|
|
0658d58efd | ||
|
|
76a700c45c | ||
|
|
5ded193ccf | ||
|
|
93420d1fa0 | ||
|
|
add787f625 | ||
|
|
ec2c2aca5e | ||
|
|
1e86c48f48 | ||
|
|
545c2dbf9b | ||
|
|
3cb029147e | ||
|
|
d48cfbba69 | ||
|
|
07b5c0daa3 | ||
|
|
b78c84af2c | ||
|
|
dfaa2df90d | ||
|
|
2e2aaa4ac9 | ||
|
|
3ef0f74110 | ||
|
|
51ba7198d7 | ||
|
|
e10e6a2235 | ||
|
|
17f84cf7d6 | ||
|
|
f7d9b7b297 | ||
|
|
b287d7a4e0 | ||
|
|
d9d1b91fa5 |
No files matched your search
@@ -31,7 +31,9 @@ jobs:
|
||||
script: |
|
||||
core.setFailed('Please add a changelog entry!')
|
||||
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
|
||||
if: always()
|
||||
if: >-
|
||||
always() &&
|
||||
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
|
||||
with:
|
||||
message-id: changelog-entry-check
|
||||
refresh-message-position: true
|
||||
|
||||
@@ -37,20 +37,6 @@ jobs:
|
||||
run: yarn build
|
||||
- name: Publish packages to npm
|
||||
run: yarn run -T lerna publish from-package --yes --no-verify-access
|
||||
- name: Verify eas-cli was published as latest on npm
|
||||
run: |
|
||||
expected="$(jq -r .version lerna.json)"
|
||||
for attempt in $(seq 1 10); do
|
||||
published="$(npm view eas-cli@latest version 2>/dev/null || true)"
|
||||
if [ "$published" = "$expected" ]; then
|
||||
echo "Verified eas-cli@$expected is latest on npm."
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $attempt: npm latest is '$published', expected '$expected'. Retrying in 15s..."
|
||||
sleep 15
|
||||
done
|
||||
echo "eas-cli@$expected was not published as latest on npm; the publish step failed." >&2
|
||||
exit 1
|
||||
release-changelog:
|
||||
name: Update changelog and publish release
|
||||
runs-on: ubuntu-latest
|
||||
@@ -67,6 +53,10 @@ jobs:
|
||||
run: yarn install --immutable
|
||||
- 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: Update changelog
|
||||
working-directory: ./scripts
|
||||
run: yarn release-changelog "$EAS_CLI_VERSION" > /tmp/current-changelog
|
||||
@@ -74,17 +64,52 @@ jobs:
|
||||
run: gh release edit "v$EAS_CLI_VERSION" --draft=false -F /tmp/current-changelog
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Commit and push
|
||||
- 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
|
||||
- name: Verify eas-cli is available on npm
|
||||
run: |
|
||||
availability_deadline_epoch=$(( $(date +%s) + 600 ))
|
||||
|
||||
while true; do
|
||||
published="$(npm view "eas-cli@$EAS_CLI_VERSION" version --prefer-online 2>/dev/null || true)"
|
||||
if [ "$published" = "$EAS_CLI_VERSION" ]; then
|
||||
echo "Verified eas-cli@$EAS_CLI_VERSION is available on npm."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
current_epoch=$(date +%s)
|
||||
if [ "$current_epoch" -ge "$availability_deadline_epoch" ]; then
|
||||
break
|
||||
fi
|
||||
|
||||
retry_delay=$((availability_deadline_epoch - current_epoch))
|
||||
if [ "$retry_delay" -gt 10 ]; then
|
||||
retry_delay=10
|
||||
fi
|
||||
echo "eas-cli@$EAS_CLI_VERSION is not available on npm yet. Retrying in ${retry_delay}s."
|
||||
sleep "$retry_delay"
|
||||
done
|
||||
|
||||
echo "eas-cli@$EAS_CLI_VERSION was not available on npm after 10 minutes." >&2
|
||||
exit 1
|
||||
- name: Bump STAGING and PRODUCTION
|
||||
run: |
|
||||
git pull --rebase --autostash origin main
|
||||
jq --arg version "$EAS_CLI_VERSION" '.STAGING = $version' \
|
||||
cli-versions.json > cli-versions.json.tmp
|
||||
mv cli-versions.json.tmp cli-versions.json
|
||||
git config --global user.email "[email protected]"
|
||||
git config --global user.name "Expo CI"
|
||||
git add cli-versions.json CHANGELOG.md
|
||||
git commit --allow-empty -m "update CHANGELOG.md and bump STAGING in cli-versions.json"
|
||||
jq --arg version "$EAS_CLI_VERSION" '.PRODUCTION = $version' \
|
||||
cli-versions.json > cli-versions.json.tmp
|
||||
mv cli-versions.json.tmp cli-versions.json
|
||||
- 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
|
||||
- name: Prepare changelog for Slack
|
||||
run: |
|
||||
|
||||
+73
-1
@@ -6,14 +6,83 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Align the `eas observe:*` commands with the renamed Observe GraphQL schema. `--json` output now uses `name`/`value` instead of `metricName`/`metricValue` (metrics) and `name` instead of `eventName` (log events and event-name summaries), and `eas observe:events` now lists user-defined events only — exceptions moved to the new `eas observe:errors`. ([#4338](https://github.com/expo/eas-cli/pull/4338) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add experimental multi-account support behind `EAS_EXPERIMENTAL_ACCOUNT_SWITCHER=1`: `account:list`, `account:switch`, a global `--account` flag, and multi-account `account:login`/`account:logout`. ([#3340](https://github.com/expo/eas-cli/pull/3340) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Add `lcd_width`, `lcd_height`, and `lcd_density` inputs to configure the Android emulator display. ([#4349](https://github.com/expo/eas-cli/pull/4349) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
|
||||
- [eas-cli] Add `eas channel:protect` and `eas channel:unprotect` to manage EAS Update channel protection, and show protection in channel list and view output. ([#4319](https://github.com/expo/eas-cli/pull/4319) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
- [eas-cli] Add `eas observe:errors` to display error and exception issue groups (grouped by fingerprint), with `--fingerprint <fingerprint>` to drill into a group's individual occurrences and stack traces. ([#4339](https://github.com/expo/eas-cli/pull/4339) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Add `eas observe:event` to display a single Observe event (metric, log, or error) by its ID. ([#4252](https://github.com/expo/eas-cli/pull/4252) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Start Argent remote sessions with `bun x` so they work when the `bunx` symlink is missing. ([491b372a](https://github.com/expo/eas-cli/commit/491b372a) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [build-tools] Reduce `expo-device-hub` preview resolution from 1280 px to 960 px to match `serve-sim` and lower streaming bandwidth. ([#4326](https://github.com/expo/eas-cli/pull/4326) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
|
||||
- [build-tools] Install FFmpeg before launching `expo-device-hub` in Android device sessions. ([#4332](https://github.com/expo/eas-cli/pull/4332) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
|
||||
- [eas-cli] Print the session token in simulator preview links, so they still open once the preview is gated. ([#4312](https://github.com/expo/eas-cli/pull/4312) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Make ASC API key issuer identifier optional in GraphQL and credentials types. ([#4248](https://github.com/expo/eas-cli/pull/4248) by [@sswrk](https://github.com/sswrk))
|
||||
|
||||
## [23.2.0](https://github.com/expo/eas-cli/releases/tag/v23.2.0) - 2026-08-31
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--tag` to `eas simulator` and `eas simulator:list` to group and filter simulator sessions by app variant. ([#4318](https://github.com/expo/eas-cli/pull/4318) by [@tchayen](https://github.com/tchayen))
|
||||
- [eas-cli] Update workflow run logs in real time, instead of every 10 seconds. ([#4228](https://github.com/expo/eas-cli/pull/4228) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [build-tools] Add `expo-device-hub` web previews to Android web-preview-only, Agent Device, Argent, and Appium remote sessions. ([#4304](https://github.com/expo/eas-cli/pull/4304) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [eas-cli] Add `eas workflow:ssh <workflow-job-id> [command...]` to open an ssh session on the worker running a workflow job. ([#4032](https://github.com/expo/eas-cli/pull/4032) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `--ssh` (and `--ssh-idle-timeout`) to `eas workflow:run` to enable ssh on the run's VM jobs. ([#4033](https://github.com/expo/eas-cli/pull/4033) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Warn when starting Android EAS Simulator sessions that support is still in development and iOS parity is coming soon. ([#4320](https://github.com/expo/eas-cli/pull/4320) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [23.1.0](https://github.com/expo/eas-cli/releases/tag/v23.1.0) - 2026-08-31
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Prompt for the Expo SDK version in `eas new` and add a `--sdk-version` flag. The template now downloads from npm instead of GitHub to support SDK-specific versions. ([#4301](https://github.com/expo/eas-cli/pull/4301) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Clean up template metadata in projects created with `eas new`: remove the template LICENSE file and reset the package.json name, version, and license fields. ([#4301](https://github.com/expo/eas-cli/pull/4301) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Support `EAS_PNPM_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `pnpm install --filter`. ([#4302](https://github.com/expo/eas-cli/pull/4302) by [@robingullo](https://github.com/robingullo))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Normalize extracted project permissions so source archives cannot leave files unreadable by EAS workers. ([#4287](https://github.com/expo/eas-cli/pull/4287) by [@AbbanMustafa](https://github.com/AbbanMustafa))
|
||||
- [eas-cli] Add dynamic retries to deploy command that scale up more generously, depending on deployment size ([#4299](https://github.com/expo/eas-cli/pull/4299) by [@kitten](https://github.com/kitten))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Add regression coverage for the bundled iOS entitlement introspection fallback. ([#4285](https://github.com/expo/eas-cli/pull/4285) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [local-build-plugin] Replace `bunyan` with `@expo/bunyan` while preserving the Steps formatter binary. ([#4284](https://github.com/expo/eas-cli/pull/4284) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [steps] Replace deprecated `lodash.get` with a local helper. ([#4283](https://github.com/expo/eas-cli/pull/4283) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli][steps] Bump `uuid` to v11 and remove the redundant `@types/uuid` dependency. ([#4282](https://github.com/expo/eas-cli/pull/4282) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Bump `minizlib` to 3.1.0 to drop its deprecated `rimraf@5` transitive dependency. ([#4281](https://github.com/expo/eas-cli/pull/4281) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [23.0.0](https://github.com/expo/eas-cli/releases/tag/v23.0.0) - 2026-08-28
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Rename the EAS Simulator preview-only session type from `--type serve-sim` to `--type web-preview-only`. ([#4298](https://github.com/expo/eas-cli/pull/4298) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Cache CocoaPods dependencies between iOS builds. ([#4266](https://github.com/expo/eas-cli/pull/4266) by [@AbbanMustafa](https://github.com/AbbanMustafa))
|
||||
- [build-tools] Support `EAS_BUN_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `bun install --filter`. ([#4292](https://github.com/expo/eas-cli/pull/4292) by [@konrad-armatys](https://github.com/konrad-armatys))
|
||||
- [eas-cli] Add `eas project:status` (alias `eas status`) command that prints a snapshot of the project's recent builds, development builds, workflow runs, submissions, and updates, with agent-friendly JSON metadata, activity links, structured errors, submission-to-build correlation, and `--non-interactive` support. ([#4006](https://github.com/expo/eas-cli/pull/4006) by [@jonsamp](https://github.com/jonsamp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Prevent user npm minimum release age settings from applying to internal EAS CLI commands. ([#4296](https://github.com/expo/eas-cli/pull/4296) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Reduce serve-sim preview resolution from 1280 px to 960 px to lower streaming bandwidth. ([#4297](https://github.com/expo/eas-cli/pull/4297) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [worker] Use turtle cache upload and download URLs for eas.json build cache restore and save. ([#3860](https://github.com/expo/eas-cli/pull/3860) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [ci] Bump both STAGING and PRODUCTION cli-versions in release workflow. ([#4290](https://github.com/expo/eas-cli/pull/4290) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [22.6.0](https://github.com/expo/eas-cli/releases/tag/v22.6.0) - 2026-08-26
|
||||
|
||||
### 🎉 New features
|
||||
@@ -48,6 +117,9 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
- [build-tools] Support an optional `package_version` input on `eas/start_serve_sim_remote_session`, so a simulator session can pin the `@expo/serve-sim` version instead of always running `latest`. ([#4253](https://github.com/expo/eas-cli/pull/4253) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add composable custom build functions for downloading, installing, and launching application archives in simulator sessions. ([#4222](https://github.com/expo/eas-cli/pull/4222) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Add `--build-id`, `--application-archive-url`, and `--expo-go` to `eas simulator` to install and launch an application before the session is ready. ([#4223](https://github.com/expo/eas-cli/pull/4223) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Add `--environment` flag to the `eas observe:*` commands. ([#4275](https://github.com/expo/eas-cli/pull/4275) by [@kadikraman](https://github.com/kadikraman))
|
||||
- [eas-cli] Add `apple` value to the `--platform` flag of the `eas observe:*` commands. ([#4276](https://github.com/expo/eas-cli/pull/4276) by [@kadikraman](https://github.com/kadikraman))
|
||||
- [eas-cli] Add `--build-number` flag to `eas observe:metrics` and `eas observe:events`. ([#4278](https://github.com/expo/eas-cli/pull/4278) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "22.6.0",
|
||||
"PRODUCTION": "22.4.0"
|
||||
"STAGING": "23.2.0",
|
||||
"PRODUCTION": "23.2.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "22.6.0",
|
||||
"version": "24.0.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "22.6.0",
|
||||
"version": "24.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -39,17 +39,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "22.0.0",
|
||||
"@expo/eas-build-job": "22.0.0",
|
||||
"@expo/downloader": "24.0.0",
|
||||
"@expo/eas-build-job": "24.0.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "22.0.0",
|
||||
"@expo/logger": "24.0.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": "22.4.0",
|
||||
"@expo/template-file": "22.0.0",
|
||||
"@expo/turtle-spawn": "22.0.0",
|
||||
"@expo/steps": "24.0.0",
|
||||
"@expo/template-file": "24.0.0",
|
||||
"@expo/turtle-spawn": "24.0.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
@@ -92,7 +92,6 @@
|
||||
"@types/retry": "^0.12.5",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"bunyan": "^1.8.15",
|
||||
"jest": "^29.7.0",
|
||||
"memfs": "^4.17.1",
|
||||
"nock": "13.4.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import bunyan from 'bunyan';
|
||||
import type { bunyan } from '@expo/logger';
|
||||
|
||||
export function createLogger(): bunyan {
|
||||
const logger = {
|
||||
|
||||
@@ -11,6 +11,19 @@ jest.mock('../utils/stepMetrics');
|
||||
const mockUploadStepMetricsToWwwAsync = jest.mocked(uploadStepMetricsToWwwAsync);
|
||||
|
||||
describe(CustomBuildContext, () => {
|
||||
it('copies the MCP server URL from the build context', () => {
|
||||
const ctx = new BuildContext(createTestIosJob(), {
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
uploadArtifact: jest.fn(),
|
||||
workingdir: '',
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
});
|
||||
|
||||
expect(new CustomBuildContext(ctx).mcpServerUrl).toBe('ws://localhost:8787');
|
||||
});
|
||||
|
||||
it('should not lose workflowInterpolationContext', () => {
|
||||
const contextUploadArtifact = jest.fn();
|
||||
const ctx = new BuildContext(
|
||||
|
||||
@@ -18,8 +18,12 @@ import { downloadApplicationArchiveAsync } from '../ios/resign';
|
||||
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
|
||||
import { Sentry } from '../sentry';
|
||||
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
|
||||
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import {
|
||||
cacheStatsAsync,
|
||||
restoreCcacheAsync,
|
||||
restoreCocoapodsCacheAsync,
|
||||
} from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync, saveCocoapodsCacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import { uploadApplicationArchive } from '../utils/artifacts';
|
||||
import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
@@ -107,6 +111,12 @@ async function buildInnerAsync(
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
await restoreCocoapodsCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.INSTALL_PODS, async () => {
|
||||
@@ -250,6 +260,12 @@ async function buildInnerAsync(
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
await saveCocoapodsCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {
|
||||
|
||||
@@ -70,6 +70,48 @@ describe('easBuildInternal', () => {
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('overrides the minimum release age only for the internal config command', async () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
child: jest.fn(),
|
||||
} as any;
|
||||
const ctx = {
|
||||
logger,
|
||||
env: {
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '365',
|
||||
},
|
||||
job: { platform: 'ios', buildProfile: 'production' },
|
||||
} as any;
|
||||
jest.mocked(resolveEasCommandPrefixAndEnvAsync).mockResolvedValueOnce({
|
||||
cmd: 'npx',
|
||||
args: ['-y', 'eas-cli@latest'],
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
jest.mocked(spawn).mockResolvedValueOnce({
|
||||
stdout: Buffer.from(JSON.stringify({ buildProfile: { env: {} } })),
|
||||
} as any);
|
||||
|
||||
await resolveEnvFromBuildProfileAsync(ctx, { cwd: '/tmp/project' });
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'npx',
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
env: {
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '0',
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(ctx.env).toEqual({
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '365',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes --refresh-ad-hoc-provisioning-profile to build:internal for iOS jobs with refreshAdHocProvisioningProfile', async () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
|
||||
@@ -3,7 +3,10 @@ import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { PackageManager } from '../../utils/packageManager';
|
||||
import { installDependenciesWithNpmCacheFallbackAsync } from '../installDependencies';
|
||||
import {
|
||||
installDependenciesAsync,
|
||||
installDependenciesWithNpmCacheFallbackAsync,
|
||||
} from '../installDependencies';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => jest.fn());
|
||||
jest.mock('../../sentry', () => ({
|
||||
@@ -236,6 +239,85 @@ describe(installDependenciesWithNpmCacheFallbackAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(installDependenciesAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(spawn).mockReturnValue(createSpawnPromise(Promise.resolve(createSpawnResult())));
|
||||
});
|
||||
|
||||
it('installs only the selected workspace when EAS_PNPM_FILTER_WORKSPACE is set', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await installDependenciesAsync({
|
||||
packageManager: PackageManager.PNPM,
|
||||
env: { EAS_PNPM_FILTER_WORKSPACE: 'my-app' },
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: true,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pnpm',
|
||||
['install', '--filter', 'my-app', '--frozen-lockfile'],
|
||||
expect.objectContaining({ cwd: '/tmp/build' })
|
||||
);
|
||||
});
|
||||
|
||||
it('installs all workspaces when EAS_PNPM_FILTER_WORKSPACE is not set', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await installDependenciesAsync({
|
||||
packageManager: PackageManager.PNPM,
|
||||
env: {},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: true,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pnpm',
|
||||
['install', '--frozen-lockfile'],
|
||||
expect.objectContaining({ cwd: '/tmp/build' })
|
||||
);
|
||||
});
|
||||
|
||||
it('installs only the selected workspace when EAS_BUN_FILTER_WORKSPACE is set', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await installDependenciesAsync({
|
||||
packageManager: PackageManager.BUN,
|
||||
env: { EAS_BUN_FILTER_WORKSPACE: 'my-app' },
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: true,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['install', '--filter', 'my-app', '--frozen-lockfile'],
|
||||
expect.objectContaining({ cwd: '/tmp/build' })
|
||||
);
|
||||
});
|
||||
|
||||
it('installs all workspaces when EAS_BUN_FILTER_WORKSPACE is not set', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await installDependenciesAsync({
|
||||
packageManager: PackageManager.BUN,
|
||||
env: {},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: true,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['install', '--frozen-lockfile'],
|
||||
expect.objectContaining({ cwd: '/tmp/build' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createSpawnPromise(result: Promise<SpawnResult>): SpawnPromise<SpawnResult> {
|
||||
return Object.assign(result, {
|
||||
child: {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Platform,
|
||||
Workflow,
|
||||
} from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { randomBytes, randomUUID } from 'crypto';
|
||||
import { vol } from 'memfs';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
@@ -14,7 +15,7 @@ import { setTimeout } from 'timers/promises';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { shallowCloneRepositoryAsync } from '../git';
|
||||
import { prepareProjectSourcesAsync } from '../projectSources';
|
||||
import { prepareProjectSourcesAsync, unpackTarGzAsync } from '../projectSources';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('node-fetch');
|
||||
@@ -23,6 +24,27 @@ jest.mock('@expo/downloader');
|
||||
jest.mock('@urql/core');
|
||||
|
||||
describe('projectSources', () => {
|
||||
it('makes extracted project sources readable and writable by the worker', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await unpackTarGzAsync({
|
||||
source: '/workingdir/project.tar.gz',
|
||||
destination: '/workingdir/build',
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'tar',
|
||||
['-C', '/workingdir/build', '--strip-components', '1', '-zxf', '/workingdir/project.tar.gz'],
|
||||
{ logger }
|
||||
);
|
||||
expect(spawn).toHaveBeenNthCalledWith(2, 'chmod', ['-R', 'u+rwX', '/workingdir/build'], {
|
||||
logger,
|
||||
});
|
||||
expect(logger.info).toHaveBeenCalledWith('Normalizing project source permissions');
|
||||
});
|
||||
|
||||
it('should use the refreshed repository URL', async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
const buildId = randomUUID();
|
||||
|
||||
@@ -32,7 +32,11 @@ export async function installDependenciesAsync({
|
||||
break;
|
||||
}
|
||||
case PackageManager.PNPM: {
|
||||
args = ['install', useFrozenLockfile ? '--frozen-lockfile' : '--no-frozen-lockfile'];
|
||||
args = [
|
||||
'install',
|
||||
...(env['EAS_PNPM_FILTER_WORKSPACE'] ? ['--filter', env['EAS_PNPM_FILTER_WORKSPACE']] : []),
|
||||
useFrozenLockfile ? '--frozen-lockfile' : '--no-frozen-lockfile',
|
||||
];
|
||||
break;
|
||||
}
|
||||
case PackageManager.YARN: {
|
||||
@@ -58,7 +62,11 @@ export async function installDependenciesAsync({
|
||||
break;
|
||||
}
|
||||
case PackageManager.BUN:
|
||||
args = ['install', ...(useFrozenLockfile ? ['--frozen-lockfile'] : [])];
|
||||
args = [
|
||||
'install',
|
||||
...(env['EAS_BUN_FILTER_WORKSPACE'] ? ['--filter', env['EAS_BUN_FILTER_WORKSPACE']] : []),
|
||||
...(useFrozenLockfile ? ['--frozen-lockfile'] : []),
|
||||
];
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported package manager: ${packageManager}`);
|
||||
|
||||
@@ -110,7 +110,7 @@ async function prepareProjectSourcesLocallyAsync<TJob extends Job>(
|
||||
});
|
||||
}
|
||||
|
||||
async function unpackTarGzAsync({
|
||||
export async function unpackTarGzAsync({
|
||||
logger,
|
||||
source,
|
||||
destination,
|
||||
@@ -122,6 +122,8 @@ async function unpackTarGzAsync({
|
||||
await spawn('tar', ['-C', destination, '--strip-components', '1', '-zxf', source], {
|
||||
logger,
|
||||
});
|
||||
logger.info('Normalizing project source permissions');
|
||||
await spawn('chmod', ['-R', 'u+rwX', destination], { logger });
|
||||
}
|
||||
|
||||
function uploadProjectMetadataAsFireAndForget(
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface BuildContextOptions {
|
||||
skipNativeBuild?: boolean;
|
||||
metadata?: Metadata;
|
||||
expoApiV2BaseUrl?: string;
|
||||
mcpServerUrl?: string;
|
||||
}
|
||||
|
||||
export class SkipNativeBuildError extends Error {}
|
||||
@@ -84,6 +85,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
) => void;
|
||||
public readonly skipNativeBuild?: boolean;
|
||||
public readonly expoApiV2BaseUrl?: string;
|
||||
public readonly mcpServerUrl?: string;
|
||||
public artifacts: Artifacts = {};
|
||||
|
||||
private readonly _isLocal: boolean;
|
||||
@@ -112,6 +114,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
this._metadata = options.metadata;
|
||||
this.skipNativeBuild = options.skipNativeBuild;
|
||||
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
|
||||
this.mcpServerUrl = options.mcpServerUrl;
|
||||
|
||||
const environmentSecrets = this.getEnvironmentSecrets(job);
|
||||
this._env = {
|
||||
|
||||
@@ -63,6 +63,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
public readonly logger: bunyan;
|
||||
public readonly graphqlClient: Client;
|
||||
public readonly runtimeApi: BuilderRuntimeApi;
|
||||
public readonly mcpServerUrl?: string;
|
||||
public job: TJob;
|
||||
public metadata?: Metadata;
|
||||
|
||||
@@ -84,6 +85,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
this.runtimeApi = {
|
||||
uploadArtifact: (...args) => buildCtx['uploadArtifact'](...args),
|
||||
};
|
||||
this.mcpServerUrl = buildCtx.mcpServerUrl;
|
||||
this.expoApiV2BaseUrl = buildCtx.expoApiV2BaseUrl;
|
||||
this.startTime = new Date();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import RemoteLoggerStream from './logging/RemoteLoggerStream';
|
||||
export { Builders, HttpLogStream, RemoteLoggerStream };
|
||||
export { uploadWithSignedUrl } from './storage/uploadWithSignedUrl';
|
||||
export type { SignedUrl, UploadWithSignedUrlParams } from './storage/uploadWithSignedUrl';
|
||||
export { downloadCacheAsync } from './steps/functions/restoreCache';
|
||||
export { uploadCacheAsync } from './steps/functions/saveCache';
|
||||
|
||||
export {
|
||||
ArtifactToUpload,
|
||||
|
||||
@@ -51,7 +51,7 @@ import { createStartAppiumRemoteSessionBuildFunction } from './functions/startAp
|
||||
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
|
||||
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
|
||||
import { createStartIosSimulatorRecordingsBuildFunction } from './functions/startIosSimulatorRecordings';
|
||||
import { createStartServeSimRemoteSessionBuildFunction } from './functions/startServeSimRemoteSession';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/startWebPreviewRemoteSession';
|
||||
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
|
||||
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
|
||||
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
|
||||
@@ -106,7 +106,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createStartIosSimulatorRecordingsBuildFunction(),
|
||||
createFinishIosSimulatorRecordingsBuildFunction(),
|
||||
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
|
||||
createStartServeSimRemoteSessionBuildFunction(ctx),
|
||||
createStartWebPreviewRemoteSessionBuildFunction(ctx),
|
||||
createStartServeSimMetricsBuildFunction(),
|
||||
createCollectServeSimMetricsBuildFunction(ctx),
|
||||
createInstallMaestroBuildFunction(),
|
||||
|
||||
@@ -162,6 +162,28 @@ describe(createEasBuildBuildFunctionGroup, () => {
|
||||
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('./ios');
|
||||
});
|
||||
|
||||
it('restores and saves caches around an iOS simulator build', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
|
||||
const stepNames = steps.map(step => step.displayName);
|
||||
const restoreCacheIndex = stepNames.indexOf('Restore Cache');
|
||||
const installPodsIndex = stepNames.indexOf('Install Pods');
|
||||
const saveCacheIndex = stepNames.indexOf('Save Cache');
|
||||
|
||||
expect(restoreCacheIndex).toBeGreaterThan(-1);
|
||||
expect(restoreCacheIndex).toBeLessThan(installPodsIndex);
|
||||
expect(saveCacheIndex).toBeGreaterThan(installPodsIndex);
|
||||
|
||||
const restoreCacheStep = steps[restoreCacheIndex];
|
||||
expect(restoreCacheStep.inputs?.find(input => input.id === 'simulator')?.rawValue).toBe(true);
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (Android with credentials)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.ANDROID,
|
||||
|
||||
@@ -98,6 +98,8 @@ function createStepsForIosSimulatorBuild({
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
@@ -106,6 +108,16 @@ function createStepsForIosSimulatorBuild({
|
||||
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
|
||||
});
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
@@ -123,6 +135,16 @@ function createStepsForIosSimulatorBuild({
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
},
|
||||
});
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
@@ -138,6 +160,7 @@ function createStepsForIosSimulatorBuild({
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
restoreCache,
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
installPods,
|
||||
configureEASUpdate,
|
||||
@@ -159,6 +182,10 @@ function createStepsForIosSimulatorBuild({
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { spawnAsync } from '@expo/steps';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { Datadog } from '../../../datadog';
|
||||
import {
|
||||
compressCocoapodsCacheAsync,
|
||||
getCocoapodsCachePaths,
|
||||
resolveCocoapodsCacheKeyAsync,
|
||||
restoreCocoapodsCacheArchiveAsync,
|
||||
} from '../../../utils/cocoapodsCache';
|
||||
import { downloadCacheAsync } from '../restoreCache';
|
||||
import { restoreCocoapodsCacheAsync } from '../restoreBuildCache';
|
||||
import { uploadCacheAsync } from '../saveCache';
|
||||
import { saveCocoapodsCacheAsync } from '../saveBuildCache';
|
||||
|
||||
jest.mock('@expo/steps', () => ({
|
||||
...jest.requireActual('@expo/steps'),
|
||||
spawnAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../restoreCache', () => ({
|
||||
decompressCacheAsync: jest.fn(),
|
||||
downloadCacheAsync: jest.fn(),
|
||||
downloadPublicCacheAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../saveCache', () => ({
|
||||
compressCacheAsync: jest.fn(),
|
||||
uploadCacheAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../utils/cocoapodsCache', () => ({
|
||||
compressCocoapodsCacheAsync: jest.fn(),
|
||||
getCocoapodsCachePaths: jest.fn(),
|
||||
resolveCocoapodsCacheKeyAsync: jest.fn(),
|
||||
restoreCocoapodsCacheArchiveAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const logger = createMockLogger();
|
||||
const env = {
|
||||
EAS_PODS_CACHE: '1',
|
||||
EAS_BUILD_ID: 'build-id',
|
||||
__API_SERVER_URL: 'https://api.expo.test',
|
||||
};
|
||||
const secrets = { robotAccessToken: 'robot-token' };
|
||||
|
||||
describe('CocoaPods build cache', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vol.fromJSON(
|
||||
{
|
||||
'/workingdir/ios/Pods/Manifest.lock': 'manifest',
|
||||
'/workingdir/ios/Podfile.lock': 'lockfile',
|
||||
'/tmp/cocoapods-cache.tar.gz': 'archive',
|
||||
},
|
||||
'/'
|
||||
);
|
||||
jest.mocked(getCocoapodsCachePaths).mockReturnValue({
|
||||
iosDirectory: '/workingdir/ios',
|
||||
podsDirectory: '/workingdir/ios/Pods',
|
||||
podfileLockPath: '/workingdir/ios/Podfile.lock',
|
||||
});
|
||||
jest.mocked(resolveCocoapodsCacheKeyAsync).mockResolvedValue({
|
||||
key: 'ios-pods-1.16.2-lock-hash',
|
||||
keyPrefix: 'ios-pods-1.16.2-',
|
||||
});
|
||||
jest.mocked(spawnAsync).mockResolvedValue({ stdout: '1.16.2\n' } as any);
|
||||
});
|
||||
|
||||
it('does nothing when the cache is disabled', async () => {
|
||||
await restoreCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory: '/workingdir',
|
||||
env: {},
|
||||
secrets,
|
||||
});
|
||||
await saveCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory: '/workingdir',
|
||||
env: {},
|
||||
secrets,
|
||||
});
|
||||
|
||||
expect(spawnAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores the newest matching CocoaPods cache', async () => {
|
||||
jest.mocked(downloadCacheAsync).mockResolvedValue({
|
||||
archivePath: '/tmp/cocoapods-cache.tar.gz',
|
||||
matchedKey: 'ios-pods-1.16.2-older-lock-hash',
|
||||
});
|
||||
const datadogLogSpy = jest.spyOn(Datadog, 'log');
|
||||
|
||||
await restoreCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory: '/workingdir',
|
||||
env,
|
||||
secrets,
|
||||
});
|
||||
|
||||
expect(downloadCacheAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'ios-pods-1.16.2-lock-hash',
|
||||
keyPrefixes: ['ios-pods-1.16.2-'],
|
||||
paths: ['/workingdir/ios/Pods'],
|
||||
})
|
||||
);
|
||||
expect(restoreCocoapodsCacheArchiveAsync).toHaveBeenCalledWith({
|
||||
archivePath: '/tmp/cocoapods-cache.tar.gz',
|
||||
workingDirectory: '/workingdir',
|
||||
});
|
||||
expect(datadogLogSpy).toHaveBeenCalledWith('CocoaPods cache restored (prefix_match)', {
|
||||
event: 'cocoapods_cache_restored',
|
||||
cache_hit_type: 'prefix_match',
|
||||
});
|
||||
});
|
||||
|
||||
it('compresses and saves the installed Pods directory', async () => {
|
||||
jest.mocked(compressCocoapodsCacheAsync).mockResolvedValue({
|
||||
archivePath: '/tmp/cocoapods-cache.tar.gz',
|
||||
});
|
||||
|
||||
await saveCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory: '/workingdir',
|
||||
env,
|
||||
secrets,
|
||||
});
|
||||
|
||||
expect(uploadCacheAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
archivePath: '/tmp/cocoapods-cache.tar.gz',
|
||||
key: 'ios-pods-1.16.2-lock-hash',
|
||||
paths: ['/workingdir/ios/Pods'],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,17 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import fs from 'fs';
|
||||
import fetch from 'node-fetch';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { compressCacheAsync } from '../saveCache';
|
||||
import { compressCacheAsync, uploadCacheAsync } from '../saveCache';
|
||||
import { decompressCacheAsync } from '../restoreCache';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return { info: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as bunyan;
|
||||
}
|
||||
@@ -81,3 +87,71 @@ describe('cache compress/decompress round trip', () => {
|
||||
expect(Math.floor(restoredStat.mtimeMs / 1000)).toBe(Math.floor(mtime.getTime() / 1000));
|
||||
});
|
||||
});
|
||||
|
||||
describe(uploadCacheAsync, () => {
|
||||
afterEach(() => {
|
||||
jest.mocked(fetch).mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['normal upload', undefined, false],
|
||||
['forced upload', true, true],
|
||||
] as const)('sends force for %s', async (_name, force, expectedForce) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'upload-cache-test-'));
|
||||
const archivePath = path.join(tempDir, 'cache.tar.gz');
|
||||
await fs.promises.writeFile(archivePath, 'cache archive');
|
||||
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
headers: {},
|
||||
url: 'https://storage.expo.test/cache',
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
.mockImplementationOnce(async (_url, request) => {
|
||||
const body = request!.body as NodeJS.ReadableStream & AsyncIterable<Buffer>;
|
||||
for await (const _chunk of body) {
|
||||
// Consume the archive stream as a real upload would.
|
||||
}
|
||||
return new Response(undefined, { status: 200 });
|
||||
});
|
||||
|
||||
try {
|
||||
await uploadCacheAsync({
|
||||
logger: createLoggerMock(),
|
||||
jobId: 'build-id',
|
||||
expoApiServerURL: 'https://api.expo.test',
|
||||
robotAccessToken: 'robot-token',
|
||||
paths: ['/cache/path'],
|
||||
key: 'cache-key',
|
||||
archivePath,
|
||||
size: 13,
|
||||
platform: Platform.ANDROID,
|
||||
force,
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
const [uploadSessionUrl, uploadSessionRequest] = jest.mocked(fetch).mock.calls[0];
|
||||
expect(uploadSessionUrl.toString()).toBe(
|
||||
'https://api.expo.test/v2/turtle-builds/caches/upload-sessions'
|
||||
);
|
||||
expect(JSON.parse(uploadSessionRequest!.body as string)).toMatchObject({
|
||||
buildId: 'build-id',
|
||||
force: expectedForce,
|
||||
key: 'cache-key',
|
||||
size: 13,
|
||||
});
|
||||
expect(jest.mocked(fetch).mock.calls[1][0].toString()).toBe(
|
||||
'https://storage.expo.test/cache'
|
||||
);
|
||||
} finally {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -83,6 +83,31 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
mockedMkdtemp.mockRestore();
|
||||
});
|
||||
|
||||
it('passes profile and LCD inputs to emulator creation', async () => {
|
||||
const systemImagePackage = 'system-images;android-35;default;x86_64';
|
||||
await createStep({
|
||||
device_identifier: 'medium_phone',
|
||||
system_image_package: systemImagePackage,
|
||||
lcd_width: 720,
|
||||
lcd_height: 1600,
|
||||
lcd_density: 262,
|
||||
}).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('sdkmanager', [systemImagePackage], expect.anything());
|
||||
expect(mockedAndroidUtils.createAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deviceIdentifier: 'medium_phone',
|
||||
systemImagePackage,
|
||||
lcdWidth: 720,
|
||||
lcdHeight: 1600,
|
||||
lcdDensity: 262,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.createAsync.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockedAndroidUtils.startAsync.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('retries base emulator startup with increasing readiness timeouts', async () => {
|
||||
mockedAndroidUtils.startAsync
|
||||
.mockResolvedValueOnce(createStartResult('emulator-1111'))
|
||||
|
||||
+39
-6
@@ -9,11 +9,13 @@ import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
|
||||
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
|
||||
import {
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
@@ -40,14 +42,14 @@ jest.mock('../../utils/argentEvents', () => ({
|
||||
startArgentEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
ensureFfmpegInstalledAsync: jest.fn(),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
getNgrokAuthtokenOrThrow: jest.fn(),
|
||||
getNgrokTunnelDomainOrThrow: jest.fn(),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
spawnDetached: jest.fn(),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
startServeSimWithTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
@@ -58,6 +60,7 @@ const EXPECTED_EVENT_LOG_PATH = path.join(ARGENT_STATE_DIR, 'tool-server-events.
|
||||
|
||||
const mockStopAsync = jest.fn();
|
||||
const mockTunnelStopAsync = jest.fn();
|
||||
const mockPreviewStopAsync = jest.fn();
|
||||
|
||||
describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
beforeEach(async () => {
|
||||
@@ -68,6 +71,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
jest.mocked(pollArgentArtifactsForUploadAsync).mockResolvedValue(undefined);
|
||||
mockStopAsync.mockResolvedValue(undefined);
|
||||
mockTunnelStopAsync.mockResolvedValue(undefined);
|
||||
mockPreviewStopAsync.mockResolvedValue(undefined);
|
||||
jest.mocked(startArgentEventCollectionAsync).mockResolvedValue({
|
||||
stopAsync: mockStopAsync,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
@@ -86,6 +90,10 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
url: 'https://argent-abc.tunnel.example.com',
|
||||
stopAsync: mockTunnelStopAsync,
|
||||
});
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewUrl: 'https://web-preview.tunnel.example.com',
|
||||
stopAsync: mockPreviewStopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
|
||||
@@ -120,18 +128,31 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
} as never
|
||||
);
|
||||
|
||||
// (1) The event log flag is enabled, before the tool-server is launched.
|
||||
// (1) FFmpeg setup starts in the background before Argent setup.
|
||||
expect(ensureFfmpegInstalledOnceAsync).toHaveBeenCalledWith({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: { EXISTING: 'value' },
|
||||
logger: expect.anything(),
|
||||
});
|
||||
expect(jest.mocked(ensureFfmpegInstalledOnceAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(spawn).mock.invocationCallOrder[0]
|
||||
);
|
||||
|
||||
// (2) The event log flag is enabled, before the tool-server is launched.
|
||||
const spawnCalls = jest.mocked(spawn).mock.calls;
|
||||
const enableEventLogIndex = spawnCalls.findIndex(
|
||||
([command, args]) =>
|
||||
command === 'bunx' && Array.isArray(args) && args.includes('tool-server-event-log')
|
||||
command === 'bun' &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === 'x' &&
|
||||
args.includes('tool-server-event-log')
|
||||
);
|
||||
expect(enableEventLogIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(jest.mocked(spawn).mock.invocationCallOrder[enableEventLogIndex]).toBeLessThan(
|
||||
jest.mocked(spawnDetached).mock.invocationCallOrder[0]
|
||||
);
|
||||
|
||||
// (2) The tool-server and the collector are pinned to the exact same event log path.
|
||||
// (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.EXISTING).toBe('value');
|
||||
@@ -140,7 +161,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
eventLogPath: EXPECTED_EVENT_LOG_PATH,
|
||||
});
|
||||
|
||||
// (3) Collection starts (before we wait for the session to stop) and (4) is torn down
|
||||
// (4) Collection starts (before we wait for the session to stop) and (5) is torn down
|
||||
// afterwards, exactly once.
|
||||
expect(startArgentEventCollectionAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockStopAsync).toHaveBeenCalledTimes(1);
|
||||
@@ -151,5 +172,17 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
|
||||
);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
webPreviewUrl: 'https://web-preview.tunnel.example.com',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -74,7 +74,7 @@ describe(warnIfArgentPackageVersionCannotBeVerified, () => {
|
||||
).not.toThrow();
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Continuing and letting bunx resolve it.')
|
||||
expect.stringContaining('Continuing and letting bun x resolve it.')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, type BuildStepContext, type BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
|
||||
|
||||
jest.mock('../../utils/remoteDeviceRunSession');
|
||||
|
||||
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> {
|
||||
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: '1.2.3' },
|
||||
max_duration_seconds: { value: 120 },
|
||||
},
|
||||
outputs: {},
|
||||
env,
|
||||
} as never
|
||||
);
|
||||
}
|
||||
|
||||
describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id');
|
||||
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewUrl: 'https://web-preview.example.test',
|
||||
stopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
stopAsync.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[BuildRuntimePlatform.DARWIN, true],
|
||||
[BuildRuntimePlatform.LINUX, false],
|
||||
])('starts the web preview for %s', async (runtimePlatform, selectsXcode) => {
|
||||
await runAsync(runtimePlatform);
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(selectsXcode ? 1 : 0);
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: 'tunnel.example.com',
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 60_000,
|
||||
packageVersion: '1.2.3',
|
||||
});
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
remoteConfig: { previewUrl: 'https://web-preview.example.test' },
|
||||
logger,
|
||||
});
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
logger,
|
||||
maxDurationSeconds: 120,
|
||||
signal: undefined,
|
||||
});
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
getCcacheKeyPrefix,
|
||||
getCcachePath,
|
||||
} from '../../utils/cacheKey';
|
||||
import {
|
||||
getCocoapodsCachePaths,
|
||||
resolveCocoapodsCacheKeyAsync,
|
||||
restoreCocoapodsCacheArchiveAsync,
|
||||
} from '../../utils/cocoapodsCache';
|
||||
import { Datadog } from '../../datadog';
|
||||
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
import { TurtleFetchError, turtleFetch } from '../../utils/turtleFetch';
|
||||
@@ -79,6 +84,13 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
} else {
|
||||
await restoreCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -206,6 +218,67 @@ export async function restoreCcacheAsync({
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
if (env.EAS_PODS_CACHE !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await spawnAsync('pod', ['--version'], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const { key, keyPrefix } = await resolveCocoapodsCacheKeyAsync(workingDirectory, stdout);
|
||||
logger.info(`Restoring CocoaPods cache key: ${key}`);
|
||||
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
);
|
||||
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
const { podsDirectory } = getCocoapodsCachePaths(workingDirectory);
|
||||
|
||||
const { archivePath, matchedKey } = await downloadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
paths: [podsDirectory],
|
||||
key,
|
||||
keyPrefixes: [keyPrefix],
|
||||
platform: Platform.IOS,
|
||||
});
|
||||
|
||||
await restoreCocoapodsCacheArchiveAsync({ archivePath, workingDirectory });
|
||||
|
||||
const hitType = matchedKey === key ? 'direct_hit' : 'prefix_match';
|
||||
logger.info(
|
||||
`CocoaPods cache restored to ${podsDirectory} (${hitType === 'direct_hit' ? 'direct hit' : 'prefix match'})`
|
||||
);
|
||||
Datadog.log(`CocoaPods cache restored (${hitType})`, {
|
||||
event: 'cocoapods_cache_restored',
|
||||
cache_hit_type: hitType,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof TurtleFetchError && err.response?.status === 404) {
|
||||
logger.info('No CocoaPods cache found for this key');
|
||||
} else {
|
||||
logger.warn('Failed to restore CocoaPods cache: ', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
generateDefaultBuildCacheKeyAsync,
|
||||
getCcachePath,
|
||||
} from '../../utils/cacheKey';
|
||||
import {
|
||||
compressCocoapodsCacheAsync,
|
||||
getCocoapodsCachePaths,
|
||||
resolveCocoapodsCacheKeyAsync,
|
||||
} from '../../utils/cocoapodsCache';
|
||||
import { generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
|
||||
export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction {
|
||||
@@ -78,11 +83,77 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
} else {
|
||||
await saveCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
if (env.EAS_PODS_CACHE !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { podsDirectory, podfileLockPath } = getCocoapodsCachePaths(workingDirectory);
|
||||
try {
|
||||
await Promise.all([fs.promises.access(podsDirectory), fs.promises.access(podfileLockPath)]);
|
||||
} catch {
|
||||
logger.warn('No CocoaPods installation found, skipping cache save');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await spawnAsync('pod', ['--version'], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const { key } = await resolveCocoapodsCacheKeyAsync(workingDirectory, stdout);
|
||||
logger.info(`Saving CocoaPods cache key: ${key}`);
|
||||
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
);
|
||||
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
|
||||
logger.info('Compressing CocoaPods cache...');
|
||||
const { archivePath } = await compressCocoapodsCacheAsync({ workingDirectory });
|
||||
const { size } = await fs.promises.stat(archivePath);
|
||||
logger.info(`CocoaPods cache archive size: ${formatBytes(size)}`);
|
||||
|
||||
await uploadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
archivePath,
|
||||
key,
|
||||
paths: [podsDirectory],
|
||||
size,
|
||||
platform: Platform.IOS,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to save CocoaPods cache');
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCcacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
|
||||
@@ -103,6 +103,7 @@ export async function uploadCacheAsync({
|
||||
archivePath,
|
||||
size,
|
||||
platform,
|
||||
force = false,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
jobId: string;
|
||||
@@ -113,6 +114,7 @@ export async function uploadCacheAsync({
|
||||
archivePath: string;
|
||||
size: number;
|
||||
platform: Platform | undefined;
|
||||
force?: boolean;
|
||||
}): Promise<void> {
|
||||
const routerURL = platform
|
||||
? 'v2/turtle-builds/caches/upload-sessions'
|
||||
@@ -127,12 +129,14 @@ export async function uploadCacheAsync({
|
||||
key,
|
||||
version: getCacheVersion(paths),
|
||||
size,
|
||||
force,
|
||||
})
|
||||
: JSON.stringify({
|
||||
jobRunId: jobId,
|
||||
key,
|
||||
version: getCacheVersion(paths),
|
||||
size,
|
||||
force,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
|
||||
@@ -23,8 +23,8 @@ import {
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
@@ -107,22 +107,19 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
const agentDeviceRemoteSessionUrl = agentDeviceTunnel.url;
|
||||
logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`);
|
||||
|
||||
let serveSim: Awaited<ReturnType<typeof startServeSimWithTunnelAsync>> | undefined;
|
||||
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
|
||||
let eventCollection:
|
||||
| Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>>
|
||||
| undefined;
|
||||
try {
|
||||
// serve-sim is iOS-only — only launch it (and report a webPreviewUrl)
|
||||
// on Darwin. Android sessions go without a preview URL.
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
serveSim = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
logger.info(`Web preview URL: ${serveSim.previewUrl}`);
|
||||
}
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
@@ -130,7 +127,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
remoteConfig: {
|
||||
agentDeviceRemoteSessionUrl,
|
||||
agentDeviceRemoteSessionToken: daemonToken,
|
||||
...(serveSim ? { webPreviewUrl: serveSim.previewUrl } : {}),
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -163,8 +160,8 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
if (serveSim) {
|
||||
await serveSim.stopAsync();
|
||||
if (webPreview) {
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
await agentDeviceTunnel.stopAsync();
|
||||
if (eventCollection) {
|
||||
|
||||
@@ -46,6 +46,21 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
defaultValue: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'lcd_width',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'lcd_height',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'lcd_density',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'count',
|
||||
required: false,
|
||||
@@ -86,6 +101,9 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
const systemImagePackage = `${inputs.system_image_package.value}`;
|
||||
// We can cast because allowedValueTypeName validated this is a string.
|
||||
const deviceIdentifier = inputs.device_identifier.value as AndroidDeviceName | undefined;
|
||||
const lcdWidth = inputs.lcd_width.value as number | undefined;
|
||||
const lcdHeight = inputs.lcd_height.value as number | undefined;
|
||||
const lcdDensity = inputs.lcd_density.value as number | undefined;
|
||||
const shouldAdjustAnimationScale =
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== 'false' &&
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== '0';
|
||||
@@ -129,6 +147,9 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
deviceName,
|
||||
systemImagePackage,
|
||||
deviceIdentifier: deviceIdentifier ?? null,
|
||||
lcdWidth: lcdWidth ?? null,
|
||||
lcdHeight: lcdHeight ?? null,
|
||||
lcdDensity: lcdDensity ?? null,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -26,8 +26,8 @@ import {
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
@@ -118,7 +118,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
logger,
|
||||
});
|
||||
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let serveSim: Awaited<ReturnType<typeof startServeSimWithTunnelAsync>> | undefined;
|
||||
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
|
||||
try {
|
||||
appiumTunnel = await startNgrokTunnelAsync({
|
||||
port: APPIUM_PORT,
|
||||
@@ -128,18 +128,15 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
logger,
|
||||
});
|
||||
|
||||
switch (runtimePlatform) {
|
||||
case BuildRuntimePlatform.DARWIN:
|
||||
serveSim = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
break;
|
||||
case BuildRuntimePlatform.LINUX:
|
||||
break;
|
||||
}
|
||||
// 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.
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
@@ -151,7 +148,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
'appium:automationName': device.automationName,
|
||||
'appium:udid': device.udid,
|
||||
},
|
||||
...(serveSim ? { webPreviewUrl: serveSim.previewUrl } : {}),
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -170,8 +167,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
if (serveSim) {
|
||||
await serveSim.stopAsync();
|
||||
if (webPreview) {
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
if (appiumTunnel) {
|
||||
await appiumTunnel.stopAsync();
|
||||
|
||||
@@ -20,14 +20,14 @@ import { sleepAsync } from '../../utils/retry';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
|
||||
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
|
||||
import {
|
||||
ensureFfmpegInstalledAsync,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
@@ -103,32 +103,39 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
|
||||
// Argent shells out to ffmpeg to record the screen. Installing it pulls a
|
||||
// large Homebrew dependency tree, so do not block session readiness on it:
|
||||
// the session comes up at its usual speed and only a recording started in
|
||||
// the first moments misses ffmpeg. Never rejects, so `void` is safe.
|
||||
void ensureFfmpegInstalledAsync({ runtimePlatform, env, logger });
|
||||
// Start the potentially slow installation while Argent is being prepared.
|
||||
// On Linux expo-device-hub calls this again and awaits the same in-flight
|
||||
// setup before launching. On macOS this remains non-blocking, so only a
|
||||
// recording started in the first moments may miss ffmpeg.
|
||||
// Never rejects, so `void` is safe.
|
||||
void ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
|
||||
logger.info('Enabling the Argent artifacts list endpoint flag.');
|
||||
await spawn(
|
||||
'bunx',
|
||||
[`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG],
|
||||
'bun',
|
||||
[
|
||||
'x',
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
|
||||
],
|
||||
{ env, logger }
|
||||
);
|
||||
|
||||
logger.info('Enabling the Argent tool-server event log flag.');
|
||||
await spawn(
|
||||
'bunx',
|
||||
[`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_EVENT_LOG_FLAG],
|
||||
'bun',
|
||||
['x', `${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_EVENT_LOG_FLAG],
|
||||
{ env, logger }
|
||||
);
|
||||
|
||||
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bunx.`);
|
||||
// Keep Argent itself in foreground mode under the detached bunx process. This preserves
|
||||
// the bunx -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
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 argentServer = spawnDetached({
|
||||
command: 'bunx',
|
||||
command: 'bun',
|
||||
args: [
|
||||
'x',
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
@@ -186,7 +193,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
});
|
||||
|
||||
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let serveSim: Awaited<ReturnType<typeof startServeSimWithTunnelAsync>> | undefined;
|
||||
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
|
||||
try {
|
||||
toolsTunnel = await startNgrokTunnelAsync({
|
||||
port: toolServerPort,
|
||||
@@ -199,18 +206,14 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
const publicToolsUrl = toolsTunnel.url;
|
||||
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
|
||||
|
||||
// serve-sim is iOS-only — Android sessions go without a preview URL.
|
||||
let webPreviewUrl: string | undefined;
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
serveSim = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
webPreviewUrl = serveSim.previewUrl;
|
||||
logger.info(`Web preview URL: ${webPreviewUrl}`);
|
||||
}
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
@@ -218,7 +221,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
remoteConfig: {
|
||||
toolsUrl: publicToolsUrl,
|
||||
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
|
||||
...(webPreviewUrl ? { webPreviewUrl } : {}),
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -238,8 +241,8 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
if (serveSim) {
|
||||
await serveSim.stopAsync();
|
||||
if (webPreview) {
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
if (toolsTunnel) {
|
||||
await toolsTunnel.stopAsync();
|
||||
@@ -297,7 +300,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 bunx resolve it.`
|
||||
`Continuing and letting bun x resolve it.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
+14
-11
@@ -10,22 +10,21 @@ import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
export function createStartServeSimRemoteSessionBuildFunction(
|
||||
export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_serve_sim_remote_session',
|
||||
name: 'Start serve-sim remote session',
|
||||
name: 'Start web preview remote session',
|
||||
__metricsId: 'eas/start_serve_sim_remote_session',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
@@ -38,30 +37,34 @@ export function createStartServeSimRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { inputs, env, signal }) => {
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
const packageVersion = inputs.package_version?.value as string | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
|
||||
logger.info('Starting serve-sim remote session.');
|
||||
logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
|
||||
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
|
||||
const serveSim = await startServeSimWithTunnelAsync(ctx, {
|
||||
const webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
packageVersion,
|
||||
});
|
||||
logger.info(`Preview URL: ${serveSim.previewUrl}`);
|
||||
logger.info(`Preview URL: ${webPreview.previewUrl}`);
|
||||
|
||||
try {
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: { previewUrl: serveSim.previewUrl },
|
||||
remoteConfig: { previewUrl: webPreview.previewUrl },
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -73,7 +76,7 @@ export function createStartServeSimRemoteSessionBuildFunction(
|
||||
signal,
|
||||
});
|
||||
} finally {
|
||||
await serveSim.stopAsync();
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -90,19 +90,24 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
const ascApiKeyJson = await fs.readJson(ascApiKeyPath);
|
||||
const ascApiKey = z
|
||||
.object({
|
||||
issuer_id: z.string(),
|
||||
issuer_id: z.string().nullish(),
|
||||
key_id: z.string(),
|
||||
key: z.string(),
|
||||
})
|
||||
.parse(ascApiKeyJson);
|
||||
|
||||
const privateKey = await jose.importPKCS8(ascApiKey.key, 'ES256');
|
||||
const token = await new jose.SignJWT({})
|
||||
const jwt = new jose.SignJWT({})
|
||||
.setProtectedHeader({ alg: 'ES256', kid: ascApiKey.key_id })
|
||||
.setIssuer(ascApiKey.issuer_id)
|
||||
.setAudience('appstoreconnect-v1')
|
||||
.setExpirationTime('20m')
|
||||
.sign(privateKey);
|
||||
.setExpirationTime('20m');
|
||||
if (ascApiKey.issuer_id) {
|
||||
jwt.setIssuer(ascApiKey.issuer_id);
|
||||
} else {
|
||||
// Nullish issuer_id means an individual API key
|
||||
jwt.setSubject('user');
|
||||
}
|
||||
const token = await jwt.sign(privateKey);
|
||||
|
||||
const client = new AscApiClient({ token, logger: stepsCtx.logger });
|
||||
|
||||
|
||||
@@ -13,14 +13,17 @@ import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { sleepAsync } from '../../../utils/retry';
|
||||
import {
|
||||
createExpoDeviceHubArgs,
|
||||
createServeSimArgs,
|
||||
ensureFfmpegInstalledAsync,
|
||||
fetchServeSimTurnArgsAsync,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
metricsCorsOriginToServeSimArgs,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startExpoDeviceHubWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
turnIceServersToServeSimArgs,
|
||||
turnIceServersToWebPreviewArgs,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForServeSimReadyAsync,
|
||||
waitForWebPreviewReadyAsync,
|
||||
} from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
@@ -132,7 +135,7 @@ describe(createServeSimArgs, () => {
|
||||
'--webrtc-codec',
|
||||
'vp8',
|
||||
'--max-dimension',
|
||||
'1280',
|
||||
'960',
|
||||
'--mjpeg-quality',
|
||||
'0.55',
|
||||
'--video-bitrate',
|
||||
@@ -173,6 +176,49 @@ describe(createServeSimArgs, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(createExpoDeviceHubArgs, () => {
|
||||
it('uses the latest Expo package and applies the EAS Android streaming policy', () => {
|
||||
expect(
|
||||
createExpoDeviceHubArgs({
|
||||
port: 4321,
|
||||
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
|
||||
})
|
||||
).toEqual([
|
||||
'--yes',
|
||||
'expo-device-hub@latest',
|
||||
'--port',
|
||||
'4321',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--platform',
|
||||
'android',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'h264',
|
||||
'--webrtc-ice-policy',
|
||||
'all',
|
||||
'--max-dimension',
|
||||
'960',
|
||||
'--video-bitrate',
|
||||
'6000000',
|
||||
'--video-fps',
|
||||
'60',
|
||||
'--hide-sidebar',
|
||||
'--hide-boot-device',
|
||||
'--turn-url',
|
||||
'turns:turn.example.test:443',
|
||||
]);
|
||||
});
|
||||
|
||||
it('pins the requested package version', () => {
|
||||
expect(createExpoDeviceHubArgs({ port: 4321, packageVersion: '0.7.0' }).slice(0, 2)).toEqual([
|
||||
'--yes',
|
||||
'[email protected]',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(metricsCorsOriginToServeSimArgs, () => {
|
||||
it('returns no args when the origin is unset or empty', () => {
|
||||
expect(metricsCorsOriginToServeSimArgs({} as BuildStepEnv)).toEqual([]);
|
||||
@@ -200,7 +246,7 @@ describe(metricsCorsOriginToServeSimArgs, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(waitForServeSimReadyAsync, () => {
|
||||
describe(waitForWebPreviewReadyAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
jest.mocked(sleepAsync).mockReset();
|
||||
@@ -215,8 +261,9 @@ describe(waitForServeSimReadyAsync, () => {
|
||||
json: async () => ({ status: 'ready', device: 'DEVICE-A' }),
|
||||
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
|
||||
await waitForServeSimReadyAsync({
|
||||
serveSim: { pid: undefined, getOutput: () => '' },
|
||||
await waitForWebPreviewReadyAsync({
|
||||
previewServer: { pid: undefined, getOutput: () => '' },
|
||||
serverName: 'expo-device-hub',
|
||||
port: 4321,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
@@ -261,14 +308,179 @@ describe(startNgrokTunnelAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(turnIceServersToServeSimArgs, () => {
|
||||
describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
const baseDomain = 'eas-simulator.ngrok.dev';
|
||||
const turnArgs = [
|
||||
'--turn-url',
|
||||
'turns:turn.example.test:443',
|
||||
'--turn-username',
|
||||
'turn-user',
|
||||
'--turn-credential',
|
||||
'turn-credential',
|
||||
];
|
||||
const metricsCorsArgs = ['--metrics-cors-origin', 'https://metrics.expo.test'];
|
||||
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;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(spawn).mockReset();
|
||||
jest.mocked(ngrok.forward).mockReset();
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
|
||||
const spawnPromise = Object.assign(Promise.resolve(undefined), {
|
||||
child: {
|
||||
pid: undefined,
|
||||
unref: jest.fn(),
|
||||
},
|
||||
});
|
||||
jest.mocked(spawn).mockReturnValue(spawnPromise as never);
|
||||
|
||||
jest.mocked(turtleFetch).mockImplementation(async url => {
|
||||
if (url.endsWith('/turn-ice-servers')) {
|
||||
return {
|
||||
json: async () => ({
|
||||
data: {
|
||||
iceServers: [
|
||||
{
|
||||
urls: ['turns:turn.example.test:443'],
|
||||
username: 'turn-user',
|
||||
credential: 'turn-credential',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
} as unknown as Awaited<ReturnType<typeof turtleFetch>>;
|
||||
}
|
||||
return {
|
||||
json: async () => ({ status: 'ready', device: 'device-id' }),
|
||||
} as unknown as Awaited<ReturnType<typeof turtleFetch>>;
|
||||
});
|
||||
});
|
||||
|
||||
it('installs ffmpeg before starting expo-device-hub for Linux', async () => {
|
||||
const packageVersion = '1.2.3';
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://android-preview.example.test',
|
||||
close,
|
||||
} as never);
|
||||
jest
|
||||
.mocked(spawn)
|
||||
.mockReturnValueOnce(
|
||||
Promise.reject(new Error('ffmpeg is missing')) as ReturnType<typeof spawn>
|
||||
)
|
||||
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>)
|
||||
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion,
|
||||
});
|
||||
|
||||
const spawnCalls = jest.mocked(spawn).mock.calls;
|
||||
expect(spawnCalls[0]).toEqual(['ffmpeg', ['-version'], { env }]);
|
||||
expect(spawnCalls[1]).toEqual([
|
||||
'sudo',
|
||||
['apt-get', 'update'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ DEBIAN_FRONTEND: 'noninteractive' }),
|
||||
}),
|
||||
]);
|
||||
expect(spawnCalls[2]).toEqual([
|
||||
'sudo',
|
||||
['apt-get', 'install', '-y', 'ffmpeg'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ DEBIAN_FRONTEND: 'noninteractive' }),
|
||||
}),
|
||||
]);
|
||||
const expoDeviceHubCallIndex = spawnCalls.findIndex(([command]) => command === 'npx');
|
||||
expect(expoDeviceHubCallIndex).toBeGreaterThan(2);
|
||||
expect(jest.mocked(spawn).mock.invocationCallOrder[2]).toBeLessThan(
|
||||
jest.mocked(spawn).mock.invocationCallOrder[expoDeviceHubCallIndex]
|
||||
);
|
||||
const [command, args] = spawnCalls[expoDeviceHubCallIndex];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
expect(port).toBeGreaterThan(0);
|
||||
expect(command).toBe('npx');
|
||||
expect(args).toEqual(createExpoDeviceHubArgs({ port, turnArgs, packageVersion }));
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.previewUrl).toBe('https://android-preview.example.test');
|
||||
|
||||
await preview.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('starts serve-sim for Darwin with its metrics policy and cleans up the preview resources', async () => {
|
||||
const packageVersion = '4.5.6';
|
||||
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,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion,
|
||||
});
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
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(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.previewUrl).toBe('https://ios-preview.example.test');
|
||||
|
||||
await preview.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not install ffmpeg before starting expo-device-hub outside Linux', async () => {
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://android-preview.example.test',
|
||||
close,
|
||||
} as never);
|
||||
|
||||
const preview = await startExpoDeviceHubWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(jest.mocked(spawn).mock.calls[0][0]).toBe('npx');
|
||||
expect(jest.mocked(spawn)).not.toHaveBeenCalledWith(
|
||||
'ffmpeg',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
|
||||
await preview.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe(turnIceServersToWebPreviewArgs, () => {
|
||||
it('returns no args for an empty ICE server list', () => {
|
||||
expect(turnIceServersToServeSimArgs([])).toEqual([]);
|
||||
expect(turnIceServersToWebPreviewArgs([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('builds --stun-url and --turn-url flags from Cloudflare ICE servers', () => {
|
||||
expect(
|
||||
turnIceServersToServeSimArgs([
|
||||
turnIceServersToWebPreviewArgs([
|
||||
{ urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.cloudflare.com:53'] },
|
||||
{
|
||||
urls: [
|
||||
@@ -293,7 +505,7 @@ describe(turnIceServersToServeSimArgs, () => {
|
||||
|
||||
it('emits only --turn-url flags when no credential-less (STUN) entry is present', () => {
|
||||
expect(
|
||||
turnIceServersToServeSimArgs([
|
||||
turnIceServersToWebPreviewArgs([
|
||||
{
|
||||
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
|
||||
username: 'u',
|
||||
@@ -311,19 +523,19 @@ describe(turnIceServersToServeSimArgs, () => {
|
||||
});
|
||||
|
||||
it('emits only --stun-url when there is no credentialed TURN entry', () => {
|
||||
expect(turnIceServersToServeSimArgs([{ urls: ['stun:stun.cloudflare.com:3478'] }])).toEqual([
|
||||
expect(turnIceServersToWebPreviewArgs([{ urls: ['stun:stun.cloudflare.com:3478'] }])).toEqual([
|
||||
'--stun-url',
|
||||
'stun:stun.cloudflare.com:3478',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(fetchServeSimTurnArgsAsync, () => {
|
||||
describe(fetchWebPreviewTurnArgsAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
it('requests TURN ICE servers from the device run session endpoint and returns serve-sim args', async () => {
|
||||
it('requests TURN ICE servers from the device run session endpoint and returns web preview args', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValue({
|
||||
json: async () => ({
|
||||
data: {
|
||||
@@ -339,7 +551,7 @@ describe(fetchServeSimTurnArgsAsync, () => {
|
||||
}),
|
||||
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
|
||||
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
|
||||
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), {
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
@@ -363,11 +575,11 @@ describe(fetchServeSimTurnArgsAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('returns [] and warns when the request fails so serve-sim falls back to P2P/STUN', async () => {
|
||||
it('returns [] and warns when the request fails so the web preview falls back to P2P/STUN', async () => {
|
||||
jest.mocked(turtleFetch).mockRejectedValue(new Error('boom'));
|
||||
const logger = createLoggerMock();
|
||||
|
||||
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
|
||||
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), {
|
||||
env: createEnvMock(),
|
||||
logger,
|
||||
});
|
||||
@@ -628,7 +840,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(ensureFfmpegInstalledAsync, () => {
|
||||
describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
const spawnMock = jest.mocked(spawn);
|
||||
|
||||
function spawnResolved(): ReturnType<typeof spawn> {
|
||||
@@ -647,7 +859,7 @@ describe(ensureFfmpegInstalledAsync, () => {
|
||||
it('does not install when ffmpeg is on PATH', async () => {
|
||||
spawnMock.mockReturnValueOnce(spawnResolved());
|
||||
|
||||
await ensureFfmpegInstalledAsync({
|
||||
await ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
@@ -660,7 +872,7 @@ describe(ensureFfmpegInstalledAsync, () => {
|
||||
it('installs ffmpeg with Homebrew on darwin when it is missing', async () => {
|
||||
spawnMock.mockReturnValueOnce(spawnRejected()).mockReturnValueOnce(spawnResolved());
|
||||
|
||||
await ensureFfmpegInstalledAsync({
|
||||
await ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
@@ -681,7 +893,7 @@ describe(ensureFfmpegInstalledAsync, () => {
|
||||
.mockReturnValueOnce(spawnResolved()) // apt-get update
|
||||
.mockReturnValueOnce(spawnResolved()); // apt-get install
|
||||
|
||||
await ensureFfmpegInstalledAsync({
|
||||
await ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
@@ -703,6 +915,33 @@ describe(ensureFfmpegInstalledAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('shares an in-flight ffmpeg setup between callers', async () => {
|
||||
let finishInstall: (() => void) | undefined;
|
||||
const pendingInstall = new Promise<void>(resolve => {
|
||||
finishInstall = resolve;
|
||||
});
|
||||
spawnMock
|
||||
.mockReturnValueOnce(spawnRejected()) // ffmpeg -version
|
||||
.mockReturnValueOnce(spawnResolved()) // apt-get update
|
||||
.mockReturnValueOnce(pendingInstall as unknown as ReturnType<typeof spawn>); // apt-get install
|
||||
const options = {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
};
|
||||
|
||||
const firstSetup = ensureFfmpegInstalledOnceAsync(options);
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
expect(spawnMock).toHaveBeenCalledTimes(3);
|
||||
|
||||
const secondSetup = ensureFfmpegInstalledOnceAsync(options);
|
||||
expect(spawnMock).toHaveBeenCalledTimes(3);
|
||||
|
||||
finishInstall?.();
|
||||
await Promise.all([firstSetup, secondSetup]);
|
||||
expect(spawnMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('still installs on linux when the apt index refresh fails', async () => {
|
||||
spawnMock
|
||||
.mockReturnValueOnce(spawnRejected()) // ffmpeg -version
|
||||
@@ -710,7 +949,7 @@ describe(ensureFfmpegInstalledAsync, () => {
|
||||
.mockReturnValueOnce(spawnResolved()); // apt-get install
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await ensureFfmpegInstalledAsync({
|
||||
await ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: createEnvMock(),
|
||||
logger,
|
||||
@@ -729,7 +968,7 @@ describe(ensureFfmpegInstalledAsync, () => {
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await expect(
|
||||
ensureFfmpegInstalledAsync({
|
||||
ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createEnvMock(),
|
||||
logger,
|
||||
@@ -750,7 +989,7 @@ describe(ensureFfmpegInstalledAsync, () => {
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await expect(
|
||||
ensureFfmpegInstalledAsync({
|
||||
ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createEnvMock(),
|
||||
logger,
|
||||
|
||||
@@ -370,6 +370,7 @@ export const APPIUM_COMMAND_SUMMARIES: Record<AppiumCommand, string> = {
|
||||
mobileSimctl: 'Ran a simctl command',
|
||||
mobileSiriCommand: 'Sent a Siri command',
|
||||
mobileStartActivity: 'Started an activity',
|
||||
mobileStartAutomationSession: 'Started an automation session',
|
||||
mobileStartLogsBroadcast: 'Started log streaming',
|
||||
mobileStartMediaProjectionRecording: 'Started media projection recording',
|
||||
mobileStartNetworkMonitor: 'Started network monitoring',
|
||||
@@ -379,6 +380,7 @@ export const APPIUM_COMMAND_SUMMARIES: Record<AppiumCommand, string> = {
|
||||
mobileStartService: 'Started a service',
|
||||
mobileStartSystemMonitor: 'Started system monitoring',
|
||||
mobileStartXctestScreenRecording: 'Started XCTest screen recording',
|
||||
mobileStopAutomationSession: 'Stopped an automation session',
|
||||
mobileStopLogsBroadcast: 'Stopped log streaming',
|
||||
mobileStopMediaProjectionRecording: 'Stopped media projection recording',
|
||||
mobileStopNetworkMonitor: 'Stopped network monitoring',
|
||||
@@ -400,6 +402,7 @@ export const APPIUM_COMMAND_SUMMARIES: Record<AppiumCommand, string> = {
|
||||
mobileUnscheduleAction: 'Canceled a scheduled action',
|
||||
mobileUpdateSafariPreferences: 'Updated Safari preferences',
|
||||
mobileViewPortRect: 'Read the viewport size',
|
||||
mobileViewportElementRect: 'Read an element position and size in the viewport',
|
||||
mobileViewportScreenshot: 'Took a viewport screenshot',
|
||||
mobileVoiceOverCurrentSpeech: 'Read current VoiceOver speech',
|
||||
mobileVoiceOverMove: 'Moved the VoiceOver cursor',
|
||||
|
||||
@@ -234,12 +234,14 @@ export type XCUITestAppiumCommand =
|
||||
| 'mobileShake'
|
||||
| 'mobileSimctl'
|
||||
| 'mobileSiriCommand'
|
||||
| 'mobileStartAutomationSession'
|
||||
| 'mobileStartLogsBroadcast'
|
||||
| 'mobileStartNetworkMonitor'
|
||||
| 'mobileStartPerfRecord'
|
||||
| 'mobileStartScreenRecording'
|
||||
| 'mobileStartSystemMonitor'
|
||||
| 'mobileStartXctestScreenRecording'
|
||||
| 'mobileStopAutomationSession'
|
||||
| 'mobileStopLogsBroadcast'
|
||||
| 'mobileStopNetworkMonitor'
|
||||
| 'mobileStopPerfRecord'
|
||||
@@ -374,6 +376,7 @@ export type UiAutomator2AppiumCommand =
|
||||
| 'mobileUnlock'
|
||||
| 'mobileUnscheduleAction'
|
||||
| 'mobileViewPortRect'
|
||||
| 'mobileViewportElementRect'
|
||||
| 'mobileViewportScreenshot'
|
||||
| 'networkSpeed'
|
||||
| 'openNotifications'
|
||||
|
||||
@@ -19,12 +19,16 @@ import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
|
||||
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_HOST = '127.0.0.1';
|
||||
const SERVE_SIM_MAX_DIMENSION = '1280';
|
||||
const SERVE_SIM_MAX_DIMENSION = '960';
|
||||
const SERVE_SIM_MJPEG_QUALITY = '0.55';
|
||||
const SERVE_SIM_VIDEO_BITRATE = '6000000';
|
||||
const SERVE_SIM_VIDEO_FPS = '60';
|
||||
const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
|
||||
const EXPO_DEVICE_HUB_MAX_DIMENSION = '960';
|
||||
const EXPO_DEVICE_HUB_VIDEO_BITRATE = '6000000';
|
||||
const EXPO_DEVICE_HUB_VIDEO_FPS = '60';
|
||||
|
||||
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
|
||||
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
|
||||
@@ -293,10 +297,9 @@ async function sleepUntilAbortedAsync(
|
||||
}
|
||||
}
|
||||
|
||||
// Argent encodes screen recordings by piping simulator frames into `ffmpeg`,
|
||||
// which it resolves from PATH. The tool-server inherits this step's env, so
|
||||
// spawning ffmpeg resolves against the same PATH argent will search: it rejects
|
||||
// with ENOENT when the binary is absent, and running it also proves it works.
|
||||
// Device-session tools resolve `ffmpeg` from PATH. Spawning it with the step's
|
||||
// environment rejects with ENOENT when the binary is absent, and running it also
|
||||
// proves that the installed binary works.
|
||||
async function isFfmpegAvailableAsync(env: BuildStepEnv): Promise<boolean> {
|
||||
return (await asyncResult(spawn('ffmpeg', ['-version'], { env }))).ok;
|
||||
}
|
||||
@@ -329,14 +332,15 @@ async function installFfmpegWithAptAsync({
|
||||
await spawn('sudo', ['apt-get', 'install', '-y', 'ffmpeg'], { env: aptEnv, logger });
|
||||
}
|
||||
|
||||
let ffmpegSetupPromise: Promise<void> | undefined;
|
||||
|
||||
/**
|
||||
* Install ffmpeg when the runtime does not already provide it, so argent's
|
||||
* `screen-recording-start` tool can encode a video. The worker images do not
|
||||
* ship ffmpeg yet, so without this the tool fails with "`ffmpeg` was not found
|
||||
* on PATH" — on macOS (iOS simulators) and Linux (Android emulators) alike.
|
||||
* Install ffmpeg when the runtime does not already provide it. Device-session
|
||||
* tools use it for video encoding on macOS (iOS simulators) and Linux (Android
|
||||
* emulators) alike, but the worker images do not ship it yet.
|
||||
*
|
||||
* Best-effort by design: screen recording is one optional argent tool, so a
|
||||
* failure here is logged and the session continues without it.
|
||||
* Best-effort by design: a failure here is logged and the session continues
|
||||
* without FFmpeg-dependent features.
|
||||
*
|
||||
* The whole body is wrapped because the caller runs this in the background with
|
||||
* `void`. There is no unhandledRejection handler in the worker, so a rejection
|
||||
@@ -344,7 +348,7 @@ async function installFfmpegWithAptAsync({
|
||||
* `spawn` is not an async function and can throw synchronously, which
|
||||
* `asyncResult` cannot catch — it only wraps an already-created promise.
|
||||
*/
|
||||
export async function ensureFfmpegInstalledAsync({
|
||||
async function ensureFfmpegInstalledAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
@@ -363,7 +367,7 @@ export async function ensureFfmpegInstalledAsync({
|
||||
logger.info(
|
||||
`ffmpeg is not installed, installing it with ${
|
||||
isDarwin ? 'Homebrew' : 'apt'
|
||||
} for argent screen recording.`
|
||||
} for the device session.`
|
||||
);
|
||||
if (isDarwin) {
|
||||
await installFfmpegWithHomebrewAsync({ env, logger });
|
||||
@@ -373,16 +377,41 @@ export async function ensureFfmpegInstalledAsync({
|
||||
logger.info('Installed ffmpeg.');
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not install ffmpeg for argent screen recording', error, {
|
||||
Sentry.capture('Could not install ffmpeg for the device session', error, {
|
||||
level: 'warning',
|
||||
});
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not install ffmpeg. Argent screen recording will not work in this session.'
|
||||
'Could not install ffmpeg. FFmpeg-dependent features may not work in this session.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
if (ffmpegSetupPromise) {
|
||||
await ffmpegSetupPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
const setupPromise = ensureFfmpegInstalledAsync({ runtimePlatform, env, logger });
|
||||
ffmpegSetupPromise = setupPromise;
|
||||
try {
|
||||
await setupPromise;
|
||||
} finally {
|
||||
if (ffmpegSetupPromise === setupPromise) {
|
||||
ffmpegSetupPromise = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const TurnIceServersResponseSchema = z.object({
|
||||
data: z.object({
|
||||
iceServers: TurnIceServersSchema,
|
||||
@@ -390,11 +419,12 @@ const TurnIceServersResponseSchema = z.object({
|
||||
});
|
||||
|
||||
/**
|
||||
* Translate Cloudflare ICE servers into serve-sim CLI flags: `--stun-url` (the
|
||||
* Translate Cloudflare ICE servers into web preview CLI flags: `--stun-url` (the
|
||||
* credential-less entries) and `--turn-url`/`--turn-username`/`--turn-credential`
|
||||
* (the entry carrying the short-lived credentials).
|
||||
* (the entry carrying the short-lived credentials). serve-sim and expo-device-hub
|
||||
* intentionally expose the same ICE flag contract.
|
||||
*/
|
||||
export function turnIceServersToServeSimArgs(iceServers: TurnIceServers): string[] {
|
||||
export function turnIceServersToWebPreviewArgs(iceServers: TurnIceServers): string[] {
|
||||
const stunUrls = iceServers
|
||||
.filter(server => !server.username && !server.credential)
|
||||
.flatMap(server => server.urls);
|
||||
@@ -420,14 +450,14 @@ export function turnIceServersToServeSimArgs(iceServers: TurnIceServers): string
|
||||
/**
|
||||
* Fetch short-lived Cloudflare TURN ICE servers for this job run from www
|
||||
* (minted on demand, mirroring how the worker fetches project clone URLs) and
|
||||
* translate them into serve-sim CLI flags.
|
||||
* translate them into web preview CLI flags.
|
||||
*
|
||||
* Best-effort: on any failure we log and return [] so serve-sim falls back to
|
||||
* its built-in P2P/STUN behavior. The credential is passed to serve-sim as a
|
||||
* process arg and deliberately not logged (turtle-spawn never logs argv and the
|
||||
* worker is single-tenant).
|
||||
* Best-effort: on any failure we log and return [] so the preview server falls
|
||||
* back to its built-in P2P/STUN behavior. The credential is passed as a process
|
||||
* arg and deliberately not logged (turtle-spawn never logs argv and the worker
|
||||
* is single-tenant).
|
||||
*/
|
||||
export async function fetchServeSimTurnArgsAsync(
|
||||
export async function fetchWebPreviewTurnArgsAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
|
||||
): Promise<string[]> {
|
||||
@@ -456,9 +486,9 @@ export async function fetchServeSimTurnArgsAsync(
|
||||
);
|
||||
|
||||
const { data } = TurnIceServersResponseSchema.parse(await response.json());
|
||||
const args = turnIceServersToServeSimArgs(data.iceServers);
|
||||
const args = turnIceServersToWebPreviewArgs(data.iceServers);
|
||||
if (args.length > 0) {
|
||||
logger.info('Configured serve-sim with Cloudflare TURN ICE servers.');
|
||||
logger.info('Configured the web preview with Cloudflare TURN ICE servers.');
|
||||
}
|
||||
return args;
|
||||
} catch (err) {
|
||||
@@ -466,7 +496,7 @@ export async function fetchServeSimTurnArgsAsync(
|
||||
Sentry.capture('Could not fetch Cloudflare TURN ICE servers', error, { level: 'warning' });
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not fetch Cloudflare TURN ICE servers; serve-sim will fall back to P2P/STUN.'
|
||||
'Could not fetch Cloudflare TURN ICE servers; the web preview will fall back to P2P/STUN.'
|
||||
);
|
||||
return [];
|
||||
}
|
||||
@@ -600,6 +630,10 @@ function createServeSimPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
|
||||
function createExpoDeviceHubPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${EXPO_DEVICE_HUB_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
|
||||
export function createServeSimArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
@@ -617,7 +651,7 @@ export function createServeSimArgs({
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
SERVE_SIM_HOST,
|
||||
WEB_PREVIEW_HOST,
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
@@ -635,44 +669,84 @@ export function createServeSimArgs({
|
||||
];
|
||||
}
|
||||
|
||||
export function createExpoDeviceHubArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
packageVersion,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
packageVersion?: string;
|
||||
}): string[] {
|
||||
return [
|
||||
'--yes',
|
||||
createExpoDeviceHubPackageSpec(packageVersion),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
WEB_PREVIEW_HOST,
|
||||
'--platform',
|
||||
'android',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'h264',
|
||||
'--webrtc-ice-policy',
|
||||
'all',
|
||||
'--max-dimension',
|
||||
EXPO_DEVICE_HUB_MAX_DIMENSION,
|
||||
'--video-bitrate',
|
||||
EXPO_DEVICE_HUB_VIDEO_BITRATE,
|
||||
'--video-fps',
|
||||
EXPO_DEVICE_HUB_VIDEO_FPS,
|
||||
'--hide-sidebar',
|
||||
'--hide-boot-device',
|
||||
...turnArgs,
|
||||
];
|
||||
}
|
||||
|
||||
async function findAvailablePortAsync(): Promise<number> {
|
||||
const server = createServer();
|
||||
server.unref();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, SERVE_SIM_HOST, () => resolve());
|
||||
server.listen(0, WEB_PREVIEW_HOST, () => resolve());
|
||||
});
|
||||
const address = server.address();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close(err => (err ? reject(err) : resolve()));
|
||||
});
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new SystemError('Could not allocate a local port for serve-sim.');
|
||||
throw new SystemError('Could not allocate a local port for the web preview.');
|
||||
}
|
||||
return address.port;
|
||||
}
|
||||
|
||||
const ServeSimReadyResponseSchema = z.object({
|
||||
const WebPreviewReadyResponseSchema = z.object({
|
||||
status: z.literal('ready'),
|
||||
device: z.string(),
|
||||
});
|
||||
|
||||
export async function waitForServeSimReadyAsync({
|
||||
serveSim,
|
||||
export async function waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
}: {
|
||||
serveSim: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
|
||||
previewServer: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
|
||||
serverName: string;
|
||||
port: number;
|
||||
timeoutMs: number;
|
||||
}): Promise<void> {
|
||||
const readyUrl = `http://${SERVE_SIM_HOST}:${port}/readyz`;
|
||||
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
if (serveSim.pid !== undefined && !isProcessRunning(serveSim.pid)) {
|
||||
if (previewServer.pid !== undefined && !isProcessRunning(previewServer.pid)) {
|
||||
throw new SystemError(
|
||||
`serve-sim exited before becoming ready. Last output:\n${serveSim.getOutput() || '<empty>'}`
|
||||
`${serverName} exited before becoming ready. Last output:\n${
|
||||
previewServer.getOutput() || '<empty>'
|
||||
}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
@@ -680,7 +754,7 @@ export async function waitForServeSimReadyAsync({
|
||||
retries: 0,
|
||||
timeout: 2_000,
|
||||
});
|
||||
ServeSimReadyResponseSchema.parse(await response.json());
|
||||
WebPreviewReadyResponseSchema.parse(await response.json());
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
@@ -688,17 +762,75 @@ export async function waitForServeSimReadyAsync({
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Timed out waiting for serve-sim readiness at ${readyUrl}${
|
||||
`Timed out waiting for ${serverName} readiness at ${readyUrl}${
|
||||
lastError instanceof Error ? `: ${lastError.message}` : ''
|
||||
}. Last output:\n${serveSim.getOutput() || '<empty>'}`
|
||||
}. Last output:\n${previewServer.getOutput() || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
export type ServeSimPreviewHandle = {
|
||||
export type DeviceWebPreviewHandle = {
|
||||
previewUrl: string;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type ServeSimPreviewHandle = DeviceWebPreviewHandle;
|
||||
|
||||
async function startWebPreviewWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
serverName,
|
||||
packageSpec,
|
||||
createArgs,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
serverName: string;
|
||||
packageSpec: string;
|
||||
createArgs: (port: number, turnArgs: string[]) => string[];
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
const port = await findAvailablePortAsync();
|
||||
logger.info(`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port}.`);
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
|
||||
const previewServer = spawnDetached({
|
||||
command: 'npx',
|
||||
args: createArgs(port, turnArgs),
|
||||
env,
|
||||
});
|
||||
|
||||
try {
|
||||
logger.info(`Waiting for ${serverName} to become ready.`);
|
||||
await waitForWebPreviewReadyAsync({ previewServer, serverName, port, timeoutMs });
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix: 'web-preview',
|
||||
baseDomain,
|
||||
authtoken: getNgrokAuthtokenOrThrow(env),
|
||||
logger,
|
||||
});
|
||||
return {
|
||||
previewUrl: tunnel.url,
|
||||
stopAsync: async () => {
|
||||
const results = await Promise.allSettled([tunnel.stopAsync(), previewServer.stopAsync()]);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
logger.warn({ err: result.reason }, `Could not stop a ${serverName} preview resource.`);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await previewServer.stopAsync();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startServeSimWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
@@ -715,42 +847,70 @@ export async function startServeSimWithTunnelAsync(
|
||||
packageVersion?: string;
|
||||
}
|
||||
): Promise<ServeSimPreviewHandle> {
|
||||
const port = await findAvailablePortAsync();
|
||||
logger.info(
|
||||
`Launching ${createServeSimPackageSpec(packageVersion)} on ${SERVE_SIM_HOST}:${port}.`
|
||||
);
|
||||
const turnArgs = await fetchServeSimTurnArgsAsync(ctx, { env, logger });
|
||||
const metricsCorsArgs = metricsCorsOriginToServeSimArgs(env);
|
||||
const serveSim = spawnDetached({
|
||||
command: 'npx',
|
||||
args: createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }),
|
||||
return await startWebPreviewWithTunnelAsync(ctx, {
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
serverName: 'serve-sim',
|
||||
packageSpec: createServeSimPackageSpec(packageVersion),
|
||||
createArgs: (port, turnArgs) =>
|
||||
createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('Waiting for serve-sim to become ready.');
|
||||
await waitForServeSimReadyAsync({ serveSim, port, timeoutMs });
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix: 'web-preview',
|
||||
baseDomain,
|
||||
authtoken: getNgrokAuthtokenOrThrow(env),
|
||||
logger,
|
||||
});
|
||||
return {
|
||||
previewUrl: tunnel.url,
|
||||
stopAsync: async () => {
|
||||
const results = await Promise.allSettled([tunnel.stopAsync(), serveSim.stopAsync()]);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
logger.warn({ err: result.reason }, 'Could not stop a serve-sim preview resource.');
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await serveSim.stopAsync();
|
||||
throw error;
|
||||
export async function startExpoDeviceHubWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
runtimePlatform,
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
packageVersion,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
if (runtimePlatform === BuildRuntimePlatform.LINUX) {
|
||||
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
}
|
||||
return await startWebPreviewWithTunnelAsync(ctx, {
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
serverName: 'expo-device-hub',
|
||||
packageSpec: createExpoDeviceHubPackageSpec(packageVersion),
|
||||
createArgs: (port, turnArgs) => createExpoDeviceHubArgs({ port, turnArgs, packageVersion }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function startDeviceWebPreviewWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
runtimePlatform,
|
||||
...options
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
switch (runtimePlatform) {
|
||||
case BuildRuntimePlatform.DARWIN:
|
||||
return await startServeSimWithTunnelAsync(ctx, options);
|
||||
case BuildRuntimePlatform.LINUX:
|
||||
return await startExpoDeviceHubWithTunnelAsync(ctx, { ...options, runtimePlatform });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,12 +99,18 @@ export namespace AndroidEmulatorUtils {
|
||||
deviceName,
|
||||
systemImagePackage,
|
||||
deviceIdentifier,
|
||||
lcdWidth,
|
||||
lcdHeight,
|
||||
lcdDensity,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
deviceName: AndroidVirtualDeviceName;
|
||||
systemImagePackage: string;
|
||||
deviceIdentifier: AndroidDeviceName | null;
|
||||
lcdWidth: number | null;
|
||||
lcdHeight: number | null;
|
||||
lcdDensity: number | null;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
@@ -176,6 +182,13 @@ export namespace AndroidEmulatorUtils {
|
||||
}
|
||||
}
|
||||
|
||||
if (lcdWidth !== null && lcdHeight !== null && lcdDensity !== null) {
|
||||
logger.info(
|
||||
`Setting screen resolution to ${lcdWidth}x${lcdHeight} and density to ${lcdDensity} ppi.`
|
||||
);
|
||||
configIniFileContent = `${configIniFileContent}\nhw.lcd.height=${lcdHeight}\nhw.lcd.width=${lcdWidth}\nhw.lcd.density=${lcdDensity}\n`;
|
||||
}
|
||||
|
||||
const shouldAdjustHeapSize =
|
||||
env.ANDROID_EMULATOR_ADJUST_HEAP_SIZE !== 'false' &&
|
||||
env.ANDROID_EMULATOR_ADJUST_HEAP_SIZE !== '0';
|
||||
|
||||
@@ -71,6 +71,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
@@ -95,6 +98,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
@@ -158,6 +164,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
@@ -209,6 +218,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: envForAttempt,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
@@ -290,6 +302,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger,
|
||||
});
|
||||
@@ -361,6 +376,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
|
||||
@@ -8,7 +8,11 @@ import path from 'node:path';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { AndroidEmulatorUtils, AndroidVirtualDeviceName } from '../AndroidEmulatorUtils';
|
||||
import {
|
||||
AndroidDeviceName,
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
@@ -48,6 +52,120 @@ describe('AndroidEmulatorUtils', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.createAsync, () => {
|
||||
it('applies an explicit LCD configuration after screen scaling', async () => {
|
||||
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
|
||||
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
|
||||
await fs.promises.mkdir(avdDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
`${avdDirectory}/config.ini`,
|
||||
[
|
||||
'hw.ramSize=1536',
|
||||
'hw.lcd.height=2424',
|
||||
'hw.lcd.width=1080',
|
||||
'hw.lcd.density=420',
|
||||
'skin.path=pixel_9',
|
||||
'showDeviceFrame=yes',
|
||||
].join('\n')
|
||||
);
|
||||
const avdManagerPromise = Promise.resolve({ stdout: '', stderr: '' }) as any;
|
||||
const write = jest.fn();
|
||||
const end = jest.fn();
|
||||
avdManagerPromise.child = { stdin: { write, end } };
|
||||
mockedSpawn.mockReturnValue(avdManagerPromise);
|
||||
const logger = createMockLogger();
|
||||
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage: 'system-images;android-35;default;x86_64',
|
||||
deviceIdentifier: 'medium_phone' as AndroidDeviceName,
|
||||
lcdWidth: 720,
|
||||
lcdHeight: 1600,
|
||||
lcdDensity: 262,
|
||||
env: {
|
||||
HOME: '/home/expo',
|
||||
ANDROID_EMULATOR_ADJUST_SCREEN: '1',
|
||||
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'avdmanager',
|
||||
[
|
||||
'create',
|
||||
'avd',
|
||||
'--name',
|
||||
deviceName,
|
||||
'--package',
|
||||
'system-images;android-35;default;x86_64',
|
||||
'--force',
|
||||
'--device',
|
||||
'medium_phone',
|
||||
],
|
||||
{ env: expect.any(Object), stdio: 'pipe' }
|
||||
);
|
||||
expect(write).toHaveBeenCalledWith('no');
|
||||
expect(end).toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Setting scaled screen resolution: hw.lcd.height to 1270 and hw.lcd.width to 566.'
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Setting screen resolution to 720x1600 and density to 262 ppi.'
|
||||
);
|
||||
await expect(fs.promises.readFile(`${avdDirectory}/config.ini`, 'utf8')).resolves.toBe(
|
||||
[
|
||||
'hw.ramSize=1536',
|
||||
'hw.lcd.height=2424',
|
||||
'hw.lcd.width=1080',
|
||||
'hw.lcd.density=420',
|
||||
'skin.path=pixel_9',
|
||||
'showDeviceFrame=yes',
|
||||
'hw.ramSize=2048',
|
||||
'',
|
||||
'hw.lcd.density=220',
|
||||
'',
|
||||
'hw.lcd.height=1270',
|
||||
'hw.lcd.width=566',
|
||||
'',
|
||||
'hw.lcd.height=1600',
|
||||
'hw.lcd.width=720',
|
||||
'hw.lcd.density=262',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
it('does not apply an LCD configuration when it is omitted', async () => {
|
||||
const deviceName = 'generic-emulator' as AndroidVirtualDeviceName;
|
||||
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
|
||||
const initialConfig = ['skin.path=pixel_9', 'showDeviceFrame=yes'].join('\n');
|
||||
await fs.promises.mkdir(avdDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(`${avdDirectory}/config.ini`, initialConfig);
|
||||
const avdManagerPromise = Promise.resolve({ stdout: '', stderr: '' }) as any;
|
||||
avdManagerPromise.child = { stdin: { write: jest.fn(), end: jest.fn() } };
|
||||
mockedSpawn.mockReturnValue(avdManagerPromise);
|
||||
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: {
|
||||
HOME: '/home/expo',
|
||||
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
|
||||
},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
const config = await fs.promises.readFile(`${avdDirectory}/config.ini`, 'utf8');
|
||||
expect(config).toContain(initialConfig);
|
||||
expect(config).not.toContain('hw.lcd.');
|
||||
});
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.startAsync, () => {
|
||||
function mockSuccessfulStart(deviceName: AndroidVirtualDeviceName) {
|
||||
// Under `stdio: 'pipe'` these are net.Sockets, which is what startAsync
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
compressCocoapodsCacheAsync,
|
||||
getCocoapodsCachePaths,
|
||||
resolveCocoapodsCacheKeyAsync,
|
||||
restoreCocoapodsCacheArchiveAsync,
|
||||
} from '../cocoapodsCache';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
describe('CocoaPods cache utilities', () => {
|
||||
let workingDirectory: string;
|
||||
const cocoapodsVersion = '1.16.2';
|
||||
|
||||
beforeEach(async () => {
|
||||
workingDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'cocoapods-cache-test-'));
|
||||
await fs.promises.mkdir(path.join(workingDirectory, 'ios'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(workingDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('uses a CocoaPods version prefix when no Podfile.lock exists', async () => {
|
||||
await expect(
|
||||
resolveCocoapodsCacheKeyAsync(workingDirectory, cocoapodsVersion)
|
||||
).resolves.toEqual({
|
||||
key: 'ios-pods-1.16.2-',
|
||||
keyPrefix: 'ios-pods-1.16.2-',
|
||||
});
|
||||
});
|
||||
|
||||
it('includes the Podfile.lock hash when it exists', async () => {
|
||||
const { podfileLockPath } = getCocoapodsCachePaths(workingDirectory);
|
||||
await fs.promises.writeFile(podfileLockPath, 'PODS:\n - Expo (55.0.0)\n');
|
||||
|
||||
const firstResult = await resolveCocoapodsCacheKeyAsync(workingDirectory, cocoapodsVersion);
|
||||
expect(firstResult.key).toMatch(/^ios-pods-1\.16\.2-[a-f0-9]+$/);
|
||||
expect(firstResult.keyPrefix).toBe('ios-pods-1.16.2-');
|
||||
|
||||
await fs.promises.writeFile(podfileLockPath, 'PODS:\n - Expo (56.0.0)\n');
|
||||
const secondResult = await resolveCocoapodsCacheKeyAsync(workingDirectory, cocoapodsVersion);
|
||||
expect(secondResult.key).not.toBe(firstResult.key);
|
||||
});
|
||||
|
||||
it('preserves symlinks and executable permissions through an archive round trip', async () => {
|
||||
const { podsDirectory } = getCocoapodsCachePaths(workingDirectory);
|
||||
const frameworkVersionsDirectory = path.join(podsDirectory, 'Example.framework', 'Versions');
|
||||
const frameworkVersionDirectory = path.join(frameworkVersionsDirectory, 'A');
|
||||
await fs.promises.mkdir(frameworkVersionDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(path.join(frameworkVersionDirectory, 'Example'), 'binary');
|
||||
await fs.promises.symlink('A', path.join(frameworkVersionsDirectory, 'Current'));
|
||||
|
||||
const scriptPath = path.join(podsDirectory, 'Target Support Files', 'script.sh');
|
||||
await fs.promises.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||
await fs.promises.writeFile(scriptPath, '#!/bin/sh\n');
|
||||
await fs.promises.chmod(scriptPath, 0o755);
|
||||
|
||||
const { archivePath } = await compressCocoapodsCacheAsync({ workingDirectory });
|
||||
await fs.promises.rm(podsDirectory, { recursive: true, force: true });
|
||||
await restoreCocoapodsCacheArchiveAsync({ archivePath, workingDirectory });
|
||||
|
||||
const restoredSymlinkPath = path.join(frameworkVersionsDirectory, 'Current');
|
||||
expect((await fs.promises.lstat(restoredSymlinkPath)).isSymbolicLink()).toBe(true);
|
||||
await expect(fs.promises.readlink(restoredSymlinkPath)).resolves.toBe('A');
|
||||
|
||||
const restoredScriptStat = await fs.promises.stat(scriptPath);
|
||||
expect(restoredScriptStat.mode & 0o111).toBe(0o111);
|
||||
|
||||
await fs.promises.rm(path.dirname(archivePath), { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${STAGING_VERSION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,7 +91,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${STAGING_VERSION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,7 +101,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${STAGING_VERSION}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0', EXPO_STAGING: '1' },
|
||||
});
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -112,7 +112,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${PRODUCTION_VERSION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
@@ -133,7 +133,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(stagingResult).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0', EXPO_STAGING: '1' },
|
||||
});
|
||||
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
@@ -141,7 +141,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(productionResult).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.PRODUCTION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
@@ -173,9 +173,17 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: [`eas-cli@${PRODUCTION_VERSION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
});
|
||||
|
||||
it('disables the minimum release age for every npx invocation', async () => {
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
|
||||
const result = await resolveEasCommandPrefixAndEnvAsync();
|
||||
|
||||
expect(result.extraEnv).toEqual({ NPM_CONFIG_MIN_RELEASE_AGE: '0' });
|
||||
});
|
||||
});
|
||||
|
||||
describe(runEasCliCommand, () => {
|
||||
@@ -218,4 +226,25 @@ describe(runEasCliCommand, () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('overrides a user-provided minimum release age', async () => {
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
|
||||
await runEasCliCommand({
|
||||
args: ['deploy', '--json'],
|
||||
options: {
|
||||
cwd: '/tmp/project',
|
||||
env: {
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '365',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const spawnEnv = jest.mocked(spawn).mock.calls[0][2]?.env;
|
||||
expect(spawnEnv).toEqual({
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '0',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { BuildPhase, BuildPhaseResult, LogMarker } from '@expo/eas-build-job';
|
||||
|
||||
import { withLogPhaseAsync } from '../logPhase';
|
||||
|
||||
describe(withLogPhaseAsync.name, () => {
|
||||
const phaseLogger = { info: jest.fn() };
|
||||
const logger = { child: jest.fn(() => phaseLogger) };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns the callback result and ends the phase successfully', async () => {
|
||||
const result = await withLogPhaseAsync(logger as any, 'Phase name', async callbackLogger => {
|
||||
expect(callbackLogger).toBe(phaseLogger);
|
||||
return 'result';
|
||||
});
|
||||
|
||||
expect(result).toBe('result');
|
||||
expect(logger.child).toHaveBeenCalledWith({
|
||||
phase: BuildPhase.CUSTOM,
|
||||
buildStepId: expect.stringMatching(/^step-\d{3,}$/),
|
||||
buildStepDisplayName: 'Phase name',
|
||||
});
|
||||
expect(phaseLogger.info).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ marker: LogMarker.START_PHASE },
|
||||
'Start phase: Phase name'
|
||||
);
|
||||
expect(phaseLogger.info).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.SUCCESS },
|
||||
'End phase: Phase name'
|
||||
);
|
||||
});
|
||||
|
||||
it('ends the phase with failure and rethrows the error', async () => {
|
||||
const error = new Error('failed');
|
||||
|
||||
await expect(
|
||||
withLogPhaseAsync(logger as any, 'Phase name', async () => {
|
||||
throw error;
|
||||
})
|
||||
).rejects.toBe(error);
|
||||
|
||||
expect(phaseLogger.info).toHaveBeenLastCalledWith(
|
||||
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.FAIL },
|
||||
'End phase: Phase name'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { hashFiles } from '@expo/steps';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import * as tar from 'tar';
|
||||
|
||||
export const COCOAPODS_CACHE_KEY_PREFIX = 'ios-pods-';
|
||||
|
||||
const PODS_DIRECTORY_NAME = 'Pods';
|
||||
const PODFILE_LOCK_NAME = 'Podfile.lock';
|
||||
|
||||
export function getCocoapodsCachePaths(workingDirectory: string): {
|
||||
iosDirectory: string;
|
||||
podsDirectory: string;
|
||||
podfileLockPath: string;
|
||||
} {
|
||||
const iosDirectory = path.join(workingDirectory, 'ios');
|
||||
return {
|
||||
iosDirectory,
|
||||
podsDirectory: path.join(iosDirectory, PODS_DIRECTORY_NAME),
|
||||
podfileLockPath: path.join(iosDirectory, PODFILE_LOCK_NAME),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveCocoapodsCacheKeyAsync(
|
||||
workingDirectory: string,
|
||||
cocoapodsVersion: string
|
||||
): Promise<{ key: string; keyPrefix: string }> {
|
||||
const normalizedVersion = cocoapodsVersion.trim();
|
||||
if (!normalizedVersion) {
|
||||
throw new Error('Failed to determine CocoaPods version');
|
||||
}
|
||||
|
||||
const keyPrefix = `${COCOAPODS_CACHE_KEY_PREFIX}${normalizedVersion}-`;
|
||||
const { podfileLockPath } = getCocoapodsCachePaths(workingDirectory);
|
||||
|
||||
try {
|
||||
await fs.promises.access(podfileLockPath);
|
||||
} catch {
|
||||
return { key: keyPrefix, keyPrefix };
|
||||
}
|
||||
|
||||
return {
|
||||
key: `${keyPrefix}${hashFiles([podfileLockPath])}`,
|
||||
keyPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
export async function compressCocoapodsCacheAsync({
|
||||
workingDirectory,
|
||||
}: {
|
||||
workingDirectory: string;
|
||||
}): Promise<{ archivePath: string }> {
|
||||
const { iosDirectory, podsDirectory } = getCocoapodsCachePaths(workingDirectory);
|
||||
await fs.promises.access(podsDirectory);
|
||||
|
||||
const archiveDestinationDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'save-cocoapods-cache-')
|
||||
);
|
||||
const archivePath = path.join(archiveDestinationDirectory, 'cache.tar.gz');
|
||||
|
||||
await tar.create(
|
||||
{
|
||||
file: archivePath,
|
||||
cwd: iosDirectory,
|
||||
gzip: true,
|
||||
},
|
||||
[PODS_DIRECTORY_NAME]
|
||||
);
|
||||
|
||||
return { archivePath };
|
||||
}
|
||||
|
||||
export async function restoreCocoapodsCacheArchiveAsync({
|
||||
archivePath,
|
||||
workingDirectory,
|
||||
}: {
|
||||
archivePath: string;
|
||||
workingDirectory: string;
|
||||
}): Promise<void> {
|
||||
const { iosDirectory, podsDirectory } = getCocoapodsCachePaths(workingDirectory);
|
||||
await fs.promises.mkdir(iosDirectory, { recursive: true });
|
||||
|
||||
const temporaryRestoreDirectory = await fs.promises.mkdtemp(
|
||||
path.join(iosDirectory, '.eas-pods-cache-')
|
||||
);
|
||||
try {
|
||||
await tar.extract({
|
||||
file: archivePath,
|
||||
cwd: temporaryRestoreDirectory,
|
||||
filter: entryPath =>
|
||||
entryPath === PODS_DIRECTORY_NAME || entryPath.startsWith(`${PODS_DIRECTORY_NAME}/`),
|
||||
});
|
||||
|
||||
const restoredPodsDirectory = path.join(temporaryRestoreDirectory, PODS_DIRECTORY_NAME);
|
||||
const restoredPodsStat = await fs.promises.stat(restoredPodsDirectory);
|
||||
if (!restoredPodsStat.isDirectory()) {
|
||||
throw new Error('CocoaPods cache archive does not contain a Pods directory');
|
||||
}
|
||||
|
||||
await fs.promises.rm(podsDirectory, { recursive: true, force: true });
|
||||
await fs.promises.rename(restoredPodsDirectory, podsDirectory);
|
||||
} finally {
|
||||
await fs.promises.rm(temporaryRestoreDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import { Sentry } from '../sentry';
|
||||
|
||||
let cachedEasCliVersionsPromise: Promise<EasCliVersions> | undefined;
|
||||
|
||||
const NPX_ENV = { NPM_CONFIG_MIN_RELEASE_AGE: '0' };
|
||||
|
||||
/**
|
||||
* Resolves the `eas-cli` versions to install for staging/production. Prefers the
|
||||
* versions committed to `cli-versions.json` (fetched from expo/eas-cli); on any
|
||||
@@ -74,21 +76,21 @@ export async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${versions.STAGING}`],
|
||||
extraEnv: {},
|
||||
extraEnv: NPX_ENV,
|
||||
};
|
||||
} else if (process.env.ENVIRONMENT === 'staging') {
|
||||
const versions = await resolveEasCliVersionsAsync();
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${versions.STAGING}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
extraEnv: { ...NPX_ENV, EXPO_STAGING: '1' },
|
||||
};
|
||||
} else {
|
||||
const versions = await resolveEasCliVersionsAsync();
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${versions.PRODUCTION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: NPX_ENV,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { BuildPhase, BuildPhaseResult, LogMarker } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildStep } from '@expo/steps';
|
||||
|
||||
export async function withLogPhaseAsync<T>(
|
||||
logger: bunyan,
|
||||
name: string,
|
||||
fn: (logger: bunyan) => Promise<T>
|
||||
): Promise<T> {
|
||||
const phaseLogger = logger.child({
|
||||
phase: BuildPhase.CUSTOM,
|
||||
buildStepId: BuildStep.getNewId(),
|
||||
buildStepDisplayName: name,
|
||||
});
|
||||
phaseLogger.info({ marker: LogMarker.START_PHASE }, `Start phase: ${name}`);
|
||||
try {
|
||||
const result = await fn(phaseLogger);
|
||||
phaseLogger.info(
|
||||
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.SUCCESS },
|
||||
`End phase: ${name}`
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
phaseLogger.info(
|
||||
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.FAIL },
|
||||
`End phase: ${name}`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "22.4.0",
|
||||
"version": "24.0.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "22.4.0"
|
||||
"@expo/steps": "24.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "22.0.0",
|
||||
"version": "24.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "22.0.0",
|
||||
"version": "24.0.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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "22.0.0",
|
||||
"version": "24.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,9 +25,9 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "22.0.0",
|
||||
"@expo/logger": "24.0.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "22.0.0",
|
||||
"@expo/turtle-spawn": "24.0.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
+440
-232
File diff suppressed because it is too large.
Load diff
@@ -13,6 +13,7 @@ const config: CodegenConfig = {
|
||||
'src/graphql/generated.ts': {
|
||||
plugins: ['typescript', 'typescript-operations'],
|
||||
config: {
|
||||
addUnderscoreToArgsType: true,
|
||||
dedupeOperationSuffix: true,
|
||||
},
|
||||
hooks: {
|
||||
|
||||
Generated
+9068
-1696
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "22.6.0",
|
||||
"version": "24.0.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -44,15 +44,15 @@
|
||||
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/apple-utils": "2.1.22",
|
||||
"@expo/apple-utils": "2.2.0",
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "22.0.0",
|
||||
"@expo/eas-json": "22.0.0",
|
||||
"@expo/eas-build-job": "24.0.0",
|
||||
"@expo/eas-json": "24.0.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "22.0.0",
|
||||
"@expo/logger": "24.0.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": "22.4.0",
|
||||
"@expo/steps": "24.0.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -72,11 +72,12 @@
|
||||
"@sentry/node": "7.77.0",
|
||||
"@urql/core": "4.0.11",
|
||||
"@urql/exchange-retry": "1.2.0",
|
||||
"agent-cli-detector": "0.1.6",
|
||||
"agent-cli-detector": "0.1.7",
|
||||
"ajv": "8.11.0",
|
||||
"ajv-formats": "2.1.1",
|
||||
"better-opn": "3.0.2",
|
||||
"bplist-parser": "^0.3.0",
|
||||
"centrifuge": "5.7.1",
|
||||
"chalk": "4.1.2",
|
||||
"cli-progress": "3.12.0",
|
||||
"dateformat": "4.6.3",
|
||||
@@ -104,7 +105,7 @@
|
||||
"log-symbols": "4.1.0",
|
||||
"mime": "3.0.0",
|
||||
"minimatch": "5.1.2",
|
||||
"minizlib": "3.0.1",
|
||||
"minizlib": "3.1.0",
|
||||
"nanoid": "3.3.8",
|
||||
"node-fetch": "2.6.7",
|
||||
"node-forge": "1.4.0",
|
||||
@@ -130,8 +131,9 @@
|
||||
"tslib": "2.6.2",
|
||||
"turndown": "7.1.2",
|
||||
"untildify": "4.0.0",
|
||||
"uuid": "9.0.1",
|
||||
"uuid": "11.1.1",
|
||||
"wrap-ansi": "7.0.0",
|
||||
"ws": "8.21.1",
|
||||
"yaml": "2.6.0",
|
||||
"zod": "^4.1.3"
|
||||
},
|
||||
@@ -159,8 +161,8 @@
|
||||
"@types/tar": "6.1.10",
|
||||
"@types/tar-stream": "3.1.3",
|
||||
"@types/tough-cookie": "4.0.2",
|
||||
"@types/uuid": "9.0.7",
|
||||
"@types/wrap-ansi": "3.0.0",
|
||||
"@types/ws": "8.5.10",
|
||||
"axios": "1.18.1",
|
||||
"eslint-plugin-graphql": "4.0.0",
|
||||
"jest": "29.7.0",
|
||||
|
||||
@@ -8,10 +8,7 @@ import {
|
||||
mockProjectId,
|
||||
mockTestCommand,
|
||||
} from './utils';
|
||||
import {
|
||||
fetchRawLogsForBuildJobAsync,
|
||||
fetchRawLogsForCustomJobAsync,
|
||||
} from '../../commandUtils/workflow/fetchLogs';
|
||||
import { fetchRawLogsForJobAsync } from '../../commandUtils/workflow/logs/fetchLogs';
|
||||
import WorkflowLogView from '../../commands/workflow/logs';
|
||||
import { AppPlatform, BuildPriority, BuildStatus } from '../../graphql/generated';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
@@ -38,7 +35,7 @@ jest.mock('fs');
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../prompts');
|
||||
jest.mock('../../utils/json');
|
||||
jest.mock('../../commandUtils/workflow/fetchLogs');
|
||||
jest.mock('../../commandUtils/workflow/logs/fetchLogs');
|
||||
|
||||
describe(WorkflowLogView, () => {
|
||||
beforeEach(() => {
|
||||
@@ -49,6 +46,7 @@ describe(WorkflowLogView, () => {
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(fetchRawLogsForJobAsync).mockReset();
|
||||
});
|
||||
test('view logs, passing in no parameters, no runs found', async () => {
|
||||
const ctx = mockCommandContext(WorkflowLogView, {
|
||||
@@ -105,7 +103,7 @@ describe(WorkflowLogView, () => {
|
||||
return { selectedStep: 'step1' };
|
||||
}
|
||||
});
|
||||
jest.mocked(fetchRawLogsForCustomJobAsync).mockResolvedValue(null);
|
||||
jest.mocked(fetchRawLogsForJobAsync).mockResolvedValue(null);
|
||||
await cmd.run();
|
||||
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
@@ -139,7 +137,7 @@ describe(WorkflowLogView, () => {
|
||||
}
|
||||
});
|
||||
jest
|
||||
.mocked(fetchRawLogsForCustomJobAsync)
|
||||
.mocked(fetchRawLogsForJobAsync)
|
||||
.mockResolvedValue(
|
||||
'{"result":"success","marker":"end-step","buildStepDisplayName":"Install dependencies","buildStepId":"step-id-1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
|
||||
);
|
||||
@@ -209,7 +207,7 @@ describe(WorkflowLogView, () => {
|
||||
}
|
||||
});
|
||||
jest
|
||||
.mocked(fetchRawLogsForBuildJobAsync)
|
||||
.mocked(fetchRawLogsForJobAsync)
|
||||
.mockResolvedValue(
|
||||
'{"result":"success","marker":"end-step","buildStepDisplayName":"step1","buildStepId":"step-id-1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
|
||||
);
|
||||
@@ -255,7 +253,7 @@ describe(WorkflowLogView, () => {
|
||||
}
|
||||
});
|
||||
jest
|
||||
.mocked(fetchRawLogsForCustomJobAsync)
|
||||
.mocked(fetchRawLogsForJobAsync)
|
||||
.mockResolvedValue(
|
||||
[
|
||||
'{"result":"success","marker":"end-step","buildStepDisplayName":"Install","buildStepId":"step-id-1","time":"2022-01-01T00:00:00.000Z","msg":"first"}',
|
||||
|
||||
@@ -112,3 +112,13 @@ export function getEASUpdateURL(projectId: string, manifestHostOverride: string
|
||||
export function getExpoApiWorkflowSchemaURL(): string {
|
||||
return getExpoApiBaseUrl() + '/v2/workflows/schema';
|
||||
}
|
||||
|
||||
export function getEASLogsWebsocketUrl(): string {
|
||||
if (process.env.EXPO_STAGING) {
|
||||
return `wss://staging-logs.expo.dev/connection/websocket`;
|
||||
} else if (process.env.EXPO_LOCAL) {
|
||||
return `ws://localhost:4997/connection/websocket`;
|
||||
} else {
|
||||
return `wss://logs.expo.dev/connection/websocket`;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getProjectDashboardUrl, getProjectPageUrl } from '../url';
|
||||
import { getProjectDashboardUrl, getProjectPageUrl, getSubmissionUrl } from '../url';
|
||||
|
||||
describe(getProjectPageUrl, () => {
|
||||
it('builds the project dashboard URL when no page is provided', () => {
|
||||
@@ -33,3 +33,11 @@ describe(getProjectDashboardUrl, () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(getSubmissionUrl, () => {
|
||||
it('builds an encoded submission URL', () => {
|
||||
expect(getSubmissionUrl('my org', 'my app', 'submission/id')).toBe(
|
||||
'https://expo.dev/accounts/my%20org/projects/my%20app/submissions/submission%2Fid'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,19 @@ export function getUpdateGroupUrl(
|
||||
).toString();
|
||||
}
|
||||
|
||||
export function getSubmissionUrl(
|
||||
accountName: string,
|
||||
projectName: string,
|
||||
submissionId: string
|
||||
): string {
|
||||
return new URL(
|
||||
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
|
||||
projectName
|
||||
)}/submissions/${encodeURIComponent(submissionId)}`,
|
||||
getExpoWebsiteBaseUrl()
|
||||
).toString();
|
||||
}
|
||||
|
||||
export function getWorkflowRunUrl(
|
||||
accountName: string,
|
||||
projectName: string,
|
||||
|
||||
@@ -133,6 +133,7 @@ export const testChannelObject: UpdateChannelObject = {
|
||||
'{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.15,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}',
|
||||
updateBranches: [testUpdateBranch1, testUpdateBranch2],
|
||||
isPaused: false,
|
||||
isProtected: false,
|
||||
__typename: 'UpdateChannel',
|
||||
};
|
||||
|
||||
@@ -141,6 +142,7 @@ export const testBasicChannelInfo: UpdateChannelBasicInfoFragment = {
|
||||
name: 'production',
|
||||
branchMapping:
|
||||
'{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.1,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}',
|
||||
isProtected: false,
|
||||
__typename: 'UpdateChannel',
|
||||
};
|
||||
|
||||
@@ -149,5 +151,6 @@ export const testBasicChannelInfo2: UpdateChannelBasicInfoFragment = {
|
||||
name: 'staging',
|
||||
branchMapping:
|
||||
'{"data":[{"branchId":"d7d68e32-d9c9-4a8d-8d1b-21e53100a5e8","branchMappingLogic":{"operand":0.1,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"f9f708c2-0c91-4360-b2a4-0b61834aef4a","branchMappingLogic":"true"}],"version":0}',
|
||||
isProtected: false,
|
||||
__typename: 'UpdateChannel',
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { protectUpdateChannelAsync, unprotectUpdateChannelAsync } from '../protection';
|
||||
|
||||
function makeGraphqlClient(data: unknown): {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
mutation: jest.Mock;
|
||||
} {
|
||||
const mutation = jest.fn().mockReturnValue({
|
||||
toPromise: jest.fn().mockResolvedValue({ data }),
|
||||
});
|
||||
return { graphqlClient: { mutation } as unknown as ExpoGraphqlClient, mutation };
|
||||
}
|
||||
|
||||
describe(protectUpdateChannelAsync.name, () => {
|
||||
it('protects a channel by ID and returns the server state', async () => {
|
||||
const channel = {
|
||||
id: 'channel-id',
|
||||
name: 'production',
|
||||
branchMapping: '{"version":0,"data":[]}',
|
||||
isProtected: true,
|
||||
};
|
||||
const { graphqlClient, mutation } = makeGraphqlClient({
|
||||
updateChannel: { protectUpdateChannel: channel },
|
||||
});
|
||||
|
||||
await expect(
|
||||
protectUpdateChannelAsync(graphqlClient, { channelId: 'channel-id' })
|
||||
).resolves.toEqual(channel);
|
||||
|
||||
expect(mutation.mock.calls[0][0].loc.source.body).toContain('protectUpdateChannel');
|
||||
expect(mutation.mock.calls[0][1]).toEqual({ channelId: 'channel-id' });
|
||||
});
|
||||
|
||||
it('throws a clear error when the channel is not returned', async () => {
|
||||
const { graphqlClient } = makeGraphqlClient({
|
||||
updateChannel: { protectUpdateChannel: null },
|
||||
});
|
||||
|
||||
await expect(
|
||||
protectUpdateChannelAsync(graphqlClient, { channelId: 'missing-channel-id' })
|
||||
).rejects.toThrow('Could not find a channel with id: missing-channel-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe(unprotectUpdateChannelAsync.name, () => {
|
||||
it('unprotects a channel by ID and returns the server state', async () => {
|
||||
const channel = {
|
||||
id: 'channel-id',
|
||||
name: 'production',
|
||||
branchMapping: '{"version":0,"data":[]}',
|
||||
isProtected: false,
|
||||
};
|
||||
const { graphqlClient, mutation } = makeGraphqlClient({
|
||||
updateChannel: { unprotectUpdateChannel: channel },
|
||||
});
|
||||
|
||||
await expect(
|
||||
unprotectUpdateChannelAsync(graphqlClient, { channelId: 'channel-id' })
|
||||
).resolves.toEqual(channel);
|
||||
|
||||
expect(mutation.mock.calls[0][0].loc.source.body).toContain('unprotectUpdateChannel');
|
||||
expect(mutation.mock.calls[0][1]).toEqual({ channelId: 'channel-id' });
|
||||
});
|
||||
|
||||
it('throws a clear error when the channel is not returned', async () => {
|
||||
const { graphqlClient } = makeGraphqlClient({
|
||||
updateChannel: { unprotectUpdateChannel: null },
|
||||
});
|
||||
|
||||
await expect(
|
||||
unprotectUpdateChannelAsync(graphqlClient, { channelId: 'missing-channel-id' })
|
||||
).rejects.toThrow('Could not find a channel with id: missing-channel-id');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { renderChannelHeaderContent } from '../queries';
|
||||
import Log from '../../log';
|
||||
|
||||
jest.mock('../../log');
|
||||
|
||||
describe(renderChannelHeaderContent.name, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[true, 'Protected'],
|
||||
[false, 'Unprotected'],
|
||||
])('renders protection state when isProtected is %s', (isProtected, expected) => {
|
||||
renderChannelHeaderContent({
|
||||
channelName: 'production',
|
||||
channelId: 'channel-id',
|
||||
isPaused: false,
|
||||
isProtected,
|
||||
});
|
||||
|
||||
const output = jest.mocked(Log.log).mock.calls.flat().join('\n');
|
||||
expect(output).toContain('Protection');
|
||||
expect(output).toContain(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { print } from 'graphql';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../graphql/client';
|
||||
import {
|
||||
ProtectUpdateChannelMutation,
|
||||
ProtectUpdateChannelMutationVariables,
|
||||
UnprotectUpdateChannelMutation,
|
||||
UnprotectUpdateChannelMutationVariables,
|
||||
UpdateChannelBasicInfoFragment,
|
||||
} from '../graphql/generated';
|
||||
import { UpdateChannelBasicInfoFragmentNode } from '../graphql/types/UpdateChannelBasicInfo';
|
||||
|
||||
export async function protectUpdateChannelAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ channelId }: ProtectUpdateChannelMutationVariables
|
||||
): Promise<UpdateChannelBasicInfoFragment> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<ProtectUpdateChannelMutation, ProtectUpdateChannelMutationVariables>(
|
||||
gql`
|
||||
mutation ProtectUpdateChannel($channelId: ID!) {
|
||||
updateChannel {
|
||||
protectUpdateChannel(channelId: $channelId) {
|
||||
id
|
||||
...UpdateChannelBasicInfoFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(UpdateChannelBasicInfoFragmentNode)}
|
||||
`,
|
||||
{ channelId }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
const channel = data.updateChannel.protectUpdateChannel;
|
||||
if (!channel) {
|
||||
throw new Error(`Could not find a channel with id: ${channelId}`);
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
export async function unprotectUpdateChannelAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ channelId }: UnprotectUpdateChannelMutationVariables
|
||||
): Promise<UpdateChannelBasicInfoFragment> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<UnprotectUpdateChannelMutation, UnprotectUpdateChannelMutationVariables>(
|
||||
gql`
|
||||
mutation UnprotectUpdateChannel($channelId: ID!) {
|
||||
updateChannel {
|
||||
unprotectUpdateChannel(channelId: $channelId) {
|
||||
id
|
||||
...UpdateChannelBasicInfoFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(UpdateChannelBasicInfoFragmentNode)}
|
||||
`,
|
||||
{ channelId }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
const channel = data.updateChannel.unprotectUpdateChannel;
|
||||
if (!channel) {
|
||||
throw new Error(`Could not find a channel with id: ${channelId}`);
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
@@ -125,6 +125,7 @@ export async function listAndRenderBranchesAndUpdatesOnChannelAsync(
|
||||
channelName: channel.name,
|
||||
channelId: channel.id,
|
||||
isPaused: channel.isPaused,
|
||||
isProtected: channel.isProtected,
|
||||
});
|
||||
|
||||
if (paginatedQueryOptions.nonInteractive) {
|
||||
@@ -186,6 +187,7 @@ function renderPageOfChannels(
|
||||
channelName: channel.name,
|
||||
channelId: channel.id,
|
||||
isPaused: channel.isPaused,
|
||||
isProtected: channel.isProtected,
|
||||
});
|
||||
Log.addNewLineIfNone();
|
||||
logChannelDetails(channel);
|
||||
@@ -212,14 +214,16 @@ function renderPageOfBranchesOnChannel(
|
||||
}
|
||||
}
|
||||
|
||||
function renderChannelHeaderContent({
|
||||
export function renderChannelHeaderContent({
|
||||
channelName,
|
||||
channelId,
|
||||
isPaused,
|
||||
isProtected,
|
||||
}: {
|
||||
channelName: string;
|
||||
channelId: string;
|
||||
isPaused: boolean;
|
||||
isProtected: boolean;
|
||||
}): void {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(chalk.bold('Channel:'));
|
||||
@@ -228,6 +232,7 @@ function renderChannelHeaderContent({
|
||||
{ label: 'Name', value: channelName },
|
||||
{ label: 'ID', value: channelId },
|
||||
{ label: 'Status', value: isPaused ? 'Paused' : 'Active' },
|
||||
{ label: 'Protection', value: isProtected ? 'Protected' : 'Unprotected' },
|
||||
])
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import nock from 'nock';
|
||||
|
||||
import { makeUserMessage, streamChatResponseAsync } from '../chatClient';
|
||||
|
||||
const mockSpinners: { isSpinning: boolean; text: string }[] = [];
|
||||
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../ora', () => ({
|
||||
ora: () => {
|
||||
const spinner = {
|
||||
isSpinning: true,
|
||||
text: '',
|
||||
start: () => spinner,
|
||||
stop: () => {
|
||||
spinner.isSpinning = false;
|
||||
return spinner;
|
||||
},
|
||||
};
|
||||
mockSpinners.push(spinner);
|
||||
return spinner;
|
||||
},
|
||||
}));
|
||||
|
||||
const WEBSITE_ORIGIN = 'https://expo.dev';
|
||||
|
||||
function sseBody(frames: object[]): string {
|
||||
return [...frames.map(frame => `data: ${JSON.stringify(frame)}`), 'data: [DONE]', ''].join(
|
||||
'\n\n'
|
||||
);
|
||||
}
|
||||
|
||||
describe(streamChatResponseAsync, () => {
|
||||
let writeSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
nock.cleanAll();
|
||||
mockSpinners.length = 0;
|
||||
writeSpy = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
writeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('streams text deltas to stdout and returns the full text', async () => {
|
||||
nock(WEBSITE_ORIGIN)
|
||||
.post('/api/chat')
|
||||
.reply(
|
||||
200,
|
||||
sseBody([
|
||||
{ type: 'start' },
|
||||
{ type: 'tool-input-available', toolCallId: 't1', toolName: 'get_latest_builds' },
|
||||
{ type: 'text-start', id: '0' },
|
||||
{ type: 'text-delta', id: '0', delta: 'Your latest ' },
|
||||
{ type: 'text-delta', id: '0', delta: 'build passed.' },
|
||||
{ type: 'finish' },
|
||||
]),
|
||||
{ 'content-type': 'text/event-stream' }
|
||||
);
|
||||
|
||||
const result = await streamChatResponseAsync({
|
||||
messages: [makeUserMessage('how are my builds?')],
|
||||
accountName: 'my-account',
|
||||
sessionSecret: '{"id":"session-id","version":"1"}',
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const written = writeSpy.mock.calls.map(call => call[0]).join('');
|
||||
expect(written).toContain('Your latest ');
|
||||
expect(written).toContain('build passed.');
|
||||
expect(result.text).toBe('Your latest build passed.');
|
||||
});
|
||||
|
||||
it('neutralizes terminal control characters in streamed text deltas', async () => {
|
||||
const delta = 'Safe \u001b[2Jtext\u009b31m\u0007.';
|
||||
nock(WEBSITE_ORIGIN)
|
||||
.post('/api/chat')
|
||||
.reply(200, sseBody([{ type: 'text-delta', id: '0', delta }]), {
|
||||
'content-type': 'text/event-stream',
|
||||
});
|
||||
|
||||
const result = await streamChatResponseAsync({
|
||||
messages: [makeUserMessage('show the build log')],
|
||||
accountName: 'my-account',
|
||||
sessionSecret: '{"id":"abc","version":"1"}',
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const written = writeSpy.mock.calls.map(call => call[0]).join('');
|
||||
expect(written).toContain('Safe [2Jtext31m.');
|
||||
expect(written).not.toContain('\u001b[2J');
|
||||
expect(written).not.toContain('\u009b31m');
|
||||
expect(written).not.toContain('\u0007');
|
||||
expect(result.text).toBe(delta);
|
||||
});
|
||||
|
||||
it('keeps the spinner active until a full line is ready instead of clearing it on the first delta', async () => {
|
||||
nock(WEBSITE_ORIGIN)
|
||||
.post('/api/chat')
|
||||
.reply(
|
||||
200,
|
||||
sseBody([
|
||||
{ type: 'text-start', id: '0' },
|
||||
{ type: 'text-delta', id: '0', delta: 'Looking good' },
|
||||
{ type: 'tool-input-available', toolCallId: 't1', toolName: 'get_latest_builds' },
|
||||
{ type: 'text-delta', id: '0', delta: ' so far.' },
|
||||
{ type: 'finish' },
|
||||
]),
|
||||
{ 'content-type': 'text/event-stream' }
|
||||
);
|
||||
|
||||
const result = await streamChatResponseAsync({
|
||||
messages: [makeUserMessage('how are my builds?')],
|
||||
accountName: 'my-account',
|
||||
sessionSecret: '{"id":"abc","version":"1"}',
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const written = writeSpy.mock.calls.map(call => call[0]).join('');
|
||||
// No newline arrives until the end, so nothing is flushed mid-stream: the tool activity
|
||||
// is shown on the still-running spinner rather than being printed inline.
|
||||
expect(mockSpinners[0].text).toContain('Looking up builds');
|
||||
expect(written).not.toContain('Looking up builds');
|
||||
// The buffered text is rendered in one go once the response completes.
|
||||
expect(written).toContain('Looking good so far.');
|
||||
expect(result.text).toBe('Looking good so far.');
|
||||
});
|
||||
|
||||
it('captures tool calls with inputs and outputs without writing to stdout when not streaming', async () => {
|
||||
nock(WEBSITE_ORIGIN)
|
||||
.post('/api/chat')
|
||||
.reply(
|
||||
200,
|
||||
sseBody([
|
||||
{ type: 'start-step' },
|
||||
{
|
||||
type: 'tool-input-available',
|
||||
toolCallId: 't1',
|
||||
toolName: 'get_latest_builds',
|
||||
input: { limit: 1 },
|
||||
},
|
||||
{ type: 'tool-output-available', toolCallId: 't1', output: { builds: ['b1'] } },
|
||||
{ type: 'finish-step' },
|
||||
{ type: 'start-step' },
|
||||
{ type: 'text-delta', delta: 'Done.' },
|
||||
{ type: 'finish-step' },
|
||||
{ type: 'finish' },
|
||||
])
|
||||
);
|
||||
|
||||
const result = await streamChatResponseAsync({
|
||||
messages: [makeUserMessage('builds?')],
|
||||
accountName: 'my-account',
|
||||
sessionSecret: '{"id":"abc","version":"1"}',
|
||||
stream: false,
|
||||
});
|
||||
|
||||
expect(writeSpy).not.toHaveBeenCalled();
|
||||
expect(result.text).toBe('Done.');
|
||||
expect(result.toolCalls).toEqual([
|
||||
{
|
||||
toolName: 'get_latest_builds',
|
||||
input: { limit: 1 },
|
||||
output: { builds: ['b1'] },
|
||||
errorText: undefined,
|
||||
},
|
||||
]);
|
||||
expect(result.assistantMessage).toEqual(
|
||||
expect.objectContaining({
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{ type: 'step-start' },
|
||||
{
|
||||
type: 'tool-get_latest_builds',
|
||||
toolCallId: 't1',
|
||||
state: 'output-available',
|
||||
input: { limit: 1 },
|
||||
output: { builds: ['b1'] },
|
||||
},
|
||||
{ type: 'step-start' },
|
||||
{ type: 'text', text: 'Done.' },
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the session secret as a cookie and the account as a header', async () => {
|
||||
let capturedHeaders: Record<string, string | string[]> = {};
|
||||
let capturedBody: any;
|
||||
nock(WEBSITE_ORIGIN)
|
||||
.post('/api/chat')
|
||||
.reply(function (_uri, body) {
|
||||
capturedHeaders = this.req.headers;
|
||||
capturedBody = body;
|
||||
return [200, sseBody([{ type: 'text-delta', delta: 'hi' }])];
|
||||
});
|
||||
|
||||
await streamChatResponseAsync({
|
||||
messages: [makeUserMessage('hello')],
|
||||
accountName: 'my-account',
|
||||
sessionSecret: '{"id":"abc","version":"1"}',
|
||||
stream: false,
|
||||
});
|
||||
|
||||
const cookie = [capturedHeaders.cookie].flat().join('; ');
|
||||
expect(cookie).toContain('io.expo.auth.sessionSecret=');
|
||||
expect(cookie).toContain(encodeURIComponent('{"id":"abc","version":"1"}'));
|
||||
expect([capturedHeaders['x-account-name']].flat()[0]).toBe('my-account');
|
||||
expect(capturedBody.messages[0].role).toBe('system');
|
||||
expect(capturedBody.messages[0].parts[0].text).toContain('https://expo.dev');
|
||||
expect(capturedBody.messages[1]).toEqual(
|
||||
expect.objectContaining({ role: 'user', parts: [{ type: 'text', text: 'hello' }] })
|
||||
);
|
||||
});
|
||||
|
||||
it('maps a 429 response to a usage-limit error', async () => {
|
||||
nock(WEBSITE_ORIGIN).post('/api/chat').reply(429, 'Chat usage limit reached');
|
||||
|
||||
await expect(
|
||||
streamChatResponseAsync({
|
||||
messages: [makeUserMessage('hello')],
|
||||
accountName: 'my-account',
|
||||
sessionSecret: '{"id":"abc","version":"1"}',
|
||||
stream: false,
|
||||
})
|
||||
).rejects.toThrow(/usage limit/);
|
||||
});
|
||||
|
||||
it('maps a 403 response to a not-enabled error', async () => {
|
||||
nock(WEBSITE_ORIGIN).post('/api/chat').reply(403, 'Forbidden');
|
||||
|
||||
await expect(
|
||||
streamChatResponseAsync({
|
||||
messages: [makeUserMessage('hello')],
|
||||
accountName: 'my-account',
|
||||
sessionSecret: '{"id":"abc","version":"1"}',
|
||||
stream: false,
|
||||
})
|
||||
).rejects.toThrow(/not enabled/);
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import {
|
||||
createMarkdownRenderState,
|
||||
renderInlineMarkdown,
|
||||
renderMarkdownLine,
|
||||
wrapToWidth,
|
||||
} from '../renderMarkdown';
|
||||
|
||||
describe(renderInlineMarkdown, () => {
|
||||
it('renders bold with chalk.bold', () => {
|
||||
expect(renderInlineMarkdown('a **bold** word')).toBe(`a ${chalk.bold('bold')} word`);
|
||||
expect(renderInlineMarkdown('a __bold__ word')).toBe(`a ${chalk.bold('bold')} word`);
|
||||
});
|
||||
|
||||
it('renders inline code with chalk.cyan', () => {
|
||||
expect(renderInlineMarkdown('run `eas build` now')).toBe(`run ${chalk.cyan('eas build')} now`);
|
||||
});
|
||||
|
||||
it('leaves underscores inside a word untouched', () => {
|
||||
expect(renderInlineMarkdown('my_project_name')).toBe('my_project_name');
|
||||
});
|
||||
|
||||
it('renders links as underlined label with dimmed url', () => {
|
||||
expect(renderInlineMarkdown('see [build](https://expo.dev/b/1)')).toBe(
|
||||
`see ${chalk.underline('build')} ${chalk.dim('(https://expo.dev/b/1)')}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(renderMarkdownLine, () => {
|
||||
it('renders bullet lines with a bullet glyph', () => {
|
||||
const state = createMarkdownRenderState();
|
||||
expect(renderMarkdownLine('- first', state)).toBe(`${chalk.dim('•')} first`);
|
||||
});
|
||||
|
||||
it('drops code fences and dims code block contents', () => {
|
||||
const state = createMarkdownRenderState();
|
||||
expect(renderMarkdownLine('```ts', state)).toBeNull();
|
||||
expect(state.inCodeBlock).toBe(true);
|
||||
expect(renderMarkdownLine('const x = 1;', state)).toBe(chalk.gray('const x = 1;'));
|
||||
expect(renderMarkdownLine('```', state)).toBeNull();
|
||||
expect(state.inCodeBlock).toBe(false);
|
||||
});
|
||||
|
||||
it('applies inline styling to plain lines', () => {
|
||||
const state = createMarkdownRenderState();
|
||||
expect(renderMarkdownLine('the **latest** build', state)).toBe(
|
||||
`the ${chalk.bold('latest')} build`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(wrapToWidth, () => {
|
||||
it('word-wraps to the given width', () => {
|
||||
const segments = wrapToWidth('one two three four five', 12);
|
||||
expect(segments.length).toBeGreaterThan(1);
|
||||
for (const segment of segments) {
|
||||
expect(segment.length).toBeLessThanOrEqual(12);
|
||||
}
|
||||
expect(segments.join(' ').replace(/\s+/g, ' ').trim()).toBe('one two three four five');
|
||||
});
|
||||
|
||||
it('hard-breaks long unbreakable tokens like URLs', () => {
|
||||
const url = 'https://expo.dev/accounts/acme/projects/mobile/builds/cb7c9bdd-56e6-42b8';
|
||||
const segments = wrapToWidth(url, 20);
|
||||
expect(segments.length).toBeGreaterThan(1);
|
||||
for (const segment of segments) {
|
||||
expect(segment.length).toBeLessThanOrEqual(20);
|
||||
}
|
||||
expect(segments.join('')).toBe(url);
|
||||
});
|
||||
|
||||
it('returns the text unchanged when the width is too small to wrap into', () => {
|
||||
expect(wrapToWidth('anything at all here', 4)).toEqual(['anything at all here']);
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { createChatReplInput } from '../replInput';
|
||||
|
||||
describe(createChatReplInput, () => {
|
||||
it('resolves the typed line', async () => {
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
const replInput = createChatReplInput({ input, output, terminal: false });
|
||||
|
||||
const pending = replInput.askAsync('> ');
|
||||
input.write('how are my builds?\n');
|
||||
|
||||
expect(await pending).toBe('how are my builds?');
|
||||
replInput.close();
|
||||
});
|
||||
|
||||
it('reads input even when the stream was left paused (e.g. by a spinner)', async () => {
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
const replInput = createChatReplInput({ input, output, terminal: false });
|
||||
input.pause();
|
||||
|
||||
const pending = replInput.askAsync('> ');
|
||||
input.write('hello\n');
|
||||
|
||||
expect(await pending).toBe('hello');
|
||||
replInput.close();
|
||||
});
|
||||
|
||||
it('resolves null when the input stream ends', async () => {
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
const replInput = createChatReplInput({ input, output, terminal: false });
|
||||
|
||||
const pending = replInput.askAsync('> ');
|
||||
input.end();
|
||||
|
||||
expect(await pending).toBeNull();
|
||||
replInput.close();
|
||||
});
|
||||
|
||||
it('resolves null immediately once closed', async () => {
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
const replInput = createChatReplInput({ input, output, terminal: false });
|
||||
replInput.close();
|
||||
|
||||
expect(await replInput.askAsync('> ')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,449 +0,0 @@
|
||||
import { StringDecoder } from 'node:string_decoder';
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { getExpoWebsiteBaseUrl } from '../api';
|
||||
import fetch, { RequestError } from '../fetch';
|
||||
import Log from '../log';
|
||||
import { ora } from '../ora';
|
||||
import {
|
||||
MarkdownRenderState,
|
||||
createMarkdownRenderState,
|
||||
renderMarkdownLine,
|
||||
wrapToWidth,
|
||||
} from './renderMarkdown';
|
||||
|
||||
const CHAT_SYSTEM_GUIDANCE = [
|
||||
'You are being used through the EAS CLI in a terminal, not the web dashboard.',
|
||||
'The user cannot see the interactive tables the dashboard renders from tool results, so include the important details directly in your text answer instead of assuming a table is shown.',
|
||||
'Whenever you reference a specific artifact (a build, update, submission, deployment, workflow run, channel, or branch), include its direct https://expo.dev link so the user can open it.',
|
||||
'Use only simple markdown: bold, inline code, and bullet or numbered lists.',
|
||||
].join(' ');
|
||||
|
||||
function getWebsiteAuthCookieName(): string {
|
||||
if (process.env.EXPO_STAGING) {
|
||||
return 'staging.expo.auth.sessionSecret';
|
||||
} else if (process.env.EXPO_LOCAL) {
|
||||
return 'local.expo.auth.sessionSecret';
|
||||
} else {
|
||||
return 'io.expo.auth.sessionSecret';
|
||||
}
|
||||
}
|
||||
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
get_projects: 'Looking up your projects',
|
||||
get_latest_builds: 'Looking up builds',
|
||||
get_build: 'Looking up a build',
|
||||
get_build_logs: 'Reading build logs',
|
||||
get_activity: 'Looking up recent activity',
|
||||
get_updates: 'Looking up updates',
|
||||
get_workflow_runs: 'Looking up workflow runs',
|
||||
get_workflow_run: 'Looking up a workflow run',
|
||||
get_workflow_run_logs: 'Reading workflow logs',
|
||||
get_submissions: 'Looking up submissions',
|
||||
get_channels: 'Looking up channels',
|
||||
get_branches: 'Looking up branches',
|
||||
get_worker_deployments: 'Looking up deployments',
|
||||
get_environment_variables: 'Looking up environment variables',
|
||||
get_account_members: 'Looking up account members',
|
||||
get_usage: 'Looking up usage',
|
||||
get_usage_history: 'Looking up usage history',
|
||||
get_invoices: 'Looking up invoices',
|
||||
search_expo_docs: 'Searching Expo docs',
|
||||
navigate: 'Finding the right page',
|
||||
};
|
||||
|
||||
function describeTool(toolName: string): string {
|
||||
return TOOL_LABELS[toolName] ?? `Using ${toolName.replace(/_/g, ' ')}`;
|
||||
}
|
||||
|
||||
const ASSISTANT_LABEL_TEXT = 'Expo';
|
||||
const ASSISTANT_LABEL = `${chalk.bold.magenta(ASSISTANT_LABEL_TEXT)}${chalk.dim(' > ')}`;
|
||||
const ASSISTANT_INDENT = ' '.repeat(`${ASSISTANT_LABEL_TEXT} > `.length);
|
||||
const ASSISTANT_SPINNER_PREFIX = `${chalk.bold.magenta(ASSISTANT_LABEL_TEXT)}${chalk.dim(' >')}`;
|
||||
|
||||
type UIMessageStreamFrame = {
|
||||
type: string;
|
||||
id?: string;
|
||||
delta?: string;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
errorText?: string;
|
||||
};
|
||||
|
||||
type ChatMessagePart =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'step-start' }
|
||||
| {
|
||||
type: `tool-${string}`;
|
||||
toolCallId: string;
|
||||
state: 'input-streaming' | 'input-available' | 'output-available' | 'output-error';
|
||||
input: unknown;
|
||||
output?: unknown;
|
||||
errorText?: string;
|
||||
};
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string;
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
parts: ChatMessagePart[];
|
||||
};
|
||||
|
||||
function makeSystemMessage(text: string): ChatMessage {
|
||||
return { id: uuidv4(), role: 'system', parts: [{ type: 'text', text }] };
|
||||
}
|
||||
|
||||
export function makeUserMessage(text: string): ChatMessage {
|
||||
return { id: uuidv4(), role: 'user', parts: [{ type: 'text', text }] };
|
||||
}
|
||||
|
||||
function makeAssistantMessage(parts: ChatMessagePart[]): ChatMessage {
|
||||
return { id: uuidv4(), role: 'assistant', parts };
|
||||
}
|
||||
|
||||
export type ChatToolCall = {
|
||||
toolName: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
errorText?: string;
|
||||
};
|
||||
|
||||
export type ChatResult = {
|
||||
text: string;
|
||||
toolCalls: ChatToolCall[];
|
||||
assistantMessage: ChatMessage;
|
||||
};
|
||||
|
||||
export async function streamChatResponseAsync({
|
||||
messages,
|
||||
accountName,
|
||||
sessionSecret,
|
||||
stream,
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
accountName: string | undefined;
|
||||
sessionSecret: string;
|
||||
stream: boolean;
|
||||
}): Promise<ChatResult> {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${getExpoWebsiteBaseUrl()}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
accept: 'text/event-stream',
|
||||
cookie: `${getWebsiteAuthCookieName()}=${encodeURIComponent(sessionSecret)}`,
|
||||
...(accountName ? { 'x-account-name': accountName } : {}),
|
||||
},
|
||||
body: JSON.stringify({ messages: [makeSystemMessage(CHAT_SYSTEM_GUIDANCE), ...messages] }),
|
||||
});
|
||||
} catch (error) {
|
||||
throw toFriendlyChatError(error);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('The chat service returned an empty response.');
|
||||
}
|
||||
|
||||
const spinner = stream
|
||||
? ora({ text: 'Thinking…', discardStdin: false, prefixText: ASSISTANT_SPINNER_PREFIX }).start()
|
||||
: undefined;
|
||||
|
||||
let spinnerStopped = false;
|
||||
const stopSpinner = (): void => {
|
||||
if (!spinner || spinnerStopped) {
|
||||
return;
|
||||
}
|
||||
spinnerStopped = true;
|
||||
spinner.stop();
|
||||
if (process.stdout.isTTY) {
|
||||
process.stdout.write('\r');
|
||||
}
|
||||
};
|
||||
const toolMessagePartsById = new Map<string, Extract<ChatMessagePart, { toolCallId: string }>>();
|
||||
const textMessagePartsById = new Map<string, Extract<ChatMessagePart, { type: 'text' }>>();
|
||||
const assistantMessageParts: ChatMessagePart[] = [];
|
||||
const announcedTools = new Set<string>();
|
||||
const markdownState: MarkdownRenderState = createMarkdownRenderState();
|
||||
let fullText = '';
|
||||
let streamingText = false;
|
||||
let errorText: string | undefined;
|
||||
let buffer = '';
|
||||
let displayBuffer = '';
|
||||
let wroteAssistantLine = false;
|
||||
let currentTextMessagePart: Extract<ChatMessagePart, { type: 'text' }> | undefined;
|
||||
|
||||
const getOrCreateToolMessagePart = (
|
||||
toolCallId: string,
|
||||
toolName: string
|
||||
): Extract<ChatMessagePart, { toolCallId: string }> => {
|
||||
const existing = toolMessagePartsById.get(toolCallId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const part: Extract<ChatMessagePart, { toolCallId: string }> = {
|
||||
type: `tool-${toolName}`,
|
||||
toolCallId,
|
||||
state: 'input-streaming',
|
||||
input: undefined,
|
||||
};
|
||||
toolMessagePartsById.set(toolCallId, part);
|
||||
assistantMessageParts.push(part);
|
||||
return part;
|
||||
};
|
||||
|
||||
const getOrCreateTextMessagePart = (
|
||||
id: string | undefined
|
||||
): Extract<ChatMessagePart, { type: 'text' }> => {
|
||||
const existing = id ? textMessagePartsById.get(id) : currentTextMessagePart;
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const part: Extract<ChatMessagePart, { type: 'text' }> = { type: 'text', text: '' };
|
||||
if (id) {
|
||||
textMessagePartsById.set(id, part);
|
||||
}
|
||||
currentTextMessagePart = part;
|
||||
assistantMessageParts.push(part);
|
||||
return part;
|
||||
};
|
||||
|
||||
const writeAssistantLine = (rendered: string, withNewline: boolean): void => {
|
||||
if (!streamingText) {
|
||||
stopSpinner();
|
||||
streamingText = true;
|
||||
}
|
||||
const columns = process.stdout.columns ?? 0;
|
||||
const width = columns > 0 ? columns - ASSISTANT_INDENT.length : 0;
|
||||
const segments = wrapToWidth(rendered, width);
|
||||
segments.forEach((segment, index) => {
|
||||
const prefix = wroteAssistantLine ? ASSISTANT_INDENT : ASSISTANT_LABEL;
|
||||
wroteAssistantLine = true;
|
||||
const isLastSegment = index === segments.length - 1;
|
||||
const needsNewline = !isLastSegment || withNewline;
|
||||
process.stdout.write(`${prefix}${segment}${needsNewline ? '\n' : ''}`);
|
||||
});
|
||||
};
|
||||
|
||||
const flushDisplayLines = (flushRemainder: boolean): void => {
|
||||
let newlineIndex: number;
|
||||
while ((newlineIndex = displayBuffer.indexOf('\n')) !== -1) {
|
||||
const rawLine = displayBuffer.slice(0, newlineIndex);
|
||||
displayBuffer = displayBuffer.slice(newlineIndex + 1);
|
||||
const rendered = renderMarkdownLine(rawLine, markdownState);
|
||||
if (rendered !== null) {
|
||||
writeAssistantLine(rendered, true);
|
||||
}
|
||||
}
|
||||
if (flushRemainder && displayBuffer.length > 0) {
|
||||
const rendered = renderMarkdownLine(displayBuffer, markdownState);
|
||||
displayBuffer = '';
|
||||
if (rendered !== null) {
|
||||
writeAssistantLine(rendered, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const announceTool = (toolName: string): void => {
|
||||
if (!stream || announcedTools.has(toolName)) {
|
||||
return;
|
||||
}
|
||||
announcedTools.add(toolName);
|
||||
const label = describeTool(toolName);
|
||||
if (streamingText) {
|
||||
flushDisplayLines(true);
|
||||
process.stdout.write(chalk.dim(`\n${ASSISTANT_INDENT}(${label})\n`));
|
||||
} else if (spinner) {
|
||||
spinner.text = `${label}…`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFrame = (frame: UIMessageStreamFrame): void => {
|
||||
switch (frame.type) {
|
||||
case 'start-step': {
|
||||
assistantMessageParts.push({ type: 'step-start' });
|
||||
currentTextMessagePart = undefined;
|
||||
break;
|
||||
}
|
||||
case 'text-start': {
|
||||
currentTextMessagePart = getOrCreateTextMessagePart(frame.id);
|
||||
break;
|
||||
}
|
||||
case 'text-delta': {
|
||||
const delta = typeof frame.delta === 'string' ? frame.delta : '';
|
||||
if (!delta) {
|
||||
return;
|
||||
}
|
||||
fullText += delta;
|
||||
getOrCreateTextMessagePart(frame.id).text += delta;
|
||||
if (stream) {
|
||||
displayBuffer += delta;
|
||||
flushDisplayLines(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'text-end': {
|
||||
currentTextMessagePart = undefined;
|
||||
break;
|
||||
}
|
||||
case 'tool-input-start':
|
||||
case 'tool-input-available': {
|
||||
const { toolCallId, toolName } = frame;
|
||||
if (!toolCallId || !toolName) {
|
||||
return;
|
||||
}
|
||||
const toolPart = getOrCreateToolMessagePart(toolCallId, toolName);
|
||||
if (frame.type === 'tool-input-available') {
|
||||
toolPart.state = 'input-available';
|
||||
toolPart.input = frame.input;
|
||||
delete toolPart.output;
|
||||
delete toolPart.errorText;
|
||||
} else if (frame.input !== undefined) {
|
||||
toolPart.input = frame.input;
|
||||
}
|
||||
announceTool(toolName);
|
||||
break;
|
||||
}
|
||||
case 'tool-input-error': {
|
||||
const { toolCallId, toolName } = frame;
|
||||
if (!toolCallId || !toolName || typeof frame.errorText !== 'string') {
|
||||
return;
|
||||
}
|
||||
const toolPart = getOrCreateToolMessagePart(toolCallId, toolName);
|
||||
toolPart.state = 'output-error';
|
||||
toolPart.input = frame.input;
|
||||
delete toolPart.output;
|
||||
toolPart.errorText = frame.errorText;
|
||||
announceTool(toolName);
|
||||
break;
|
||||
}
|
||||
case 'tool-output-available': {
|
||||
const { toolCallId } = frame;
|
||||
if (!toolCallId) {
|
||||
return;
|
||||
}
|
||||
const toolPart = toolMessagePartsById.get(toolCallId);
|
||||
if (toolPart) {
|
||||
toolPart.state = 'output-available';
|
||||
toolPart.output = frame.output;
|
||||
delete toolPart.errorText;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tool-output-error': {
|
||||
const { toolCallId } = frame;
|
||||
if (!toolCallId || typeof frame.errorText !== 'string') {
|
||||
return;
|
||||
}
|
||||
const toolPart = toolMessagePartsById.get(toolCallId);
|
||||
if (toolPart) {
|
||||
toolPart.state = 'output-error';
|
||||
delete toolPart.output;
|
||||
toolPart.errorText = frame.errorText;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
if (typeof frame.errorText === 'string') {
|
||||
errorText = frame.errorText;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const decoder = new StringDecoder('utf8');
|
||||
try {
|
||||
for await (const chunk of response.body) {
|
||||
buffer += decoder.write(chunk as Buffer);
|
||||
let newlineIndex: number;
|
||||
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
|
||||
const line = buffer.slice(0, newlineIndex).trim();
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
if (!line.startsWith('data:')) {
|
||||
continue;
|
||||
}
|
||||
const data = line.slice('data:'.length).trim();
|
||||
if (!data || data === '[DONE]') {
|
||||
continue;
|
||||
}
|
||||
let frame: UIMessageStreamFrame;
|
||||
try {
|
||||
frame = JSON.parse(data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
handleFrame(frame);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (stream) {
|
||||
flushDisplayLines(true);
|
||||
}
|
||||
stopSpinner();
|
||||
}
|
||||
|
||||
if (errorText) {
|
||||
throw new Error(errorText);
|
||||
}
|
||||
|
||||
const toolCalls: ChatToolCall[] = assistantMessageParts
|
||||
.filter((part): part is Extract<ChatMessagePart, { toolCallId: string }> =>
|
||||
part.type.startsWith('tool-')
|
||||
)
|
||||
.map(part => ({
|
||||
toolName: part.type.slice('tool-'.length),
|
||||
input: part.input,
|
||||
output: part.output,
|
||||
errorText: part.errorText,
|
||||
}));
|
||||
const assistantMessage = makeAssistantMessage(
|
||||
assistantMessageParts.filter(
|
||||
part =>
|
||||
part.type === 'text' ||
|
||||
part.type === 'step-start' ||
|
||||
part.state === 'output-available' ||
|
||||
part.state === 'output-error'
|
||||
)
|
||||
);
|
||||
|
||||
if (stream) {
|
||||
if (fullText.length > 0) {
|
||||
Log.newLine();
|
||||
} else {
|
||||
Log.log(
|
||||
chalk.dim(
|
||||
toolCalls.length > 0
|
||||
? 'The assistant looked up your data but did not return a text response. Try asking a more specific question.'
|
||||
: 'The assistant did not return a response.'
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { text: fullText, toolCalls, assistantMessage };
|
||||
}
|
||||
|
||||
function toFriendlyChatError(error: unknown): Error {
|
||||
if (error instanceof RequestError) {
|
||||
const status = error.response.status;
|
||||
if (status === 401) {
|
||||
return new Error('Your Expo session is not valid for chat. Run `eas login` and try again.');
|
||||
}
|
||||
if (status === 403) {
|
||||
return new Error(
|
||||
'Chat is not enabled for this account yet. It is still rolling out. If you think you should have access, reach out to Expo.'
|
||||
);
|
||||
}
|
||||
if (status === 429) {
|
||||
return new Error(
|
||||
'You have reached your chat usage limit. Try again later or upgrade your plan for a larger allowance.'
|
||||
);
|
||||
}
|
||||
return new Error(`The chat request failed (HTTP ${status}).`);
|
||||
}
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { getConfigFilePaths } from '@expo/config';
|
||||
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { findProjectDirAndVerifyProjectSetupAsync } from '../commandUtils/context/contextUtils/findProjectDirAndVerifyProjectSetupAsync';
|
||||
import { AppQuery } from '../graphql/queries/AppQuery';
|
||||
import { getPrivateExpoConfigAsync } from '../project/expoConfig';
|
||||
|
||||
export async function detectCurrentProjectAsync(
|
||||
graphqlClient: ExpoGraphqlClient
|
||||
): Promise<{ accountName: string; label: string } | null> {
|
||||
let projectDir: string;
|
||||
try {
|
||||
projectDir = await findProjectDirAndVerifyProjectSetupAsync();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!projectDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configPaths = getConfigFilePaths(projectDir);
|
||||
if (!configPaths.staticConfigPath && !configPaths.dynamicConfigPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let projectId: unknown;
|
||||
try {
|
||||
const expoConfig = await getPrivateExpoConfigAsync(projectDir);
|
||||
projectId = expoConfig.extra?.eas?.projectId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof projectId !== 'string' || !projectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
|
||||
return { accountName: app.ownerAccount.name, label: app.fullName };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import chalk from 'chalk';
|
||||
import wrapAnsi from 'wrap-ansi';
|
||||
|
||||
export function wrapToWidth(text: string, width: number): string[] {
|
||||
if (width < 10) {
|
||||
return [text];
|
||||
}
|
||||
return wrapAnsi(text, width, { hard: true, trim: false }).split('\n');
|
||||
}
|
||||
|
||||
export type MarkdownRenderState = { inCodeBlock: boolean };
|
||||
|
||||
export function createMarkdownRenderState(): MarkdownRenderState {
|
||||
return { inCodeBlock: false };
|
||||
}
|
||||
|
||||
const FENCE_REGEX = /^\s*```/;
|
||||
const BULLET_REGEX = /^(\s*)[-*+]\s+(.*)$/;
|
||||
const ORDERED_REGEX = /^(\s*)(\d+)\.\s+(.*)$/;
|
||||
const TERMINAL_CONTROL_CHARACTERS_REGEX = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
|
||||
|
||||
export function renderMarkdownLine(line: string, state: MarkdownRenderState): string | null {
|
||||
const sanitizedLine = line.replace(TERMINAL_CONTROL_CHARACTERS_REGEX, '');
|
||||
if (FENCE_REGEX.test(sanitizedLine)) {
|
||||
state.inCodeBlock = !state.inCodeBlock;
|
||||
return null;
|
||||
}
|
||||
if (state.inCodeBlock) {
|
||||
return chalk.gray(sanitizedLine);
|
||||
}
|
||||
|
||||
const bullet = sanitizedLine.match(BULLET_REGEX);
|
||||
if (bullet) {
|
||||
return `${bullet[1]}${chalk.dim('•')} ${renderInlineMarkdown(bullet[2])}`;
|
||||
}
|
||||
|
||||
const ordered = sanitizedLine.match(ORDERED_REGEX);
|
||||
if (ordered) {
|
||||
return `${ordered[1]}${chalk.dim(`${ordered[2]}.`)} ${renderInlineMarkdown(ordered[3])}`;
|
||||
}
|
||||
|
||||
return renderInlineMarkdown(sanitizedLine);
|
||||
}
|
||||
|
||||
export function renderInlineMarkdown(text: string): string {
|
||||
return text
|
||||
.replace(/`([^`]+)`/g, (_match, code) => chalk.cyan(code))
|
||||
.replace(
|
||||
/\[([^\]]+)\]\(([^)]+)\)/g,
|
||||
(_match, label, url) => `${chalk.underline(label)} ${chalk.dim(`(${url})`)}`
|
||||
)
|
||||
.replace(/(\*\*|__)(.+?)\1/g, (_match, _marker, content) => chalk.bold(content));
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import readline from 'node:readline';
|
||||
|
||||
export type ChatReplInput = {
|
||||
askAsync(prompt: string): Promise<string | null>;
|
||||
close(): void;
|
||||
};
|
||||
|
||||
export function createChatReplInput(options?: {
|
||||
input?: NodeJS.ReadableStream;
|
||||
output?: NodeJS.WritableStream;
|
||||
terminal?: boolean;
|
||||
history?: string[];
|
||||
}): ChatReplInput {
|
||||
const inputStream = options?.input ?? process.stdin;
|
||||
const readlineInterface = readline.createInterface({
|
||||
input: inputStream,
|
||||
output: options?.output ?? process.stdout,
|
||||
terminal: options?.terminal ?? true,
|
||||
historySize: 100,
|
||||
history: options?.history,
|
||||
});
|
||||
|
||||
let closed = false;
|
||||
readlineInterface.on('close', () => {
|
||||
closed = true;
|
||||
});
|
||||
readlineInterface.on('SIGINT', () => {
|
||||
readlineInterface.close();
|
||||
});
|
||||
|
||||
return {
|
||||
async askAsync(prompt: string): Promise<string | null> {
|
||||
if (closed) {
|
||||
return null;
|
||||
}
|
||||
inputStream.resume();
|
||||
return await new Promise<string | null>(resolve => {
|
||||
let settled = false;
|
||||
const onClose = (): void => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
readlineInterface.once('close', onClose);
|
||||
readlineInterface.question(prompt, answer => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
readlineInterface.removeListener('close', onClose);
|
||||
resolve(answer);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
close(): void {
|
||||
if (!closed) {
|
||||
readlineInterface.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -30,7 +30,6 @@ import Log, { link } from '../log';
|
||||
import Sentry from '../sentry';
|
||||
import SessionManager from '../user/SessionManager';
|
||||
import { getActorDisplayName } from '../user/User';
|
||||
import { isMultiAccountEnabled } from '../utils/easCli';
|
||||
import { Client } from '../vcs/vcs';
|
||||
|
||||
export type ContextInput<
|
||||
@@ -225,75 +224,11 @@ export default abstract class EasCommand extends Command {
|
||||
|
||||
protected abstract runAsync(): Promise<any>;
|
||||
|
||||
/**
|
||||
* Parse and remove the --account flag from command line arguments.
|
||||
* This is a global flag available on all commands when multi-account is enabled.
|
||||
* We remove it from this.argv so child commands don't see it during their parse().
|
||||
* Commands that declare their own `account` flag (with different semantics,
|
||||
* e.g. `project:init`) are skipped so their flag keeps working.
|
||||
*/
|
||||
private parseAndRemoveAccountFlag(): string | undefined {
|
||||
if (this.ctor.flags && 'account' in this.ctor.flags) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const terminatorIndex = this.argv.indexOf('--');
|
||||
const searchEnd = terminatorIndex === -1 ? this.argv.length : terminatorIndex;
|
||||
|
||||
for (let i = 0; i < searchEnd; i++) {
|
||||
const arg = this.argv[i];
|
||||
if (arg === '--account') {
|
||||
const value = i + 1 < searchEnd ? this.argv[i + 1] : undefined;
|
||||
if (!value || value.startsWith('-')) {
|
||||
throw new Error('The --account flag requires a username value.');
|
||||
}
|
||||
this.argv.splice(i, 2);
|
||||
return value;
|
||||
}
|
||||
if (arg.startsWith('--account=')) {
|
||||
const value = arg.slice('--account='.length);
|
||||
if (!value) {
|
||||
throw new Error('The --account flag requires a username value.');
|
||||
}
|
||||
this.argv.splice(i, 1);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
async run(): Promise<any> {
|
||||
this.analyticsInternal = await createAnalyticsAsync();
|
||||
this.sessionManagerInternal = new SessionManager(this.analytics);
|
||||
|
||||
// Handle --account flag for per-command account selection
|
||||
if (isMultiAccountEnabled()) {
|
||||
const accountFlag = this.parseAndRemoveAccountFlag();
|
||||
if (accountFlag) {
|
||||
if (this.sessionManager.getAccessToken()) {
|
||||
throw new Error(
|
||||
'EXPO_TOKEN is set in your environment and is used for all EAS authentication, so --account has no effect. Unset EXPO_TOKEN to select an account with --account.'
|
||||
);
|
||||
}
|
||||
const accounts = this.sessionManager.getAllAccounts();
|
||||
const targetAccount = accounts.find(a => a.username === accountFlag);
|
||||
if (!targetAccount) {
|
||||
const availableAccounts = accounts.map(a => a.username).join(', ') || 'none';
|
||||
throw new Error(
|
||||
`Account '${accountFlag}' not found. Available accounts: ${availableAccounts}`
|
||||
);
|
||||
}
|
||||
if (
|
||||
!targetAccount.isActive &&
|
||||
!this.sessionManager.useAccountForProcessByUsername(accountFlag)
|
||||
) {
|
||||
throw new Error(`Account '${accountFlag}' not found.`);
|
||||
}
|
||||
Log.log(chalk.dim(`Using account: ${accountFlag}`));
|
||||
}
|
||||
}
|
||||
|
||||
// this is needed for logEvent call below as it identifies the user in the analytics system
|
||||
// if possible
|
||||
const actor = await this.sessionManager.getUserAsync();
|
||||
|
||||
@@ -1,68 +1,109 @@
|
||||
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../../onboarding/git';
|
||||
import glob from 'fast-glob';
|
||||
import fs from 'fs-extra';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { extract } from 'tar';
|
||||
|
||||
import fetch from '../../../fetch';
|
||||
import { installDependenciesAsync } from '../../../onboarding/installDependencies';
|
||||
import { runCommandAsync } from '../../../onboarding/runCommand';
|
||||
import { expoCommandAsync } from '../../../utils/expoCli';
|
||||
import {
|
||||
cloneTemplateAsync,
|
||||
downloadTemplateAsync,
|
||||
fetchTemplatePackumentAsync,
|
||||
initializeGitRepositoryAsync,
|
||||
installProjectDependenciesAsync,
|
||||
} from '../commands';
|
||||
|
||||
jest.mock('../../../utils/expoCli');
|
||||
jest.mock('../../../onboarding/git');
|
||||
jest.mock('../../../fetch');
|
||||
jest.mock('../../../onboarding/runCommand');
|
||||
jest.mock('../../../onboarding/installDependencies');
|
||||
jest.mock('../../../ora');
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('tar');
|
||||
jest.mock('stream/promises');
|
||||
jest.mock('fast-glob');
|
||||
|
||||
describe('commands', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('cloneTemplateAsync', () => {
|
||||
it('should clone the template project with ssh', async () => {
|
||||
describe('fetchTemplatePackumentAsync', () => {
|
||||
it('should fetch the packument from the npm registry', async () => {
|
||||
const packument = { 'dist-tags': { latest: '57.0.19' } };
|
||||
jest.mocked(fetch).mockResolvedValueOnce({ json: async () => packument } as any);
|
||||
|
||||
expect(await fetchTemplatePackumentAsync()).toEqual(packument);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://registry.npmjs.org/expo-template-default',
|
||||
expect.objectContaining({
|
||||
headers: { accept: 'application/vnd.npm.install-v1+json' },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadTemplateAsync', () => {
|
||||
const template = {
|
||||
npmTag: 'sdk-56',
|
||||
version: '56.0.34',
|
||||
tarballUrl: 'https://registry.npmjs.org/expo-template-default/-/56.0.34.tgz',
|
||||
};
|
||||
|
||||
it('should download and extract the template', async () => {
|
||||
const targetProjectDir = '/test/target-project';
|
||||
const finalTargetProjectDir = '/test/final-target-project';
|
||||
const tarballBody = 'tarball-stream';
|
||||
|
||||
jest.mocked(canAccessRepositoryUsingSshAsync).mockResolvedValue(true);
|
||||
jest.mocked(runGitCloneAsync).mockResolvedValue({
|
||||
targetProjectDir: finalTargetProjectDir,
|
||||
});
|
||||
jest.mocked(fs.pathExists).mockResolvedValueOnce(false as never);
|
||||
jest.mocked(fetch).mockResolvedValueOnce({ body: tarballBody } as any);
|
||||
jest.mocked(glob).mockResolvedValueOnce(['gitignore', '_vscode'] as any);
|
||||
|
||||
const result = await cloneTemplateAsync(targetProjectDir);
|
||||
const result = await downloadTemplateAsync(targetProjectDir, template);
|
||||
|
||||
expect(runGitCloneAsync).toHaveBeenCalledWith({
|
||||
githubUsername: 'expo',
|
||||
githubRepositoryName: 'expo-template-default',
|
||||
targetProjectDir,
|
||||
cloneMethod: 'ssh',
|
||||
showOutput: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(finalTargetProjectDir);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://registry.npmjs.org/expo-template-default/-/56.0.34.tgz'
|
||||
);
|
||||
expect(extract).toHaveBeenCalledWith({ cwd: targetProjectDir, strip: 1 });
|
||||
expect(pipeline).toHaveBeenCalledWith(tarballBody, extract({} as any));
|
||||
expect(fs.move).toHaveBeenCalledWith(
|
||||
'/test/target-project/gitignore',
|
||||
'/test/target-project/.gitignore',
|
||||
{ overwrite: true }
|
||||
);
|
||||
expect(fs.move).toHaveBeenCalledWith(
|
||||
'/test/target-project/_vscode',
|
||||
'/test/target-project/.vscode',
|
||||
{ overwrite: true }
|
||||
);
|
||||
expect(result).toBe(targetProjectDir);
|
||||
});
|
||||
|
||||
it('should clone the template project with https', async () => {
|
||||
const targetProjectDir = '/test/target-project';
|
||||
const finalTargetProjectDir = '/test/final-target-project';
|
||||
it('should throw when the target directory exists and is not empty', async () => {
|
||||
jest.mocked(fs.pathExists).mockResolvedValueOnce(true as never);
|
||||
jest.mocked(fs.readdir).mockResolvedValueOnce(['existing-file'] as never);
|
||||
|
||||
jest.mocked(canAccessRepositoryUsingSshAsync).mockResolvedValue(false);
|
||||
jest.mocked(runGitCloneAsync).mockResolvedValue({
|
||||
targetProjectDir: finalTargetProjectDir,
|
||||
});
|
||||
await expect(downloadTemplateAsync('/test/target-project', template)).rejects.toThrow(
|
||||
'already exists and is not empty'
|
||||
);
|
||||
});
|
||||
|
||||
const result = await cloneTemplateAsync(targetProjectDir);
|
||||
it('should not remove a pre-existing directory on failure', async () => {
|
||||
jest.mocked(fs.pathExists).mockResolvedValueOnce(true as never);
|
||||
jest.mocked(fs.readdir).mockResolvedValueOnce(['existing-file'] as never);
|
||||
|
||||
expect(runGitCloneAsync).toHaveBeenCalledWith({
|
||||
githubUsername: 'expo',
|
||||
githubRepositoryName: 'expo-template-default',
|
||||
targetProjectDir,
|
||||
cloneMethod: 'https',
|
||||
showOutput: false,
|
||||
});
|
||||
await expect(downloadTemplateAsync('/test/target-project', template)).rejects.toThrow();
|
||||
expect(fs.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(result).toBe(finalTargetProjectDir);
|
||||
it('should clean up a partially extracted directory on failure', async () => {
|
||||
jest.mocked(fs.pathExists).mockResolvedValueOnce(false as never);
|
||||
jest.mocked(fetch).mockRejectedValueOnce(new Error('network error'));
|
||||
|
||||
await expect(downloadTemplateAsync('/test/target-project', template)).rejects.toThrow(
|
||||
'network error'
|
||||
);
|
||||
expect(fs.remove).toHaveBeenCalledWith('/test/target-project');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
copyProjectTemplatesAsync,
|
||||
generateAppConfigAsync,
|
||||
generateEasConfigAsync,
|
||||
removeTemplateFilesAsync,
|
||||
updatePackageJsonAsync,
|
||||
updateReadmeAsync,
|
||||
} from '../projectFiles';
|
||||
@@ -239,17 +240,24 @@ describe('projectFiles', () => {
|
||||
});
|
||||
|
||||
describe('updatePackageJsonAsync', () => {
|
||||
it('should update the package.json', async () => {
|
||||
it('should replace template metadata and add workflow scripts', async () => {
|
||||
jest.mocked(fs.readJson).mockResolvedValue({
|
||||
name: 'test-app',
|
||||
version: '1.0.0',
|
||||
name: 'expo-template-default',
|
||||
version: '57.0.19',
|
||||
license: '0BSD',
|
||||
description: 'A starter template',
|
||||
repository: 'https://github.com/expo/expo-template-default',
|
||||
tags: ['expo'],
|
||||
main: 'expo-router/entry',
|
||||
});
|
||||
const projectDir = '/test/project-dir';
|
||||
await updatePackageJsonAsync(projectDir);
|
||||
await updatePackageJsonAsync(projectDir, 'My App');
|
||||
|
||||
const expectedPackageJson = {
|
||||
name: 'test-app',
|
||||
name: 'myapp',
|
||||
version: '1.0.0',
|
||||
private: true,
|
||||
main: 'expo-router/entry',
|
||||
scripts: {
|
||||
draft: 'npx eas-cli@latest workflow:run create-draft.yml',
|
||||
'development-builds': 'npx eas-cli@latest workflow:run create-development-builds.yml',
|
||||
@@ -261,6 +269,28 @@ describe('projectFiles', () => {
|
||||
spaces: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep a non-template license', async () => {
|
||||
jest.mocked(fs.readJson).mockResolvedValue({
|
||||
name: 'some-template',
|
||||
license: 'MIT',
|
||||
});
|
||||
await updatePackageJsonAsync('/test/project-dir', 'my-app');
|
||||
|
||||
expect(fs.writeJson).toHaveBeenCalledWith(
|
||||
'/test/project-dir/package.json',
|
||||
expect.objectContaining({ license: 'MIT' }),
|
||||
{ spaces: 2 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeTemplateFilesAsync', () => {
|
||||
it('should remove the template LICENSE file', async () => {
|
||||
await removeTemplateFilesAsync('/test/project-dir');
|
||||
|
||||
expect(fs.remove).toHaveBeenCalledWith('/test/project-dir/LICENSE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyProjectTemplatesAsync', () => {
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import fetch from '../../../fetch';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import { fetchTemplatePackumentAsync } from '../commands';
|
||||
import {
|
||||
fetchSdkVersionsAsync,
|
||||
promptForSdkVersionAsync,
|
||||
resolveTemplateAsync,
|
||||
} from '../sdkVersion';
|
||||
|
||||
jest.mock('../../../fetch');
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../api', () => ({
|
||||
getExpoApiBaseUrl: () => 'https://api.expo.dev',
|
||||
}));
|
||||
jest.mock('../commands');
|
||||
|
||||
const packument = {
|
||||
'dist-tags': { latest: '57.0.19', 'sdk-57': '57.0.19', 'sdk-56': '56.0.34' },
|
||||
versions: {
|
||||
'57.0.19': {
|
||||
dist: { tarball: 'https://registry.npmjs.org/expo-template-default/-/57.0.19.tgz' },
|
||||
},
|
||||
'56.0.34': {
|
||||
dist: { tarball: 'https://registry.npmjs.org/expo-template-default/-/56.0.34.tgz' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function mockVersionsEndpoint(json: object): void {
|
||||
jest.mocked(fetch).mockResolvedValueOnce({ json: async () => json } as any);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(fetchTemplatePackumentAsync).mockResolvedValue(packument);
|
||||
});
|
||||
|
||||
describe('fetchSdkVersionsAsync', () => {
|
||||
it('returns released SDK versions sorted descending', async () => {
|
||||
mockVersionsEndpoint({
|
||||
sdkVersions: {
|
||||
'55.0.0': { releaseNoteUrl: 'https://expo.dev/changelog/sdk-55' },
|
||||
'57.0.0': { releaseNoteUrl: 'https://expo.dev/changelog/sdk-57' },
|
||||
'56.0.0': { releaseNoteUrl: 'https://expo.dev/changelog/sdk-56' },
|
||||
'58.0.0': {}, // canary, no release notes
|
||||
'54.0.0': { releaseNoteUrl: 'https://expo.dev/changelog/sdk-54', isDeprecated: true },
|
||||
},
|
||||
expoGoSdkVersion: '56.0.0',
|
||||
});
|
||||
|
||||
const versions = await fetchSdkVersionsAsync();
|
||||
|
||||
expect(versions).toEqual({
|
||||
latest: 57,
|
||||
expoGoCompatible: 56,
|
||||
available: [57, 56, 55],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the endpoint is unreachable', async () => {
|
||||
jest.mocked(fetch).mockRejectedValueOnce(new Error('network error'));
|
||||
|
||||
expect(await fetchSdkVersionsAsync()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no released versions are available', async () => {
|
||||
mockVersionsEndpoint({ sdkVersions: {} });
|
||||
|
||||
expect(await fetchSdkVersionsAsync()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptForSdkVersionAsync', () => {
|
||||
it('offers latest and Expo Go compatible choices', async () => {
|
||||
jest.mocked(promptAsync).mockResolvedValueOnce({ answer: 56 });
|
||||
|
||||
const result = await promptForSdkVersionAsync({
|
||||
latest: 57,
|
||||
expoGoCompatible: 56,
|
||||
available: [57, 56, 55],
|
||||
});
|
||||
|
||||
expect(result).toBe(56);
|
||||
expect(promptAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Select an Expo SDK version:',
|
||||
choices: [
|
||||
expect.objectContaining({ title: 'Latest (SDK 57)', value: 57 }),
|
||||
expect.objectContaining({
|
||||
title: 'For learning with Expo Go (SDK 56)',
|
||||
value: 56,
|
||||
}),
|
||||
expect.objectContaining({ title: 'Other SDK version…', value: 'other' }),
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the Expo Go choice when it matches the latest SDK or is unavailable', async () => {
|
||||
jest.mocked(promptAsync).mockResolvedValue({ answer: 57 });
|
||||
|
||||
await promptForSdkVersionAsync({ latest: 57, expoGoCompatible: 57, available: [57, 56] });
|
||||
await promptForSdkVersionAsync({ latest: 57, expoGoCompatible: 54, available: [57, 56] });
|
||||
|
||||
for (const call of jest.mocked(promptAsync).mock.calls) {
|
||||
expect(call[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
choices: [
|
||||
expect.objectContaining({ title: 'Latest (SDK 57)' }),
|
||||
expect.objectContaining({ title: 'Other SDK version…' }),
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('hides the "Other" choice when no other versions are available', async () => {
|
||||
jest.mocked(promptAsync).mockResolvedValueOnce({ answer: 57 });
|
||||
|
||||
await promptForSdkVersionAsync({ latest: 57, expoGoCompatible: null, available: [57] });
|
||||
|
||||
expect(promptAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
choices: [expect.objectContaining({ title: 'Latest (SDK 57)' })],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('offers the remaining versions when "other" is selected', async () => {
|
||||
jest
|
||||
.mocked(promptAsync)
|
||||
.mockResolvedValueOnce({ answer: 'other' })
|
||||
.mockResolvedValueOnce({ sdkVersion: 53 });
|
||||
|
||||
const result = await promptForSdkVersionAsync({
|
||||
latest: 57,
|
||||
expoGoCompatible: 56,
|
||||
available: [57, 56, 55, 54, 53],
|
||||
});
|
||||
|
||||
expect(result).toBe(53);
|
||||
expect(promptAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Select an SDK version:',
|
||||
choices: [
|
||||
{ title: 'SDK 55', value: 55 },
|
||||
{ title: 'SDK 54', value: 54 },
|
||||
{ title: 'SDK 53', value: 53 },
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTemplateAsync', () => {
|
||||
it('uses the provided SDK version without prompting', async () => {
|
||||
expect(await resolveTemplateAsync({ sdkVersion: '56' })).toEqual({
|
||||
npmTag: 'sdk-56',
|
||||
version: '56.0.34',
|
||||
tarballUrl: 'https://registry.npmjs.org/expo-template-default/-/56.0.34.tgz',
|
||||
});
|
||||
expect((await resolveTemplateAsync({ sdkVersion: 'sdk-57' })).npmTag).toBe('sdk-57');
|
||||
expect((await resolveTemplateAsync({ sdkVersion: 'latest' })).npmTag).toBe('latest');
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws for an invalid SDK version', async () => {
|
||||
await expect(resolveTemplateAsync({ sdkVersion: 'abc' })).rejects.toThrow(
|
||||
'Invalid SDK version'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws for an SDK version without a published template', async () => {
|
||||
await expect(resolveTemplateAsync({ sdkVersion: '50' })).rejects.toThrow(
|
||||
'A project template for "sdk-50" is not available. Supported SDK versions: 57, 56.'
|
||||
);
|
||||
});
|
||||
|
||||
it('prompts with only the SDK versions that have a published template', async () => {
|
||||
mockVersionsEndpoint({
|
||||
sdkVersions: {
|
||||
'57.0.0': { releaseNoteUrl: 'https://expo.dev/changelog/sdk-57' },
|
||||
'56.0.0': { releaseNoteUrl: 'https://expo.dev/changelog/sdk-56' },
|
||||
'55.0.0': { releaseNoteUrl: 'https://expo.dev/changelog/sdk-55' },
|
||||
},
|
||||
});
|
||||
jest.mocked(promptAsync).mockResolvedValueOnce({ answer: 56 });
|
||||
|
||||
const template = await resolveTemplateAsync({});
|
||||
|
||||
expect(template.npmTag).toBe('sdk-56');
|
||||
// SDK 55 has no dist-tag in the packument, so it must not be offered.
|
||||
expect(promptAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
choices: [
|
||||
expect.objectContaining({ title: 'Latest (SDK 57)' }),
|
||||
expect.objectContaining({ title: 'Other SDK version…' }),
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the latest tag when the versions endpoint is unavailable', async () => {
|
||||
jest.mocked(fetch).mockRejectedValueOnce(new Error('network error'));
|
||||
|
||||
expect((await resolveTemplateAsync({})).npmTag).toBe('latest');
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the latest tag when no listed version has a template', async () => {
|
||||
mockVersionsEndpoint({
|
||||
sdkVersions: { '99.0.0': { releaseNoteUrl: 'https://expo.dev/changelog/sdk-99' } },
|
||||
});
|
||||
|
||||
expect((await resolveTemplateAsync({})).npmTag).toBe('latest');
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,40 +1,106 @@
|
||||
import chalk from 'chalk';
|
||||
import glob from 'fast-glob';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { extract } from 'tar';
|
||||
|
||||
import { printDirectory } from './utils';
|
||||
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../onboarding/git';
|
||||
import fetch from '../../fetch';
|
||||
import { PackageManager, installDependenciesAsync } from '../../onboarding/installDependencies';
|
||||
import { runCommandAsync } from '../../onboarding/runCommand';
|
||||
import { ora } from '../../ora';
|
||||
import { expoCommandAsync } from '../../utils/expoCli';
|
||||
|
||||
export async function cloneTemplateAsync(targetProjectDir: string): Promise<string> {
|
||||
const githubUsername = 'expo';
|
||||
const githubRepositoryName = 'expo-template-default';
|
||||
export const TEMPLATE_PACKAGE_NAME = 'expo-template-default';
|
||||
const NPM_REGISTRY_URL =
|
||||
process.env.npm_config_registry?.replace(/\/$/, '') ?? 'https://registry.npmjs.org';
|
||||
|
||||
export interface TemplateInfo {
|
||||
npmTag: string;
|
||||
version: string;
|
||||
tarballUrl: string;
|
||||
}
|
||||
|
||||
export interface TemplatePackument {
|
||||
'dist-tags'?: Record<string, string>;
|
||||
versions?: Record<string, { dist: { tarball: string } }>;
|
||||
}
|
||||
|
||||
export async function fetchTemplatePackumentAsync(): Promise<TemplatePackument> {
|
||||
const response = await fetch(`${NPM_REGISTRY_URL}/${TEMPLATE_PACKAGE_NAME}`, {
|
||||
headers: { accept: 'application/vnd.npm.install-v1+json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
return (await response.json()) as TemplatePackument;
|
||||
}
|
||||
|
||||
export async function downloadTemplateAsync(
|
||||
targetProjectDir: string,
|
||||
template: TemplateInfo
|
||||
): Promise<string> {
|
||||
const spinner = ora(
|
||||
`${chalk.bold(`Cloning the project to ${printDirectory(targetProjectDir)}`)}`
|
||||
`${chalk.bold(`Downloading the project template to ${printDirectory(targetProjectDir)}`)}`
|
||||
).start();
|
||||
|
||||
const cloneMethod = (await canAccessRepositoryUsingSshAsync({
|
||||
githubUsername,
|
||||
githubRepositoryName,
|
||||
}))
|
||||
? 'ssh'
|
||||
: 'https';
|
||||
const targetExisted = await fs.pathExists(targetProjectDir);
|
||||
try {
|
||||
// The directory was verified to not exist earlier, but it may have been
|
||||
// created while the user was answering prompts.
|
||||
if (targetExisted && (await fs.readdir(targetProjectDir)).length > 0) {
|
||||
throw new Error(
|
||||
`Directory ${printDirectory(targetProjectDir)} already exists and is not empty.`
|
||||
);
|
||||
}
|
||||
|
||||
const { targetProjectDir: finalTargetProjectDirectory } = await runGitCloneAsync({
|
||||
githubUsername,
|
||||
githubRepositoryName,
|
||||
targetProjectDir,
|
||||
cloneMethod,
|
||||
showOutput: false,
|
||||
await fs.mkdirp(targetProjectDir);
|
||||
const tarballResponse = await fetch(template.tarballUrl);
|
||||
await pipeline(tarballResponse.body, extract({ cwd: targetProjectDir, strip: 1 }));
|
||||
|
||||
await restoreTemplateDotfilesAsync(targetProjectDir);
|
||||
|
||||
spinner.succeed(
|
||||
`Downloaded ${chalk.bold(`${TEMPLATE_PACKAGE_NAME}@${template.version}`)} to ${printDirectory(
|
||||
targetProjectDir
|
||||
)}`
|
||||
);
|
||||
} catch (error) {
|
||||
spinner.fail();
|
||||
if (!targetExisted) {
|
||||
await fs.remove(targetProjectDir);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return targetProjectDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* npm strips `.gitignore` files from published packages, and the template ships
|
||||
* dot-directories with an underscore prefix (e.g. `_vscode`), so restore the
|
||||
* real names after extraction. This mirrors the renames in create-expo-app;
|
||||
* keep the directory list in sync with SUPPORTED_DIRECTORIES in
|
||||
* https://github.com/expo/expo/blob/main/packages/create-expo-app/src/createFileTransform.ts
|
||||
*/
|
||||
async function restoreTemplateDotfilesAsync(projectDir: string): Promise<void> {
|
||||
const entries = await glob('**/{gitignore,_eas,_vscode,_github,_cursor}', {
|
||||
cwd: projectDir,
|
||||
onlyFiles: false,
|
||||
dot: true,
|
||||
});
|
||||
|
||||
spinner.succeed(`Cloned the project to ${printDirectory(finalTargetProjectDirectory)}`);
|
||||
// Rename deeper entries first so parent renames do not invalidate child paths.
|
||||
entries.sort((a, b) => b.split('/').length - a.split('/').length);
|
||||
|
||||
return finalTargetProjectDirectory;
|
||||
for (const entry of entries) {
|
||||
const basename = path.basename(entry);
|
||||
const dotName = basename === 'gitignore' ? '.gitignore' : `.${basename.slice(1)}`;
|
||||
await fs.move(
|
||||
path.join(projectDir, entry),
|
||||
path.join(projectDir, path.dirname(entry), dotName),
|
||||
{ overwrite: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function installProjectDependenciesAsync(
|
||||
|
||||
@@ -110,10 +110,37 @@ export async function generateEasConfigAsync(projectDir: string): Promise<void>
|
||||
await fs.writeJson(easJsonPath, easJson, { spaces: 2 });
|
||||
}
|
||||
|
||||
export async function updatePackageJsonAsync(projectDir: string): Promise<void> {
|
||||
const TEMPLATE_LICENSE = '0BSD';
|
||||
|
||||
// https://github.com/npm/validate-npm-package-name#naming-rules
|
||||
function sanitizeNpmPackageName(name: string): string {
|
||||
return (
|
||||
name
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/^[._]+/, '')
|
||||
.replace(/[^a-z0-9._\-/@]/g, '') || 'app'
|
||||
);
|
||||
}
|
||||
|
||||
export async function updatePackageJsonAsync(
|
||||
projectDir: string,
|
||||
projectName: string
|
||||
): Promise<void> {
|
||||
const packageJsonPath = path.join(projectDir, 'package.json');
|
||||
const packageJson = await fs.readJson(packageJsonPath);
|
||||
|
||||
packageJson.name = sanitizeNpmPackageName(projectName);
|
||||
packageJson.version = '1.0.0';
|
||||
packageJson.private = true;
|
||||
delete packageJson.description;
|
||||
delete packageJson.tags;
|
||||
delete packageJson.repository;
|
||||
if (packageJson.license === TEMPLATE_LICENSE) {
|
||||
delete packageJson.license;
|
||||
}
|
||||
|
||||
if (!packageJson.scripts) {
|
||||
packageJson.scripts = {};
|
||||
}
|
||||
@@ -125,6 +152,10 @@ export async function updatePackageJsonAsync(projectDir: string): Promise<void>
|
||||
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
|
||||
}
|
||||
|
||||
export async function removeTemplateFilesAsync(projectDir: string): Promise<void> {
|
||||
await fs.remove(path.join(projectDir, 'LICENSE'));
|
||||
}
|
||||
|
||||
export async function copyProjectTemplatesAsync(projectDir: string): Promise<void> {
|
||||
const templatesSourceDir = path.join(__dirname, 'templates');
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { TemplateInfo, fetchTemplatePackumentAsync } from './commands';
|
||||
import { getExpoApiBaseUrl } from '../../api';
|
||||
import fetch from '../../fetch';
|
||||
import Log from '../../log';
|
||||
import { promptAsync } from '../../prompts';
|
||||
|
||||
export interface SdkVersions {
|
||||
latest: number;
|
||||
expoGoCompatible: number | null;
|
||||
available: number[];
|
||||
}
|
||||
|
||||
interface VersionsEndpointResponse {
|
||||
sdkVersions?: Record<string, { releaseNoteUrl?: string; isDeprecated?: boolean }>;
|
||||
expoGoSdkVersion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SDKs without a `releaseNoteUrl` are canary/in-development and are excluded.
|
||||
*/
|
||||
export async function fetchSdkVersionsAsync(): Promise<SdkVersions | null> {
|
||||
let json: VersionsEndpointResponse;
|
||||
try {
|
||||
const response = await fetch(`${getExpoApiBaseUrl()}/v2/versions`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
json = (await response.json()) as VersionsEndpointResponse;
|
||||
} catch (error) {
|
||||
Log.debug(`Failed to fetch SDK versions: ${error}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const available = Object.entries(json.sdkVersions ?? {})
|
||||
.filter(([, info]) => !!info.releaseNoteUrl && !info.isDeprecated)
|
||||
.map(([version]) => parseSdkMajor(version))
|
||||
.filter((major): major is number => major !== null)
|
||||
.sort((a, b) => b - a);
|
||||
|
||||
const latest = available[0];
|
||||
if (latest == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
latest,
|
||||
expoGoCompatible: parseSdkMajor(json.expoGoSdkVersion),
|
||||
available,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSdkMajor(version: string | undefined): number | null {
|
||||
const major = parseInt(version?.split('.')[0] ?? '', 10);
|
||||
return Number.isFinite(major) ? major : null;
|
||||
}
|
||||
|
||||
export async function promptForSdkVersionAsync({
|
||||
latest,
|
||||
expoGoCompatible,
|
||||
available,
|
||||
}: SdkVersions): Promise<number> {
|
||||
const choices: { title: string; value: number | 'other'; description?: string }[] = [
|
||||
{
|
||||
title: `Latest (SDK ${latest})`,
|
||||
value: latest,
|
||||
description: 'Recommended for most projects',
|
||||
},
|
||||
];
|
||||
|
||||
if (
|
||||
expoGoCompatible !== null &&
|
||||
expoGoCompatible !== latest &&
|
||||
available.includes(expoGoCompatible)
|
||||
) {
|
||||
choices.push({
|
||||
title: `For learning with Expo Go (SDK ${expoGoCompatible})`,
|
||||
value: expoGoCompatible,
|
||||
description: 'Compatible with Expo Go on App Store and Play Store',
|
||||
});
|
||||
}
|
||||
|
||||
const otherVersions = available.filter(sdk => sdk !== latest && sdk !== expoGoCompatible);
|
||||
if (otherVersions.length > 0) {
|
||||
choices.push({ title: 'Other SDK version…', value: 'other' });
|
||||
}
|
||||
|
||||
const { answer } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'answer',
|
||||
message: 'Select an Expo SDK version:',
|
||||
choices,
|
||||
});
|
||||
|
||||
if (answer !== 'other') {
|
||||
return answer;
|
||||
}
|
||||
|
||||
const { sdkVersion } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'sdkVersion',
|
||||
message: 'Select an SDK version:',
|
||||
choices: otherVersions.map(sdk => ({
|
||||
title: `SDK ${sdk}`,
|
||||
value: sdk,
|
||||
})),
|
||||
});
|
||||
|
||||
return sdkVersion;
|
||||
}
|
||||
|
||||
function resolveTemplateForTag(
|
||||
distTags: Record<string, string>,
|
||||
versions: Record<string, { dist: { tarball: string } }>,
|
||||
npmTag: string
|
||||
): TemplateInfo {
|
||||
const version = distTags[npmTag];
|
||||
const tarballUrl = version ? versions[version]?.dist.tarball : undefined;
|
||||
if (!version || !tarballUrl) {
|
||||
const supported = Object.keys(distTags)
|
||||
.filter(tag => /^sdk-\d+$/.test(tag))
|
||||
.sort((a, b) => parseInt(b.slice(4), 10) - parseInt(a.slice(4), 10));
|
||||
throw new Error(
|
||||
`A project template for "${npmTag}" is not available. Supported SDK versions: ${supported
|
||||
.map(tag => tag.slice(4))
|
||||
.join(', ')}.`
|
||||
);
|
||||
}
|
||||
return { npmTag, version, tarballUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* `sdkVersion` accepts "57", "sdk-57", or "latest".
|
||||
*/
|
||||
export async function resolveTemplateAsync({
|
||||
sdkVersion,
|
||||
}: {
|
||||
sdkVersion?: string;
|
||||
}): Promise<TemplateInfo> {
|
||||
const packument = await fetchTemplatePackumentAsync();
|
||||
const templateDistTags = packument['dist-tags'] ?? {};
|
||||
const packumentVersions = packument.versions ?? {};
|
||||
|
||||
if (sdkVersion) {
|
||||
if (sdkVersion === 'latest') {
|
||||
return resolveTemplateForTag(templateDistTags, packumentVersions, 'latest');
|
||||
}
|
||||
const major = parseSdkMajor(sdkVersion.replace(/^sdk-/, ''));
|
||||
if (major === null) {
|
||||
throw new Error(
|
||||
`Invalid SDK version: "${sdkVersion}". Specify a version number (e.g. 57) or "latest".`
|
||||
);
|
||||
}
|
||||
return resolveTemplateForTag(templateDistTags, packumentVersions, `sdk-${major}`);
|
||||
}
|
||||
|
||||
const versions = await fetchSdkVersionsAsync();
|
||||
const availableWithTemplates =
|
||||
versions?.available.filter(sdk => `sdk-${sdk}` in templateDistTags) ?? [];
|
||||
|
||||
if (!versions || availableWithTemplates.length === 0) {
|
||||
if (!versions) {
|
||||
Log.warn('Could not fetch the list of Expo SDK versions. Using the latest SDK.');
|
||||
}
|
||||
return resolveTemplateForTag(templateDistTags, packumentVersions, 'latest');
|
||||
}
|
||||
|
||||
const latest = availableWithTemplates[0];
|
||||
|
||||
// Match the prompt guard in prompts.ts: pin to the latest released SDK
|
||||
// instead of prompting when stdin is not interactive.
|
||||
if (!process.stdin.isTTY && !global.test) {
|
||||
Log.withInfo(`Using Expo SDK ${latest}`);
|
||||
return resolveTemplateForTag(templateDistTags, packumentVersions, `sdk-${latest}`);
|
||||
}
|
||||
|
||||
const selectedSdk = await promptForSdkVersionAsync({
|
||||
latest,
|
||||
expoGoCompatible: versions.expoGoCompatible,
|
||||
available: availableWithTemplates,
|
||||
});
|
||||
return resolveTemplateForTag(templateDistTags, packumentVersions, `sdk-${selectedSdk}`);
|
||||
}
|
||||
@@ -1,35 +1,137 @@
|
||||
import { getMockWorkflowRunWithJobsFragment } from '../../../__tests__/commands/utils';
|
||||
import { fetchRawLogsForCustomJobAsync } from '../fetchLogs';
|
||||
import { infoForActiveWorkflowRunAsync } from '../utils';
|
||||
import { WorkflowJobStatus } from '../../../graphql/generated';
|
||||
import { groupLogLinesIntoSteps, parseLogLines } from '../logs/parseLogs';
|
||||
import { WorkflowLogs, WorkflowRawLogLine } from '../types';
|
||||
import { formatActiveWorkflowRun, formatFailedWorkflowRun } from '../utils';
|
||||
|
||||
jest.mock('../fetchLogs');
|
||||
function jobWithLogs(
|
||||
logLines: WorkflowRawLogLine[],
|
||||
status: WorkflowJobStatus = WorkflowJobStatus.InProgress
|
||||
): {
|
||||
job: ReturnType<typeof getMockWorkflowRunWithJobsFragment>['jobs'][number];
|
||||
logs: WorkflowLogs;
|
||||
} {
|
||||
return {
|
||||
job: { ...getMockWorkflowRunWithJobsFragment().jobs[0], status },
|
||||
logs: groupLogLinesIntoSteps(logLines),
|
||||
};
|
||||
}
|
||||
|
||||
describe('workflow utils', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
function stepLines(buildStepId: string, count: number): WorkflowRawLogLine[] {
|
||||
return Array.from({ length: count }, (_, index) => ({ buildStepId, msg: `line${index}` }));
|
||||
}
|
||||
|
||||
test('shows the display name for the current step while keying logs by step id', async () => {
|
||||
const workflowRun = getMockWorkflowRunWithJobsFragment();
|
||||
workflowRun.jobs = workflowRun.jobs.map(job => ({
|
||||
...job,
|
||||
status: WorkflowJobStatus.InProgress,
|
||||
}));
|
||||
|
||||
jest
|
||||
.mocked(fetchRawLogsForCustomJobAsync)
|
||||
.mockResolvedValue(
|
||||
[
|
||||
'{"buildStepId":"step-id-1","buildStepDisplayName":"Install dependencies","time":"2022-01-01T00:00:00.000Z","msg":"npm ci"}',
|
||||
'{"buildStepId":"step-id-1","buildStepDisplayName":"Install dependencies","marker":"end-step","result":"success","time":"2022-01-01T00:00:01.000Z","msg":"done"}',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const output = await infoForActiveWorkflowRunAsync({} as any, workflowRun);
|
||||
describe(formatActiveWorkflowRun, () => {
|
||||
test('shows the display name for the current step while keying logs by step id', () => {
|
||||
const output = formatActiveWorkflowRun([
|
||||
jobWithLogs(
|
||||
parseLogLines(
|
||||
[
|
||||
'{"buildStepId":"step-id-1","buildStepDisplayName":"Install dependencies","time":"2022-01-01T00:00:00.000Z","msg":"npm ci"}',
|
||||
'{"buildStepId":"step-id-1","buildStepDisplayName":"Install dependencies","marker":"end-step","result":"success","time":"2022-01-01T00:00:01.000Z","msg":"done"}',
|
||||
].join('\n')
|
||||
).logLines
|
||||
),
|
||||
]);
|
||||
|
||||
expect(output).toContain('Current step');
|
||||
expect(output).toContain('Install dependencies');
|
||||
expect(output).not.toContain('step-id-1');
|
||||
});
|
||||
|
||||
test('shows exactly maxLogLines trailing lines of the current step', () => {
|
||||
const output = formatActiveWorkflowRun([jobWithLogs(stepLines('step-id-1', 10))], 5);
|
||||
|
||||
expect(output).toContain('line5');
|
||||
expect(output).toContain('line9');
|
||||
expect(output).not.toContain('line4');
|
||||
});
|
||||
|
||||
test('keeps five trailing lines by default', () => {
|
||||
const output = formatActiveWorkflowRun([jobWithLogs(stepLines('step-id-1', 10))]);
|
||||
|
||||
expect(output).toContain('line5');
|
||||
expect(output).toContain('line9');
|
||||
expect(output).not.toContain('line4');
|
||||
});
|
||||
|
||||
test('reports the last step as the current one', () => {
|
||||
const output = formatActiveWorkflowRun([
|
||||
jobWithLogs([
|
||||
{ buildStepId: 'install', buildStepDisplayName: 'Install', msg: 'installing' },
|
||||
{ buildStepId: 'build', buildStepDisplayName: 'Build', msg: 'compiling' },
|
||||
]),
|
||||
]);
|
||||
|
||||
expect(output).toContain('Build');
|
||||
expect(output).not.toContain('Install');
|
||||
});
|
||||
|
||||
test('passes over a trailing skipped step', () => {
|
||||
const output = formatActiveWorkflowRun([
|
||||
jobWithLogs([
|
||||
{ buildStepId: 'install', buildStepDisplayName: 'Install', msg: 'installing' },
|
||||
{
|
||||
buildStepId: 'build',
|
||||
buildStepDisplayName: 'Build',
|
||||
msg: 'skipping',
|
||||
marker: 'end-step',
|
||||
result: 'skipped',
|
||||
},
|
||||
]),
|
||||
]);
|
||||
|
||||
expect(output).toContain('Install');
|
||||
expect(output).not.toContain('Build');
|
||||
});
|
||||
|
||||
test('reports a step that has not ended yet', () => {
|
||||
const output = formatActiveWorkflowRun([
|
||||
jobWithLogs([
|
||||
{
|
||||
buildStepId: 'install',
|
||||
buildStepDisplayName: 'Install',
|
||||
msg: 'installed',
|
||||
marker: 'end-step',
|
||||
result: 'success',
|
||||
},
|
||||
{ buildStepId: 'build', buildStepDisplayName: 'Build', msg: 'compiling' },
|
||||
]),
|
||||
]);
|
||||
|
||||
expect(output).toContain('Build');
|
||||
});
|
||||
|
||||
test('omits the current step when the job has no steps yet', () => {
|
||||
const output = formatActiveWorkflowRun([jobWithLogs([])]);
|
||||
|
||||
expect(output).not.toContain('Current step');
|
||||
expect(output).not.toContain('Current logs');
|
||||
});
|
||||
|
||||
test('omits the current step for a job that is not in progress', () => {
|
||||
const output = formatActiveWorkflowRun([
|
||||
jobWithLogs(stepLines('step-id-1', 3), WorkflowJobStatus.Success),
|
||||
]);
|
||||
|
||||
expect(output).not.toContain('Current step');
|
||||
expect(output).not.toContain('line0');
|
||||
});
|
||||
});
|
||||
|
||||
describe(formatFailedWorkflowRun, () => {
|
||||
test('prints every line of the failed step when there is no limit', () => {
|
||||
const output = formatFailedWorkflowRun([
|
||||
jobWithLogs(
|
||||
[
|
||||
...stepLines('build', 8),
|
||||
{ buildStepId: 'build', msg: 'it failed', marker: 'end-step', result: 'fail' },
|
||||
],
|
||||
WorkflowJobStatus.Failure
|
||||
),
|
||||
]);
|
||||
|
||||
expect(output).toContain('line0');
|
||||
expect(output).toContain('line7');
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { WorkflowJobResult } from './types';
|
||||
import { BuildQuery } from '../../graphql/queries/BuildQuery';
|
||||
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
|
||||
|
||||
// This function is in a separate module for testing purposes
|
||||
|
||||
export async function fetchRawLogsForCustomJobAsync(
|
||||
job: WorkflowJobResult
|
||||
): Promise<string | null> {
|
||||
const firstLogFileUrl = job.turtleJobRun?.logFileUrls?.[0];
|
||||
if (!firstLogFileUrl) {
|
||||
return null;
|
||||
}
|
||||
const response = await fetch(firstLogFileUrl, {
|
||||
method: 'GET',
|
||||
});
|
||||
const rawLogs = await response.text();
|
||||
return rawLogs;
|
||||
}
|
||||
|
||||
export async function fetchRawLogsForBuildJobAsync(
|
||||
state: { graphqlClient: ExpoGraphqlClient },
|
||||
job: WorkflowJobResult
|
||||
): Promise<string | null> {
|
||||
// Prefer turtleJobRun logs, which contain JSONL-formatted step logs
|
||||
// for both built-in and custom build steps
|
||||
const turtleLogFileUrl = job.turtleJobRun?.logFileUrls?.[0];
|
||||
if (turtleLogFileUrl) {
|
||||
const response = await fetch(turtleLogFileUrl, {
|
||||
method: 'GET',
|
||||
});
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
// Fall back to build logFiles
|
||||
const buildId = job.outputs?.build_id;
|
||||
if (!buildId) {
|
||||
return null;
|
||||
}
|
||||
const buildFragment = await BuildQuery.byIdAsync(state.graphqlClient, buildId, {
|
||||
useCache: false,
|
||||
});
|
||||
const firstLogFileUrl = buildFragment.logFiles?.[0];
|
||||
if (!firstLogFileUrl) {
|
||||
return null;
|
||||
}
|
||||
const response = await fetch(firstLogFileUrl, {
|
||||
method: 'GET',
|
||||
});
|
||||
return await response.text();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getMockWorkflowCustomJobFragment } from '../../../../__tests__/commands/utils';
|
||||
import { BuildQuery } from '../../../../graphql/queries/BuildQuery';
|
||||
import { ExpoGraphqlClient } from '../../../context/contextUtils/createGraphqlClient';
|
||||
import { fetchRawLogsForJobAsync } from '../fetchLogs';
|
||||
|
||||
jest.mock('../../../../graphql/queries/BuildQuery');
|
||||
|
||||
describe(fetchRawLogsForJobAsync, () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reads a custom job from its turtle job run log file', async () => {
|
||||
const rawLogs = '{"logId":"1","buildStepId":"install","msg":"from the turtle job run"}';
|
||||
const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(rawLogs));
|
||||
|
||||
await expect(
|
||||
fetchRawLogsForJobAsync(
|
||||
{ graphqlClient: {} as ExpoGraphqlClient },
|
||||
getMockWorkflowCustomJobFragment()
|
||||
)
|
||||
).resolves.toBe(rawLogs);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith('https://example.com/log1');
|
||||
expect(BuildQuery.byIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { groupLogLinesIntoSteps, mergeLogLines, parseLogLines } from '../parseLogs';
|
||||
import { WorkflowRawLogLine } from '../../types';
|
||||
|
||||
function logLine(overrides: Partial<WorkflowRawLogLine> = {}): WorkflowRawLogLine {
|
||||
return { msg: 'a message', ...overrides };
|
||||
}
|
||||
|
||||
describe(parseLogLines, () => {
|
||||
it('parses a JSONL log file', () => {
|
||||
const { logLines, errors } = parseLogLines(
|
||||
[
|
||||
'{"logId":"1","buildStepId":"install","msg":"npm ci"}',
|
||||
'{"logId":"2","buildStepId":"install","msg":"done"}',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(logLines).toEqual([
|
||||
{ logId: '1', buildStepId: 'install', msg: 'npm ci' },
|
||||
{ logId: '2', buildStepId: 'install', msg: 'done' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips blank lines, including a trailing newline', () => {
|
||||
const { logLines, errors } = parseLogLines('{"logId":"1","msg":"one"}\n\n');
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(logLines).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports malformed lines as errors while keeping the parsable ones', () => {
|
||||
const { logLines, errors } = parseLogLines(
|
||||
['{"logId":"1","msg":"one"}', 'this is not json', '{"logId":"2","msg":"two"}'].join('\n')
|
||||
);
|
||||
|
||||
expect(logLines.map(line => line.msg)).toEqual(['one', 'two']);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it('parses a line that carries no message', () => {
|
||||
const { logLines, errors } = parseLogLines('{"logId":"1"}');
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(logLines).toEqual([{ logId: '1' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(mergeLogLines, () => {
|
||||
it('keeps the rightmost line for a repeated logId', () => {
|
||||
const fileLogLines = [
|
||||
logLine({ logId: '1', msg: 'from the file' }),
|
||||
logLine({ logId: '2', msg: 'from the file' }),
|
||||
];
|
||||
const realtimeLogLines = [
|
||||
logLine({ logId: '2', msg: 'from realtime' }),
|
||||
logLine({ logId: '3', msg: 'from realtime' }),
|
||||
];
|
||||
|
||||
expect(mergeLogLines(fileLogLines, realtimeLogLines)).toEqual([
|
||||
{ logId: '1', msg: 'from the file' },
|
||||
{ logId: '2', msg: 'from realtime' },
|
||||
{ logId: '3', msg: 'from realtime' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps identical messages that have different logIds', () => {
|
||||
const merged = mergeLogLines(
|
||||
[logLine({ logId: '1', msg: 'Repeated text' })],
|
||||
[logLine({ logId: '2', msg: 'Repeated text' })]
|
||||
);
|
||||
|
||||
expect(merged).toEqual([
|
||||
{ logId: '1', msg: 'Repeated text' },
|
||||
{ logId: '2', msg: 'Repeated text' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps identical lines without logId', () => {
|
||||
const merged = mergeLogLines(
|
||||
[logLine({ msg: 'same' }), logLine({ msg: 'same' })],
|
||||
[logLine({ msg: 'same' })]
|
||||
);
|
||||
|
||||
expect(merged).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('deduplicates within a single group', () => {
|
||||
const merged = mergeLogLines([logLine({ logId: '1' }), logLine({ logId: '1' })]);
|
||||
|
||||
expect(merged).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe(groupLogLinesIntoSteps, () => {
|
||||
it('groups lines by step id and labels the step with its display name', () => {
|
||||
const logs = groupLogLinesIntoSteps([
|
||||
logLine({ buildStepId: 'step-id-1', msg: 'npm ci' }),
|
||||
logLine({
|
||||
buildStepId: 'step-id-1',
|
||||
buildStepDisplayName: 'Install dependencies',
|
||||
msg: 'ok',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(Array.from(logs.keys())).toEqual(['step-id-1']);
|
||||
expect(logs.get('step-id-1')).toEqual({
|
||||
key: 'step-id-1',
|
||||
label: 'Install dependencies',
|
||||
logLines: [
|
||||
{ time: undefined, msg: 'npm ci', result: undefined, marker: undefined, err: undefined },
|
||||
{ time: undefined, msg: 'ok', result: undefined, marker: undefined, err: undefined },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps steps in the order they first appear', () => {
|
||||
const logs = groupLogLinesIntoSteps([
|
||||
logLine({ buildStepId: 'first' }),
|
||||
logLine({ buildStepId: 'second' }),
|
||||
logLine({ buildStepId: 'first' }),
|
||||
logLine({ buildStepId: 'third' }),
|
||||
]);
|
||||
|
||||
expect(Array.from(logs.keys())).toEqual(['first', 'second', 'third']);
|
||||
});
|
||||
|
||||
it('falls back to the display name, then the phase, when there is no step id', () => {
|
||||
const logs = groupLogLinesIntoSteps([
|
||||
logLine({ buildStepDisplayName: 'Run fastlane' }),
|
||||
logLine({ phase: 'PREPARE_CREDENTIALS' }),
|
||||
]);
|
||||
|
||||
expect(Array.from(logs.keys())).toEqual(['Run fastlane', 'PREPARE_CREDENTIALS']);
|
||||
expect(logs.get('PREPARE_CREDENTIALS')?.label).toBe('PREPARE_CREDENTIALS');
|
||||
});
|
||||
|
||||
it('drops lines that belong to no step', () => {
|
||||
const logs = groupLogLinesIntoSteps([logLine({ msg: 'a line with no step' })]);
|
||||
|
||||
expect(logs.size).toBe(0);
|
||||
});
|
||||
|
||||
it('drops lines that carry no message, keeping the rest of the step', () => {
|
||||
const logs = groupLogLinesIntoSteps([
|
||||
logLine({ buildStepId: 'install', msg: undefined, marker: 'start-step' }),
|
||||
logLine({ buildStepId: 'install', msg: 'npm ci' }),
|
||||
]);
|
||||
|
||||
expect(logs.get('install')?.logLines).toEqual([
|
||||
{ time: undefined, msg: 'npm ci', result: undefined, marker: undefined, err: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates no step for a line that carries no message', () => {
|
||||
const logs = groupLogLinesIntoSteps([
|
||||
logLine({ buildStepId: 'install', msg: undefined, marker: 'end-step', result: 'success' }),
|
||||
]);
|
||||
|
||||
expect(logs.size).toBe(0);
|
||||
});
|
||||
|
||||
it('records the result of the first end marker and ignores a later one', () => {
|
||||
const logs = groupLogLinesIntoSteps([
|
||||
logLine({ buildStepId: 'install', msg: 'skipping', marker: 'end-step', result: 'skipped' }),
|
||||
logLine({ buildStepId: 'install', msg: 'retried', marker: 'end-step', result: 'success' }),
|
||||
]);
|
||||
|
||||
expect(logs.get('install')?.result).toBe('skipped');
|
||||
});
|
||||
|
||||
it('leaves the result undefined for a step that has not ended', () => {
|
||||
const logs = groupLogLinesIntoSteps([logLine({ buildStepId: 'install', msg: 'npm ci' })]);
|
||||
|
||||
expect(logs.get('install')?.result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('records an end marker that carries no result and does not let a later one replace it', () => {
|
||||
const logs = groupLogLinesIntoSteps([
|
||||
logLine({ buildStepId: 'install', msg: 'ended', marker: 'END_PHASE' }),
|
||||
logLine({ buildStepId: 'install', msg: 'retried', marker: 'END_PHASE', result: 'success' }),
|
||||
]);
|
||||
|
||||
expect(logs.get('install')?.result).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
import { getMockWorkflowRunWithJobsFragment } from '../../../../__tests__/commands/utils';
|
||||
import { RealtimeLogsTargetType, WorkflowJobStatus } from '../../../../graphql/generated';
|
||||
import { RealtimeLogsClient } from '../../../../utils/centrifuge';
|
||||
import { fetchAndParseLogsFromJobAsync } from '../parseLogs';
|
||||
import { WorkflowJobLogsState, WorkflowRunLogsWatcher, realtimeLogsTargetForJob } from '../watcher';
|
||||
import { WorkflowJobResult, WorkflowRawLogLine } from '../../types';
|
||||
|
||||
jest.mock('../parseLogs', () => ({
|
||||
...jest.requireActual('../parseLogs'),
|
||||
fetchAndParseLogsFromJobAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
function createFakeClient(): {
|
||||
client: RealtimeLogsClient;
|
||||
publish: (data: unknown) => void;
|
||||
subscribeCalls: unknown[];
|
||||
closeCount: () => number;
|
||||
} {
|
||||
const listeners: ((data: unknown) => void)[] = [];
|
||||
const subscribeCalls: unknown[] = [];
|
||||
let closed = 0;
|
||||
return {
|
||||
subscribeCalls,
|
||||
closeCount: () => closed,
|
||||
publish: data => {
|
||||
listeners.forEach(listener => {
|
||||
listener(data);
|
||||
});
|
||||
},
|
||||
client: {
|
||||
subscribeAsync: async (args, onPublication) => {
|
||||
subscribeCalls.push(args);
|
||||
listeners.push(onPublication);
|
||||
return { close: () => closed++ };
|
||||
},
|
||||
close: () => closed++,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function inProgressJob(): WorkflowJobResult {
|
||||
const job = getMockWorkflowRunWithJobsFragment().jobs[0];
|
||||
return { ...job, status: WorkflowJobStatus.InProgress };
|
||||
}
|
||||
|
||||
describe(realtimeLogsTargetForJob, () => {
|
||||
it('targets the turtle job run when there is one', () => {
|
||||
const job = { ...inProgressJob(), turtleJobRun: { id: 'job-run-id' } } as WorkflowJobResult;
|
||||
|
||||
expect(realtimeLogsTargetForJob(job)).toEqual({
|
||||
type: RealtimeLogsTargetType.JobRun,
|
||||
id: 'job-run-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the build when there is no turtle job run', () => {
|
||||
const job = {
|
||||
...inProgressJob(),
|
||||
turtleJobRun: null,
|
||||
turtleBuild: { id: 'build-id' },
|
||||
} as WorkflowJobResult;
|
||||
|
||||
expect(realtimeLogsTargetForJob(job)).toEqual({
|
||||
type: RealtimeLogsTargetType.Build,
|
||||
id: 'build-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('has no target when neither is present', () => {
|
||||
const job = {
|
||||
...inProgressJob(),
|
||||
turtleJobRun: null,
|
||||
turtleBuild: null,
|
||||
outputs: null,
|
||||
} as WorkflowJobResult;
|
||||
|
||||
expect(realtimeLogsTargetForJob(job)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(WorkflowRunLogsWatcher, () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.mocked(fetchAndParseLogsFromJobAsync)
|
||||
.mockResolvedValue([{ logId: '1', buildStepId: 'install', msg: 'from the file' }]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('subscribes once per in-progress job', async () => {
|
||||
const fake = createFakeClient();
|
||||
const watcher = new WorkflowRunLogsWatcher(
|
||||
{} as any,
|
||||
() => fake.client,
|
||||
() => {}
|
||||
);
|
||||
const job = inProgressJob();
|
||||
|
||||
await watcher.syncJobsAsync([job]);
|
||||
await watcher.syncJobsAsync([job]);
|
||||
|
||||
expect(fake.subscribeCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports realtime logs as they arrive', async () => {
|
||||
const fake = createFakeClient();
|
||||
const onRealtimeLogs = jest.fn();
|
||||
const watcher = new WorkflowRunLogsWatcher({} as any, () => fake.client, onRealtimeLogs);
|
||||
await watcher.syncJobsAsync([inProgressJob()]);
|
||||
|
||||
fake.publish([{ logId: '2', buildStepId: 'install', msg: 'pushed' }]);
|
||||
|
||||
expect(onRealtimeLogs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not report a publication that carries no usable log lines', async () => {
|
||||
const fake = createFakeClient();
|
||||
const onRealtimeLogs = jest.fn();
|
||||
const watcher = new WorkflowRunLogsWatcher({} as any, () => fake.client, onRealtimeLogs);
|
||||
await watcher.syncJobsAsync([inProgressJob()]);
|
||||
|
||||
fake.publish([{ msg: 'no logId' }]);
|
||||
|
||||
expect(onRealtimeLogs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still fetches logs when the realtime logs client is unavailable', async () => {
|
||||
const watcher = new WorkflowRunLogsWatcher(
|
||||
{} as any,
|
||||
() => null,
|
||||
() => {}
|
||||
);
|
||||
|
||||
await watcher.syncJobsAsync([inProgressJob()]);
|
||||
|
||||
expect(fetchAndParseLogsFromJobAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fetches logs on every sync while in progress, and stops once the job completes', async () => {
|
||||
const fake = createFakeClient();
|
||||
const watcher = new WorkflowRunLogsWatcher(
|
||||
{} as any,
|
||||
() => fake.client,
|
||||
() => {}
|
||||
);
|
||||
const job = inProgressJob();
|
||||
|
||||
await watcher.syncJobsAsync([job]);
|
||||
await watcher.syncJobsAsync([job]);
|
||||
expect(fetchAndParseLogsFromJobAsync).toHaveBeenCalledTimes(2);
|
||||
|
||||
await watcher.syncJobsAsync([{ ...job, status: WorkflowJobStatus.Success }]);
|
||||
expect(fetchAndParseLogsFromJobAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not fetch logs for a job that has not started', async () => {
|
||||
const fake = createFakeClient();
|
||||
const watcher = new WorkflowRunLogsWatcher(
|
||||
{} as any,
|
||||
() => fake.client,
|
||||
() => {}
|
||||
);
|
||||
|
||||
await watcher.syncJobsAsync([{ ...inProgressJob(), status: WorkflowJobStatus.New }]);
|
||||
|
||||
expect(fetchAndParseLogsFromJobAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes the subscription when a job leaves in-progress', async () => {
|
||||
const fake = createFakeClient();
|
||||
const watcher = new WorkflowRunLogsWatcher(
|
||||
{} as any,
|
||||
() => fake.client,
|
||||
() => {}
|
||||
);
|
||||
const job = inProgressJob();
|
||||
|
||||
await watcher.syncJobsAsync([job]);
|
||||
expect(fake.closeCount()).toBe(0);
|
||||
|
||||
await watcher.syncJobsAsync([{ ...job, status: WorkflowJobStatus.Failure }]);
|
||||
expect(fake.closeCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('closes the client and its subscriptions', async () => {
|
||||
const fake = createFakeClient();
|
||||
const watcher = new WorkflowRunLogsWatcher(
|
||||
{} as any,
|
||||
() => fake.client,
|
||||
() => {}
|
||||
);
|
||||
|
||||
await watcher.syncJobsAsync([inProgressJob()]);
|
||||
watcher.close();
|
||||
|
||||
expect(fake.closeCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
function fileLine(logId: string, msg: string, buildStepId = 'install'): WorkflowRawLogLine {
|
||||
return { logId, buildStepId, msg };
|
||||
}
|
||||
|
||||
function stepMessages(logsState: WorkflowJobLogsState): string[] {
|
||||
return Array.from(logsState.getLogs().values()).flatMap(group =>
|
||||
group.logLines.map(logLine => logLine.msg)
|
||||
);
|
||||
}
|
||||
|
||||
describe(WorkflowJobLogsState, () => {
|
||||
it('hides realtime lines until the file logs reach them', () => {
|
||||
const logsState = new WorkflowJobLogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('5', 'pushed')]);
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['first']);
|
||||
});
|
||||
|
||||
it('shows realtime lines once a published logId also appears in the file', () => {
|
||||
const logsState = new WorkflowJobLogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('3', 'newer')]);
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['first', 'pushed', 'newer']);
|
||||
});
|
||||
|
||||
it('shows realtime logs when a publication repeats a logId already in the file', () => {
|
||||
const logsState = new WorkflowJobLogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
|
||||
});
|
||||
|
||||
it('shows buffered realtime lines once the job is completed', () => {
|
||||
const logsState = new WorkflowJobLogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('9', 'pushed')]);
|
||||
|
||||
logsState.markCompleted();
|
||||
logsState.markCompleted();
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
|
||||
});
|
||||
|
||||
it('folds later publications once completion showed them', () => {
|
||||
const logsState = new WorkflowJobLogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('9', 'buffered')]);
|
||||
logsState.markCompleted();
|
||||
|
||||
logsState.ingestRealtimeLogLines([fileLine('10', 'pushed')]);
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['first', 'buffered', 'pushed']);
|
||||
});
|
||||
|
||||
it('replaces the file snapshot instead of accumulating it', () => {
|
||||
const logsState = new WorkflowJobLogsState();
|
||||
const keyless: WorkflowRawLogLine = { buildStepId: 'install', msg: 'submission log' };
|
||||
|
||||
logsState.ingestFileLogLines([keyless]);
|
||||
logsState.ingestFileLogLines([keyless]);
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['submission log']);
|
||||
});
|
||||
|
||||
it('drops a buffered realtime line once it lands in the file', () => {
|
||||
const logsState = new WorkflowJobLogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
|
||||
});
|
||||
|
||||
it('ignores publications that are not arrays of log lines', () => {
|
||||
const logsState = new WorkflowJobLogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
|
||||
expect(logsState.ingestRealtimeLogLines({ logId: '2', msg: 'not an array' })).toBe(false);
|
||||
expect(logsState.ingestRealtimeLogLines(['a string', 42, null])).toBe(false);
|
||||
expect(logsState.ingestRealtimeLogLines([{ msg: 'no logId' }])).toBe(false);
|
||||
expect(stepMessages(logsState)).toEqual(['first']);
|
||||
});
|
||||
|
||||
it('deduplicates a line republished on the same channel', () => {
|
||||
const logsState = new WorkflowJobLogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
|
||||
});
|
||||
|
||||
it('deduplicates a line republished later', () => {
|
||||
const logsState = new WorkflowJobLogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('3', 'newer')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['first', 'pushed', 'newer']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { WorkflowJobResult } from '../types';
|
||||
import { BuildQuery } from '../../../graphql/queries/BuildQuery';
|
||||
import { ExpoGraphqlClient } from '../../context/contextUtils/createGraphqlClient';
|
||||
|
||||
export async function fetchRawLogsForJobAsync(
|
||||
state: { graphqlClient: ExpoGraphqlClient },
|
||||
job: WorkflowJobResult
|
||||
): Promise<string | null> {
|
||||
const turtleLogFileUrl = job.turtleJobRun?.logFileUrls?.[0];
|
||||
if (turtleLogFileUrl) {
|
||||
const response = await fetch(turtleLogFileUrl);
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
const buildId = job.outputs?.build_id;
|
||||
if (!buildId) {
|
||||
return null;
|
||||
}
|
||||
const buildFragment = await BuildQuery.byIdAsync(state.graphqlClient, buildId, {
|
||||
useCache: false,
|
||||
});
|
||||
const firstLogFileUrl = buildFragment.logFiles?.[0];
|
||||
if (!firstLogFileUrl) {
|
||||
return null;
|
||||
}
|
||||
const response = await fetch(firstLogFileUrl);
|
||||
return await response.text();
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { fetchRawLogsForJobAsync } from './fetchLogs';
|
||||
import { WorkflowJobResult, WorkflowLogs, WorkflowRawLogLine } from '../types';
|
||||
import Log from '../../../log';
|
||||
import uniqBy from '../../../utils/expodash/uniqBy';
|
||||
import { ExpoGraphqlClient } from '../../context/contextUtils/createGraphqlClient';
|
||||
|
||||
export function parseLogLines(rawLogs: string): {
|
||||
logLines: WorkflowRawLogLine[];
|
||||
errors: Error[];
|
||||
} {
|
||||
const logLines: WorkflowRawLogLine[] = [];
|
||||
const errors: Error[] = [];
|
||||
|
||||
for (const rawLogLine of rawLogs.split('\n')) {
|
||||
if (!rawLogLine) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
logLines.push(JSON.parse(rawLogLine));
|
||||
} catch (err) {
|
||||
errors.push(err as Error);
|
||||
}
|
||||
}
|
||||
|
||||
return { logLines, errors };
|
||||
}
|
||||
|
||||
export function mergeLogLines<T extends WorkflowRawLogLine>(...logLineGroups: T[][]): T[] {
|
||||
return uniqBy(logLineGroups.flat().reverse(), logLine => logLine.logId ?? Symbol()).reverse();
|
||||
}
|
||||
|
||||
export function groupLogLinesIntoSteps(
|
||||
logLines: WorkflowRawLogLine[],
|
||||
accumulator: WorkflowLogs = new Map()
|
||||
): WorkflowLogs {
|
||||
for (const logLine of logLines) {
|
||||
const { buildStepDisplayName, buildStepId, phase, time, msg, result, marker, err } = logLine;
|
||||
const stepKey = buildStepId ?? buildStepDisplayName ?? phase;
|
||||
const stepLabel = buildStepDisplayName ?? buildStepId ?? phase;
|
||||
if (!stepKey || !stepLabel || !msg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let logGroup = accumulator.get(stepKey);
|
||||
if (!logGroup) {
|
||||
logGroup = { key: stepKey, label: stepLabel, logLines: [] };
|
||||
accumulator.set(stepKey, logGroup);
|
||||
}
|
||||
if (buildStepDisplayName) {
|
||||
logGroup.label = buildStepDisplayName;
|
||||
}
|
||||
if (logGroup.result === undefined && (marker === 'end-step' || marker === 'END_PHASE')) {
|
||||
logGroup.result = result ?? '';
|
||||
}
|
||||
logGroup.logLines.push({ time, msg, result, marker, err });
|
||||
}
|
||||
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
export async function fetchAndParseLogsFromJobAsync(
|
||||
state: { graphqlClient: ExpoGraphqlClient },
|
||||
job: WorkflowJobResult
|
||||
): Promise<WorkflowRawLogLine[] | null> {
|
||||
const rawLogs = await fetchRawLogsForJobAsync(state, job);
|
||||
if (!rawLogs) {
|
||||
return null;
|
||||
}
|
||||
Log.debug(`rawLogs = ${JSON.stringify(rawLogs, null, 2)}`);
|
||||
const { logLines, errors } = parseLogLines(rawLogs);
|
||||
for (const error of errors) {
|
||||
Log.debug(`Failed to parse a log line: ${error.message}`);
|
||||
}
|
||||
return logLines;
|
||||
}
|
||||
|
||||
export async function fetchAndProcessLogsFromJobAsync(
|
||||
state: { graphqlClient: ExpoGraphqlClient },
|
||||
job: WorkflowJobResult
|
||||
): Promise<WorkflowLogs | null> {
|
||||
const logLines = await fetchAndParseLogsFromJobAsync(state, job);
|
||||
return logLines && groupLogLinesIntoSteps(logLines);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { fetchAndParseLogsFromJobAsync, groupLogLinesIntoSteps, mergeLogLines } from './parseLogs';
|
||||
import { WorkflowJobResult, WorkflowLogs, WorkflowRawLogLine } from '../types';
|
||||
import {
|
||||
RealtimeLogsTargetInput,
|
||||
RealtimeLogsTargetType,
|
||||
WorkflowJobStatus,
|
||||
} from '../../../graphql/generated';
|
||||
import Log from '../../../log';
|
||||
import { RealtimeLogsClient, RealtimeLogsSubscription } from '../../../utils/centrifuge';
|
||||
import { ExpoGraphqlClient } from '../../context/contextUtils/createGraphqlClient';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
type RealtimeLogLine = WorkflowRawLogLine & { logId: string };
|
||||
|
||||
function isRealtimeLogLine(entry: unknown): entry is RealtimeLogLine {
|
||||
return (
|
||||
typeof entry === 'object' &&
|
||||
entry !== null &&
|
||||
typeof (entry as Partial<WorkflowRawLogLine>).logId === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export class WorkflowJobLogsState {
|
||||
private fileLogIds = new Set<string>();
|
||||
private realtimeLogLines: RealtimeLogLine[] = [];
|
||||
private realtimeLogIds = new Set<string>();
|
||||
private haveFileLogsCaughtUp = false;
|
||||
private realtimeLogsRevealed = false;
|
||||
private groupedLogs: WorkflowLogs = new Map();
|
||||
|
||||
public ingestFileLogLines(logLines: WorkflowRawLogLine[]): void {
|
||||
this.fileLogIds = new Set(logLines.flatMap(logLine => (logLine.logId ? [logLine.logId] : [])));
|
||||
|
||||
if (
|
||||
!this.haveFileLogsCaughtUp &&
|
||||
this.realtimeLogLines.some(logLine => this.fileLogIds.has(logLine.logId))
|
||||
) {
|
||||
this.haveFileLogsCaughtUp = true;
|
||||
}
|
||||
this.realtimeLogLines = this.realtimeLogLines.filter(
|
||||
logLine => !this.fileLogIds.has(logLine.logId)
|
||||
);
|
||||
this.realtimeLogIds = new Set(this.realtimeLogLines.map(logLine => logLine.logId));
|
||||
|
||||
if (this.haveFileLogsCaughtUp) {
|
||||
this.realtimeLogsRevealed = true;
|
||||
}
|
||||
this.groupedLogs = groupLogLinesIntoSteps(
|
||||
mergeLogLines(logLines, this.realtimeLogsRevealed ? this.realtimeLogLines : [])
|
||||
);
|
||||
}
|
||||
|
||||
public ingestRealtimeLogLines(data: unknown): boolean {
|
||||
if (!Array.isArray(data)) {
|
||||
return false;
|
||||
}
|
||||
const publishedLogLines = data.filter(isRealtimeLogLine);
|
||||
if (publishedLogLines.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!this.haveFileLogsCaughtUp &&
|
||||
publishedLogLines.some(logLine => this.fileLogIds.has(logLine.logId))
|
||||
) {
|
||||
this.haveFileLogsCaughtUp = true;
|
||||
}
|
||||
|
||||
const newLogLines = publishedLogLines.filter(
|
||||
logLine => !this.fileLogIds.has(logLine.logId) && !this.realtimeLogIds.has(logLine.logId)
|
||||
);
|
||||
for (const logLine of newLogLines) {
|
||||
this.realtimeLogLines.push(logLine);
|
||||
this.realtimeLogIds.add(logLine.logId);
|
||||
}
|
||||
|
||||
if (!this.realtimeLogsRevealed) {
|
||||
if (this.haveFileLogsCaughtUp) {
|
||||
this.revealRealtimeLogs();
|
||||
}
|
||||
} else if (newLogLines.length > 0) {
|
||||
groupLogLinesIntoSteps(newLogLines, this.groupedLogs);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public markCompleted(): void {
|
||||
this.revealRealtimeLogs();
|
||||
}
|
||||
|
||||
public getLogs(): WorkflowLogs {
|
||||
return this.groupedLogs;
|
||||
}
|
||||
|
||||
private revealRealtimeLogs(): void {
|
||||
if (this.realtimeLogsRevealed) {
|
||||
return;
|
||||
}
|
||||
this.realtimeLogsRevealed = true;
|
||||
groupLogLinesIntoSteps(this.realtimeLogLines, this.groupedLogs);
|
||||
}
|
||||
}
|
||||
|
||||
type TrackedJob = {
|
||||
logsState: WorkflowJobLogsState;
|
||||
subscription: RealtimeLogsSubscription | null;
|
||||
};
|
||||
|
||||
export function realtimeLogsTargetForJob(job: WorkflowJobResult): RealtimeLogsTargetInput | null {
|
||||
if (job.turtleJobRun?.id) {
|
||||
return { type: RealtimeLogsTargetType.JobRun, id: job.turtleJobRun.id };
|
||||
}
|
||||
const buildId = job.turtleBuild?.id ?? job.outputs?.build_id;
|
||||
if (buildId) {
|
||||
return { type: RealtimeLogsTargetType.Build, id: buildId };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class WorkflowRunLogsWatcher {
|
||||
private readonly trackedJobs = new Map<string, TrackedJob>();
|
||||
private realtimeLogsClient?: RealtimeLogsClient | null;
|
||||
|
||||
constructor(
|
||||
private readonly graphqlClient: ExpoGraphqlClient,
|
||||
private readonly createRealtimeLogsClient: () => RealtimeLogsClient | null,
|
||||
private readonly onRealtimeLogs: () => void
|
||||
) {}
|
||||
|
||||
public async syncJobsAsync(
|
||||
jobs: WorkflowJobResult[]
|
||||
): Promise<Map<string, WorkflowJobLogsState>> {
|
||||
await Promise.all(jobs.map(job => this.syncJobAsync(job)));
|
||||
return new Map(
|
||||
jobs.map(job => [
|
||||
job.id,
|
||||
nullthrows(
|
||||
this.trackedJobs.get(job.id),
|
||||
'job has to be in tracked jobs after it was synced'
|
||||
).logsState,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
for (const trackedJob of this.trackedJobs.values()) {
|
||||
trackedJob.subscription?.close();
|
||||
trackedJob.subscription = null;
|
||||
}
|
||||
this.realtimeLogsClient?.close();
|
||||
}
|
||||
|
||||
private async syncJobAsync(job: WorkflowJobResult): Promise<void> {
|
||||
let trackedJob = this.trackedJobs.get(job.id);
|
||||
if (!trackedJob) {
|
||||
trackedJob = { logsState: new WorkflowJobLogsState(), subscription: null };
|
||||
this.trackedJobs.set(job.id, trackedJob);
|
||||
}
|
||||
|
||||
const workflowInProgress = job.status === WorkflowJobStatus.InProgress;
|
||||
if (!workflowInProgress) {
|
||||
trackedJob.subscription?.close();
|
||||
trackedJob.subscription = null;
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
trackedJob.subscription ? Promise.resolve() : this.subscribeAsync(job, trackedJob),
|
||||
this.fetchLogsAsync(job, trackedJob),
|
||||
]);
|
||||
}
|
||||
|
||||
private async fetchLogsAsync(job: WorkflowJobResult, trackedJob: TrackedJob): Promise<void> {
|
||||
try {
|
||||
const logLines = await fetchAndParseLogsFromJobAsync(
|
||||
{ graphqlClient: this.graphqlClient },
|
||||
job
|
||||
);
|
||||
if (logLines) {
|
||||
trackedJob.logsState.ingestFileLogLines(logLines);
|
||||
}
|
||||
} catch (err: any) {
|
||||
Log.debug(`Failed to fetch logs for job ${job.id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private getRealtimeLogsClient(): RealtimeLogsClient | null {
|
||||
if (this.realtimeLogsClient === undefined) {
|
||||
this.realtimeLogsClient = this.createRealtimeLogsClient();
|
||||
}
|
||||
return this.realtimeLogsClient;
|
||||
}
|
||||
|
||||
private async subscribeAsync(job: WorkflowJobResult, trackedJob: TrackedJob): Promise<void> {
|
||||
const target = realtimeLogsTargetForJob(job);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const realtimeLogsClient = this.getRealtimeLogsClient();
|
||||
if (!realtimeLogsClient) {
|
||||
return;
|
||||
}
|
||||
trackedJob.subscription = await realtimeLogsClient.subscribeAsync({ target }, data => {
|
||||
if (trackedJob.logsState.ingestRealtimeLogLines(data)) {
|
||||
this.onRealtimeLogs();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Choice } from 'prompts';
|
||||
|
||||
import { WorkflowJobResult, WorkflowLogs } from './types';
|
||||
import { fetchAndProcessLogsFromJobAsync } from './logs/parseLogs';
|
||||
import {
|
||||
choiceFromWorkflowJob,
|
||||
choiceFromWorkflowRun,
|
||||
choicesFromWorkflowLogs,
|
||||
fetchAndProcessLogsFromJobAsync,
|
||||
processWorkflowRuns,
|
||||
} from './utils';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
|
||||
@@ -42,19 +42,26 @@ export type WorkflowRunWithJobsResult = WorkflowRunResult & {
|
||||
logs?: string;
|
||||
};
|
||||
|
||||
export type WorkflowLogLine = {
|
||||
time: string;
|
||||
msg: string;
|
||||
export type WorkflowRawLogLine = {
|
||||
logId?: string;
|
||||
buildStepId?: string;
|
||||
buildStepDisplayName?: string;
|
||||
phase?: string;
|
||||
time?: string;
|
||||
msg?: string;
|
||||
result?: string;
|
||||
marker?: string;
|
||||
err?: any;
|
||||
};
|
||||
|
||||
export type WorkflowLogs = Map<
|
||||
string,
|
||||
{
|
||||
key: string;
|
||||
label: string;
|
||||
logLines: WorkflowLogLine[];
|
||||
}
|
||||
>;
|
||||
export type WorkflowLogLine = Pick<WorkflowRawLogLine, 'time' | 'result' | 'marker' | 'err'> &
|
||||
Required<Pick<WorkflowRawLogLine, 'msg'>>;
|
||||
|
||||
export type StepLogs = {
|
||||
key: string;
|
||||
label: string;
|
||||
result?: string;
|
||||
logLines: WorkflowLogLine[];
|
||||
};
|
||||
|
||||
export type WorkflowLogs = Map<string, StepLogs>;
|
||||
@@ -1,7 +1,7 @@
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
import { fetchRawLogsForBuildJobAsync, fetchRawLogsForCustomJobAsync } from './fetchLogs';
|
||||
import { fetchAndProcessLogsFromJobAsync } from './logs/parseLogs';
|
||||
import {
|
||||
WorkflowJobResult,
|
||||
WorkflowLogLine,
|
||||
@@ -11,20 +11,22 @@ import {
|
||||
} from './types';
|
||||
import {
|
||||
WorkflowJobStatus,
|
||||
WorkflowJobType,
|
||||
WorkflowRunByIdQuery,
|
||||
WorkflowRunByIdWithJobsQuery,
|
||||
WorkflowRunFragment,
|
||||
WorkflowRunStatus,
|
||||
WorkflowRunTriggerEventType,
|
||||
} from '../../graphql/generated';
|
||||
import { WorkflowRunLogsWatcher } from './logs/watcher';
|
||||
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { ora, updateSpinnerText } from '../../ora';
|
||||
import { Choice } from '../../prompts';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import { createRealtimeLogsClient } from '../../utils/centrifuge';
|
||||
import { sleepAsync } from '../../utils/promise';
|
||||
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
export function computeTriggerInfoForWorkflowRun(run: WorkflowRunFragment): {
|
||||
triggerType: WorkflowTriggerType;
|
||||
@@ -85,19 +87,13 @@ export function choicesFromWorkflowLogs(
|
||||
logs: WorkflowLogs
|
||||
): (Choice & { name: string; status: string; logLines: WorkflowLogLine[] | undefined })[] {
|
||||
return Array.from(logs.values())
|
||||
.map(({ key, label, logLines }) => {
|
||||
const stepStatus =
|
||||
logLines?.filter(
|
||||
(line: WorkflowLogLine) => line.marker === 'end-step' || line.marker === 'END_PHASE'
|
||||
)[0]?.result ?? '';
|
||||
return {
|
||||
title: `${label} - ${stepStatus}`,
|
||||
name: label,
|
||||
status: stepStatus,
|
||||
value: key,
|
||||
logLines,
|
||||
};
|
||||
})
|
||||
.map(({ key, label, result, logLines }) => ({
|
||||
title: `${label} - ${result ?? ''}`,
|
||||
name: label,
|
||||
status: result ?? '',
|
||||
value: key,
|
||||
logLines,
|
||||
}))
|
||||
.filter(step => step.status !== 'skipped');
|
||||
}
|
||||
|
||||
@@ -121,52 +117,6 @@ export function processWorkflowRuns(runs: WorkflowRunFragment[]): WorkflowRunRes
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchAndProcessLogsFromJobAsync(
|
||||
state: { graphqlClient: ExpoGraphqlClient },
|
||||
job: WorkflowJobResult
|
||||
): Promise<WorkflowLogs | null> {
|
||||
let rawLogs: string | null;
|
||||
switch (job.type) {
|
||||
case WorkflowJobType.Build:
|
||||
case WorkflowJobType.Repack:
|
||||
rawLogs = await fetchRawLogsForBuildJobAsync(state, job);
|
||||
break;
|
||||
default:
|
||||
rawLogs = await fetchRawLogsForCustomJobAsync(job);
|
||||
break;
|
||||
}
|
||||
if (!rawLogs) {
|
||||
return null;
|
||||
}
|
||||
Log.debug(`rawLogs = ${JSON.stringify(rawLogs, null, 2)}`);
|
||||
const logs: WorkflowLogs = new Map();
|
||||
rawLogs.split('\n').forEach((line, index) => {
|
||||
Log.debug(`line ${index} = ${JSON.stringify(line, null, 2)}`);
|
||||
try {
|
||||
const parsedLine = JSON.parse(line);
|
||||
const { buildStepDisplayName, buildStepId, phase, time, msg, result, marker, err } =
|
||||
parsedLine;
|
||||
const stepKey = buildStepId ?? buildStepDisplayName ?? phase;
|
||||
const stepLabel = buildStepDisplayName ?? buildStepId ?? phase;
|
||||
if (stepKey && stepLabel) {
|
||||
if (!logs.has(stepKey)) {
|
||||
logs.set(stepKey, {
|
||||
key: stepKey,
|
||||
label: stepLabel,
|
||||
logLines: [],
|
||||
});
|
||||
}
|
||||
const logGroup = logs.get(stepKey)!;
|
||||
if (buildStepDisplayName) {
|
||||
logGroup.label = buildStepDisplayName;
|
||||
}
|
||||
logGroup.logLines.push({ time, msg, result, marker, err });
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
return logs;
|
||||
}
|
||||
|
||||
function descriptionForJobStatus(status: WorkflowJobStatus): string {
|
||||
switch (status) {
|
||||
case WorkflowJobStatus.New:
|
||||
@@ -188,82 +138,106 @@ function descriptionForJobStatus(status: WorkflowJobStatus): string {
|
||||
}
|
||||
}
|
||||
|
||||
export async function infoForActiveWorkflowRunAsync(
|
||||
function isJobCompleted(status: WorkflowJobStatus): boolean {
|
||||
switch (status) {
|
||||
case WorkflowJobStatus.Success:
|
||||
case WorkflowJobStatus.Failure:
|
||||
case WorkflowJobStatus.Canceled:
|
||||
case WorkflowJobStatus.Skipped:
|
||||
return true;
|
||||
case WorkflowJobStatus.New:
|
||||
case WorkflowJobStatus.InProgress:
|
||||
case WorkflowJobStatus.ActionRequired:
|
||||
case WorkflowJobStatus.PendingCancel:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
type WorkflowRunWithJobs = WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'];
|
||||
|
||||
type JobWithLogs = {
|
||||
job: WorkflowRunWithJobs['jobs'][number];
|
||||
logs: WorkflowLogs;
|
||||
};
|
||||
|
||||
function stepLogTail(
|
||||
step: { logLines?: WorkflowLogLine[] },
|
||||
maxLogLines: number // -1 means no limit
|
||||
): string[] {
|
||||
const logLines = step.logLines ?? [];
|
||||
const tail = maxLogLines === -1 ? logLines : logLines.slice(-maxLogLines);
|
||||
return tail.map(line => line.msg);
|
||||
}
|
||||
|
||||
export async function logsForFailedWorkflowRunAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
workflowRun: WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'],
|
||||
workflowRun: WorkflowRunWithJobs
|
||||
): Promise<JobWithLogs[]> {
|
||||
const jobs = workflowRun.jobs.filter(job => job.status === WorkflowJobStatus.Failure);
|
||||
return await Promise.all(
|
||||
jobs.map(async job => {
|
||||
const maybeLogs = await fetchAndProcessLogsFromJobAsync({ graphqlClient }, job);
|
||||
return { job, logs: maybeLogs ?? new Map() };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function formatActiveWorkflowRun(
|
||||
jobLogs: JobWithLogs[],
|
||||
maxLogLines: number = 5 // -1 means no limit
|
||||
): Promise<string> {
|
||||
const statusLines = [];
|
||||
): string {
|
||||
const statusValues = [];
|
||||
for (const job of workflowRun.jobs) {
|
||||
|
||||
for (const { job, logs } of jobLogs) {
|
||||
statusValues.push({ label: '', value: '' });
|
||||
statusValues.push({ label: ' Job', value: job.name });
|
||||
statusValues.push({ label: ' Status', value: descriptionForJobStatus(job.status) });
|
||||
if (job.status !== WorkflowJobStatus.InProgress) {
|
||||
continue;
|
||||
}
|
||||
const logs = await fetchAndProcessLogsFromJobAsync({ graphqlClient }, job);
|
||||
const steps = logs ? choicesFromWorkflowLogs(logs) : [];
|
||||
if (steps.length > 0) {
|
||||
const currentStep = steps[steps.length - 1];
|
||||
statusValues.push({ label: ' Current step', value: currentStep.name });
|
||||
if (currentStep?.logLines?.length) {
|
||||
statusValues.push({ label: ' Current logs', value: '' });
|
||||
const currentLogs =
|
||||
currentStep.logLines
|
||||
?.map(line => line.msg)
|
||||
.filter((_, index) => {
|
||||
if (maxLogLines === -1) {
|
||||
return true;
|
||||
}
|
||||
return index > (currentStep.logLines?.length ?? 0) - maxLogLines;
|
||||
}) ?? [];
|
||||
for (const log of currentLogs) {
|
||||
statusValues.push({ label: '', value: log });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
statusValues.push({ label: '', value: '' });
|
||||
statusLines.push(formatFields(statusValues));
|
||||
return statusLines.join('\n');
|
||||
}
|
||||
|
||||
export async function infoForFailedWorkflowRunAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
workflowRun: WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'],
|
||||
maxLogLines: number = -1 // -1 means no limit
|
||||
): Promise<string> {
|
||||
const statusLines = [];
|
||||
const statusValues = [];
|
||||
const logLinesToKeep = maxLogLines === -1 ? Infinity : maxLogLines;
|
||||
for (const job of workflowRun.jobs) {
|
||||
if (job.status !== WorkflowJobStatus.Failure) {
|
||||
const steps = choicesFromWorkflowLogs(logs);
|
||||
if (steps.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const logs = await fetchAndProcessLogsFromJobAsync({ graphqlClient }, job);
|
||||
const steps = logs ? choicesFromWorkflowLogs(logs) : [];
|
||||
statusValues.push({ label: '', value: '' });
|
||||
statusValues.push({ label: ' Failed job', value: job.name });
|
||||
if (steps.length > 0) {
|
||||
const failedStep = steps.find(step => step.status === 'fail' || step.status === 'failed');
|
||||
if (failedStep) {
|
||||
const logs = failedStep.logLines?.map(line => line.msg).slice(-logLinesToKeep) ?? [];
|
||||
statusValues.push({ label: ' Failed step', value: failedStep.name });
|
||||
statusValues.push({
|
||||
label: ' Logs for failed step',
|
||||
value: '',
|
||||
});
|
||||
for (const log of logs) {
|
||||
statusValues.push({ label: '', value: log });
|
||||
}
|
||||
const currentStep = steps[steps.length - 1];
|
||||
statusValues.push({ label: ' Current step', value: currentStep.name });
|
||||
if (currentStep.logLines?.length) {
|
||||
statusValues.push({ label: ' Current logs', value: '' });
|
||||
for (const log of stepLogTail(currentStep, maxLogLines)) {
|
||||
statusValues.push({ label: '', value: log });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
statusValues.push({ label: '', value: '' });
|
||||
statusLines.push(formatFields(statusValues));
|
||||
return statusLines.join('\n');
|
||||
return formatFields(statusValues);
|
||||
}
|
||||
|
||||
export function formatFailedWorkflowRun(
|
||||
jobLogs: JobWithLogs[],
|
||||
maxLogLines: number = -1 // -1 means no limit
|
||||
): string {
|
||||
const statusValues = [];
|
||||
|
||||
for (const { job, logs } of jobLogs) {
|
||||
statusValues.push({ label: '', value: '' });
|
||||
statusValues.push({ label: ' Failed job', value: job.name });
|
||||
const steps = choicesFromWorkflowLogs(logs);
|
||||
const failedStep = steps.find(step => step.status === 'fail' || step.status === 'failed');
|
||||
if (!failedStep) {
|
||||
continue;
|
||||
}
|
||||
statusValues.push({ label: ' Failed step', value: failedStep.name });
|
||||
statusValues.push({ label: ' Logs for failed step', value: '' });
|
||||
for (const log of stepLogTail(failedStep, maxLogLines)) {
|
||||
statusValues.push({ label: '', value: log });
|
||||
}
|
||||
}
|
||||
|
||||
statusValues.push({ label: '', value: '' });
|
||||
return formatFields(statusValues);
|
||||
}
|
||||
|
||||
export async function fileExistsAsync(filePath: string): Promise<boolean> {
|
||||
return await fs.promises
|
||||
.access(filePath, fs.constants.F_OK)
|
||||
@@ -312,65 +286,111 @@ export async function showWorkflowStatusAsync(
|
||||
stream: spinnerUsesStdErr ? process.stderr : process.stdout,
|
||||
text: '',
|
||||
}).start();
|
||||
spinner.prefixText = chalk`{bold.yellow Workflow run is waiting to start:}`;
|
||||
updateSpinnerText(spinner, {
|
||||
prefixText: chalk`{bold.yellow Workflow run is waiting to start:}`,
|
||||
});
|
||||
|
||||
let failedFetchesCount = 0;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const workflowRun = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, workflowRunId, {
|
||||
useCache: false,
|
||||
});
|
||||
|
||||
failedFetchesCount = 0;
|
||||
|
||||
switch (workflowRun.status) {
|
||||
case WorkflowRunStatus.New:
|
||||
break;
|
||||
case WorkflowRunStatus.InProgress: {
|
||||
spinner.prefixText = chalk`{bold.green Workflow run is in progress:}`;
|
||||
spinner.text = await infoForActiveWorkflowRunAsync(graphqlClient, workflowRun, 5);
|
||||
break;
|
||||
}
|
||||
case WorkflowRunStatus.ActionRequired:
|
||||
spinner.prefixText = chalk`{bold.yellow Workflow run is waiting for action:}`;
|
||||
break;
|
||||
|
||||
case WorkflowRunStatus.Canceled:
|
||||
spinner.prefixText = chalk`{bold.yellow Workflow has been canceled.}`;
|
||||
spinner.stopAndPersist();
|
||||
return workflowRun;
|
||||
|
||||
case WorkflowRunStatus.Failure: {
|
||||
spinner.prefixText = chalk`{bold.red Workflow has failed.}`;
|
||||
const failedInfo = await infoForFailedWorkflowRunAsync(graphqlClient, workflowRun, 30);
|
||||
spinner.fail(failedInfo);
|
||||
return workflowRun;
|
||||
}
|
||||
case WorkflowRunStatus.Success:
|
||||
spinner.prefixText = chalk`{bold.green Workflow has completed successfully.}`;
|
||||
spinner.text = '';
|
||||
spinner.succeed('');
|
||||
return workflowRun;
|
||||
}
|
||||
if (!waitForCompletion) {
|
||||
if (spinner.isSpinning) {
|
||||
spinner.stopAndPersist();
|
||||
}
|
||||
return workflowRun;
|
||||
}
|
||||
} catch {
|
||||
spinner.text = '⚠ Failed to fetch the workflow run status. Check your network connection.';
|
||||
|
||||
failedFetchesCount += 1;
|
||||
|
||||
if (failedFetchesCount > 6) {
|
||||
spinner.fail('Failed to fetch the workflow run status 6 times in a row. Aborting wait.');
|
||||
process.exit(workflowRunExitCodes.WAIT_ABORTED);
|
||||
}
|
||||
const watcher = new WorkflowRunLogsWatcher(
|
||||
graphqlClient,
|
||||
() => createRealtimeLogsClient(graphqlClient),
|
||||
() => {
|
||||
renderActiveWorkflowRun();
|
||||
}
|
||||
);
|
||||
let failedFetchesCount = 0;
|
||||
let renderActiveWorkflowRun = (): void => {};
|
||||
|
||||
await sleepAsync(10 /* seconds */ * 1000 /* milliseconds */);
|
||||
try {
|
||||
while (true) {
|
||||
try {
|
||||
const workflowRun = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, workflowRunId, {
|
||||
useCache: false,
|
||||
});
|
||||
|
||||
failedFetchesCount = 0;
|
||||
|
||||
switch (workflowRun.status) {
|
||||
case WorkflowRunStatus.New:
|
||||
break;
|
||||
case WorkflowRunStatus.InProgress: {
|
||||
updateSpinnerText(spinner, {
|
||||
prefixText: chalk`{bold.green Workflow run is in progress:}`,
|
||||
});
|
||||
const logsStates = await watcher.syncJobsAsync(workflowRun.jobs);
|
||||
for (const job of workflowRun.jobs) {
|
||||
if (isJobCompleted(job.status)) {
|
||||
nullthrows(
|
||||
logsStates.get(job.id),
|
||||
'syncJobsAsync must have been called before markCompleted'
|
||||
).markCompleted();
|
||||
}
|
||||
}
|
||||
renderActiveWorkflowRun = () => {
|
||||
const text = formatActiveWorkflowRun(
|
||||
workflowRun.jobs.map(job => ({
|
||||
job,
|
||||
logs: nullthrows(
|
||||
logsStates.get(job.id),
|
||||
'syncJobsAsync must have been called before getLogs'
|
||||
).getLogs(),
|
||||
}))
|
||||
);
|
||||
updateSpinnerText(spinner, { text });
|
||||
};
|
||||
renderActiveWorkflowRun();
|
||||
break;
|
||||
}
|
||||
case WorkflowRunStatus.ActionRequired:
|
||||
updateSpinnerText(spinner, {
|
||||
prefixText: chalk`{bold.yellow Workflow run is waiting for action:}`,
|
||||
});
|
||||
break;
|
||||
|
||||
case WorkflowRunStatus.Canceled:
|
||||
updateSpinnerText(spinner, {
|
||||
prefixText: chalk`{bold.yellow Workflow has been canceled.}`,
|
||||
});
|
||||
spinner.stopAndPersist();
|
||||
return workflowRun;
|
||||
|
||||
case WorkflowRunStatus.Failure: {
|
||||
updateSpinnerText(spinner, {
|
||||
prefixText: chalk`{bold.red Workflow has failed.}`,
|
||||
});
|
||||
const jobLogs = await logsForFailedWorkflowRunAsync(graphqlClient, workflowRun);
|
||||
spinner.fail(formatFailedWorkflowRun(jobLogs, 30));
|
||||
return workflowRun;
|
||||
}
|
||||
case WorkflowRunStatus.Success:
|
||||
updateSpinnerText(spinner, {
|
||||
prefixText: chalk`{bold.green Workflow has completed successfully.}`,
|
||||
});
|
||||
spinner.succeed('');
|
||||
return workflowRun;
|
||||
}
|
||||
if (!waitForCompletion) {
|
||||
if (spinner.isSpinning) {
|
||||
spinner.stopAndPersist();
|
||||
}
|
||||
return workflowRun;
|
||||
}
|
||||
} catch {
|
||||
updateSpinnerText(spinner, {
|
||||
text: '⚠ Failed to fetch the workflow run status. Check your network connection.',
|
||||
});
|
||||
|
||||
failedFetchesCount += 1;
|
||||
|
||||
if (failedFetchesCount > 6) {
|
||||
spinner.fail('Failed to fetch the workflow run status 6 times in a row. Aborting wait.');
|
||||
process.exit(workflowRunExitCodes.WAIT_ABORTED);
|
||||
}
|
||||
}
|
||||
|
||||
await sleepAsync(10 /* seconds */ * 1000 /* milliseconds */);
|
||||
}
|
||||
} finally {
|
||||
watcher.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,370 +0,0 @@
|
||||
import { getMockOclifConfig } from '../../__tests__/commands/utils';
|
||||
import { ChatResult, streamChatResponseAsync } from '../../chat/chatClient';
|
||||
import { detectCurrentProjectAsync } from '../../chat/detectProject';
|
||||
import { ChatReplInput, createChatReplInput } from '../../chat/replInput';
|
||||
import * as flagsModule from '../../commandUtils/flags';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import Chat from '../chat';
|
||||
|
||||
jest.mock('../../chat/chatClient', () => ({
|
||||
...jest.requireActual('../../chat/chatClient'),
|
||||
streamChatResponseAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../chat/replInput');
|
||||
jest.mock('../../chat/detectProject');
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../utils/json');
|
||||
jest.mock('../../graphql/queries/AppQuery', () => ({
|
||||
AppQuery: { byFullNameAsync: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockStreamChatResponseAsync = jest.mocked(streamChatResponseAsync);
|
||||
const mockCreateChatReplInput = jest.mocked(createChatReplInput);
|
||||
const mockDetectCurrentProjectAsync = jest.mocked(detectCurrentProjectAsync);
|
||||
const mockAppByFullNameAsync = jest.mocked(AppQuery.byFullNameAsync);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
function makeChatResult(text: string, toolCalls: ChatResult['toolCalls'] = []): ChatResult {
|
||||
return {
|
||||
text,
|
||||
toolCalls,
|
||||
assistantMessage: {
|
||||
id: 'assistant-message',
|
||||
role: 'assistant',
|
||||
parts: [{ type: 'text', text }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const emptyResult = makeChatResult('ok');
|
||||
|
||||
function mockReplInput(lines: (string | null)[]): { askAsync: jest.Mock; close: jest.Mock } {
|
||||
const askAsync = jest.fn();
|
||||
for (const line of lines) {
|
||||
askAsync.mockResolvedValueOnce(line);
|
||||
}
|
||||
askAsync.mockResolvedValue(null);
|
||||
const input = { askAsync, close: jest.fn() };
|
||||
mockCreateChatReplInput.mockReturnValue(input as ChatReplInput);
|
||||
return input;
|
||||
}
|
||||
|
||||
function forceInteractive(): void {
|
||||
jest
|
||||
.spyOn(flagsModule, 'resolveNonInteractiveAndJsonFlags')
|
||||
.mockReturnValue({ json: false, nonInteractive: false });
|
||||
}
|
||||
|
||||
describe(Chat, () => {
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
mockStreamChatResponseAsync.mockResolvedValue(emptyResult);
|
||||
mockDetectCurrentProjectAsync.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
function createCommand(
|
||||
argv: string[],
|
||||
{
|
||||
sessionSecret = '{"id":"session-id","version":"1"}',
|
||||
primaryAccountName = 'my-account',
|
||||
}: { sessionSecret?: string | null; primaryAccountName?: string } = {}
|
||||
): Chat {
|
||||
const command = new Chat(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: {
|
||||
actor: {
|
||||
__typename: 'User',
|
||||
id: 'user-id',
|
||||
primaryAccount: { id: 'account-id', name: primaryAccountName },
|
||||
accounts: [{ id: 'account-id', name: primaryAccountName }],
|
||||
},
|
||||
graphqlClient: {},
|
||||
authenticationInfo: sessionSecret
|
||||
? { accessToken: null, sessionSecret }
|
||||
: { accessToken: 'token', sessionSecret: null },
|
||||
},
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('sends the message for the primary account and does not prompt in non-interactive mode', async () => {
|
||||
const command = createCommand(['how are my builds?', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1);
|
||||
const call = mockStreamChatResponseAsync.mock.calls[0][0];
|
||||
expect(call.accountName).toBe('my-account');
|
||||
expect(call.sessionSecret).toBe('{"id":"session-id","version":"1"}');
|
||||
expect(call.stream).toBe(true);
|
||||
expect(call.messages).toHaveLength(1);
|
||||
expect(call.messages[0].parts[0]).toEqual({ type: 'text', text: 'how are my builds?' });
|
||||
expect(mockCreateChatReplInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prefers the --account flag over the primary account and skips project auto-detection', async () => {
|
||||
const command = createCommand(['hi', '--account', 'other-account', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockStreamChatResponseAsync.mock.calls[0][0].accountName).toBe('other-account');
|
||||
expect(mockDetectCurrentProjectAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('auto-scopes to the current directory project when no scope flags are given', async () => {
|
||||
mockDetectCurrentProjectAsync.mockResolvedValue({ accountName: 'acme', label: '@acme/mobile' });
|
||||
|
||||
const command = createCommand(['is my build ok?', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
const call = mockStreamChatResponseAsync.mock.calls[0][0];
|
||||
expect(call.accountName).toBe('acme');
|
||||
expect(call.messages[0].parts[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'Regarding the EAS project @acme/mobile: is my build ok?',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not auto-detect when --project is given', async () => {
|
||||
mockAppByFullNameAsync.mockResolvedValue({
|
||||
id: 'app1',
|
||||
fullName: '@acme/mobile',
|
||||
slug: 'mobile',
|
||||
ownerAccount: { id: 'acc', name: 'acme' },
|
||||
} as any);
|
||||
|
||||
const command = createCommand(['hi', '--project', 'acme/mobile', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockDetectCurrentProjectAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves --project to its owner account and frames the message', async () => {
|
||||
mockAppByFullNameAsync.mockResolvedValue({
|
||||
id: 'app1',
|
||||
fullName: '@acme/mobile',
|
||||
slug: 'mobile',
|
||||
ownerAccount: { id: 'acc', name: 'acme' },
|
||||
} as any);
|
||||
|
||||
const command = createCommand([
|
||||
'is my build ok?',
|
||||
'--project',
|
||||
'acme/mobile',
|
||||
'--non-interactive',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockAppByFullNameAsync).toHaveBeenCalledWith(expect.anything(), '@acme/mobile');
|
||||
const call = mockStreamChatResponseAsync.mock.calls[0][0];
|
||||
expect(call.accountName).toBe('acme');
|
||||
expect(call.messages[0].parts[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'Regarding the EAS project @acme/mobile: is my build ok?',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws a friendly error when --project cannot be found', async () => {
|
||||
mockAppByFullNameAsync.mockRejectedValue(new Error('not found'));
|
||||
const command = createCommand(['hi', '--project', 'acme/ghost', '--non-interactive']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/@acme\/ghost was not found/);
|
||||
});
|
||||
|
||||
it('continues the conversation with follow-up replies until the user types /exit', async () => {
|
||||
forceInteractive();
|
||||
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
|
||||
const input = mockReplInput(['and my updates?', '/exit']);
|
||||
|
||||
const command = createCommand(['how are my builds?']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2);
|
||||
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
|
||||
expect(secondCall.messages).toHaveLength(3);
|
||||
expect(secondCall.messages[1].role).toBe('assistant');
|
||||
expect(secondCall.messages[2].role).toBe('user');
|
||||
expect(secondCall.messages[2].parts[0]).toEqual({ type: 'text', text: 'and my updates?' });
|
||||
expect(input.close).toHaveBeenCalled();
|
||||
expect(mockCreateChatReplInput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ history: ['how are my builds?'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('retains tool results in the conversation history for follow-up questions', async () => {
|
||||
forceInteractive();
|
||||
const result = makeChatResult('The latest build passed.', [
|
||||
{ toolName: 'get_latest_builds', input: { limit: 1 }, output: { builds: ['b1'] } },
|
||||
]);
|
||||
result.assistantMessage.parts = [
|
||||
{ type: 'step-start' },
|
||||
{
|
||||
type: 'tool-get_latest_builds',
|
||||
toolCallId: 't1',
|
||||
state: 'output-available',
|
||||
input: { limit: 1 },
|
||||
output: { builds: ['b1'] },
|
||||
},
|
||||
{ type: 'step-start' },
|
||||
{ type: 'text', text: result.text },
|
||||
];
|
||||
mockStreamChatResponseAsync.mockResolvedValue(result);
|
||||
mockReplInput(['what SDK did it use?', '/exit']);
|
||||
|
||||
const command = createCommand(['how are my builds?']);
|
||||
await command.runAsync();
|
||||
|
||||
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
|
||||
expect(secondCall.messages[1]).toEqual(result.assistantMessage);
|
||||
});
|
||||
|
||||
it('exits when the input stream closes (Ctrl-D)', async () => {
|
||||
forceInteractive();
|
||||
mockReplInput([null]);
|
||||
|
||||
const command = createCommand(['how are my builds?']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the interactive session alive when a request fails and re-prompts', async () => {
|
||||
forceInteractive();
|
||||
mockStreamChatResponseAsync
|
||||
.mockRejectedValueOnce(new Error('You have reached your chat usage limit.'))
|
||||
.mockResolvedValue(makeChatResult('answer'));
|
||||
const input = mockReplInput(['try again', '/exit']);
|
||||
|
||||
const command = createCommand(['how are my builds?']);
|
||||
await expect(command.runAsync()).resolves.toBeUndefined();
|
||||
|
||||
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2);
|
||||
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
|
||||
expect(secondCall.messages).toHaveLength(1);
|
||||
expect(secondCall.messages[0].parts[0]).toEqual({ type: 'text', text: 'try again' });
|
||||
expect(input.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates a request failure in non-interactive mode without retrying', async () => {
|
||||
mockStreamChatResponseAsync.mockRejectedValue(new Error('boom'));
|
||||
|
||||
const command = createCommand(['hi', '--non-interactive']);
|
||||
await expect(command.runAsync()).rejects.toThrow('boom');
|
||||
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('starts a new conversation with /clear', async () => {
|
||||
forceInteractive();
|
||||
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
|
||||
mockReplInput(['/clear', 'fresh question', '/exit']);
|
||||
|
||||
const command = createCommand(['first question']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2);
|
||||
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
|
||||
expect(secondCall.messages).toHaveLength(1);
|
||||
expect(secondCall.messages[0].parts[0]).toEqual({ type: 'text', text: 'fresh question' });
|
||||
});
|
||||
|
||||
it('restores project scope after /clear', async () => {
|
||||
forceInteractive();
|
||||
mockDetectCurrentProjectAsync.mockResolvedValue({ accountName: 'acme', label: '@acme/mobile' });
|
||||
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
|
||||
mockReplInput(['/clear', 'fresh question', '/exit']);
|
||||
|
||||
const command = createCommand(['first question']);
|
||||
await command.runAsync();
|
||||
|
||||
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
|
||||
expect(secondCall.messages).toHaveLength(1);
|
||||
expect(secondCall.messages[0].parts[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'Regarding the EAS project @acme/mobile: fresh question',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores unknown slash commands and keeps prompting', async () => {
|
||||
forceInteractive();
|
||||
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
|
||||
mockReplInput(['/bogus', 'a real question', '/exit']);
|
||||
|
||||
const command = createCommand(['first question']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockStreamChatResponseAsync.mock.calls[1][0].messages[2].parts[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'a real question',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits structured JSON and does not stream or prompt when --json is passed', async () => {
|
||||
mockStreamChatResponseAsync.mockResolvedValue(
|
||||
makeChatResult('Your latest build passed.', [
|
||||
{ toolName: 'get_latest_builds', input: { limit: 1 }, output: { builds: [] } },
|
||||
])
|
||||
);
|
||||
|
||||
const command = createCommand(['how are my builds?', '--json']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockStreamChatResponseAsync.mock.calls[0][0].stream).toBe(false);
|
||||
expect(mockCreateChatReplInput).not.toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
message: 'how are my builds?',
|
||||
account: 'my-account',
|
||||
project: null,
|
||||
response: 'Your latest build passed.',
|
||||
toolCalls: [{ toolName: 'get_latest_builds', input: { limit: 1 }, output: { builds: [] } }],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when authenticated with an access token instead of a session secret', async () => {
|
||||
const command = createCommand(['hi', '--non-interactive'], { sessionSecret: null });
|
||||
await expect(command.runAsync()).rejects.toThrow(/requires an interactive login/);
|
||||
expect(mockStreamChatResponseAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires a message argument', async () => {
|
||||
const command = createCommand(['--non-interactive']);
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('opens an interactive prompt when no message is given', async () => {
|
||||
forceInteractive();
|
||||
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
|
||||
const input = mockReplInput(['how are my builds?', '/exit']);
|
||||
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateChatReplInput).toHaveBeenCalledWith(expect.objectContaining({ history: [] }));
|
||||
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1);
|
||||
const call = mockStreamChatResponseAsync.mock.calls[0][0];
|
||||
expect(call.messages).toHaveLength(1);
|
||||
expect(call.messages[0].parts[0]).toEqual({ type: 'text', text: 'how are my builds?' });
|
||||
expect(input.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('frames the first typed message with the auto-detected project when no message is given', async () => {
|
||||
forceInteractive();
|
||||
mockDetectCurrentProjectAsync.mockResolvedValue({ accountName: 'acme', label: '@acme/mobile' });
|
||||
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
|
||||
mockReplInput(['is my build ok?', '/exit']);
|
||||
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
const call = mockStreamChatResponseAsync.mock.calls[0][0];
|
||||
expect(call.accountName).toBe('acme');
|
||||
expect(call.messages[0].parts[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'Regarding the EAS project @acme/mobile: is my build ok?',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import Log from '../../log';
|
||||
import { fromNow } from '../../utils/date';
|
||||
import { isMultiAccountEnabled } from '../../utils/easCli';
|
||||
|
||||
export default class AccountList extends EasCommand {
|
||||
static override description = 'list all logged-in Expo accounts';
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.SessionManagment,
|
||||
};
|
||||
|
||||
// Hide this command when the feature flag is disabled
|
||||
static override get hidden(): boolean {
|
||||
return !isMultiAccountEnabled();
|
||||
}
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
if (!isMultiAccountEnabled()) {
|
||||
Log.error(
|
||||
'Multi-account listing is not enabled. Set EAS_EXPERIMENTAL_ACCOUNT_SWITCHER=1 to enable.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { sessionManager } = await this.getContextAsync(AccountList, { nonInteractive: true });
|
||||
|
||||
if (sessionManager.getAccessToken()) {
|
||||
Log.log(chalk.yellow('Using EXPO_TOKEN from environment for authentication.'));
|
||||
Log.log('Account switching is not available when using EXPO_TOKEN.');
|
||||
return;
|
||||
}
|
||||
|
||||
const accounts = sessionManager.getAllAccounts();
|
||||
|
||||
if (accounts.length === 0) {
|
||||
Log.log('No accounts logged in.');
|
||||
Log.log(`Run ${chalk.bold('eas login')} to log in.`);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.log('Logged-in accounts:');
|
||||
Log.newLine();
|
||||
|
||||
for (const account of accounts) {
|
||||
const marker = account.isActive ? chalk.green('●') : chalk.dim('○');
|
||||
const activeLabel = account.isActive ? chalk.dim(' (active)') : '';
|
||||
const username = account.isActive ? chalk.bold(account.username) : account.username;
|
||||
|
||||
Log.log(`${marker} ${username}${activeLabel}`);
|
||||
|
||||
if (account.lastUsedAt) {
|
||||
const lastUsed = fromNow(new Date(account.lastUsedAt));
|
||||
Log.log(chalk.dim(` └─ Last used: ${lastUsed} ago`));
|
||||
}
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
Log.log(`Use ${chalk.bold('eas account:switch')} to change accounts.`);
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,8 @@ import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import Log from '../../log';
|
||||
import { confirmAsync, selectAsync } from '../../prompts';
|
||||
import SessionManager from '../../user/SessionManager';
|
||||
import { Actor, getActorDisplayName } from '../../user/User';
|
||||
import { isMultiAccountEnabled } from '../../utils/easCli';
|
||||
import { confirmAsync } from '../../prompts';
|
||||
import { getActorDisplayName } from '../../user/User';
|
||||
|
||||
export default class AccountLogin extends EasCommand {
|
||||
static override description = 'log in with your Expo account';
|
||||
@@ -49,13 +47,6 @@ export default class AccountLogin extends EasCommand {
|
||||
}
|
||||
|
||||
if (actor) {
|
||||
if (isMultiAccountEnabled()) {
|
||||
// Multi-account mode: offer options
|
||||
await this.handleMultiAccountLoginAsync(sessionManager, actor, { sso, browser });
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy mode: simple confirmation
|
||||
Log.warn(`You are already logged in as ${chalk.bold(getActorDisplayName(actor))}.`);
|
||||
|
||||
const shouldContinue = await confirmAsync({
|
||||
@@ -69,58 +60,4 @@ export default class AccountLogin extends EasCommand {
|
||||
await sessionManager.showLoginPromptAsync({ sso, browser });
|
||||
Log.log('Logged in');
|
||||
}
|
||||
|
||||
private async handleMultiAccountLoginAsync(
|
||||
sessionManager: SessionManager,
|
||||
actor: Actor,
|
||||
{ sso, browser }: { sso: boolean; browser: boolean }
|
||||
): Promise<void> {
|
||||
const accounts = sessionManager.getAllAccounts();
|
||||
const currentUsername = getActorDisplayName(actor);
|
||||
|
||||
Log.log(`You're logged in as ${chalk.bold(currentUsername)}.`);
|
||||
|
||||
const choices: { title: string; value: string }[] = [
|
||||
{
|
||||
title: 'Add another account',
|
||||
value: 'add',
|
||||
},
|
||||
];
|
||||
|
||||
// Add switch options for other accounts
|
||||
const otherAccounts = accounts.filter(a => !a.isActive);
|
||||
if (otherAccounts.length > 0) {
|
||||
for (const account of otherAccounts) {
|
||||
choices.push({
|
||||
title: `Switch to ${account.username}`,
|
||||
value: `switch:${account.username}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
choices.push({
|
||||
title: 'Cancel',
|
||||
value: 'cancel',
|
||||
});
|
||||
|
||||
const action = await selectAsync('What would you like to do?', choices);
|
||||
|
||||
if (action === 'cancel') {
|
||||
Errors.error('Aborted', { exit: 1 });
|
||||
}
|
||||
|
||||
if (action === 'add') {
|
||||
await sessionManager.showLoginPromptAsync({ sso, browser });
|
||||
const newAccounts = sessionManager.getAllAccounts();
|
||||
Log.log('Logged in');
|
||||
Log.log(`You now have ${newAccounts.length} account${newAccounts.length > 1 ? 's' : ''}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.startsWith('switch:')) {
|
||||
const username = action.slice('switch:'.length);
|
||||
await sessionManager.switchAccountByUsernameAsync(username);
|
||||
Log.log(`Switched to ${chalk.bold(username)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,120 +1,17 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import Log from '../../log';
|
||||
import { confirmAsync } from '../../prompts';
|
||||
import { isMultiAccountEnabled } from '../../utils/easCli';
|
||||
|
||||
export default class AccountLogout extends EasCommand {
|
||||
static override description = 'log out';
|
||||
static override aliases = ['logout'];
|
||||
|
||||
static override args = {
|
||||
username: Args.string({
|
||||
description: 'Username of the account to log out (multi-account mode only)',
|
||||
required: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
all: Flags.boolean({
|
||||
description: 'Log out of all accounts (multi-account mode only)',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.SessionManagment,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(AccountLogout);
|
||||
const { sessionManager } = await this.getContextAsync(AccountLogout, { nonInteractive: false });
|
||||
|
||||
if (!isMultiAccountEnabled()) {
|
||||
if (args.username || flags.all) {
|
||||
Log.error(
|
||||
'Logging out a specific account requires the experimental account switcher. Set EAS_EXPERIMENTAL_ACCOUNT_SWITCHER=1 to enable it.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Legacy behavior
|
||||
await sessionManager.logoutAsync();
|
||||
Log.log('Logged out');
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-account mode
|
||||
const accounts = sessionManager.getAllAccounts();
|
||||
|
||||
if (accounts.length === 0) {
|
||||
Log.log('Not logged in to any accounts.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle --all flag
|
||||
if (flags.all) {
|
||||
const confirmed = await confirmAsync({
|
||||
message: `Log out of all ${accounts.length} account${accounts.length > 1 ? 's' : ''}?`,
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
Log.log('Aborted');
|
||||
return;
|
||||
}
|
||||
|
||||
await sessionManager.removeAllAccountsAsync();
|
||||
Log.log('Logged out of all accounts');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle specific username argument
|
||||
if (args.username) {
|
||||
const account = accounts.find(a => a.username === args.username);
|
||||
if (!account) {
|
||||
Log.error(`Account '${args.username}' not found.`);
|
||||
Log.log('Logged-in accounts:');
|
||||
accounts.forEach(a => {
|
||||
Log.log(` • ${a.username}${a.isActive ? ' (active)' : ''}`);
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await sessionManager.removeAccountAsync(account.userId);
|
||||
Log.log(`Logged out of ${chalk.bold(args.username)}`);
|
||||
|
||||
// Show remaining accounts info
|
||||
const remainingAccounts = sessionManager.getAllAccounts();
|
||||
if (remainingAccounts.length > 0) {
|
||||
const activeAccount = remainingAccounts.find(a => a.isActive);
|
||||
if (activeAccount) {
|
||||
Log.log(`Active account is now: ${chalk.bold(activeAccount.username)}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: log out of active account
|
||||
const activeAccount = accounts.find(a => a.isActive);
|
||||
if (!activeAccount) {
|
||||
Log.log('No active account to log out of.');
|
||||
return;
|
||||
}
|
||||
|
||||
await sessionManager.removeAccountAsync(activeAccount.userId);
|
||||
Log.log(`Logged out of ${chalk.bold(activeAccount.username)}`);
|
||||
|
||||
// Show remaining accounts info
|
||||
const remainingAccounts = sessionManager.getAllAccounts();
|
||||
if (remainingAccounts.length > 0) {
|
||||
const newActiveAccount = remainingAccounts.find(a => a.isActive);
|
||||
if (newActiveAccount) {
|
||||
Log.log(`Active account is now: ${chalk.bold(newActiveAccount.username)}`);
|
||||
}
|
||||
} else {
|
||||
Log.log('No accounts remaining. Run `eas login` to log in.');
|
||||
}
|
||||
await sessionManager.logoutAsync();
|
||||
Log.log('Logged out');
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Args } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import Log from '../../log';
|
||||
import { selectAsync } from '../../prompts';
|
||||
import { isMultiAccountEnabled } from '../../utils/easCli';
|
||||
|
||||
export default class AccountSwitch extends EasCommand {
|
||||
static override description = 'switch to a different Expo account';
|
||||
|
||||
static override args = {
|
||||
username: Args.string({
|
||||
description: 'Username of the account to switch to',
|
||||
required: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.SessionManagment,
|
||||
};
|
||||
|
||||
// Hide this command when the feature flag is disabled
|
||||
static override get hidden(): boolean {
|
||||
return !isMultiAccountEnabled();
|
||||
}
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
if (!isMultiAccountEnabled()) {
|
||||
Log.error(
|
||||
'Multi-account switching is not enabled. Set EAS_EXPERIMENTAL_ACCOUNT_SWITCHER=1 to enable.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { args } = await this.parse(AccountSwitch);
|
||||
const { sessionManager } = await this.getContextAsync(AccountSwitch, { nonInteractive: false });
|
||||
|
||||
const accounts = sessionManager.getAllAccounts();
|
||||
|
||||
if (accounts.length === 0) {
|
||||
Log.warn('No accounts logged in. Run `eas login` to log in.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (accounts.length === 1) {
|
||||
Log.log(`Only one account is logged in: ${chalk.bold(accounts[0].username)}`);
|
||||
Log.log('Run `eas login` to add another account.');
|
||||
return;
|
||||
}
|
||||
|
||||
let targetUsername = args.username;
|
||||
|
||||
if (!targetUsername) {
|
||||
// Interactive mode: show account picker
|
||||
const choices = accounts.map(account => {
|
||||
const title = account.isActive
|
||||
? `${account.username} ${chalk.dim('(active)')}`
|
||||
: account.username;
|
||||
return { title, value: account.username };
|
||||
});
|
||||
|
||||
choices.push({
|
||||
title: chalk.dim('Add new account...'),
|
||||
value: '__add_new__',
|
||||
});
|
||||
|
||||
targetUsername = await selectAsync('Select an account:', choices);
|
||||
|
||||
if (targetUsername === '__add_new__') {
|
||||
// Match the default `eas login` behavior (browser-based login)
|
||||
await sessionManager.showLoginPromptAsync({ browser: true });
|
||||
Log.log('Logged in');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already active
|
||||
const targetAccount = accounts.find(a => a.username === targetUsername);
|
||||
if (!targetAccount) {
|
||||
Log.error(`Account '${targetUsername}' not found.`);
|
||||
Log.log('Available accounts:');
|
||||
accounts.forEach(a => {
|
||||
Log.log(` • ${a.username}${a.isActive ? ' (active)' : ''}`);
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (targetAccount.isActive) {
|
||||
Log.log(`Already using account ${chalk.bold(targetUsername)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Switch to the account
|
||||
await sessionManager.switchAccountByUsernameAsync(targetUsername);
|
||||
Log.log(`Switched to ${chalk.bold(targetUsername)}`);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { Role } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { Actor, getActorDisplayName } from '../../user/User';
|
||||
import { isMultiAccountEnabled } from '../../utils/easCli';
|
||||
|
||||
export default class AccountView extends EasCommand {
|
||||
static override description = 'show the username you are logged in as';
|
||||
@@ -12,53 +11,39 @@ export default class AccountView extends EasCommand {
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.MaybeLoggedIn,
|
||||
...this.ContextOptions.SessionManagment,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
maybeLoggedIn: { actor, authenticationInfo },
|
||||
sessionManager,
|
||||
} = await this.getContextAsync(AccountView, { nonInteractive: true });
|
||||
if (actor) {
|
||||
const displayName = getActorDisplayName(actor);
|
||||
const loggedInAs = authenticationInfo.accessToken
|
||||
? `${displayName} (authenticated using EXPO_TOKEN)`
|
||||
: displayName;
|
||||
Log.log(chalk.green(loggedInAs));
|
||||
if ('email' in actor) {
|
||||
Log.log(actor.email);
|
||||
}
|
||||
|
||||
if (!actor) {
|
||||
// personal account is included, only show if more accounts that personal account
|
||||
// but do show personal account in list if there are more
|
||||
const accountExcludingPersonalAccount = actor.accounts.filter(
|
||||
account => !('username' in actor) || account.name !== actor.username
|
||||
);
|
||||
if (accountExcludingPersonalAccount.length > 0) {
|
||||
Log.newLine();
|
||||
Log.log('Accounts:');
|
||||
actor.accounts.forEach(account => {
|
||||
const roleOnAccount = AccountView.getRoleOnAccount(actor, account);
|
||||
Log.log(`• ${account.name} (Role: ${AccountView.getLabelForRole(roleOnAccount)})`);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
Log.warn('Not logged in');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const displayName = getActorDisplayName(actor);
|
||||
const loggedInAs = authenticationInfo.accessToken
|
||||
? `${displayName} (authenticated using EXPO_TOKEN)`
|
||||
: displayName;
|
||||
Log.log(chalk.green(loggedInAs));
|
||||
if ('email' in actor) {
|
||||
Log.log(actor.email);
|
||||
}
|
||||
|
||||
// Show other logged-in accounts if multi-account is enabled
|
||||
if (isMultiAccountEnabled() && !authenticationInfo.accessToken) {
|
||||
const accounts = sessionManager.getAllAccounts();
|
||||
const otherAccounts = accounts.filter(a => !a.isActive);
|
||||
|
||||
if (otherAccounts.length > 0) {
|
||||
const otherUsernames = otherAccounts.map(a => a.username).join(', ');
|
||||
Log.log(chalk.dim(`Also logged in: ${otherUsernames}`));
|
||||
}
|
||||
}
|
||||
|
||||
// personal account is included, only show if more accounts that personal account
|
||||
// but do show personal account in list if there are more
|
||||
const accountExcludingPersonalAccount = actor.accounts.filter(
|
||||
account => !('username' in actor) || account.name !== actor.username
|
||||
);
|
||||
if (accountExcludingPersonalAccount.length > 0) {
|
||||
Log.newLine();
|
||||
Log.log('Accounts:');
|
||||
actor.accounts.forEach(account => {
|
||||
const roleOnAccount = AccountView.getRoleOnAccount(actor, account);
|
||||
Log.log(`• ${account.name} (Role: ${AccountView.getLabelForRole(roleOnAccount)})`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static getRoleOnAccount(actor: Actor, account: Actor['accounts'][0]): Role {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import {
|
||||
protectUpdateChannelAsync,
|
||||
unprotectUpdateChannelAsync,
|
||||
} from '../../../channel/protection';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { ChannelQuery } from '../../../graphql/queries/ChannelQuery';
|
||||
import Log from '../../../log';
|
||||
import { toggleConfirmAsync } from '../../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ChannelProtect from '../protect';
|
||||
import ChannelUnprotect from '../unprotect';
|
||||
|
||||
jest.mock('../../../channel/protection');
|
||||
jest.mock('../../../graphql/queries/ChannelQuery');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const channel = {
|
||||
id: 'channel-id',
|
||||
name: 'production',
|
||||
branchMapping: '{"version":0,"data":[]}',
|
||||
isProtected: true,
|
||||
};
|
||||
|
||||
function setContext(command: ChannelProtect | ChannelUnprotect): void {
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
projectId: 'project-id',
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
}
|
||||
|
||||
describe(ChannelProtect, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(ChannelQuery.viewUpdateChannelBasicInfoAsync).mockResolvedValue({
|
||||
...channel,
|
||||
isProtected: false,
|
||||
});
|
||||
jest.mocked(protectUpdateChannelAsync).mockResolvedValue(channel);
|
||||
});
|
||||
|
||||
it('protects a named channel', async () => {
|
||||
const command = new ChannelProtect(['production'], getMockOclifConfig());
|
||||
setContext(command);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(protectUpdateChannelAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
channelId: 'channel-id',
|
||||
});
|
||||
expect(Log.withTick).toHaveBeenCalledWith(expect.stringContaining('production'));
|
||||
});
|
||||
|
||||
it('prints the server response as JSON', async () => {
|
||||
const command = new ChannelProtect(['production', '--json'], getMockOclifConfig());
|
||||
setContext(command);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(enableJsonOutput).toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith(channel);
|
||||
expect(Log.withTick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not mutate a channel that is already protected', async () => {
|
||||
jest.mocked(ChannelQuery.viewUpdateChannelBasicInfoAsync).mockResolvedValue(channel);
|
||||
const command = new ChannelProtect(['production'], getMockOclifConfig());
|
||||
setContext(command);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(protectUpdateChannelAsync).not.toHaveBeenCalled();
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('already protected'));
|
||||
});
|
||||
|
||||
it('requires a channel name in non-interactive mode', async () => {
|
||||
const command = new ChannelProtect(['--non-interactive', '--json'], getMockOclifConfig());
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'Channel name must be set when running in non-interactive mode'
|
||||
);
|
||||
expect(enableJsonOutput).toHaveBeenCalled();
|
||||
expect(protectUpdateChannelAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(ChannelUnprotect, () => {
|
||||
let processExitSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(ChannelQuery.viewUpdateChannelBasicInfoAsync).mockResolvedValue(channel);
|
||||
jest.mocked(unprotectUpdateChannelAsync).mockResolvedValue({ ...channel, isProtected: false });
|
||||
jest.mocked(toggleConfirmAsync).mockResolvedValue(true);
|
||||
processExitSpy = jest.spyOn(process, 'exit').mockImplementation((() => {
|
||||
throw new Error('process.exit');
|
||||
}) as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('asks for confirmation before removing protection', async () => {
|
||||
const command = new ChannelUnprotect(['production'], getMockOclifConfig());
|
||||
setContext(command);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(toggleConfirmAsync).toHaveBeenCalledWith({
|
||||
message: expect.stringContaining('production'),
|
||||
});
|
||||
expect(unprotectUpdateChannelAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
channelId: 'channel-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mutate the channel when confirmation is declined', async () => {
|
||||
jest.mocked(toggleConfirmAsync).mockResolvedValue(false);
|
||||
const command = new ChannelUnprotect(['production'], getMockOclifConfig());
|
||||
setContext(command);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('process.exit');
|
||||
|
||||
expect(unprotectUpdateChannelAsync).not.toHaveBeenCalled();
|
||||
expect(Log.error).toHaveBeenCalledWith(expect.stringContaining('production'));
|
||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('does not mutate a channel that is already unprotected', async () => {
|
||||
jest.mocked(ChannelQuery.viewUpdateChannelBasicInfoAsync).mockResolvedValue({
|
||||
...channel,
|
||||
isProtected: false,
|
||||
});
|
||||
const command = new ChannelUnprotect(['production'], getMockOclifConfig());
|
||||
setContext(command);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(toggleConfirmAsync).not.toHaveBeenCalled();
|
||||
expect(unprotectUpdateChannelAsync).not.toHaveBeenCalled();
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('already unprotected'));
|
||||
});
|
||||
|
||||
it('does not prompt in non-interactive mode', async () => {
|
||||
const command = new ChannelUnprotect(['production', '--non-interactive'], getMockOclifConfig());
|
||||
setContext(command);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(toggleConfirmAsync).not.toHaveBeenCalled();
|
||||
expect(unprotectUpdateChannelAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires a channel name in non-interactive mode', async () => {
|
||||
const command = new ChannelUnprotect(['--non-interactive', '--json'], getMockOclifConfig());
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'Channel name must be set when running in non-interactive mode'
|
||||
);
|
||||
expect(enableJsonOutput).toHaveBeenCalled();
|
||||
expect(unprotectUpdateChannelAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Args } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { protectUpdateChannelAsync } from '../../channel/protection';
|
||||
import { selectChannelOnAppAsync } from '../../channel/queries';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
|
||||
import Log from '../../log';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class ChannelProtect extends EasCommand {
|
||||
static override description = 'protect a channel so only account admins can publish to it';
|
||||
|
||||
static override args = {
|
||||
name: Args.string({
|
||||
required: false,
|
||||
description: 'Name of the channel to protect',
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(ChannelProtect);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
if (!args.name && nonInteractive) {
|
||||
throw new Error('Channel name must be set when running in non-interactive mode');
|
||||
}
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ChannelProtect, { nonInteractive });
|
||||
const existingChannel = args.name
|
||||
? await ChannelQuery.viewUpdateChannelBasicInfoAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName: args.name,
|
||||
})
|
||||
: await selectChannelOnAppAsync(graphqlClient, {
|
||||
projectId,
|
||||
selectionPromptTitle: 'Select a channel to protect',
|
||||
paginatedQueryOptions: { json, nonInteractive, offset: 0 },
|
||||
});
|
||||
|
||||
if (existingChannel.isProtected) {
|
||||
if (json) {
|
||||
printJsonOnlyOutput(existingChannel);
|
||||
} else {
|
||||
Log.log(chalk`Channel {bold ${existingChannel.name}} is already protected.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = await protectUpdateChannelAsync(graphqlClient, {
|
||||
channelId: existingChannel.id,
|
||||
});
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(channel);
|
||||
} else {
|
||||
Log.withTick(chalk`Channel {bold ${channel.name}} is now protected.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Args } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { unprotectUpdateChannelAsync } from '../../channel/protection';
|
||||
import { selectChannelOnAppAsync } from '../../channel/queries';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
|
||||
import Log from '../../log';
|
||||
import { toggleConfirmAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class ChannelUnprotect extends EasCommand {
|
||||
static override description = "remove a channel's admin-only publishing restriction";
|
||||
|
||||
static override args = {
|
||||
name: Args.string({
|
||||
required: false,
|
||||
description: 'Name of the channel to unprotect',
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(ChannelUnprotect);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
if (!args.name && nonInteractive) {
|
||||
throw new Error('Channel name must be set when running in non-interactive mode');
|
||||
}
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ChannelUnprotect, { nonInteractive });
|
||||
const existingChannel = args.name
|
||||
? await ChannelQuery.viewUpdateChannelBasicInfoAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName: args.name,
|
||||
})
|
||||
: await selectChannelOnAppAsync(graphqlClient, {
|
||||
projectId,
|
||||
selectionPromptTitle: 'Select a channel to unprotect',
|
||||
paginatedQueryOptions: { json, nonInteractive, offset: 0 },
|
||||
});
|
||||
|
||||
if (!existingChannel.isProtected) {
|
||||
if (json) {
|
||||
printJsonOnlyOutput(existingChannel);
|
||||
} else {
|
||||
Log.log(chalk`Channel {bold ${existingChannel.name}} is already unprotected.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nonInteractive) {
|
||||
const confirmed = await toggleConfirmAsync({
|
||||
message: chalk`Remove protection from channel {bold ${existingChannel.name}}?`,
|
||||
});
|
||||
if (!confirmed) {
|
||||
Log.error(chalk`Canceled removing protection from channel {bold ${existingChannel.name}}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const channel = await unprotectUpdateChannelAsync(graphqlClient, {
|
||||
channelId: existingChannel.id,
|
||||
});
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(channel);
|
||||
} else {
|
||||
Log.withTick(chalk`Channel {bold ${channel.name}} is no longer protected.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import {
|
||||
ChatMessage,
|
||||
ChatResult,
|
||||
makeUserMessage,
|
||||
streamChatResponseAsync,
|
||||
} from '../chat/chatClient';
|
||||
import { detectCurrentProjectAsync } from '../chat/detectProject';
|
||||
import { ChatReplInput, createChatReplInput } from '../chat/replInput';
|
||||
import EasCommand from '../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../commandUtils/flags';
|
||||
import { AppQuery } from '../graphql/queries/AppQuery';
|
||||
import Log from '../log';
|
||||
import { Actor } from '../user/User';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../utils/json';
|
||||
|
||||
const EXIT_WORDS = new Set(['exit', 'quit', 'q']);
|
||||
const USER_LABEL = `${chalk.bold.cyan('Chat')}${chalk.dim(' > ')}`;
|
||||
const CHAT_HELP = [
|
||||
'Commands:',
|
||||
' /help Show this help',
|
||||
' /clear Start a new conversation',
|
||||
' /exit Quit',
|
||||
].join('\n');
|
||||
|
||||
export default class Chat extends EasCommand {
|
||||
static override description = 'ask an AI assistant about your Expo account and EAS projects';
|
||||
|
||||
static override hidden = true;
|
||||
|
||||
static override args = {
|
||||
message: Args.string({
|
||||
required: false,
|
||||
description: 'Message to send to the assistant (omit to open an interactive prompt)',
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
account: Flags.string({
|
||||
char: 'a',
|
||||
description: 'Account to scope the conversation to (defaults to your primary account)',
|
||||
}),
|
||||
project: Flags.string({
|
||||
char: 'p',
|
||||
description:
|
||||
'Project to focus the conversation on, as @account-name/project-slug or a project slug',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(Chat);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
if (!args.message && (json || nonInteractive)) {
|
||||
throw new Error('Provide a message, e.g. `eas chat "how are my builds?"`.');
|
||||
}
|
||||
|
||||
const {
|
||||
loggedIn: { actor, authenticationInfo, graphqlClient },
|
||||
} = await this.getContextAsync(Chat, { nonInteractive });
|
||||
|
||||
if (!authenticationInfo.sessionSecret) {
|
||||
throw new Error(
|
||||
'`eas chat` requires an interactive login. Run `eas login` first. Chat does not support EXPO_TOKEN access tokens yet.'
|
||||
);
|
||||
}
|
||||
const { sessionSecret } = authenticationInfo;
|
||||
|
||||
let accountName = flags.account ?? getDefaultAccountName(actor);
|
||||
let projectLabel: string | undefined;
|
||||
let projectDetectedFromDirectory = false;
|
||||
if (flags.project) {
|
||||
const resolved = await resolveProjectAsync(graphqlClient, flags.project, accountName);
|
||||
accountName = resolved.accountName;
|
||||
projectLabel = resolved.label;
|
||||
} else if (!flags.account) {
|
||||
const detected = await detectCurrentProjectAsync(graphqlClient);
|
||||
if (detected) {
|
||||
accountName = detected.accountName;
|
||||
projectLabel = detected.label;
|
||||
projectDetectedFromDirectory = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (json) {
|
||||
const messages: ChatMessage[] = [
|
||||
makeUserMessage(scopeMessageToProject(args.message as string, projectLabel)),
|
||||
];
|
||||
const result = await streamChatResponseAsync({
|
||||
messages: [...messages],
|
||||
accountName,
|
||||
sessionSecret,
|
||||
stream: false,
|
||||
});
|
||||
printJsonOnlyOutput({
|
||||
message: args.message,
|
||||
account: accountName ?? null,
|
||||
project: projectLabel ?? null,
|
||||
response: result.text,
|
||||
toolCalls: result.toolCalls,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (projectLabel) {
|
||||
Log.log(
|
||||
chalk.dim(
|
||||
projectDetectedFromDirectory
|
||||
? `Project: ${projectLabel} (from current directory; use --account to widen)`
|
||||
: `Project: ${projectLabel}`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!nonInteractive) {
|
||||
Log.log(chalk.dim('Use /help for commands, or /exit to quit.'));
|
||||
}
|
||||
|
||||
const input = nonInteractive
|
||||
? undefined
|
||||
: createChatReplInput({ history: args.message ? [args.message] : [] });
|
||||
|
||||
const messages: ChatMessage[] = [];
|
||||
if (args.message) {
|
||||
Log.log(`${USER_LABEL}${args.message}`);
|
||||
Log.newLine();
|
||||
messages.push(makeUserMessage(scopeMessageToProject(args.message, projectLabel)));
|
||||
}
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (!lastMessage || lastMessage.role === 'assistant') {
|
||||
if (!input) {
|
||||
break;
|
||||
}
|
||||
const scopeNextMessage = messages.length === 0;
|
||||
const nextMessage = await readNextUserMessageAsync(
|
||||
input,
|
||||
messages,
|
||||
projectLabel,
|
||||
scopeNextMessage
|
||||
);
|
||||
if (nextMessage === null) {
|
||||
Log.log(chalk.dim('Ending chat.'));
|
||||
break;
|
||||
}
|
||||
messages.push(makeUserMessage(nextMessage));
|
||||
}
|
||||
|
||||
let result: ChatResult;
|
||||
try {
|
||||
result = await streamChatResponseAsync({
|
||||
messages: [...messages],
|
||||
accountName,
|
||||
sessionSecret,
|
||||
stream: true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!input) {
|
||||
throw error;
|
||||
}
|
||||
Log.error(error instanceof Error ? error.message : String(error));
|
||||
messages.pop();
|
||||
continue;
|
||||
}
|
||||
messages.push(result.assistantMessage);
|
||||
}
|
||||
} finally {
|
||||
input?.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readNextUserMessageAsync(
|
||||
input: ChatReplInput,
|
||||
messages: ChatMessage[],
|
||||
projectLabel: string | undefined,
|
||||
scopeFirstMessage = false
|
||||
): Promise<string | null> {
|
||||
let shouldRestoreProjectScope = scopeFirstMessage;
|
||||
for (;;) {
|
||||
Log.newLine();
|
||||
const line = await input.askAsync(USER_LABEL);
|
||||
if (line === null) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
if (EXIT_WORDS.has(trimmed.toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('/')) {
|
||||
const command = trimmed.slice(1).split(/\s+/)[0].toLowerCase();
|
||||
if (['exit', 'quit', 'q'].includes(command)) {
|
||||
return null;
|
||||
}
|
||||
if (['clear', 'new', 'reset'].includes(command)) {
|
||||
messages.length = 0;
|
||||
shouldRestoreProjectScope = true;
|
||||
Log.log(chalk.dim('Started a new conversation.'));
|
||||
continue;
|
||||
}
|
||||
if (['help', 'h', '?'].includes(command)) {
|
||||
Log.log(chalk.dim(CHAT_HELP));
|
||||
continue;
|
||||
}
|
||||
Log.warn(`Unknown command "/${command}". Type /help for commands.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
return shouldRestoreProjectScope ? scopeMessageToProject(trimmed, projectLabel) : trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
function scopeMessageToProject(message: string, projectLabel: string | undefined): string {
|
||||
return projectLabel ? `Regarding the EAS project ${projectLabel}: ${message}` : message;
|
||||
}
|
||||
|
||||
function getDefaultAccountName(actor: Actor): string | undefined {
|
||||
if ('primaryAccount' in actor && actor.primaryAccount) {
|
||||
return actor.primaryAccount.name;
|
||||
}
|
||||
return actor.accounts[0]?.name ?? undefined;
|
||||
}
|
||||
|
||||
async function resolveProjectAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectReference: string,
|
||||
fallbackAccountName: string | undefined
|
||||
): Promise<{ accountName: string; label: string }> {
|
||||
const parsed = parseProjectReference(projectReference);
|
||||
const accountForLookup = parsed.account ?? fallbackAccountName;
|
||||
if (!accountForLookup) {
|
||||
throw new Error(
|
||||
`Could not determine which account owns project "${projectReference}". Pass it as @account-name/project-slug.`
|
||||
);
|
||||
}
|
||||
|
||||
const fullName = `@${accountForLookup}/${parsed.slug}`;
|
||||
let app;
|
||||
try {
|
||||
app = await AppQuery.byFullNameAsync(graphqlClient, fullName);
|
||||
} catch {
|
||||
throw new Error(`Project ${fullName} was not found or you do not have access to it.`);
|
||||
}
|
||||
return { accountName: app.ownerAccount.name, label: app.fullName };
|
||||
}
|
||||
|
||||
function parseProjectReference(reference: string): { account?: string; slug: string } {
|
||||
const cleaned = reference.startsWith('@') ? reference.slice(1) : reference;
|
||||
const parts = cleaned.split('/');
|
||||
if (parts.length === 2 && parts[0] && parts[1]) {
|
||||
return { account: parts[0], slug: parts[1] };
|
||||
}
|
||||
return { slug: cleaned };
|
||||
}
|
||||
Loaded 100 of 213 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user