Compare commits

...
Author SHA1 Message Date
Gabe Debes 8e343fff84 [eas-cli] Update CHANGELOG with PR #3699 2026-05-08 09:37:21 -07:00
Gabe Debes 7a40d10ca7 [eas-cli] Add eas update:upload-embedded command 2026-05-08 09:36:09 -07:00
7 changed files with 748 additions and 0 deletions

No files matched your search

+2
View File
@@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Add `eas update:upload-embedded` command to upload the JS bundle embedded in a native build so EAS Update can generate bsdiff patches against it. ([#3699](https://github.com/expo/eas-cli/pull/3699) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Update the minimum Expo version required for iOS precompiled modules. ([#3677](https://github.com/expo/eas-cli/pull/3677) by [@sjchmiela](https://github.com/sjchmiela))
@@ -0,0 +1,310 @@
import { Platform } from '@expo/eas-build-job';
import { Updates } from '@expo/config-plugins';
import { vol } from 'memfs';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { ChannelNotFoundError } from '../../../channel/errors';
import { EmbeddedUpdateAssetMutation } from '../../../graphql/mutations/EmbeddedUpdateAssetMutation';
import {
EmbeddedUpdateMutation,
isEmbeddedUpdateAssetNotReadyError,
isEmbeddedUpdateConflictError,
} from '../../../graphql/mutations/EmbeddedUpdateMutation';
import { AppPlatform } from '../../../graphql/generated';
import { ChannelQuery } from '../../../graphql/queries/ChannelQuery';
import Log from '../../../log';
import * as uploads from '../../../uploads';
import * as promise from '../../../utils/promise';
import UpdateUploadEmbedded from '../upload-embedded';
jest.mock('fs', () => jest.requireActual('memfs').fs);
jest.mock('@expo/config-plugins', () => ({
Updates: { getRuntimeVersionNullableAsync: jest.fn() },
}));
jest.mock('../../../graphql/mutations/EmbeddedUpdateAssetMutation', () => ({
EmbeddedUpdateAssetMutation: { getSignedUploadSpecAsync: jest.fn() },
}));
jest.mock('../../../graphql/mutations/EmbeddedUpdateMutation', () => ({
EmbeddedUpdateMutation: { uploadEmbeddedUpdateAsync: jest.fn() },
isEmbeddedUpdateAssetNotReadyError: jest.fn(),
isEmbeddedUpdateConflictError: jest.fn(),
}));
jest.mock('../../../graphql/queries/ChannelQuery', () => ({
ChannelQuery: { viewUpdateChannelBasicInfoAsync: jest.fn() },
}));
jest.mock('../../../uploads');
jest.mock('../../../log');
jest.mock('../../../utils/promise', () => ({
sleepAsync: jest.fn().mockResolvedValue(undefined),
}));
const mockGetRuntimeVersion = jest.mocked(Updates.getRuntimeVersionNullableAsync);
const mockGetSignedUploadSpec = jest.mocked(EmbeddedUpdateAssetMutation.getSignedUploadSpecAsync);
const mockViewChannel = jest.mocked(ChannelQuery.viewUpdateChannelBasicInfoAsync);
const mockUpload = jest.mocked(uploads.uploadWithPresignedPostWithRetryAsync);
const mockLogLog = jest.mocked(Log.log);
const mockUploadEmbeddedUpdate = jest.mocked(EmbeddedUpdateMutation.uploadEmbeddedUpdateAsync);
const mockSleepAsync = jest.mocked(promise.sleepAsync);
const mockIsEmbeddedUpdateAssetNotReadyError = jest.mocked(isEmbeddedUpdateAssetNotReadyError);
const mockIsEmbeddedUpdateConflictError = jest.mocked(isEmbeddedUpdateConflictError);
const BUNDLE_PATH = '/project/app.bundle';
const MANIFEST_PATH = '/project/app.manifest';
const VALID_UUID = 'a1b2c3d4-1234-4000-8000-000000000000';
const VALID_MANIFEST = JSON.stringify({ id: VALID_UUID });
const BASE_ARGV = [
'--platform',
Platform.IOS,
'--bundle',
BUNDLE_PATH,
'--manifest',
MANIFEST_PATH,
'--channel',
'production',
];
const MOCK_CONTEXT = {
loggedIn: { graphqlClient: {} as ExpoGraphqlClient },
privateProjectConfig: {
projectId: 'project-123',
exp: { name: 'test', slug: 'test' },
projectDir: '/project',
},
};
const MOCK_EMBEDDED_UPDATE = {
id: 'embedded-update-id-abc',
platform: AppPlatform.Ios,
runtimeVersion: '1.0.0',
channelId: 'channel-id-123',
createdAt: '2024-01-01T00:00:00Z',
};
describe(UpdateUploadEmbedded, () => {
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
vol.reset();
vol.fromJSON({
[BUNDLE_PATH]: 'bundle-bytes',
[MANIFEST_PATH]: VALID_MANIFEST,
});
mockGetRuntimeVersion.mockResolvedValue('1.0.0');
mockViewChannel.mockResolvedValue({ id: 'channel-id-123', name: 'production' } as any);
mockGetSignedUploadSpec.mockResolvedValue({
storageKey: 'storage-key-abc',
presignedUrl: 'https://storage.googleapis.com/upload-bucket',
fields: { key: 'obj-key', policy: 'abc123' },
});
mockUpload.mockResolvedValue(undefined as any);
mockUploadEmbeddedUpdate.mockResolvedValue(MOCK_EMBEDDED_UPDATE);
mockIsEmbeddedUpdateAssetNotReadyError.mockReturnValue(false);
mockIsEmbeddedUpdateConflictError.mockReturnValue(false);
});
function createCommand(argv: string[]): UpdateUploadEmbedded {
const command = new UpdateUploadEmbedded(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue(MOCK_CONTEXT);
return command;
}
describe('file existence checks', () => {
it('runs successfully when both files exist', async () => {
const command = createCommand(BASE_ARGV);
await command.runAsync();
expect(mockLogLog).toHaveBeenCalledWith(expect.stringContaining('production'));
});
it('errors with message when bundle file does not exist', async () => {
vol.reset();
vol.fromJSON({ [MANIFEST_PATH]: VALID_MANIFEST });
const command = createCommand(BASE_ARGV);
await expect(command.runAsync()).rejects.toThrow(/Bundle file not found/);
});
it('errors with message when manifest file does not exist', async () => {
vol.reset();
vol.fromJSON({ [BUNDLE_PATH]: 'bundle-bytes' });
const command = createCommand(BASE_ARGV);
await expect(command.runAsync()).rejects.toThrow(/Manifest file not found/);
});
it('includes build-id in log when provided', async () => {
const command = createCommand([...BASE_ARGV, '--build-id', 'build-uuid-123']);
await command.runAsync();
expect(mockLogLog).toHaveBeenCalledWith(expect.stringContaining('build-uuid-123'));
});
it('accepts android platform', async () => {
const argv = [...BASE_ARGV];
argv[1] = Platform.ANDROID;
const command = createCommand(argv);
await command.runAsync();
expect(mockLogLog).toHaveBeenCalledWith(expect.stringContaining(Platform.ANDROID));
});
});
describe('manifest parsing', () => {
it('reads embeddedUpdateId from manifest and passes platform directly to getRuntimeVersion', async () => {
const command = createCommand(BASE_ARGV);
await command.runAsync();
expect(mockGetRuntimeVersion).toHaveBeenCalledWith(
MOCK_CONTEXT.privateProjectConfig.projectDir,
MOCK_CONTEXT.privateProjectConfig.exp,
Platform.IOS
);
});
it('errors when manifest contains invalid JSON', async () => {
vol.fromJSON({ [MANIFEST_PATH]: 'not-json', [BUNDLE_PATH]: 'bytes' });
const command = createCommand(BASE_ARGV);
await expect(command.runAsync()).rejects.toThrow(/not valid JSON/);
});
it('errors when manifest id is missing', async () => {
vol.fromJSON({ [MANIFEST_PATH]: JSON.stringify({ noId: true }), [BUNDLE_PATH]: 'bytes' });
const command = createCommand(BASE_ARGV);
await expect(command.runAsync()).rejects.toThrow(/"id" field/);
});
it('errors when manifest id is not a valid UUID', async () => {
vol.fromJSON({ [MANIFEST_PATH]: JSON.stringify({ id: 'not-a-uuid' }), [BUNDLE_PATH]: 'bytes' });
const command = createCommand(BASE_ARGV);
await expect(command.runAsync()).rejects.toThrow(/is not a UUID/);
});
it('errors when runtimeVersion cannot be resolved', async () => {
mockGetRuntimeVersion.mockResolvedValue(null);
const command = createCommand(BASE_ARGV);
await expect(command.runAsync()).rejects.toThrow(/runtimeVersion/);
});
});
describe('channel resolution', () => {
it('resolves channel by name and app id', async () => {
const command = createCommand(BASE_ARGV);
await command.runAsync();
expect(mockViewChannel).toHaveBeenCalledWith(
MOCK_CONTEXT.loggedIn.graphqlClient,
{ appId: MOCK_CONTEXT.privateProjectConfig.projectId, channelName: 'production' }
);
});
it('propagates ChannelNotFoundError when channel does not exist', async () => {
mockViewChannel.mockRejectedValue(new ChannelNotFoundError('Channel not found'));
const command = createCommand(BASE_ARGV);
await expect(command.runAsync()).rejects.toThrow(ChannelNotFoundError);
});
});
describe('bundle upload', () => {
it('requests a presigned URL and uploads the bundle', async () => {
const command = createCommand(BASE_ARGV);
await command.runAsync();
expect(mockGetSignedUploadSpec).toHaveBeenCalledWith(
MOCK_CONTEXT.loggedIn.graphqlClient,
expect.objectContaining({
appId: MOCK_CONTEXT.privateProjectConfig.projectId,
contentType: 'application/javascript',
})
);
expect(mockUpload).toHaveBeenCalledWith(
BUNDLE_PATH,
{ url: 'https://storage.googleapis.com/upload-bucket', fields: { key: 'obj-key', policy: 'abc123' } },
expect.any(Function)
);
});
it('propagates upload errors', async () => {
mockUpload.mockRejectedValue(new Error('upload failed'));
const command = createCommand(BASE_ARGV);
await expect(command.runAsync()).rejects.toThrow('upload failed');
});
});
describe('mutation registration', () => {
it('calls uploadEmbeddedUpdateAsync with all collected inputs including AppPlatform enum', async () => {
const command = createCommand(BASE_ARGV);
await command.runAsync();
expect(mockUploadEmbeddedUpdate).toHaveBeenCalledWith(
MOCK_CONTEXT.loggedIn.graphqlClient,
expect.objectContaining({
appId: MOCK_CONTEXT.privateProjectConfig.projectId,
platform: AppPlatform.Ios,
runtimeVersion: '1.0.0',
channelId: 'channel-id-123',
embeddedUpdateId: VALID_UUID,
launchAssetStorageKey: 'storage-key-abc',
})
);
});
it('logs the embedded update id on success', async () => {
const command = createCommand(BASE_ARGV);
await command.runAsync();
expect(mockLogLog).toHaveBeenCalledWith(
expect.stringContaining(MOCK_EMBEDDED_UPDATE.id)
);
});
it('passes build-id as turtleBuildId when provided', async () => {
const command = createCommand([...BASE_ARGV, '--build-id', 'turtle-build-xyz']);
await command.runAsync();
expect(mockUploadEmbeddedUpdate).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ turtleBuildId: 'turtle-build-xyz' })
);
});
it('retries on ASSET_NOT_READY: succeeds on second attempt, sleeps once with first delay', async () => {
const assetNotReadyError = new Error('asset not ready');
mockIsEmbeddedUpdateAssetNotReadyError.mockImplementation(e => e === assetNotReadyError);
mockUploadEmbeddedUpdate
.mockRejectedValueOnce(assetNotReadyError)
.mockResolvedValueOnce(MOCK_EMBEDDED_UPDATE);
const command = createCommand(BASE_ARGV);
await command.runAsync();
expect(mockUploadEmbeddedUpdate).toHaveBeenCalledTimes(2);
expect(mockSleepAsync).toHaveBeenCalledTimes(1);
expect(mockSleepAsync).toHaveBeenCalledWith(3_000);
expect(mockLogLog).toHaveBeenCalledWith(expect.stringContaining(MOCK_EMBEDDED_UPDATE.id));
});
it('exhausts all 10 attempts on ASSET_NOT_READY, sleeping 9 times then throwing', async () => {
const assetNotReadyError = new Error('asset not ready');
mockIsEmbeddedUpdateAssetNotReadyError.mockImplementation(e => e === assetNotReadyError);
mockUploadEmbeddedUpdate.mockRejectedValue(assetNotReadyError);
const command = createCommand(BASE_ARGV);
await expect(command.runAsync()).rejects.toThrow();
expect(mockUploadEmbeddedUpdate).toHaveBeenCalledTimes(10);
expect(mockSleepAsync).toHaveBeenCalledTimes(9);
});
it('throws a user-facing error on CONFLICT without retrying', async () => {
const conflictError = new Error('conflict');
mockIsEmbeddedUpdateConflictError.mockImplementation(e => e === conflictError);
mockUploadEmbeddedUpdate.mockRejectedValue(conflictError);
const command = createCommand(BASE_ARGV);
await expect(command.runAsync()).rejects.toThrow(/already registered/);
expect(mockUploadEmbeddedUpdate).toHaveBeenCalledTimes(1);
expect(mockSleepAsync).not.toHaveBeenCalled();
});
it('propagates unexpected mutation errors immediately without retrying', async () => {
const unexpectedError = new Error('network timeout');
mockUploadEmbeddedUpdate.mockRejectedValue(unexpectedError);
const command = createCommand(BASE_ARGV);
await expect(command.runAsync()).rejects.toThrow('network timeout');
expect(mockUploadEmbeddedUpdate).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,198 @@
import { Platform } from '@expo/eas-build-job';
import { Updates } from '@expo/config-plugins';
import { Errors, Flags } from '@oclif/core';
import fs from 'fs';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EmbeddedUpdateAssetMutation } from '../../graphql/mutations/EmbeddedUpdateAssetMutation';
import {
EmbeddedUpdateMutation,
EmbeddedUpdateResult,
isEmbeddedUpdateAssetNotReadyError,
isEmbeddedUpdateConflictError,
} from '../../graphql/mutations/EmbeddedUpdateMutation';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import { toAppPlatform } from '../../graphql/types/AppPlatform';
import Log from '../../log';
import { readEmbeddedManifest } from '../../update/embeddedManifest';
import { PresignedPost, uploadWithPresignedPostWithRetryAsync } from '../../uploads';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { sleepAsync } from '../../utils/promise';
const MAX_ATTEMPTS = 10;
const RETRY_BASE_DELAY_MS = 3_000;
const RETRY_MAX_DELAY_MS = 10_000;
export default class UpdateUploadEmbedded extends EasCommand {
static override description =
'upload the JS bundle embedded in a native build so EAS Update can generate bsdiff patches against it';
static override examples = [
'$ eas update:upload-embedded --platform ios --bundle ios/build/App.app/main.jsbundle --manifest ios/build/App.app/app.manifest --channel production',
'$ eas update:upload-embedded --platform android --bundle android/app/src/main/assets/index.android.bundle --manifest android/app/src/main/assets/app.manifest --channel production --build-id <BUILD-ID>',
];
static override flags = {
platform: Flags.option({
char: 'p',
description: 'Platform of the embedded bundle',
options: [Platform.IOS, Platform.ANDROID] as const,
required: true,
})(),
bundle: Flags.string({
description: 'Path to the embedded JS bundle file',
required: true,
}),
manifest: Flags.string({
description: 'Path to the app.manifest file embedded in the build',
required: true,
}),
channel: Flags.string({
description: 'Channel name the embedded update should be associated with',
required: true,
}),
'build-id': Flags.string({
description:
'EAS Build ID that produced this binary (required when invoked from EAS Build)',
required: false,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(UpdateUploadEmbedded);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const platform = flags.platform;
const bundlePath = flags.bundle;
const manifestPath = flags.manifest;
const channelName = flags.channel;
const buildId = flags['build-id'];
const {
loggedIn: { graphqlClient },
privateProjectConfig: { projectId, exp, projectDir },
} = await this.getContextAsync(UpdateUploadEmbedded, {
nonInteractive,
withServerSideEnvironment: null,
});
if (jsonFlag) {
enableJsonOutput();
}
if (!fs.existsSync(bundlePath)) {
Errors.error(
`Bundle file not found at "${bundlePath}". Check that the path is correct and points to the JS bundle in your native build output.`,
{ exit: 1 }
);
}
if (!fs.existsSync(manifestPath)) {
Errors.error(
`Manifest file not found at "${manifestPath}". Check that the path points to the app.manifest in your native build output ` +
`(iOS: <App>.app/app.manifest, Android: assets/app.manifest).`,
{ exit: 1 }
);
}
const { id: embeddedUpdateId } = readEmbeddedManifest(manifestPath);
const runtimeVersion = await Updates.getRuntimeVersionNullableAsync(projectDir, exp, platform);
if (runtimeVersion === null) {
Errors.error(
`Could not resolve runtimeVersion for platform "${platform}". ` +
`Ensure runtimeVersion is set in your app.json under the expo key.`,
{ exit: 1 }
);
}
const appPlatform = toAppPlatform(platform);
const channel = await ChannelQuery.viewUpdateChannelBasicInfoAsync(graphqlClient, {
appId: projectId,
channelName,
});
const channelId = channel.id;
Log.log(
`Uploading embedded ${platform} bundle to channel "${channelName}"` +
(buildId ? ` (build ${buildId})` : '')
);
const contentType = 'application/javascript';
const uploadSpec = await EmbeddedUpdateAssetMutation.getSignedUploadSpecAsync(graphqlClient, {
appId: projectId,
contentType,
});
const presignedPost: PresignedPost = {
url: uploadSpec.presignedUrl,
fields: uploadSpec.fields,
};
await uploadWithPresignedPostWithRetryAsync(bundlePath, presignedPost, () => {
Log.debug('Uploading bundle...');
});
Log.debug(
`Bundle uploaded. storageKey: ${uploadSpec.storageKey}, embeddedUpdateId: ${embeddedUpdateId}, runtimeVersion: ${runtimeVersion}, channelId: ${channelId}`
);
let embeddedUpdate: EmbeddedUpdateResult | undefined;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
embeddedUpdate = await EmbeddedUpdateMutation.uploadEmbeddedUpdateAsync(graphqlClient, {
appId: projectId,
platform: appPlatform,
runtimeVersion,
channelId,
embeddedUpdateId,
launchAssetStorageKey: uploadSpec.storageKey,
turtleBuildId: buildId,
});
break;
} catch (e: unknown) {
if (isEmbeddedUpdateConflictError(e)) {
Errors.error(
`An embedded update is already registered for this build (manifest id: ${embeddedUpdateId}). ` +
`Each native binary can only have one embedded update. ` +
`If you re-ran this command by mistake, no action is needed. ` +
`To replace the existing registration, delete it from the Expo dashboard first, then re-run this command.`,
{ exit: 1 }
);
}
const delayMs = attempt < MAX_ATTEMPTS
? Math.min(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS)
: undefined;
if (isEmbeddedUpdateAssetNotReadyError(e) && delayMs !== undefined) {
Log.log(
`Asset not yet finalized, retrying (attempt ${attempt + 1}/${MAX_ATTEMPTS})...`
);
await sleepAsync(delayMs);
continue;
}
throw e;
}
}
if (embeddedUpdate === undefined) {
throw new Error(
`The uploaded bundle was not processed after ${MAX_ATTEMPTS} attempts. ` +
`Storage finalization is taking longer than expected — wait a moment and try re-running the command.`
);
}
Log.log(
`Embedded update registered: ${embeddedUpdate.id} (${platform}, runtimeVersion ${runtimeVersion}, channel "${channelName}")`
);
if (jsonFlag) {
printJsonOnlyOutput(embeddedUpdate);
}
}
}
@@ -0,0 +1,49 @@
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
export type EmbeddedUpdateAssetUploadSpec = {
storageKey: string;
presignedUrl: string;
fields: Record<string, string>;
};
type GetSignedEmbeddedUploadSpecMutationResult = {
embeddedUpdateAsset: {
getSignedEmbeddedUpdateAssetUploadSpecifications: EmbeddedUpdateAssetUploadSpec;
};
};
export const EmbeddedUpdateAssetMutation = {
async getSignedUploadSpecAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, contentType }: { appId: string; contentType: string }
): Promise<EmbeddedUpdateAssetUploadSpec> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<GetSignedEmbeddedUploadSpecMutationResult>(
gql`
mutation GetSignedEmbeddedUpdateAssetUploadSpec(
$appId: ID!
$contentType: String!
) {
embeddedUpdateAsset {
getSignedEmbeddedUpdateAssetUploadSpecifications(
appId: $appId
contentType: $contentType
) {
storageKey
presignedUrl
fields
}
}
}
`,
{ appId, contentType }
)
.toPromise()
);
return data.embeddedUpdateAsset.getSignedEmbeddedUpdateAssetUploadSpecifications;
},
};
@@ -0,0 +1,75 @@
import { CombinedError } from '@urql/core';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppPlatform } from '../generated';
import { withErrorHandlingAsync } from '../client';
export type EmbeddedUpdateResult = {
id: string;
platform: AppPlatform;
runtimeVersion: string;
channelId: string;
createdAt: string;
};
type UploadEmbeddedUpdateInput = {
appId: string;
platform: AppPlatform;
runtimeVersion: string;
channelId: string;
embeddedUpdateId: string;
launchAssetStorageKey: string;
turtleBuildId?: string;
};
type UploadEmbeddedUpdateMutationResult = {
embeddedUpdate: {
uploadEmbeddedUpdate: EmbeddedUpdateResult;
};
};
export function isEmbeddedUpdateAssetNotReadyError(error: unknown): boolean {
return (
error instanceof CombinedError &&
error.graphQLErrors.some(
e => e.extensions?.['errorCode'] === 'EMBEDDED_UPDATE_ASSET_NOT_READY'
)
);
}
export function isEmbeddedUpdateConflictError(error: unknown): boolean {
return (
error instanceof CombinedError &&
error.graphQLErrors.some(e => e.extensions?.['errorCode'] === 'EMBEDDED_UPDATE_CONFLICT')
);
}
export const EmbeddedUpdateMutation = {
async uploadEmbeddedUpdateAsync(
graphqlClient: ExpoGraphqlClient,
input: UploadEmbeddedUpdateInput
): Promise<EmbeddedUpdateResult> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<UploadEmbeddedUpdateMutationResult>(
gql`
mutation UploadEmbeddedUpdate($input: UploadEmbeddedUpdateInput!) {
embeddedUpdate {
uploadEmbeddedUpdate(input: $input) {
id
platform
runtimeVersion
channelId
createdAt
}
}
}
`,
{ input }
)
.toPromise()
);
return data.embeddedUpdate.uploadEmbeddedUpdate;
},
};
@@ -0,0 +1,64 @@
import { CombinedError } from '@urql/core';
import {
isEmbeddedUpdateAssetNotReadyError,
isEmbeddedUpdateConflictError,
} from '../EmbeddedUpdateMutation';
function makeCombinedError(errorCode: string): CombinedError {
return new CombinedError({
graphQLErrors: [{ message: 'error', extensions: { errorCode } }],
});
}
describe('isEmbeddedUpdateAssetNotReadyError', () => {
it('returns true for CombinedError with EMBEDDED_UPDATE_ASSET_NOT_READY errorCode', () => {
expect(
isEmbeddedUpdateAssetNotReadyError(makeCombinedError('EMBEDDED_UPDATE_ASSET_NOT_READY'))
).toBe(true);
});
it('returns false for CombinedError with a different errorCode', () => {
expect(
isEmbeddedUpdateAssetNotReadyError(makeCombinedError('EMBEDDED_UPDATE_CONFLICT'))
).toBe(false);
});
it('returns false for CombinedError with no extensions', () => {
expect(
isEmbeddedUpdateAssetNotReadyError(new CombinedError({ graphQLErrors: [{ message: 'oops' }] }))
).toBe(false);
});
it('returns false for a plain Error', () => {
expect(isEmbeddedUpdateAssetNotReadyError(new Error('plain error'))).toBe(false);
});
it('returns false for null and non-error values', () => {
expect(isEmbeddedUpdateAssetNotReadyError(null)).toBe(false);
expect(isEmbeddedUpdateAssetNotReadyError('string')).toBe(false);
expect(isEmbeddedUpdateAssetNotReadyError(42)).toBe(false);
});
});
describe('isEmbeddedUpdateConflictError', () => {
it('returns true for CombinedError with EMBEDDED_UPDATE_CONFLICT errorCode', () => {
expect(
isEmbeddedUpdateConflictError(makeCombinedError('EMBEDDED_UPDATE_CONFLICT'))
).toBe(true);
});
it('returns false for CombinedError with a different errorCode', () => {
expect(
isEmbeddedUpdateConflictError(makeCombinedError('EMBEDDED_UPDATE_ASSET_NOT_READY'))
).toBe(false);
});
it('returns false for a plain Error', () => {
expect(isEmbeddedUpdateConflictError(new Error('plain error'))).toBe(false);
});
it('returns false for null', () => {
expect(isEmbeddedUpdateConflictError(null)).toBe(false);
});
});
@@ -0,0 +1,50 @@
import fs from 'fs';
import * as uuid from 'uuid';
export type EmbeddedManifest = {
id: string;
};
export function readEmbeddedManifest(manifestPath: string): EmbeddedManifest {
let raw: string;
try {
raw = fs.readFileSync(manifestPath, 'utf8');
} catch {
throw new Error(
`Could not read manifest file at "${manifestPath}". ` +
`The file exists but could not be read — check that you have permission to access it.`
);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error(
`Manifest file at "${manifestPath}" is not valid JSON. ` +
`This usually means the file is corrupted or you pointed at the wrong file. ` +
`Rebuild your app and check that the path points to the app.manifest in your native build output.`
);
}
if (
typeof parsed !== 'object' ||
parsed === null ||
typeof (parsed as Record<string, unknown>).id !== 'string'
) {
throw new Error(
`Manifest at "${manifestPath}" is missing the required "id" field. ` +
`Make sure you're pointing at the app.manifest generated by expo-updates during your native build, not a different JSON file.`
);
}
const id = (parsed as Record<string, string>).id;
if (!uuid.validate(id)) {
throw new Error(
`Manifest at "${manifestPath}" has an invalid "id" field ("${id}" is not a UUID). ` +
`Make sure you're pointing at the app.manifest generated by expo-updates during your native build.`
);
}
return { id };
}