mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
586b77aca4 | ||
|
|
a5a8c90d59 | ||
|
|
a2c0d0cf09 | ||
|
|
f148570a36 | ||
|
|
e8ca106f2e | ||
|
|
62ddc91aa4 |
No files matched your search
@@ -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,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Show the final Google Play rejection when a retried release commit has an uncertain outcome.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Retry recognized Google Play processing and review-setting rejections after transient request failures, while preserving uncertain commit outcomes.
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -48,7 +48,10 @@ describe(createCheckoutBuildFunction, () => {
|
||||
});
|
||||
|
||||
it('moves project sources to the target directory when no inputs are given', async () => {
|
||||
const logger = createMockLogger();
|
||||
jest.mocked(logger.child).mockReturnValue(logger);
|
||||
const { globalCtx, executeAsync } = createCheckoutStep({
|
||||
logger,
|
||||
projectArchive: GIT_PROJECT_ARCHIVE,
|
||||
});
|
||||
|
||||
@@ -56,6 +59,9 @@ describe(createCheckoutBuildFunction, () => {
|
||||
|
||||
expect(await vol.promises.readFile(`${projectTargetDirectory}/app.json`, 'utf8')).toBe('{}');
|
||||
expect(globalCtx.wasCheckedOut()).toBe(true);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Changing default working directory to')
|
||||
);
|
||||
expect(fetchAndCheckoutRefAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -96,14 +102,11 @@ describe(createCheckoutBuildFunction, () => {
|
||||
});
|
||||
|
||||
it('rejects the ref input when the project is already checked out', async () => {
|
||||
const logger = createMockLogger();
|
||||
jest.mocked(logger.child).mockReturnValue(logger);
|
||||
const { globalCtx, executeAsync } = createCheckoutStep({
|
||||
logger,
|
||||
projectArchive: GIT_PROJECT_ARCHIVE,
|
||||
callInputs: { ref: 'feature/add-icon' },
|
||||
});
|
||||
globalCtx.markAsCheckedOut(logger);
|
||||
globalCtx.markAsCheckedOut();
|
||||
|
||||
await expect(executeAsync()).rejects.toMatchObject({
|
||||
errorCode: 'EAS_CHECKOUT_REF_AFTER_CHECKOUT',
|
||||
|
||||
@@ -1,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,
|
||||
}),
|
||||
|
||||
@@ -198,7 +198,10 @@ it('does not delete the edit or publish outputs when commit outcome is unknown',
|
||||
return [204];
|
||||
});
|
||||
const task = submission();
|
||||
await expect(task.run()).rejects.toBeInstanceOf(SystemError);
|
||||
const error = await task.run().catch(error => error);
|
||||
expect(error).toBeInstanceOf(SystemError);
|
||||
expect((error as Error).message).toContain('HTTP 404');
|
||||
expect((error as Error).message).toContain('may still have saved');
|
||||
expect(deleted).toBe(false);
|
||||
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -121,26 +121,32 @@ describe(createUploadArtifactBuildFunction, () => {
|
||||
expect(typeInput.isRawValueOneOfAllowedValues()).toBe(false);
|
||||
});
|
||||
|
||||
it('passes the metadata input through to runtimeApi.uploadArtifact', async () => {
|
||||
contextUploadArtifact.mockClear();
|
||||
const metadata = { kind: 'screen-recording', startedAtMs: '123', device: 'emulator-5554' };
|
||||
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
|
||||
callInputs: {
|
||||
// Generic ('other') artifact: only generic artifacts persist metadata in the worker
|
||||
// (the managed-type upload paths drop it), so that's the type metadata is meant for.
|
||||
type: 'other',
|
||||
key: 'Screen Recording',
|
||||
path: '/',
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
it.each(['other', 'android-apk', 'android-aab', 'ios-simulator-app', 'ios-ipa'])(
|
||||
'uploads %s as a workflow artifact with metadata',
|
||||
async type => {
|
||||
contextUploadArtifact.mockClear();
|
||||
const metadata = { kind: 'screen-recording', startedAtMs: '123', device: 'emulator-5554' };
|
||||
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({}),
|
||||
{
|
||||
callInputs: {
|
||||
type,
|
||||
key: 'Screen Recording',
|
||||
path: '/',
|
||||
metadata,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(contextUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ artifact: expect.objectContaining({ metadata }) })
|
||||
);
|
||||
});
|
||||
expect(contextUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({ type: null, metadata }),
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not attach metadata to managed artifact types (the worker drops it)', async () => {
|
||||
contextUploadArtifact.mockClear();
|
||||
|
||||
@@ -58,7 +58,11 @@ export function createCheckoutBuildFunction(): BuildFunction {
|
||||
});
|
||||
}
|
||||
|
||||
stepsCtx.global.markAsCheckedOut(stepsCtx.logger);
|
||||
const previousWorkingDirectory = stepsCtx.global.defaultWorkingDirectory;
|
||||
stepsCtx.global.markAsCheckedOut();
|
||||
stepsCtx.logger.info(
|
||||
`Changing default working directory to ${stepsCtx.global.defaultWorkingDirectory} (was ${previousWorkingDirectory})`
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -144,6 +144,16 @@ export namespace GooglePlayUtils {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<void> {
|
||||
let uncertainCommitError: GooglePlayCommitOutcomeUnknownError | undefined;
|
||||
const lastCommitError = (error: unknown): unknown => {
|
||||
while (
|
||||
error instanceof GooglePlayCommitOutcomeUnknownError ||
|
||||
error instanceof AggregateError
|
||||
) {
|
||||
error = error.cause;
|
||||
}
|
||||
return error;
|
||||
};
|
||||
const sendCommitAsync = async ({
|
||||
changesNotSentForReview,
|
||||
}: {
|
||||
@@ -165,13 +175,32 @@ export namespace GooglePlayUtils {
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
uncertainCommitError ||
|
||||
!(error instanceof GooglePlayApiError) ||
|
||||
GooglePlayApiError.isRetryableStatus(error.status)
|
||||
) {
|
||||
throw new GooglePlayCommitOutcomeUnknownError(
|
||||
`Could not confirm whether Google Play saved the release for ${packageName}. Check its release status at https://play.google.com/console/ before submitting again.`,
|
||||
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
|
||||
const cause = uncertainCommitError
|
||||
? new AggregateError(
|
||||
[uncertainCommitError, error],
|
||||
'Google Play commit attempts failed.',
|
||||
{ cause: error }
|
||||
)
|
||||
: error;
|
||||
const lastError = lastCommitError(error);
|
||||
const lastResponse =
|
||||
lastError instanceof GooglePlayApiError &&
|
||||
lastError.status >= 400 &&
|
||||
lastError.status < 500 &&
|
||||
!GooglePlayApiError.isRetryableStatus(lastError.status)
|
||||
? `Google Play returned HTTP ${lastError.status} for the last commit attempt${lastError.apiMessage ? `: ${lastError.apiMessage}` : '.'}\n`
|
||||
: '';
|
||||
uncertainCommitError = new GooglePlayCommitOutcomeUnknownError(
|
||||
lastResponse
|
||||
? `${lastResponse}An earlier commit attempt failed with a temporary error, so Google Play may still have saved the release for ${packageName}. Check its release status at https://play.google.com/console/. If the release is not there, fix the problem above and submit again.`
|
||||
: `Could not confirm whether Google Play saved the release for ${packageName}. Check its release status at https://play.google.com/console/ before submitting again.`,
|
||||
{ cause, metadata: GooglePlayApiError.getErrorMetadata(error) }
|
||||
);
|
||||
throw uncertainCommitError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -183,10 +212,14 @@ export namespace GooglePlayUtils {
|
||||
};
|
||||
// Google can accept the bundle upload before the edit is ready to commit.
|
||||
// Retry only this explicit rejection, not other HTTP 400 preconditions.
|
||||
const isUploadStillProcessing = (error: unknown): boolean =>
|
||||
error instanceof GooglePlayApiError &&
|
||||
error.status === 400 &&
|
||||
error.apiMessage === 'Some of the Android App Bundle uploads are not completed yet.';
|
||||
const isUploadStillProcessing = (error: unknown): boolean => {
|
||||
const lastError = lastCommitError(error);
|
||||
return (
|
||||
lastError instanceof GooglePlayApiError &&
|
||||
lastError.status === 400 &&
|
||||
lastError.apiMessage === 'Some of the Android App Bundle uploads are not completed yet.'
|
||||
);
|
||||
};
|
||||
|
||||
const commitAsync = async (options: { changesNotSentForReview?: boolean }): Promise<void> => {
|
||||
try {
|
||||
@@ -204,7 +237,21 @@ export namespace GooglePlayUtils {
|
||||
}
|
||||
)(options);
|
||||
} catch (error) {
|
||||
if (isUploadStillProcessing(error)) {
|
||||
if (uncertainCommitError && !(error instanceof GooglePlayCommitOutcomeUnknownError)) {
|
||||
// Cancellation or a later authentication failure cannot establish the earlier outcome.
|
||||
throw new GooglePlayCommitOutcomeUnknownError(uncertainCommitError.message, {
|
||||
cause: new AggregateError(
|
||||
[uncertainCommitError, error],
|
||||
'Google Play commit recovery stopped.',
|
||||
{ cause: error }
|
||||
),
|
||||
metadata: uncertainCommitError.metadata,
|
||||
});
|
||||
}
|
||||
if (
|
||||
isUploadStillProcessing(error) &&
|
||||
!(error instanceof GooglePlayCommitOutcomeUnknownError)
|
||||
) {
|
||||
throw new SystemError(
|
||||
`Google Play did not finish processing the uploaded bundle for ${packageName} within five minutes. Wait a few minutes, then check the release at https://play.google.com/console/ before submitting again.`,
|
||||
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
|
||||
@@ -218,13 +265,14 @@ export namespace GooglePlayUtils {
|
||||
// reject its presence, even when false. True saves changes for review in Console.
|
||||
await commitAsync({ changesNotSentForReview: changesNotSentForReview || undefined });
|
||||
} catch (error) {
|
||||
if (!(error instanceof GooglePlayApiError) || error.status !== 400) {
|
||||
const lastError = lastCommitError(error);
|
||||
if (!(lastError instanceof GooglePlayApiError) || lastError.status !== 400) {
|
||||
throw error;
|
||||
}
|
||||
// Like Fastlane, retry only an explicit rejection of the review setting.
|
||||
if (
|
||||
changesNotSentForReview &&
|
||||
error.apiMessage.includes('The query parameter changesNotSentForReview must not be set')
|
||||
lastError.apiMessage.includes('The query parameter changesNotSentForReview must not be set')
|
||||
) {
|
||||
logger.warn(
|
||||
'Google does not allow changesNotSentForReview for this app right now. Retrying commit once without it. The changes will be sent for review.'
|
||||
@@ -232,7 +280,9 @@ export namespace GooglePlayUtils {
|
||||
await commitAsync({});
|
||||
} else if (
|
||||
!changesNotSentForReview &&
|
||||
error.apiMessage.includes('Please set the query parameter changesNotSentForReview to true')
|
||||
lastError.apiMessage.includes(
|
||||
'Please set the query parameter changesNotSentForReview to true'
|
||||
)
|
||||
) {
|
||||
logger.warn(
|
||||
'Google requires changesNotSentForReview=true. Retrying commit once. Send the saved changes for review from Play Console.'
|
||||
|
||||
+78
-1
@@ -84,6 +84,23 @@ it.each([408, 429, 503])('retries a transient HTTP %s commit on the same edit',
|
||||
api().post(`${editPath}:commit`).query({}).reply(200, { id: 'edit' });
|
||||
await expect(commit()).resolves.toBeUndefined();
|
||||
});
|
||||
it('shows the final rejection after a transient commit failure without claiming the outcome is known', async () => {
|
||||
mockToken();
|
||||
api().post(`${editPath}:commit`).query({}).reply(503);
|
||||
const message = 'You must let us know whether your app includes any health features.';
|
||||
api().post(`${editPath}:commit`).query({}).reply(403, { error: { message } });
|
||||
const error = await commit().catch(error => error);
|
||||
const mapped = GooglePlayUtils.mapGooglePlayError(error, packageName);
|
||||
expect(mapped).toBeInstanceOf(GooglePlayCommitOutcomeUnknownError);
|
||||
expect((mapped as Error).message).toContain(`HTTP 403 for the last commit attempt: ${message}`);
|
||||
expect((mapped as Error).message).toContain('may still have saved');
|
||||
expect((mapped as Error).message).toContain('https://play.google.com/console/');
|
||||
expect(mapped).toMatchObject({
|
||||
metadata: { googlePlayErrorResponse: JSON.stringify({ error: { message } }) },
|
||||
cause: expect.any(AggregateError),
|
||||
});
|
||||
expect(((mapped as Error).cause as AggregateError).errors).toHaveLength(2);
|
||||
});
|
||||
it('preserves cancellation before commit is sent', async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('cancelled before commit');
|
||||
@@ -92,6 +109,37 @@ it('preserves cancellation before commit is sent', async () => {
|
||||
});
|
||||
const pendingUploadMessage = 'Some of the Android App Bundle uploads are not completed yet.';
|
||||
|
||||
it.each([
|
||||
[false, pendingUploadMessage, {}],
|
||||
[true, 'The query parameter changesNotSentForReview must not be set', {}],
|
||||
[
|
||||
false,
|
||||
'Please set the query parameter changesNotSentForReview to true',
|
||||
{ changesNotSentForReview: 'true' },
|
||||
],
|
||||
] as const)(
|
||||
'recovers from a wrapped rejection: %s %s',
|
||||
async (changesNotSentForReview, message, retryQuery) => {
|
||||
mockToken();
|
||||
const query = changesNotSentForReview ? { changesNotSentForReview: 'true' } : {};
|
||||
api().post(`${editPath}:commit`).query(query).reply(503);
|
||||
api().post(`${editPath}:commit`).query(query).reply(400, { error: { message } });
|
||||
api().post(`${editPath}:commit`).query(retryQuery).reply(200, { id: 'edit' });
|
||||
await expect(commit({ changesNotSentForReview })).resolves.toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps an uncertain outcome when processing recovery ends in another rejection', async () => {
|
||||
mockToken();
|
||||
api().post(`${editPath}:commit`).query({}).reply(503);
|
||||
api()
|
||||
.post(`${editPath}:commit`)
|
||||
.query({})
|
||||
.reply(400, { error: { message: pendingUploadMessage } });
|
||||
api().post(`${editPath}:commit`).query({}).reply(404);
|
||||
await expect(commit()).rejects.toBeInstanceOf(GooglePlayCommitOutcomeUnknownError);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'waits for bundle processing without changing review settings (%s)',
|
||||
async changesNotSentForReview => {
|
||||
@@ -110,6 +158,17 @@ it.each([false, true])(
|
||||
}
|
||||
);
|
||||
|
||||
it('preserves uncertainty when wrapped processing retries are exhausted', async () => {
|
||||
mockToken();
|
||||
api().post(`${editPath}:commit`).query({}).reply(503);
|
||||
api()
|
||||
.post(`${editPath}:commit`)
|
||||
.query({})
|
||||
.times(14)
|
||||
.reply(400, { error: { message: pendingUploadMessage } });
|
||||
await expect(commit()).rejects.toBeInstanceOf(GooglePlayCommitOutcomeUnknownError);
|
||||
});
|
||||
|
||||
it('stops waiting for bundle processing after 14 attempts', async () => {
|
||||
mockToken();
|
||||
api()
|
||||
@@ -250,7 +309,9 @@ it('keeps the last Google Play error response when the commit outcome is unknown
|
||||
.query({})
|
||||
.times(6)
|
||||
.reply(503, { error: { message: 'Backend error' } });
|
||||
await expect(commit()).rejects.toMatchObject({
|
||||
const error = await commit().catch(error => error);
|
||||
expect(error.message).not.toContain('last commit attempt');
|
||||
expect(error).toMatchObject({
|
||||
metadata: {
|
||||
googlePlayRequestPath: `${editPath}:commit`,
|
||||
googlePlayErrorResponse: JSON.stringify({ error: { message: 'Backend error' } }),
|
||||
@@ -269,3 +330,19 @@ it('does not keep the upload session query or a non-JSON response', async () =>
|
||||
googlePlayErrorResponse: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves an uncertain commit if processing recovery is cancelled', async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('cancelled after uncertain commit');
|
||||
const logger = createMockLogger();
|
||||
jest.spyOn(logger, 'warn').mockImplementation(() => controller.abort(reason));
|
||||
mockToken();
|
||||
api().post(`${editPath}:commit`).query({}).reply(503);
|
||||
api()
|
||||
.post(`${editPath}:commit`)
|
||||
.query({})
|
||||
.reply(400, { error: { message: pendingUploadMessage } });
|
||||
await expect(commit({ signal: controller.signal, logger })).rejects.toBeInstanceOf(
|
||||
GooglePlayCommitOutcomeUnknownError
|
||||
);
|
||||
});
|
||||
@@ -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',
|
||||
'',
|
||||
|
||||
@@ -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,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 ?? {};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+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,7 +92,7 @@ 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;
|
||||
|
||||
@@ -9,7 +9,10 @@ import { fetchUserAsync } from './fetchUser';
|
||||
import { ApiV2Error } from '../ApiV2Error';
|
||||
import { AnalyticsWithOrchestration } from '../analytics/AnalyticsManager';
|
||||
import { ApiV2Client } from '../api';
|
||||
import { createGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
ExpoGraphqlClient,
|
||||
createGraphqlClient,
|
||||
} from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { CurrentUserQuery } from '../graphql/generated';
|
||||
import { UserQuery } from '../graphql/queries/UserQuery';
|
||||
import Log, { learnMore } from '../log';
|
||||
@@ -43,6 +46,7 @@ type Actor = NonNullable<CurrentUserQuery['meActor']>;
|
||||
|
||||
export default class SessionManager {
|
||||
private currentActor: Actor | undefined;
|
||||
private graphqlClient: { credential: string | null; client: ExpoGraphqlClient } | undefined;
|
||||
|
||||
constructor(private readonly analytics: AnalyticsWithOrchestration) {}
|
||||
|
||||
@@ -87,6 +91,21 @@ export default class SessionManager {
|
||||
await this.setSessionAsync(undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns one GraphQL client per credential, so context fields of one command share
|
||||
* the urql cache. For example, the project ID check and the command both query the app.
|
||||
*/
|
||||
public getGraphqlClient(authenticationInfo: {
|
||||
accessToken: string | null;
|
||||
sessionSecret: string | null;
|
||||
}): ExpoGraphqlClient {
|
||||
const credential = authenticationInfo.accessToken ?? authenticationInfo.sessionSecret;
|
||||
if (this.graphqlClient?.credential !== credential) {
|
||||
this.graphqlClient = { credential, client: createGraphqlClient(authenticationInfo) };
|
||||
}
|
||||
return this.graphqlClient.client;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param requestTimeoutMs caps the actor lookup when no actor is cached yet. Callers that must
|
||||
* not delay a command past a deadline pass it; the default has no timeout. A capped lookup
|
||||
|
||||
@@ -5,7 +5,10 @@ import path from 'path';
|
||||
import { ApiV2Error } from '../../ApiV2Error';
|
||||
import { AnalyticsWithOrchestration } from '../../analytics/AnalyticsManager';
|
||||
import { ApiV2Client } from '../../api';
|
||||
import { createGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
ExpoGraphqlClient,
|
||||
createGraphqlClient,
|
||||
} from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { UserQuery } from '../../graphql/queries/UserQuery';
|
||||
import Log from '../../log';
|
||||
import { promptAsync } from '../../prompts';
|
||||
@@ -113,6 +116,28 @@ describe(SessionManager, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGraphqlClient', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(createGraphqlClient).mockImplementation(() => ({}) as ExpoGraphqlClient);
|
||||
});
|
||||
|
||||
it('returns the same client for the same credential', () => {
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
const client = sessionManager.getGraphqlClient({ accessToken: 'token', sessionSecret: null });
|
||||
expect(
|
||||
sessionManager.getGraphqlClient({ accessToken: 'token', sessionSecret: 'secret' })
|
||||
).toBe(client);
|
||||
});
|
||||
|
||||
it('returns a new client when the credential changes', () => {
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
const client = sessionManager.getGraphqlClient({ accessToken: null, sessionSecret: 'a' });
|
||||
expect(sessionManager.getGraphqlClient({ accessToken: null, sessionSecret: 'b' })).not.toBe(
|
||||
client
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserAsync', () => {
|
||||
it('skips fetching user without access token or session secret', async () => {
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
|
||||
@@ -202,11 +202,8 @@ export class BuildStepGlobalContext {
|
||||
return new BuildStepContext(this, options);
|
||||
}
|
||||
|
||||
public markAsCheckedOut(logger: bunyan): void {
|
||||
public markAsCheckedOut(): void {
|
||||
this.didCheckOut = true;
|
||||
logger.info(
|
||||
`Changing default working directory to ${this.defaultWorkingDirectory} (was ${this.projectTargetDirectory})`
|
||||
);
|
||||
}
|
||||
|
||||
public get hasAnyPreviousStepFailed(): boolean {
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
parseLocalCompositeFunctionPath,
|
||||
} from './utils/localCompositeFunctions';
|
||||
|
||||
type ValidatedHooks = ReadonlyMap<HookKey, { anchorId: HookAnchorId; steps: Step[] }>;
|
||||
type ValidatedHooks = ReadonlyMap<HookKey, Step[]>;
|
||||
|
||||
export class StepsConfigParser extends AbstractConfigParser {
|
||||
private readonly steps: Step[];
|
||||
@@ -114,7 +114,6 @@ export class StepsConfigParser extends AbstractConfigParser {
|
||||
// step ids identical across the splicing→engine rollout.
|
||||
const buildSteps: BuildStep[] = [];
|
||||
const hooksByAnchorStep = new Map<BuildStep, AnchorHooks>();
|
||||
const seenAnchorIds = new Set<HookAnchorId>();
|
||||
|
||||
for (const stepConfig of validatedSteps) {
|
||||
const maybeFunctionGroup =
|
||||
@@ -135,7 +134,6 @@ export class StepsConfigParser extends AbstractConfigParser {
|
||||
if (anchorId === undefined) {
|
||||
continue;
|
||||
}
|
||||
seenAnchorIds.add(anchorId);
|
||||
const anchorHooks = await this.constructAnchorHooksAsync(
|
||||
anchorId,
|
||||
validatedHooks,
|
||||
@@ -162,7 +160,6 @@ export class StepsConfigParser extends AbstractConfigParser {
|
||||
'Hook anchors are not supported on local composite function steps.'
|
||||
);
|
||||
}
|
||||
seenAnchorIds.add(anchorId);
|
||||
const before = await this.constructHookSideEntriesAsync(
|
||||
anchorId,
|
||||
'before',
|
||||
@@ -190,14 +187,6 @@ export class StepsConfigParser extends AbstractConfigParser {
|
||||
}
|
||||
}
|
||||
|
||||
for (const [hookKey, { anchorId }] of validatedHooks) {
|
||||
if (!seenAnchorIds.has(anchorId)) {
|
||||
this.ctx.baseLogger.warn(
|
||||
`Ignoring "hooks.${hookKey}": this build does not run the "${anchorId}" step.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
buildSteps,
|
||||
buildFunctionById,
|
||||
@@ -206,14 +195,13 @@ export class StepsConfigParser extends AbstractConfigParser {
|
||||
}
|
||||
|
||||
private validateHooks(): ValidatedHooks {
|
||||
const validatedHooks = new Map<HookKey, { anchorId: HookAnchorId; steps: Step[] }>();
|
||||
const validatedHooks = new Map<HookKey, Step[]>();
|
||||
for (const [hookKey, hookSteps] of Object.entries(this.hooks)) {
|
||||
// A worker must not fail on a hook key newer than itself, so unregistered
|
||||
// keys skip validation entirely (their steps may reference functions this
|
||||
// worker lacks).
|
||||
const parsed = parseHookKey(hookKey);
|
||||
if (parsed === null) {
|
||||
this.ctx.baseLogger.warn(`Ignoring unknown hook key "${hookKey}".`);
|
||||
continue;
|
||||
}
|
||||
// An empty array is a deliberate no-op (e.g. opting out of a default);
|
||||
@@ -223,10 +211,7 @@ export class StepsConfigParser extends AbstractConfigParser {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
validatedHooks.set(`${parsed.side}_${parsed.anchorId}`, {
|
||||
anchorId: parsed.anchorId,
|
||||
steps: validateSteps(hookSteps),
|
||||
});
|
||||
validatedHooks.set(`${parsed.side}_${parsed.anchorId}`, validateSteps(hookSteps));
|
||||
} catch (err) {
|
||||
throw new BuildConfigError(
|
||||
`Invalid steps in "hooks.${hookKey}": ${err instanceof Error ? err.message : String(err)}`
|
||||
@@ -286,7 +271,7 @@ export class StepsConfigParser extends AbstractConfigParser {
|
||||
validatedHooks: ValidatedHooks,
|
||||
compositeFunctionExpander: CompositeFunctionExpander
|
||||
): Promise<HookEntry[]> {
|
||||
const hookSteps = validatedHooks.get(`${side}_${anchorId}`)?.steps;
|
||||
const hookSteps = validatedHooks.get(`${side}_${anchorId}`);
|
||||
if (hookSteps === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ describe(BuildStep, () => {
|
||||
projectTargetDirectory: '/a/b',
|
||||
relativeWorkingDirectory: 'c',
|
||||
});
|
||||
ctx.markAsCheckedOut(ctx.baseLogger);
|
||||
ctx.markAsCheckedOut();
|
||||
|
||||
const id = 'test1';
|
||||
const command = 'ls -la';
|
||||
@@ -150,7 +150,7 @@ describe(BuildStep, () => {
|
||||
projectTargetDirectory: '/a/b',
|
||||
relativeWorkingDirectory: 'c',
|
||||
});
|
||||
ctx.markAsCheckedOut(ctx.baseLogger);
|
||||
ctx.markAsCheckedOut();
|
||||
|
||||
const id = 'test1';
|
||||
const command = 'ls -la';
|
||||
|
||||
@@ -65,7 +65,7 @@ describe(BuildStepGlobalContext, () => {
|
||||
),
|
||||
false
|
||||
);
|
||||
ctx.markAsCheckedOut(ctx.baseLogger);
|
||||
ctx.markAsCheckedOut();
|
||||
expect(ctx.defaultWorkingDirectory).toBe(workingDirectory);
|
||||
});
|
||||
});
|
||||
@@ -136,7 +136,7 @@ describe(BuildStepGlobalContext, () => {
|
||||
},
|
||||
createMockLogger()
|
||||
);
|
||||
ctx.markAsCheckedOut(ctx.baseLogger);
|
||||
ctx.markAsCheckedOut();
|
||||
expect(ctx.stepsInternalBuildDirectory).toBe('/m/n/o');
|
||||
expect(ctx.defaultWorkingDirectory).toBe('/g/h/i');
|
||||
expect(ctx.runtimePlatform).toBe(BuildRuntimePlatform.DARWIN);
|
||||
@@ -199,7 +199,7 @@ describe(BuildStepGlobalContext, () => {
|
||||
const ctx = createGlobalContextMock({
|
||||
relativeWorkingDirectory: 'apps/mobile',
|
||||
});
|
||||
ctx.markAsCheckedOut(ctx.baseLogger);
|
||||
ctx.markAsCheckedOut();
|
||||
|
||||
const relativeChildCtx = ctx.stepCtx({
|
||||
relativeWorkingDirectory: 'scripts',
|
||||
@@ -230,7 +230,7 @@ describe(BuildStepGlobalContext, () => {
|
||||
projectTargetDirectory: tempDir,
|
||||
relativeWorkingDirectory: '',
|
||||
});
|
||||
ctx.markAsCheckedOut(ctx.baseLogger);
|
||||
ctx.markAsCheckedOut();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -283,9 +283,6 @@ describe('StepsConfigParser hook construction', () => {
|
||||
});
|
||||
expect(orderedDisplayNames(workflow)).toEqual(['Checkout']);
|
||||
expect(workflow.hooksByAnchorStep.size).toBe(0);
|
||||
expect(ctx.baseLogger.warn).toHaveBeenCalledWith(
|
||||
'Ignoring "hooks.before_install_node_modules": this build does not run the "install_node_modules" step.'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a hook step using a composite function missing from the catalog with BuildConfigError', async () => {
|
||||
@@ -858,26 +855,17 @@ describe('StepsConfigParser lazy hook composite loading', () => {
|
||||
expect(orderedDisplayNames(workflow)).toEqual(['Checkout']);
|
||||
});
|
||||
|
||||
it('warns for a registered hook key whose anchor never appeared', async () => {
|
||||
await parseWorkflowAsync({
|
||||
it('ignores unknown and unrun hook keys without warning', async () => {
|
||||
const workflow = await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'eas/checkout' }],
|
||||
hooks: { before_install_node_modules: [{ run: 'echo never' }] },
|
||||
hooks: {
|
||||
before_install_node_modules: [{ run: 'echo never' }],
|
||||
before_install_node_module: [{ run: 'echo typo' }],
|
||||
},
|
||||
});
|
||||
expect(ctx.baseLogger.warn).toHaveBeenCalledWith(
|
||||
'Ignoring "hooks.before_install_node_modules": this build does not run the "install_node_modules" step.'
|
||||
);
|
||||
});
|
||||
|
||||
it('warns for an unknown hook key', async () => {
|
||||
await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'eas/checkout' }],
|
||||
hooks: { before_install_node_module: [{ run: 'echo typo' }] },
|
||||
});
|
||||
expect(ctx.baseLogger.warn).toHaveBeenCalledWith(
|
||||
'Ignoring unknown hook key "before_install_node_module".'
|
||||
);
|
||||
expect(workflow.hooksByAnchorStep.size).toBe(0);
|
||||
expect(ctx.baseLogger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wraps a loader failure in a BuildConfigError naming the before-side hook key', async () => {
|
||||
|
||||
@@ -42,18 +42,3 @@ Github action builds a docker image for every PR with any change in `infra/eas-b
|
||||
### VM template
|
||||
|
||||
Github action builds a new vm template for every PR with any change in `infra/eas-build-worker-images/ios/{{ templateName }}`.
|
||||
|
||||
### Packaged startup
|
||||
|
||||
Existing services run `src/services/worker/dist/main.js`. In the worker archive,
|
||||
that path points to `bin/bootstrap.cjs`. The bootstrap loads only Node built-ins
|
||||
and starts `bin/start-worker.sh`. The shell then uses `exec` to start the normal
|
||||
worker with the original Node binary, options, and arguments.
|
||||
|
||||
Put setup that must precede worker imports or Node startup options in the shell.
|
||||
The bootstrap forwards SIGINT, SIGTERM, IPC messages, and the worker exit status.
|
||||
It remains the supervised parent process; test service shutdown on both platforms
|
||||
before deployment. A failure in the startup shell prevents the worker from
|
||||
loading. Direct execution of `packages/worker/dist/main.js` bypasses this hook.
|
||||
|
||||
Run `yarn workspace @expo/worker test:startup` for the process-level checks.
|
||||
@@ -1,53 +0,0 @@
|
||||
// This entry point must only load Node built-ins before the startup shell runs.
|
||||
const { spawn } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
|
||||
const worker = spawn(
|
||||
'/bin/bash',
|
||||
[
|
||||
path.join(__dirname, 'start-worker.sh'),
|
||||
process.execPath,
|
||||
...process.execArgv,
|
||||
path.join(__dirname, '../dist/main.js'),
|
||||
...process.argv.slice(2),
|
||||
],
|
||||
{ stdio: process.send ? ['inherit', 'inherit', 'inherit', 'ipc'] : 'inherit' }
|
||||
);
|
||||
|
||||
const signals = ['SIGINT', 'SIGTERM'];
|
||||
for (const signal of signals) {
|
||||
process.on(signal, () => worker.kill(signal));
|
||||
}
|
||||
|
||||
// Retain the readiness channel used by process managers in development.
|
||||
if (process.send) {
|
||||
worker.on('message', message => {
|
||||
if (process.connected) {
|
||||
process.send(message, () => {});
|
||||
}
|
||||
});
|
||||
process.on('message', message => {
|
||||
if (worker.connected) {
|
||||
worker.send(message, () => {});
|
||||
}
|
||||
});
|
||||
process.on('disconnect', () => {
|
||||
if (worker.connected) {
|
||||
worker.disconnect();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
worker.on('error', error => {
|
||||
process.stderr.write(`Worker startup failed: ${error.stack ?? error}\n`, () => process.exit(1));
|
||||
});
|
||||
worker.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
for (const forwarded of signals) {
|
||||
process.removeAllListeners(forwarded);
|
||||
}
|
||||
process.kill(process.pid, signal);
|
||||
} else {
|
||||
process.exit(code ?? 1);
|
||||
}
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Startup setup belongs here, before the normal worker's Node process starts.
|
||||
exec "$@"
|
||||
@@ -11,12 +11,11 @@
|
||||
"compile": "tsc -p tsconfig.build.json",
|
||||
"prepublishOnly": "npm run build",
|
||||
"test": "yarn test:unit",
|
||||
"test:unit": "yarn test:startup && ./resources/jest --config jest.config.unit.ts",
|
||||
"test:unit": "./resources/jest --config jest.config.unit.ts",
|
||||
"test:unit:watch": "./resources/jest --config jest.config.unit.ts --watch",
|
||||
"test:integration": "./resources/jest --runInBand --config jest.config.integration.ts",
|
||||
"test:integration:watch": "./resources/jest --runInBand --config jest.config.integration.ts --watchAll",
|
||||
"typecheck": "tsc",
|
||||
"test:startup": "node --test tests/*.test.cjs"
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
|
||||
@@ -120,10 +120,10 @@ fi
|
||||
|
||||
# Create backward-compatible symlink for orchestrator
|
||||
# The orchestrator expects ./src/services/worker/dist/main.js
|
||||
# Route it through the packaged startup wrapper before loading worker modules.
|
||||
# but the new structure has ./packages/worker/dist/main.js
|
||||
# We're only symlinking the specific file to discover any other legacy path usage
|
||||
mkdir -p "$target_root_dir/src/services/worker/dist"
|
||||
ln -s ../../../../packages/worker/bin/bootstrap.cjs "$target_root_dir/src/services/worker/dist/main.js"
|
||||
ln -s ../../../../packages/worker/dist/main.js "$target_root_dir/src/services/worker/dist/main.js"
|
||||
|
||||
tar zcf $OUTPUT_FILE -C $target_root_dir .
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import config from '../config';
|
||||
import { createBuildContext } from '../context';
|
||||
import { getBuildEnv } from '../env';
|
||||
import { prepareRuntimeEnvironmentConfigFiles } from '../runtimeEnvironment';
|
||||
import { uploadSourceMapAsync } from '../upload';
|
||||
import { uploadSourceMapAsync, uploadWorkflowArtifactAsync } from '../upload';
|
||||
|
||||
jest.mock('../env', () => ({
|
||||
getBuildEnv: jest.fn(() => ({
|
||||
@@ -18,6 +18,7 @@ jest.mock('../runtimeEnvironment', () => ({
|
||||
}));
|
||||
jest.mock('../upload', () => ({
|
||||
...jest.requireActual('../upload'),
|
||||
uploadWorkflowArtifactAsync: jest.fn(async () => ({ artifactId: 'artifact-id' })),
|
||||
uploadSourceMapAsync: jest.fn(async () => ({ filename: null })),
|
||||
uploadWithAnalyticsAsync: jest.fn(async fn => await fn()),
|
||||
}));
|
||||
@@ -129,4 +130,28 @@ describe(createBuildContext.name, () => {
|
||||
logger: childLogger,
|
||||
});
|
||||
});
|
||||
it('uploads named artifacts to workflow storage and returns their ID', async () => {
|
||||
const ctx = await createBuildContext({
|
||||
...baseOptions,
|
||||
job: {
|
||||
type: Workflow.GENERIC,
|
||||
secrets: { environmentSecrets: [] },
|
||||
} as any,
|
||||
});
|
||||
const metadata = { kind: 'maestro-report' };
|
||||
|
||||
const result = await ctx.uploadArtifact({
|
||||
artifact: { type: null, name: 'Report', paths: ['/workingdir/report'], metadata },
|
||||
logger: childLogger,
|
||||
});
|
||||
|
||||
expect(uploadWorkflowArtifactAsync).toHaveBeenCalledWith(ctx, {
|
||||
artifactPaths: ['/workingdir/report'],
|
||||
name: 'Report',
|
||||
metadata,
|
||||
logger: childLogger,
|
||||
});
|
||||
expect(result).toEqual({ artifactId: 'artifact-id' });
|
||||
expect(ctx.artifacts).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { once } = require('node:events');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
function archive(t, main, shell) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'worker-startup-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const worker = path.join(root, 'packages/worker');
|
||||
fs.mkdirSync(path.join(worker, 'dist'), { recursive: true });
|
||||
fs.cpSync(path.join(__dirname, '../bin'), path.join(worker, 'bin'), { recursive: true });
|
||||
fs.writeFileSync(path.join(worker, 'dist/main.js'), main);
|
||||
if (shell) {
|
||||
fs.writeFileSync(path.join(worker, 'bin/start-worker.sh'), shell);
|
||||
}
|
||||
// Exercise the path that existing images run, including Node's symlink resolution.
|
||||
const legacy = path.join(root, 'src/services/worker/dist/main.js');
|
||||
fs.mkdirSync(path.dirname(legacy), { recursive: true });
|
||||
fs.symlinkSync('../../../../packages/worker/bin/bootstrap.cjs', legacy);
|
||||
return legacy;
|
||||
}
|
||||
|
||||
function launch(t, entry, args = []) {
|
||||
const child = spawn(process.execPath, [entry, ...args], {
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||||
});
|
||||
t.after(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
});
|
||||
return child;
|
||||
}
|
||||
|
||||
test(
|
||||
'startup settings reach the worker before its first module runs',
|
||||
{ timeout: 10000 },
|
||||
async t => {
|
||||
const entry = archive(
|
||||
t,
|
||||
'process.send({ ready: true, args: process.argv.slice(2), setting: process.env.STARTUP_TEST }); process.on("message", () => process.exit(7));',
|
||||
'#!/bin/bash\nset -euo pipefail\nexport STARTUP_TEST=installed\nexec "$@"\n'
|
||||
);
|
||||
const child = launch(t, entry, ['argument with spaces']);
|
||||
const exited = once(child, 'exit');
|
||||
assert.deepEqual((await once(child, 'message'))[0], {
|
||||
ready: true,
|
||||
args: ['argument with spaces'],
|
||||
setting: 'installed',
|
||||
});
|
||||
child.send('stop');
|
||||
assert.deepEqual(await exited, [7, null]);
|
||||
}
|
||||
);
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
test(`forwards ${signal} and preserves termination status`, { timeout: 10000 }, async t => {
|
||||
const entry = archive(t, 'process.send("ready"); setInterval(() => {}, 1000);');
|
||||
const child = launch(t, entry);
|
||||
const exited = once(child, 'exit');
|
||||
await once(child, 'message');
|
||||
child.kill(signal);
|
||||
assert.deepEqual(await exited, [null, signal]);
|
||||
});
|
||||
}
|
||||
|
||||
test('startup failure prevents loading the worker', { timeout: 10000 }, async t => {
|
||||
const entry = archive(
|
||||
t,
|
||||
'process.send("must not start");',
|
||||
'#!/bin/bash\nset -euo pipefail\nexit 42\nexec "$@"\n'
|
||||
);
|
||||
const child = launch(t, entry);
|
||||
const messages = [];
|
||||
child.on('message', message => messages.push(message));
|
||||
assert.deepEqual(await once(child, 'exit'), [42, null]);
|
||||
assert.deepEqual(messages, []);
|
||||
});
|
||||
|
||||
test('runs without IPC and retains stdout, stderr, and exit code', { timeout: 10000 }, async t => {
|
||||
const entry = archive(
|
||||
t,
|
||||
'console.log("worker output"); console.error("worker error"); process.exit(9);'
|
||||
);
|
||||
const child = spawn(process.execPath, [entry], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', data => {
|
||||
stdout += data;
|
||||
});
|
||||
child.stderr.on('data', data => {
|
||||
stderr += data;
|
||||
});
|
||||
assert.deepEqual(await once(child, 'close'), [9, null]);
|
||||
assert.equal(stdout, 'worker output\n');
|
||||
assert.equal(stderr, 'worker error\n');
|
||||
});
|
||||
Reference in new issue
Block a user