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 @@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages.

### 🎉 New features

- [eas-cli] Add commands to protect and unprotect EAS Update channels, and show protection in channel list and view output. ([#4319](https://github.com/expo/eas-cli/pull/4319) by [@sjkim-expo](https://github.com/sjkim-expo))

### 🐛 Bug fixes

### 🧹 Chores
Expand Down
3 changes: 3 additions & 0 deletions packages/eas-cli/src/channel/__tests__/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export const testChannelObject: UpdateChannelObject = {
'{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.15,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}',
updateBranches: [testUpdateBranch1, testUpdateBranch2],
isPaused: false,
isProtected: false,
__typename: 'UpdateChannel',
};

Expand All @@ -141,6 +142,7 @@ export const testBasicChannelInfo: UpdateChannelBasicInfoFragment = {
name: 'production',
branchMapping:
'{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.1,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}',
isProtected: false,
__typename: 'UpdateChannel',
};

Expand All @@ -149,5 +151,6 @@ export const testBasicChannelInfo2: UpdateChannelBasicInfoFragment = {
name: 'staging',
branchMapping:
'{"data":[{"branchId":"d7d68e32-d9c9-4a8d-8d1b-21e53100a5e8","branchMappingLogic":{"operand":0.1,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"f9f708c2-0c91-4360-b2a4-0b61834aef4a","branchMappingLogic":"true"}],"version":0}',
isProtected: false,
__typename: 'UpdateChannel',
};
74 changes: 74 additions & 0 deletions packages/eas-cli/src/channel/__tests__/protection-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { protectUpdateChannelAsync, unprotectUpdateChannelAsync } from '../protection';

function makeGraphqlClient(data: unknown): {
graphqlClient: ExpoGraphqlClient;
mutation: jest.Mock;
} {
const mutation = jest.fn().mockReturnValue({
toPromise: jest.fn().mockResolvedValue({ data }),
});
return { graphqlClient: { mutation } as unknown as ExpoGraphqlClient, mutation };
}

describe(protectUpdateChannelAsync.name, () => {
it('protects a channel by ID and returns the server state', async () => {
const channel = {
id: 'channel-id',
name: 'production',
branchMapping: '{"version":0,"data":[]}',
isProtected: true,
};
const { graphqlClient, mutation } = makeGraphqlClient({
updateChannel: { protectUpdateChannel: channel },
});

await expect(
protectUpdateChannelAsync(graphqlClient, { channelId: 'channel-id' })
).resolves.toEqual(channel);

expect(mutation.mock.calls[0][0].loc.source.body).toContain('protectUpdateChannel');
expect(mutation.mock.calls[0][1]).toEqual({ channelId: 'channel-id' });
});

it('throws a clear error when the channel is not returned', async () => {
const { graphqlClient } = makeGraphqlClient({
updateChannel: { protectUpdateChannel: null },
});

await expect(
protectUpdateChannelAsync(graphqlClient, { channelId: 'missing-channel-id' })
).rejects.toThrow('Could not find a channel with id: missing-channel-id');
});
});

describe(unprotectUpdateChannelAsync.name, () => {
it('unprotects a channel by ID and returns the server state', async () => {
const channel = {
id: 'channel-id',
name: 'production',
branchMapping: '{"version":0,"data":[]}',
isProtected: false,
};
const { graphqlClient, mutation } = makeGraphqlClient({
updateChannel: { unprotectUpdateChannel: channel },
});

await expect(
unprotectUpdateChannelAsync(graphqlClient, { channelId: 'channel-id' })
).resolves.toEqual(channel);

expect(mutation.mock.calls[0][0].loc.source.body).toContain('unprotectUpdateChannel');
expect(mutation.mock.calls[0][1]).toEqual({ channelId: 'channel-id' });
});

it('throws a clear error when the channel is not returned', async () => {
const { graphqlClient } = makeGraphqlClient({
updateChannel: { unprotectUpdateChannel: null },
});

await expect(
unprotectUpdateChannelAsync(graphqlClient, { channelId: 'missing-channel-id' })
).rejects.toThrow('Could not find a channel with id: missing-channel-id');
});
});
26 changes: 26 additions & 0 deletions packages/eas-cli/src/channel/__tests__/queries-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { renderChannelHeaderContent } from '../queries';
import Log from '../../log';

jest.mock('../../log');

describe(renderChannelHeaderContent.name, () => {
beforeEach(() => {
jest.clearAllMocks();
});

it.each([
[true, 'Protected'],
[false, 'Unprotected'],
])('renders protection state when isProtected is %s', (isProtected, expected) => {
renderChannelHeaderContent({
channelName: 'production',
channelId: 'channel-id',
isPaused: false,
isProtected,
});

const output = jest.mocked(Log.log).mock.calls.flat().join('\n');
expect(output).toContain('Protection');
expect(output).toContain(expected);
});
});
71 changes: 71 additions & 0 deletions packages/eas-cli/src/channel/protection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { print } from 'graphql';
import gql from 'graphql-tag';

import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../graphql/client';
import {
ProtectUpdateChannelMutation,
ProtectUpdateChannelMutationVariables,
UnprotectUpdateChannelMutation,
UnprotectUpdateChannelMutationVariables,
UpdateChannelBasicInfoFragment,
} from '../graphql/generated';
import { UpdateChannelBasicInfoFragmentNode } from '../graphql/types/UpdateChannelBasicInfo';

export async function protectUpdateChannelAsync(
graphqlClient: ExpoGraphqlClient,
{ channelId }: ProtectUpdateChannelMutationVariables
): Promise<UpdateChannelBasicInfoFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<ProtectUpdateChannelMutation, ProtectUpdateChannelMutationVariables>(
gql`
mutation ProtectUpdateChannel($channelId: ID!) {
updateChannel {
protectUpdateChannel(channelId: $channelId) {
id
...UpdateChannelBasicInfoFragment
}
}
}
${print(UpdateChannelBasicInfoFragmentNode)}
`,
{ channelId }
)
.toPromise()
);
const channel = data.updateChannel.protectUpdateChannel;
if (!channel) {
throw new Error(`Could not find a channel with id: ${channelId}`);
}
return channel;
}

