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
141 changes: 62 additions & 79 deletions packages/cli/src/services/CommandService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,8 @@ import { type ICommandLoader } from './types.js';
import { CommandKind, type SlashCommand } from '../ui/commands/types.js';
import { debugLogger } from '@google/gemini-cli-core';

const createMockCommand = (
name: string,
kind: CommandKind,
namespace?: string,
): SlashCommand => ({
const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({
name,
namespace,
description: `Description for ${name}`,
kind,
action: vi.fn(),
Expand Down Expand Up @@ -184,18 +179,18 @@ describe('CommandService', () => {
expect(loader2.loadCommands).toHaveBeenCalledWith(signal);
});

it('should apply namespaces to commands from user and extensions', async () => {
it('should rename extension commands when they conflict', async () => {
const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN);
const userCommand = createMockCommand('sync', CommandKind.FILE, 'user');
const userCommand = createMockCommand('sync', CommandKind.FILE);
const extensionCommand1 = {
...createMockCommand('deploy', CommandKind.FILE, 'firebase'),
...createMockCommand('deploy', CommandKind.FILE),
extensionName: 'firebase',
description: 'Deploy to Firebase',
description: '[firebase] Deploy to Firebase',
};
const extensionCommand2 = {
...createMockCommand('sync', CommandKind.FILE, 'git-helper'),
...createMockCommand('sync', CommandKind.FILE),
extensionName: 'git-helper',
description: 'Sync with remote',
description: '[git-helper] Sync with remote',
};

const mockLoader1 = new MockCommandLoader([builtinCommand]);
Expand All @@ -213,28 +208,30 @@ describe('CommandService', () => {
const commands = service.getCommands();
expect(commands).toHaveLength(4);

// Built-in command keeps original name because it has no namespace
// Built-in command keeps original name
const deployBuiltin = commands.find(
(cmd) => cmd.name === 'deploy' && !cmd.extensionName,
);
expect(deployBuiltin).toBeDefined();
expect(deployBuiltin?.kind).toBe(CommandKind.BUILT_IN);

// Extension command gets namespaced, preventing conflict with built-in
// Extension command conflicting with built-in gets renamed
const deployExtension = commands.find(
(cmd) => cmd.name === 'firebase:deploy',
(cmd) => cmd.name === 'firebase.deploy',
);
expect(deployExtension).toBeDefined();
expect(deployExtension?.extensionName).toBe('firebase');

// User command gets namespaced
const syncUser = commands.find((cmd) => cmd.name === 'user:sync');
// User command keeps original name
const syncUser = commands.find(
(cmd) => cmd.name === 'sync' && !cmd.extensionName,
);
expect(syncUser).toBeDefined();
expect(syncUser?.kind).toBe(CommandKind.FILE);

// Extension command gets namespaced
// Extension command conflicting with user command gets renamed
const syncExtension = commands.find(
(cmd) => cmd.name === 'git-helper:sync',
(cmd) => cmd.name === 'git-helper.sync',
);
expect(syncExtension).toBeDefined();
expect(syncExtension?.extensionName).toBe('git-helper');
Expand Down Expand Up @@ -272,16 +269,16 @@ describe('CommandService', () => {
expect(deployCommand?.kind).toBe(CommandKind.FILE);
});

it('should handle namespaced name conflicts when renaming extension commands', async () => {
// User has both /deploy and /gcp:deploy commands
it('should handle secondary conflicts when renaming extension commands', async () => {
// User has both /deploy and /gcp.deploy commands
const userCommand1 = createMockCommand('deploy', CommandKind.FILE);
const userCommand2 = createMockCommand('gcp:deploy', CommandKind.FILE);
const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE);

// Extension also has a deploy command that will resolve to /gcp:deploy and conflict with userCommand2
// Extension also has a deploy command that will conflict with user's /deploy
const extensionCommand = {
...createMockCommand('deploy', CommandKind.FILE, 'gcp'),
...createMockCommand('deploy', CommandKind.FILE),
extensionName: 'gcp',
description: 'Deploy to Google Cloud',
description: '[gcp] Deploy to Google Cloud',
};

const mockLoader = new MockCommandLoader([
Expand All @@ -304,31 +301,31 @@ describe('CommandService', () => {
);
expect(deployUser).toBeDefined();

// User's command keeps its name
// User's dot notation command keeps its name
const gcpDeployUser = commands.find(
(cmd) => cmd.name === 'gcp:deploy' && !cmd.extensionName,
(cmd) => cmd.name === 'gcp.deploy' && !cmd.extensionName,
);
expect(gcpDeployUser).toBeDefined();

// Extension command gets renamed with suffix due to namespaced name conflict
// Extension command gets renamed with suffix due to secondary conflict
const deployExtension = commands.find(
(cmd) => cmd.name === 'gcp:deploy1' && cmd.extensionName === 'gcp',
(cmd) => cmd.name === 'gcp.deploy1' && cmd.extensionName === 'gcp',
);
expect(deployExtension).toBeDefined();
expect(deployExtension?.description).toBe('Deploy to Google Cloud');
expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud');
});

it('should handle multiple namespaced name conflicts with incrementing suffixes', async () => {
// User has /deploy, /gcp:deploy, and /gcp:deploy1
it('should handle multiple secondary conflicts with incrementing suffixes', async () => {
// User has /deploy, /gcp.deploy, and /gcp.deploy1
const userCommand1 = createMockCommand('deploy', CommandKind.FILE);
const userCommand2 = createMockCommand('gcp:deploy', CommandKind.FILE);
const userCommand3 = createMockCommand('gcp:deploy1', CommandKind.FILE);
const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE);
const userCommand3 = createMockCommand('gcp.deploy1', CommandKind.FILE);

// Extension has a deploy command which resolves to /gcp:deploy
// Extension has a deploy command
const extensionCommand = {
...createMockCommand('deploy', CommandKind.FILE, 'gcp'),
...createMockCommand('deploy', CommandKind.FILE),
extensionName: 'gcp',
description: 'Deploy to Google Cloud',
description: '[gcp] Deploy to Google Cloud',
};

const mockLoader = new MockCommandLoader([
Expand All @@ -348,19 +345,16 @@ describe('CommandService', () => {

// Extension command gets renamed with suffix 2 due to multiple conflicts
const deployExtension = commands.find(
(cmd) => cmd.name === 'gcp:deploy2' && cmd.extensionName === 'gcp',
(cmd) => cmd.name === 'gcp.deploy2' && cmd.extensionName === 'gcp',
);
expect(deployExtension).toBeDefined();
expect(deployExtension?.description).toBe('Deploy to Google Cloud');
expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud');
});

it('should report extension namespaced name conflicts via getConflicts', async () => {
const builtinCommand = createMockCommand(
'firebase:deploy',
CommandKind.BUILT_IN,
);
it('should report conflicts via getConflicts', async () => {
const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN);
const extensionCommand = {
...createMockCommand('deploy', CommandKind.FILE, 'firebase'),
...createMockCommand('deploy', CommandKind.FILE),
extensionName: 'firebase',
};

Expand All @@ -378,29 +372,29 @@ describe('CommandService', () => {
expect(conflicts).toHaveLength(1);

expect(conflicts[0]).toMatchObject({
name: 'firebase:deploy',
name: 'deploy',
winner: builtinCommand,
losers: [
{
renamedTo: 'firebase:deploy1',
renamedTo: 'firebase.deploy',
command: expect.objectContaining({
name: 'deploy',
namespace: 'firebase',
extensionName: 'firebase',
}),
},
],
});
});

it('should report extension vs extension namespaced name conflicts correctly', async () => {
// Both extensions try to register 'firebase:deploy'
it('should report extension vs extension conflicts correctly', async () => {
// Both extensions try to register 'deploy'
const extension1Command = {
...createMockCommand('deploy', CommandKind.FILE, 'firebase'),
...createMockCommand('deploy', CommandKind.FILE),
extensionName: 'firebase',
};
const extension2Command = {
...createMockCommand('deploy', CommandKind.FILE, 'firebase'),
extensionName: 'firebase',
...createMockCommand('deploy', CommandKind.FILE),
extensionName: 'aws',
};

const mockLoader = new MockCommandLoader([
Expand All @@ -417,37 +411,32 @@ describe('CommandService', () => {
expect(conflicts).toHaveLength(1);

expect(conflicts[0]).toMatchObject({
name: 'firebase:deploy',
name: 'deploy',
winner: expect.objectContaining({
name: 'firebase:deploy',
name: 'deploy',
extensionName: 'firebase',
}),
losers: [
{
renamedTo: 'firebase:deploy1',
renamedTo: 'aws.deploy', // ext2 is 'aws' and it lost because it was second in the list
command: expect.objectContaining({
name: 'deploy',
extensionName: 'firebase',
extensionName: 'aws',
}),
},
],
});
});

it('should report multiple extension namespaced name conflicts for the same name', async () => {
// Built-in command is 'firebase:deploy'
const builtinCommand = createMockCommand(
'firebase:deploy',
CommandKind.BUILT_IN,
);
// Two extension commands from extension 'firebase' also try to be 'firebase:deploy'
it('should report multiple conflicts for the same command name', async () => {
const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN);
const ext1 = {
...createMockCommand('deploy', CommandKind.FILE, 'firebase'),
extensionName: 'firebase',
...createMockCommand('deploy', CommandKind.FILE),
extensionName: 'ext1',
};
const ext2 = {
...createMockCommand('deploy', CommandKind.FILE, 'firebase'),
extensionName: 'firebase',
...createMockCommand('deploy', CommandKind.FILE),
extensionName: 'ext2',
};

const mockLoader = new MockCommandLoader([builtinCommand, ext1, ext2]);
Expand All @@ -459,23 +448,17 @@ describe('CommandService', () => {

const conflicts = service.getConflicts();
expect(conflicts).toHaveLength(1);
expect(conflicts[0].name).toBe('firebase:deploy');
expect(conflicts[0].name).toBe('deploy');
expect(conflicts[0].losers).toHaveLength(2);
expect(conflicts[0].losers).toEqual(
expect.arrayContaining([
expect.objectContaining({
renamedTo: 'firebase:deploy1',
command: expect.objectContaining({
name: 'deploy',
namespace: 'firebase',
}),
renamedTo: 'ext1.deploy',
command: expect.objectContaining({ extensionName: 'ext1' }),
}),
expect.objectContaining({
renamedTo: 'firebase:deploy2',
command: expect.objectContaining({
name: 'deploy',
namespace: 'firebase',
}),
renamedTo: 'ext2.deploy',
command: expect.objectContaining({ extensionName: 'ext2' }),
}),
]),
);
Expand Down
Loading
Loading