- Updated all package.json name fields and dependency references - Updated all TypeScript/JavaScript imports - Updated .woodpecker/publish.yml filters and registry paths - Updated tools/install.sh scope default - Updated .npmrc registry paths (worktree + host) - Enhanced update-checker.ts with checkForAllUpdates() multi-package support - Updated CLI update command to show table of all packages - Added KNOWN_PACKAGES, formatAllPackagesTable, getInstallAllCommand - Marked checkForUpdate() with @deprecated JSDoc Closes #391
349 lines
11 KiB
TypeScript
349 lines
11 KiB
TypeScript
/**
|
|
* Integration tests for TUI command parsing + registry (P8-019)
|
|
*
|
|
* Covers:
|
|
* - parseSlashCommand() + commandRegistry.find() round-trip for all aliases
|
|
* - /help, /stop, /cost, /status resolve to 'local' execution
|
|
* - Unknown commands return null from find()
|
|
* - Alias resolution: /h → help, /m → model, /n → new, etc.
|
|
* - filterCommands prefix filtering
|
|
*/
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { parseSlashCommand } from './parse.js';
|
|
import { CommandRegistry } from './registry.js';
|
|
import type { CommandDef } from '@mosaicstack/types';
|
|
|
|
// ─── Parse + Registry Round-trip ─────────────────────────────────────────────
|
|
|
|
describe('parseSlashCommand + CommandRegistry — integration', () => {
|
|
let registry: CommandRegistry;
|
|
|
|
// Gateway-style commands to simulate a live manifest
|
|
const gatewayCommands: CommandDef[] = [
|
|
{
|
|
name: 'model',
|
|
description: 'Switch the active model',
|
|
aliases: ['m'],
|
|
args: [{ name: 'model-name', type: 'string', optional: false, description: 'Model name' }],
|
|
scope: 'core',
|
|
execution: 'socket',
|
|
available: true,
|
|
},
|
|
{
|
|
name: 'thinking',
|
|
description: 'Set thinking level',
|
|
aliases: ['t'],
|
|
args: [
|
|
{
|
|
name: 'level',
|
|
type: 'enum',
|
|
optional: false,
|
|
values: ['none', 'low', 'medium', 'high', 'auto'],
|
|
description: 'Thinking level',
|
|
},
|
|
],
|
|
scope: 'core',
|
|
execution: 'socket',
|
|
available: true,
|
|
},
|
|
{
|
|
name: 'new',
|
|
description: 'Start a new conversation',
|
|
aliases: ['n'],
|
|
scope: 'core',
|
|
execution: 'socket',
|
|
available: true,
|
|
},
|
|
{
|
|
name: 'agent',
|
|
description: 'Switch or list available agents',
|
|
aliases: ['a'],
|
|
args: [{ name: 'args', type: 'string', optional: true, description: 'list or <agent-id>' }],
|
|
scope: 'agent',
|
|
execution: 'socket',
|
|
available: true,
|
|
},
|
|
{
|
|
name: 'preferences',
|
|
description: 'View or set user preferences',
|
|
aliases: ['pref'],
|
|
args: [
|
|
{
|
|
name: 'action',
|
|
type: 'enum',
|
|
optional: true,
|
|
values: ['show', 'set', 'reset'],
|
|
description: 'Action',
|
|
},
|
|
],
|
|
scope: 'core',
|
|
execution: 'rest',
|
|
available: true,
|
|
},
|
|
{
|
|
name: 'gc',
|
|
description: 'Trigger garbage collection sweep',
|
|
aliases: [],
|
|
scope: 'core',
|
|
execution: 'socket',
|
|
available: true,
|
|
},
|
|
{
|
|
name: 'mission',
|
|
description: 'View or set active mission',
|
|
aliases: [],
|
|
args: [{ name: 'args', type: 'string', optional: true, description: 'status | set <id>' }],
|
|
scope: 'agent',
|
|
execution: 'socket',
|
|
available: true,
|
|
},
|
|
];
|
|
|
|
beforeEach(() => {
|
|
registry = new CommandRegistry();
|
|
registry.updateManifest({ version: 1, commands: gatewayCommands, skills: [] });
|
|
});
|
|
|
|
// ── parseSlashCommand tests ──
|
|
|
|
it('returns null for non-slash input', () => {
|
|
expect(parseSlashCommand('hello world')).toBeNull();
|
|
expect(parseSlashCommand('')).toBeNull();
|
|
expect(parseSlashCommand('model')).toBeNull();
|
|
});
|
|
|
|
it('parses "/model claude-3-opus" → command=model args=claude-3-opus', () => {
|
|
const parsed = parseSlashCommand('/model claude-3-opus');
|
|
expect(parsed).not.toBeNull();
|
|
expect(parsed!.command).toBe('model');
|
|
expect(parsed!.args).toBe('claude-3-opus');
|
|
expect(parsed!.raw).toBe('/model claude-3-opus');
|
|
});
|
|
|
|
it('parses "/gc" with no args → command=gc args=null', () => {
|
|
const parsed = parseSlashCommand('/gc');
|
|
expect(parsed).not.toBeNull();
|
|
expect(parsed!.command).toBe('gc');
|
|
expect(parsed!.args).toBeNull();
|
|
});
|
|
|
|
it('parses "/system you are a helpful assistant" → args contains full text', () => {
|
|
const parsed = parseSlashCommand('/system you are a helpful assistant');
|
|
expect(parsed!.command).toBe('system');
|
|
expect(parsed!.args).toBe('you are a helpful assistant');
|
|
});
|
|
|
|
it('parses "/help" → command=help args=null', () => {
|
|
const parsed = parseSlashCommand('/help');
|
|
expect(parsed!.command).toBe('help');
|
|
expect(parsed!.args).toBeNull();
|
|
});
|
|
|
|
// ── Round-trip: parse then find ──
|
|
|
|
it('round-trip: /m → resolves to "model" command via alias', () => {
|
|
const parsed = parseSlashCommand('/m claude-3-haiku');
|
|
expect(parsed).not.toBeNull();
|
|
const cmd = registry.find(parsed!.command);
|
|
expect(cmd).not.toBeNull();
|
|
// /m → model (alias map in registry)
|
|
expect(cmd!.name === 'model' || cmd!.aliases.includes('m')).toBe(true);
|
|
});
|
|
|
|
it('round-trip: /h → resolves to "help" (local command)', () => {
|
|
const parsed = parseSlashCommand('/h');
|
|
expect(parsed).not.toBeNull();
|
|
const cmd = registry.find(parsed!.command);
|
|
expect(cmd).not.toBeNull();
|
|
expect(cmd!.name === 'help' || cmd!.aliases.includes('h')).toBe(true);
|
|
});
|
|
|
|
it('round-trip: /n → resolves to "new" via gateway manifest', () => {
|
|
const parsed = parseSlashCommand('/n');
|
|
expect(parsed).not.toBeNull();
|
|
const cmd = registry.find(parsed!.command);
|
|
expect(cmd).not.toBeNull();
|
|
expect(cmd!.name === 'new' || cmd!.aliases.includes('n')).toBe(true);
|
|
});
|
|
|
|
it('round-trip: /a → resolves to "agent" via gateway manifest', () => {
|
|
const parsed = parseSlashCommand('/a list');
|
|
expect(parsed).not.toBeNull();
|
|
const cmd = registry.find(parsed!.command);
|
|
expect(cmd).not.toBeNull();
|
|
expect(cmd!.name === 'agent' || cmd!.aliases.includes('a')).toBe(true);
|
|
});
|
|
|
|
it('round-trip: /pref → resolves to "preferences" via alias', () => {
|
|
const parsed = parseSlashCommand('/pref show');
|
|
expect(parsed).not.toBeNull();
|
|
const cmd = registry.find(parsed!.command);
|
|
expect(cmd).not.toBeNull();
|
|
expect(cmd!.name === 'preferences' || cmd!.aliases.includes('pref')).toBe(true);
|
|
});
|
|
|
|
it('round-trip: /t → resolves to "thinking" via alias', () => {
|
|
const parsed = parseSlashCommand('/t high');
|
|
expect(parsed).not.toBeNull();
|
|
const cmd = registry.find(parsed!.command);
|
|
expect(cmd).not.toBeNull();
|
|
expect(cmd!.name === 'thinking' || cmd!.aliases.includes('t')).toBe(true);
|
|
});
|
|
|
|
// ── Local commands resolve to 'local' execution ──
|
|
|
|
it('/help resolves to local execution', () => {
|
|
const cmd = registry.find('help');
|
|
expect(cmd).not.toBeNull();
|
|
expect(cmd!.execution).toBe('local');
|
|
});
|
|
|
|
it('/stop resolves to local execution', () => {
|
|
const cmd = registry.find('stop');
|
|
expect(cmd).not.toBeNull();
|
|
expect(cmd!.execution).toBe('local');
|
|
});
|
|
|
|
it('/cost resolves to local execution', () => {
|
|
const cmd = registry.find('cost');
|
|
expect(cmd).not.toBeNull();
|
|
expect(cmd!.execution).toBe('local');
|
|
});
|
|
|
|
it('/status resolves to local execution (TUI local override)', () => {
|
|
const cmd = registry.find('status');
|
|
expect(cmd).not.toBeNull();
|
|
// status is 'local' in the TUI registry (local takes precedence over gateway)
|
|
expect(cmd!.execution).toBe('local');
|
|
});
|
|
|
|
// ── Unknown commands return null ──
|
|
|
|
it('find() returns null for unknown command', () => {
|
|
expect(registry.find('nonexistent')).toBeNull();
|
|
expect(registry.find('xyz')).toBeNull();
|
|
expect(registry.find('')).toBeNull();
|
|
});
|
|
|
|
it('find() returns null when no gateway manifest and command not local', () => {
|
|
const emptyRegistry = new CommandRegistry();
|
|
expect(emptyRegistry.find('model')).toBeNull();
|
|
expect(emptyRegistry.find('gc')).toBeNull();
|
|
});
|
|
|
|
// ── getAll returns combined local + gateway ──
|
|
|
|
it('getAll() includes both local and gateway commands', () => {
|
|
const all = registry.getAll();
|
|
const names = all.map((c) => c.name);
|
|
// Local commands
|
|
expect(names).toContain('help');
|
|
expect(names).toContain('stop');
|
|
expect(names).toContain('cost');
|
|
expect(names).toContain('status');
|
|
// Gateway commands
|
|
expect(names).toContain('model');
|
|
expect(names).toContain('gc');
|
|
});
|
|
|
|
it('getLocalCommands() returns only local commands', () => {
|
|
const local = registry.getLocalCommands();
|
|
expect(local.every((c) => c.execution === 'local')).toBe(true);
|
|
expect(local.some((c) => c.name === 'help')).toBe(true);
|
|
expect(local.some((c) => c.name === 'stop')).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── filterCommands (autocomplete) ────────────────────────────────────────────
|
|
|
|
describe('filterCommands (from CommandAutocomplete)', () => {
|
|
// Import inline since filterCommands is not exported — replicate the logic here
|
|
function filterCommands(commands: CommandDef[], query: string): CommandDef[] {
|
|
if (!query) return commands;
|
|
const q = query.toLowerCase();
|
|
return commands.filter(
|
|
(c) =>
|
|
c.name.includes(q) ||
|
|
c.aliases.some((a) => a.includes(q)) ||
|
|
c.description.toLowerCase().includes(q),
|
|
);
|
|
}
|
|
|
|
const commands: CommandDef[] = [
|
|
{
|
|
name: 'model',
|
|
description: 'Switch the active model',
|
|
aliases: ['m'],
|
|
scope: 'core',
|
|
execution: 'socket',
|
|
available: true,
|
|
},
|
|
{
|
|
name: 'mission',
|
|
description: 'View or set active mission',
|
|
aliases: [],
|
|
scope: 'agent',
|
|
execution: 'socket',
|
|
available: true,
|
|
},
|
|
{
|
|
name: 'help',
|
|
description: 'Show available commands',
|
|
aliases: ['h'],
|
|
scope: 'core',
|
|
execution: 'local',
|
|
available: true,
|
|
},
|
|
{
|
|
name: 'gc',
|
|
description: 'Trigger garbage collection sweep',
|
|
aliases: [],
|
|
scope: 'core',
|
|
execution: 'socket',
|
|
available: true,
|
|
},
|
|
];
|
|
|
|
it('returns all commands when query is empty', () => {
|
|
expect(filterCommands(commands, '')).toHaveLength(commands.length);
|
|
});
|
|
|
|
it('filters by name prefix "mi" → mission only (not model, as "mi" not in model name or aliases)', () => {
|
|
const result = filterCommands(commands, 'mi');
|
|
const names = result.map((c) => c.name);
|
|
expect(names).toContain('mission');
|
|
expect(names).not.toContain('gc');
|
|
});
|
|
|
|
it('filters by name prefix "mo" → model only', () => {
|
|
const result = filterCommands(commands, 'mo');
|
|
const names = result.map((c) => c.name);
|
|
expect(names).toContain('model');
|
|
expect(names).not.toContain('mission');
|
|
expect(names).not.toContain('gc');
|
|
});
|
|
|
|
it('filters by exact name "gc" → gc only', () => {
|
|
const result = filterCommands(commands, 'gc');
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]!.name).toBe('gc');
|
|
});
|
|
|
|
it('filters by alias "h" → help', () => {
|
|
const result = filterCommands(commands, 'h');
|
|
const names = result.map((c) => c.name);
|
|
expect(names).toContain('help');
|
|
});
|
|
|
|
it('filters by description keyword "switch" → model', () => {
|
|
const result = filterCommands(commands, 'switch');
|
|
const names = result.map((c) => c.name);
|
|
expect(names).toContain('model');
|
|
});
|
|
|
|
it('returns empty array when no commands match', () => {
|
|
const result = filterCommands(commands, 'zzznotfound');
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
});
|