export async function unprotectUpdateChannelAsync(
graphqlClient: ExpoGraphqlClient,
{ channelId }: UnprotectUpdateChannelMutationVariables
): Promise<UpdateChannelBasicInfoFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<UnprotectUpdateChannelMutation, UnprotectUpdateChannelMutationVariables>(
gql`
mutation UnprotectUpdateChannel($channelId: ID!) {
updateChannel {
unprotectUpdateChannel(channelId: $channelId) {
id
...UpdateChannelBasicInfoFragment
}
}
}
${print(UpdateChannelBasicInfoFragmentNode)}
`,
{ channelId }
)
.toPromise()
);
const channel = data.updateChannel.unprotectUpdateChannel;
if (!channel) {
throw new Error(`Could not find a channel with id: ${channelId}`);
}
return channel;
}
7 changes: 6 additions & 1 deletion packages/eas-cli/src/channel/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export async function listAndRenderBranchesAndUpdatesOnChannelAsync(
channelName: channel.name,
channelId: channel.id,
isPaused: channel.isPaused,
isProtected: channel.isProtected,
});

if (paginatedQueryOptions.nonInteractive) {
Expand Down Expand Up @@ -186,6 +187,7 @@ function renderPageOfChannels(
channelName: channel.name,
channelId: channel.id,
isPaused: channel.isPaused,
isProtected: channel.isProtected,
});
Log.addNewLineIfNone();
logChannelDetails(channel);
Expand All @@ -212,14 +214,16 @@ function renderPageOfBranchesOnChannel(
}
}

