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: 21 additions & 38 deletions packages/cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Command } from 'commander';
import { CLAUDE_MARKETPLACE_NAME, CLAUDE_PLUGIN_ID } from '#src/harnesses/ClaudeInstaller.ts';
import { getDescriptor } from '#src/harnesses/registry.ts';
import { environment } from '#src/testFactories.ts';
import { createStubContext, marketplaceListResult, pluginListResult, readErrorLogs } from '#src/testHelpers/index.ts';
import { createStubContext, primeClaudeShellouts, readErrorLogs, stubClaudeState } from '#src/testHelpers/index.ts';
import { resolveCbCommand, runCli } from './cli.ts';

const CLAUDE_BINARY = getDescriptor('claude').binaryName;
Expand Down Expand Up @@ -90,11 +90,7 @@ describe('runCli', () => {
});

it('routes argv into the install claude subcommand with default user scope', async () => {
const { context, io, commandRunner } = createStubContext();
commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude');
commandRunner.on(CLAUDE_BINARY, ['plugin', 'list', '--json']).resolves(pluginListResult([]));
commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves();
commandRunner.on(CLAUDE_BINARY, ['plugin', 'install']).resolves();
const { context, io, commandRunner } = setupClaudeTest();

const exitCode = await runCli(context, ['install', 'claude']);

Expand Down Expand Up @@ -151,16 +147,10 @@ describe('runCli', () => {
});

it('routes argv into the uninstall claude subcommand with --scope project', async () => {
const { context, commandRunner } = createStubContext();
commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude');
commandRunner
.on(CLAUDE_BINARY, ['plugin', 'list', '--json'])
.resolves(pluginListResult([{ id: CLAUDE_PLUGIN_ID, scope: 'project' }]));
commandRunner
.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json'])
.resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }]));
commandRunner.on(CLAUDE_BINARY, ['plugin', 'uninstall']).resolves();
commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove']).resolves();
const { context, commandRunner } = setupClaudeTest();
stubClaudeState(commandRunner, {
marketplaces: [{ name: CLAUDE_MARKETPLACE_NAME, plugins: [{ id: CLAUDE_PLUGIN_ID, scope: 'project' }] }],
});

const exitCode = await runCli(context, ['uninstall', 'claude', '--scope', 'project']);

Expand All @@ -171,12 +161,7 @@ describe('runCli', () => {
});

it('routes argv into the no-target install orchestrator with --yes', async () => {
const { context, io, commandRunner, prompter } = createStubContext();
commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude');
commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']).resolves(marketplaceListResult([]));
commandRunner.on(CLAUDE_BINARY, ['plugin', 'list', '--json']).resolves(pluginListResult([]));
commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves();
commandRunner.on(CLAUDE_BINARY, ['plugin', 'install']).resolves();
const { context, io, prompter } = setupClaudeTest();

const exitCode = await runCli(context, ['install', '--yes']);

Expand All @@ -186,14 +171,10 @@ describe('runCli', () => {
});

it('routes argv into install status with --json and emits to stdout', async () => {
const { context, io, commandRunner } = createStubContext();
commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude');
commandRunner
.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json'])
.resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }]));
commandRunner
.on(CLAUDE_BINARY, ['plugin', 'list', '--json'])
.resolves(pluginListResult([{ id: CLAUDE_PLUGIN_ID, scope: 'user' }]));
const { context, io, commandRunner } = setupClaudeTest();
stubClaudeState(commandRunner, {
marketplaces: [{ name: CLAUDE_MARKETPLACE_NAME, plugins: [{ id: CLAUDE_PLUGIN_ID, scope: 'user' }] }],
});

const exitCode = await runCli(context, ['install', 'status', '--json']);

Expand All @@ -202,14 +183,10 @@ describe('runCli', () => {
});

