mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e192ecba6 | ||
|
|
864b11effe | ||
|
|
62df1bae1c | ||
|
|
8070663faa | ||
|
|
e38a5a88d2 | ||
|
|
82845def18 | ||
|
|
476b4e9b09 | ||
|
|
9315aebef5 | ||
|
|
786d8bac85 | ||
|
|
8f67105e5a | ||
|
|
3bfb405c87 | ||
|
|
73335e8233 | ||
|
|
9af03f901e | ||
|
|
13ab40e271 | ||
|
|
355f3f2012 | ||
|
|
736e1aeb90 | ||
|
|
58d372a096 | ||
|
|
56d01c4129 | ||
|
|
bcb5d96ea8 | ||
|
|
d02de4da64 | ||
|
|
04c214be93 | ||
|
|
1d9607ca00 | ||
|
|
78ba76001b | ||
|
|
3c8b62fb39 | ||
|
|
c3a26fef34 | ||
|
|
a2c0d0cf09 | ||
|
|
f148570a36 | ||
|
|
e8ca106f2e | ||
|
|
62ddc91aa4 |
No files matched your search
@@ -1,6 +0,0 @@
|
||||
---
|
||||
'@expo/eas-build-job': minor
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': none
|
||||
---
|
||||
|
||||
Make command descriptions in `eas --help` consistently lowercase without trailing periods.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
'eas-cli': none
|
||||
'@expo/build-tools': none
|
||||
---
|
||||
|
||||
Bump Node.js to 22.23.4 to fix the CVEs in the [October 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases).
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Stop sandboxes after an optional maximum duration measured from readiness.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/worker': patch
|
||||
---
|
||||
|
||||
Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages.
|
||||
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 8 * * *' # 8:00 AM UTC time every day
|
||||
|
||||
|
||||
@@ -2,6 +2,69 @@
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## [27.0.0](https://github.com/expo/eas-cli/releases/tag/v27.0.0) - 2026-10-27
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Write the reason a command failed to stderr, next to the `command failed.` line, instead of to stdout. A program that reads the reason from stdout must read stderr instead. ([#4592](https://github.com/expo/eas-cli/pull/4592) by [@vonovak](https://github.com/vonovak))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Send the `permissions.account.role` from the build profile to request a role for the build's `EXPO_TOKEN`. ([#4734](https://github.com/expo/eas-cli/pull/4734) by [@wschurman](https://github.com/wschurman))
|
||||
- [eas-cli] Add `simulator:logs` to read and follow logs from an iOS simulator session and download saved logs after it stops. ([#4545](https://github.com/expo/eas-cli/pull/4545) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Keep the launched app open when an Appium iOS session starts. ([#4699](https://github.com/expo/eas-cli/pull/4699) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Run Appium through the prepared Full Disk Access helper when available on macOS. ([#4622](https://github.com/expo/eas-cli/pull/4622) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Show permission guidance, including `permissions.account.role` in eas.json, when a build request fails with an authorization error. ([#4760](https://github.com/expo/eas-cli/pull/4760) by [@wschurman](https://github.com/wschurman))
|
||||
- [eas-cli] Print the notice about eas-cli in project dependencies only when stderr is a terminal, so programs that read stderr get the command's error. ([#4591](https://github.com/expo/eas-cli/pull/4591) by [@vonovak](https://github.com/vonovak))
|
||||
- [eas-cli] Show account roles and credential permission limits in `eas login` when you are already logged in. ([#4759](https://github.com/expo/eas-cli/pull/4759) by [@wschurman](https://github.com/wschurman))
|
||||
- [build-tools] Identify the build or application archive when a download fails, and explain when the archive is no longer available. ([#4703](https://github.com/expo/eas-cli/pull/4703) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Run apsd setup in the background without delaying iOS app launch or preview readiness. ([#4706](https://github.com/expo/eas-cli/pull/4706) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Fix simulator control action help routing and show help when a flag is incomplete. ([#4681](https://github.com/expo/eas-cli/pull/4681) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Require explicit `host:` simulator-host upload paths and report missing local files. ([#4683](https://github.com/expo/eas-cli/pull/4683) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation.
|
||||
- [build-tools] Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Run the GraphQL schema and generated code check on pull requests to any base branch, including stacked pull requests. ([#4763](https://github.com/expo/eas-cli/pull/4763) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
## [26.0.0](https://github.com/expo/eas-cli/releases/tag/v26.0.0) - 2026-10-26
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-build-job][build-tools] Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-build-job][build-tools] Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-json] Add `permissions.account.role` to build profiles to request a role for the build's `EXPO_TOKEN`. ([#4732](https://github.com/expo/eas-cli/pull/4732) by [@wschurman](https://github.com/wschurman))
|
||||
- [build-tools] Stop sandboxes after an optional maximum duration measured from readiness. ([#4702](https://github.com/expo/eas-cli/pull/4702) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Unify signed URL uploading to a single implementation and use it everywhere. ([#4712](https://github.com/expo/eas-cli/pull/4712) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Regenerate graphql code. ([#4692](https://github.com/expo/eas-cli/pull/4692) by [@wschurman](https://github.com/wschurman))
|
||||
- [build-tools] Default Android emulators to 4 GiB of RAM to reduce ADB disconnects during large transfers. Retain the 2 GiB default for API 34 and below outside interactive device sessions, and preserve explicit emulator configuration overrides. ([#4727](https://github.com/expo/eas-cli/pull/4727) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Retry transient failures when reporting that a device run session has started. ([#4680](https://github.com/expo/eas-cli/pull/4680) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log. ([#4690](https://github.com/expo/eas-cli/pull/4690) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Read the app config fewer times in `eas build` and other project commands. An iOS build now runs `expo config` 2 times instead of 6. ([`78ba760`](https://github.com/expo/eas-cli/commit/78ba76001b67c9971236af1a1d02e46c2f82d24a) by [@vonovak](https://github.com/vonovak))
|
||||
- [build-tools][steps] Stop printing workflow hook parsing messages in build logs: native builds showed them under an "Unknown build phase" group, and custom builds warned about unknown hook keys and hooks on steps that never run. ([#4717](https://github.com/expo/eas-cli/pull/4717) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [worker] Retry Node.js installation without the internal Node.js cache mirror when the first attempt fails, while preserving other configured mirrors. ([#3452](https://github.com/expo/eas-cli/pull/3452) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Share one GraphQL client between the project ID check and the command, so commands send one fewer app query. ([#4646](https://github.com/expo/eas-cli/pull/4646) by [@vonovak](https://github.com/vonovak))
|
||||
- [eas-cli] Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs. ([#4694](https://github.com/expo/eas-cli/pull/4694) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status. ([#4693](https://github.com/expo/eas-cli/pull/4693) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [worker] Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages. ([#4698](https://github.com/expo/eas-cli/pull/4698) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Make command descriptions in `eas --help` consistently lowercase without trailing periods. ([#4385](https://github.com/expo/eas-cli/pull/4385) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli][build-tools] Bump Node.js to 22.23.4 to fix the CVEs in the [October 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases). ([#4714](https://github.com/expo/eas-cli/pull/4714) by [@ramonclaudio](https://github.com/ramonclaudio))
|
||||
|
||||
## [25.3.0](https://github.com/expo/eas-cli/releases/tag/v25.3.0) - 2026-10-20
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
@@ -215,7 +215,7 @@ if: ${{ steps.previous_step.outputs.success == 'true' && env.ENVIRONMENT == 'pro
|
||||
Artifacts tracked as `ArtifactToUpload`:
|
||||
|
||||
- Managed artifacts (APK, IPA, AAB) with specific handling
|
||||
- Generic artifacts for any file
|
||||
- Workflow artifacts for any file
|
||||
- Upload via `ctx.uploadArtifact()` or `upload-artifact` step
|
||||
|
||||
## Package Interdependencies
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "25.3.0",
|
||||
"PRODUCTION": "25.3.0"
|
||||
"STAGING": "26.0.0",
|
||||
"PRODUCTION": "26.0.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "25.3.0",
|
||||
"version": "27.0.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,5 +1,35 @@
|
||||
# @expo/build-tools
|
||||
|
||||
## 27.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Keep the launched app open when an Appium iOS session starts. ([#4699](https://github.com/expo/eas-cli/pull/4699) by [@gwdp](https://github.com/gwdp))
|
||||
- Run Appium through the prepared Full Disk Access helper when available on macOS. ([#4622](https://github.com/expo/eas-cli/pull/4622) by [@gwdp](https://github.com/gwdp))
|
||||
- Identify the build or application archive when a download fails, and explain when the archive is no longer available. ([#4703](https://github.com/expo/eas-cli/pull/4703) by [@gwdp](https://github.com/gwdp))
|
||||
- Run apsd setup in the background without delaying iOS app launch or preview readiness. ([#4706](https://github.com/expo/eas-cli/pull/4706) by [@gwdp](https://github.com/gwdp))
|
||||
- Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp))
|
||||
- Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 26.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
- Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Stop sandboxes after an optional maximum duration measured from readiness. ([#4702](https://github.com/expo/eas-cli/pull/4702) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Unify signed URL uploading to a single implementation and use it everywhere. ([#4712](https://github.com/expo/eas-cli/pull/4712) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Default Android emulators to 4 GiB of RAM to reduce ADB disconnects during large transfers. Retain the 2 GiB default for API 34 and below outside interactive device sessions, and preserve explicit emulator configuration overrides. ([#4727](https://github.com/expo/eas-cli/pull/4727) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
|
||||
- Retry transient failures when reporting that a device run session has started. ([#4680](https://github.com/expo/eas-cli/pull/4680) by [@gwdp](https://github.com/gwdp))
|
||||
- Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log. ([#4690](https://github.com/expo/eas-cli/pull/4690) by [@hSATAC](https://github.com/hSATAC))
|
||||
- Stop printing workflow hook parsing messages in build logs: native builds showed them under an "Unknown build phase" group, and custom builds warned about unknown hook keys and hooks on steps that never run. ([#4717](https://github.com/expo/eas-cli/pull/4717) by [@hSATAC](https://github.com/hSATAC))
|
||||
|
||||
## 25.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "25.3.0",
|
||||
"version": "27.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -42,17 +42,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "25.3.0",
|
||||
"@expo/eas-build-job": "25.3.0",
|
||||
"@expo/downloader": "27.0.0",
|
||||
"@expo/eas-build-job": "27.0.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "25.3.0",
|
||||
"@expo/logger": "27.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": "25.3.0",
|
||||
"@expo/template-file": "25.3.0",
|
||||
"@expo/turtle-spawn": "25.3.0",
|
||||
"@expo/steps": "27.0.0",
|
||||
"@expo/template-file": "27.0.0",
|
||||
"@expo/turtle-spawn": "27.0.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
|
||||
@@ -16,11 +16,13 @@ function createCtx(
|
||||
{ workingdir = '/tmp/wd' }: { workingdir?: string } = {}
|
||||
): {
|
||||
ctx: BuildContext<Ios.Job>;
|
||||
info: jest.Mock;
|
||||
warn: jest.Mock;
|
||||
} {
|
||||
const info = jest.fn();
|
||||
const warn = jest.fn();
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
info,
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn,
|
||||
@@ -36,7 +38,7 @@ function createCtx(
|
||||
workingdir,
|
||||
}
|
||||
);
|
||||
return { ctx, warn };
|
||||
return { ctx, info, warn };
|
||||
}
|
||||
|
||||
// Project checkout lives at <workingdir>/build (job projectRootDirectory is '.').
|
||||
@@ -79,22 +81,31 @@ describe(parseJobHooksAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an unknown hook key as inert and warns', async () => {
|
||||
const { ctx, warn } = createCtx({
|
||||
it('writes nothing to the build log while parsing', async () => {
|
||||
const { ctx, info, warn } = createCtx({
|
||||
before_install_node_modules: [{ run: 'echo hi' }],
|
||||
before_submit: [{ run: 'echo hi' }],
|
||||
before_bogus: [{ run: 'echo hi' }],
|
||||
});
|
||||
await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(info).not.toHaveBeenCalled();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats an unknown hook key as inert', async () => {
|
||||
const { ctx } = createCtx({
|
||||
before_bogus: [{ run: 'echo hi' }],
|
||||
});
|
||||
const parsed = await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(parsed!.hookEntriesByKey).toEqual({});
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unknown hook key "before_bogus"'));
|
||||
});
|
||||
|
||||
it('shape-checks a registered-but-unwrapped key, then warns and skips it', async () => {
|
||||
const { ctx, warn } = createCtx({
|
||||
it('shape-checks a registered-but-unwrapped key, then skips it', async () => {
|
||||
const { ctx } = createCtx({
|
||||
before_submit: [{ run: 'echo hi' }],
|
||||
});
|
||||
const parsed = await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(parsed!.hookEntriesByKey.before_submit).toBeUndefined();
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
|
||||
});
|
||||
|
||||
it('shape-validates a registered-but-unwrapped key even though it is skipped', async () => {
|
||||
@@ -192,10 +203,9 @@ describe(parseJobHooksAsync, () => {
|
||||
});
|
||||
|
||||
it('never loads a composite function referenced under a registered-but-unwrapped key', async () => {
|
||||
const { ctx, warn } = createCtx({ before_submit: [{ uses: './.eas/functions/missing' }] });
|
||||
const { ctx } = createCtx({ before_submit: [{ uses: './.eas/functions/missing' }] });
|
||||
const parsed = await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(parsed!.hookEntriesByKey).toEqual({});
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
|
||||
});
|
||||
|
||||
it('wraps a cross-key duplicate step id in the aggregate error', async () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import spawn from '@expo/turtle-spawn';
|
||||
import { randomBytes, randomUUID } from 'crypto';
|
||||
import { vol } from 'memfs';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
import { Readable } from 'stream';
|
||||
import { setTimeout } from 'timers/promises';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
@@ -390,7 +391,13 @@ describe('projectSources', () => {
|
||||
ctx.graphqlClient.mutation = mockGraphqlMutation as any;
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockImplementation(async () => ({ ok: true }) as Response);
|
||||
let uploadedBody = '';
|
||||
fetchMock.mockImplementation(async (_url, request) => {
|
||||
for await (const chunk of request!.body as Readable) {
|
||||
uploadedBody += chunk;
|
||||
}
|
||||
return { ok: true } as Response;
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: ctx.job.projectArchive }),
|
||||
@@ -414,10 +421,14 @@ describe('projectSources', () => {
|
||||
'https://storage.example.com/upload',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
headers: { 'x-custom-header': 'value' },
|
||||
body: '{"archiveContent":["project/README.md","project/package.json"]}',
|
||||
headers: {
|
||||
'x-custom-header': 'value',
|
||||
'content-length': '63',
|
||||
'content-type': 'text/plain;charset=UTF-8',
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(uploadedBody).toBe('{"archiveContent":["project/README.md","project/package.json"]}');
|
||||
|
||||
// Verify build metadata was updated
|
||||
expect(mockGraphqlMutation).toHaveBeenNthCalledWith(
|
||||
|
||||
@@ -44,9 +44,8 @@ export async function runHookableBuildPhaseAsync<TJob extends BuildJob, T>({
|
||||
if (hooks) {
|
||||
try {
|
||||
await runHookEntriesAsync({ ctx, hooks, timing: 'after', anchor, anchorFailed: true });
|
||||
} catch (hookError) {
|
||||
// The phase error wins; still surface the after-hook failure in the log.
|
||||
ctx.logger.error({ err: hookError }, `after_${anchor} hook failed`);
|
||||
} catch {
|
||||
// The phase error wins.
|
||||
}
|
||||
}
|
||||
throw phaseError;
|
||||
|
||||
@@ -53,19 +53,17 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
|
||||
const globalContext = new BuildStepGlobalContext(customBuildContext, false);
|
||||
// Hook steps run in the checked-out project directory, not the pre-checkout
|
||||
// target directory the global context defaults to.
|
||||
globalContext.markAsCheckedOut(ctx.logger);
|
||||
globalContext.markAsCheckedOut();
|
||||
|
||||
const externalFunctions = getEasFunctions(customBuildContext);
|
||||
const externalFunctionGroups = getEasFunctionGroups(customBuildContext);
|
||||
|
||||
// Shape-validate every REGISTERED key, reachable or not (steps-world parity:
|
||||
// a malformed `before_submit` fails a steps build, so it fails here too), and
|
||||
// warn on keys this build won't run. An unknown key is inert — a key newer
|
||||
// than this worker must be ignored, never fail the job.
|
||||
// a malformed `before_submit` fails a steps build, so it fails here too). An
|
||||
// unknown key is inert — a key newer than this worker must be ignored, never
|
||||
// fail the job.
|
||||
for (const [key, steps] of Object.entries(hooks)) {
|
||||
const parsed = parseHookKey(key);
|
||||
if (parsed === null) {
|
||||
ctx.logger.warn(`Ignoring unknown hook key "${key}".`);
|
||||
if (parseHookKey(key) === null) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(steps) && steps.length === 0) {
|
||||
@@ -81,15 +79,6 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
// Registered but not wrapped by this build: shape-checked above, then
|
||||
// skipped. Function existence is deliberately NOT checked (that happens only
|
||||
// during construction) — an unknown `uses:` under an unwrapped key warns
|
||||
// rather than failing the build.
|
||||
if (!wrappedAnchors.includes(parsed.anchorId)) {
|
||||
ctx.logger.warn(
|
||||
`Ignoring "hooks.${key}": this build does not run the "${parsed.anchorId}" step.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Construct entries for the wrapped anchors in EXECUTION order (per anchor,
|
||||
|
||||
@@ -5,13 +5,13 @@ import { asyncResult } from '@expo/results';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs/promises';
|
||||
import { graphql } from 'gql.tada';
|
||||
import fetch from 'node-fetch';
|
||||
import nullthrows from 'nullthrows';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { shallowCloneRepositoryAsync } from './git';
|
||||
import { BuildContext } from '../context';
|
||||
import { uploadContentWithSignedUrl } from '../storage/uploadWithSignedUrl';
|
||||
import { turtleFetch } from '../utils/turtleFetch';
|
||||
|
||||
export async function prepareProjectSourcesAsync<TJob extends Job>(
|
||||
@@ -208,10 +208,9 @@ async function uploadProjectMetadataAsync(
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
await fetch(uploadSession.url, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ archiveContent: sortedFiles }),
|
||||
headers: uploadSession.headers,
|
||||
await uploadContentWithSignedUrl({
|
||||
signedUrl: uploadSession,
|
||||
content: JSON.stringify({ archiveContent: sortedFiles }),
|
||||
});
|
||||
|
||||
const updateMetadataResult = await ctx.graphqlClient
|
||||
|
||||
@@ -4,13 +4,11 @@ import {
|
||||
BuildPhaseResult,
|
||||
Env,
|
||||
EnvironmentSecretType,
|
||||
GenericArtifactType,
|
||||
Job,
|
||||
LogMarker,
|
||||
ManagedArtifactType,
|
||||
Metadata,
|
||||
errors,
|
||||
isGenericArtifact,
|
||||
} from '@expo/eas-build-job';
|
||||
import { BuildTrigger } from '@expo/eas-build-job/dist/common';
|
||||
import { bunyan } from '@expo/logger';
|
||||
@@ -42,7 +40,7 @@ export type ArtifactToUpload =
|
||||
paths: string[];
|
||||
}
|
||||
| {
|
||||
type: GenericArtifactType;
|
||||
type: null;
|
||||
name: string;
|
||||
paths: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
@@ -241,7 +239,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
logger: bunyan;
|
||||
}): Promise<{ artifactId: string | null }> {
|
||||
const result = await this._uploadArtifact({ artifact, logger });
|
||||
if (result.filename && !isGenericArtifact(artifact)) {
|
||||
if (result.filename && artifact.type !== null) {
|
||||
this.artifacts[artifact.type] = result.filename;
|
||||
}
|
||||
return { artifactId: result.artifactId ?? null };
|
||||
|
||||
@@ -3,8 +3,16 @@ import HttpLogStream from './logging/HttpLogStream';
|
||||
import RemoteLoggerStream from './logging/RemoteLoggerStream';
|
||||
|
||||
export { Builders, HttpLogStream, RemoteLoggerStream };
|
||||
export { uploadWithSignedUrl } from './storage/uploadWithSignedUrl';
|
||||
export type { SignedUrl, UploadWithSignedUrlParams } from './storage/uploadWithSignedUrl';
|
||||
export {
|
||||
SignedUrlUploadApiError,
|
||||
SignedUrlUploadNetworkError,
|
||||
uploadWithSignedUrl,
|
||||
} from './storage/uploadWithSignedUrl';
|
||||
export type {
|
||||
SignedUrl,
|
||||
UploadWithSignedUrlOptions,
|
||||
UploadWithSignedUrlParams,
|
||||
} from './storage/uploadWithSignedUrl';
|
||||
export { downloadCacheAsync } from './steps/functions/restoreCache';
|
||||
export { uploadCacheAsync } from './steps/functions/saveCache';
|
||||
|
||||
|
||||
@@ -56,10 +56,10 @@ class RemoteLoggerStream extends Writable {
|
||||
return null;
|
||||
}
|
||||
|
||||
public async init(): Promise<string> {
|
||||
public async init(): Promise<void> {
|
||||
this.fileHandle = await fs.open(this.temporaryLogsPath, 'w+', 0o660);
|
||||
this.flushInterval = setInterval(() => this.flush(), this.options.uploadIntervalMs);
|
||||
return (await this.flush(true)) as string;
|
||||
await this.flush(true);
|
||||
}
|
||||
|
||||
public async cleanUp(): Promise<void> {
|
||||
@@ -121,17 +121,18 @@ class RemoteLoggerStream extends Writable {
|
||||
}
|
||||
}
|
||||
|
||||
private async flush(force = false): Promise<string | void> {
|
||||
private async flush(force = false): Promise<void> {
|
||||
if (force || this.hasChangesToUpload) {
|
||||
if (this.uploadingPromise) {
|
||||
await this.uploadingPromise;
|
||||
return await this.flush(force);
|
||||
await this.flush(force);
|
||||
return;
|
||||
}
|
||||
return await this.flushInternal();
|
||||
await this.flushInternal();
|
||||
}
|
||||
}
|
||||
|
||||
private async flushInternal(): Promise<string | void> {
|
||||
private async flushInternal(): Promise<void> {
|
||||
await this.safeWriteToFile();
|
||||
|
||||
let resolveFn: PromiseResolveFn;
|
||||
@@ -139,21 +140,17 @@ class RemoteLoggerStream extends Writable {
|
||||
this.uploadingPromise = new Promise(res => {
|
||||
resolveFn = res;
|
||||
});
|
||||
return await this.upload()
|
||||
.then(result => {
|
||||
return result;
|
||||
})
|
||||
await this.upload()
|
||||
.catch(err => {
|
||||
this.logger.error({ err }, 'Failed to upload logs file');
|
||||
})
|
||||
.then(result => {
|
||||
.then(() => {
|
||||
this.uploadingPromise = undefined;
|
||||
resolveFn();
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
private async upload(): Promise<string | void> {
|
||||
private async upload(): Promise<void> {
|
||||
if (!this.uploadMethod) {
|
||||
return;
|
||||
}
|
||||
@@ -165,7 +162,7 @@ class RemoteLoggerStream extends Writable {
|
||||
);
|
||||
};
|
||||
|
||||
return await uploadWithSignedUrl({
|
||||
await uploadWithSignedUrl({
|
||||
signedUrl: this.uploadMethod.signedUrl,
|
||||
srcGeneratorAsync,
|
||||
});
|
||||
|
||||
@@ -48,7 +48,10 @@ describe(createCheckoutBuildFunction, () => {
|
||||
});
|
||||
|
||||
it('moves project sources to the target directory when no inputs are given', async () => {
|
||||
const logger = createMockLogger();
|
||||
jest.mocked(logger.child).mockReturnValue(logger);
|
||||
const { globalCtx, executeAsync } = createCheckoutStep({
|
||||
logger,
|
||||
projectArchive: GIT_PROJECT_ARCHIVE,
|
||||
});
|
||||
|
||||
@@ -56,6 +59,9 @@ describe(createCheckoutBuildFunction, () => {
|
||||
|
||||
expect(await vol.promises.readFile(`${projectTargetDirectory}/app.json`, 'utf8')).toBe('{}');
|
||||
expect(globalCtx.wasCheckedOut()).toBe(true);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Changing default working directory to')
|
||||
);
|
||||
expect(fetchAndCheckoutRefAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -96,14 +102,11 @@ describe(createCheckoutBuildFunction, () => {
|
||||
});
|
||||
|
||||
it('rejects the ref input when the project is already checked out', async () => {
|
||||
const logger = createMockLogger();
|
||||
jest.mocked(logger.child).mockReturnValue(logger);
|
||||
const { globalCtx, executeAsync } = createCheckoutStep({
|
||||
logger,
|
||||
projectArchive: GIT_PROJECT_ARCHIVE,
|
||||
callInputs: { ref: 'feature/add-icon' },
|
||||
});
|
||||
globalCtx.markAsCheckedOut(logger);
|
||||
globalCtx.markAsCheckedOut();
|
||||
|
||||
await expect(executeAsync()).rejects.toMatchObject({
|
||||
errorCode: 'EAS_CHECKOUT_REF_AFTER_CHECKOUT',
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ beforeEach(() => {
|
||||
const events = { stopAsync: stopEvents, getLastEventObservedAt: () => undefined };
|
||||
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue(events);
|
||||
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue(events);
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
|
||||
@@ -162,6 +162,59 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('downloadBuild', () => {
|
||||
it.each([404, 410])('identifies the unavailable build on HTTP %s', async status => {
|
||||
const buildId = randomUUID();
|
||||
const { Response: ActualResponse } =
|
||||
jest.requireActual<typeof import('node-fetch')>('node-fetch');
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(new ActualResponse('<Error>NoSuchKey</Error>', { status }));
|
||||
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId,
|
||||
graphqlClient: createMockGraphqlClient({ applicationArchiveUrl: APPLICATION_ARCHIVE_URL }),
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
errorCode: 'EAS_DOWNLOAD_BUILD_ARCHIVE_UNAVAILABLE',
|
||||
message:
|
||||
`Failed to download the application archive for build ${buildId} (HTTP ${status}). ` +
|
||||
'The archive is unavailable. Check that the archive still exists and try again.',
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([404, 410, 500])(
|
||||
'identifies the application URL without credentials on HTTP %s',
|
||||
async status => {
|
||||
const { Response: ActualResponse } =
|
||||
jest.requireActual<typeof import('node-fetch')>('node-fetch');
|
||||
jest.mocked(fetch).mockResolvedValue(new ActualResponse('download failed', { status }));
|
||||
|
||||
const download = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl:
|
||||
'https://user:[email protected]/app.tar.gz?token=secret#fragment',
|
||||
graphqlClient: createMockGraphqlClient({}),
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
|
||||
const prefix = `Failed to download the archive at https://example.test/app.tar.gz (HTTP ${status})`;
|
||||
await expect(download).rejects.toThrow(
|
||||
status === 500
|
||||
? `${prefix}: download failed`
|
||||
: `${prefix}. The archive is unavailable. ` +
|
||||
'Check that the URL is correct and the archive still exists, then try again.'
|
||||
);
|
||||
await expect(download).rejects.not.toThrow(/password|secret|fragment/);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
@@ -305,7 +305,6 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
type: GenericArtifactType.OTHER,
|
||||
name:
|
||||
format === 'html' ? 'Maestro Runner HTML Report' : 'Maestro Runner Allure Results',
|
||||
paths: [
|
||||
@@ -1091,7 +1090,6 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
type: GenericArtifactType.OTHER,
|
||||
name: 'Maestro HTML Report',
|
||||
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
|
||||
}),
|
||||
@@ -1420,7 +1418,6 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
type: GenericArtifactType.OTHER,
|
||||
name: shot.displayName,
|
||||
metadata: shot.metadata,
|
||||
}),
|
||||
|
||||
+11
-1
@@ -165,7 +165,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopPreview,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
|
||||
expect(lifetime.aborted).toBe(false);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(1);
|
||||
@@ -176,6 +176,16 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('releases resources without monitoring when the session stopped during startup', async () => {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValueOnce('stopped');
|
||||
await run();
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(stopPreview).toHaveBeenCalledTimes(1);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('reports credentials and starts monitoring before waiting, then releases resources', async () => {
|
||||
await run();
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
|
||||
+13
-1
@@ -103,6 +103,18 @@ async function runAsync(
|
||||
}
|
||||
|
||||
describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
it('cleans up a stopped startup without starting live-session polling', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValueOnce('stopped');
|
||||
await runAsync(logger, BuildRuntimePlatform.DARWIN);
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
expect(pollAgentDeviceArtifactsForUploadAsync).not.toHaveBeenCalled();
|
||||
expect(startAgentDeviceEventCollectionAsync).not.toHaveBeenCalled();
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalled();
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalled();
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
@@ -147,7 +159,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
}
|
||||
return host;
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
|
||||
await fs.promises.mkdir(path.dirname(DAEMON_ENTRY_PATH), { recursive: true });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
@@ -202,6 +203,78 @@ describe(installAppiumAsync, () => {
|
||||
});
|
||||
|
||||
describe('createStartAppiumRemoteSessionBuildFunction', () => {
|
||||
it.each([
|
||||
[BuildRuntimePlatform.DARWIN, true],
|
||||
[BuildRuntimePlatform.DARWIN, false],
|
||||
[BuildRuntimePlatform.LINUX, true],
|
||||
[BuildRuntimePlatform.LINUX, false],
|
||||
])('starts Appium on %s with helper present=%s', async (runtimePlatform, helperPresent) => {
|
||||
const helper = '/usr/local/libexec/turtle-tcc-fda';
|
||||
if (helperPresent) {
|
||||
fs.mkdirSync(path.dirname(helper), { recursive: true });
|
||||
fs.writeFileSync(helper, 'prepared helper');
|
||||
}
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'simulator' }] as never);
|
||||
jest
|
||||
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
|
||||
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' }] as never);
|
||||
// Stop at the launch boundary; no readiness requests or remote session are needed.
|
||||
const launched = new Error('launch captured');
|
||||
jest.mocked(spawnDetached).mockImplementation(() => {
|
||||
throw launched;
|
||||
});
|
||||
const env = {
|
||||
DEVICE_RUN_SESSION_ID: 'session',
|
||||
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
|
||||
PATH: '/configured/node/bin:/usr/bin',
|
||||
DEVELOPER_DIR: '/Applications/Xcode.app/Contents/Developer',
|
||||
};
|
||||
const buildFunction = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: { ...env, NGROK_AUTHTOKEN: 'token' },
|
||||
} as never
|
||||
)
|
||||
).rejects.toBe(launched);
|
||||
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
const launch = jest.mocked(spawnDetached).mock.calls[0][0];
|
||||
const appiumBinPath = path.join(launch.env.APPIUM_HOME!, 'node_modules', '.bin', 'appium');
|
||||
const useHelper = runtimePlatform === BuildRuntimePlatform.DARWIN && helperPresent;
|
||||
expect(launch).toEqual({
|
||||
command: useHelper ? helper : appiumBinPath,
|
||||
args: [
|
||||
...(useHelper ? [appiumBinPath] : []),
|
||||
'--address',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'4723',
|
||||
'--base-path',
|
||||
'/',
|
||||
'--log-level',
|
||||
'error',
|
||||
'--allow-insecure',
|
||||
'*:session_discovery',
|
||||
'--default-capabilities',
|
||||
runtimePlatform === BuildRuntimePlatform.DARWIN
|
||||
? '{"appium:eventTimings":true,"appium:isHeadless":true}'
|
||||
: '{"appium:eventTimings":true}',
|
||||
],
|
||||
env: { ...env, APPIUM_HOME: launch.env.APPIUM_HOME },
|
||||
logger,
|
||||
});
|
||||
});
|
||||
|
||||
it('declares the launch inputs so serve-sim can launch the app', () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const buildFunction = createStartAppiumRemoteSessionBuildFunction(ctx);
|
||||
@@ -361,6 +434,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
|
||||
'appium:eventTimings': true,
|
||||
'appium:isHeadless': true,
|
||||
'appium:usePreinstalledWDA': true,
|
||||
'appium:prebuiltWDAPath': '/tmp/legacy-wda.app',
|
||||
});
|
||||
@@ -447,6 +521,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
);
|
||||
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
|
||||
'appium:eventTimings': true,
|
||||
'appium:isHeadless': true,
|
||||
'appium:usePreinstalledWDA': true,
|
||||
'appium:prebuiltWDAPath': '/tmp/selected-wda.app',
|
||||
});
|
||||
|
||||
+1
-1
@@ -128,7 +128,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
|
||||
// The (real) tool-server state wait reads this file from the redirected ~/.argent.
|
||||
|
||||
+43
-1
@@ -84,11 +84,52 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
stopAsync.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('uses preview activity to reset the configured idle timeout', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, { max_idle_time_minutes: { value: 5 } });
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
|
||||
expect(idleTimeout?.getLastEventObservedAt()).toBeUndefined();
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
|
||||
const observedAt = new Date();
|
||||
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(observedAt);
|
||||
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(observedAt);
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'leaves the idle timer disabled when omitted on %s',
|
||||
async runtimePlatform => {
|
||||
await runAsync(runtimePlatform);
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ idleTimeout: undefined, maxDurationSeconds: 120 })
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('leaves the idle timer disabled for zero', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, {
|
||||
max_idle_time_minutes: { value: 0 },
|
||||
});
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ idleTimeout: undefined, maxDurationSeconds: 120 })
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an Android idle timeout before starting the host', async () => {
|
||||
await expect(
|
||||
runAsync(BuildRuntimePlatform.LINUX, {
|
||||
max_idle_time_minutes: { value: 5 },
|
||||
})
|
||||
).rejects.toThrow('Omit max_idle_time_minutes for Android sessions.');
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports the session token when serve-sim minted one', async () => {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
@@ -268,6 +309,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
'frame_ancestors',
|
||||
'cors_origins',
|
||||
'package_version',
|
||||
'max_idle_time_minutes',
|
||||
'max_duration_seconds',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -121,26 +121,32 @@ describe(createUploadArtifactBuildFunction, () => {
|
||||
expect(typeInput.isRawValueOneOfAllowedValues()).toBe(false);
|
||||
});
|
||||
|
||||
it('passes the metadata input through to runtimeApi.uploadArtifact', async () => {
|
||||
contextUploadArtifact.mockClear();
|
||||
const metadata = { kind: 'screen-recording', startedAtMs: '123', device: 'emulator-5554' };
|
||||
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
|
||||
callInputs: {
|
||||
// Generic ('other') artifact: only generic artifacts persist metadata in the worker
|
||||
// (the managed-type upload paths drop it), so that's the type metadata is meant for.
|
||||
type: 'other',
|
||||
key: 'Screen Recording',
|
||||
path: '/',
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
it.each(['other', 'android-apk', 'android-aab', 'ios-simulator-app', 'ios-ipa'])(
|
||||
'uploads %s as a workflow artifact with metadata',
|
||||
async type => {
|
||||
contextUploadArtifact.mockClear();
|
||||
const metadata = { kind: 'screen-recording', startedAtMs: '123', device: 'emulator-5554' };
|
||||
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({}),
|
||||
{
|
||||
callInputs: {
|
||||
type,
|
||||
key: 'Screen Recording',
|
||||
path: '/',
|
||||
metadata,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(contextUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ artifact: expect.objectContaining({ metadata }) })
|
||||
);
|
||||
});
|
||||
expect(contextUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({ type: null, metadata }),
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not attach metadata to managed artifact types (the worker drops it)', async () => {
|
||||
contextUploadArtifact.mockClear();
|
||||
|
||||
@@ -58,7 +58,11 @@ export function createCheckoutBuildFunction(): BuildFunction {
|
||||
});
|
||||
}
|
||||
|
||||
stepsCtx.global.markAsCheckedOut(stepsCtx.logger);
|
||||
const previousWorkingDirectory = stepsCtx.global.defaultWorkingDirectory;
|
||||
stepsCtx.global.markAsCheckedOut();
|
||||
stepsCtx.logger.info(
|
||||
`Changing default working directory to ${stepsCtx.global.defaultWorkingDirectory} (was ${previousWorkingDirectory})`
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -312,8 +312,22 @@ export async function downloadBuildAsync(
|
||||
const response = await fetch(downloadUrl, { headers, signal });
|
||||
if (!response.ok) {
|
||||
const textResult = await asyncResult(response.text());
|
||||
const archiveUrl = new URL(downloadUrl);
|
||||
const source = params.buildId
|
||||
? `application archive for build ${params.buildId}`
|
||||
: `archive at ${archiveUrl.origin}${archiveUrl.pathname}`;
|
||||
if (response.status === 404 || response.status === 410) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_ARCHIVE_UNAVAILABLE',
|
||||
`Failed to download the ${source} (HTTP ${response.status}). ` +
|
||||
'The archive is unavailable. ' +
|
||||
(params.buildId
|
||||
? 'Check that the archive still exists and try again.'
|
||||
: 'Check that the URL is correct and the archive still exists, then try again.')
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Unexpected response from server (${response.status}): ${textResult.value}`
|
||||
`Failed to download the ${source} (HTTP ${response.status}): ${textResult.value}`
|
||||
);
|
||||
}
|
||||
const archiveFilename = resolveArchiveFilename({ response, extensions });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
@@ -631,7 +631,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
try {
|
||||
await ctx.runtimeApi.uploadArtifact({
|
||||
artifact: {
|
||||
type: GenericArtifactType.OTHER,
|
||||
type: null,
|
||||
name: selectedReport.name,
|
||||
paths: [selectedReport.artifactPath],
|
||||
},
|
||||
@@ -738,7 +738,7 @@ async function uploadFailureScreenshotsAsync({
|
||||
await fs.copyFile(shot.fileAbsPath, safePath);
|
||||
await ctx.runtimeApi.uploadArtifact({
|
||||
artifact: {
|
||||
type: GenericArtifactType.OTHER,
|
||||
type: null,
|
||||
name: shot.displayName,
|
||||
paths: [safePath],
|
||||
metadata: shot.metadata,
|
||||
|
||||
@@ -11,6 +11,7 @@ import path from 'path';
|
||||
import * as tar from 'tar';
|
||||
import z from 'zod';
|
||||
|
||||
import { uploadWithSignedUrl } from '../../storage/uploadWithSignedUrl';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
|
||||
import { getCacheVersion } from '../utils/cache';
|
||||
@@ -161,16 +162,13 @@ export async function uploadCacheAsync({
|
||||
|
||||
logger.info(`Uploading cache...`);
|
||||
|
||||
const uploadResponse = await retryOnDNSFailure(fetch)(new URL(url), {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: fs.createReadStream(archivePath),
|
||||
});
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(
|
||||
`Unexpected response from cache server (${uploadResponse.status}): ${uploadResponse.statusText}`
|
||||
);
|
||||
}
|
||||
await uploadWithSignedUrl(
|
||||
{
|
||||
signedUrl: { url, headers },
|
||||
srcGeneratorAsync: async () => fs.createReadStream(archivePath),
|
||||
},
|
||||
{ retries: 3, retryIntervalMs: 100 }
|
||||
);
|
||||
logger.info(`Uploaded cache archive to ${archivePath} (${formatBytes(size)}).`);
|
||||
}
|
||||
|
||||
@@ -226,16 +224,13 @@ export async function uploadPublicCacheAsync({
|
||||
|
||||
logger.info(`Uploading public cache...`);
|
||||
|
||||
const uploadResponse = await retryOnDNSFailure(fetch)(new URL(url), {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: fs.createReadStream(archivePath),
|
||||
});
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(
|
||||
`Unexpected response from cache server (${uploadResponse.status}): ${uploadResponse.statusText}`
|
||||
);
|
||||
}
|
||||
await uploadWithSignedUrl(
|
||||
{
|
||||
signedUrl: { url, headers },
|
||||
srcGeneratorAsync: async () => fs.createReadStream(archivePath),
|
||||
},
|
||||
{ retries: 3, retryIntervalMs: 100 }
|
||||
);
|
||||
logger.info(`Uploaded cache archive to ${archivePath} (${formatBytes(size)}).`);
|
||||
}
|
||||
|
||||
|
||||
@@ -225,7 +225,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
tasks.signal.throwIfAborted();
|
||||
logger.info(tasks.summary());
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
@@ -239,6 +239,9 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
},
|
||||
logger,
|
||||
});
|
||||
if (sessionStart === 'stopped') {
|
||||
return;
|
||||
}
|
||||
tasks.signal.throwIfAborted();
|
||||
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
|
||||
@@ -65,6 +65,7 @@ const APPIUM_HOST = '127.0.0.1';
|
||||
const APPIUM_PORT = 4723;
|
||||
const APPIUM_STARTUP_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_APPIUM_VERSION = '^3';
|
||||
const FDA_HELPER = '/usr/local/libexec/turtle-tcc-fda';
|
||||
|
||||
const AppiumInstalledDriversSchema = z.record(
|
||||
z.string(),
|
||||
@@ -225,9 +226,12 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
});
|
||||
|
||||
setupSignal?.throwIfAborted();
|
||||
const useFdaHelper =
|
||||
runtimePlatform === BuildRuntimePlatform.DARWIN && fs.existsSync(FDA_HELPER);
|
||||
appiumProcess = spawnDetached({
|
||||
command: appiumBinPath,
|
||||
command: useFdaHelper ? FDA_HELPER : appiumBinPath,
|
||||
args: [
|
||||
...(useFdaHelper ? [appiumBinPath] : []),
|
||||
'--address',
|
||||
APPIUM_HOST,
|
||||
'--port',
|
||||
@@ -242,7 +246,13 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
'--allow-insecure',
|
||||
'*:session_discovery',
|
||||
'--default-capabilities',
|
||||
JSON.stringify({ 'appium:eventTimings': true, ...wdaCapabilities }),
|
||||
JSON.stringify({
|
||||
'appium:eventTimings': true,
|
||||
// Without it, XCUITest restarted the booted Simulator at session start, which
|
||||
// closed the app serve-sim had launched. EAS VMs never show the Simulator window.
|
||||
...(device.platformName === 'iOS' ? { 'appium:isHeadless': true } : {}),
|
||||
...wdaCapabilities,
|
||||
}),
|
||||
],
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
@@ -278,7 +288,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
authtoken: ngrokAuthtoken,
|
||||
});
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
@@ -296,6 +306,9 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
},
|
||||
logger,
|
||||
});
|
||||
if (sessionStart === 'stopped') {
|
||||
return;
|
||||
}
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
|
||||
@@ -360,7 +360,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
: 'Private preview URL unavailable for this session.'
|
||||
);
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
@@ -374,6 +374,9 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
},
|
||||
logger,
|
||||
});
|
||||
if (sessionStart === 'stopped') {
|
||||
return;
|
||||
}
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -57,6 +58,11 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_idle_time_minutes',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_duration_seconds',
|
||||
required: false,
|
||||
@@ -68,9 +74,20 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
|
||||
const env = omitNgrokAuthtoken(stepEnv);
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes?.value as number | undefined;
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
const packageVersion = inputs.package_version?.value as string | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
if (
|
||||
maxIdleTimeMinutes !== undefined &&
|
||||
maxIdleTimeMinutes > 0 &&
|
||||
runtimePlatform !== BuildRuntimePlatform.DARWIN
|
||||
) {
|
||||
throw new UserError(
|
||||
'UNSUPPORTED_WEB_PREVIEW_IDLE_TIMEOUT',
|
||||
'Web preview idle timeouts require iOS preview activity reporting. Omit max_idle_time_minutes for Android sessions.'
|
||||
);
|
||||
}
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
@@ -129,7 +146,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
? `Preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
|
||||
: 'Private preview URL unavailable for this session.'
|
||||
);
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
@@ -147,6 +164,9 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
},
|
||||
logger,
|
||||
});
|
||||
if (sessionStart === 'stopped') {
|
||||
return;
|
||||
}
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
@@ -154,6 +174,14 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
logger,
|
||||
maxDurationSeconds,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
getLastPreviewActivityAt: () => sessionHost.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
await sessionHost.finishAsync();
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
GenericArtifactType,
|
||||
Job,
|
||||
ManagedArtifactType,
|
||||
isGenericArtifact,
|
||||
} from '@expo/eas-build-job';
|
||||
import { Job, ManagedArtifactType } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
@@ -33,7 +28,12 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
|
||||
ManagedArtifactType.APPLICATION_ARCHIVE,
|
||||
ManagedArtifactType.BUILD_ARTIFACTS,
|
||||
...Object.keys(artifactTypeInputToManagedArtifactType),
|
||||
...Object.values(GenericArtifactType),
|
||||
// Keep accepting these legacy artifact types for compatibility.
|
||||
'android-apk',
|
||||
'android-aab',
|
||||
'ios-simulator-app',
|
||||
'ios-ipa',
|
||||
'other',
|
||||
],
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
@@ -126,12 +126,13 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
|
||||
paths: artifactPaths,
|
||||
name: (inputs.name.value || inputs.key.value) as string,
|
||||
};
|
||||
const artifact = isGenericArtifact(artifactSpec)
|
||||
? {
|
||||
...artifactSpec,
|
||||
metadata: inputs.metadata.value as Record<string, unknown> | undefined,
|
||||
}
|
||||
: artifactSpec;
|
||||
const artifact =
|
||||
artifactSpec.type === null
|
||||
? {
|
||||
...artifactSpec,
|
||||
metadata: inputs.metadata.value as Record<string, unknown> | undefined,
|
||||
}
|
||||
: artifactSpec;
|
||||
|
||||
try {
|
||||
const { artifactId } = await ctx.runtimeApi.uploadArtifact({ artifact, logger });
|
||||
@@ -141,7 +142,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
|
||||
}
|
||||
} catch (error) {
|
||||
if (inputs.ignore_error.value) {
|
||||
logger.error({ err: error }, `Failed to upload ${artifact.type}. Ignoring error.`);
|
||||
logger.error({ err: error }, 'Failed to upload artifact. Ignoring error.');
|
||||
// Ignoring error.
|
||||
return;
|
||||
}
|
||||
@@ -158,7 +159,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
|
||||
* values. This makes sure we support all:
|
||||
* - kebab-case managed artifact types (the original)
|
||||
* - snake-caps-case managed artifact types (the mistake)
|
||||
* - generic artifact types.
|
||||
* - workflow artifact types.
|
||||
*/
|
||||
function parseArtifactTypeInput({
|
||||
inputValue,
|
||||
@@ -166,19 +167,15 @@ function parseArtifactTypeInput({
|
||||
}: {
|
||||
inputValue: string;
|
||||
platform: Job['platform'];
|
||||
}): GenericArtifactType | ManagedArtifactType {
|
||||
}): ManagedArtifactType | null {
|
||||
if (!inputValue) {
|
||||
// In build jobs the default artifact type is application-archive.
|
||||
return platform ? ManagedArtifactType.APPLICATION_ARCHIVE : GenericArtifactType.OTHER;
|
||||
return platform ? ManagedArtifactType.APPLICATION_ARCHIVE : null;
|
||||
}
|
||||
|
||||
// Step's allowedValues ensures input is either
|
||||
// a key of artifactTypeInputToManagedArtifactType
|
||||
// or a value of an artifact type.
|
||||
const translatedManagedArtifactType = artifactTypeInputToManagedArtifactType[inputValue];
|
||||
if (translatedManagedArtifactType) {
|
||||
return translatedManagedArtifactType;
|
||||
}
|
||||
|
||||
return inputValue as GenericArtifactType | ManagedArtifactType;
|
||||
return (
|
||||
artifactTypeInputToManagedArtifactType[inputValue] ??
|
||||
Object.values(ManagedArtifactType).find(type => type === inputValue) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,14 @@ import {
|
||||
} from '../deviceRunSessionPreview';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
jest.mock('node-fetch', () => {
|
||||
const actual = jest.requireActual('node-fetch');
|
||||
return {
|
||||
__esModule: true,
|
||||
...actual,
|
||||
default: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
@@ -87,8 +94,8 @@ describe(startDeviceRunSessionPreview, () => {
|
||||
'https://uploads.expo.test/preview',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: image,
|
||||
headers: { 'content-type': 'image/webp' },
|
||||
body: expect.any(Readable),
|
||||
headers: { 'content-type': 'image/webp', 'content-length': String(image.length) },
|
||||
})
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(59_999);
|
||||
@@ -130,9 +137,7 @@ describe(startDeviceRunSessionPreview, () => {
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const [fields] = jest.mocked(logger.warn).mock.calls[0] as unknown as [{ err: Error }];
|
||||
expect(fields.err.message).toBe(
|
||||
'Session preview upload request failed (name: FetchError, type: request-timeout).'
|
||||
);
|
||||
expect(fields.err.message).toBe('Signed URL upload failed: network error.');
|
||||
expect(JSON.stringify(jest.mocked(logger.warn).mock.calls)).not.toContain('uploads.expo.test');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { FetchError } from 'node-fetch';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
} from '../deviceRunSessionWorkerApi';
|
||||
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.unmock('node-fetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
type Response = InstanceType<typeof Response>;
|
||||
@@ -262,12 +264,12 @@ describe(DeviceRunSessionWorkerApi, () => {
|
||||
it('rejects an application response without the build field', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { platform: 'ios' } }));
|
||||
|
||||
await expect(createApi().getApplicationAsync()).rejects.toThrow(
|
||||
await expect(createApi().getApplicationAsync({ retries: 3 })).rejects.toThrow(
|
||||
'GET /v2/device-run-session-worker/application returned an unexpected response'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the signal, timeout, and retries to the request', async () => {
|
||||
it('passes the signal and timeout without enabling nested retries', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { iceServers: [] } }));
|
||||
const controller = new AbortController();
|
||||
|
||||
@@ -280,7 +282,7 @@ describe(DeviceRunSessionWorkerApi, () => {
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'POST',
|
||||
expect.objectContaining({ signal: controller.signal, timeout: 5000, retries: 1 })
|
||||
expect.objectContaining({ signal: controller.signal, timeout: 5000, retries: 0 })
|
||||
);
|
||||
});
|
||||
|
||||
@@ -319,7 +321,7 @@ describe(DeviceRunSessionWorkerApi, () => {
|
||||
);
|
||||
|
||||
const error = await createApi()
|
||||
.getSessionAsync()
|
||||
.getSessionAsync({ retries: 0 })
|
||||
.catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
|
||||
@@ -334,7 +336,7 @@ describe(DeviceRunSessionWorkerApi, () => {
|
||||
jest.mocked(turtleFetch).mockRejectedValueOnce(reason);
|
||||
|
||||
const error = await createApi()
|
||||
.getSessionAsync()
|
||||
.getSessionAsync({ retries: 0 })
|
||||
.catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
|
||||
@@ -348,7 +350,7 @@ describe(DeviceRunSessionWorkerApi, () => {
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'STOPPED' } }));
|
||||
|
||||
await expect(createApi().getSessionAsync()).rejects.toThrow(
|
||||
await expect(createApi().getSessionAsync({ retries: 3 })).rejects.toThrow(
|
||||
'GET /v2/device-run-session-worker/session returned an unexpected response'
|
||||
);
|
||||
});
|
||||
@@ -356,8 +358,183 @@ describe(DeviceRunSessionWorkerApi, () => {
|
||||
it('rejects a successful response that is not JSON', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('ok', { status: 200 }));
|
||||
|
||||
await expect(createApi().getSessionAsync()).rejects.toThrow(
|
||||
await expect(createApi().getSessionAsync({ retries: 3 })).rejects.toThrow(
|
||||
'GET /v2/device-run-session-worker/session returned a response that is not JSON.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('device run session worker API retries', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([429, 500, 502, 503, 504])(
|
||||
'recovers from HTTP %s when starting a session',
|
||||
async status => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(new Response('unavailable', { status }))
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
|
||||
const result = createApi().startAsync(
|
||||
{ remoteConfig: { url: 'https://preview.test' } },
|
||||
{ retries: 3, timeoutMs: 30_000 }
|
||||
);
|
||||
const assertion = expect(result).resolves.toEqual({ id: 'drs-id', status: 'in-progress' });
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
expect(jest.mocked(turtleFetch).mock.calls[1]).toEqual(
|
||||
jest.mocked(turtleFetch).mock.calls[0]
|
||||
);
|
||||
expect(jest.mocked(turtleFetch).mock.calls[0][2]).toMatchObject({
|
||||
retries: 0,
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('recovers from a network failure', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockRejectedValueOnce(new Error('socket hang up'))
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { build: null } }));
|
||||
const assertion = expect(createApi().getApplicationAsync({ retries: 3 })).resolves.toEqual({
|
||||
build: null,
|
||||
});
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('exhausts three retries with exponential backoff', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockImplementation(async () => new Response('unavailable', { status: 503 }));
|
||||
const assertion = expect(
|
||||
createApi().startAsync({ remoteConfig: {} }, { retries: 3 })
|
||||
).rejects.toMatchObject({
|
||||
status: 503,
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
await jest.advanceTimersByTimeAsync(2000);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(3);
|
||||
await jest.advanceTimersByTimeAsync(4000);
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it.each([400, 401, 403, 404, 409])('does not retry HTTP %s', async status => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('refused', { status }));
|
||||
await expect(
|
||||
createApi().startAsync({ remoteConfig: {} }, { retries: 3 })
|
||||
).rejects.toMatchObject({ status });
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['status polling', (api: DeviceRunSessionWorkerApi) => api.getSessionAsync()],
|
||||
['stop', (api: DeviceRunSessionWorkerApi) => api.stopAsync()],
|
||||
[
|
||||
'screenshot application metadata',
|
||||
(api: DeviceRunSessionWorkerApi) => api.getApplicationAsync(),
|
||||
],
|
||||
['event log setup', (api: DeviceRunSessionWorkerApi) => api.createEventLogUploadSessionAsync()],
|
||||
['preview setup', (api: DeviceRunSessionWorkerApi) => api.createPreviewUploadSessionAsync()],
|
||||
[
|
||||
'TURN without an explicit retry option',
|
||||
(api: DeviceRunSessionWorkerApi) => api.fetchTurnIceServersAsync(),
|
||||
],
|
||||
])('does not retry %s by default', async (_name, call) => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
|
||||
await expect(call(createApi())).rejects.toMatchObject({ status: 503 });
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('honors an explicit retry count', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockImplementation(async () => new Response('unavailable', { status: 503 }));
|
||||
const assertion = expect(
|
||||
createApi().fetchTurnIceServersAsync({ retries: 1 })
|
||||
).rejects.toMatchObject({ status: 503 });
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not send another request after cancellation during backoff', async () => {
|
||||
const controller = new AbortController();
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
|
||||
const assertion = expect(
|
||||
createApi().getApplicationAsync({ signal: controller.signal, retries: 3 })
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
controller.abort();
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('device run session retry safety', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
it('does not replay artifact creation after a transient failure', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
|
||||
await expect(
|
||||
createApi().createArtifactUploadSessionAsync({
|
||||
name: 'report',
|
||||
filename: 'report.json',
|
||||
size: 1,
|
||||
})
|
||||
).rejects.toMatchObject({ status: 503 });
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not send an already cancelled request', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
createApi().getApplicationAsync({ signal: controller.signal, retries: 3 })
|
||||
).rejects.toMatchObject({
|
||||
name: 'AbortError',
|
||||
});
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['system', 'body-timeout'])(
|
||||
'retries a response body transport failure (%s)',
|
||||
async type => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const response = jsonResponse({});
|
||||
jest
|
||||
.spyOn(response, 'json')
|
||||
.mockRejectedValueOnce(new FetchError('connection interrupted', type));
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(response)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
|
||||
const assertion = expect(
|
||||
createApi().startAsync({ remoteConfig: {} }, { retries: 3 })
|
||||
).resolves.toMatchObject({ status: 'in-progress' });
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
+57
-4
@@ -151,10 +151,11 @@ describe('with a device run session token', () => {
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts the session through the worker API', async () => {
|
||||
it('retries a transient failure when starting the session through the worker API', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'Service unavailable' }] }, 503))
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
@@ -165,9 +166,57 @@ describe('with a device run session token', () => {
|
||||
});
|
||||
|
||||
expectWorkerApiCall('start', 'POST', { json: { remoteConfig: { type: 'agent-device' } } });
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles the structured stopped code without matching the message', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
errors: [
|
||||
{ code: 'DEVICE_RUN_SESSION_ALREADY_STOPPED', message: 'A different explanation.' },
|
||||
],
|
||||
},
|
||||
400
|
||||
)
|
||||
);
|
||||
await expect(
|
||||
uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
remoteConfig: {},
|
||||
logger,
|
||||
})
|
||||
).resolves.toBe('stopped');
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Device run session drs-id was stopped during startup.'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([undefined, 'VALIDATION_ERROR'])('preserves an ordinary 400 with code %s', async code => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{ errors: [{ code, message: 'Device run session has already stopped.' }] },
|
||||
400
|
||||
)
|
||||
);
|
||||
await expect(
|
||||
uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
remoteConfig: {},
|
||||
logger,
|
||||
})
|
||||
).rejects.toBeInstanceOf(SystemError);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fails the start with a system error that does not contain the token', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest
|
||||
@@ -213,7 +262,7 @@ describe('with a device run session token', () => {
|
||||
'--turn-credential',
|
||||
'credential',
|
||||
]);
|
||||
expectWorkerApiCall('turn-ice-servers', 'POST', { timeout: 5000, retries: 1 });
|
||||
expectWorkerApiCall('turn-ice-servers', 'POST', { timeout: 5000, retries: 0 });
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -362,7 +411,9 @@ describe('with a device run session token', () => {
|
||||
robotAccessToken: ROBOT_ACCESS_TOKEN,
|
||||
extensions: ['app'],
|
||||
})
|
||||
).rejects.toThrow('Unexpected response from server (500): stop here');
|
||||
).rejects.toThrow(
|
||||
`Failed to download the application archive for build ${BUILD_ID} (HTTP 500): stop here`
|
||||
);
|
||||
|
||||
expectWorkerApiCall('application', 'GET');
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
@@ -587,7 +638,9 @@ describe('without a device run session token', () => {
|
||||
robotAccessToken: ROBOT_ACCESS_TOKEN,
|
||||
extensions: ['app'],
|
||||
})
|
||||
).rejects.toThrow('Unexpected response from server (500)');
|
||||
).rejects.toThrow(
|
||||
`Failed to download the application archive for build ${BUILD_ID} (HTTP 500): stop here`
|
||||
);
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalled();
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
|
||||
@@ -1413,7 +1413,7 @@ it('does not read the iOS egress handoff for an Android host', async () => {
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
'waits for setup before launch or boot-only readiness (app: %s)',
|
||||
'runs setup in parallel with app launch and readiness, then joins it at teardown (app: %s)',
|
||||
async hasApp => {
|
||||
const setup = deferred<void>();
|
||||
const prepared = deferred<ServeSimApplicationOptions | void>();
|
||||
@@ -1468,70 +1468,78 @@ it.each([true, false])(
|
||||
? { installAppPath: '/tmp/App.app', launchAppIdentifier: 'dev.example.other' }
|
||||
: undefined
|
||||
);
|
||||
if (!hasApp) {
|
||||
let queried = false;
|
||||
ready.resolve({
|
||||
ok: true,
|
||||
json: async () => {
|
||||
queried = true;
|
||||
return { status: 'ready', device: 'emulator-5554' };
|
||||
},
|
||||
} as never);
|
||||
let reported = false;
|
||||
void starting.then(() => {
|
||||
reported = true;
|
||||
});
|
||||
while (!queried) {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
if (hasApp) {
|
||||
while (!jest.mocked(runServeSimActionAsync).mock.calls.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(reported).toBe(false);
|
||||
expect(runServeSimActionAsync).toHaveBeenCalledTimes(2);
|
||||
} else {
|
||||
expect(runServeSimActionAsync).not.toHaveBeenCalled();
|
||||
setup.resolve();
|
||||
const host = await starting;
|
||||
expect(runServeSimActionAsync).not.toHaveBeenCalled();
|
||||
await host.finishAsync();
|
||||
return;
|
||||
}
|
||||
while (!jest.mocked(runServeSimActionAsync).mock.calls.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(runServeSimActionAsync).toHaveBeenCalledTimes(1);
|
||||
setup.resolve();
|
||||
while (jest.mocked(runServeSimActionAsync).mock.calls.length < 2) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
expect(
|
||||
jest
|
||||
.mocked(runServeSimActionAsync)
|
||||
.mock.calls.map(([options]) => [options.action, options.params])
|
||||
).toEqual([
|
||||
['app.install', { udid: 'emulator-5554', path: `${directory}/App.app` }],
|
||||
[
|
||||
'app.launch',
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
bundleId: 'dev.example.other',
|
||||
launchArgs: [],
|
||||
openUrl: undefined,
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(runServeSimActionAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ token: 'preview-token', timeoutMs: 120_000 })
|
||||
);
|
||||
expect(stageServeSimAppAsync).toHaveBeenCalledWith(
|
||||
'/tmp/App.app',
|
||||
env.TMPDIR ?? require('node:os').tmpdir()
|
||||
).toEqual(
|
||||
hasApp
|
||||
? [
|
||||
['app.install', { udid: 'emulator-5554', path: `${directory}/App.app` }],
|
||||
[
|
||||
'app.launch',
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
bundleId: 'dev.example.other',
|
||||
launchArgs: [],
|
||||
openUrl: undefined,
|
||||
},
|
||||
],
|
||||
]
|
||||
: []
|
||||
);
|
||||
if (hasApp) {
|
||||
expect(runServeSimActionAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ token: 'preview-token', timeoutMs: 120_000 })
|
||||
);
|
||||
expect(stageServeSimAppAsync).toHaveBeenCalledWith(
|
||||
'/tmp/App.app',
|
||||
env.TMPDIR ?? require('node:os').tmpdir()
|
||||
);
|
||||
}
|
||||
let queried = false;
|
||||
ready.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ status: 'ready', device: 'emulator-5554' }),
|
||||
json: async () => {
|
||||
queried = true;
|
||||
return { status: 'ready', device: 'emulator-5554' };
|
||||
},
|
||||
} as never);
|
||||
const host = await starting;
|
||||
await host.finishAsync();
|
||||
await expect(access(directory)).rejects.toThrow();
|
||||
let reported = false;
|
||||
void starting.then(() => {
|
||||
reported = true;
|
||||
});
|
||||
while (!queried) {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
try {
|
||||
expect(reported).toBe(true);
|
||||
const host = await starting;
|
||||
let finished = false;
|
||||
const finishing = host.finishAsync().then(() => {
|
||||
finished = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(finished).toBe(false);
|
||||
setup.resolve();
|
||||
await finishing;
|
||||
} finally {
|
||||
setup.resolve();
|
||||
await (await starting).finishAsync();
|
||||
}
|
||||
if (hasApp) {
|
||||
await expect(access(directory)).rejects.toThrow();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1676,7 +1684,8 @@ it('warns and continues launching when disabling push fails', async () => {
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it('cancels pending setup without launching and observes its late failure', async () => {
|
||||
it('cancels pending app preparation and observes late setup failure', async () => {
|
||||
const application = deferred<ServeSimApplicationOptions>();
|
||||
const controller = new AbortController();
|
||||
let rejectSetup!: (error: Error) => void;
|
||||
jest.mocked(IosSimulatorUtils.disableApsdAsync).mockReturnValue(
|
||||
@@ -1690,7 +1699,7 @@ it('cancels pending setup without launching and observes its late failure', asyn
|
||||
logger,
|
||||
timeoutMs: 2_000,
|
||||
iosSimulatorUdid: 'emulator-5554',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
application: application.promise,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const rejected = expect(starting).rejects.toThrow('session canceled');
|
||||
|
||||
@@ -2,6 +2,14 @@ jest.unmock('fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('node:fs/promises');
|
||||
jest.mock('node-fetch', () => {
|
||||
const actual = jest.requireActual('node-fetch');
|
||||
return {
|
||||
__esModule: true,
|
||||
...actual,
|
||||
default: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { SandboxDaemonErrorCode, SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
@@ -310,7 +318,7 @@ describe('sandbox artifact uploads', () => {
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
const logged = inspect([logger.info.mock.calls, logger.error.mock.calls], { depth: 10 });
|
||||
expect(fetch).toHaveBeenCalledTimes(3);
|
||||
expect(logged).toContain('Artifact PUT failed: ECONNRESET.');
|
||||
expect(logged).toContain('Signed URL upload failed: ECONNRESET.');
|
||||
expect(logged).not.toContain('secret-signature');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import fetch, { Headers } from 'node-fetch';
|
||||
import type { Readable } from 'node:stream';
|
||||
import { finished } from 'node:stream/promises';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import { AbortUtils } from '../../utils/AbortUtils';
|
||||
|
||||
export type ArtifactUploadSession = { url: string; headers: unknown };
|
||||
export type ArtifactSource = { openStream: () => Readable };
|
||||
|
||||
export async function putArtifactWithRetriesAsync(
|
||||
{
|
||||
uploadSession,
|
||||
source,
|
||||
signal,
|
||||
}: {
|
||||
uploadSession: ArtifactUploadSession;
|
||||
source: ArtifactSource;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
{ attempts = 1 }: { attempts?: number } = {}
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
signal?.throwIfAborted();
|
||||
const stream = source.openStream();
|
||||
// Upload time scales with file size, so the deadline bounds stalls, not the whole request.
|
||||
const timeout = AbortUtils.resettableTimeout(90_000);
|
||||
try {
|
||||
await putArtifactAsync({
|
||||
uploadSession,
|
||||
stream,
|
||||
signal: signal ? AbortSignal.any([signal, timeout.signal]) : timeout.signal,
|
||||
onProgress: timeout.resetDeadline,
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
const retryable =
|
||||
error instanceof ArtifactPutError
|
||||
? [408, 429, 500, 502, 503, 504].includes(error.status)
|
||||
: timeout.signal.aborted || error instanceof ArtifactPutNetworkError;
|
||||
if (!retryable || attempt + 1 === attempts) {
|
||||
throw error;
|
||||
}
|
||||
await delay(500 * (attempt + 1), undefined, { signal });
|
||||
} finally {
|
||||
stream.destroy();
|
||||
await finished(stream).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function putArtifactAsync({
|
||||
uploadSession,
|
||||
stream,
|
||||
signal,
|
||||
onProgress,
|
||||
}: {
|
||||
uploadSession: ArtifactUploadSession;
|
||||
stream: Readable;
|
||||
signal: AbortSignal;
|
||||
onProgress: () => void;
|
||||
}): Promise<void> {
|
||||
const requestController = new AbortController();
|
||||
try {
|
||||
const responding = fetch(uploadSession.url, {
|
||||
method: 'PUT',
|
||||
headers: new Headers(uploadSession.headers as Record<string, string>),
|
||||
body: stream,
|
||||
signal: AbortSignal.any([signal, requestController.signal]),
|
||||
});
|
||||
// fetch has piped the body already, so this listener observes flow without starting it.
|
||||
stream.on('data', onProgress);
|
||||
let response;
|
||||
try {
|
||||
response = await responding;
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
throw error instanceof Error && error.name === 'FetchError'
|
||||
? new ArtifactPutNetworkError(error)
|
||||
: error;
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
if (!response.ok) {
|
||||
throw new ArtifactPutError(response);
|
||||
}
|
||||
} finally {
|
||||
// node-fetch destroys the request body on abort, and its response stream is a PassThrough
|
||||
// whose destruction leaves the socket open, so the request itself is aborted here.
|
||||
requestController.abort();
|
||||
}
|
||||
}
|
||||
|
||||
export class ArtifactPutError extends SystemError {
|
||||
readonly status: number;
|
||||
readonly reason: string;
|
||||
|
||||
constructor(response: { status: number; statusText: string }) {
|
||||
const reason = `HTTP ${response.status} ${response.statusText}`;
|
||||
super(`Artifact PUT failed: ${reason}.`);
|
||||
this.status = response.status;
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
// node-fetch names the request URL in its message, and here that URL is signed. The message keeps
|
||||
// only the error code, and the original is not kept as the cause because loggers and Sentry print causes.
|
||||
export class ArtifactPutNetworkError extends SystemError {
|
||||
readonly code: string | undefined;
|
||||
readonly reason: string;
|
||||
|
||||
constructor(error: Error & { code?: unknown }) {
|
||||
const code = typeof error.code === 'string' ? error.code : undefined;
|
||||
const reason = code ?? 'network error';
|
||||
super(`Artifact PUT failed: ${reason}.`);
|
||||
this.code = code;
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import type { Readable } from 'node:stream';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
ArtifactPutError,
|
||||
ArtifactPutNetworkError,
|
||||
type ArtifactSource,
|
||||
type ArtifactUploadSession,
|
||||
putArtifactWithRetriesAsync,
|
||||
} from './artifactPut';
|
||||
type SignedUrl,
|
||||
SignedUrlUploadApiError,
|
||||
SignedUrlUploadNetworkError,
|
||||
uploadWithSignedUrl,
|
||||
} from '../../storage/uploadWithSignedUrl';
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
|
||||
|
||||
@@ -23,7 +23,11 @@ type ArtifactMetadata = {
|
||||
|
||||
export async function uploadDeviceRunSessionArtifactAsync(
|
||||
ctx: CustomBuildContext,
|
||||
options: ArtifactMetadata & ArtifactSource & { signal?: AbortSignal; attempts?: number }
|
||||
options: ArtifactMetadata & {
|
||||
openStream: () => Readable;
|
||||
signal?: AbortSignal;
|
||||
attempts?: number;
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const uploadSession = await withDeviceRunSessionTimeoutAsync(
|
||||
@@ -31,12 +35,12 @@ export async function uploadDeviceRunSessionArtifactAsync(
|
||||
async signal => await createDeviceRunSessionArtifactUploadSessionAsync(ctx, options, signal)
|
||||
);
|
||||
options.signal?.throwIfAborted();
|
||||
await putArtifactWithRetriesAsync(
|
||||
{ uploadSession, source: options, signal: options.signal },
|
||||
{ attempts: options.attempts }
|
||||
await uploadWithSignedUrl(
|
||||
{ signedUrl: uploadSession, srcGeneratorAsync: async () => options.openStream() },
|
||||
{ signal: options.signal, retries: (options.attempts ?? 1) - 1, retryIntervalMs: 500 }
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ArtifactPutError || error instanceof ArtifactPutNetworkError) {
|
||||
if (error instanceof SignedUrlUploadApiError || error instanceof SignedUrlUploadNetworkError) {
|
||||
throw new DeviceRunSessionArtifactUploadError(options.artifactId, error.reason);
|
||||
}
|
||||
throw error;
|
||||
@@ -53,9 +57,9 @@ async function createDeviceRunSessionArtifactUploadSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{ deviceRunSessionId, artifactId, name, filename, kind, metadata, size }: ArtifactMetadata,
|
||||
signal: AbortSignal
|
||||
): Promise<ArtifactUploadSession> {
|
||||
): Promise<SignedUrl> {
|
||||
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
|
||||
let uploadSession: ArtifactUploadSession;
|
||||
let uploadSession: SignedUrl;
|
||||
try {
|
||||
uploadSession = await workerApi.createArtifactUploadSessionAsync(
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@ import { type bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
|
||||
import spawn, { type SpawnOptions } from '@expo/turtle-spawn';
|
||||
import fetch from 'node-fetch';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
@@ -10,6 +9,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { uploadContentWithSignedUrl } from '../../storage/uploadWithSignedUrl';
|
||||
import { killProcessGroup } from '../../utils/processes';
|
||||
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
|
||||
|
||||
@@ -17,31 +17,6 @@ const PREVIEW_INTERVAL_MS = 60_000;
|
||||
const PREVIEW_MAX_DIMENSION = 320;
|
||||
const PREVIEW_QUALITY = 70;
|
||||
const MAX_PREVIEW_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
/**
|
||||
* node-fetch includes the request URL in its error messages, and this URL is a signed upload URL
|
||||
* that is valid for the whole session. Replace such errors with one that names only the failure.
|
||||
*/
|
||||
async function putPreviewAsync(
|
||||
uploadSession: { url: string; headers: Record<string, string> },
|
||||
{ image, signal }: { image: Buffer; signal: AbortSignal }
|
||||
): Promise<Awaited<ReturnType<typeof fetch>>> {
|
||||
try {
|
||||
return await fetch(uploadSession.url, {
|
||||
method: 'PUT',
|
||||
headers: uploadSession.headers,
|
||||
body: image,
|
||||
signal,
|
||||
timeout: 30_000,
|
||||
});
|
||||
} catch (err) {
|
||||
const { name, type, code } = err as { name?: unknown; type?: unknown; code?: unknown };
|
||||
const details = Object.entries({ name, type, code })
|
||||
.filter(([, value]) => typeof value === 'string' && value.length > 0)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join(', ');
|
||||
throw new Error(`Session preview upload request failed (${details || 'unknown error'}).`);
|
||||
}
|
||||
}
|
||||
|
||||
export function startDeviceRunSessionPreview({
|
||||
ctx,
|
||||
@@ -75,10 +50,10 @@ export function startDeviceRunSessionPreview({
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
const response = await putPreviewAsync(uploadSession, { image, signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Session preview upload failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
await uploadContentWithSignedUrl(
|
||||
{ signedUrl: uploadSession, content: image },
|
||||
{ signal, retries: 0, stallTimeoutMs: 30_000 }
|
||||
);
|
||||
} catch (err) {
|
||||
if (!controller.signal.aborted) {
|
||||
logger.warn({ err }, 'Could not refresh the session preview; retrying in 60 seconds.');
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { type EnvironmentSecret, EnvironmentSecretType, SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { FetchError } from 'node-fetch';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { promiseRetryWithCondition } from '../../utils/promiseRetryWithCondition';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
|
||||
/**
|
||||
@@ -75,7 +77,7 @@ type RequestOptions = {
|
||||
signal?: AbortSignal;
|
||||
/** node-fetch timeout of each attempt, in milliseconds. */
|
||||
timeoutMs?: number;
|
||||
/** Additional attempts after a failed one. Defaults to none, so each call sends one request. */
|
||||
/** Additional attempts for transient failures. Defaults to none; critical callers opt in. */
|
||||
retries?: number;
|
||||
logger?: bunyan;
|
||||
};
|
||||
@@ -89,7 +91,8 @@ export class DeviceRunSessionWorkerApiError extends Error {
|
||||
message: string,
|
||||
/** The HTTP status of the response, or undefined when no response arrived. */
|
||||
readonly status: number | undefined,
|
||||
options?: ErrorOptions
|
||||
options?: ErrorOptions,
|
||||
readonly code?: string
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = 'DeviceRunSessionWorkerApiError';
|
||||
@@ -189,16 +192,44 @@ export class DeviceRunSessionWorkerApi {
|
||||
}
|
||||
|
||||
private async requestAsync<T>(
|
||||
method: 'GET' | 'POST',
|
||||
route: string,
|
||||
dataSchema: z.ZodType<T>,
|
||||
{ retries = 0, ...options }: RequestOptions & { json?: Record<string, unknown> }
|
||||
): Promise<T> {
|
||||
const request = () => {
|
||||
options.signal?.throwIfAborted();
|
||||
return this.requestOnceAsync(method, route, dataSchema, options);
|
||||
};
|
||||
if (retries === 0) {
|
||||
return await request();
|
||||
}
|
||||
return await promiseRetryWithCondition(
|
||||
request,
|
||||
error =>
|
||||
!options.signal?.aborted &&
|
||||
error instanceof DeviceRunSessionWorkerApiError &&
|
||||
(error.status === undefined || error.status === 429 || error.status >= 500),
|
||||
{ retries, factor: 2, minTimeout: 1000 },
|
||||
({ attemptNumber, maxAttemptsCount }) =>
|
||||
options.logger?.warn(
|
||||
`Retrying ${method} /v2/${DEVICE_RUN_SESSION_WORKER_PATH}${route} (attempt ${attemptNumber}/${maxAttemptsCount}).`
|
||||
)
|
||||
)();
|
||||
}
|
||||
|
||||
private async requestOnceAsync<T>(
|
||||
method: 'GET' | 'POST',
|
||||
route: string,
|
||||
dataSchema: z.ZodType<T>,
|
||||
{
|
||||
signal,
|
||||
timeoutMs,
|
||||
retries = 0,
|
||||
logger,
|
||||
json,
|
||||
}: RequestOptions & { json?: Record<string, unknown> }
|
||||
}: Omit<RequestOptions, 'retries'> & {
|
||||
json?: Record<string, unknown>;
|
||||
}
|
||||
): Promise<T> {
|
||||
const routeDescription = `${method} /v2/${DEVICE_RUN_SESSION_WORKER_PATH}${route}`;
|
||||
const url = new URL(
|
||||
@@ -212,7 +243,7 @@ export class DeviceRunSessionWorkerApi {
|
||||
...(json ? { json } : {}),
|
||||
...(signal ? { signal } : {}),
|
||||
...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}),
|
||||
retries,
|
||||
retries: 0,
|
||||
shouldThrowOnNotOk: false,
|
||||
logger,
|
||||
})
|
||||
@@ -237,10 +268,21 @@ export class DeviceRunSessionWorkerApi {
|
||||
: response.statusText;
|
||||
throw new DeviceRunSessionWorkerApiError(
|
||||
`${routeDescription} failed with status ${response.status}: ${apiErrorMessage}`,
|
||||
response.status
|
||||
response.status,
|
||||
undefined,
|
||||
apiError?.success ? apiError.data.errors[0]?.code : undefined
|
||||
);
|
||||
}
|
||||
if (!bodyResult.ok) {
|
||||
if (
|
||||
bodyResult.reason instanceof FetchError &&
|
||||
['system', 'body-timeout'].includes(bodyResult.reason.type)
|
||||
) {
|
||||
throw new DeviceRunSessionWorkerApiError(
|
||||
`${routeDescription} failed while reading the response body.`,
|
||||
undefined
|
||||
);
|
||||
}
|
||||
throw new DeviceRunSessionWorkerApiError(
|
||||
`${routeDescription} returned a response that is not JSON.`,
|
||||
response.status
|
||||
|
||||
@@ -577,7 +577,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
}
|
||||
|
||||
let stagedAppDirectory: string | undefined;
|
||||
let beforeLaunch: ReturnType<typeof asyncResult<void>> | undefined;
|
||||
let simulatorSetup: ReturnType<typeof asyncResult<void>> | undefined;
|
||||
const readinessController = new AbortController();
|
||||
const readinessSignal = AbortSignal.any([startup.signal, readinessController.signal]);
|
||||
let previewToken: string | undefined;
|
||||
@@ -670,7 +670,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
onStopped: successful => onFinished(successful && !previewFailed),
|
||||
})
|
||||
.finally(async () => {
|
||||
await beforeLaunch;
|
||||
await simulatorSetup;
|
||||
if (stagedAppDirectory) {
|
||||
await fs.promises.rm(stagedAppDirectory, { recursive: true, force: true });
|
||||
}
|
||||
@@ -711,7 +711,8 @@ async function startDeviceSessionHostInternalAsync(
|
||||
);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
beforeLaunch = asyncResult(
|
||||
// APSD cleanup runs alongside app startup and readiness; teardown joins the task.
|
||||
simulatorSetup = asyncResult(
|
||||
startup.untilAborted(
|
||||
startup.run('Simulator setup', async taskLogger => {
|
||||
try {
|
||||
@@ -749,7 +750,6 @@ async function startDeviceSessionHostInternalAsync(
|
||||
signal?.throwIfAborted();
|
||||
await action('app.install', { udid: iosSimulatorUdid, path: staged.path });
|
||||
}
|
||||
(await beforeLaunch)?.enforceValue();
|
||||
if (app.launchAppIdentifier) {
|
||||
signal?.throwIfAborted();
|
||||
await action('app.launch', {
|
||||
|
||||
@@ -31,14 +31,17 @@ export async function uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
}: Parameters<typeof uploadRemoteSessionConfigAsync>[0] & {
|
||||
env: BuildStepEnv;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
}): Promise<'started' | 'stopped'> {
|
||||
// Written by start_local_egress before simulator boot; absent for ordinary sessions.
|
||||
const localEgress = await readLocalEgressHandoffAsync();
|
||||
signal?.throwIfAborted();
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
const sessionStart = await uploadRemoteSessionConfigAsync({
|
||||
...options,
|
||||
remoteConfig: { ...options.remoteConfig, ...buildEgressRemoteConfigFields(localEgress) },
|
||||
});
|
||||
if (sessionStart === 'stopped') {
|
||||
return sessionStart;
|
||||
}
|
||||
if (localEgress && !signal?.aborted) {
|
||||
// Guard refusals from here on show up under this step in the job log,
|
||||
// alongside the monitor's reports, instead of under the boot step.
|
||||
@@ -57,4 +60,5 @@ export async function uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
...(localEgress.platform === 'android' ? { scanSimulatorConnections: false } : {}),
|
||||
});
|
||||
}
|
||||
return sessionStart;
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import { createProcessOutput } from './processOutput';
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
import {
|
||||
type DeviceRunSessionStatus,
|
||||
DeviceRunSessionWorkerApiError,
|
||||
requireDeviceRunSessionWorkerApi,
|
||||
} from './deviceRunSessionWorkerApi';
|
||||
|
||||
@@ -554,14 +555,26 @@ export async function uploadRemoteSessionConfigAsync({
|
||||
deviceRunSessionId: string;
|
||||
remoteConfig: Record<string, unknown>;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
}): Promise<'started' | 'stopped'> {
|
||||
logger.info(
|
||||
`Reporting remote config to the API server (device run session: ${deviceRunSessionId}).`
|
||||
);
|
||||
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
|
||||
try {
|
||||
await workerApi.startAsync({ remoteConfig });
|
||||
await workerApi.startAsync(
|
||||
{ remoteConfig },
|
||||
{ retries: 3, timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS, logger }
|
||||
);
|
||||
return 'started';
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof DeviceRunSessionWorkerApiError &&
|
||||
err.code === 'DEVICE_RUN_SESSION_ALREADY_STOPPED'
|
||||
) {
|
||||
logger.info(`Device run session ${deviceRunSessionId} was stopped during startup.`);
|
||||
return 'stopped';
|
||||
}
|
||||
|
||||
throw new SystemError(
|
||||
`Failed to start device run session ${deviceRunSessionId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
|
||||
@@ -14,12 +14,8 @@ import { performance } from 'node:perf_hooks';
|
||||
import { Readable } from 'node:stream';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import { SignedUrlUploadApiError, uploadWithSignedUrl } from '../../storage/uploadWithSignedUrl';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import {
|
||||
ArtifactPutError,
|
||||
type ArtifactUploadSession,
|
||||
putArtifactWithRetriesAsync,
|
||||
} from './artifactPut';
|
||||
|
||||
const CREATE_SANDBOX_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
|
||||
mutation CreateSandboxArtifactUploadSession(
|
||||
@@ -238,18 +234,23 @@ async function putSandboxArtifactAsync({
|
||||
}: {
|
||||
filePath: string;
|
||||
size: number;
|
||||
uploadSession: ArtifactUploadSession;
|
||||
uploadSession: { url: string; headers: unknown };
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const openStream = (): Readable =>
|
||||
size === 0 ? Readable.from([]) : createReadStream(filePath, { start: 0, end: size - 1 });
|
||||
try {
|
||||
await putArtifactWithRetriesAsync(
|
||||
{ uploadSession, source: { openStream }, signal },
|
||||
{ attempts: 3 }
|
||||
await uploadWithSignedUrl(
|
||||
{
|
||||
signedUrl: {
|
||||
url: uploadSession.url,
|
||||
headers: uploadSession.headers as Record<string, string>,
|
||||
},
|
||||
srcGeneratorAsync: async () =>
|
||||
size === 0 ? Readable.from([]) : createReadStream(filePath, { start: 0, end: size - 1 }),
|
||||
},
|
||||
{ signal, retries: 2, retryIntervalMs: 500 }
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ArtifactPutError && error.status === 412)) {
|
||||
if (!(error instanceof SignedUrlUploadApiError && error.status === 412)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,325 +1,254 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import fetch, { RequestInit, Response } from 'node-fetch';
|
||||
import { Readable } from 'stream';
|
||||
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { type SignedUrl, uploadWithSignedUrl } from '../uploadWithSignedUrl';
|
||||
import {
|
||||
SignedUrlUploadApiError,
|
||||
SignedUrlUploadNetworkError,
|
||||
uploadContentWithSignedUrl,
|
||||
uploadWithSignedUrl,
|
||||
} from '../uploadWithSignedUrl';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
jest.unmock('node-fetch');
|
||||
|
||||
class ErrorWithCode extends Error {
|
||||
protected readonly _code: string | undefined;
|
||||
let handle: (request: IncomingMessage, response: ServerResponse) => void | Promise<void>;
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let origin: string;
|
||||
let signedUrl: { url: string; headers: Record<string, string> };
|
||||
let requests: number;
|
||||
|
||||
constructor(message: string, code?: string) {
|
||||
super(message);
|
||||
if (code) {
|
||||
this._code = code;
|
||||
}
|
||||
}
|
||||
|
||||
public get code(): string | undefined {
|
||||
return this._code;
|
||||
async function readBodyAsync(request: IncomingMessage): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks).toString();
|
||||
}
|
||||
|
||||
class DNSError extends ErrorWithCode {
|
||||
protected readonly _code: 'ENOTFOUND' | 'EAI_AGAIN' = 'ENOTFOUND';
|
||||
}
|
||||
|
||||
const TEST_BUCKET = 'turtle-v2-test';
|
||||
|
||||
function createSignedUrl(key: string, contentType: string): SignedUrl {
|
||||
return {
|
||||
url: `https://storage.googleapis.com/${TEST_BUCKET}/${key}?signature=test`,
|
||||
headers: { 'content-type': contentType },
|
||||
function respondWithStatuses(statuses: number[]): void {
|
||||
handle = async (request, response) => {
|
||||
await readBodyAsync(request);
|
||||
response.statusCode = statuses[requests - 1];
|
||||
response.end();
|
||||
};
|
||||
}
|
||||
|
||||
describe('signed URL uploader', () => {
|
||||
describe('uploadWithSignedUrl', () => {
|
||||
it('should throw an error if upload fails', async () => {
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const res = {
|
||||
ok: false,
|
||||
status: 500,
|
||||
} as Response;
|
||||
beforeEach(async () => {
|
||||
requests = 0;
|
||||
server = createServer((request, response) => {
|
||||
requests++;
|
||||
void handle(request, response);
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Missing test server address');
|
||||
}
|
||||
origin = `http://127.0.0.1:${address.port}`;
|
||||
signedUrl = { url: `${origin}/bucket/object?signature=secret-signature`, headers: {} };
|
||||
});
|
||||
|
||||
const key = 'cat.jpg';
|
||||
afterEach(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
describe(uploadWithSignedUrl, () => {
|
||||
it('sends the full body on every attempt', async () => {
|
||||
const bodies: string[] = [];
|
||||
handle = async (request, response) => {
|
||||
bodies.push(await readBodyAsync(request));
|
||||
response.statusCode = bodies.length === 1 ? 503 : 200;
|
||||
response.end();
|
||||
};
|
||||
const payload = '*'.repeat(128 * 1024);
|
||||
|
||||
fetchMock.mockImplementation(async () => res);
|
||||
await expect(
|
||||
uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
await uploadWithSignedUrl(
|
||||
{ signedUrl, srcGeneratorAsync: async () => Readable.from([Buffer.from(payload)]) },
|
||||
{ retryIntervalMs: 0 }
|
||||
);
|
||||
|
||||
expect(bodies).toEqual([payload, payload]);
|
||||
});
|
||||
|
||||
it('waits longer after each failed attempt', async () => {
|
||||
respondWithStatuses([503, 503, 200]);
|
||||
const startedAt = performance.now();
|
||||
|
||||
await uploadWithSignedUrl(
|
||||
{ signedUrl, srcGeneratorAsync: async () => Readable.from(['body']) },
|
||||
{ retryIntervalMs: 100 }
|
||||
);
|
||||
|
||||
expect(requests).toBe(3);
|
||||
expect(performance.now() - startedAt).toBeGreaterThanOrEqual(290);
|
||||
});
|
||||
|
||||
it('does not retry a client error', async () => {
|
||||
handle = async (request, response) => {
|
||||
await readBodyAsync(request);
|
||||
response.statusCode = 403;
|
||||
response.end('<Error><Code>AccessDenied</Code></Error>');
|
||||
};
|
||||
|
||||
const error = await uploadWithSignedUrl(
|
||||
{ signedUrl, srcGeneratorAsync: async () => Readable.from(['body']) },
|
||||
{ retryIntervalMs: 0 }
|
||||
).catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(SignedUrlUploadApiError);
|
||||
expect(error).toMatchObject({
|
||||
status: 403,
|
||||
reason: 'HTTP 403 Forbidden',
|
||||
message: 'Signed URL upload failed: HTTP 403 Forbidden.',
|
||||
});
|
||||
expect(requests).toBe(1);
|
||||
});
|
||||
|
||||
it('should return stripped URL if successful', async () => {
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const res = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
|
||||
fetchMock.mockImplementation(async () => res);
|
||||
const result = await uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
});
|
||||
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
|
||||
});
|
||||
|
||||
it('should retry upload on DNS error up to 2 times', async () => {
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const res = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
|
||||
fetchMock
|
||||
.mockImplementationOnce(async () => {
|
||||
throw new DNSError('failed once');
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
throw new DNSError('failed twice', 'EAI_AGAIN');
|
||||
})
|
||||
.mockImplementation(async () => res);
|
||||
const result = await uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
});
|
||||
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should retry upload on retriable status codes error up to 2 times', async () => {
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const res = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
|
||||
fetchMock
|
||||
.mockImplementationOnce(async () => {
|
||||
return {
|
||||
ok: false,
|
||||
status: 503,
|
||||
} as Response;
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
return {
|
||||
ok: false,
|
||||
status: 408,
|
||||
} as Response;
|
||||
})
|
||||
.mockImplementation(async () => res);
|
||||
const result = await uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
});
|
||||
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should retry upload on retriable status codes and DNS errors error up to 2 times', async () => {
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const res = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
|
||||
fetchMock
|
||||
.mockImplementationOnce(async () => {
|
||||
return {
|
||||
ok: false,
|
||||
status: 503,
|
||||
} as Response;
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
throw new DNSError('failed once');
|
||||
})
|
||||
.mockImplementation(async () => res);
|
||||
const result = await uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
});
|
||||
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should not retry upload on DNS error more than 2 times', async () => {
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const res = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
|
||||
const lastDNSError = new DNSError('failed thrice');
|
||||
fetchMock
|
||||
.mockImplementationOnce(async () => {
|
||||
throw new DNSError('failed once');
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
throw new DNSError('failed twice', 'EAI_AGAIN');
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
throw lastDNSError;
|
||||
})
|
||||
.mockImplementation(async () => res);
|
||||
await expect(
|
||||
uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
})
|
||||
).rejects.toThrow(lastDNSError);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should not retry upload on retriable status code more than 2 times', async () => {
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const res = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
|
||||
fetchMock
|
||||
.mockImplementationOnce(async () => {
|
||||
return {
|
||||
ok: false,
|
||||
status: 503,
|
||||
} as Response;
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
return {
|
||||
ok: false,
|
||||
status: 408,
|
||||
} as Response;
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
return {
|
||||
ok: false,
|
||||
status: 504,
|
||||
} as Response;
|
||||
})
|
||||
.mockImplementation(async () => res);
|
||||
await expect(
|
||||
uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should not retry upload on other error codes', async () => {
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const res = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
|
||||
const nonDNSError = new ErrorWithCode('failed once', 'A_DIFFERENT_CODE');
|
||||
fetchMock
|
||||
.mockImplementationOnce(async () => {
|
||||
throw nonDNSError;
|
||||
})
|
||||
.mockImplementation(async () => res);
|
||||
await expect(
|
||||
uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
})
|
||||
).rejects.toThrow(nonDNSError);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries upload with full stream', async () => {
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const receivedBodies: Buffer[] = [];
|
||||
const recordBody = async (request: RequestInit | undefined): Promise<void> => {
|
||||
let body = Buffer.from([]);
|
||||
for await (const chunk of request!.body as Readable) {
|
||||
body = Buffer.concat([body, chunk]);
|
||||
}
|
||||
receivedBodies.push(body);
|
||||
};
|
||||
fetchMock
|
||||
.mockImplementationOnce(async (_path, request) => {
|
||||
await recordBody(request);
|
||||
return {
|
||||
ok: false,
|
||||
status: 503,
|
||||
} as Response;
|
||||
})
|
||||
.mockImplementationOnce(async (_path, request) => {
|
||||
await recordBody(request);
|
||||
throw new DNSError('failed once');
|
||||
})
|
||||
.mockImplementation(async (_path, request) => {
|
||||
await recordBody(request);
|
||||
return {
|
||||
ok: true,
|
||||
status: 201,
|
||||
} as Response;
|
||||
});
|
||||
|
||||
const signedUrl = createSignedUrl('text.txt', 'text/plain');
|
||||
|
||||
const bufferToUpload = randomBytes(16);
|
||||
|
||||
const result = await uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from(bufferToUpload),
|
||||
retryIntervalMs: 0,
|
||||
});
|
||||
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/text.txt');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(receivedBodies.length).toBe(3);
|
||||
for (const body of receivedBodies) {
|
||||
// Here we're testing that each body received in every retry is the same.
|
||||
// If we passed the same body instance to each of the retries
|
||||
// each retry would consume a chunk of the same stream,
|
||||
// causing the uploaded file to be corrupted (missing first bytes).
|
||||
expect(body).toStrictEqual(bufferToUpload);
|
||||
it('retries a dropped connection', async () => {
|
||||
handle = async (request, response) => {
|
||||
if (requests === 1) {
|
||||
request.socket.destroy();
|
||||
return;
|
||||
}
|
||||
await readBodyAsync(request);
|
||||
response.end();
|
||||
};
|
||||
const srcGeneratorAsync = jest.fn(async () => Readable.from(['body']));
|
||||
|
||||
await uploadWithSignedUrl({ signedUrl, srcGeneratorAsync }, { retryIntervalMs: 0 });
|
||||
|
||||
expect(srcGeneratorAsync).toHaveBeenCalledTimes(2);
|
||||
expect(requests).toBe(2);
|
||||
});
|
||||
|
||||
it('retries a refused connection and keeps the signed URL out of the error', async () => {
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
const srcGeneratorAsync = jest.fn(async () => Readable.from(['body']));
|
||||
|
||||
const error = await uploadWithSignedUrl(
|
||||
{ signedUrl, srcGeneratorAsync },
|
||||
{ retries: 1, retryIntervalMs: 0 }
|
||||
).catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(SignedUrlUploadNetworkError);
|
||||
expect(error).toMatchObject({ code: 'ECONNREFUSED' });
|
||||
expect(error.message).not.toContain('secret-signature');
|
||||
expect(srcGeneratorAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not retry a network error outside the retryable codes', async () => {
|
||||
const srcGeneratorAsync = jest.fn(async () => Readable.from(['body']));
|
||||
|
||||
const error = await uploadWithSignedUrl(
|
||||
{
|
||||
signedUrl: { ...signedUrl, url: signedUrl.url.replace('http:', 'https:') },
|
||||
srcGeneratorAsync,
|
||||
},
|
||||
{ retryIntervalMs: 0 }
|
||||
).catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(SignedUrlUploadNetworkError);
|
||||
expect(error).toMatchObject({ code: 'EPROTO' });
|
||||
expect(srcGeneratorAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('propagates a source error without retrying, even with a retryable code', async () => {
|
||||
const sourceError = Object.assign(new Error('read failed'), { code: 'ECONNRESET' });
|
||||
const srcGeneratorAsync = jest.fn(async (): Promise<Readable> => {
|
||||
throw sourceError;
|
||||
});
|
||||
|
||||
await expect(
|
||||
uploadWithSignedUrl({ signedUrl, srcGeneratorAsync }, { retryIntervalMs: 0 })
|
||||
).rejects.toBe(sourceError);
|
||||
expect(srcGeneratorAsync).toHaveBeenCalledTimes(1);
|
||||
expect(requests).toBe(0);
|
||||
});
|
||||
|
||||
it('retries an attempt that stalls past the stall timeout', async () => {
|
||||
handle = async (request, response) => {
|
||||
await readBodyAsync(request);
|
||||
if (requests > 1) {
|
||||
response.end();
|
||||
}
|
||||
};
|
||||
const streams: Readable[] = [];
|
||||
const srcGeneratorAsync = async (): Promise<Readable> => {
|
||||
const stream = Readable.from(['body']);
|
||||
streams.push(stream);
|
||||
return stream;
|
||||
};
|
||||
|
||||
await uploadWithSignedUrl(
|
||||
{ signedUrl, srcGeneratorAsync },
|
||||
{ retryIntervalMs: 0, stallTimeoutMs: 100 }
|
||||
);
|
||||
|
||||
expect(requests).toBe(2);
|
||||
expect(streams.every(stream => stream.destroyed)).toBe(true);
|
||||
});
|
||||
|
||||
it('stops waiting for the next attempt when aborted', async () => {
|
||||
const controller = new AbortController();
|
||||
handle = async (request, response) => {
|
||||
await readBodyAsync(request);
|
||||
response.statusCode = 503;
|
||||
response.end();
|
||||
setTimeout(() => controller.abort(), 50);
|
||||
};
|
||||
|
||||
await expect(
|
||||
uploadWithSignedUrl(
|
||||
{ signedUrl, srcGeneratorAsync: async () => Readable.from(['body']) },
|
||||
{ signal: controller.signal, retryIntervalMs: 60_000 }
|
||||
)
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(requests).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe(uploadContentWithSignedUrl, () => {
|
||||
let received: IncomingMessage | undefined;
|
||||
let body: string;
|
||||
|
||||
beforeEach(() => {
|
||||
received = undefined;
|
||||
handle = async (request, response) => {
|
||||
received = request;
|
||||
body = await readBodyAsync(request);
|
||||
response.end();
|
||||
};
|
||||
});
|
||||
|
||||
it('counts the content length in bytes', async () => {
|
||||
await uploadContentWithSignedUrl({ signedUrl, content: 'zażółć' });
|
||||
|
||||
expect(received?.headersDistinct['content-length']).toEqual(['10']);
|
||||
expect(body).toBe('zażółć');
|
||||
});
|
||||
|
||||
it('keeps a content length that the signed headers already set', async () => {
|
||||
signedUrl.headers = { 'Content-Length': '5' };
|
||||
|
||||
await uploadContentWithSignedUrl({ signedUrl, content: 'hello' });
|
||||
|
||||
expect(received?.headersDistinct['content-length']).toEqual(['5']);
|
||||
});
|
||||
|
||||
it('labels only string content as plain text', async () => {
|
||||
await uploadContentWithSignedUrl({ signedUrl, content: 'hello' });
|
||||
expect(received?.headersDistinct['content-type']).toEqual(['text/plain;charset=UTF-8']);
|
||||
|
||||
await uploadContentWithSignedUrl({ signedUrl, content: Buffer.from('hello') });
|
||||
expect(received?.headersDistinct['content-type']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a content type that the signed headers already set', async () => {
|
||||
signedUrl.headers = { 'Content-Type': 'application/json' };
|
||||
|
||||
await uploadContentWithSignedUrl({ signedUrl, content: '{}' });
|
||||
|
||||
expect(received?.headersDistinct['content-type']).toEqual(['application/json']);
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Response } from 'node-fetch';
|
||||
|
||||
type RetryOptions = {
|
||||
retries: number;
|
||||
retryIntervalMs: number;
|
||||
shouldRetryOnError: (error: unknown) => boolean;
|
||||
shouldRetryOnResponse: (response: Response) => boolean;
|
||||
};
|
||||
|
||||
// based on https://github.com/googleapis/nodejs-storage/blob/8ab50804fc7bae3bbd159bbb4adf65c02215b11b/src/storage.ts#L284-L320
|
||||
export async function retryOnUploadFailure(
|
||||
fn: (attemptCount: number) => Promise<Response>,
|
||||
{
|
||||
retries,
|
||||
retryIntervalMs,
|
||||
}: { retries: RetryOptions['retries']; retryIntervalMs: RetryOptions['retryIntervalMs'] }
|
||||
): Promise<Response> {
|
||||
return await retry(fn, {
|
||||
retries,
|
||||
retryIntervalMs,
|
||||
shouldRetryOnError: err => {
|
||||
return (
|
||||
isErrorWithCode(err) &&
|
||||
(err.code === 'ENOTFOUND' ||
|
||||
err.code === 'EAI_AGAIN' ||
|
||||
err.code === 'ECONNRESET' ||
|
||||
err.code === 'ETIMEDOUT' ||
|
||||
err.code === 'EPIPE')
|
||||
);
|
||||
},
|
||||
shouldRetryOnResponse: resp => {
|
||||
return [408, 429, 500, 502, 503, 504].includes(resp.status);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isErrorWithCode(error: unknown): error is { code: string } {
|
||||
return typeof error === 'object' && error !== null && 'code' in error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper used to execute an inner function and possibly retry it if it throws an error
|
||||
* @param fn Function to be executed and retried in case of error
|
||||
* @param retries How many times at most should the function be retried
|
||||
* @param retryIntervalMs Time interval between the retries
|
||||
* @param shouldRetryOnError Function that determines if the function should be retried based on the error thrown
|
||||
* @param shouldRetryOnResponse Function that determines if the function should be retried based on the response
|
||||
*/
|
||||
async function retry(
|
||||
fn: (attemptCount: number) => Promise<Response>,
|
||||
{ retries, retryIntervalMs, shouldRetryOnError, shouldRetryOnResponse }: RetryOptions
|
||||
): Promise<Response> {
|
||||
let attemptCount = -1;
|
||||
for (;;) {
|
||||
try {
|
||||
attemptCount += 1;
|
||||
const resp = await fn(attemptCount);
|
||||
if (attemptCount < retries && shouldRetryOnResponse(resp)) {
|
||||
await new Promise(res => setTimeout(res, retryIntervalMs));
|
||||
} else {
|
||||
return resp;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (attemptCount === retries || !shouldRetryOnError(err)) {
|
||||
throw err;
|
||||
}
|
||||
await new Promise(res => setTimeout(res, retryIntervalMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import fetch, { FetchError } from 'node-fetch';
|
||||
import { Readable } from 'stream';
|
||||
import { URL } from 'url';
|
||||
import { Readable } from 'node:stream';
|
||||
import { finished } from 'node:stream/promises';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import { retryOnUploadFailure } from './retry';
|
||||
import { AbortUtils } from '../utils/AbortUtils';
|
||||
import { isConnectFailure, isConnectionInterruptedError } from '../utils/networkErrors';
|
||||
|
||||
export type SignedUrl = {
|
||||
url: string;
|
||||
@@ -12,46 +15,140 @@ export type SignedUrl = {
|
||||
export type UploadWithSignedUrlParams = {
|
||||
signedUrl: SignedUrl;
|
||||
srcGeneratorAsync: () => Promise<Readable>;
|
||||
retryIntervalMs?: number;
|
||||
retries?: number;
|
||||
};
|
||||
|
||||
export async function uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync,
|
||||
retries = 2,
|
||||
retryIntervalMs = 30_000,
|
||||
}: UploadWithSignedUrlParams): Promise<string> {
|
||||
let resp;
|
||||
try {
|
||||
resp = await retryOnUploadFailure(
|
||||
async () => {
|
||||
const src = await srcGeneratorAsync();
|
||||
return await fetch(signedUrl.url, {
|
||||
method: 'PUT',
|
||||
headers: signedUrl.headers,
|
||||
body: src,
|
||||
});
|
||||
},
|
||||
{ retries, retryIntervalMs }
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof FetchError) {
|
||||
throw new Error(`Failed to upload the file, reason: ${err.code}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
export type UploadWithSignedUrlOptions = {
|
||||
signal?: AbortSignal;
|
||||
retries?: number;
|
||||
retryIntervalMs?: number;
|
||||
stallTimeoutMs?: number;
|
||||
};
|
||||
|
||||
if (!resp.ok) {
|
||||
let body: string | undefined;
|
||||
export async function uploadWithSignedUrl(
|
||||
{ signedUrl, srcGeneratorAsync }: UploadWithSignedUrlParams,
|
||||
{
|
||||
signal,
|
||||
retries = 2,
|
||||
retryIntervalMs = 30_000,
|
||||
stallTimeoutMs = 90_000,
|
||||
}: UploadWithSignedUrlOptions = {}
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
signal?.throwIfAborted();
|
||||
const stream = await srcGeneratorAsync();
|
||||
// Upload time scales with file size, so the deadline bounds stalls, not the whole request.
|
||||
const timeout = AbortUtils.resettableTimeout(stallTimeoutMs);
|
||||
try {
|
||||
body = await resp.text();
|
||||
} catch {}
|
||||
throw new Error(
|
||||
`Failed to upload file: status: ${resp.status} status text: ${resp.statusText}, body: ${body}`
|
||||
);
|
||||
await putAsync({
|
||||
signedUrl,
|
||||
stream,
|
||||
signal: signal ? AbortSignal.any([signal, timeout.signal]) : timeout.signal,
|
||||
onProgress: timeout.resetDeadline,
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
if (attempt >= retries || (!timeout.signal.aborted && !isRetryableError(error))) {
|
||||
throw error;
|
||||
}
|
||||
await delay(retryIntervalMs * (attempt + 1), undefined, { signal });
|
||||
} finally {
|
||||
stream.destroy();
|
||||
await finished(stream).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadContentWithSignedUrl(
|
||||
{ signedUrl, content }: { signedUrl: SignedUrl; content: string | Buffer },
|
||||
options?: UploadWithSignedUrlOptions
|
||||
): Promise<void> {
|
||||
const headers = { ...signedUrl.headers };
|
||||
const headerNames = Object.keys(headers).map(name => name.toLowerCase());
|
||||
if (!headerNames.includes('content-length')) {
|
||||
headers['content-length'] = String(Buffer.byteLength(content));
|
||||
}
|
||||
if (typeof content === 'string' && !headerNames.includes('content-type')) {
|
||||
headers['content-type'] = 'text/plain;charset=UTF-8';
|
||||
}
|
||||
await uploadWithSignedUrl(
|
||||
{
|
||||
signedUrl: { url: signedUrl.url, headers },
|
||||
srcGeneratorAsync: async () => Readable.from([content]),
|
||||
},
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
const RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504];
|
||||
function isRetryableError(error: unknown): boolean {
|
||||
if (error instanceof SignedUrlUploadApiError && RETRYABLE_STATUSES.includes(error.status)) {
|
||||
return true;
|
||||
}
|
||||
return isConnectFailure(error) || isConnectionInterruptedError(error);
|
||||
}
|
||||
|
||||
async function putAsync({
|
||||
signedUrl,
|
||||
stream,
|
||||
signal,
|
||||
onProgress,
|
||||
}: {
|
||||
signedUrl: SignedUrl;
|
||||
stream: Readable;
|
||||
signal: AbortSignal;
|
||||
onProgress: () => void;
|
||||
}): Promise<void> {
|
||||
const requestController = new AbortController();
|
||||
try {
|
||||
const responsePromise = fetch(signedUrl.url, {
|
||||
method: 'PUT',
|
||||
headers: signedUrl.headers,
|
||||
body: stream,
|
||||
signal: AbortSignal.any([signal, requestController.signal]),
|
||||
});
|
||||
// fetch has piped the body already, so this listener observes flow without starting it.
|
||||
stream.on('data', onProgress);
|
||||
|
||||
const response = await responsePromise;
|
||||
signal.throwIfAborted();
|
||||
if (!response.ok) {
|
||||
throw new SignedUrlUploadApiError(response);
|
||||
}
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
if (error instanceof FetchError) {
|
||||
throw new SignedUrlUploadNetworkError(error);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
// node-fetch destroys the request body on abort, and its response stream is a PassThrough
|
||||
// whose destruction leaves the socket open, so the request itself is aborted here.
|
||||
requestController.abort();
|
||||
}
|
||||
}
|
||||
|
||||
export class SignedUrlUploadApiError extends SystemError {
|
||||
readonly status: number;
|
||||
readonly reason: string;
|
||||
|
||||
constructor(response: { status: number; statusText: string }) {
|
||||
const reason = `HTTP ${response.status} ${response.statusText}`;
|
||||
super(`Signed URL upload failed: ${reason}.`);
|
||||
this.status = response.status;
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
// node-fetch names the request URL in its message, which we want to avoid for signed urls.
|
||||
export class SignedUrlUploadNetworkError extends SystemError {
|
||||
readonly code: string | undefined;
|
||||
readonly reason: string;
|
||||
|
||||
constructor(error: FetchError) {
|
||||
const reason = error.code ?? 'network error';
|
||||
super(`Signed URL upload failed: ${reason}.`);
|
||||
this.code = error.code;
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
const url = new URL(signedUrl.url);
|
||||
return `${url.protocol}//${url.host}${url.pathname}`;
|
||||
}
|
||||
@@ -154,8 +154,9 @@ export namespace AndroidEmulatorUtils {
|
||||
try {
|
||||
let configIniFileContent = await fs.promises.readFile(configIniFile, 'utf-8');
|
||||
|
||||
// Give interactive sessions more headroom for guest VSOCK packet allocations.
|
||||
const ramSize = env.DEVICE_RUN_SESSION_ID ? 4096 : 2048;
|
||||
// Keep the lower RAM default for older images; newer guests need more headroom for ADB.
|
||||
const apiLevel = systemImagePackage.match(/^system-images;android-(\d+)/)?.[1];
|
||||
const ramSize = !env.DEVICE_RUN_SESSION_ID && apiLevel && Number(apiLevel) < 35 ? 2048 : 4096;
|
||||
logger.info(`Setting hw.ramSize to ${ramSize}.`);
|
||||
configIniFileContent = `${configIniFileContent}\nhw.ramSize=${ramSize}\n`;
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
'hw.lcd.density=420',
|
||||
'skin.path=pixel_9',
|
||||
'showDeviceFrame=yes',
|
||||
'hw.ramSize=2048',
|
||||
'hw.ramSize=4096',
|
||||
'',
|
||||
'hw.lcd.density=220',
|
||||
'',
|
||||
|
||||
@@ -20,6 +20,7 @@ it.each([
|
||||
'ENETDOWN',
|
||||
'ENETUNREACH',
|
||||
'ENOTFOUND',
|
||||
'ERR_SOCKET_CONNECTION_TIMEOUT',
|
||||
])('recognizes wrapped %s as a connection establishment failure', code => {
|
||||
const error = new Error('Request failed', {
|
||||
cause: new TypeError('fetch failed', {
|
||||
@@ -30,7 +31,7 @@ it.each([
|
||||
expect(isConnectionInterruptedError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['ECONNRESET', 'UND_ERR_SOCKET'])(
|
||||
it.each(['ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'UND_ERR_SOCKET'])(
|
||||
'requires caller-specific recovery for wrapped %s',
|
||||
code => {
|
||||
const error = new Error('Request failed', {
|
||||
@@ -41,7 +42,7 @@ it.each(['ECONNRESET', 'UND_ERR_SOCKET'])(
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['ETIMEDOUT', 'CERT_HAS_EXPIRED', undefined])('does not automatically retry %s', code => {
|
||||
it.each(['CERT_HAS_EXPIRED', undefined])('does not automatically retry %s', code => {
|
||||
const error = Object.assign(new Error('Request failed'), { code });
|
||||
expect(isConnectFailure(error)).toBe(false);
|
||||
expect(isConnectionInterruptedError(error)).toBe(false);
|
||||
|
||||
@@ -8,6 +8,14 @@ const CONNECT_FAILURE_CODES = new Set([
|
||||
'ENETDOWN',
|
||||
'ENETUNREACH',
|
||||
'ENOTFOUND',
|
||||
'ERR_SOCKET_CONNECTION_TIMEOUT',
|
||||
]);
|
||||
|
||||
const CONNECTION_INTERRUPTED_CODES = new Set([
|
||||
'ECONNRESET',
|
||||
'EPIPE',
|
||||
'ETIMEDOUT',
|
||||
'UND_ERR_SOCKET',
|
||||
]);
|
||||
|
||||
export function isConnectFailure(error: unknown): boolean {
|
||||
@@ -26,7 +34,11 @@ export function isConnectFailure(error: unknown): boolean {
|
||||
// The server may already have applied a request. The caller must decide whether repetition is safe.
|
||||
export function isConnectionInterruptedError(error: unknown): boolean {
|
||||
for (let current = error; current instanceof Error; current = current.cause) {
|
||||
if ('code' in current && (current.code === 'ECONNRESET' || current.code === 'UND_ERR_SOCKET')) {
|
||||
if (
|
||||
'code' in current &&
|
||||
typeof current.code === 'string' &&
|
||||
CONNECTION_INTERRUPTED_CODES.has(current.code)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# create-eas-build-function
|
||||
|
||||
## 27.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 26.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "25.3.0",
|
||||
"version": "27.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": "25.3.0"
|
||||
"@expo/steps": "27.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @expo/downloader
|
||||
|
||||
## 27.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 26.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "25.3.0",
|
||||
"version": "27.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# eas-build-cache-provider
|
||||
|
||||
## 27.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 26.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "25.3.0",
|
||||
"version": "27.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,5 +1,19 @@
|
||||
# @expo/eas-build-job
|
||||
|
||||
## 27.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 26.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
- Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "25.3.0",
|
||||
"version": "27.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": "25.3.0",
|
||||
"@expo/logger": "27.0.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "25.3.0",
|
||||
"@expo/turtle-spawn": "27.0.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -7,24 +7,3 @@ export enum ManagedArtifactType {
|
||||
*/
|
||||
XCODE_BUILD_LOGS = 'XCODE_BUILD_LOGS',
|
||||
}
|
||||
|
||||
export enum GenericArtifactType {
|
||||
ANDROID_APK = 'android-apk',
|
||||
ANDROID_AAB = 'android-aab',
|
||||
|
||||
IOS_SIMULATOR_APP = 'ios-simulator-app',
|
||||
IOS_IPA = 'ios-ipa',
|
||||
|
||||
OTHER = 'other',
|
||||
}
|
||||
|
||||
export const isGenericArtifact = <
|
||||
TSpec extends { type: GenericArtifactType | ManagedArtifactType },
|
||||
>(
|
||||
artifactSpec: TSpec
|
||||
): artifactSpec is TSpec & { type: GenericArtifactType } => {
|
||||
if (Object.values(GenericArtifactType).includes(artifactSpec.type as GenericArtifactType)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -1,5 +1,36 @@
|
||||
# eas-cli
|
||||
|
||||
## 27.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
- Write the reason a command failed to stderr, next to the `command failed.` line, instead of to stdout. A program that reads the reason from stdout must read stderr instead. ([#4592](https://github.com/expo/eas-cli/pull/4592) by [@vonovak](https://github.com/vonovak))
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Send the `permissions.account.role` from the build profile to request a role for the build's `EXPO_TOKEN`. ([#4734](https://github.com/expo/eas-cli/pull/4734) by [@wschurman](https://github.com/wschurman))
|
||||
- Add `simulator:logs` to read and follow logs from an iOS simulator session and download saved logs after it stops. ([#4545](https://github.com/expo/eas-cli/pull/4545) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Show permission guidance, including `permissions.account.role` in eas.json, when a build request fails with an authorization error. ([#4760](https://github.com/expo/eas-cli/pull/4760) by [@wschurman](https://github.com/wschurman))
|
||||
- Print the notice about eas-cli in project dependencies only when stderr is a terminal, so programs that read stderr get the command's error. ([#4591](https://github.com/expo/eas-cli/pull/4591) by [@vonovak](https://github.com/vonovak))
|
||||
- Show account roles and credential permission limits in `eas login` when you are already logged in. ([#4759](https://github.com/expo/eas-cli/pull/4759) by [@wschurman](https://github.com/wschurman))
|
||||
- Fix simulator control action help routing and show help when a flag is incomplete. ([#4681](https://github.com/expo/eas-cli/pull/4681) by [@gwdp](https://github.com/gwdp))
|
||||
- Require explicit `host:` simulator-host upload paths and report missing local files. ([#4683](https://github.com/expo/eas-cli/pull/4683) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation.
|
||||
|
||||
## 26.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Regenerate graphql code. ([#4692](https://github.com/expo/eas-cli/pull/4692) by [@wschurman](https://github.com/wschurman))
|
||||
- Read the app config fewer times in `eas build` and other project commands. An iOS build now runs `expo config` 2 times instead of 6. ([`78ba760`](https://github.com/expo/eas-cli/commit/78ba76001b67c9971236af1a1d02e46c2f82d24a) by [@vonovak](https://github.com/vonovak))
|
||||
- Share one GraphQL client between the project ID check and the command, so commands send one fewer app query. ([#4646](https://github.com/expo/eas-cli/pull/4646) by [@vonovak](https://github.com/vonovak))
|
||||
- Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs. ([#4694](https://github.com/expo/eas-cli/pull/4694) by [@brentvatne](https://github.com/brentvatne))
|
||||
- Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status. ([#4693](https://github.com/expo/eas-cli/pull/4693) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## 25.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
+268
-198
File diff suppressed because it is too large.
Load diff
Generated
+725
-731
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "25.3.0",
|
||||
"version": "27.0.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,12 +48,12 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "25.3.0",
|
||||
"@expo/eas-json": "25.3.0",
|
||||
"@expo/eas-build-job": "27.0.0",
|
||||
"@expo/eas-json": "27.0.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/experimentation": "0.0.5",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "25.3.0",
|
||||
"@expo/logger": "27.0.0",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -65,7 +65,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "25.3.0",
|
||||
"@expo/steps": "27.0.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { createRequire } = require('node:module');
|
||||
|
||||
const root = fs.realpathSync(process.argv[2]);
|
||||
const cli = path.join(root, 'packages/eas-cli');
|
||||
const cliRequire = createRequire(path.join(cli, 'package.json'));
|
||||
cliRequire('ts-node').register({ project: path.join(cli, 'tsconfig.json'), transpileOnly: true });
|
||||
const { Config } = cliRequire('@oclif/core');
|
||||
const EasCommand = require(path.join(cli, 'src/commandUtils/EasCommand')).default;
|
||||
const Control = require(path.join(cli, 'src/commands/simulator/control')).default;
|
||||
const EasHelp = require(path.join(cli, 'src/help')).default;
|
||||
const cases = JSON.parse(process.argv[3]);
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'eas-help-lifecycle-'));
|
||||
const events = [];
|
||||
globalThis.__helpLifecycleEvents = events;
|
||||
|
||||
// Replace command work (auth/device I/O), keeping discovery, parsing and hooks real.
|
||||
EasCommand.prototype.run = async function () {
|
||||
events.push({ event: 'run', id: this.id });
|
||||
};
|
||||
EasCommand.prototype.finally = async function () {};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(directory, 'package.json'),
|
||||
JSON.stringify({
|
||||
name: 'eas-help-lifecycle-fixture',
|
||||
version: '1.0.0',
|
||||
oclif: {
|
||||
bin: 'eas',
|
||||
commands: { strategy: 'explicit', target: './commands.cjs' },
|
||||
hooks: { prerun: './prerun.cjs', postrun: './postrun.cjs' },
|
||||
},
|
||||
})
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(directory, 'commands.cjs'),
|
||||
`exports.default = { 'simulator:control': require(${JSON.stringify(path.join(cli, 'src/commands/simulator/control'))}).default };`
|
||||
);
|
||||
for (const event of ['prerun', 'postrun']) {
|
||||
fs.writeFileSync(
|
||||
path.join(directory, `${event}.cjs`),
|
||||
`module.exports = async function({Command}) { globalThis.__helpLifecycleEvents.push({event:${JSON.stringify(event)},id:Command.id}); };`
|
||||
);
|
||||
}
|
||||
process.env.XDG_DATA_HOME = directory;
|
||||
process.env.XDG_CACHE_HOME = directory;
|
||||
const config = await Config.load({ root: directory });
|
||||
const results = [];
|
||||
for (const args of cases) {
|
||||
// Published manifests discover metadata without loading the command class.
|
||||
// Clear eager discovery's assignment to exercise that cold-start boundary.
|
||||
delete Control.id;
|
||||
const start = events.length;
|
||||
const write = process.stdout.write;
|
||||
let stdout = '',
|
||||
error;
|
||||
process.stdout.write = (chunk, encoding, callback) => {
|
||||
stdout += String(chunk);
|
||||
const done = typeof encoding === 'function' ? encoding : callback;
|
||||
done?.();
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
await new EasHelp(config).showHelp(args);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
process.stdout.write = write;
|
||||
}
|
||||
results.push({ stdout, events: events.slice(start), error });
|
||||
}
|
||||
console.log('LIFECYCLE_RESULT ' + JSON.stringify(results));
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const root = path.resolve(__dirname, '../../../..');
|
||||
const fixture = path.join(__dirname, 'fixtures/simulator-control-lifecycle.cjs');
|
||||
const actionHelp = [
|
||||
['simulator:control', 'rotate', '--help'],
|
||||
['sim:control', 'screenshot.capture', '-h', '--id', 'session-123'],
|
||||
['simulator:control', '--id', 'session-123', '--help', 'rotate'],
|
||||
];
|
||||
const incompleteFlags = [
|
||||
['simulator:control', 'screenshot.capture', '--help', '--output'],
|
||||
['sim:control', 'rotate', '-h', '--id'],
|
||||
];
|
||||
let results: { stdout: string; events: unknown[]; error?: string }[];
|
||||
|
||||
beforeAll(async () => {
|
||||
// Load TypeScript once, outside per-case timeouts, including on coverage runners.
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
[fixture, root, JSON.stringify([...actionHelp, ...incompleteFlags])],
|
||||
{ timeout: 20_000 }
|
||||
);
|
||||
results = JSON.parse(stdout.split('LIFECYCLE_RESULT ').at(-1)!);
|
||||
}, 30_000);
|
||||
|
||||
describe('simulator action help through real oclif lookup and hooks', () => {
|
||||
it.each(actionHelp.map((argv, index) => ({ argv, index })))(
|
||||
'keeps canonical command identity and executes lifecycle hooks: $argv',
|
||||
({ index }) => {
|
||||
expect(results[index].error).toBeUndefined();
|
||||
expect(results[index].events).toEqual([
|
||||
{ event: 'prerun', id: 'simulator:control' },
|
||||
{ event: 'run', id: 'simulator:control' },
|
||||
{ event: 'postrun', id: 'simulator:control' },
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(incompleteFlags.map((argv, index) => ({ argv, index })))(
|
||||
'shows ordinary help without starting the command for an incomplete flag: $argv',
|
||||
({ index }) => {
|
||||
const { stdout, events, error } = results[index + actionHelp.length];
|
||||
expect(error).toBeUndefined();
|
||||
expect(stdout).toContain('USAGE');
|
||||
expect(events).toEqual([]);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Config, Help } from '@oclif/core';
|
||||
|
||||
import SimulatorControl from '../commands/simulator/control';
|
||||
import EasHelp from '../help';
|
||||
|
||||
describe(EasHelp, () => {
|
||||
@@ -9,7 +8,7 @@ describe(EasHelp, () => {
|
||||
let showHelp: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
run = jest.spyOn(SimulatorControl, 'run').mockResolvedValue(Promise.resolve());
|
||||
run = jest.spyOn(config, 'runCommand').mockResolvedValue(Promise.resolve());
|
||||
showHelp = jest.spyOn(Help.prototype, 'showHelp').mockResolvedValue();
|
||||
});
|
||||
|
||||
@@ -21,12 +20,14 @@ describe(EasHelp, () => {
|
||||
['simulator:control', '--id', 'session-123', '--help', 'rotate'],
|
||||
])('routes action help through the command: %j', async (...argv) => {
|
||||
await new EasHelp(config).showHelp(argv);
|
||||
expect(run).toHaveBeenCalledWith(argv.slice(1), config);
|
||||
expect(run).toHaveBeenCalledWith('simulator:control', argv.slice(1));
|
||||
expect(showHelp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['simulator:control', '--help'],
|
||||
['simulator:control', 'screenshot.capture', '--help', '--output'],
|
||||
['sim:control', 'rotate', '-h', '--id'],
|
||||
['sim:control', '-h'],
|
||||
['build', '--help'],
|
||||
['simulator:control', 'clipboard.write', '--', '--help'],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { CombinedError } from '@urql/core';
|
||||
import chalk from 'chalk';
|
||||
import { GraphQLError } from 'graphql/error';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
EasBuildResourceClassNotAvailableInFreeTierError,
|
||||
EasBuildTooManyPendingBuildsError,
|
||||
EasBuildUnauthorizedError,
|
||||
RequestValidationError,
|
||||
TurtleDeprecatedJobFormatError,
|
||||
} from '../errors';
|
||||
@@ -758,6 +760,38 @@ describe(Build.name, () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('for UNAUTHORIZED', () => {
|
||||
it('points to the permission grant and permissions.account.role in eas.json', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError(
|
||||
'Cannot create an EXPO_TOKEN for the job: the job requires the "Release Manager" role',
|
||||
'UNAUTHORIZED'
|
||||
);
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildUnauthorizedError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildUnauthorizedError,
|
||||
'Cannot create an EXPO_TOKEN for the job: the job requires the "Release Manager" role\n' +
|
||||
`Request ID: ${mockRequestId}\n\n` +
|
||||
`Run ${chalk.bold(
|
||||
'eas whoami'
|
||||
)} to check the user you are logged in as and your permission grant. Run ${chalk.bold(
|
||||
'eas login'
|
||||
)} to change the user or permission grant.\n\n` +
|
||||
`If your build profile in eas.json uses ${chalk.bold(
|
||||
'permissions.account.role'
|
||||
)}, it may mean your credentials do not grant the requested role.`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error is unexpected graphQL-related error', () => {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
EasBuildProjectArchiveUploadError,
|
||||
EasBuildResourceClassNotAvailableInFreeTierError,
|
||||
EasBuildTooManyPendingBuildsError,
|
||||
EasBuildUnauthorizedError,
|
||||
RequestValidationError,
|
||||
TurtleDeprecatedJobFormatError,
|
||||
} from './errors';
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
logSourceForBuild,
|
||||
} from './logs';
|
||||
import { collectMetadataAsync } from './metadata';
|
||||
import { resolveJobExpoTokenRole } from './utils/expoTokenRole';
|
||||
import { printDeprecationWarnings } from './utils/printBuildInfo';
|
||||
import {
|
||||
LocalFile,
|
||||
@@ -130,6 +132,7 @@ function resolveBuildParamsInput<T extends Platform>(
|
||||
resourceClass: ctx.resourceClass,
|
||||
sdkVersion: metadata.sdkVersion,
|
||||
reactNativeVersion: metadata.reactNativeVersion,
|
||||
expoTokenRole: resolveJobExpoTokenRole(ctx.buildProfile),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -300,6 +303,17 @@ export async function handleBuildRequestErrorAsync(
|
||||
throw new EasBuildTooManyPendingBuildsError(
|
||||
`You have already reached the maximum number of pending ${requestedPlatformDisplayNames[platform]} builds for your account. Try again later.`
|
||||
);
|
||||
} else if (graphQLErrorCode === 'UNAUTHORIZED') {
|
||||
const requestId = graphQLErrors[0]?.extensions?.requestId;
|
||||
throw new EasBuildUnauthorizedError(
|
||||
`${graphQLErrors[0]?.message}${requestId ? `\nRequest ID: ${requestId}` : ''}\n\nRun ${chalk.bold(
|
||||
'eas whoami'
|
||||
)} to check the user you are logged in as and your permission grant. Run ${chalk.bold(
|
||||
'eas login'
|
||||
)} to change the user or permission grant.\n\nIf your build profile in eas.json uses ${chalk.bold(
|
||||
'permissions.account.role'
|
||||
)}, it may mean your credentials do not grant the requested role.`
|
||||
);
|
||||
} else if (Array.isArray(error?.graphQLErrors)) {
|
||||
const errorDetails = formatBuildRequestErrorDetails(error, graphQLErrors);
|
||||
throw new Error(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { Env, Platform } from '@expo/eas-build-job';
|
||||
import { BuildProfile, EasJson, ResourceClass } from '@expo/eas-json';
|
||||
import JsonFile from '@expo/json-file';
|
||||
@@ -13,7 +14,6 @@ import { createIosContextAsync } from './ios/build';
|
||||
import { LocalBuildMode, LocalBuildOptions } from './local';
|
||||
import { resolveBuildResourceClass } from './utils/resourceClass';
|
||||
import { Analytics, AnalyticsEventProperties, BuildEvent } from '../analytics/AnalyticsManager';
|
||||
import { DynamicConfigContextFn } from '../commandUtils/context/DynamicProjectConfigContextField';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { CredentialsContext } from '../credentials/context';
|
||||
import { LazyExperimentationClient } from '../experimentation/LazyExperimentationClient';
|
||||
@@ -40,7 +40,8 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
graphqlClient,
|
||||
analytics,
|
||||
vcsClient,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
exp,
|
||||
projectId,
|
||||
customBuildConfigMetadata,
|
||||
buildLoggerLevel,
|
||||
freezeCredentials,
|
||||
@@ -66,7 +67,8 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
analytics: Analytics;
|
||||
vcsClient: Client;
|
||||
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
|
||||
exp: ExpoConfig;
|
||||
projectId: string;
|
||||
customBuildConfigMetadata?: CustomBuildConfigMetadata;
|
||||
buildLoggerLevel?: LoggerLevel;
|
||||
freezeCredentials: boolean;
|
||||
@@ -77,9 +79,6 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
experimentation?: LazyExperimentationClient;
|
||||
authenticationInfo?: LoggedInAuthenticationInfo;
|
||||
}): Promise<BuildContext<T>> {
|
||||
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({
|
||||
env,
|
||||
});
|
||||
const projectName = exp.slug;
|
||||
|
||||
const [account, workflow] = await Promise.all([
|
||||
|
||||
@@ -22,4 +22,6 @@ export class EasBuildDownForMaintenanceError extends EasCommandError {}
|
||||
|
||||
export class EasBuildTooManyPendingBuildsError extends EasCommandError {}
|
||||
|
||||
export class EasBuildUnauthorizedError extends EasCommandError {}
|
||||
|
||||
export class EasBuildProjectArchiveUploadError extends EasCommandError {}
|
||||
@@ -119,7 +119,7 @@ export async function runBuildAndSubmitAsync({
|
||||
actor,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
downloadSimBuildAutoConfirm,
|
||||
envOverride,
|
||||
evaluatedConfig,
|
||||
experimentation,
|
||||
authenticationInfo,
|
||||
}: {
|
||||
@@ -131,7 +131,7 @@ export async function runBuildAndSubmitAsync({
|
||||
actor: Actor;
|
||||
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
|
||||
downloadSimBuildAutoConfirm?: boolean;
|
||||
envOverride?: Env;
|
||||
evaluatedConfig?: { env: Env; exp: ExpoConfig; projectId: string };
|
||||
/** Enables the Free plan limit experiments on the build request error path. */
|
||||
experimentation?: LazyExperimentationClient;
|
||||
/** Lets the Free plan limit checkout experiment create a checkout session. */
|
||||
@@ -224,15 +224,15 @@ export async function runBuildAndSubmitAsync({
|
||||
for (const buildProfile of buildProfiles) {
|
||||
const platform = toAppPlatform(buildProfile.platform);
|
||||
|
||||
const { env } = !envOverride
|
||||
? await evaluateConfigWithEnvVarsAsync({
|
||||
buildProfile: buildProfile.profile,
|
||||
buildProfileName: buildProfile.profileName,
|
||||
graphqlClient,
|
||||
getProjectConfig: getDynamicPrivateProjectConfigAsync,
|
||||
opts: { env: buildProfile.profile.env },
|
||||
})
|
||||
: { env: envOverride };
|
||||
const { env, exp, projectId } =
|
||||
evaluatedConfig ??
|
||||
(await evaluateConfigWithEnvVarsAsync({
|
||||
buildProfile: buildProfile.profile,
|
||||
buildProfileName: buildProfile.profileName,
|
||||
graphqlClient,
|
||||
getProjectConfig: getDynamicPrivateProjectConfigAsync,
|
||||
opts: { env: buildProfile.profile.env },
|
||||
}));
|
||||
|
||||
const { build: maybeBuild, buildCtx } = await prepareAndStartBuildAsync({
|
||||
projectDir,
|
||||
@@ -244,7 +244,8 @@ export async function runBuildAndSubmitAsync({
|
||||
graphqlClient,
|
||||
analytics,
|
||||
vcsClient,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
exp,
|
||||
projectId,
|
||||
customBuildConfigMetadata: customBuildConfigMetadataByPlatform[platform],
|
||||
env,
|
||||
easJsonAccessor,
|
||||
@@ -384,7 +385,8 @@ async function prepareAndStartBuildAsync({
|
||||
graphqlClient,
|
||||
analytics,
|
||||
vcsClient,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
exp,
|
||||
projectId,
|
||||
customBuildConfigMetadata,
|
||||
env,
|
||||
easJsonAccessor,
|
||||
@@ -400,7 +402,8 @@ async function prepareAndStartBuildAsync({
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
analytics: Analytics;
|
||||
vcsClient: Client;
|
||||
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
|
||||
exp: ExpoConfig;
|
||||
projectId: string;
|
||||
customBuildConfigMetadata?: CustomBuildConfigMetadata;
|
||||
easJsonAccessor: EasJsonAccessor;
|
||||
env: Env;
|
||||
@@ -423,7 +426,8 @@ async function prepareAndStartBuildAsync({
|
||||
graphqlClient,
|
||||
analytics,
|
||||
vcsClient,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
exp,
|
||||
projectId,
|
||||
customBuildConfigMetadata,
|
||||
buildLoggerLevel: flags.buildLoggerLevel ?? (Log.isDebug ? LoggerLevel.DEBUG : undefined),
|
||||
freezeCredentials: flags.freezeCredentials,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildProfile,
|
||||
CredentialsSource,
|
||||
JobExpoTokenRole as EasJsonJobExpoTokenRole,
|
||||
} from '@expo/eas-json';
|
||||
|
||||
import { JobExpoTokenRole } from '../../../graphql/generated';
|
||||
import { resolveJobExpoTokenRole } from '../expoTokenRole';
|
||||
|
||||
function createBuildProfile(
|
||||
permissions?: BuildProfile<Platform.ANDROID>['permissions']
|
||||
): BuildProfile<Platform.ANDROID> {
|
||||
return {
|
||||
credentialsSource: CredentialsSource.REMOTE,
|
||||
distribution: 'store',
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
|
||||
describe(resolveJobExpoTokenRole, () => {
|
||||
it('returns undefined when the build profile does not request a role', () => {
|
||||
expect(resolveJobExpoTokenRole(createBuildProfile())).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[EasJsonJobExpoTokenRole.RUNNER, JobExpoTokenRole.Runner],
|
||||
[EasJsonJobExpoTokenRole.DEVELOPER, JobExpoTokenRole.Developer],
|
||||
[EasJsonJobExpoTokenRole.RELEASE_MANAGER, JobExpoTokenRole.ReleaseManager],
|
||||
])('maps the %s role to %s', (role, expected) => {
|
||||
expect(resolveJobExpoTokenRole(createBuildProfile({ account: { role } }))).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { BuildProfile, JobExpoTokenRole as EasJsonJobExpoTokenRole } from '@expo/eas-json';
|
||||
|
||||
import { JobExpoTokenRole } from '../../graphql/generated';
|
||||
|
||||
const easJsonJobExpoTokenRoleToJobExpoTokenRoleMapping: Record<
|
||||
EasJsonJobExpoTokenRole,
|
||||
JobExpoTokenRole
|
||||
> = {
|
||||
[EasJsonJobExpoTokenRole.RUNNER]: JobExpoTokenRole.Runner,
|
||||
[EasJsonJobExpoTokenRole.DEVELOPER]: JobExpoTokenRole.Developer,
|
||||
[EasJsonJobExpoTokenRole.RELEASE_MANAGER]: JobExpoTokenRole.ReleaseManager,
|
||||
};
|
||||
|
||||
export function resolveJobExpoTokenRole<T extends Platform>(
|
||||
profile: BuildProfile<T>
|
||||
): JobExpoTokenRole | undefined {
|
||||
const role = profile.permissions?.account.role;
|
||||
return role ? easJsonJobExpoTokenRoleToJobExpoTokenRoleMapping[role] : undefined;
|
||||
}
|
||||
@@ -272,7 +272,7 @@ export default abstract class EasCommand extends Command {
|
||||
let baseMessage = `${commandId} command failed.`;
|
||||
if (err instanceof EasCommandError) {
|
||||
if (!err.messageAlreadyPrinted) {
|
||||
Log.error(err.message);
|
||||
Log.errorToStderr(err.message);
|
||||
}
|
||||
} else if (err instanceof CombinedError && err?.graphQLErrors) {
|
||||
const cleanGQLErrorsMessage = err?.graphQLErrors
|
||||
@@ -321,10 +321,10 @@ export default abstract class EasCommand extends Command {
|
||||
const cleanMessage = err.networkError
|
||||
? `${cleanGQLErrorsMessage}\n${err.networkError.message}`
|
||||
: cleanGQLErrorsMessage;
|
||||
Log.error(cleanMessage);
|
||||
Log.errorToStderr(cleanMessage);
|
||||
baseMessage = BASE_GRAPHQL_ERROR_MESSAGE;
|
||||
} else {
|
||||
Log.error(err.message);
|
||||
Log.errorToStderr(err.message);
|
||||
}
|
||||
Log.debug(err);
|
||||
Sentry.withScope(scope => {
|
||||
|
||||
@@ -200,7 +200,7 @@ describe('EasCommand', () => {
|
||||
it('logs the message', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const Log = jest.requireMock('../../log').default;
|
||||
const logErrorSpy = jest.spyOn(Log, 'error');
|
||||
const logErrorSpy = jest.spyOn(Log, 'errorToStderr');
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
|
||||
const error = new Error('Unexpected, internal error message');
|
||||
@@ -218,7 +218,7 @@ describe('EasCommand', () => {
|
||||
it('skips the message when the command already printed it', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const Log = jest.requireMock('../../log').default;
|
||||
const logErrorSpy = jest.spyOn(Log, 'error');
|
||||
const logErrorSpy = jest.spyOn(Log, 'errorToStderr');
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const { EasCommandError } = require('../errors');
|
||||
const Sentry = jest.requireMock('../../sentry').default;
|
||||
@@ -242,7 +242,7 @@ describe('EasCommand', () => {
|
||||
it('logs the cleaned message if needed', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const Log = jest.requireMock('../../log').default;
|
||||
const logErrorSpy = jest.spyOn(Log, 'error');
|
||||
const logErrorSpy = jest.spyOn(Log, 'errorToStderr');
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
|
||||
const { CombinedError } = require('@urql/core');
|
||||
@@ -262,7 +262,7 @@ describe('EasCommand', () => {
|
||||
it('logs the cleaned message with request ID if present', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const Log = jest.requireMock('../../log').default;
|
||||
const logErrorSpy = jest.spyOn(Log, 'error');
|
||||
const logErrorSpy = jest.spyOn(Log, 'errorToStderr');
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
|
||||
const { CombinedError } = require('@urql/core');
|
||||
@@ -297,7 +297,7 @@ describe('EasCommand', () => {
|
||||
it('logs the cleaned messages with request IDs if multiple GraphQL errors present', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const Log = jest.requireMock('../../log').default;
|
||||
const logErrorSpy = jest.spyOn(Log, 'error');
|
||||
const logErrorSpy = jest.spyOn(Log, 'errorToStderr');
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
|
||||
const { CombinedError } = require('@urql/core');
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ExpoConfig } from '@expo/config';
|
||||
import ContextField, { ContextOptions } from './ContextField';
|
||||
import { createGraphqlClient } from './contextUtils/createGraphqlClient';
|
||||
import { findProjectDirAndVerifyProjectSetupAsync } from './contextUtils/findProjectDirAndVerifyProjectSetupAsync';
|
||||
import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
|
||||
import { getProjectIdAsync, isProjectIdInAppConfig } from './contextUtils/getProjectIdAsync';
|
||||
import { loadServerSideEnvironmentVariablesAsync } from './contextUtils/loadServerSideEnvironmentVariablesAsync';
|
||||
import {
|
||||
ExpoConfigOptions,
|
||||
@@ -18,83 +18,57 @@ export type DynamicConfigContextFn = (options?: ExpoConfigOptions) => Promise<{
|
||||
}>;
|
||||
|
||||
export class DynamicPublicProjectConfigContextField extends ContextField<DynamicConfigContextFn> {
|
||||
async getValueAsync({
|
||||
nonInteractive,
|
||||
sessionManager,
|
||||
withServerSideEnvironment,
|
||||
}: ContextOptions): Promise<DynamicConfigContextFn> {
|
||||
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
|
||||
return async (options?: ExpoConfigOptions) => {
|
||||
const expBefore = await getPublicExpoConfigAsync(projectDir, options);
|
||||
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
|
||||
nonInteractive,
|
||||
env: options?.env,
|
||||
});
|
||||
if (withServerSideEnvironment) {
|
||||
const { authenticationInfo } = await sessionManager.ensureLoggedInAsync({
|
||||
nonInteractive,
|
||||
});
|
||||
const graphqlClient = createGraphqlClient(authenticationInfo);
|
||||
const serverSideEnvironmentVariables = await loadServerSideEnvironmentVariablesAsync({
|
||||
environment: withServerSideEnvironment,
|
||||
projectId,
|
||||
graphqlClient,
|
||||
});
|
||||
options = {
|
||||
...options,
|
||||
env: {
|
||||
...options?.env,
|
||||
...serverSideEnvironmentVariables,
|
||||
},
|
||||
};
|
||||
}
|
||||
const exp = await getPublicExpoConfigAsync(projectDir, options);
|
||||
return {
|
||||
exp,
|
||||
projectDir,
|
||||
projectId,
|
||||
};
|
||||
};
|
||||
async getValueAsync(options: ContextOptions): Promise<DynamicConfigContextFn> {
|
||||
return await createDynamicConfigFnAsync(getPublicExpoConfigAsync, options);
|
||||
}
|
||||
}
|
||||
|
||||
export class DynamicPrivateProjectConfigContextField extends ContextField<DynamicConfigContextFn> {
|
||||
async getValueAsync({
|
||||
nonInteractive,
|
||||
sessionManager,
|
||||
withServerSideEnvironment,
|
||||
}: ContextOptions): Promise<DynamicConfigContextFn> {
|
||||
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
|
||||
return async (options?: ExpoConfigOptions) => {
|
||||
const expBefore = await getPrivateExpoConfigAsync(projectDir, options);
|
||||
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
|
||||
async getValueAsync(options: ContextOptions): Promise<DynamicConfigContextFn> {
|
||||
return await createDynamicConfigFnAsync(getPrivateExpoConfigAsync, options);
|
||||
}
|
||||
}
|
||||
|
||||
async function createDynamicConfigFnAsync(
|
||||
getExpoConfigAsync: (projectDir: string, options?: ExpoConfigOptions) => Promise<ExpoConfig>,
|
||||
{ nonInteractive, sessionManager, withServerSideEnvironment }: ContextOptions
|
||||
): Promise<DynamicConfigContextFn> {
|
||||
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
|
||||
return async (options?: ExpoConfigOptions) => {
|
||||
const expBefore = await getExpoConfigAsync(projectDir, options);
|
||||
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
|
||||
nonInteractive,
|
||||
env: options?.env,
|
||||
});
|
||||
if (withServerSideEnvironment) {
|
||||
const { authenticationInfo } = await sessionManager.ensureLoggedInAsync({
|
||||
nonInteractive,
|
||||
env: options?.env,
|
||||
});
|
||||
if (withServerSideEnvironment) {
|
||||
const { authenticationInfo } = await sessionManager.ensureLoggedInAsync({
|
||||
nonInteractive,
|
||||
});
|
||||
const graphqlClient = createGraphqlClient(authenticationInfo);
|
||||
const serverSideEnvironmentVariables = await loadServerSideEnvironmentVariablesAsync({
|
||||
environment: withServerSideEnvironment,
|
||||
projectId,
|
||||
graphqlClient,
|
||||
});
|
||||
options = {
|
||||
...options,
|
||||
env: {
|
||||
...options?.env,
|
||||
...serverSideEnvironmentVariables,
|
||||
},
|
||||
};
|
||||
}
|
||||
const exp = await getPrivateExpoConfigAsync(projectDir, options);
|
||||
const graphqlClient = createGraphqlClient(authenticationInfo);
|
||||
const serverSideEnvironmentVariables = await loadServerSideEnvironmentVariablesAsync({
|
||||
environment: withServerSideEnvironment,
|
||||
projectId,
|
||||
graphqlClient,
|
||||
});
|
||||
options = {
|
||||
...options,
|
||||
env: {
|
||||
...options?.env,
|
||||
...serverSideEnvironmentVariables,
|
||||
},
|
||||
};
|
||||
} else if (isProjectIdInAppConfig(expBefore, projectId)) {
|
||||
return {
|
||||
exp,
|
||||
exp: expBefore,
|
||||
projectDir,
|
||||
projectId,
|
||||
};
|
||||
}
|
||||
const exp = await getExpoConfigAsync(projectDir, options);
|
||||
return {
|
||||
exp,
|
||||
projectDir,
|
||||
projectId,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import ContextField, { ContextOptions } from './ContextField';
|
||||
import { ExpoGraphqlClient, createGraphqlClient } from './contextUtils/createGraphqlClient';
|
||||
import { ExpoGraphqlClient } from './contextUtils/createGraphqlClient';
|
||||
import { LoggedInAuthenticationInfo } from '../../user/SessionManager';
|
||||
import { Actor } from '../../user/User';
|
||||
import FeatureGateEnvOverrides from '../gating/FeatureGateEnvOverrides';
|
||||
@@ -22,7 +22,7 @@ export default class LoggedInContextField extends ContextField<LoggedInContextTy
|
||||
});
|
||||
const featureGateServerValues: { [key: string]: boolean } = actor?.featureGates ?? {};
|
||||
|
||||
const graphqlClient = createGraphqlClient(authenticationInfo);
|
||||
const graphqlClient = sessionManager.getGraphqlClient(authenticationInfo);
|
||||
|
||||
return {
|
||||
actor,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import ContextField, { ContextOptions } from './ContextField';
|
||||
import { ExpoGraphqlClient, createGraphqlClient } from './contextUtils/createGraphqlClient';
|
||||
import { ExpoGraphqlClient } from './contextUtils/createGraphqlClient';
|
||||
import { Actor } from '../../user/User';
|
||||
import FeatureGateEnvOverrides from '../gating/FeatureGateEnvOverrides';
|
||||
import FeatureGating from '../gating/FeatureGating';
|
||||
@@ -21,7 +21,7 @@ export default class MaybeLoggedInContextField extends ContextField<MaybeLoggedI
|
||||
sessionSecret: sessionManager.getSessionSecret(),
|
||||
};
|
||||
|
||||
const graphqlClient = createGraphqlClient(authenticationInfo);
|
||||
const graphqlClient = sessionManager.getGraphqlClient(authenticationInfo);
|
||||
const actor = await sessionManager.getUserAsync();
|
||||
const featureGateServerValues: { [key: string]: boolean } = actor?.featureGates ?? {};
|
||||
|
||||
|
||||
+5
-2
@@ -4,7 +4,7 @@ import { InvalidEasJsonError } from '@expo/eas-json/build/errors';
|
||||
import ContextField, { ContextOptions } from './ContextField';
|
||||
import { createGraphqlClient } from './contextUtils/createGraphqlClient';
|
||||
import { findProjectDirAndVerifyProjectSetupAsync } from './contextUtils/findProjectDirAndVerifyProjectSetupAsync';
|
||||
import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
|
||||
import { getProjectIdAsync, isProjectIdInAppConfig } from './contextUtils/getProjectIdAsync';
|
||||
import { loadServerSideEnvironmentVariablesAsync } from './contextUtils/loadServerSideEnvironmentVariablesAsync';
|
||||
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
|
||||
|
||||
@@ -58,7 +58,10 @@ export class OptionalPrivateProjectConfigContextField extends ContextField<
|
||||
});
|
||||
serverSideEnvVars = serverSideEnvironmentVariables;
|
||||
}
|
||||
const exp = await getPrivateExpoConfigAsync(projectDir, { env: serverSideEnvVars });
|
||||
const exp =
|
||||
withServerSideEnvironment || !isProjectIdInAppConfig(expBefore, projectId)
|
||||
? await getPrivateExpoConfigAsync(projectDir, { env: serverSideEnvVars })
|
||||
: expBefore;
|
||||
return {
|
||||
exp,
|
||||
projectDir,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ExpoConfig } from '@expo/config';
|
||||
import ContextField, { ContextOptions } from './ContextField';
|
||||
import { createGraphqlClient } from './contextUtils/createGraphqlClient';
|
||||
import { findProjectDirAndVerifyProjectSetupAsync } from './contextUtils/findProjectDirAndVerifyProjectSetupAsync';
|
||||
import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
|
||||
import { getProjectIdAsync, isProjectIdInAppConfig } from './contextUtils/getProjectIdAsync';
|
||||
import { loadServerSideEnvironmentVariablesAsync } from './contextUtils/loadServerSideEnvironmentVariablesAsync';
|
||||
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
|
||||
|
||||
@@ -39,7 +39,10 @@ export class PrivateProjectConfigContextField extends ContextField<{
|
||||
});
|
||||
serverSideEnvVars = serverSideEnvironmentVariables;
|
||||
}
|
||||
const exp = await getPrivateExpoConfigAsync(projectDir, { env: serverSideEnvVars });
|
||||
const exp =
|
||||
withServerSideEnvironment || !isProjectIdInAppConfig(expBefore, projectId)
|
||||
? await getPrivateExpoConfigAsync(projectDir, { env: serverSideEnvVars })
|
||||
: expBefore;
|
||||
|
||||
return {
|
||||
projectId,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import nock from 'nock';
|
||||
|
||||
import { AnalyticsWithOrchestration } from '../../../analytics/AnalyticsManager';
|
||||
import { getExpoApiBaseUrl } from '../../../api';
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import SessionManager from '../../../user/SessionManager';
|
||||
import { ContextOptions } from '../ContextField';
|
||||
import LoggedInContextField from '../LoggedInContextField';
|
||||
import { getProjectIdAsync } from '../contextUtils/getProjectIdAsync';
|
||||
|
||||
const app = {
|
||||
__typename: 'App',
|
||||
id: 'PROJECT_ID',
|
||||
name: 'app',
|
||||
fullName: '@owner/app',
|
||||
slug: 'app',
|
||||
ownerAccount: { __typename: 'Account', id: 'ACCOUNT_ID', name: 'owner' },
|
||||
githubRepository: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
nock.enableNetConnect();
|
||||
});
|
||||
|
||||
describe(LoggedInContextField, () => {
|
||||
it('shares the GraphQL cache with the project ID check', async () => {
|
||||
const analytics: AnalyticsWithOrchestration = {
|
||||
logEvent: jest.fn(),
|
||||
setActor: jest.fn(),
|
||||
flushAsync: jest.fn(),
|
||||
getDeviceId: jest.fn(() => null),
|
||||
};
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
jest.spyOn(sessionManager, 'ensureLoggedInAsync').mockResolvedValue({
|
||||
actor: { __typename: 'User', id: 'USER_ID', username: 'owner' } as any,
|
||||
authenticationInfo: { accessToken: 'token', sessionSecret: null },
|
||||
});
|
||||
const scope = nock(getExpoApiBaseUrl())
|
||||
.post('/graphql')
|
||||
.once()
|
||||
.reply(200, { data: { app: { __typename: 'AppQuery', byId: app } } });
|
||||
|
||||
const projectId = await getProjectIdAsync(
|
||||
sessionManager,
|
||||
{ name: 'app', slug: 'app', extra: { eas: { projectId: 'PROJECT_ID' } } },
|
||||
{ nonInteractive: true }
|
||||
);
|
||||
const { graphqlClient } = await new LoggedInContextField().getValueAsync({
|
||||
sessionManager,
|
||||
nonInteractive: true,
|
||||
} as ContextOptions);
|
||||
|
||||
expect(await AppQuery.byIdAsync(graphqlClient, projectId)).toMatchObject({
|
||||
fullName: '@owner/app',
|
||||
});
|
||||
expect(scope.isDone()).toBe(true);
|
||||
});
|
||||
});
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { anything, instance, mock, when } from 'ts-mockito';
|
||||
|
||||
import { getPrivateExpoConfigAsync, getPublicExpoConfigAsync } from '../../../project/expoConfig';
|
||||
import SessionManager from '../../../user/SessionManager';
|
||||
import { ContextOptions } from '../ContextField';
|
||||
import {
|
||||
DynamicPrivateProjectConfigContextField,
|
||||
DynamicPublicProjectConfigContextField,
|
||||
} from '../DynamicProjectConfigContextField';
|
||||
import { OptionalPrivateProjectConfigContextField } from '../OptionalPrivateProjectConfigContextField';
|
||||
import { PrivateProjectConfigContextField } from '../PrivateProjectConfigContextField';
|
||||
import { findProjectDirAndVerifyProjectSetupAsync } from '../contextUtils/findProjectDirAndVerifyProjectSetupAsync';
|
||||
import { getProjectIdAsync } from '../contextUtils/getProjectIdAsync';
|
||||
import { loadServerSideEnvironmentVariablesAsync } from '../contextUtils/loadServerSideEnvironmentVariablesAsync';
|
||||
|
||||
jest.mock('../../../project/expoConfig');
|
||||
jest.mock('../contextUtils/findProjectDirAndVerifyProjectSetupAsync');
|
||||
jest.mock('../contextUtils/getProjectIdAsync', () => ({
|
||||
...jest.requireActual('../contextUtils/getProjectIdAsync'),
|
||||
getProjectIdAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../contextUtils/loadServerSideEnvironmentVariablesAsync');
|
||||
jest.mock('../contextUtils/createGraphqlClient');
|
||||
|
||||
const projectId = 'test-project-id';
|
||||
const linkedExp = { name: 'a', slug: 'a', extra: { eas: { projectId } } } as ExpoConfig;
|
||||
const unlinkedExp = { name: 'a', slug: 'a' } as ExpoConfig;
|
||||
|
||||
type LoadConfigAsync = (
|
||||
options: ContextOptions
|
||||
) => Promise<{ exp: ExpoConfig; projectId: string } | undefined>;
|
||||
|
||||
const fields: [string, LoadConfigAsync, typeof getPrivateExpoConfigAsync][] = [
|
||||
[
|
||||
DynamicPublicProjectConfigContextField.name,
|
||||
async options =>
|
||||
await (
|
||||
await new DynamicPublicProjectConfigContextField().getValueAsync(options)
|
||||
)(),
|
||||
getPublicExpoConfigAsync,
|
||||
],
|
||||
[
|
||||
DynamicPrivateProjectConfigContextField.name,
|
||||
async options =>
|
||||
await (
|
||||
await new DynamicPrivateProjectConfigContextField().getValueAsync(options)
|
||||
)(),
|
||||
getPrivateExpoConfigAsync,
|
||||
],
|
||||
[
|
||||
PrivateProjectConfigContextField.name,
|
||||
async options => await new PrivateProjectConfigContextField().getValueAsync(options),
|
||||
getPrivateExpoConfigAsync,
|
||||
],
|
||||
[
|
||||
OptionalPrivateProjectConfigContextField.name,
|
||||
async options => await new OptionalPrivateProjectConfigContextField().getValueAsync(options),
|
||||
getPrivateExpoConfigAsync,
|
||||
],
|
||||
];
|
||||
|
||||
function createContextOptions(withServerSideEnvironment: string | null): ContextOptions {
|
||||
const sessionManager = mock<SessionManager>();
|
||||
when(sessionManager.ensureLoggedInAsync(anything())).thenResolve({
|
||||
authenticationInfo: { accessToken: null, sessionSecret: null },
|
||||
} as any);
|
||||
return {
|
||||
nonInteractive: true,
|
||||
sessionManager: instance(sessionManager),
|
||||
withServerSideEnvironment,
|
||||
} as ContextOptions;
|
||||
}
|
||||
|
||||
describe.each(fields)('%s', (_name, loadConfigAsync, getExpoConfigAsync) => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(findProjectDirAndVerifyProjectSetupAsync).mockResolvedValue('/app');
|
||||
jest.mocked(getProjectIdAsync).mockResolvedValue(projectId);
|
||||
});
|
||||
|
||||
it('reads the app config once when the project ID is in it', async () => {
|
||||
jest.mocked(getExpoConfigAsync).mockResolvedValue(linkedExp);
|
||||
|
||||
const result = await loadConfigAsync(createContextOptions(null));
|
||||
|
||||
expect(result).toMatchObject({ exp: linkedExp, projectId });
|
||||
expect(getExpoConfigAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reads the app config again after the project ID is written to it', async () => {
|
||||
jest
|
||||
.mocked(getExpoConfigAsync)
|
||||
.mockResolvedValueOnce(unlinkedExp)
|
||||
.mockResolvedValueOnce(linkedExp);
|
||||
|
||||
const result = await loadConfigAsync(createContextOptions(null));
|
||||
|
||||
expect(result?.exp).toBe(linkedExp);
|
||||
expect(getExpoConfigAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reads the app config again with server-side environment variables', async () => {
|
||||
jest.mocked(getExpoConfigAsync).mockResolvedValue(linkedExp);
|
||||
jest.mocked(loadServerSideEnvironmentVariablesAsync).mockResolvedValue({ B: '2' });
|
||||
|
||||
await loadConfigAsync(createContextOptions('production'));
|
||||
|
||||
expect(getExpoConfigAsync).toHaveBeenCalledTimes(2);
|
||||
expect(getExpoConfigAsync).toHaveBeenLastCalledWith('/app', { env: { B: '2' } });
|
||||
});
|
||||
|
||||
it('reads the app config again when the server-side environment is empty', async () => {
|
||||
jest.mocked(getExpoConfigAsync).mockResolvedValue(linkedExp);
|
||||
jest.mocked(loadServerSideEnvironmentVariablesAsync).mockResolvedValue({});
|
||||
|
||||
await loadConfigAsync(createContextOptions('production'));
|
||||
|
||||
expect(getExpoConfigAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe(DynamicPrivateProjectConfigContextField, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(findProjectDirAndVerifyProjectSetupAsync).mockResolvedValue('/app');
|
||||
jest.mocked(getProjectIdAsync).mockResolvedValue(projectId);
|
||||
});
|
||||
|
||||
it('merges server-side environment variables into the env passed in', async () => {
|
||||
jest.mocked(getPrivateExpoConfigAsync).mockResolvedValue(linkedExp);
|
||||
jest.mocked(loadServerSideEnvironmentVariablesAsync).mockResolvedValue({ B: '2' });
|
||||
|
||||
const getConfigAsync = await new DynamicPrivateProjectConfigContextField().getValueAsync(
|
||||
createContextOptions('production')
|
||||
);
|
||||
await getConfigAsync({ env: { A: '1' } });
|
||||
|
||||
expect(getPrivateExpoConfigAsync).toHaveBeenNthCalledWith(1, '/app', { env: { A: '1' } });
|
||||
expect(getPrivateExpoConfigAsync).toHaveBeenNthCalledWith(2, '/app', {
|
||||
env: { A: '1', B: '2' },
|
||||
});
|
||||
});
|
||||
});
|
||||
+37
@@ -11,8 +11,16 @@ jest.mock('fs');
|
||||
|
||||
jest.mock('../../../../prompts');
|
||||
|
||||
const originalStderrIsTTY = process.stderr.isTTY;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
// The dependency notice prints once per process and only to a terminal, so keep it unspent.
|
||||
process.stderr.isTTY = false;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.stderr.isTTY = originalStderrIsTTY;
|
||||
});
|
||||
|
||||
describe(findProjectRootAsync, () => {
|
||||
@@ -115,3 +123,32 @@ describe(findProjectDirAndVerifyProjectSetupAsync, () => {
|
||||
await expect(findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' })).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('eas-cli dependency notice', () => {
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./eas.json': JSON.stringify({}),
|
||||
'./package.json': JSON.stringify({ devDependencies: { 'eas-cli': '*' } }),
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('prints the notice only when stderr is a terminal', async () => {
|
||||
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
process.stderr.isTTY = false;
|
||||
await findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' });
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
|
||||
process.stderr.isTTY = true;
|
||||
await findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' });
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('in your project dependencies'));
|
||||
});
|
||||
});
|
||||
+7
@@ -10,6 +10,7 @@ import { fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync } from '../
|
||||
import { isExpoInstalled } from '../../../../project/projectUtils';
|
||||
import { promptAsync } from '../../../../prompts';
|
||||
import SessionManager from '../../../../user/SessionManager';
|
||||
import { ExpoGraphqlClient } from '../createGraphqlClient';
|
||||
import { findProjectRootAsync } from '../findProjectDirAndVerifyProjectSetupAsync';
|
||||
import { getProjectIdAsync } from '../getProjectIdAsync';
|
||||
|
||||
@@ -40,6 +41,8 @@ describe(getProjectIdAsync, () => {
|
||||
.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
|
||||
|
||||
const sessionManagerMock = mock<SessionManager>();
|
||||
|
||||
when(sessionManagerMock.getGraphqlClient(anything())).thenReturn({} as ExpoGraphqlClient);
|
||||
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
|
||||
actor: {
|
||||
__typename: 'User',
|
||||
@@ -186,6 +189,7 @@ describe(getProjectIdAsync, () => {
|
||||
|
||||
it('throws when the owner is not specified and is different than logged in user which is a robot', async () => {
|
||||
const sessionManagerMock = mock<SessionManager>();
|
||||
when(sessionManagerMock.getGraphqlClient(anything())).thenReturn({} as ExpoGraphqlClient);
|
||||
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
|
||||
actor: {
|
||||
__typename: 'Robot',
|
||||
@@ -410,6 +414,7 @@ describe(getProjectIdAsync, () => {
|
||||
|
||||
it('uses the only account without prompting when the user has a single account', async () => {
|
||||
const sessionManagerMock = mock<SessionManager>();
|
||||
when(sessionManagerMock.getGraphqlClient(anything())).thenReturn({} as ExpoGraphqlClient);
|
||||
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
|
||||
actor: {
|
||||
__typename: 'User',
|
||||
@@ -448,6 +453,7 @@ describe(getProjectIdAsync, () => {
|
||||
|
||||
it('throws for a robot user when no owner is set', async () => {
|
||||
const sessionManagerMock = mock<SessionManager>();
|
||||
when(sessionManagerMock.getGraphqlClient(anything())).thenReturn({} as ExpoGraphqlClient);
|
||||
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
|
||||
actor: {
|
||||
__typename: 'Robot',
|
||||
@@ -509,6 +515,7 @@ describe(getProjectIdAsync, () => {
|
||||
|
||||
it('defers to the project fetch in non-interactive mode when the account is already known', async () => {
|
||||
const sessionManagerMock = mock<SessionManager>();
|
||||
when(sessionManagerMock.getGraphqlClient(anything())).thenReturn({} as ExpoGraphqlClient);
|
||||
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
|
||||
actor: {
|
||||
__typename: 'User',
|
||||
|
||||
+2
-1
@@ -127,7 +127,8 @@ export async function findProjectDirAndVerifyProjectSetupAsync({
|
||||
} = {}): Promise<string> {
|
||||
const projectDir = await findProjectRootAsync({ cwd });
|
||||
await applyCliConfigAsync(projectDir);
|
||||
if (!ranEnsureEasCliIsNotInDependencies) {
|
||||
// The notice is advice for a person. Programs that run eas-cli read stderr for its errors.
|
||||
if (!ranEnsureEasCliIsNotInDependencies && process.stderr.isTTY) {
|
||||
ranEnsureEasCliIsNotInDependencies = true;
|
||||
await ensureEasCliIsNotInDependenciesAsync(projectDir);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Env } from '@expo/eas-build-job';
|
||||
import chalk from 'chalk';
|
||||
import semver from 'semver';
|
||||
|
||||
import { ExpoGraphqlClient, createGraphqlClient } from './createGraphqlClient';
|
||||
import { ExpoGraphqlClient } from './createGraphqlClient';
|
||||
import { findProjectRootAsync } from './findProjectDirAndVerifyProjectSetupAsync';
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import Log, { learnMore } from '../../../log';
|
||||
@@ -92,12 +92,16 @@ export async function getProjectIdAsync(
|
||||
const { actor, authenticationInfo } = await sessionManager.ensureLoggedInAsync({
|
||||
nonInteractive: options.nonInteractive,
|
||||
});
|
||||
const graphqlClient = createGraphqlClient(authenticationInfo);
|
||||
const graphqlClient = sessionManager.getGraphqlClient(authenticationInfo);
|
||||
|
||||
const projectId = await validateOrSetProjectIdAsync({ exp, graphqlClient, actor, options });
|
||||
return projectId;
|
||||
}
|
||||
|
||||
export function isProjectIdInAppConfig(exp: Pick<ExpoConfig, 'extra'>, projectId: string): boolean {
|
||||
return exp.extra?.eas?.projectId === projectId;
|
||||
}
|
||||
|
||||
export async function validateOrSetProjectIdAsync({
|
||||
exp,
|
||||
graphqlClient,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { getMockViewerUserPermission } from '../../../__tests__/viewerUserPermission';
|
||||
import { Permission, Role } from '../../../graphql/generated';
|
||||
import Log from '../../../log';
|
||||
import { confirmAsync, promptAsync } from '../../../prompts';
|
||||
import SessionManager from '../../../user/SessionManager';
|
||||
import { Actor } from '../../../user/User';
|
||||
import { resumeDeviceLoginAsync, startDeviceLoginAsync } from '../../../user/deviceLogin';
|
||||
import AccountLogin from '../login';
|
||||
|
||||
@@ -33,12 +38,12 @@ const sessionManager = new SessionManager({
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
const originalCI = process.env.CI;
|
||||
|
||||
function command(args: string[]): AccountLogin {
|
||||
function command(args: string[], actor: Actor | null = null): AccountLogin {
|
||||
const instance = new AccountLogin(['--device', ...args], getMockOclifConfig());
|
||||
// @ts-expect-error Mock the protected context without initializing a real session.
|
||||
jest.spyOn(instance, 'getContextAsync').mockResolvedValue({
|
||||
sessionManager,
|
||||
maybeLoggedIn: { actor: null },
|
||||
maybeLoggedIn: { actor },
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
@@ -179,3 +184,48 @@ it('requires device login for non-interactive sign-in', async () => {
|
||||
'Use eas login --device --non-interactive to log in without prompts.'
|
||||
);
|
||||
});
|
||||
|
||||
describe('when already logged in', () => {
|
||||
const ownerPermissions = getMockViewerUserPermission(Role.Owner).permissions;
|
||||
const limitedMessage =
|
||||
'The permissions granted during authentication limit your access to some accounts. Log in again and change the permission grant.';
|
||||
|
||||
function userActor(
|
||||
effectivePermissions: Actor['accounts'][number]['viewerUserPermission2']['effectivePermissions']
|
||||
): Actor {
|
||||
return {
|
||||
__typename: 'User',
|
||||
id: 'user-id',
|
||||
email: '[email protected]',
|
||||
username: 'jane',
|
||||
accounts: [
|
||||
{
|
||||
id: 'jane-id',
|
||||
name: 'jane',
|
||||
viewerUserPermission2: { permissions: ownerPermissions, effectivePermissions },
|
||||
},
|
||||
],
|
||||
isExpoAdmin: false,
|
||||
featureGates: {},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(startDeviceLoginAsync).mockResolvedValue(pending);
|
||||
});
|
||||
|
||||
it('shows the roles that the current credentials limit', async () => {
|
||||
await command(['--non-interactive'], userActor([Permission.View])).runAsync();
|
||||
expect(Log.warn).toHaveBeenCalledWith(`You are already logged in as ${chalk.bold('jane')}.`);
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
`• jane (Role: Owner, ${chalk.yellow('limited to Viewer by the current credentials')})`
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(limitedMessage);
|
||||
});
|
||||
|
||||
it('does not list accounts when the credentials do not limit them', async () => {
|
||||
await command(['--non-interactive'], userActor(ownerPermissions)).runAsync();
|
||||
expect(Log.log).not.toHaveBeenCalledWith('Accounts:');
|
||||
expect(Log.log).not.toHaveBeenCalledWith(limitedMessage);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import Log from '../../log';
|
||||
import { confirmAsync, promptAsync } from '../../prompts';
|
||||
import SessionManager from '../../user/SessionManager';
|
||||
import { getActorDisplayName } from '../../user/User';
|
||||
import { logAccountPermissions } from '../../user/accountPermissions';
|
||||
import {
|
||||
isDeviceLoginFailure,
|
||||
resumeDeviceLoginAsync,
|
||||
@@ -77,6 +78,13 @@ export default class AccountLogin extends EasCommand {
|
||||
|
||||
if (actor && !resume) {
|
||||
Log.warn(`You are already logged in as ${chalk.bold(getActorDisplayName(actor))}.`);
|
||||
const { hasLimitedAccount } = logAccountPermissions(actor);
|
||||
if (hasLimitedAccount) {
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
'The permissions granted during authentication limit your access to some accounts. Log in again and change the permission grant.'
|
||||
);
|
||||
}
|
||||
|
||||
const shouldContinue =
|
||||
nonInteractive ||
|
||||
|
||||
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user