qwen-code/packages/cli/src/nonInteractiveCliCommands.test.ts
易良 5e1b8b0d59
feat(vscode-companion): support /export session command (#2592)
* feat(vscode-companion): support /export session command

* fix(vscode-ide-companion/webview): prefer ACP session id for export

* feat(vscode-ide-companion): support /export slash command

Add nested /export completion and ACP command availability for the VS Code companion.

Reuse the shared export flow, write to the default path, and show clickable export results in chat.

* fix(export): align slash command messaging

Restore the CLI export description to the existing wording.

Keep the VS Code companion error message consistent with the required /export subcommands.

* fix(webui): support explicit markdown file links

Handle local markdown file links in assistant messages even when automatic file-link detection is disabled.

Normalize encoded paths and line fragments so exported files can be opened from the VS Code webview.

* test(vscode-ide-companion): make export path assertion cross-platform

* fix(vscode-ide-companion): use public session export entrypoint

* fix(cli): replay standalone ESC after early capture

* fix(vscode-ide-companion): resolve rebase artifacts and vitest export alias

Remove duplicate AvailableCommand import caused by merge, and add
vitest resolve alias for @qwen-code/qwen-code/export so the session
export service tests can resolve the CLI export module from source.

* fix(cli): fix getAvailableCommands test mock to use getCommandsForMode

The test mock was only setting up getCommands but getAvailableCommands
calls getCommandsForMode. Add getCommandsForMode to the mock and set up
test data on it instead.

* fix(vscode-ide-companion): fix export file link click and add save dialog

- Fix file:/// URI handling in MarkdownRenderer: normalizeExplicitFileLink
  now strips the file:// scheme before checking isAbsolutePath, so exported
  file links are properly recognized and clickable
- Replace direct cwd file write with vscode.window.showSaveDialog() so
  users can choose the export destination and filename
- Handle cancelled save dialog gracefully (return null, skip success message)

* fix(webui): scope file link handler to file:// URIs only, fix # in filenames

- normalizeExplicitFileLink now returns early for file:// URIs without
  splitting on #, since vscode.Uri.file() encodes # as %23 in the path.
  This prevents filenames containing # from being truncated after decode.
- Explicit-link click handler now only fires for file:// URI hrefs,
  not arbitrary relative paths. This prevents model-generated markdown
  links from bypassing enableFileLinks=false and opening arbitrary files.
- Remove unused KNOWN_FILE_EXTENSIONS constant.

* fix(vscode-ide-companion): update export tests for save dialog, fix stale JSDoc

- Add showSaveDialog mock to sessionExportService.test.ts
- Update existing test to verify save dialog is called with correct args
- Add test for cancelled save dialog returning null
- Fix JSDoc that incorrectly claimed fallback-to-cwd behavior
2026-04-24 17:55:26 +08:00

385 lines
11 KiB
TypeScript

/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
getAvailableCommands,
handleSlashCommand,
} from './nonInteractiveCliCommands.js';
import type { Config } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from './config/settings.js';
import { CommandKind, type ExecutionMode } from './ui/commands/types.js';
import { filterCommandsForMode } from './services/commandUtils.js';
// Mock the CommandService
const mockGetCommands = vi.hoisted(() => vi.fn());
const mockGetCommandsForMode = vi.hoisted(() => vi.fn());
const mockCommandServiceCreate = vi.hoisted(() => vi.fn());
vi.mock('./services/CommandService.js', () => ({
CommandService: {
create: mockCommandServiceCreate,
},
}));
describe('handleSlashCommand', () => {
let mockConfig: Config;
let mockSettings: LoadedSettings;
let abortController: AbortController;
beforeEach(() => {
// getCommandsForMode applies real mode filtering on top of getCommands()
mockGetCommandsForMode.mockImplementation((mode: ExecutionMode) =>
filterCommandsForMode(mockGetCommands(), mode),
);
mockCommandServiceCreate.mockResolvedValue({
getCommands: mockGetCommands,
getCommandsForMode: mockGetCommandsForMode,
});
mockConfig = {
getExperimentalZedIntegration: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(false),
getSessionId: vi.fn().mockReturnValue('test-session'),
getFolderTrustFeature: vi.fn().mockReturnValue(false),
getFolderTrust: vi.fn().mockReturnValue(false),
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
setModelInvocableCommandsProvider: vi.fn(),
setModelInvocableCommandsExecutor: vi.fn(),
getDisabledSlashCommands: vi.fn().mockReturnValue([]),
storage: {},
} as unknown as Config;
mockSettings = {
system: { path: '', settings: {} },
systemDefaults: { path: '', settings: {} },
user: { path: '', settings: {} },
workspace: { path: '', settings: {} },
} as LoadedSettings;
abortController = new AbortController();
});
it('should return no_command for non-slash input', async () => {
const result = await handleSlashCommand(
'regular text',
abortController,
mockConfig,
mockSettings,
);
expect(result.type).toBe('no_command');
});
it('should return no_command for unknown slash commands', async () => {
mockGetCommands.mockReturnValue([]);
const result = await handleSlashCommand(
'/unknowncommand',
abortController,
mockConfig,
mockSettings,
);
expect(result.type).toBe('no_command');
});
it('should return unsupported for built-in commands without non-interactive supportedModes', async () => {
const mockHelpCommand = {
name: 'help',
description: 'Show help',
kind: CommandKind.BUILT_IN,
// No supportedModes → BUILT_IN fallback → interactive only
action: vi.fn(),
};
mockGetCommands.mockReturnValue([mockHelpCommand]);
const result = await handleSlashCommand(
'/help',
abortController,
mockConfig,
mockSettings,
);
expect(result.type).toBe('unsupported');
if (result.type === 'unsupported') {
expect(result.reason).toContain('/help');
expect(result.reason).toContain('not supported');
}
});
it('should return unsupported for /help when using default allowed list', async () => {
const mockHelpCommand = {
name: 'help',
description: 'Show help',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
};
mockGetCommands.mockReturnValue([mockHelpCommand]);
const result = await handleSlashCommand(
'/help',
abortController,
mockConfig,
mockSettings,
// Default allowed list: ['init', 'summary', 'compress']
);
expect(result.type).toBe('unsupported');
if (result.type === 'unsupported') {
expect(result.reason).toBe(
'The command "/help" is not supported in this mode.',
);
}
});
it('should execute local commands with non_interactive supportedModes', async () => {
const mockInitCommand = {
name: 'init',
description: 'Initialize project',
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
action: vi.fn().mockResolvedValue({
type: 'message',
messageType: 'info',
content: 'Project initialized',
}),
};
mockGetCommands.mockReturnValue([mockInitCommand]);
const result = await handleSlashCommand(
'/init',
abortController,
mockConfig,
mockSettings,
);
expect(result.type).toBe('message');
if (result.type === 'message') {
expect(result.content).toBe('Project initialized');
}
});
it('should execute /btw with non_interactive supportedModes', async () => {
const mockBtwCommand = {
name: 'btw',
description: 'Ask a side question',
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
action: vi.fn().mockResolvedValue({
type: 'message',
messageType: 'info',
content: 'btw> question\nanswer',
}),
};
mockGetCommands.mockReturnValue([mockBtwCommand]);
const result = await handleSlashCommand(
'/btw question',
abortController,
mockConfig,
mockSettings,
);
expect(mockBtwCommand.action).toHaveBeenCalled();
expect(result.type).toBe('message');
if (result.type === 'message') {
expect(result.content).toBe('btw> question\nanswer');
}
});
it('should execute FILE commands in any mode without explicit supportedModes', async () => {
const mockFileCommand = {
name: 'custom',
description: 'Custom file command',
kind: CommandKind.FILE,
action: vi.fn().mockResolvedValue({
type: 'submit_prompt',
content: [{ text: 'Custom prompt' }],
}),
};
mockGetCommands.mockReturnValue([mockFileCommand]);
const result = await handleSlashCommand(
'/custom',
abortController,
mockConfig,
mockSettings,
);
expect(result.type).toBe('submit_prompt');
if (result.type === 'submit_prompt') {
expect(result.content).toEqual([{ text: 'Custom prompt' }]);
}
});
it('should return unsupported for other built-in commands like /quit', async () => {
const mockQuitCommand = {
name: 'quit',
description: 'Quit application',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
};
mockGetCommands.mockReturnValue([mockQuitCommand]);
const result = await handleSlashCommand(
'/quit',
abortController,
mockConfig,
mockSettings,
);
expect(result.type).toBe('unsupported');
if (result.type === 'unsupported') {
expect(result.reason).toContain('/quit');
expect(result.reason).toContain('not supported');
}
});
it('should handle command with no action', async () => {
const mockCommand = {
name: 'noaction',
description: 'Command without action',
kind: CommandKind.FILE,
// No action property
};
mockGetCommands.mockReturnValue([mockCommand]);
const result = await handleSlashCommand(
'/noaction',
abortController,
mockConfig,
mockSettings,
);
expect(result.type).toBe('no_command');
});
it('should return message when command returns void', async () => {
const mockCommand = {
name: 'voidcmd',
description: 'Command that returns void',
kind: CommandKind.FILE,
action: vi.fn().mockResolvedValue(undefined),
};
mockGetCommands.mockReturnValue([mockCommand]);
const result = await handleSlashCommand(
'/voidcmd',
abortController,
mockConfig,
mockSettings,
);
expect(result.type).toBe('message');
if (result.type === 'message') {
expect(result.content).toBe('Command executed successfully.');
expect(result.messageType).toBe('info');
}
});
describe('disabled slash commands', () => {
const mockDisabledCommand = {
name: 'help',
description: 'Show help',
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
action: vi.fn().mockResolvedValue({
type: 'message',
messageType: 'info',
content: 'Help content',
}),
};
it('should return unsupported with disabled reason for a disabled command', async () => {
mockGetCommands.mockReturnValue([mockDisabledCommand]);
vi.mocked(mockConfig.getDisabledSlashCommands).mockReturnValue(['help']);
const result = await handleSlashCommand(
'/help',
abortController,
mockConfig,
mockSettings,
);
expect(result.type).toBe('unsupported');
if (result.type === 'unsupported') {
expect(result.reason).toContain('disabled');
expect(result.originalType).toBe('filtered_command');
}
});
it('should match disabled command names case-insensitively', async () => {
mockGetCommands.mockReturnValue([mockDisabledCommand]);
vi.mocked(mockConfig.getDisabledSlashCommands).mockReturnValue(['HELP']);
const result = await handleSlashCommand(
'/help',
abortController,
mockConfig,
mockSettings,
);
expect(result.type).toBe('unsupported');
if (result.type === 'unsupported') {
expect(result.reason).toContain('disabled');
}
});
it('should still return no_command for genuinely unknown commands even with a denylist', async () => {
mockGetCommands.mockReturnValue([mockDisabledCommand]);
vi.mocked(mockConfig.getDisabledSlashCommands).mockReturnValue(['help']);
const result = await handleSlashCommand(
'/unknowncommand',
abortController,
mockConfig,
mockSettings,
);
expect(result.type).toBe('no_command');
});
});
});
describe('getAvailableCommands', () => {
let mockConfig: Config;
beforeEach(() => {
mockCommandServiceCreate.mockResolvedValue({
getCommands: mockGetCommands,
getCommandsForMode: mockGetCommandsForMode,
});
mockConfig = {
getExperimentalZedIntegration: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(false),
getSessionId: vi.fn().mockReturnValue('test-session'),
getFolderTrustFeature: vi.fn().mockReturnValue(false),
getFolderTrust: vi.fn().mockReturnValue(false),
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
getDisabledSlashCommands: vi.fn().mockReturnValue([]),
storage: {},
} as unknown as Config;
});
it('includes /export in the default non-interactive command list', async () => {
mockGetCommandsForMode.mockReturnValue([
{
name: 'export',
description: 'Export current session',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
},
]);
const commands = await getAvailableCommands(
mockConfig,
new AbortController().signal,
);
expect(commands.map((command) => command.name)).toContain('export');
});
});