it('registers cb_command and identifies before parsing for a top-level subcommand', async () => {
const { context, analytics, commandRunner } = createStubContext();
commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude');
commandRunner
.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json'])
.resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }]));
commandRunner
.on(CLAUDE_BINARY, ['plugin', 'list', '--json'])
.resolves(pluginListResult([{ id: CLAUDE_PLUGIN_ID, scope: 'user' }]));
const { context, analytics, commandRunner } = setupClaudeTest();
stubClaudeState(commandRunner, {
marketplaces: [{ name: CLAUDE_MARKETPLACE_NAME, plugins: [{ id: CLAUDE_PLUGIN_ID, scope: 'user' }] }],
});

const exitCode = await runCli(context, ['install', 'status', '--json']);

Expand Down Expand Up @@ -279,3 +256,9 @@ describe('resolveCbCommand', () => {
expect(resolveCbCommand(program, ['parent', '--', 'child'])).toBe('parent');
});
});

function setupClaudeTest(overrides?: Parameters<typeof createStubContext>[0]) {
const stub = createStubContext(overrides);
primeClaudeShellouts(stub.commandRunner);
return stub;
}
84 changes: 26 additions & 58 deletions packages/cli/src/commands/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,15 @@ import {
} from '#src/harnesses/ClaudeInstaller.ts';
import { getDescriptor } from '#src/harnesses/registry.ts';
import { environment } from '#src/testFactories.ts';
import { createStubContext, marketplaceListResult, pluginListResult } from '#src/testHelpers/index.ts';
import { createStubContext, primeClaudeShellouts, stubClaudeState } from '#src/testHelpers/index.ts';
import { runInstall } from './install.ts';

const CLAUDE_BINARY = getDescriptor('claude').binaryName;
const CODEX_BINARY = getDescriptor('codex').binaryName;

describe('runInstall', () => {
it('with --yes installs Claude when not yet wired up and reports the summary', async () => {
const { context, io, commandRunner, prompter } = createStubContext();
commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude');
commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']).resolves(marketplaceListResult([]));
commandRunner.on(CLAUDE_BINARY, ['plugin', 'list', '--json']).resolves(pluginListResult([]));
commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves();
commandRunner.on(CLAUDE_BINARY, ['plugin', 'install']).resolves();
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The amount of boilerplate / churn on these commands was too much. I split out some common helpers to make it cleaner. What's nice is that you can easily see in each test "what deviates from the boilerplate baseline". As noted in the PR description, I recommend reviewing commit-by-commit.

const { context, io, commandRunner, prompter } = setupTest();

await runInstall(context, { yes: true });

Expand All @@ -47,14 +42,10 @@ describe('runInstall', () => {
});

it('skips already-installed harnesses by default and notes them in the summary', async () => {
const { context, io, commandRunner, prompter } = createStubContext();
commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude');
commandRunner
.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json'])
.resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }]));
commandRunner
.on(CLAUDE_BINARY, ['plugin', 'list', '--json'])
.resolves(pluginListResult([{ id: CLAUDE_PLUGIN_ID, scope: 'user' }]));
const { context, io, commandRunner, prompter } = setupTest();
stubClaudeState(commandRunner, {
marketplaces: [{ name: CLAUDE_MARKETPLACE_NAME, plugins: [{ id: CLAUDE_PLUGIN_ID, scope: 'user' }] }],
});

await runInstall(context, { yes: true });

Expand All @@ -69,16 +60,10 @@ describe('runInstall', () => {
});

it('with --force re-runs install over an already-installed harness and drops the skipped suffix', async () => {
const { context, io, commandRunner, prompter } = createStubContext();
commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude');
commandRunner
.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json'])
.resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }]));
commandRunner
.on(CLAUDE_BINARY, ['plugin', 'list', '--json'])
.resolves(pluginListResult([{ id: CLAUDE_PLUGIN_ID, scope: 'user' }]));
commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves();
commandRunner.on(CLAUDE_BINARY, ['plugin', 'update']).resolves();
const { context, io, commandRunner, prompter } = setupTest();
stubClaudeState(commandRunner, {
marketplaces: [{ name: CLAUDE_MARKETPLACE_NAME, plugins: [{ id: CLAUDE_PLUGIN_ID, scope: 'user' }] }],
});

await runInstall(context, { yes: true, force: true });

Expand All @@ -91,14 +76,10 @@ describe('runInstall', () => {
});

