import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE, OPENCODE_SERVER_PLUGIN_FILENAME, OPENCODE_TUI_PLUGIN_FILENAME, } from '../../src/config/opencode-extension.js'; /** * `mcpctl config opencode` installs the *embedded* copy of the plugins, not the * files in src/opencode-ext/. Editing the sources without re-running the * generator therefore ships stale code to users while the repo looks correct — * and the embedded copy is the one thing no typecheck covers. Same guarantee * the completions check gives. */ const repoRoot = join(import.meta.dirname, '..', '..', '..', '..'); const extDir = join(repoRoot, 'src', 'opencode-ext'); describe('embedded opencode plugins', () => { it('match the sources in src/opencode-ext (re-run scripts/generate-opencode-extension.ts)', () => { expect(OPENCODE_SERVER_PLUGIN_SOURCE, 'server plugin is stale — regenerate the embed') .toBe(readFileSync(join(extDir, 'mcpctl-opencode.ts'), 'utf-8')); expect(OPENCODE_TUI_PLUGIN_SOURCE, 'TUI plugin is stale — regenerate the embed') .toBe(readFileSync(join(extDir, 'mcpctl-opencode-tui.tsx'), 'utf-8')); }); it('install under names opencode can actually load', () => { // The server plugin is auto-discovered from plugin/*.ts; the TUI plugin is // referenced by path from tui.json and must stay .tsx for its JSX to be // transpiled. expect(OPENCODE_SERVER_PLUGIN_FILENAME).toBe('mcpctl.ts'); expect(OPENCODE_TUI_PLUGIN_FILENAME).toBe('mcpctl-tui.tsx'); }); it('are self-contained — the installed files have no mcpctl imports to resolve', () => { for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) { expect(src).not.toMatch(/from '@mcpctl\//); expect(src).not.toMatch(/from '\.\.\//); } }); it('keep the JSX pragma the TUI plugin needs to render its indicator', () => { expect(OPENCODE_TUI_PLUGIN_SOURCE.startsWith('/** @jsxImportSource @opentui/solid */')).toBe(true); }); it('agree on the MCP server name, so a switch re-points one mount instead of stacking two', () => { for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) { expect(src).toContain("const SERVER_NAME = 'mcpctl'"); } }); it('agree on the state file both read', () => { for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) { expect(src).toContain("join(homedir(), '.mcpctl', 'opencode-state.json')"); } }); it('spawn the CLI without a shell, so a project name is never interpolated into a command string', () => { expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("execFile('mcpctl', args"); expect(OPENCODE_TUI_PLUGIN_SOURCE).not.toMatch(/\bexecSync\s*\(/); expect(OPENCODE_TUI_PLUGIN_SOURCE).not.toMatch(/\bexec\(`/); }); it('sync skills into opencode’s own tree, never Claude’s', () => { expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--agent', 'opencode'"); }); it('bind the switcher to a chord as well as a slash command', () => { // Switching is the repeated action; typing /mcpctl every time is friction. expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("slashName: 'mcpctl'"); expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("key: 'm'"); }); it('tear the outgoing mount down before re-pointing it', () => { // An abandoned client keeps its mcp-session-id — and a gated project's // unlocked state — alive on mcplocal. expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain('mcp.disconnect({ name: SERVER_NAME })'); }); it('clip rather than wrap the footer label on the narrow home prompt', () => { expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain('wrapMode="none"'); }); it('switch without rewriting the plugin file opencode has already loaded', () => { expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-plugin'"); expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-marker'"); }); });