import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); const fishFile = readFileSync(join(root, 'completions', 'mcpctl.fish'), 'utf-8'); const bashFile = readFileSync(join(root, 'completions', 'mcpctl.bash'), 'utf-8'); describe('fish completions', () => { it('erases stale completions at the top', () => { const lines = fishFile.split('\n'); const firstComplete = lines.findIndex((l) => l.startsWith('complete ')); expect(lines[firstComplete]).toContain('-e'); }); it('does not offer resource types without __mcpctl_needs_resource_type guard', () => { const resourceTypes = ['servers', 'instances', 'secrets', 'templates', 'projects', 'users', 'groups', 'rbac']; const lines = fishFile.split('\n').filter((l) => l.startsWith('complete ')); for (const line of lines) { // Find lines that offer resource types as positional args const offersResourceType = resourceTypes.some((r) => { // Match `-a "...servers..."` or `-a 'servers projects'` const aMatch = line.match(/-a\s+['"]([^'"]+)['"]/); if (!aMatch) return false; return aMatch[1].split(/\s+/).includes(r); }); if (!offersResourceType) continue; // Skip the help completions line and the -e line if (line.includes('__fish_seen_subcommand_from help')) continue; // Skip project-scoped command offerings (those offer commands, not resource types) if (line.includes('attach-server') || line.includes('detach-server')) continue; // Skip lines that offer commands (not resource types) if (line.includes("-d 'Show") || line.includes("-d 'Manage") || line.includes("-d 'Authenticate") || line.includes("-d 'Log out'") || line.includes("-d 'Get instance") || line.includes("-d 'Create a resource'") || line.includes("-d 'Edit a resource'") || line.includes("-d 'Apply") || line.includes("-d 'Backup") || line.includes("-d 'Restore") || line.includes("-d 'List resources") || line.includes("-d 'Delete a resource'")) continue; // Lines offering resource types MUST have __mcpctl_needs_resource_type in their condition expect(line, `Resource type completion missing guard: ${line}`).toContain('__mcpctl_needs_resource_type'); } }); it('resource name completions require resource type to be selected', () => { const lines = fishFile.split('\n').filter((l) => l.startsWith('complete') && l.includes('__mcpctl_resource_names')); expect(lines.length).toBeGreaterThan(0); for (const line of lines) { expect(line).toContain('not __mcpctl_needs_resource_type'); } }); it('defines --project option', () => { expect(fishFile).toContain("complete -c mcpctl -l project"); }); it('attach-server command only shows with --project', () => { // Only check lines that OFFER attach-server as a command (via -a attach-server), not argument completions const lines = fishFile.split('\n').filter((l) => l.startsWith('complete') && l.includes("-a attach-server")); expect(lines.length).toBeGreaterThan(0); for (const line of lines) { expect(line).toContain('__mcpctl_has_project'); } }); it('detach-server command only shows with --project', () => { const lines = fishFile.split('\n').filter((l) => l.startsWith('complete') && l.includes("-a detach-server")); expect(lines.length).toBeGreaterThan(0); for (const line of lines) { expect(line).toContain('__mcpctl_has_project'); } }); it('resource name functions use jq .[][].name to unwrap wrapped JSON and avoid nested matches', () => { // API returns { "resources": [...] } not [...], so .[].name fails silently. // Must use .[][].name to unwrap the outer object then iterate the array. // Also must not use string match regex which matches nested name fields. const resourceNamesFn = fishFile.match(/function __mcpctl_resource_names[\s\S]*?^end/m)?.[0] ?? ''; const projectNamesFn = fishFile.match(/function __mcpctl_project_names[\s\S]*?^end/m)?.[0] ?? ''; expect(resourceNamesFn, '__mcpctl_resource_names must use jq .[][].name').toContain("jq -r '.[][].name'"); expect(resourceNamesFn, '__mcpctl_resource_names must not use string match on name').not.toMatch(/string match.*"name"/); expect(projectNamesFn, '__mcpctl_project_names must use jq .[][].name').toContain("jq -r '.[][].name'"); expect(projectNamesFn, '__mcpctl_project_names must not use string match on name').not.toMatch(/string match.*"name"/); }); it('instances use server.name instead of name', () => { const resourceNamesFn = fishFile.match(/function __mcpctl_resource_names[\s\S]*?^end/m)?.[0] ?? ''; expect(resourceNamesFn, 'must handle instances via server.name').toContain('.server.name'); }); it('attach-server completes with available (unattached) servers', () => { // Find the line that provides argument completions AFTER attach-server is selected const attachLine = fishFile.split('\n').find((l) => l.startsWith('complete') && l.includes('__fish_seen_subcommand_from attach-server')); expect(attachLine, 'attach-server argument completion must exist').toBeDefined(); expect(attachLine, 'attach-server must use __mcpctl_available_servers').toContain('__mcpctl_available_servers'); }); it('detach-server completes with project servers', () => { const detachLine = fishFile.split('\n').find((l) => l.startsWith('complete') && l.includes('__fish_seen_subcommand_from detach-server')); expect(detachLine, 'detach-server argument completion must exist').toBeDefined(); expect(detachLine, 'detach-server must use __mcpctl_project_servers').toContain('__mcpctl_project_servers'); }); it('non-project commands do not show with --project', () => { const nonProjectCmds = ['status', 'login', 'logout', 'config', 'apply', 'backup', 'restore']; const lines = fishFile.split('\n').filter((l) => l.startsWith('complete') && l.includes('-a ')); for (const cmd of nonProjectCmds) { const cmdLines = lines.filter((l) => { const aMatch = l.match(/-a\s+(\S+)/); return aMatch && aMatch[1].replace(/['"]/g, '') === cmd; }); for (const line of cmdLines) { expect(line, `${cmd} should require 'not __mcpctl_has_project'`).toContain('not __mcpctl_has_project'); } } }); }); describe('bash completions', () => { it('separates project commands from regular commands', () => { expect(bashFile).toContain('project_commands='); expect(bashFile).toContain('attach-server detach-server'); }); it('checks has_project before offering project commands', () => { expect(bashFile).toContain('if $has_project'); expect(bashFile).toContain('$project_commands'); }); it('fetches resource names dynamically after resource type', () => { expect(bashFile).toContain('_mcpctl_resource_names'); // get/describe/delete should use resource_names when resource_type is set expect(bashFile).toMatch(/get\|describe\|delete\)[\s\S]*?_mcpctl_resource_names/); }); it('attach-server filters out already-attached servers using project context', () => { const attachBlock = bashFile.match(/attach-server\)[\s\S]*?return ;;/)?.[0] ?? ''; expect(attachBlock, 'attach-server must use _mcpctl_get_project_value').toContain('_mcpctl_get_project_value'); expect(attachBlock, 'attach-server must query project servers to exclude').toContain('--project'); }); it('detach-server shows only project servers', () => { const detachBlock = bashFile.match(/detach-server\)[\s\S]*?return ;;/)?.[0] ?? ''; expect(detachBlock, 'detach-server must use _mcpctl_get_project_value').toContain('_mcpctl_get_project_value'); expect(detachBlock, 'detach-server must query project servers').toContain('--project'); }); it('instances use server.name instead of name', () => { const fnMatch = bashFile.match(/_mcpctl_resource_names\(\)[\s\S]*?\n\s*\}/)?.[0] ?? ''; expect(fnMatch, 'must handle instances via .server.name').toContain('.server.name'); }); it('defines --project option', () => { expect(bashFile).toContain('--project'); }); it('resource name function uses jq .[][].name to unwrap wrapped JSON and avoid nested matches', () => { const fnMatch = bashFile.match(/_mcpctl_resource_names\(\)[\s\S]*?\n\s*\}/)?.[0] ?? ''; expect(fnMatch, '_mcpctl_resource_names must use jq .[][].name').toContain("jq -r '.[][].name'"); expect(fnMatch, '_mcpctl_resource_names must not use grep on name').not.toMatch(/grep.*"name"/); // Guard against .[].name (single bracket) which fails on wrapped JSON expect(fnMatch, '_mcpctl_resource_names must not use .[].name (needs .[][].name)').not.toMatch(/jq.*'\.\[\]\.name'/); }); });