it('treats an install at a different scope as already installed and skips by default', async () => {
const { context, io, commandRunner } = createStubContext();
commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude');
commandRunner
.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json'])
.resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }]));
commandRunner
.on(CLAUDE_BINARY, ['plugin', 'list', '--json'])
.resolves(pluginListResult([{ id: CLAUDE_PLUGIN_ID, scope: 'project' }]));
const { context, io, commandRunner } = setupTest();
stubClaudeState(commandRunner, {
marketplaces: [{ name: CLAUDE_MARKETPLACE_NAME, plugins: [{ id: CLAUDE_PLUGIN_ID, scope: 'project' }] }],
});

await runInstall(context, { yes: true });

Expand All @@ -112,14 +93,8 @@ describe('runInstall', () => {
});

it('does not skip Claude when only the marketplace is configured', async () => {
const { context, io, commandRunner } = createStubContext();
commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude');
commandRunner
.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json'])
.resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }]));
commandRunner.on(CLAUDE_BINARY, ['plugin', 'list', '--json']).resolves(pluginListResult([]));
commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves();
commandRunner.on(CLAUDE_BINARY, ['plugin', 'install']).resolves();
const { context, io, commandRunner } = setupTest();
stubClaudeState(commandRunner, { marketplaces: [{ name: CLAUDE_MARKETPLACE_NAME }] });

await runInstall(context, { yes: true });

Expand All @@ -133,13 +108,8 @@ describe('runInstall', () => {

it('records a status failure for one harness and still installs another detected harness', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'cb-cli-install-status-failure-'));
const { context, io, commandRunner } = createStubContext({ env: environment.build({ HOME: tmp }) });
commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude');
const { context, io, commandRunner } = setupTest({ env: environment.build({ HOME: tmp }) });
commandRunner.setWhich(CODEX_BINARY, '/usr/local/bin/codex');
commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']).resolves(marketplaceListResult([]));
commandRunner.on(CLAUDE_BINARY, ['plugin', 'list', '--json']).resolves(pluginListResult([]));
commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves();
commandRunner.on(CLAUDE_BINARY, ['plugin', 'install']).resolves();

try {
const configDir = join(tmp, '.codex');
Expand Down Expand Up @@ -174,10 +144,7 @@ describe('runInstall', () => {
});

it('without --yes prompts the user and skips when they decline', async () => {
const { context, io, commandRunner, prompter } = createStubContext();
commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude');
commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']).resolves(marketplaceListResult([]));
commandRunner.on(CLAUDE_BINARY, ['plugin', 'list', '--json']).resolves(pluginListResult([]));
const { context, io, commandRunner, prompter } = setupTest();
prompter.setConfirm(false);

await runInstall(context);
Expand All @@ -190,12 +157,7 @@ describe('runInstall', () => {
});

it('without --yes runs confirm + scope prompts and installs at the chosen scope', async () => {
const { context, commandRunner, prompter } = createStubContext();
commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude');
commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']).resolves(marketplaceListResult([]));
commandRunner.on(CLAUDE_BINARY, ['plugin', 'list', '--json']).resolves(pluginListResult([]));
commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves();
commandRunner.on(CLAUDE_BINARY, ['plugin', 'install']).resolves();
const { context, commandRunner, prompter } = setupTest();
prompter.setConfirm(true);
prompter.setSelect('project');

Expand All @@ -213,7 +175,7 @@ describe('runInstall', () => {
});

it('throws when no supported harnesses are detected', () => {
const { context, commandRunner, io } = createStubContext();
const { context, commandRunner, io } = setupTest();
commandRunner.setWhich(CLAUDE_BINARY, null);

expect(runInstall(context, { yes: true })).rejects.toBeInstanceOf(CommanderError);
Expand All @@ -222,6 +184,12 @@ describe('runInstall', () => {
});
});

function setupTest(overrides?: Parameters<typeof createStubContext>[0]) {
const stub = createStubContext(overrides);
primeClaudeShellouts(stub.commandRunner);
return stub;
}

async function captureError(promise: Promise<unknown>): Promise<unknown> {
try {
await promise;
Expand Down
Loading
Loading