Skip to content
Merged
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
59 changes: 18 additions & 41 deletions packages/catalyst/src/cli/commands/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { password } from '@inquirer/prompts';
import { confirm, input, password } from '@inquirer/prompts';
import { Command } from 'commander';
import { http, HttpResponse } from 'msw';
import { realpath } from 'node:fs/promises';
Expand Down Expand Up @@ -27,9 +27,13 @@ import { auth } from './auth';
vi.mock('yocto-spinner', () => import('../../../tests/mocks/spinner'));
vi.mock('open', () => ({ default: vi.fn().mockResolvedValue(undefined) }));
vi.mock('@inquirer/prompts', () => ({
confirm: vi.fn(),
input: vi.fn(),
password: vi.fn(),
}));

const confirmMock = vi.mocked(confirm);
const inputMock = vi.mocked(input);
const passwordMock = vi.mocked(password);

let exitMock: MockInstance;
Expand Down Expand Up @@ -163,25 +167,16 @@ describe('login', () => {
),
);

confirmMock.mockResolvedValueOnce(true);
inputMock.mockResolvedValueOnce('manual-store-hash');
passwordMock.mockResolvedValueOnce('manual-access-token');

const promptMock = vi
.spyOn(consola, 'prompt')
.mockImplementationOnce(async (message, opts) => {
expect(message).toContain('Try logging in manually');
expect(opts).toMatchObject({ type: 'confirm' });

return Promise.resolve(true);
})
.mockImplementationOnce(async (message, opts) => {
expect(message).toBe('Store hash:');
expect(opts).toMatchObject({ type: 'text' });

return Promise.resolve('manual-store-hash');
});

await program.parseAsync(['node', 'catalyst', 'auth', 'login']);

expect(confirmMock).toHaveBeenCalledOnce();
expect(confirmMock.mock.calls[0]?.[0].message).toContain('Try logging in manually');
expect(inputMock).toHaveBeenCalledWith(expect.objectContaining({ message: 'Store hash:' }));

expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining("Browser login didn't work"));
expect(consola.success).toHaveBeenCalledWith('Logged in to store manual-store-hash.');
expect(exitMock).toHaveBeenCalledWith(0);
Expand All @@ -190,8 +185,6 @@ describe('login', () => {

expect(config.get('storeHash')).toBe('manual-store-hash');
expect(config.get('accessToken')).toBe('manual-access-token');

promptMock.mockRestore();
});