function renderChannelHeaderContent({
export function renderChannelHeaderContent({
channelName,
channelId,
isPaused,
isProtected,
}: {
channelName: string;
channelId: string;
isPaused: boolean;
isProtected: boolean;
}): void {
Log.addNewLineIfNone();
Log.log(chalk.bold('Channel:'));
Expand All @@ -228,6 +232,7 @@ function renderChannelHeaderContent({
{ label: 'Name', value: channelName },
{ label: 'ID', value: channelId },
{ label: 'Status', value: isPaused ? 'Paused' : 'Active' },
{ label: 'Protection', value: isProtected ? 'Protected' : 'Unprotected' },
])
);
Log.addNewLineIfNone();
Expand Down
127 changes: 127 additions & 0 deletions packages/eas-cli/src/commands/channel/__tests__/protection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import {
protectUpdateChannelAsync,
unprotectUpdateChannelAsync,
} from '../../../channel/protection';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { ChannelQuery } from '../../../graphql/queries/ChannelQuery';
import Log from '../../../log';
import { toggleConfirmAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ChannelProtect from '../protect';
import ChannelUnprotect from '../unprotect';

jest.mock('../../../channel/protection');
jest.mock('../../../graphql/queries/ChannelQuery');
jest.mock('../../../log');
jest.mock('../../../prompts');
jest.mock('../../../utils/json');

const graphqlClient = {} as ExpoGraphqlClient;
const channel = {
id: 'channel-id',
name: 'production',
branchMapping: '{"version":0,"data":[]}',
isProtected: true,
};

function setContext(command: ChannelProtect | ChannelUnprotect): void {
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
projectId: 'project-id',
loggedIn: { graphqlClient },
});
}

describe(ChannelProtect, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(ChannelQuery.viewUpdateChannelBasicInfoAsync).mockResolvedValue(channel);
jest.mocked(protectUpdateChannelAsync).mockResolvedValue(channel);
});

it('protects a named channel', async () => {
const command = new ChannelProtect(['production'], getMockOclifConfig());
setContext(command);

await command.runAsync();

expect(protectUpdateChannelAsync).toHaveBeenCalledWith(graphqlClient, {
channelId: 'channel-id',
});
expect(Log.withTick).toHaveBeenCalledWith(expect.stringContaining('production'));
});

it('prints the server response as JSON', async () => {
const command = new ChannelProtect(['production', '--json'], getMockOclifConfig());
setContext(command);

await command.runAsync();

expect(enableJsonOutput).toHaveBeenCalled();
expect(printJsonOnlyOutput).toHaveBeenCalledWith(channel);
expect(Log.withTick).not.toHaveBeenCalled();
});

it('requires a channel name in non-interactive mode', async () => {
const command = new ChannelProtect(['--non-interactive'], getMockOclifConfig());

await expect(command.runAsync()).rejects.toThrow(
'Channel name must be set when running in non-interactive mode'
);
expect(protectUpdateChannelAsync).not.toHaveBeenCalled();
});
});

describe(ChannelUnprotect, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(ChannelQuery.viewUpdateChannelBasicInfoAsync).mockResolvedValue(channel);
jest.mocked(unprotectUpdateChannelAsync).mockResolvedValue({ ...channel, isProtected: false });
jest.mocked(toggleConfirmAsync).mockResolvedValue(true);
});

it('asks for confirmation before removing protection', async () => {
const command = new ChannelUnprotect(['production'], getMockOclifConfig());
setContext(command);

await command.runAsync();

expect(toggleConfirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining('production'),
});
expect(unprotectUpdateChannelAsync).toHaveBeenCalledWith(graphqlClient, {
channelId: 'channel-id',
});
});

it('does not mutate the channel when confirmation is declined', async () => {
jest.mocked(toggleConfirmAsync).mockResolvedValue(false);
const command = new ChannelUnprotect(['production'], getMockOclifConfig());
setContext(command);

await command.runAsync();

expect(unprotectUpdateChannelAsync).not.toHaveBeenCalled();
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('production'));
});

it('does not prompt in non-interactive mode', async () => {
const command = new ChannelUnprotect(['production', '--non-interactive'], getMockOclifConfig());
setContext(command);

await command.runAsync();

expect(toggleConfirmAsync).not.toHaveBeenCalled();
expect(unprotectUpdateChannelAsync).toHaveBeenCalled();
});

it('requires a channel name in non-interactive mode', async () => {
const command = new ChannelUnprotect(['--non-interactive'], getMockOclifConfig());

await expect(command.runAsync()).rejects.toThrow(
'Channel name must be set when running in non-interactive mode'
);
expect(unprotectUpdateChannelAsync).not.toHaveBeenCalled();
});
});
Loading
Loading