mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1572a6420a | ||
|
|
524d0222ae | ||
|
|
c0151e4ce0 | ||
|
|
44f5ec55c9 | ||
|
|
bae232ac30 | ||
|
|
b5442a5bb6 | ||
|
|
547496c588 | ||
|
|
9f3ecf60c3 | ||
|
|
6275c1e54c | ||
|
|
d02de4da64 | ||
|
|
04c214be93 | ||
|
|
1d9607ca00 | ||
|
|
78ba76001b | ||
|
|
3c8b62fb39 | ||
|
|
c3a26fef34 | ||
|
|
a2c0d0cf09 | ||
|
|
f148570a36 | ||
|
|
e8ca106f2e | ||
|
|
62ddc91aa4 | ||
|
|
9b54c203ee | ||
|
|
a0fe3ff2c3 | ||
|
|
fb383d818b | ||
|
|
c07c5edba9 | ||
|
|
56646b7436 | ||
|
|
f775b10549 | ||
|
|
c756c5d0b5 | ||
|
|
9f674b1d19 | ||
|
|
6f33a4d2a4 |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Regenerate graphql code
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/eas-json': minor
|
||||
---
|
||||
|
||||
Add `permissions.account.role` to build profiles to request a role for the build's `EXPO_TOKEN`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Retry transient failures when reporting that a device run session has started.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': none
|
||||
---
|
||||
|
||||
Make command descriptions in `eas --help` consistently lowercase without trailing periods.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
'@expo/steps': patch
|
||||
---
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'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).
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/worker': patch
|
||||
---
|
||||
|
||||
Retry Node.js installation without the internal Node.js cache mirror when the first attempt fails, while preserving other configured mirrors.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Stop sandboxes after an optional maximum duration measured from readiness.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Add `eas/wait_for_asc_build_processing` to wait for an existing App Store Connect upload. Keep `eas/upload_to_asc`'s `wait_for_processing` input supported through the same processing logic.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Share one GraphQL client between the project ID check and the command, so commands send one fewer app query.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Unify signed URL uploading to a single implementation and use it everywhere
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/worker': patch
|
||||
---
|
||||
|
||||
Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@expo/eas-build-job': major
|
||||
'@expo/build-tools': major
|
||||
---
|
||||
|
||||
Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports.
|
||||
@@ -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.2.0",
|
||||
"PRODUCTION": "25.2.0"
|
||||
"STAGING": "25.3.0",
|
||||
"PRODUCTION": "25.3.0"
|
||||
}
|
||||
@@ -6,4 +6,4 @@ MISE_NODE_COREPACK = "true"
|
||||
actionlint = "1.7.12"
|
||||
gcloud = "570.0.0"
|
||||
jq = "1.8.1"
|
||||
node = "22.23.3"
|
||||
node = "22.23.4"
|
||||
@@ -26,7 +26,7 @@
|
||||
"build:egress-guard": "resources/egress-guard/build.sh",
|
||||
"test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh",
|
||||
"typecheck": "tsc",
|
||||
"generate-appium-commands": "mise exec [email protected]2.0 -- node scripts/generate-appium-commands.js",
|
||||
"generate-appium-commands": "mise exec [email protected]3.4 -- node scripts/generate-appium-commands.js",
|
||||
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
|
||||
"jest-unit": "jest --config jest/unit-config.ts",
|
||||
"jest-integration": "jest --config jest/integration-config.ts",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -67,6 +67,7 @@ import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
|
||||
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
|
||||
import { createUploadEmbeddedBundleBuildFunction } from './functions/uploadEmbeddedBundle';
|
||||
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
|
||||
import { createWaitForAscBuildProcessingBuildFunction } from './functions/waitForAscBuildProcessing';
|
||||
import { createUpdateTestFlightMetadataBuildFunction } from './functions/updateTestFlightMetadata';
|
||||
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
|
||||
import { createWaitForPosthogMetricFunction } from './functions/waitForPosthogMetric';
|
||||
@@ -144,6 +145,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createSubmissionEntityFunction(),
|
||||
createSubmitToGooglePlayBuildFunction(),
|
||||
createUploadToAscBuildFunction(),
|
||||
createWaitForAscBuildProcessingBuildFunction(),
|
||||
createUpdateTestFlightMetadataBuildFunction(),
|
||||
|
||||
createReportMaestroTestResultsFunction(ctx),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -11,6 +11,7 @@ import * as tar from 'tar';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { Datadog } from '../../../datadog';
|
||||
import * as files from '../../../utils/files';
|
||||
import { createDownloadBuildFunction, downloadBuildAsync } from '../downloadBuild';
|
||||
|
||||
@@ -30,11 +31,17 @@ const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.
|
||||
function createMockGraphqlClient({
|
||||
applicationArchiveUrl,
|
||||
error,
|
||||
transientError,
|
||||
}: {
|
||||
applicationArchiveUrl?: string | null;
|
||||
error?: Error;
|
||||
transientError?: Error;
|
||||
}): Client {
|
||||
const toPromise = jest.fn().mockResolvedValue(
|
||||
const toPromise = jest.fn();
|
||||
if (transientError) {
|
||||
toPromise.mockResolvedValueOnce({ error: transientError, data: undefined });
|
||||
}
|
||||
toPromise.mockResolvedValue(
|
||||
error
|
||||
? { error, data: undefined }
|
||||
: {
|
||||
@@ -62,7 +69,7 @@ function createSuccessfulResponse({
|
||||
url,
|
||||
headers,
|
||||
}: {
|
||||
body: Buffer;
|
||||
body: Buffer | Readable;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): Response {
|
||||
@@ -72,7 +79,7 @@ function createSuccessfulResponse({
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
body: Readable.from(body),
|
||||
body: body instanceof Readable ? body : Readable.from(body),
|
||||
url,
|
||||
headers: {
|
||||
get: (name: string): string | null => normalizedHeaders[name.toLowerCase()] ?? null,
|
||||
@@ -80,6 +87,47 @@ function createSuccessfulResponse({
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function createUndiciNetworkError(code: string): CombinedError {
|
||||
return new CombinedError({
|
||||
networkError: Object.assign(new TypeError('fetch failed'), {
|
||||
cause: Object.assign(new Error(`read ${code}`), { code }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const { FetchError: ActualFetchError } = jest.requireActual(
|
||||
'node-fetch'
|
||||
) as typeof import('node-fetch');
|
||||
|
||||
function createSocketFetchError(url: string, code: string): Error {
|
||||
return new ActualFetchError(`request to ${url} failed, reason: connect ${code}`, 'system', {
|
||||
code,
|
||||
} as any);
|
||||
}
|
||||
|
||||
function useFakeBackoffTimers(): void {
|
||||
// Streams and fs callbacks need the real nextTick and setImmediate; only the backoff timers are faked.
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
}
|
||||
|
||||
const MAX_IO_TURNS_BEFORE_BACKOFF = 1000;
|
||||
|
||||
async function waitForBackoffTimerAsync(): Promise<void> {
|
||||
for (let turns = 0; jest.getTimerCount() === 0; turns++) {
|
||||
if (turns === MAX_IO_TURNS_BEFORE_BACKOFF) {
|
||||
throw new Error('No backoff timer was scheduled.');
|
||||
}
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
async function fireBackoffTimersAsync(count: number): Promise<void> {
|
||||
for (let i = 0; i < count; i++) {
|
||||
await waitForBackoffTimerAsync();
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
}
|
||||
}
|
||||
|
||||
async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
|
||||
const sourceDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'flat-app-source-'));
|
||||
const archivePath = path.join(os.tmpdir(), `${randomUUID()}.tar.gz`);
|
||||
@@ -108,6 +156,11 @@ async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('downloadBuild', () => {
|
||||
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
|
||||
const buildId = randomUUID();
|
||||
@@ -465,13 +518,13 @@ describe('downloadBuild', () => {
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws SystemError when GraphQL request fails with a network error', async () => {
|
||||
it('throws SystemError with the cause code after retrying a failing build lookup', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
error: new CombinedError({
|
||||
networkError: new Error('Network request failed'),
|
||||
}),
|
||||
error: createUndiciNetworkError('ECONNRESET'),
|
||||
});
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
@@ -480,19 +533,81 @@ describe('downloadBuild', () => {
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await rejection;
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: `Could not fetch build ${buildId}: [Network] Network request failed`,
|
||||
message: `Could not fetch build ${buildId}: [Network] fetch failed (ECONNRESET)`,
|
||||
});
|
||||
expect(graphqlClient.query).toHaveBeenCalledTimes(6);
|
||||
expect(datadogLog).toHaveBeenCalledWith('download_build.build_lookup failed on attempt 6', {
|
||||
event: 'network_retry',
|
||||
operation: 'download_build.build_lookup',
|
||||
outcome: 'failed',
|
||||
attempts: '6',
|
||||
code: 'ECONNRESET',
|
||||
});
|
||||
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws SystemError when GraphQL request returns 5xx', async () => {
|
||||
it.each([
|
||||
['a network error', createUndiciNetworkError('ECONNRESET'), 'ECONNRESET'],
|
||||
[
|
||||
'a transient GraphQL error',
|
||||
new CombinedError({
|
||||
graphQLErrors: [
|
||||
{
|
||||
message: 'Too many requests',
|
||||
extensions: { isTransient: true, errorCode: 'TOO_MANY_REQUESTS' },
|
||||
},
|
||||
],
|
||||
response: { status: 200 },
|
||||
}),
|
||||
'TOO_MANY_REQUESTS',
|
||||
],
|
||||
])('downloads after a build lookup that failed once with %s', async (_, transientError, code) => {
|
||||
useFakeBackoffTimers();
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
error: Object.assign(new Error('Internal Server Error'), {
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
transientError,
|
||||
});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
|
||||
);
|
||||
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
await fireBackoffTimersAsync(1);
|
||||
|
||||
const { artifactPath } = await promise;
|
||||
expect(path.basename(artifactPath)).toBe('app.apk');
|
||||
expect(graphqlClient.query).toHaveBeenCalledTimes(2);
|
||||
expect(datadogLog).toHaveBeenCalledWith('download_build.build_lookup recovered on attempt 2', {
|
||||
event: 'network_retry',
|
||||
operation: 'download_build.build_lookup',
|
||||
outcome: 'recovered',
|
||||
attempts: '2',
|
||||
code,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws SystemError when GraphQL request returns 5xx', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
error: new CombinedError({
|
||||
graphQLErrors: [{ message: 'Internal Server Error' }],
|
||||
response: { status: 500 },
|
||||
}),
|
||||
});
|
||||
@@ -504,12 +619,14 @@ describe('downloadBuild', () => {
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await rejection;
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: `Could not fetch build ${buildId}: Internal Server Error`,
|
||||
message: `Could not fetch build ${buildId}: [GraphQL] Internal Server Error`,
|
||||
});
|
||||
|
||||
expect(graphqlClient.query).toHaveBeenCalledTimes(6);
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -534,10 +651,193 @@ describe('downloadBuild', () => {
|
||||
errorCode: 'EAS_DOWNLOAD_BUILD_FETCH_FAILED',
|
||||
message: `Could not fetch build ${buildId}: [GraphQL] Build not found`,
|
||||
});
|
||||
|
||||
expect(graphqlClient.query).toHaveBeenCalledTimes(1);
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('downloads after an archive request that failed once with a socket error', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValueOnce(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ECONNRESET'))
|
||||
.mockResolvedValueOnce(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
|
||||
);
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
await fireBackoffTimersAsync(1);
|
||||
|
||||
const { artifactPath } = await promise;
|
||||
expect(path.basename(artifactPath)).toBe('app.apk');
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws SystemError with the cause code after retrying a failing build archive request', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValue(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ETIMEDOUT'));
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await rejection;
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: `Could not download the application archive: request to ${APPLICATION_ARCHIVE_URL} failed, reason: connect ETIMEDOUT`,
|
||||
});
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
|
||||
expect(datadogLog).toHaveBeenCalledWith('download_build.archive_download failed on attempt 6', {
|
||||
event: 'network_retry',
|
||||
operation: 'download_build.archive_download',
|
||||
outcome: 'failed',
|
||||
attempts: '6',
|
||||
code: 'ETIMEDOUT',
|
||||
});
|
||||
});
|
||||
|
||||
function createBrokenBody(code: string): Readable {
|
||||
return Readable.from(
|
||||
(async function* () {
|
||||
yield Buffer.from('he');
|
||||
throw Object.assign(new Error(`body failed with ${code}`), { code });
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
it.each(['ERR_STREAM_PREMATURE_CLOSE', 'ECONNRESET'])(
|
||||
'downloads again when the archive body breaks off with %s after the headers arrived',
|
||||
async code => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
const brokenBody = createBrokenBody(code);
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(
|
||||
createSuccessfulResponse({ body: brokenBody, url: 'https://cdn.test/app.apk' })
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
|
||||
);
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
await fireBackoffTimersAsync(1);
|
||||
|
||||
const { artifactPath } = await promise;
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(2);
|
||||
expect(datadogLog).toHaveBeenCalledWith(
|
||||
'download_build.archive_download recovered on attempt 2',
|
||||
expect.objectContaining({ outcome: 'recovered', code })
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('throws SystemError when the build archive body keeps breaking off', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
jest.mocked(fetch).mockImplementation(async () =>
|
||||
createSuccessfulResponse({
|
||||
body: createBrokenBody('ECONNRESET'),
|
||||
url: 'https://cdn.test/app.apk',
|
||||
})
|
||||
);
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await rejection;
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: 'Could not download the application archive: body failed with ECONNRESET',
|
||||
});
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
|
||||
expect(datadogLog).toHaveBeenCalledWith(
|
||||
'download_build.archive_download failed on attempt 6',
|
||||
expect.objectContaining({ outcome: 'failed', code: 'ECONNRESET' })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not start another archive attempt after an abort during the backoff', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValueOnce(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ECONNRESET'));
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('step timed out');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
signal: controller.signal,
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBe(reason);
|
||||
await waitForBackoffTimerAsync();
|
||||
controller.abort(reason);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
await rejection;
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rethrows the raw error of a failing user-supplied archive URL after the retries', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValue(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ENOTFOUND'));
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(ActualFetchError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await rejection;
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it('should throw an error if no matching files are found', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
@@ -605,6 +905,21 @@ describe('createDownloadBuildFunction', () => {
|
||||
};
|
||||
}
|
||||
|
||||
function createApplicationResponse(buildId: string): Response {
|
||||
return new ActualResponse(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
build: {
|
||||
id: buildId,
|
||||
gitCommitHash: null,
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
function createStep(ctx: any, callInputs: Record<string, unknown>) {
|
||||
return createDownloadBuildFunction(ctx).createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({
|
||||
@@ -620,18 +935,7 @@ describe('createDownloadBuildFunction', () => {
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest.mocked(fetch).mockImplementation(async url => {
|
||||
if (url === workerApiUrl) {
|
||||
return new ActualResponse(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
build: {
|
||||
id: buildId,
|
||||
gitCommitHash: null,
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
return createApplicationResponse(buildId);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
@@ -661,6 +965,33 @@ describe('createDownloadBuildFunction', () => {
|
||||
expect(graphqlClient.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries the worker API lookup after a 5xx response', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(new ActualResponse('Service Unavailable', { status: 503 }))
|
||||
.mockResolvedValueOnce(createApplicationResponse(buildId))
|
||||
.mockResolvedValueOnce(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
|
||||
);
|
||||
|
||||
const execution = createStep(createSessionCtx(graphqlClient), {
|
||||
build_id: buildId,
|
||||
extensions: ['apk'],
|
||||
}).executeAsync();
|
||||
await fireBackoffTimersAsync(1);
|
||||
|
||||
await execution;
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(3);
|
||||
expect(jest.mocked(fetch).mock.calls.map(([url]) => url)).toEqual([
|
||||
workerApiUrl,
|
||||
workerApiUrl,
|
||||
APPLICATION_ARCHIVE_URL,
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not call the worker API for an application_archive_url input', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -687,6 +687,18 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['NUL argument', { launch_args: ['a\u0000b'] }],
|
||||
['command length', { launch_args: ['--es', 'quotes', "'".repeat(20_000)] }],
|
||||
['NUL URL', { open_url: 'example:\u0000screen' }],
|
||||
])('rejects Android launch %s before startup work', async (_name, inputs) => {
|
||||
const step = runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', ...inputs });
|
||||
await expect(step).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
|
||||
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps Android launch inputs on its existing direct path', async () => {
|
||||
const launchArgs = ['a'.repeat(9000)];
|
||||
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', launch_args: launchArgs });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
|
||||
import * as sandboxDaemon from '../../utils/sandboxDaemon';
|
||||
@@ -6,6 +6,90 @@ import * as sandboxDaemon from '../../utils/sandboxDaemon';
|
||||
import { createStartSandboxBuildFunction, markSandboxReadyAsync } from '../startSandbox';
|
||||
|
||||
describe('sandbox build functions', () => {
|
||||
it.each([
|
||||
{ completion: 'duration', duration: 0.02 },
|
||||
{ completion: 'cancellation', duration: 60 },
|
||||
{ completion: 'duration', duration: 0.0001 },
|
||||
])(
|
||||
'stops after $completion with $duration seconds only after readiness',
|
||||
async ({ completion, duration }) => {
|
||||
const controller = new AbortController();
|
||||
let ready!: () => void;
|
||||
const stopAsync = jest.fn(async () => {});
|
||||
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockResolvedValue({
|
||||
ready: new Promise<void>(resolve => {
|
||||
ready = resolve;
|
||||
}),
|
||||
stopAsync,
|
||||
});
|
||||
const mutation = jest.fn(() => ({ toPromise: async () => ({ data: {} }) }));
|
||||
const logger = { info: jest.fn(), child: jest.fn() };
|
||||
logger.child.mockReturnValue(logger);
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
graphqlClient: { mutation },
|
||||
} as any);
|
||||
try {
|
||||
const pending = fn.fn!({ logger } as any, {
|
||||
inputs: {
|
||||
sandbox_id: { value: 'sandbox-id' },
|
||||
max_duration_seconds: { value: duration },
|
||||
},
|
||||
outputs: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 40));
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
expect(stopAsync).not.toHaveBeenCalled();
|
||||
ready();
|
||||
if (completion === 'cancellation') {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(stopAsync).not.toHaveBeenCalled();
|
||||
controller.abort();
|
||||
}
|
||||
await pending;
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
const timeoutMessage = `Sandbox reached its maximum runtime of ${duration} seconds. Stopping.`;
|
||||
if (completion === 'duration') {
|
||||
expect(logger.info).toHaveBeenCalledWith(timeoutMessage);
|
||||
} else {
|
||||
expect(logger.info).not.toHaveBeenCalledWith(timeoutMessage);
|
||||
}
|
||||
} finally {
|
||||
start.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.each([0, -1, Infinity, NaN])(
|
||||
'rejects invalid maximum duration %s before starting the daemon',
|
||||
async duration => {
|
||||
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync');
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
} as any);
|
||||
try {
|
||||
const result = fn.fn!({} as any, {
|
||||
inputs: {
|
||||
sandbox_id: { value: 'sandbox-id' },
|
||||
max_duration_seconds: { value: duration },
|
||||
},
|
||||
outputs: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
});
|
||||
await expect(result).rejects.toBeInstanceOf(UserError);
|
||||
await expect(result).rejects.toThrow(
|
||||
`Invalid sandbox maximum duration: ${duration}. Provide a finite number greater than zero, or omit max_duration_seconds.`
|
||||
);
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
start.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('aborts an active ready request and keeps its authentication headers', async () => {
|
||||
const controller = new AbortController();
|
||||
let requestStarted!: () => void;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import {
|
||||
isClosedVersionTrainError,
|
||||
isInvalidBundleIdentifierError,
|
||||
isMissingPurposeStringError,
|
||||
isSdkVersionIssueError,
|
||||
parseMissingUsageDescriptionKeys,
|
||||
} from '../uploadToAsc';
|
||||
|
||||
describe(isClosedVersionTrainError, () => {
|
||||
it('returns true when all errors are closed-version-train codes', () => {
|
||||
expect(
|
||||
isClosedVersionTrainError([
|
||||
{ code: '90062' },
|
||||
{ code: '90186' },
|
||||
{ code: '90478' },
|
||||
{ code: '90062' },
|
||||
])
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when any other error code is present', () => {
|
||||
expect(
|
||||
isClosedVersionTrainError([{ code: '90062' }, { code: '90186' }, { code: '12345' }])
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when there are no errors', () => {
|
||||
expect(isClosedVersionTrainError([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isInvalidBundleIdentifierError, () => {
|
||||
it('returns true when all errors are invalid-bundle-id codes', () => {
|
||||
expect(
|
||||
isInvalidBundleIdentifierError([{ code: '90054' }, { code: '90055' }, { code: '90055' }])
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when any other error code is present', () => {
|
||||
expect(isInvalidBundleIdentifierError([{ code: '90055' }, { code: '90713' }])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when there are no errors', () => {
|
||||
expect(isInvalidBundleIdentifierError([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isMissingPurposeStringError, () => {
|
||||
it('returns true when all errors are missing-purpose-string codes', () => {
|
||||
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90683' }])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when any other error code is present', () => {
|
||||
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90054' }])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when there are no errors', () => {
|
||||
expect(isMissingPurposeStringError([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isSdkVersionIssueError, () => {
|
||||
it('returns true when all errors are SDK-version-issue codes', () => {
|
||||
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90725' }])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when any other error code is present', () => {
|
||||
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90062' }])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when there are no errors', () => {
|
||||
expect(isSdkVersionIssueError([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseMissingUsageDescriptionKeys, () => {
|
||||
it('extracts missing UsageDescription keys from ASC messages', () => {
|
||||
expect(
|
||||
parseMissingUsageDescriptionKeys([
|
||||
{
|
||||
description:
|
||||
'The Info.plist file should contain a NSPhotoLibraryUsageDescription key with a user-facing purpose string. (90683)',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
|
||||
},
|
||||
])
|
||||
).toEqual(['NSPhotoLibraryUsageDescription', 'NSCameraUsageDescription']);
|
||||
});
|
||||
|
||||
it('deduplicates repeated keys', () => {
|
||||
expect(
|
||||
parseMissingUsageDescriptionKeys([
|
||||
{
|
||||
description:
|
||||
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
|
||||
},
|
||||
])
|
||||
).toEqual(['NSCameraUsageDescription']);
|
||||
});
|
||||
|
||||
it('returns an empty array when no usage description key is present', () => {
|
||||
expect(
|
||||
parseMissingUsageDescriptionKeys([{ description: 'Some other upload validation error.' }])
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import fs from 'fs-extra';
|
||||
import nock from 'nock';
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
|
||||
import {
|
||||
createCustomBuildContextMock,
|
||||
createGlobalContextMock,
|
||||
} from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { getEasFunctions } from '../../easFunctions';
|
||||
import { readIpaInfoAsync } from '../readIpaInfo';
|
||||
import { createUploadToAscBuildFunction } from '../uploadToAsc';
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
jest.mock('node:timers/promises', () => ({ setTimeout: jest.fn().mockResolvedValue(undefined) }));
|
||||
jest.mock('../readIpaInfo', () => ({
|
||||
...jest.requireActual('../readIpaInfo'),
|
||||
readIpaInfoAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||||
const api = () => nock('https://api.appstoreconnect.apple.com');
|
||||
const logger = createMockLogger();
|
||||
const globalContext = () => createGlobalContextMock({ projectTargetDirectory: '/project', logger });
|
||||
|
||||
async function waitAsync(diagnostics: Record<string, string> = {}): Promise<void> {
|
||||
const fn = getEasFunctions(createCustomBuildContextMock()).find(
|
||||
fn => fn.getFullId() === 'eas/wait_for_asc_build_processing'
|
||||
)!;
|
||||
await fn
|
||||
.createBuildStepFromFunctionCall(globalContext(), {
|
||||
callInputs: { asc_api_key_path: 'key.json', build_upload_id: 'upload', ...diagnostics },
|
||||
})
|
||||
.executeAsync();
|
||||
}
|
||||
|
||||
async function uploadAsync(waitForProcessing?: boolean): Promise<void> {
|
||||
await fs.outputFile('/project/app.ipa', 'ipa');
|
||||
jest.mocked(readIpaInfoAsync).mockResolvedValue({
|
||||
bundleIdentifier: 'com.test.ipa',
|
||||
bundleVersion: '1',
|
||||
bundleShortVersion: '1.0',
|
||||
dtPlatformName: 'iphoneos',
|
||||
});
|
||||
mockUpload();
|
||||
const step = createUploadToAscBuildFunction().createBuildStepFromFunctionCall(globalContext(), {
|
||||
callInputs: {
|
||||
ipa_path: 'app.ipa',
|
||||
asc_api_key_path: 'key.json',
|
||||
apple_app_identifier: 'app',
|
||||
bundle_version: '1',
|
||||
bundle_short_version: '1.0',
|
||||
...(waitForProcessing === undefined ? {} : { wait_for_processing: waitForProcessing }),
|
||||
},
|
||||
});
|
||||
await step.executeAsync();
|
||||
}
|
||||
|
||||
function mockProcessing(state: string, errors: { code: string; description: string }[] = []): void {
|
||||
api()
|
||||
.get('/v1/buildUploads/upload')
|
||||
.query(true)
|
||||
.reply(200, {
|
||||
data: { type: 'buildUploads', id: 'upload', attributes: { state: { state, errors } } },
|
||||
});
|
||||
}
|
||||
|
||||
function mockApp(): void {
|
||||
api()
|
||||
.get('/v1/apps/app')
|
||||
.query(true)
|
||||
.reply(200, {
|
||||
data: { type: 'apps', id: 'app', attributes: { name: 'Test app', bundleId: 'com.test.app' } },
|
||||
});
|
||||
}
|
||||
|
||||
function mockUpload(): void {
|
||||
mockApp();
|
||||
api()
|
||||
.post('/v1/buildUploads')
|
||||
.reply(201, { data: { type: 'buildUploads', id: 'upload' } });
|
||||
api()
|
||||
.post('/v1/buildUploadFiles')
|
||||
.reply(201, {
|
||||
data: {
|
||||
type: 'buildUploadFiles',
|
||||
id: 'file',
|
||||
attributes: {
|
||||
uploadOperations: [
|
||||
{
|
||||
length: 3,
|
||||
offset: 0,
|
||||
partNumber: 1,
|
||||
method: 'PUT',
|
||||
requestHeaders: [],
|
||||
url: 'https://upload.example.com/ipa',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
nock('https://upload.example.com').put('/ipa', 'ipa').reply(200);
|
||||
api()
|
||||
.get('/v1/buildUploadFiles/file')
|
||||
.query(true)
|
||||
.twice()
|
||||
.reply(200, {
|
||||
data: {
|
||||
type: 'buildUploadFiles',
|
||||
id: 'file',
|
||||
attributes: { assetDeliveryState: { state: 'COMPLETE' } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(() => nock.disableNetConnect());
|
||||
afterAll(() => nock.enableNetConnect());
|
||||
beforeEach(async () => {
|
||||
await fs.outputJson('/project/key.json', {
|
||||
key_id: 'TESTKEY',
|
||||
key: privateKey.export({ type: 'pkcs8', format: 'pem' }),
|
||||
});
|
||||
jest.mocked(setTimeout).mockResolvedValue(undefined);
|
||||
});
|
||||
afterEach(() => {
|
||||
try {
|
||||
expect(nock.pendingMocks()).toEqual([]);
|
||||
} finally {
|
||||
nock.cleanAll();
|
||||
jest.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it('polls an existing upload through awaiting and processing states until complete', async () => {
|
||||
mockProcessing('AWAITING_UPLOAD');
|
||||
mockProcessing('PROCESSING');
|
||||
mockProcessing('COMPLETE');
|
||||
await expect(waitAsync()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses the supplied bundle identifier in mismatch errors without an IPA', async () => {
|
||||
mockProcessing(
|
||||
'FAILED',
|
||||
['90054', '90055'].map(code => ({ code, description: 'Bundle mismatch' }))
|
||||
);
|
||||
mockApp();
|
||||
api().get('/v1/apps').query(true).reply(200, { data: [] });
|
||||
await expect(
|
||||
waitAsync({ bundle_identifier: 'com.test.ipa', apple_app_identifier: 'app' })
|
||||
).rejects.toMatchObject({
|
||||
errorCode: 'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
|
||||
message: expect.stringContaining(
|
||||
'IPA bundle identifier: com.test.ipa\nApp Store Connect app bundle identifier: com.test.app'
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it.each<[string, string[], string]>([
|
||||
['SDK version', ['90725'], 'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE'],
|
||||
['closed version train', ['90062', '90186', '90478'], 'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN'],
|
||||
])('reports a %s processing error', async (_label, codes, errorCode) => {
|
||||
mockProcessing(
|
||||
'FAILED',
|
||||
codes.map(code => ({ code, description: 'Rejected' }))
|
||||
);
|
||||
await expect(waitAsync()).rejects.toMatchObject({ errorCode });
|
||||
});
|
||||
|
||||
it.each(
|
||||
[
|
||||
[],
|
||||
['OTHER'],
|
||||
['90725', 'OTHER'],
|
||||
['90062', 'OTHER'],
|
||||
['90054', 'OTHER'],
|
||||
['90683', 'OTHER'],
|
||||
].map(codes => ({ codes }))
|
||||
)('keeps unclassified or mixed processing errors generic: $codes', async ({ codes }) => {
|
||||
mockProcessing(
|
||||
'FAILED',
|
||||
codes.map(code => ({ code, description: 'Rejected' }))
|
||||
);
|
||||
await expect(waitAsync()).rejects.toThrow('Build upload (ID: upload) failed.');
|
||||
});
|
||||
|
||||
it('reports missing privacy keys once each', async () => {
|
||||
mockProcessing('FAILED', [
|
||||
{
|
||||
code: '90683',
|
||||
description: 'Missing NSCameraUsageDescription and NSPhotoLibraryUsageDescription.',
|
||||
},
|
||||
{ code: '90683', description: 'Missing NSCameraUsageDescription.' },
|
||||
]);
|
||||
await expect(waitAsync()).rejects.toMatchObject({
|
||||
errorCode: 'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
|
||||
message: expect.stringContaining(
|
||||
'Missing keys reported by App Store Connect:\n- NSCameraUsageDescription\n- NSPhotoLibraryUsageDescription\n\n'
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it('reports missing privacy purpose strings when Apple does not name a key', async () => {
|
||||
mockProcessing('FAILED', [{ code: '90683', description: 'Missing purpose string.' }]);
|
||||
await expect(waitAsync()).rejects.toMatchObject({
|
||||
errorCode: 'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
|
||||
message: expect.not.stringContaining('Missing keys reported by App Store Connect:'),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([undefined, false, true])(
|
||||
'keeps upload wait_for_processing=%s compatible',
|
||||
async waitForProcessing => {
|
||||
if (waitForProcessing) {
|
||||
mockProcessing('PROCESSING');
|
||||
mockProcessing('FAILED', [{ code: '90725', description: 'SDK is too old' }]);
|
||||
}
|
||||
if (waitForProcessing) {
|
||||
await expect(uploadAsync(waitForProcessing)).rejects.toMatchObject({
|
||||
errorCode: 'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE',
|
||||
});
|
||||
} else {
|
||||
await expect(uploadAsync(waitForProcessing)).resolves.toBeUndefined();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['upload', 'wait'])(
|
||||
'preserves the successful timeout return for the %s step',
|
||||
async step => {
|
||||
mockProcessing('PROCESSING');
|
||||
const start = Date.now();
|
||||
jest.spyOn(Date, 'now').mockReturnValue(start);
|
||||
jest.mocked(setTimeout).mockImplementationOnce(async () => {
|
||||
jest.spyOn(Date, 'now').mockReturnValue(start + 30 * 60 * 1000);
|
||||
});
|
||||
await expect(step === 'upload' ? uploadAsync(true) : waitAsync()).resolves.toBeUndefined();
|
||||
}
|
||||
);
|
||||
@@ -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})`
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import { Client } from '@urql/core';
|
||||
import { Client, CombinedError } from '@urql/core';
|
||||
import contentDisposition from 'content-disposition';
|
||||
import { glob } from 'fast-glob';
|
||||
import { graphql } from 'gql.tada';
|
||||
@@ -26,10 +26,16 @@ import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
|
||||
import {
|
||||
describeError,
|
||||
isTransferFailure,
|
||||
isTransientGraphqlError,
|
||||
} from '../../utils/networkErrors';
|
||||
import { retryOnNetworkFailureAsync } from '../../utils/retryOnNetworkFailure';
|
||||
import { pluralize } from '../../utils/strings';
|
||||
import {
|
||||
type DeviceRunSessionWorkerApi,
|
||||
DeviceRunSessionWorkerApiError,
|
||||
getDeviceRunSessionWorkerApi,
|
||||
} from '../utils/deviceRunSessionWorkerApi';
|
||||
|
||||
@@ -152,21 +158,34 @@ async function fetchApplicationArchiveUrlAsync({
|
||||
signal,
|
||||
});
|
||||
}
|
||||
const result = await graphqlClient
|
||||
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
let data;
|
||||
try {
|
||||
data = await retryOnNetworkFailureAsync(
|
||||
async () => {
|
||||
const queryResult = await graphqlClient
|
||||
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
if (queryResult.error) {
|
||||
throw queryResult.error;
|
||||
}
|
||||
return queryResult.data;
|
||||
},
|
||||
{
|
||||
operation: 'download_build.build_lookup',
|
||||
signal,
|
||||
isRetryable: error => error instanceof CombinedError && isTransientGraphqlError(error),
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
const message = `Could not fetch build ${buildId}: ${describeError(error)}`;
|
||||
throw error instanceof CombinedError && !isTransientGraphqlError(error)
|
||||
? new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error })
|
||||
: new SystemError(message, { cause: error });
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
|
||||
if (result.error) {
|
||||
const { error } = result;
|
||||
const message = `Could not fetch build ${buildId}: ${error.message}`;
|
||||
|
||||
throw error.networkError || result.error.response?.status >= 500
|
||||
? new SystemError(message, { cause: error })
|
||||
: new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error });
|
||||
}
|
||||
|
||||
const applicationArchiveUrl = result.data?.builds.byId?.artifacts?.applicationArchiveUrl;
|
||||
const applicationArchiveUrl = data?.builds.byId?.artifacts?.applicationArchiveUrl;
|
||||
if (!applicationArchiveUrl) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
|
||||
@@ -195,13 +214,18 @@ async function fetchDeviceRunSessionApplicationArchiveUrlAsync({
|
||||
}): Promise<string> {
|
||||
let application;
|
||||
try {
|
||||
application = await deviceRunSessionWorkerApi.getApplicationAsync({ signal });
|
||||
application = await retryOnNetworkFailureAsync(
|
||||
() => deviceRunSessionWorkerApi.getApplicationAsync({ signal }),
|
||||
{
|
||||
operation: 'download_build.session_build_lookup',
|
||||
signal,
|
||||
isRetryable: error => error instanceof DeviceRunSessionWorkerApiError && error.isTransient,
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
signal?.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Could not fetch build ${buildId} for this device run session: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
`Could not fetch build ${buildId} for this device run session: ${describeError(err)}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
@@ -281,18 +305,34 @@ export async function downloadBuildAsync(
|
||||
);
|
||||
|
||||
signal?.throwIfAborted();
|
||||
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers, signal });
|
||||
|
||||
if (!response.ok) {
|
||||
const textResult = await asyncResult(response.text());
|
||||
throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`);
|
||||
let archivePath: string;
|
||||
try {
|
||||
archivePath = await retryOnNetworkFailureAsync(
|
||||
async () => {
|
||||
const response = await fetch(downloadUrl, { headers, signal });
|
||||
if (!response.ok) {
|
||||
const textResult = await asyncResult(response.text());
|
||||
throw new Error(
|
||||
`Unexpected response from server (${response.status}): ${textResult.value}`
|
||||
);
|
||||
}
|
||||
const archiveFilename = resolveArchiveFilename({ response, extensions });
|
||||
const attemptArchivePath = path.join(downloadDestinationDirectory, archiveFilename);
|
||||
// On abort, node-fetch emits an error on the body stream, which fails the pipeline.
|
||||
await streamPipeline(response.body, fs.createWriteStream(attemptArchivePath));
|
||||
return attemptArchivePath;
|
||||
},
|
||||
{ operation: 'download_build.archive_download', signal, isRetryable: isTransferFailure }
|
||||
);
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
if (params.buildId && isTransferFailure(error)) {
|
||||
throw new SystemError(`Could not download the application archive: ${describeError(error)}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const archiveFilename = resolveArchiveFilename({ response, extensions });
|
||||
const archivePath = path.join(downloadDestinationDirectory, archiveFilename);
|
||||
|
||||
// On abort, node-fetch emits an error on the body stream, which fails the pipeline.
|
||||
await streamPipeline(response.body, fs.createWriteStream(archivePath));
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const { size } = await fs.promises.stat(archivePath);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { UserError, quoteAndroidShellCommand } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
@@ -11,8 +11,6 @@ import spawn from '@expo/turtle-spawn';
|
||||
|
||||
const IOS_URL_SCHEME_APPROVAL_DOMAIN = 'com.apple.launchservices.schemeapproval';
|
||||
const IOS_URL_SCHEME_APPROVAL_KEY_PREFIX = 'com.apple.CoreSimulator.CoreSimulatorBridge-->';
|
||||
// adb closes the connection for longer shell commands (about 65.5 KB with adb 37 on API 35).
|
||||
const ANDROID_SHELL_COMMAND_MAX_BYTES = 65_000;
|
||||
|
||||
export function createLaunchApplicationFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -135,33 +133,11 @@ export async function launchApplicationAsync({
|
||||
}
|
||||
}
|
||||
|
||||
// `adb shell` joins its arguments with spaces and the device shell parses the result again, so
|
||||
// each argument is quoted to reach the command as one literal word.
|
||||
async function spawnAndroidShellCommandAsync(
|
||||
command: string[],
|
||||
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
|
||||
): Promise<void> {
|
||||
if (command.some(argument => argument.includes('\0'))) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'Android "launch_args" and "open_url" cannot contain NUL characters.'
|
||||
);
|
||||
}
|
||||
const quotedCommand = command.map(quoteAndroidShellArgument);
|
||||
if (Buffer.byteLength(quotedCommand.join(' ')) > ANDROID_SHELL_COMMAND_MAX_BYTES) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
`The Android launch command is longer than ${ANDROID_SHELL_COMMAND_MAX_BYTES} bytes after shell quoting. Shorten "launch_args" or "open_url".`
|
||||
);
|
||||
}
|
||||
await spawn('adb', ['shell', ...quotedCommand], { env, logger });
|
||||
}
|
||||
|
||||
function quoteAndroidShellArgument(argument: string): string {
|
||||
if (/^[\w@%+=:,./-]+$/.test(argument)) {
|
||||
return argument;
|
||||
}
|
||||
return `'${argument.replaceAll("'", `'\\''`)}'`;
|
||||
await spawn('adb', ['shell', ...quoteAndroidShellCommand(command)], { env, logger });
|
||||
}
|
||||
|
||||
async function preapproveIosUrlSchemeAsync({
|
||||
|
||||
@@ -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)}).`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { UserError, validateAndroidLaunchOptions } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -164,6 +164,8 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
}
|
||||
if (isIos) {
|
||||
validateServeSimLaunchOptions({ launchArgs, openUrl });
|
||||
} else {
|
||||
validateAndroidLaunchOptions({ launchArgs, openUrl });
|
||||
}
|
||||
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
|
||||
const capture = parseNetworkCaptureInputs(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import { graphql } from 'gql.tada';
|
||||
|
||||
@@ -32,6 +32,11 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_duration_seconds',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, signal, env }) => {
|
||||
// The daemon needs this credential, but shell commands do not.
|
||||
@@ -44,6 +49,18 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
throw new SystemError('MCP server URL is required to start the sandbox daemon.');
|
||||
}
|
||||
const sandboxId = String(inputs.sandbox_id.value);
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value;
|
||||
if (
|
||||
maxDurationSeconds !== undefined &&
|
||||
(typeof maxDurationSeconds !== 'number' ||
|
||||
!Number.isFinite(maxDurationSeconds) ||
|
||||
maxDurationSeconds <= 0)
|
||||
) {
|
||||
throw new UserError(
|
||||
'EAS_SANDBOX_INVALID_DURATION',
|
||||
`Invalid sandbox maximum duration: ${String(maxDurationSeconds)}. Provide a finite number greater than zero, or omit max_duration_seconds.`
|
||||
);
|
||||
}
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: sandboxToken,
|
||||
serverUrl: mcpServerUrl,
|
||||
@@ -63,7 +80,21 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
stepCtx.logger.info('Sandbox daemon started.');
|
||||
|
||||
await withLogPhaseAsync(stepCtx.logger, AWAIT_SANDBOX_STEP_NAME, async () => {
|
||||
await waitForCancellationAsync(signal);
|
||||
const timeoutSignal =
|
||||
maxDurationSeconds === undefined
|
||||
? undefined
|
||||
: AbortSignal.timeout(Math.ceil(maxDurationSeconds * 1000));
|
||||
await waitForCompletionAsync(
|
||||
AbortSignal.any([
|
||||
...(signal ? [signal] : []),
|
||||
...(timeoutSignal ? [timeoutSignal] : []),
|
||||
])
|
||||
);
|
||||
if (timeoutSignal?.aborted) {
|
||||
stepCtx.logger.info(
|
||||
`Sandbox reached its maximum runtime of ${maxDurationSeconds} seconds. Stopping.`
|
||||
);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await daemon.stopAsync();
|
||||
@@ -89,11 +120,11 @@ export async function markSandboxReadyAsync(
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCancellationAsync(signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) {
|
||||
async function waitForCompletionAsync(signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>(resolve => {
|
||||
signal?.addEventListener('abort', () => resolve(), { once: true });
|
||||
signal.addEventListener('abort', () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export async function updateTestFlightMetadataAsync({
|
||||
if (upload.attributes?.state?.state !== 'COMPLETE' || !buildId) {
|
||||
throw new UserError(
|
||||
'EAS_TESTFLIGHT_BUILD_NOT_READY',
|
||||
'The uploaded build is not ready for TestFlight metadata. Run eas/upload_to_asc with wait_for_processing: true first.'
|
||||
'The uploaded build is not ready for TestFlight metadata. Run eas/wait_for_asc_build_processing first, or use eas/upload_to_asc with wait_for_processing: true.'
|
||||
);
|
||||
}
|
||||
logger.info(`Updating TestFlight metadata for Apple build ${buildId}...`);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
BuildFunction,
|
||||
@@ -15,6 +14,7 @@ import { z } from 'zod';
|
||||
import { AscApiClient, AscApiClientPostApi } from '../utils/ios/AscApiClient';
|
||||
import { AscApiUtils } from '../utils/ios/AscApiUtils';
|
||||
import { readIpaInfoAsync } from './readIpaInfo';
|
||||
import { waitForAscBuildProcessingAsync } from './waitForAscBuildProcessing';
|
||||
|
||||
export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -184,10 +184,10 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
|
||||
const { errors = [], warnings = [] } = assetDeliveryState;
|
||||
if (warnings.length > 0) {
|
||||
stepsCtx.logger.warn(`Warnings:\n${itemizeMessages(warnings)}\n`);
|
||||
stepsCtx.logger.warn(`Warnings:\n${AscApiUtils.itemizeMessages(warnings)}\n`);
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
stepsCtx.logger.error(`Errors:\n${itemizeMessages(errors)}\n`);
|
||||
stepsCtx.logger.error(`Errors:\n${AscApiUtils.itemizeMessages(errors)}\n`);
|
||||
}
|
||||
|
||||
if (assetDeliveryState.state === 'FAILED') {
|
||||
@@ -208,165 +208,19 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
return;
|
||||
}
|
||||
|
||||
stepsCtx.logger.info('Checking build upload status...');
|
||||
const waitingForBuildStartedAt = Date.now();
|
||||
const waitingLogIntervalMs = 10 * 1000;
|
||||
let lastWaitLogTime = 0;
|
||||
let lastWaitLogState: string | null = null;
|
||||
while (Date.now() - waitingForBuildStartedAt < 30 * 60 * 1000 /* 30 minutes */) {
|
||||
const {
|
||||
data: {
|
||||
attributes: { state },
|
||||
},
|
||||
} = await client.getAsync(
|
||||
`/v1/buildUploads/:id`,
|
||||
{ 'fields[buildUploads]': ['state', 'build'], include: ['build'] },
|
||||
{ id: buildUploadId }
|
||||
);
|
||||
|
||||
if (state.state === 'AWAITING_UPLOAD' || state.state === 'PROCESSING') {
|
||||
const now = Date.now();
|
||||
if (lastWaitLogState !== state.state || now - lastWaitLogTime >= waitingLogIntervalMs) {
|
||||
stepsCtx.logger.info(
|
||||
`Waiting for build upload to complete... (status = ${state.state})`
|
||||
);
|
||||
lastWaitLogTime = now;
|
||||
lastWaitLogState = state.state;
|
||||
}
|
||||
await setTimeout(2000);
|
||||
continue;
|
||||
}
|
||||
|
||||
stepsCtx.logger.info('\n');
|
||||
|
||||
const { errors = [], warnings = [], infos = [] } = state;
|
||||
if (infos.length > 0) {
|
||||
stepsCtx.logger.info(`Infos:\n${itemizeMessages(infos)}\n`);
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
stepsCtx.logger.warn(`Warnings:\n${itemizeMessages(warnings)}\n`);
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
stepsCtx.logger.error(`Errors:\n${itemizeMessages(errors)}\n`);
|
||||
}
|
||||
|
||||
if (state.state === 'FAILED') {
|
||||
if (isSdkVersionIssueError(errors)) {
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE',
|
||||
'Build upload was rejected by App Store Connect because the IPA was built with an iOS SDK that is too old for current App Store Connect requirements. ' +
|
||||
'In an Expo project, this usually means the build used an outdated EAS iOS image or the project still depends on an Expo SDK / native setup that does not support the required Xcode toolchain. ' +
|
||||
'Upgrade to a supported Expo SDK if needed and/or select a newer iOS image in `eas.json`, rebuild, and submit again.',
|
||||
{
|
||||
docsUrl: 'https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/',
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isInvalidBundleIdentifierError(errors)) {
|
||||
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
|
||||
const ipaBundleIdentifier = ipaInfoResult.ok
|
||||
? ipaInfoResult.value.bundleIdentifier
|
||||
: null;
|
||||
|
||||
let visibleAppsSummary: string | null = null;
|
||||
try {
|
||||
const apps = await AscApiUtils.getAppsAsync({ client, limit: 5 });
|
||||
visibleAppsSummary = AscApiUtils.formatAppsList(apps);
|
||||
} catch {
|
||||
// Ok to fail, this is just trying to be helpful.
|
||||
}
|
||||
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
|
||||
`Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` +
|
||||
`IPA bundle identifier: ${ipaBundleIdentifier ?? '(unavailable)'}\n` +
|
||||
`App Store Connect app bundle identifier: ${ascAppBundleIdentifier}\n\n` +
|
||||
'Bundle identifier cannot be changed for an existing App Store Connect app. ' +
|
||||
'If you selected the wrong app, change the Apple app identifier in the submit profile. ' +
|
||||
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).' +
|
||||
'\n\nOther App Store Connect apps visible to this API key:\n' +
|
||||
(visibleAppsSummary ?? ' (unable to retrieve)'),
|
||||
{
|
||||
docsUrl: 'https://expo.fyi/asc-app-id',
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isMissingPurposeStringError(errors)) {
|
||||
const missingUsageDescriptionKeys = parseMissingUsageDescriptionKeys(errors);
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
|
||||
`Build upload was rejected by App Store Connect because Info.plist is missing one or more privacy purpose strings.\n\n` +
|
||||
`${
|
||||
missingUsageDescriptionKeys.length > 0
|
||||
? `Missing keys reported by App Store Connect:\n- ${missingUsageDescriptionKeys.join(
|
||||
'\n- '
|
||||
)}\n\n`
|
||||
: ''
|
||||
}` +
|
||||
'Add the missing keys with clear user-facing explanations, then rebuild and submit again.\n' +
|
||||
'If you use Continuous Native Generation (CNG), update `ios.infoPlist` in app.json/app.config.js.\n' +
|
||||
'If you do not use CNG, update your app target Info.plist directly.',
|
||||
{
|
||||
docsUrl: 'https://docs.expo.dev/guides/permissions/#ios',
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isClosedVersionTrainError(errors)) {
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN',
|
||||
`Build upload was rejected by App Store Connect because the ${bundleShortVersion} app version is not accepted for new build submissions. ` +
|
||||
'This usually means the version train is closed or lower than a previously approved version. ' +
|
||||
'Bump the iOS app version (CFBundleShortVersionString, e.g. expo.version) to a higher version, then rebuild and submit again.'
|
||||
);
|
||||
}
|
||||
throw new Error(`Build upload (ID: ${buildUploadId}) failed.`);
|
||||
} else if (state.state === 'COMPLETE') {
|
||||
stepsCtx.logger.info(`Build upload (ID: ${buildUploadId}) complete!`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
await waitForAscBuildProcessingAsync({
|
||||
client,
|
||||
buildUploadId,
|
||||
logger: stepsCtx.logger,
|
||||
ipaBundleIdentifier: ipaInfoResult.ok ? ipaInfoResult.value.bundleIdentifier : undefined,
|
||||
appleAppIdentifier: undefined,
|
||||
ascAppBundleIdentifier,
|
||||
bundleShortVersion,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function itemizeMessages(messages: { description: string; code: string }[]): string {
|
||||
return `- ${messages.map(m => `${m.description} (${m.code})`).join('\n- ')}`;
|
||||
}
|
||||
|
||||
export function isClosedVersionTrainError(messages: { code: string }[]): boolean {
|
||||
return (
|
||||
messages.length > 0 &&
|
||||
messages.every(message => ['90062', '90186', '90478'].includes(message.code))
|
||||
);
|
||||
}
|
||||
|
||||
export function isSdkVersionIssueError(messages: { code: string }[]): boolean {
|
||||
return messages.length > 0 && messages.every(message => message.code === '90725');
|
||||
}
|
||||
|
||||
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
|
||||
return (
|
||||
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
|
||||
);
|
||||
}
|
||||
|
||||
export function isMissingPurposeStringError(messages: { code: string }[]): boolean {
|
||||
return messages.length > 0 && messages.every(message => message.code === '90683');
|
||||
}
|
||||
|
||||
export function parseMissingUsageDescriptionKeys(messages: { description: string }[]): string[] {
|
||||
const usageDescriptionKeyRegex = /\b(\w+UsageDescription)\b/g;
|
||||
const keys = new Set<string>();
|
||||
for (const message of messages) {
|
||||
const matches = message.description.matchAll(usageDescriptionKeyRegex);
|
||||
for (const match of matches) {
|
||||
keys.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
async function uploadChunksAsync({
|
||||
uploadOperations,
|
||||
ipaPath,
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import path from 'node:path';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AscApiClient } from '../utils/ios/AscApiClient';
|
||||
import { AscApiUtils } from '../utils/ios/AscApiUtils';
|
||||
|
||||
export function createWaitForAscBuildProcessingBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'wait_for_asc_build_processing',
|
||||
name: 'Wait for App Store Connect processing',
|
||||
__metricsId: 'eas/wait_for_asc_build_processing',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'asc_api_key_path',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'build_upload_id',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'bundle_identifier',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'apple_app_identifier',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'bundle_short_version',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
fn: async (ctx, { inputs }) => {
|
||||
const parsedInputs = z
|
||||
.object({
|
||||
asc_api_key_path: z.string(),
|
||||
build_upload_id: z.string(),
|
||||
bundle_identifier: z.string().optional(),
|
||||
apple_app_identifier: z.string().optional(),
|
||||
bundle_short_version: z.string().optional(),
|
||||
})
|
||||
.parse({
|
||||
asc_api_key_path: inputs.asc_api_key_path.value,
|
||||
build_upload_id: inputs.build_upload_id.value,
|
||||
bundle_identifier: inputs.bundle_identifier.value,
|
||||
apple_app_identifier: inputs.apple_app_identifier.value,
|
||||
bundle_short_version: inputs.bundle_short_version.value,
|
||||
});
|
||||
const keyPath = path.resolve(ctx.workingDirectory, parsedInputs.asc_api_key_path);
|
||||
const key = await AscApiUtils.loadApiKeyAsync({ keyPath });
|
||||
const client = new AscApiClient({ key, logger: ctx.logger });
|
||||
await waitForAscBuildProcessingAsync({
|
||||
client,
|
||||
buildUploadId: parsedInputs.build_upload_id,
|
||||
logger: ctx.logger,
|
||||
ipaBundleIdentifier: parsedInputs.bundle_identifier,
|
||||
appleAppIdentifier: parsedInputs.apple_app_identifier,
|
||||
ascAppBundleIdentifier: undefined,
|
||||
bundleShortVersion: parsedInputs.bundle_short_version,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForAscBuildProcessingAsync({
|
||||
client,
|
||||
buildUploadId,
|
||||
logger,
|
||||
ipaBundleIdentifier,
|
||||
appleAppIdentifier,
|
||||
ascAppBundleIdentifier,
|
||||
bundleShortVersion,
|
||||
}: {
|
||||
client: AscApiClient;
|
||||
buildUploadId: string;
|
||||
logger: bunyan;
|
||||
ipaBundleIdentifier: string | undefined;
|
||||
appleAppIdentifier: string | undefined;
|
||||
ascAppBundleIdentifier: string | undefined;
|
||||
bundleShortVersion: string | undefined;
|
||||
}): Promise<void> {
|
||||
logger.info('Checking build upload status...');
|
||||
const waitingForBuildStartedAt = Date.now();
|
||||
const waitingLogIntervalMs = 10 * 1000;
|
||||
let lastWaitLogTime = 0;
|
||||
let lastWaitLogState: string | null = null;
|
||||
while (Date.now() - waitingForBuildStartedAt < 30 * 60 * 1000 /* 30 minutes */) {
|
||||
const {
|
||||
data: {
|
||||
attributes: { state },
|
||||
},
|
||||
} = await client.getAsync(
|
||||
`/v1/buildUploads/:id`,
|
||||
{ 'fields[buildUploads]': ['state', 'build'], include: ['build'] },
|
||||
{ id: buildUploadId }
|
||||
);
|
||||
|
||||
if (state.state === 'AWAITING_UPLOAD' || state.state === 'PROCESSING') {
|
||||
const now = Date.now();
|
||||
if (lastWaitLogState !== state.state || now - lastWaitLogTime >= waitingLogIntervalMs) {
|
||||
logger.info(`Waiting for build upload to complete... (status = ${state.state})`);
|
||||
lastWaitLogTime = now;
|
||||
lastWaitLogState = state.state;
|
||||
}
|
||||
await setTimeout(2000);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info('\n');
|
||||
|
||||
const { errors = [], warnings = [], infos = [] } = state;
|
||||
if (infos.length > 0) {
|
||||
logger.info(`Infos:\n${AscApiUtils.itemizeMessages(infos)}\n`);
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
logger.warn(`Warnings:\n${AscApiUtils.itemizeMessages(warnings)}\n`);
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
logger.error(`Errors:\n${AscApiUtils.itemizeMessages(errors)}\n`);
|
||||
}
|
||||
|
||||
if (state.state === 'FAILED') {
|
||||
if (isSdkVersionIssueError(errors)) {
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE',
|
||||
'Build upload was rejected by App Store Connect because the IPA was built with an iOS SDK that is too old for current App Store Connect requirements. ' +
|
||||
'In an Expo project, this usually means the build used an outdated EAS iOS image or the project still depends on an Expo SDK / native setup that does not support the required Xcode toolchain. ' +
|
||||
'Upgrade to a supported Expo SDK if needed and/or select a newer iOS image in `eas.json`, rebuild, and submit again.',
|
||||
{
|
||||
docsUrl: 'https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/',
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isInvalidBundleIdentifierError(errors)) {
|
||||
if (!ascAppBundleIdentifier && appleAppIdentifier) {
|
||||
try {
|
||||
const app = await AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier });
|
||||
ascAppBundleIdentifier = app.data.attributes.bundleId;
|
||||
} catch {
|
||||
// Keep the processing error if app details are unavailable.
|
||||
}
|
||||
}
|
||||
let visibleAppsSummary: string | null = null;
|
||||
try {
|
||||
const apps = await AscApiUtils.getAppsAsync({ client, limit: 5 });
|
||||
visibleAppsSummary = AscApiUtils.formatAppsList(apps);
|
||||
} catch {
|
||||
// Ok to fail, this is just trying to be helpful.
|
||||
}
|
||||
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
|
||||
`Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` +
|
||||
`IPA bundle identifier: ${ipaBundleIdentifier ?? '(unavailable)'}\n` +
|
||||
`App Store Connect app bundle identifier: ${ascAppBundleIdentifier ?? '(unavailable)'}\n\n` +
|
||||
'Bundle identifier cannot be changed for an existing App Store Connect app. ' +
|
||||
'If you selected the wrong app, change the Apple app identifier in the submit profile. ' +
|
||||
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).' +
|
||||
'\n\nOther App Store Connect apps visible to this API key:\n' +
|
||||
(visibleAppsSummary ?? ' (unable to retrieve)'),
|
||||
{
|
||||
docsUrl: 'https://expo.fyi/asc-app-id',
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isMissingPurposeStringError(errors)) {
|
||||
const missingUsageDescriptionKeys = parseMissingUsageDescriptionKeys(errors);
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
|
||||
`Build upload was rejected by App Store Connect because Info.plist is missing one or more privacy purpose strings.\n\n` +
|
||||
`${
|
||||
missingUsageDescriptionKeys.length > 0
|
||||
? `Missing keys reported by App Store Connect:\n- ${missingUsageDescriptionKeys.join(
|
||||
'\n- '
|
||||
)}\n\n`
|
||||
: ''
|
||||
}` +
|
||||
'Add the missing keys with clear user-facing explanations, then rebuild and submit again.\n' +
|
||||
'If you use Continuous Native Generation (CNG), update `ios.infoPlist` in app.json/app.config.js.\n' +
|
||||
'If you do not use CNG, update your app target Info.plist directly.',
|
||||
{
|
||||
docsUrl: 'https://docs.expo.dev/guides/permissions/#ios',
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isClosedVersionTrainError(errors)) {
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN',
|
||||
`Build upload was rejected by App Store Connect because the ${bundleShortVersion ?? 'selected'} app version is not accepted for new build submissions. ` +
|
||||
'This usually means the version train is closed or lower than a previously approved version. ' +
|
||||
'Bump the iOS app version (CFBundleShortVersionString, e.g. expo.version) to a higher version, then rebuild and submit again.'
|
||||
);
|
||||
}
|
||||
throw new Error(`Build upload (ID: ${buildUploadId}) failed.`);
|
||||
} else if (state.state === 'COMPLETE') {
|
||||
logger.info(`Build upload (ID: ${buildUploadId}) complete!`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function isClosedVersionTrainError(messages: { code: string }[]): boolean {
|
||||
return (
|
||||
messages.length > 0 &&
|
||||
messages.every(message => ['90062', '90186', '90478'].includes(message.code))
|
||||
);
|
||||
}
|
||||
|
||||
function isSdkVersionIssueError(messages: { code: string }[]): boolean {
|
||||
return messages.length > 0 && messages.every(message => message.code === '90725');
|
||||
}
|
||||
|
||||
function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
|
||||
return (
|
||||
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
|
||||
);
|
||||
}
|
||||
|
||||
function isMissingPurposeStringError(messages: { code: string }[]): boolean {
|
||||
return messages.length > 0 && messages.every(message => message.code === '90683');
|
||||
}
|
||||
|
||||
function parseMissingUsageDescriptionKeys(messages: { description: string }[]): string[] {
|
||||
const usageDescriptionKeyRegex = /\b(\w+UsageDescription)\b/g;
|
||||
const keys = new Set<string>();
|
||||
for (const message of messages) {
|
||||
const matches = message.description.matchAll(usageDescriptionKeyRegex);
|
||||
for (const match of matches) {
|
||||
keys.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...keys];
|
||||
}
|
||||
@@ -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);
|
||||
@@ -330,14 +332,16 @@ describe(DeviceRunSessionWorkerApi, () => {
|
||||
});
|
||||
|
||||
it('reports a request that failed before www responded', async () => {
|
||||
jest.mocked(turtleFetch).mockRejectedValueOnce(new Error('socket hang up'));
|
||||
const reason = new Error('socket hang up');
|
||||
jest.mocked(turtleFetch).mockRejectedValueOnce(reason);
|
||||
|
||||
const error = await createApi()
|
||||
.getSessionAsync()
|
||||
.getSessionAsync({ retries: 0 })
|
||||
.catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
|
||||
expect(error.status).toBeUndefined();
|
||||
expect(error.cause).toBe(reason);
|
||||
expect(error.message).toContain('failed before www responded: socket hang up');
|
||||
});
|
||||
|
||||
@@ -346,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'
|
||||
);
|
||||
});
|
||||
@@ -354,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();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
+4
-2
@@ -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,6 +166,7 @@ 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();
|
||||
});
|
||||
|
||||
@@ -213,7 +215,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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -88,11 +90,16 @@ export class DeviceRunSessionWorkerApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
/** The HTTP status of the response, or undefined when no response arrived. */
|
||||
readonly status: number | undefined
|
||||
readonly status: number | undefined,
|
||||
options?: ErrorOptions
|
||||
) {
|
||||
super(message);
|
||||
super(message, options);
|
||||
this.name = 'DeviceRunSessionWorkerApiError';
|
||||
}
|
||||
|
||||
get isTransient(): boolean {
|
||||
return this.status === undefined || this.status >= 500;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,16 +191,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(
|
||||
@@ -207,7 +242,7 @@ export class DeviceRunSessionWorkerApi {
|
||||
...(json ? { json } : {}),
|
||||
...(signal ? { signal } : {}),
|
||||
...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}),
|
||||
retries,
|
||||
retries: 0,
|
||||
shouldThrowOnNotOk: false,
|
||||
logger,
|
||||
})
|
||||
@@ -218,7 +253,8 @@ export class DeviceRunSessionWorkerApi {
|
||||
`${routeDescription} failed before www responded: ${
|
||||
reason instanceof Error ? reason.message : String(reason)
|
||||
}. Check the network connection of the worker.`,
|
||||
undefined
|
||||
undefined,
|
||||
{ cause: reason }
|
||||
);
|
||||
}
|
||||
const response = responseResult.value;
|
||||
@@ -235,6 +271,15 @@ export class DeviceRunSessionWorkerApi {
|
||||
);
|
||||
}
|
||||
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
|
||||
|
||||
@@ -302,4 +302,8 @@ export namespace AscApiUtils {
|
||||
.join('\n') || ' (none)'
|
||||
);
|
||||
}
|
||||
|
||||
export function itemizeMessages(messages: { description: string; code: string }[]): string {
|
||||
return `- ${messages.map(m => `${m.description} (${m.code})`).join('\n- ')}`;
|
||||
}
|
||||
}
|
||||
@@ -560,7 +560,10 @@ export async function uploadRemoteSessionConfigAsync({
|
||||
);
|
||||
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
|
||||
try {
|
||||
await workerApi.startAsync({ remoteConfig });
|
||||
await workerApi.startAsync(
|
||||
{ remoteConfig },
|
||||
{ retries: 3, timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS, logger }
|
||||
);
|
||||
} catch (err) {
|
||||
throw new SystemError(
|
||||
`Failed to start device run session ${deviceRunSessionId}: ${
|
||||
|
||||
@@ -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',
|
||||
'',
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { isConnectFailure, isConnectionInterruptedError } from '../networkErrors';
|
||||
import { CombinedError } from '@urql/core';
|
||||
|
||||
import {
|
||||
describeError,
|
||||
findErrorCode,
|
||||
findGraphqlErrorCode,
|
||||
isConnectFailure,
|
||||
isConnectionInterruptedError,
|
||||
isTransferFailure,
|
||||
isTransientGraphqlError,
|
||||
} from '../networkErrors';
|
||||
|
||||
const { FetchError } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
it.each([
|
||||
'EAI_AGAIN',
|
||||
@@ -8,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', {
|
||||
@@ -18,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', {
|
||||
@@ -29,8 +42,82 @@ 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);
|
||||
});
|
||||
|
||||
it('finds the errno code on the cause of an undici fetch failure', () => {
|
||||
const error = new CombinedError({
|
||||
networkError: new TypeError('fetch failed', {
|
||||
cause: Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }),
|
||||
}),
|
||||
});
|
||||
expect(findErrorCode(error)).toBe('ECONNRESET');
|
||||
expect(findErrorCode(new Error('Request failed'))).toBeUndefined();
|
||||
expect(findErrorCode('Request failed')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('finds the www error code on a GraphQL error', () => {
|
||||
const error = new CombinedError({
|
||||
graphQLErrors: [
|
||||
{ message: 'Too many requests', extensions: { errorCode: 'TOO_MANY_REQUESTS' } },
|
||||
],
|
||||
});
|
||||
expect(findGraphqlErrorCode(error)).toBe('TOO_MANY_REQUESTS');
|
||||
expect(
|
||||
findGraphqlErrorCode(new CombinedError({ graphQLErrors: ['Build not found'] }))
|
||||
).toBeUndefined();
|
||||
expect(findGraphqlErrorCode(new Error('Request failed'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Request failed', 'ECONNRESET', 'Request failed (ECONNRESET)'],
|
||||
['read ECONNRESET', 'ECONNRESET', 'read ECONNRESET'],
|
||||
['Request failed', undefined, 'Request failed'],
|
||||
])('describes %p with code %p as %p', (message, code, expected) => {
|
||||
expect(describeError(Object.assign(new Error(message), { code }))).toBe(expected);
|
||||
});
|
||||
|
||||
it('treats a missing response, a 5xx response or an isTransient GraphQL error as transient', () => {
|
||||
expect(
|
||||
isTransientGraphqlError(new CombinedError({ networkError: new Error('fetch failed') }))
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTransientGraphqlError(
|
||||
new CombinedError({ graphQLErrors: ['Internal Server Error'], response: { status: 503 } })
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTransientGraphqlError(
|
||||
new CombinedError({
|
||||
graphQLErrors: [{ message: 'Database unavailable', extensions: { isTransient: true } }],
|
||||
response: { status: 200 },
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTransientGraphqlError(
|
||||
new CombinedError({ graphQLErrors: ['Build not found'], response: { status: 200 } })
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a node-fetch socket failure',
|
||||
new FetchError('request failed', 'system', { code: 'ECONNRESET' } as any),
|
||||
true,
|
||||
],
|
||||
['a node-fetch body size failure', new FetchError('too large', 'max-size'), false],
|
||||
['a body reset', Object.assign(new Error('aborted'), { code: 'ECONNRESET' }), true],
|
||||
[
|
||||
'a body closed early',
|
||||
Object.assign(new Error('closed'), { code: 'ERR_STREAM_PREMATURE_CLOSE' }),
|
||||
true,
|
||||
],
|
||||
['an HTTP error response', new Error('Unexpected response from server (500)'), false],
|
||||
])('classifies %s as a transfer failure or not', (_label, error, expected) => {
|
||||
expect(isTransferFailure(error)).toBe(expected);
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
|
||||
const CONNECT_FAILURE_CODES = new Set([
|
||||
'EAI_AGAIN',
|
||||
'ECONNREFUSED',
|
||||
@@ -6,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 {
|
||||
@@ -24,9 +34,60 @@ 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;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Errno code of the error or its causes; undici keeps it on cause, urql on networkError. */
|
||||
export function findErrorCode(error: unknown): string | undefined {
|
||||
let current: unknown = error;
|
||||
while (current instanceof Error) {
|
||||
if ('code' in current && typeof current.code === 'string') {
|
||||
return current.code;
|
||||
}
|
||||
current = current instanceof CombinedError ? current.networkError : current.cause;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function findGraphqlErrorCode(error: unknown): string | undefined {
|
||||
if (!(error instanceof CombinedError)) {
|
||||
return undefined;
|
||||
}
|
||||
const code = error.graphQLErrors.at(0)?.extensions.errorCode;
|
||||
return typeof code === 'string' ? code : undefined;
|
||||
}
|
||||
|
||||
export function describeError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const code = findErrorCode(error);
|
||||
return code && !message.includes(code) ? `${message} (${code})` : message;
|
||||
}
|
||||
|
||||
export function isTransientGraphqlError(error: CombinedError): boolean {
|
||||
return (
|
||||
Boolean(error.networkError) ||
|
||||
error.response?.status >= 500 ||
|
||||
error.graphQLErrors.some(graphqlError => graphqlError.extensions.isTransient === true)
|
||||
);
|
||||
}
|
||||
|
||||
// node-fetch throws type 'system' for a socket failure before the headers; after them it forwards
|
||||
// ECONNRESET or ERR_STREAM_PREMATURE_CLOSE to the body. HTTP error responses never throw.
|
||||
export function isTransferFailure(error: unknown): boolean {
|
||||
return (
|
||||
(error instanceof Error &&
|
||||
error.name === 'FetchError' &&
|
||||
'type' in error &&
|
||||
error.type === 'system') ||
|
||||
isConnectionInterruptedError(error) ||
|
||||
findErrorCode(error) === 'ERR_STREAM_PREMATURE_CLOSE'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Datadog } from '../datadog';
|
||||
import { findErrorCode, findGraphqlErrorCode } from './networkErrors';
|
||||
import { promiseRetryWithCondition } from './promiseRetryWithCondition';
|
||||
|
||||
export async function retryOnNetworkFailureAsync<T>(
|
||||
attemptAsync: () => Promise<T>,
|
||||
{
|
||||
operation,
|
||||
signal,
|
||||
isRetryable,
|
||||
}: {
|
||||
operation: string;
|
||||
signal?: AbortSignal;
|
||||
isRetryable: (error: unknown) => boolean;
|
||||
}
|
||||
): Promise<T> {
|
||||
const shouldRetry = (error: unknown): boolean => !signal?.aborted && isRetryable(error);
|
||||
let attempts = 1;
|
||||
let lastError: unknown;
|
||||
const report = (outcome: 'recovered' | 'failed', error: unknown): void => {
|
||||
Datadog.log(`${operation} ${outcome} on attempt ${attempts}`, {
|
||||
event: 'network_retry',
|
||||
operation,
|
||||
outcome,
|
||||
attempts: String(attempts),
|
||||
code: findErrorCode(error) ?? findGraphqlErrorCode(error) ?? 'unknown',
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await promiseRetryWithCondition(
|
||||
async () => {
|
||||
signal?.throwIfAborted();
|
||||
return await attemptAsync();
|
||||
},
|
||||
shouldRetry,
|
||||
{ retries: 5, minTimeout: 1_000, factor: 2 },
|
||||
({ attemptNumber, error }) => {
|
||||
attempts = attemptNumber;
|
||||
lastError = error;
|
||||
}
|
||||
)();
|
||||
if (attempts > 1) {
|
||||
report('recovered', lastError);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (attempts > 1 && !signal?.aborted) {
|
||||
report('failed', error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { DeviceRunSessionEvent, parseDeviceRunSessionEvents } from '../deviceRunSession';
|
||||
import {
|
||||
DeviceRunSessionEvent,
|
||||
parseDeviceRunSessionEvents,
|
||||
quoteAndroidShellCommand,
|
||||
validateAndroidLaunchOptions,
|
||||
} from '../deviceRunSession';
|
||||
|
||||
describe('parseDeviceRunSessionEvents', () => {
|
||||
it('parses, deduplicates, and sorts events from the dedicated event log', () => {
|
||||
@@ -33,6 +38,56 @@ describe('parseDeviceRunSessionEvents', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(quoteAndroidShellCommand, () => {
|
||||
it('quotes only arguments the device shell would split or expand', () => {
|
||||
expect(
|
||||
quoteAndroidShellCommand(['am', 'start', '--es', 'text', 'hello world', "it's", '', '-n'])
|
||||
).toEqual(['am', 'start', '--es', 'text', "'hello world'", "'it'\\''s'", "''", '-n']);
|
||||
});
|
||||
|
||||
it('rejects NUL characters', () => {
|
||||
expect(() => quoteAndroidShellCommand(['am', 'start', 'a\0b'])).toThrow(
|
||||
expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(validateAndroidLaunchOptions, () => {
|
||||
it('rejects only commands that are too long without the launch component', () => {
|
||||
const fixedLength = 'am start --es key -n'.length;
|
||||
const launchArgs = (valueLength: number) => ['--es', 'key', 'a'.repeat(valueLength)];
|
||||
|
||||
expect(() =>
|
||||
validateAndroidLaunchOptions({ launchArgs: launchArgs(65_000 - fixedLength) })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
validateAndroidLaunchOptions({ launchArgs: launchArgs(65_000 - fixedLength + 1) })
|
||||
).toThrow(expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' }));
|
||||
});
|
||||
|
||||
it('counts bytes, not characters', () => {
|
||||
// "é" is two bytes in UTF-8 and is quoted, which adds two more.
|
||||
const fixedLength = "am start --es key '' -n".length;
|
||||
const launchArgs = (extraLength: number) => [
|
||||
'--es',
|
||||
'key',
|
||||
'a'.repeat(extraLength) + 'é'.repeat(32_488),
|
||||
];
|
||||
|
||||
expect(fixedLength + 1 + 2 * 32_488).toBe(65_000);
|
||||
expect(() => validateAndroidLaunchOptions({ launchArgs: launchArgs(1) })).not.toThrow();
|
||||
expect(() => validateAndroidLaunchOptions({ launchArgs: launchArgs(2) })).toThrow(
|
||||
expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' })
|
||||
);
|
||||
});
|
||||
|
||||
it('checks the URL to open', () => {
|
||||
expect(() => validateAndroidLaunchOptions({ openUrl: 'exp://example.test/a\0b' })).toThrow(
|
||||
expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createEvent(overrides: Partial<DeviceRunSessionEvent> = {}): string {
|
||||
return JSON.stringify(createEventValue(overrides));
|
||||
}
|
||||
|
||||
@@ -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,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { UserError } from './errors';
|
||||
|
||||
/**
|
||||
* Canonical record format for the activity events emitted during a remote device run (simulator)
|
||||
* session. Producers (e.g. the worker and agent-device) write these as NDJSON to the
|
||||
@@ -46,3 +48,54 @@ export function parseDeviceRunSessionEvents(eventLog: string): DeviceRunSessionE
|
||||
(left, right) => new Date(left.ts).getTime() - new Date(right.ts).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
// adb closes the connection for longer shell commands (about 65.5 KB with adb 37 on API 35).
|
||||
export const ANDROID_SHELL_COMMAND_MAX_BYTES = 65_000;
|
||||
|
||||
/**
|
||||
* Check Android launch inputs before a session starts. The launch component is only known once the
|
||||
* build is installed, so this rejects only commands that are too long without it; the launch itself
|
||||
* checks the exact command.
|
||||
*/
|
||||
export function validateAndroidLaunchOptions({
|
||||
launchArgs = [],
|
||||
openUrl,
|
||||
}: {
|
||||
launchArgs?: string[];
|
||||
openUrl?: string;
|
||||
}): void {
|
||||
quoteAndroidShellCommand(['am', 'start', ...launchArgs, '-n']);
|
||||
if (openUrl) {
|
||||
quoteAndroidShellCommand(['am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `adb shell` joins its arguments with spaces and the device shell parses the result again, so
|
||||
* each argument is quoted to reach the command as one literal word.
|
||||
*/
|
||||
export function quoteAndroidShellCommand(command: string[]): string[] {
|
||||
if (command.some(argument => argument.includes('\0'))) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'Android "launch_args" and "open_url" cannot contain NUL characters.'
|
||||
);
|
||||
}
|
||||
const quotedCommand = command.map(quoteAndroidShellArgument);
|
||||
if (
|
||||
new TextEncoder().encode(quotedCommand.join(' ')).byteLength > ANDROID_SHELL_COMMAND_MAX_BYTES
|
||||
) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
`The Android launch command is longer than ${ANDROID_SHELL_COMMAND_MAX_BYTES} bytes after shell quoting. Shorten "launch_args" or "open_url".`
|
||||
);
|
||||
}
|
||||
return quotedCommand;
|
||||
}
|
||||
|
||||
function quoteAndroidShellArgument(argument: string): string {
|
||||
if (/^[\w@%+=:,./-]+$/.test(argument)) {
|
||||
return argument;
|
||||
}
|
||||
return `'${argument.replaceAll("'", `'\\''`)}'`;
|
||||
}
|
||||
Generated
+725
-731
File diff suppressed because it is too large.
Load diff
@@ -252,7 +252,7 @@
|
||||
},
|
||||
"update": {
|
||||
"node": {
|
||||
"version": "22.23.3"
|
||||
"version": "22.23.4"
|
||||
},
|
||||
"s3": {
|
||||
"templates": {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
+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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -33,7 +33,7 @@ const PROJECT_PAGES: Record<string, string> = {
|
||||
|
||||
export default class Browse extends EasCommand {
|
||||
static override description =
|
||||
'Transition from the terminal to the web browser to view and interact with your project on https://expo.dev';
|
||||
'transition from the terminal to the web browser to view and interact with your project on https://expo.dev';
|
||||
|
||||
static override args = {
|
||||
page: Args.string({
|
||||
|
||||
@@ -109,7 +109,7 @@ export default class BuildDev extends EasCommand {
|
||||
|
||||
const workflow = await resolveWorkflowAsync(projectDir, platform, vcsClient);
|
||||
|
||||
const { env } = await evaluateConfigWithEnvVarsAsync({
|
||||
const evaluatedConfig = await evaluateConfigWithEnvVarsAsync({
|
||||
buildProfile: buildProfile.profile,
|
||||
buildProfileName: buildProfile.profileName,
|
||||
graphqlClient,
|
||||
@@ -118,7 +118,7 @@ export default class BuildDev extends EasCommand {
|
||||
});
|
||||
|
||||
const fingerprint = await createFingerprintAsync(projectDir, {
|
||||
env,
|
||||
env: evaluatedConfig.env,
|
||||
workflow,
|
||||
platforms: [platform],
|
||||
});
|
||||
@@ -197,7 +197,7 @@ export default class BuildDev extends EasCommand {
|
||||
actor,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
downloadSimBuildAutoConfirm: true,
|
||||
envOverride: env,
|
||||
evaluatedConfig,
|
||||
});
|
||||
if (!flags['skip-bundler']) {
|
||||
await this.startDevServerAsync({ projectDir, platform });
|
||||
|
||||
@@ -13,7 +13,7 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
|
||||
|
||||
export default class ChannelDelete extends EasCommand {
|
||||
static override description = 'Delete a channel';
|
||||
static override description = 'delete a channel';
|
||||
|
||||
static override args = {
|
||||
name: Args.string({
|
||||
|
||||
@@ -50,7 +50,7 @@ type ChannelRolloutArgsAndFlags = {
|
||||
Partial<CreateRolloutNonInteractiveOptions>;
|
||||
|
||||
export default class ChannelRollout extends EasCommand {
|
||||
static override description = 'Roll a new branch out on a channel incrementally.';
|
||||
static override description = 'roll a new branch out on a channel incrementally';
|
||||
|
||||
static override args = {
|
||||
channel: Args.string({
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SetUpBuildCredentialsCommandAction } from '../../credentials/manager/Se
|
||||
import { selectPlatformAsync } from '../../platform';
|
||||
|
||||
export default class InitializeBuildCredentials extends EasCommand {
|
||||
static override description = 'Set up credentials for building your project.';
|
||||
static override description = 'set up credentials for building your project';
|
||||
|
||||
static override flags = {
|
||||
platform: Flags.option({
|
||||
|
||||
@@ -22,7 +22,7 @@ interface DeployAliasDeleteFlags {
|
||||
}
|
||||
|
||||
export default class WorkerAliasDelete extends EasCommand {
|
||||
static override description = 'Delete deployment aliases.';
|
||||
static override description = 'delete deployment aliases';
|
||||
static override aliases = ['worker:alias:delete'];
|
||||
static override state = 'preview';
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ interface RawDeployAliasFlags {
|
||||
}
|
||||
|
||||
export default class WorkerAlias extends EasCommand {
|
||||
static override description = 'Assign deployment aliases.';
|
||||
static override description = 'assign deployment aliases';
|
||||
static override aliases = ['worker:alias', 'deploy:promote'];
|
||||
static override state = 'preview';
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ interface RawDeployDeleteFlags {
|
||||
}
|
||||
|
||||
export default class WorkerDelete extends EasCommand {
|
||||
static override description = 'Delete a deployment.';
|
||||
static override description = 'delete a deployment';
|
||||
static override aliases = ['worker:delete'];
|
||||
static override state = 'preview';
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ async function withSuppressedOutputAsync<T>(fn: () => Promise<T>): Promise<T> {
|
||||
/* eslint-enable no-console */
|
||||
|
||||
export default class Go extends EasCommand {
|
||||
static override description = 'Create a custom Expo Go and submit to TestFlight';
|
||||
static override description = 'create a custom Expo Go and submit to TestFlight';
|
||||
static override hidden = true;
|
||||
|
||||
static override flags = {
|
||||
|
||||
@@ -22,7 +22,7 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class ObserveEvent extends EasCommand {
|
||||
static override description =
|
||||
'display a single Observe event (metric, log, or error) by its ID. IDs are included in event data when the `--json` flag is passed to `eas observe:session`, `eas observe:metrics`, or `eas observe:events`.';
|
||||
'display a single Observe event (metric, log, or error) by its ID. IDs are included in event data when the `--json` flag is passed to `eas observe:session`, `eas observe:metrics`, or `eas observe:events`';
|
||||
|
||||
static override args = {
|
||||
id: Args.string({
|
||||
|
||||
@@ -36,7 +36,7 @@ const DEFAULT_EVENTS_LIMIT = 10;
|
||||
|
||||
export default class ObserveEvents extends EasCommand {
|
||||
static override description =
|
||||
'display individual events emitted by the app via `logEvent`, filtered by the event name in the argument. With no arguments, a list of the available event names and associated event counts is returned.';
|
||||
'display individual events emitted by the app via `logEvent`, filtered by the event name in the argument. With no arguments, a list of the available event names and associated event counts is returned';
|
||||
|
||||
static override args = {
|
||||
eventName: Args.string({
|
||||
|
||||
@@ -134,7 +134,7 @@ export default class New extends EasCommand {
|
||||
static override aliases = ['new'];
|
||||
|
||||
static override description =
|
||||
'Create a new project configured with Expo Application Services (EAS)';
|
||||
'create a new project configured with Expo Application Services (EAS)';
|
||||
|
||||
static override args = {
|
||||
path: Args.string({
|
||||
|
||||
@@ -40,6 +40,7 @@ jest.mock('../../../utils/json');
|
||||
type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
|
||||
|
||||
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
|
||||
const mockJobRunErrorsByIdAsync = jest.mocked(DeviceRunSessionQuery.jobRunErrorsByIdAsync);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockLoadSimulatorEnvironmentVariablesAsync = jest.mocked(loadSimulatorEnvAsync);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
@@ -108,6 +109,7 @@ describe(SimulatorGet, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockLoadSimulatorEnvironmentVariablesAsync.mockResolvedValue();
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): {
|
||||
@@ -162,6 +164,79 @@ describe(SimulatorGet, () => {
|
||||
previewApiUrl: null,
|
||||
},
|
||||
artifacts: session.artifacts,
|
||||
errors: [],
|
||||
});
|
||||
expect(mockJobRunErrorsByIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('errored sessions', () => {
|
||||
const jobRunErrors = {
|
||||
id: 'job-123',
|
||||
errors: [
|
||||
{
|
||||
errorCode: 'EAS_IOS_SIMULATOR_NOT_FOUND',
|
||||
message: 'No available iOS Simulator is named "iPhone 16 Pro".',
|
||||
docsUrl: null,
|
||||
},
|
||||
],
|
||||
logFileUrls: [],
|
||||
};
|
||||
|
||||
it('includes the job run errors in JSON output', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored, remoteConfig: null })
|
||||
);
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue(jobRunErrors);
|
||||
|
||||
const { command } = createCommand(['--id', 'session-123', '--json']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockJobRunErrorsByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-123', {
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
errors: [
|
||||
{
|
||||
errorCode: 'EAS_IOS_SIMULATOR_NOT_FOUND',
|
||||
message: 'No available iOS Simulator is named "iPhone 16 Pro".',
|
||||
docsUrl: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('prints the job run errors when only the job run errored', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({
|
||||
status: DeviceRunSessionStatus.Stopped,
|
||||
remoteConfig: null,
|
||||
turtleJobRun: { id: 'job-123', status: JobRunStatus.Errored },
|
||||
})
|
||||
);
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue(jobRunErrors);
|
||||
|
||||
const { command } = createCommand(['--id', 'session-123']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(Log.gray).toHaveBeenCalledWith('Session errors:');
|
||||
expect(Log.error).toHaveBeenCalledWith(
|
||||
' No available iOS Simulator is named "iPhone 16 Pro".'
|
||||
);
|
||||
});
|
||||
|
||||
it('prints no errors section when the job run errors cannot be fetched', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored, remoteConfig: null })
|
||||
);
|
||||
mockJobRunErrorsByIdAsync.mockRejectedValue(new Error('graphql boom'));
|
||||
|
||||
const { command } = createCommand(['--id', 'session-123']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(Log.gray).not.toHaveBeenCalledWith('Session errors:');
|
||||
expect(Log.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { GraphQLError } from 'graphql';
|
||||
import { SimulatorEvent, isAnalyticsOptedOutAsync } from '../../../analytics/AnalyticsManager';
|
||||
import { getAgentTelemetryContext } from '../../../analytics/agent';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import fetch from '../../../fetch';
|
||||
import {
|
||||
AppPlatform,
|
||||
CreateDeviceRunSessionMutation,
|
||||
@@ -44,6 +45,7 @@ jest.mock('../../../analytics/AnalyticsManager', () => ({
|
||||
jest.mock('../../../analytics/agent');
|
||||
jest.mock('../../../graphql/mutations/DeviceRunSessionMutation');
|
||||
jest.mock('../../../graphql/queries/DeviceRunSessionAvailabilityQuery');
|
||||
jest.mock('../../../fetch');
|
||||
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
|
||||
jest.mock('../../../log', () => ({
|
||||
__esModule: true,
|
||||
@@ -57,6 +59,7 @@ jest.mock('../../../log', () => ({
|
||||
withTick: jest.fn(),
|
||||
},
|
||||
link: jest.fn((url: string) => url),
|
||||
learnMore: jest.fn((url: string) => `Learn more: ${url}`),
|
||||
}));
|
||||
jest.mock('../../../simulator/env', () => ({
|
||||
...jest.requireActual('../../../simulator/env'),
|
||||
@@ -105,6 +108,8 @@ const mockEnsureDeviceRunSessionStoppedAsync = jest.mocked(
|
||||
);
|
||||
const mockAvailabilityByAppIdAsync = jest.mocked(DeviceRunSessionAvailabilityQuery.byAppIdAsync);
|
||||
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
|
||||
const mockJobRunErrorsByIdAsync = jest.mocked(DeviceRunSessionQuery.jobRunErrorsByIdAsync);
|
||||
const mockFetch = jest.mocked(fetch);
|
||||
const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync);
|
||||
const mockLogEvent = jest.fn();
|
||||
const mockFlushAsync = jest.fn(async () => {});
|
||||
@@ -191,6 +196,7 @@ describe(Simulator, () => {
|
||||
jest.restoreAllMocks();
|
||||
jest.clearAllMocks();
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue(null);
|
||||
mockAvailabilityByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: true });
|
||||
mockCreateDeviceRunSessionAsync.mockResolvedValue(makeCreatedDeviceRunSession());
|
||||
mockEnsureDeviceRunSessionStoppedAsync.mockResolvedValue({
|
||||
@@ -1990,6 +1996,292 @@ describe(Simulator, () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('job run errors', () => {
|
||||
const erroredSessionMessage =
|
||||
'Simulator session session-123 errored before the agent-device session was ready. https://expo.dev/accounts/testuser/projects/testapp/simulator-sessions/session-123';
|
||||
const logFileUrl = 'https://logs.example.com/job-123';
|
||||
const unknownError = {
|
||||
errorCode: 'UNKNOWN_ERROR',
|
||||
message: 'Unknown error. See logs for more information.',
|
||||
docsUrl: null,
|
||||
};
|
||||
|
||||
// Shaped like the worker's bunyan output: the failing call logs at error level with `err`,
|
||||
// then the step runner logs an end-step marker with result "fail".
|
||||
function failedStepLogLines(
|
||||
stepId: string,
|
||||
displayName: string,
|
||||
messages: string[]
|
||||
): Record<string, unknown>[] {
|
||||
return [
|
||||
{
|
||||
level: 30,
|
||||
buildStepId: stepId,
|
||||
buildStepDisplayName: displayName,
|
||||
marker: 'start-step',
|
||||
msg: `Executing build step "${displayName}"`,
|
||||
},
|
||||
...messages.map(msg => ({
|
||||
level: 50,
|
||||
buildStepId: stepId,
|
||||
buildStepDisplayName: displayName,
|
||||
msg,
|
||||
err: { message: msg },
|
||||
})),
|
||||
{
|
||||
level: 50,
|
||||
buildStepId: stepId,
|
||||
buildStepDisplayName: displayName,
|
||||
marker: 'end-step',
|
||||
result: 'fail',
|
||||
msg: `Build step "${displayName}" failed`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function mockLogFile(logLines: Record<string, unknown>[]): void {
|
||||
mockFetch.mockResolvedValue({
|
||||
text: async () => logLines.map(line => JSON.stringify(line)).join('\n'),
|
||||
} as unknown as Awaited<ReturnType<typeof fetch>>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored, remoteConfig: null })
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the message unchanged without reading logs when the job run has no errors', async () => {
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue({
|
||||
id: 'job-123',
|
||||
errors: [],
|
||||
logFileUrls: [logFileUrl],
|
||||
});
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
expect.objectContaining({ message: erroredSessionMessage })
|
||||
);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists each job run error on its own line with its docs link', async () => {
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue({
|
||||
id: 'job-123',
|
||||
errors: [
|
||||
{
|
||||
errorCode: 'EAS_IOS_SIMULATOR_NOT_FOUND',
|
||||
message: 'No available iOS Simulator is named "iPhone 16 Pro".',
|
||||
docsUrl: null,
|
||||
},
|
||||
{
|
||||
errorCode: 'EAS_SIMULATOR_BUILD_NOT_INSTALLABLE',
|
||||
message: 'The build cannot be installed on a simulator.',
|
||||
docsUrl: 'https://docs.expo.dev/simulator',
|
||||
},
|
||||
],
|
||||
logFileUrls: [logFileUrl],
|
||||
});
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
message: `${erroredSessionMessage}\nNo available iOS Simulator is named "iPhone 16 Pro".\nThe build cannot be installed on a simulator. Learn more: https://docs.expo.dev/simulator`,
|
||||
})
|
||||
);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads the failed step error from the job run logs for an unknown error', async () => {
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue({
|
||||
id: 'job-123',
|
||||
errors: [unknownError],
|
||||
logFileUrls: [logFileUrl],
|
||||
});
|
||||
mockLogFile([
|
||||
{
|
||||
level: 50,
|
||||
buildStepId: 'step-001',
|
||||
buildStepDisplayName: 'Install application',
|
||||
msg: 'Retrying install',
|
||||
err: { message: 'Retrying install' },
|
||||
},
|
||||
{
|
||||
level: 30,
|
||||
buildStepId: 'step-001',
|
||||
marker: 'end-step',
|
||||
result: 'success',
|
||||
msg: 'Finished',
|
||||
},
|
||||
...failedStepLogLines('step-002', 'Launch application', [
|
||||
'adb shell am start exited with non-zero code: 1',
|
||||
]),
|
||||
{
|
||||
level: 20,
|
||||
buildStepId: 'step-002',
|
||||
msg: 'Getting value for output parameter "x" failed.',
|
||||
err: { message: 'not set' },
|
||||
},
|
||||
{
|
||||
level: 40,
|
||||
msg: 'Could not stop the emulator during remote session teardown.',
|
||||
err: { message: 'teardown' },
|
||||
},
|
||||
]);
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
message: `${erroredSessionMessage}\nLaunch application: adb shell am start exited with non-zero code: 1`,
|
||||
})
|
||||
);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
logFileUrl,
|
||||
expect.objectContaining({ size: 20 * 1024 * 1024, signal: expect.any(AbortSignal) })
|
||||
);
|
||||
});
|
||||
|
||||
it('prints the masked log message instead of the unmasked err.message', async () => {
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue({
|
||||
id: 'job-123',
|
||||
errors: [unknownError],
|
||||
logFileUrls: [logFileUrl],
|
||||
});
|
||||
mockLogFile([
|
||||
{
|
||||
level: 50,
|
||||
buildStepId: 'step-001',
|
||||
buildStepDisplayName: 'Run script',
|
||||
msg: 'curl -H "Authorization: ********" exited with non-zero code: 22',
|
||||
err: { message: 'curl -H "Authorization: secret-token" exited with non-zero code: 22' },
|
||||
},
|
||||
{
|
||||
level: 50,
|
||||
buildStepId: 'step-001',
|
||||
marker: 'end-step',
|
||||
result: 'fail',
|
||||
msg: 'Build step "Run script" failed',
|
||||
},
|
||||
]);
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
message: `${erroredSessionMessage}\nRun script: curl -H "Authorization: ********" exited with non-zero code: 22`,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the first failed step, then only the error line of later failed steps', async () => {
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue({
|
||||
id: 'job-123',
|
||||
errors: [unknownError],
|
||||
logFileUrls: [logFileUrl],
|
||||
});
|
||||
mockLogFile([
|
||||
...failedStepLogLines('step-001', 'Boot', ['A', 'B']),
|
||||
...failedStepLogLines('step-002', 'Finish recordings', ['C', 'D']),
|
||||
]);
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
message: `${erroredSessionMessage}\nBoot: B\nFinish recordings: D`,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates very long log errors', async () => {
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue({
|
||||
id: 'job-123',
|
||||
errors: [unknownError],
|
||||
logFileUrls: [logFileUrl],
|
||||
});
|
||||
mockLogFile(failedStepLogLines('step-001', 'Boot', ['x'.repeat(2000)]));
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
message: `${erroredSessionMessage}\n${`Boot: ${'x'.repeat(2000)}`.slice(0, 500)}…`,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the job run error when no step failed, ignoring errors that steps ignored', async () => {
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue({
|
||||
id: 'job-123',
|
||||
errors: [
|
||||
{
|
||||
errorCode: 'UNKNOWN_ERROR',
|
||||
message:
|
||||
'Unknown error. See logs of the Complete job build phase for more information.',
|
||||
docsUrl: null,
|
||||
},
|
||||
],
|
||||
logFileUrls: [logFileUrl],
|
||||
});
|
||||
mockLogFile([
|
||||
{
|
||||
level: 50,
|
||||
buildStepId: 'step-001',
|
||||
buildStepDisplayName: 'Upload artifact',
|
||||
msg: 'Failed to upload screen-recording. Ignoring error.',
|
||||
err: { message: 'upload failed' },
|
||||
},
|
||||
{
|
||||
level: 30,
|
||||
buildStepId: 'step-001',
|
||||
marker: 'end-step',
|
||||
result: 'success',
|
||||
msg: 'Finished',
|
||||
},
|
||||
{ level: 50, phase: 'COMPLETE_JOB', msg: '', err: { message: 'upload outputs failed' } },
|
||||
]);
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
message: `${erroredSessionMessage}\nUnknown error. See logs of the Complete job build phase for more information.`,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the unknown error message when the job run logs cannot be read', async () => {
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue({
|
||||
id: 'job-123',
|
||||
errors: [unknownError],
|
||||
logFileUrls: [logFileUrl],
|
||||
});
|
||||
mockFetch.mockRejectedValue(new Error('Request failed: 503 (Service Unavailable)'));
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
message: `${erroredSessionMessage}\nUnknown error. See logs for more information.`,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the message unchanged when the job run errors cannot be fetched', async () => {
|
||||
mockJobRunErrorsByIdAsync.mockRejectedValue(new Error('graphql boom'));
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
expect.objectContaining({ message: erroredSessionMessage })
|
||||
);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps polling until the remote config appears', async () => {
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(makeDeviceRunSession({ remoteConfig: null }))
|
||||
@@ -2289,6 +2581,31 @@ describe(Simulator, () => {
|
||||
await expect(command.runAsync()).rejects.toThrow('Simulator session session-123 errored.');
|
||||
});
|
||||
|
||||
it('includes the job run error when the session errors while it is running', async () => {
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(makeDeviceRunSession())
|
||||
.mockResolvedValueOnce(makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored }));
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue({
|
||||
id: 'job-123',
|
||||
errors: [
|
||||
{
|
||||
errorCode: 'EAS_SIMULATOR_SESSION_CRASHED',
|
||||
message: 'The simulator stopped responding.',
|
||||
docsUrl: null,
|
||||
},
|
||||
],
|
||||
logFileUrls: [],
|
||||
});
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
message: 'Simulator session session-123 errored.\nThe simulator stopped responding.',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps waiting while the session is still in progress', async () => {
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(makeDeviceRunSession())
|
||||
|
||||
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user