test('exits cleanly when user declines manual login fallback', async () => {
Expand All @@ -202,7 +195,7 @@ describe('login', () => {
),
);

const promptMock = vi.spyOn(consola, 'prompt').mockResolvedValueOnce(false);
confirmMock.mockResolvedValueOnce(false);

await program.parseAsync(['node', 'catalyst', 'auth', 'login']);

Expand All @@ -211,8 +204,6 @@ describe('login', () => {
'Login aborted. Re-run `catalyst auth login` when you have your credentials ready.',
);
expect(exitMock).toHaveBeenCalledWith(0);

promptMock.mockRestore();
});

test('fails when manual credentials cannot be validated', async () => {
Expand All @@ -227,21 +218,16 @@ describe('login', () => {
),
);

confirmMock.mockResolvedValueOnce(true);
inputMock.mockResolvedValueOnce('manual-store-hash');
passwordMock.mockResolvedValueOnce('bad-token');

const promptMock = vi
.spyOn(consola, 'prompt')
.mockResolvedValueOnce(true)
.mockResolvedValueOnce('manual-store-hash');

await program.parseAsync(['node', 'catalyst', 'auth', 'login']);

expect(consola.error).toHaveBeenCalledWith(
expect.stringContaining('Could not validate credentials'),
);
expect(exitMock).toHaveBeenCalledWith(1);

promptMock.mockRestore();
});

test('rejects empty store hash during manual login', async () => {
Expand All @@ -252,17 +238,13 @@ describe('login', () => {
),
);

const promptMock = vi
.spyOn(consola, 'prompt')
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(' ');
confirmMock.mockResolvedValueOnce(true);
inputMock.mockResolvedValueOnce(' ');

await program.parseAsync(['node', 'catalyst', 'auth', 'login']);

expect(consola.error).toHaveBeenCalledWith(expect.stringContaining('Store hash is required'));
expect(exitMock).toHaveBeenCalledWith(1);

promptMock.mockRestore();
});

test('rejects empty access token during manual login', async () => {
Expand All @@ -273,19 +255,14 @@ describe('login', () => {
),
);

confirmMock.mockResolvedValueOnce(true);
inputMock.mockResolvedValueOnce('manual-store-hash');
passwordMock.mockResolvedValueOnce(' ');

const promptMock = vi
.spyOn(consola, 'prompt')
.mockResolvedValueOnce(true)
.mockResolvedValueOnce('manual-store-hash');

await program.parseAsync(['node', 'catalyst', 'auth', 'login']);

expect(consola.error).toHaveBeenCalledWith(expect.stringContaining('Access token is required'));
expect(exitMock).toHaveBeenCalledWith(1);

promptMock.mockRestore();
});

test('handles browser open failure gracefully', async () => {
Expand Down
37 changes: 18 additions & 19 deletions packages/catalyst/src/cli/commands/channel.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { confirm, select } from '@inquirer/prompts';
import { Command } from 'commander';
import Conf from 'conf';
import { http, HttpResponse } from 'msw';
Expand All @@ -13,12 +14,20 @@ import { program } from '../program';

import { channel } from './channel';

vi.mock('@inquirer/prompts', () => ({
select: vi.fn(),
confirm: vi.fn(),
input: vi.fn(),
}));
// `channel link` can trigger the interactive device-code login (browser +
// spinner); stub both so the no-credentials path runs headless in tests.
vi.mock('open', () => ({ default: vi.fn().mockResolvedValue(undefined) }));
// eslint-disable-next-line import/dynamic-import-chunkname
vi.mock('yocto-spinner', () => import('../../../tests/mocks/spinner'));

const mockSelect = vi.mocked(select);
const mockConfirm = vi.mocked(confirm);

let exitMock: MockInstance;

let tmpDir: string;
Expand Down Expand Up @@ -122,10 +131,7 @@ describe('channel update', () => {
),
);

const promptMock = vi
.spyOn(consola, 'prompt')
.mockResolvedValueOnce('2')
.mockResolvedValueOnce('project-one.catalyst-sandbox.store');
mockSelect.mockResolvedValueOnce(2).mockResolvedValueOnce('project-one.catalyst-sandbox.store');

await program.parseAsync([
'node',
Expand All @@ -140,7 +146,7 @@ describe('channel update', () => {
linkedProjectUuid,
]);

expect(promptMock).toHaveBeenCalledTimes(2);
expect(mockSelect).toHaveBeenCalledTimes(2);
expect(putChannelId).toBe('2');
expect(putBody).toEqual({ url: 'https://project-one.catalyst-sandbox.store' });
expect(consola.success).toHaveBeenCalledWith(
Expand All @@ -152,10 +158,7 @@ describe('channel update', () => {
test('reads project UUID from .bigcommerce/project.json when no flag is passed', async () => {
config.set('projectUuid', linkedProjectUuid);

const promptMock = vi
.spyOn(consola, 'prompt')
.mockResolvedValueOnce('2')
.mockResolvedValueOnce('vanity.project-one.example.com');
mockSelect.mockResolvedValueOnce(2).mockResolvedValueOnce('vanity.project-one.example.com');

await program.parseAsync([
'node',
Expand All @@ -168,7 +171,7 @@ describe('channel update', () => {
accessToken,
]);

expect(promptMock).toHaveBeenCalledTimes(2);
expect(mockSelect).toHaveBeenCalledTimes(2);
expect(consola.success).toHaveBeenCalledWith(
expect.stringContaining('https://vanity.project-one.example.com'),
);
Expand All @@ -192,8 +195,6 @@ describe('channel update', () => {
),
);

const promptMock = vi.spyOn(consola, 'prompt');

await program.parseAsync([
'node',
'catalyst',
Expand All @@ -211,7 +212,7 @@ describe('channel update', () => {
'override.example',
]);

expect(promptMock).not.toHaveBeenCalled();
expect(mockSelect).not.toHaveBeenCalled();
expect(putChannelId).toBe('5');
expect(putBody).toEqual({ url: 'https://override.example' });
});
Expand All @@ -224,7 +225,7 @@ describe('channel update', () => {
);

// First prompt: "Would you like to create one?" — user says no
vi.spyOn(consola, 'prompt').mockResolvedValueOnce(false);
mockConfirm.mockResolvedValueOnce(false);

await program.parseAsync([
'node',
Expand All @@ -248,9 +249,7 @@ describe('channel update', () => {
),
);

vi.spyOn(consola, 'prompt')
.mockResolvedValueOnce('2')
.mockResolvedValueOnce('project-one.catalyst-sandbox.store');
mockSelect.mockResolvedValueOnce(2).mockResolvedValueOnce('project-one.catalyst-sandbox.store');

await expect(
program.parseAsync([
Expand Down Expand Up @@ -330,7 +329,7 @@ describe('channel link', () => {
}),
);

const promptMock = vi.spyOn(consola, 'prompt').mockResolvedValueOnce('2');
mockSelect.mockResolvedValueOnce(2);

await program.parseAsync([
'node',
Expand All @@ -343,7 +342,7 @@ describe('channel link', () => {
accessToken,
]);

expect(promptMock).toHaveBeenCalledTimes(1);
expect(mockSelect).toHaveBeenCalledTimes(1);
expect(initChannelId).toBe('2');
// id 2 in the default channels handler is "Catalyst Storefront".
expect(consola.success).toHaveBeenCalledWith(
Expand Down
17 changes: 8 additions & 9 deletions packages/catalyst/src/cli/commands/channel.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { select } from '@inquirer/prompts';
import { Command, InvalidArgumentError, Option } from 'commander';
import type Conf from 'conf';
import { colorize } from 'consola/utils';
Expand Down Expand Up @@ -200,17 +201,15 @@ Examples:
return;
}

const selected = await consola.prompt('Which channel would you like to link?', {
type: 'select',
options: sortChannelsByPlatform(channels).map((c) => ({
label: c.name,
value: String(c.id),
hint: channelPlatformLabel(c.platform),
channelId = await select({
message: 'Which channel would you like to link?',
choices: sortChannelsByPlatform(channels).map((c) => ({
name: c.name,
value: c.id,
description: channelPlatformLabel(c.platform),
})),
cancel: 'reject',
});

channelId = Number(selected);
channelName = channels.find((c) => c.id === channelId)?.name;
}

Expand Down Expand Up @@ -241,7 +240,7 @@ Examples:
consola.success(
`Linked to channel ${label} and wrote ${colorize('cyanBright', '.env.local')}.`,
);
consola.log(`\nStart your storefront:\n\n ${colorize('yellow', 'pnpm run dev')}\n`);
consola.log(`Next steps:\n\n ${colorize('yellow', 'pnpm run dev')}`);

process.exit(0);
});
Expand Down
1 change: 1 addition & 0 deletions packages/catalyst/src/cli/commands/create.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { create } from './create';
vi.mock('child_process', () => ({ execSync: vi.fn() }));

vi.mock('@inquirer/prompts', () => ({
checkbox: vi.fn(),
input: vi.fn(),
select: vi.fn(),
}));
Expand Down
46 changes: 16 additions & 30 deletions packages/catalyst/src/cli/commands/create.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Command, InvalidArgumentError, Option } from '@commander-js/extra-typings';
import { input, select } from '@inquirer/prompts';
import { checkbox, input, select } from '@inquirer/prompts';
import { execSync } from 'child_process';
import { colorize } from 'consola/utils';
import { pathExistsSync } from 'fs-extra/esm';
Expand Down Expand Up @@ -100,30 +100,15 @@ async function handleChannelCreation(
let additionalLocales: string[] = [];

if (shouldAddAdditionalLocales) {
const localeOptions = availableLocales
const localeChoices = availableLocales
.filter(({ value }) => value !== storefrontLocale)
.map(({ name, value }) => ({ label: name, value, hint: value }));

// consola's multiselect returns the value strings at runtime, but its typed
// return is loose (the whole option array). Recursion + cast avoids the
// no-await-in-loop / no-constant-condition lint hits and re-prompts on overflow.
const pickLocales = async (): Promise<string[]> => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const selected = (await consola.prompt(
'Which additional languages would you like to add to your channel?',
{ type: 'multiselect', options: localeOptions, cancel: 'reject' },
)) as unknown as string[];

if (selected.length > 4) {
consola.warn('You can only select up to 4 additional languages. Please try again.');

return pickLocales();
}

return selected;
};
.map(({ name, value }) => ({ name, value, description: value }));

additionalLocales = await pickLocales();
additionalLocales = await checkbox({
message: 'Which additional languages would you like to add to your channel?',
choices: localeChoices,
validate: (items) => items.length <= 4 || 'You can only select up to 4 additional languages.',
});
}

const shouldInstallSampleData = await select({
Expand Down Expand Up @@ -487,15 +472,16 @@ Examples:
}

consola.success(`Created '${projectName}' at '${projectDir}'`);
consola.info('Next steps:');
consola.info(colorize('yellow', ` cd ${projectName}/core && pnpm run dev`));

const steps = [`cd ${projectName}/core && pnpm run dev`];

if (useCommerceHosting) {
consola.info(
colorize(
'yellow',
` Run 'cd ${projectName}/core && pnpm run deploy' when ready to deploy to Commerce Hosting.`,
),
steps.push(
`Run 'cd ${projectName}/core && pnpm run deploy' when ready to deploy to Commerce Hosting.`,
);
}

consola.log(
`Next steps:\n\n${steps.map((step) => ` ${colorize('yellow', step)}`).join('\n')}`,
);
});
Loading
Loading