/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { CommandService } from './CommandService.js'; import { type ICommandLoader } from './types.js'; import { CommandKind, type SlashCommand } from '../ui/commands/types.js'; const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({ name, description: `Description for ${name}`, kind, action: vi.fn(), }); const mockCommandA = createMockCommand('command-a', CommandKind.BUILT_IN); const mockCommandB = createMockCommand('command-b', CommandKind.BUILT_IN); const mockCommandC = createMockCommand('command-c', CommandKind.FILE); const mockCommandB_Override = createMockCommand('command-b', CommandKind.FILE); class MockCommandLoader implements ICommandLoader { private commandsToLoad: SlashCommand[]; constructor(commandsToLoad: SlashCommand[]) { this.commandsToLoad = commandsToLoad; } loadCommands = vi.fn( async (): Promise => Promise.resolve(this.commandsToLoad), ); } describe('CommandService', () => { beforeEach(() => { vi.spyOn(console, 'debug').mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); }); it('should load commands from a single loader', async () => { const mockLoader = new MockCommandLoader([mockCommandA, mockCommandB]); const service = await CommandService.create( [mockLoader], new AbortController().signal, ); const commands = service.getCommands(); expect(mockLoader.loadCommands).toHaveBeenCalledTimes(1); expect(commands).toHaveLength(2); expect(commands).toEqual( expect.arrayContaining([mockCommandA, mockCommandB]), ); }); it('should aggregate commands from multiple loaders', async () => { const loader1 = new MockCommandLoader([mockCommandA]); const loader2 = new MockCommandLoader([mockCommandC]); const service = await CommandService.create( [loader1, loader2], new AbortController().signal, ); const commands = service.getCommands(); expect(loader1.loadCommands).toHaveBeenCalledTimes(1); expect(loader2.loadCommands).toHaveBeenCalledTimes(1); expect(commands).toHaveLength(2); expect(commands).toEqual( expect.arrayContaining([mockCommandA, mockCommandC]), ); }); it('should override commands from earlier loaders with those from later loaders', async () => { const loader1 = new MockCommandLoader([mockCommandA, mockCommandB]); const loader2 = new MockCommandLoader([ mockCommandB_Override, mockCommandC, ]); const service = await CommandService.create( [loader1, loader2], new AbortController().signal, ); const commands = service.getCommands(); expect(commands).toHaveLength(3); // Should be A, C, and the overridden B. // The final list should contain the override from the *last* loader. const commandB = commands.find((cmd) => cmd.name === 'command-b'); expect(commandB).toBeDefined(); expect(commandB?.kind).toBe(CommandKind.FILE); // Verify it's the overridden version. expect(commandB).toEqual(mockCommandB_Override); // Ensure the other commands are still present. expect(commands).toEqual( expect.arrayContaining([ mockCommandA, mockCommandC, mockCommandB_Override, ]), ); }); it('should handle loaders that return an empty array of commands gracefully', async () => { const loader1 = new MockCommandLoader([mockCommandA]); const emptyLoader = new MockCommandLoader([]); const loader3 = new MockCommandLoader([mockCommandB]); const service = await CommandService.create( [loader1, emptyLoader, loader3], new AbortController().signal, ); const commands = service.getCommands(); expect(emptyLoader.loadCommands).toHaveBeenCalledTimes(1); expect(commands).toHaveLength(2); expect(commands).toEqual( expect.arrayContaining([mockCommandA, mockCommandB]), ); }); it('should load commands from successful loaders even if one fails', async () => { const successfulLoader = new MockCommandLoader([mockCommandA]); const failingLoader = new MockCommandLoader([]); const error = new Error('Loader failed'); vi.spyOn(failingLoader, 'loadCommands').mockRejectedValue(error); const service = await CommandService.create( [successfulLoader, failingLoader], new AbortController().signal, ); const commands = service.getCommands(); expect(commands).toHaveLength(1); expect(commands).toEqual([mockCommandA]); expect(console.debug).toHaveBeenCalledWith( 'A command loader failed:', error, ); }); it('getCommands should return a readonly array that cannot be mutated', async () => { const service = await CommandService.create( [new MockCommandLoader([mockCommandA])], new AbortController().signal, ); const commands = service.getCommands(); // Expect it to throw a TypeError at runtime because the array is frozen. expect(() => { // @ts-expect-error - Testing immutability is intentional here. commands.push(mockCommandB); }).toThrow(); // Verify the original array was not mutated. expect(service.getCommands()).toHaveLength(1); }); it('should pass the abort signal to all loaders', async () => { const controller = new AbortController(); const signal = controller.signal; const loader1 = new MockCommandLoader([mockCommandA]); const loader2 = new MockCommandLoader([mockCommandB]); await CommandService.create([loader1, loader2], signal); expect(loader1.loadCommands).toHaveBeenCalledTimes(1); expect(loader1.loadCommands).toHaveBeenCalledWith(signal); expect(loader2.loadCommands).toHaveBeenCalledTimes(1); expect(loader2.loadCommands).toHaveBeenCalledWith(signal); }); });