Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ This is the log of notable changes to EAS CLI and related packages.

### 🐛 Bug fixes

- [eas-cli] Pre-warm bsdiff update patches against the top recent updates and the embedded bundles on a branch, rather than only the second-most-recent update, so more devices are served a diffed patch instead of the full un-diffed update while the on-demand diff computes. ([#3869](https://github.com/expo/eas-cli/pull/3869) by [@jc-expo](https://github.com/jc-expo))

### 🧹 Chores

- [eas-cli] Simplify 2FA now that SMS is no longer supported. ([#3859](https://github.com/expo/eas-cli/pull/3859) by [@wschurman](https://github.com/wschurman))
Expand Down
150 changes: 149 additions & 1 deletion packages/eas-cli/src/update/__tests__/utils-test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,27 @@
import { getPlatformsForGroup, truncateString } from '../utils';
import { instance, mock } from 'ts-mockito';

import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import fetch from '../../fetch';
import { UpdatePublishMutation } from '../../graphql/generated';
import { AssetQuery } from '../../graphql/queries/AssetQuery';
import { BranchQuery } from '../../graphql/queries/BranchQuery';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import { EmbeddedUpdateQuery } from '../../graphql/queries/EmbeddedUpdateQuery';
import { getPlatformsForGroup, prewarmDiffingAsync, truncateString } from '../utils';

jest.mock('../../fetch');
jest.mock('../../graphql/queries/AssetQuery');
jest.mock('../../graphql/queries/BranchQuery');
jest.mock('../../graphql/queries/ChannelQuery');
jest.mock('../../graphql/queries/EmbeddedUpdateQuery');

// A standard "always true" branch mapping that routes the channel to the given branch.
function branchMappingForBranch(branchId: string): string {
return JSON.stringify({
version: 0,
data: [{ branchId, branchMappingLogic: 'true' }],
});
}

describe('update utility functions', () => {
describe(truncateString, () => {
Expand Down Expand Up @@ -27,4 +50,129 @@ describe('update utility functions', () => {
expect(getPlatformsForGroup(input)).toEqual(`N/A`);
});
});

describe(prewarmDiffingAsync, () => {
const updateStub: UpdatePublishMutation['updateBranch']['publishUpdateGroups'][number] = {
id: 'new-update-id',
group: 'group-1234',
createdAt: '2026-01-01T00:00:00Z',
runtimeVersion: '1.0.0',
platform: 'ios',
manifestFragment: JSON.stringify({ launchAsset: { storageKey: 'launch-key' } }),
isRollBackToEmbedded: false,
manifestPermalink: 'https://expo.dev/fake/manifest/link',
isGitWorkingTreeDirty: false,
branch: { id: 'branch-1234', name: 'production' },
};

beforeEach(() => {
jest.resetAllMocks();
jest.mocked(fetch).mockResolvedValue({} as any);
});

it('warms the top-K recent updates and the embedded bundle', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(BranchQuery.getUpdateIdsOnBranchAsync).mockResolvedValue(['r1', 'r2']);
jest
.mocked(AssetQuery.getSignedUrlsAsync)
.mockResolvedValue([{ storageKey: 'launch-key', url: 'https://cdn/asset', headers: {} }]);
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [
{
node: {
id: 'channel-1',
name: 'production',
branchMapping: branchMappingForBranch('branch-1234'),
},
},
],
} as any);
jest
.mocked(EmbeddedUpdateQuery.viewPaginatedAsync)
.mockResolvedValue({ edges: [{ cursor: 'c0', node: { id: 'e1' } }] } as any);

const warmed = await prewarmDiffingAsync(graphqlClient, 'app-id', [updateStub]);

// The embedded bundle diffs against itself; recent updates fall back to the first embedded id.
expect(warmed).toEqual([
{ requestedUpdateId: 'new-update-id', currentUpdateId: 'e1', embeddedUpdateId: 'e1' },
{ requestedUpdateId: 'new-update-id', currentUpdateId: 'r1', embeddedUpdateId: 'e1' },
{ requestedUpdateId: 'new-update-id', currentUpdateId: 'r2', embeddedUpdateId: 'e1' },
]);
// Embedded bundles are restricted server-side to the channel that routes to the branch.
expect(
jest.mocked(EmbeddedUpdateQuery.viewPaginatedAsync).mock.calls.map(call => call[1].filter)
).toContainEqual(expect.objectContaining({ channel: 'production' }));
});

it('only pre-warms embedded bundles for channels whose branch mapping routes to the published branch', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(BranchQuery.getUpdateIdsOnBranchAsync).mockResolvedValue(['r1']);
jest
.mocked(AssetQuery.getSignedUrlsAsync)
.mockResolvedValue([{ storageKey: 'launch-key', url: 'https://cdn/asset', headers: {} }]);
// 'production' routes to the published branch (branch-1234); 'staging' routes elsewhere.
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [
{
node: {
id: 'channel-prod',
name: 'production',
branchMapping: branchMappingForBranch('branch-1234'),
},
},
{
node: {
id: 'channel-staging',
name: 'staging',
branchMapping: branchMappingForBranch('other-branch'),
},
},
],
} as any);
jest
.mocked(EmbeddedUpdateQuery.viewPaginatedAsync)
.mockResolvedValue({ edges: [{ cursor: 'c0', node: { id: 'e1' } }] } as any);

await prewarmDiffingAsync(graphqlClient, 'app-id', [updateStub]);

// Only the eligible 'production' channel is queried — never 'staging'.
expect(EmbeddedUpdateQuery.viewPaginatedAsync).toHaveBeenCalledTimes(1);
expect(
jest.mocked(EmbeddedUpdateQuery.viewPaginatedAsync).mock.calls.map(call => call[1].filter)
).toEqual([expect.objectContaining({ channel: 'production' })]);
});

it('is best-effort: swallows errors and resolves to an empty list', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(BranchQuery.getUpdateIdsOnBranchAsync).mockRejectedValue(new Error('boom'));

await expect(prewarmDiffingAsync(graphqlClient, 'app-id', [updateStub])).resolves.toEqual([]);
});

it('returns empty when there are no recent updates on the branch', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(BranchQuery.getUpdateIdsOnBranchAsync).mockResolvedValue([]);

await expect(prewarmDiffingAsync(graphqlClient, 'app-id', [updateStub])).resolves.toEqual([]);
});

it('returns empty when there is no signed launch asset URL', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(BranchQuery.getUpdateIdsOnBranchAsync).mockResolvedValue(['r1']);
jest.mocked(AssetQuery.getSignedUrlsAsync).mockResolvedValue([]);

await expect(prewarmDiffingAsync(graphqlClient, 'app-id', [updateStub])).resolves.toEqual([]);
});

it('skips updates with no launch asset in the manifest', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());

await expect(
prewarmDiffingAsync(graphqlClient, 'app-id', [
{ ...updateStub, manifestFragment: JSON.stringify({}) },
])
).resolves.toEqual([]);
});
});
});
Loading
Loading