mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-13 15:40:57 -07:00
375 lines
11 KiB
TypeScript
375 lines
11 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { FileCommandLoader } from './FileCommandLoader.js';
|
|
import {
|
|
Config,
|
|
getProjectCommandsDir,
|
|
getUserCommandsDir,
|
|
} from '@google/gemini-cli-core';
|
|
import mock from 'mock-fs';
|
|
import { assert } from 'vitest';
|
|
import { createMockCommandContext } from '../test-utils/mockCommandContext.js';
|
|
|
|
describe('FileCommandLoader', () => {
|
|
const signal: AbortSignal = new AbortController().signal;
|
|
|
|
afterEach(() => {
|
|
mock.restore();
|
|
});
|
|
|
|
it('loads a single command from a file', async () => {
|
|
const userCommandsDir = getUserCommandsDir();
|
|
mock({
|
|
[userCommandsDir]: {
|
|
'test.toml': 'prompt = "This is a test prompt"',
|
|
},
|
|
});
|
|
|
|
const loader = new FileCommandLoader(null as unknown as Config);
|
|
const commands = await loader.loadCommands(signal);
|
|
|
|
expect(commands).toHaveLength(1);
|
|
const command = commands[0];
|
|
expect(command).toBeDefined();
|
|
expect(command.name).toBe('test');
|
|
|
|
const result = await command.action?.(
|
|
createMockCommandContext({
|
|
invocation: {
|
|
raw: '/test',
|
|
name: 'test',
|
|
args: '',
|
|
},
|
|
}),
|
|
'',
|
|
);
|
|
if (result?.type === 'submit_prompt') {
|
|
expect(result.content).toBe('This is a test prompt');
|
|
} else {
|
|
assert.fail('Incorrect action type');
|
|
}
|
|
});
|
|
|
|
// Symlink creation on Windows requires special permissions that are not
|
|
// available in the standard CI environment. Therefore, we skip these tests
|
|
// on Windows to prevent CI failures. The core functionality is still
|
|
// validated on Linux and macOS.
|
|
const itif = (condition: boolean) => (condition ? it : it.skip);
|
|
|
|
itif(process.platform !== 'win32')(
|
|
'loads commands from a symlinked directory',
|
|
async () => {
|
|
const userCommandsDir = getUserCommandsDir();
|
|
const realCommandsDir = '/real/commands';
|
|
mock({
|
|
[realCommandsDir]: {
|
|
'test.toml': 'prompt = "This is a test prompt"',
|
|
},
|
|
// Symlink the user commands directory to the real one
|
|
[userCommandsDir]: mock.symlink({
|
|
path: realCommandsDir,
|
|
}),
|
|
});
|
|
|
|
const loader = new FileCommandLoader(null as unknown as Config);
|
|
const commands = await loader.loadCommands(signal);
|
|
|
|
expect(commands).toHaveLength(1);
|
|
const command = commands[0];
|
|
expect(command).toBeDefined();
|
|
expect(command.name).toBe('test');
|
|
},
|
|
);
|
|
|
|
itif(process.platform !== 'win32')(
|
|
'loads commands from a symlinked subdirectory',
|
|
async () => {
|
|
const userCommandsDir = getUserCommandsDir();
|
|
const realNamespacedDir = '/real/namespaced-commands';
|
|
mock({
|
|
[userCommandsDir]: {
|
|
namespaced: mock.symlink({
|
|
path: realNamespacedDir,
|
|
}),
|
|
},
|
|
[realNamespacedDir]: {
|
|
'my-test.toml': 'prompt = "This is a test prompt"',
|
|
},
|
|
});
|
|
|
|
const loader = new FileCommandLoader(null as unknown as Config);
|
|
const commands = await loader.loadCommands(signal);
|
|
|
|
expect(commands).toHaveLength(1);
|
|
const command = commands[0];
|
|
expect(command).toBeDefined();
|
|
expect(command.name).toBe('namespaced:my-test');
|
|
},
|
|
);
|
|
|
|
it('loads multiple commands', async () => {
|
|
const userCommandsDir = getUserCommandsDir();
|
|
mock({
|
|
[userCommandsDir]: {
|
|
'test1.toml': 'prompt = "Prompt 1"',
|
|
'test2.toml': 'prompt = "Prompt 2"',
|
|
},
|
|
});
|
|
|
|
const loader = new FileCommandLoader(null as unknown as Config);
|
|
const commands = await loader.loadCommands(signal);
|
|
|
|
expect(commands).toHaveLength(2);
|
|
});
|
|
|
|
it('creates deeply nested namespaces correctly', async () => {
|
|
const userCommandsDir = getUserCommandsDir();
|
|
|
|
mock({
|
|
[userCommandsDir]: {
|
|
gcp: {
|
|
pipelines: {
|
|
'run.toml': 'prompt = "run pipeline"',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const loader = new FileCommandLoader({
|
|
getProjectRoot: () => '/path/to/project',
|
|
} as Config);
|
|
const commands = await loader.loadCommands(signal);
|
|
expect(commands).toHaveLength(1);
|
|
expect(commands[0]!.name).toBe('gcp:pipelines:run');
|
|
});
|
|
|
|
it('creates namespaces from nested directories', async () => {
|
|
const userCommandsDir = getUserCommandsDir();
|
|
mock({
|
|
[userCommandsDir]: {
|
|
git: {
|
|
'commit.toml': 'prompt = "git commit prompt"',
|
|
},
|
|
},
|
|
});
|
|
|
|
const loader = new FileCommandLoader(null as unknown as Config);
|
|
const commands = await loader.loadCommands(signal);
|
|
|
|
expect(commands).toHaveLength(1);
|
|
const command = commands[0];
|
|
expect(command).toBeDefined();
|
|
expect(command.name).toBe('git:commit');
|
|
});
|
|
|
|
it('overrides user commands with project commands', async () => {
|
|
const userCommandsDir = getUserCommandsDir();
|
|
const projectCommandsDir = getProjectCommandsDir(process.cwd());
|
|
mock({
|
|
[userCommandsDir]: {
|
|
'test.toml': 'prompt = "User prompt"',
|
|
},
|
|
[projectCommandsDir]: {
|
|
'test.toml': 'prompt = "Project prompt"',
|
|
},
|
|
});
|
|
|
|
const loader = new FileCommandLoader({
|
|
getProjectRoot: () => process.cwd(),
|
|
} as Config);
|
|
const commands = await loader.loadCommands(signal);
|
|
|
|
expect(commands).toHaveLength(1);
|
|
const command = commands[0];
|
|
expect(command).toBeDefined();
|
|
|
|
const result = await command.action?.(
|
|
createMockCommandContext({
|
|
invocation: {
|
|
raw: '/test',
|
|
name: 'test',
|
|
args: '',
|
|
},
|
|
}),
|
|
'',
|
|
);
|
|
if (result?.type === 'submit_prompt') {
|
|
expect(result.content).toBe('Project prompt');
|
|
} else {
|
|
assert.fail('Incorrect action type');
|
|
}
|
|
});
|
|
|
|
it('ignores files with TOML syntax errors', async () => {
|
|
const userCommandsDir = getUserCommandsDir();
|
|
mock({
|
|
[userCommandsDir]: {
|
|
'invalid.toml': 'this is not valid toml',
|
|
'good.toml': 'prompt = "This one is fine"',
|
|
},
|
|
});
|
|
|
|
const loader = new FileCommandLoader(null as unknown as Config);
|
|
const commands = await loader.loadCommands(signal);
|
|
|
|
expect(commands).toHaveLength(1);
|
|
expect(commands[0].name).toBe('good');
|
|
});
|
|
|
|
it('ignores files that are semantically invalid (missing prompt)', async () => {
|
|
const userCommandsDir = getUserCommandsDir();
|
|
mock({
|
|
[userCommandsDir]: {
|
|
'no_prompt.toml': 'description = "This file is missing a prompt"',
|
|
'good.toml': 'prompt = "This one is fine"',
|
|
},
|
|
});
|
|
|
|
const loader = new FileCommandLoader(null as unknown as Config);
|
|
const commands = await loader.loadCommands(signal);
|
|
|
|
expect(commands).toHaveLength(1);
|
|
expect(commands[0].name).toBe('good');
|
|
});
|
|
|
|
it('handles filename edge cases correctly', async () => {
|
|
const userCommandsDir = getUserCommandsDir();
|
|
mock({
|
|
[userCommandsDir]: {
|
|
'test.v1.toml': 'prompt = "Test prompt"',
|
|
},
|
|
});
|
|
|
|
const loader = new FileCommandLoader(null as unknown as Config);
|
|
const commands = await loader.loadCommands(signal);
|
|
const command = commands[0];
|
|
expect(command).toBeDefined();
|
|
expect(command.name).toBe('test.v1');
|
|
});
|
|
|
|
it('handles file system errors gracefully', async () => {
|
|
mock({}); // Mock an empty file system
|
|
const loader = new FileCommandLoader(null as unknown as Config);
|
|
const commands = await loader.loadCommands(signal);
|
|
expect(commands).toHaveLength(0);
|
|
});
|
|
|
|
it('uses a default description if not provided', async () => {
|
|
const userCommandsDir = getUserCommandsDir();
|
|
mock({
|
|
[userCommandsDir]: {
|
|
'test.toml': 'prompt = "Test prompt"',
|
|
},
|
|
});
|
|
|
|
const loader = new FileCommandLoader(null as unknown as Config);
|
|
const commands = await loader.loadCommands(signal);
|
|
const command = commands[0];
|
|
expect(command).toBeDefined();
|
|
expect(command.description).toBe('Custom command from test.toml');
|
|
});
|
|
|
|
it('uses the provided description', async () => {
|
|
const userCommandsDir = getUserCommandsDir();
|
|
mock({
|
|
[userCommandsDir]: {
|
|
'test.toml': 'prompt = "Test prompt"\ndescription = "My test command"',
|
|
},
|
|
});
|
|
|
|
const loader = new FileCommandLoader(null as unknown as Config);
|
|
const commands = await loader.loadCommands(signal);
|
|
const command = commands[0];
|
|
expect(command).toBeDefined();
|
|
expect(command.description).toBe('My test command');
|
|
});
|
|
|
|
it('should sanitize colons in filenames to prevent namespace conflicts', async () => {
|
|
const userCommandsDir = getUserCommandsDir();
|
|
mock({
|
|
[userCommandsDir]: {
|
|
'legacy:command.toml': 'prompt = "This is a legacy command"',
|
|
},
|
|
});
|
|
|
|
const loader = new FileCommandLoader(null as unknown as Config);
|
|
const commands = await loader.loadCommands(signal);
|
|
|
|
expect(commands).toHaveLength(1);
|
|
const command = commands[0];
|
|
expect(command).toBeDefined();
|
|
|
|
// Verify that the ':' in the filename was replaced with an '_'
|
|
expect(command.name).toBe('legacy_command');
|
|
});
|
|
|
|
describe('Shorthand Argument Processor Integration', () => {
|
|
it('correctly processes a command with {{args}}', async () => {
|
|
const userCommandsDir = getUserCommandsDir();
|
|
mock({
|
|
[userCommandsDir]: {
|
|
'shorthand.toml':
|
|
'prompt = "The user wants to: {{args}}"\ndescription = "Shorthand test"',
|
|
},
|
|
});
|
|
|
|
const loader = new FileCommandLoader(null as unknown as Config);
|
|
const commands = await loader.loadCommands(signal);
|
|
const command = commands.find((c) => c.name === 'shorthand');
|
|
expect(command).toBeDefined();
|
|
|
|
const result = await command!.action?.(
|
|
createMockCommandContext({
|
|
invocation: {
|
|
raw: '/shorthand do something cool',
|
|
name: 'shorthand',
|
|
args: 'do something cool',
|
|
},
|
|
}),
|
|
'do something cool',
|
|
);
|
|
expect(result?.type).toBe('submit_prompt');
|
|
if (result?.type === 'submit_prompt') {
|
|
expect(result.content).toBe('The user wants to: do something cool');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Default Argument Processor Integration', () => {
|
|
it('correctly processes a command without {{args}}', async () => {
|
|
const userCommandsDir = getUserCommandsDir();
|
|
mock({
|
|
[userCommandsDir]: {
|
|
'model_led.toml':
|
|
'prompt = "This is the instruction."\ndescription = "Default processor test"',
|
|
},
|
|
});
|
|
|
|
const loader = new FileCommandLoader(null as unknown as Config);
|
|
const commands = await loader.loadCommands(signal);
|
|
const command = commands.find((c) => c.name === 'model_led');
|
|
expect(command).toBeDefined();
|
|
|
|
const result = await command!.action?.(
|
|
createMockCommandContext({
|
|
invocation: {
|
|
raw: '/model_led 1.2.0 added "a feature"',
|
|
name: 'model_led',
|
|
args: '1.2.0 added "a feature"',
|
|
},
|
|
}),
|
|
'1.2.0 added "a feature"',
|
|
);
|
|
expect(result?.type).toBe('submit_prompt');
|
|
if (result?.type === 'submit_prompt') {
|
|
const expectedContent =
|
|
'This is the instruction.\n\n/model_led 1.2.0 added "a feature"';
|
|
expect(result.content).toBe(expectedContent);
|
|
}
|
|
});
|
|
});
|
|
});
|