plugin-management.test.ts
1 /** 2 * E2E integration tests for plugin management commands. 3 * Uses a real GitHub plugin (opencli-plugin-hot-digest) to verify the full 4 * install → list → update → uninstall lifecycle in an isolated HOME. 5 */ 6 7 import { describe, it, expect, afterAll } from 'vitest'; 8 import * as fs from 'node:fs'; 9 import * as path from 'node:path'; 10 import * as os from 'node:os'; 11 import { runCli, parseJsonOutput } from './helpers.js'; 12 13 const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-plugin-e2e-')); 14 const OPENCLI_HOME = path.join(TEST_HOME, '.opencli'); 15 const PLUGINS_DIR = path.join(OPENCLI_HOME, 'plugins'); 16 const PLUGIN_SOURCE = 'github:ByteYue/opencli-plugin-hot-digest'; 17 const PLUGIN_NAME = 'hot-digest'; 18 const PLUGIN_DIR = path.join(PLUGINS_DIR, PLUGIN_NAME); 19 const LOCK_FILE = path.join(OPENCLI_HOME, 'plugins.lock.json'); 20 21 function runPluginCli( 22 args: string[], 23 opts: { timeout?: number; env?: Record<string, string> } = {}, 24 ) { 25 return runCli(args, { 26 ...opts, 27 env: { 28 HOME: TEST_HOME, 29 USERPROFILE: TEST_HOME, 30 ...opts.env, 31 }, 32 }); 33 } 34 35 describe('plugin management E2E', () => { 36 afterAll(() => { 37 fs.rmSync(TEST_HOME, { recursive: true, force: true }); 38 }); 39 40 // ── plugin list (empty) ── 41 it('plugin list shows "No plugins installed" when none exist', async () => { 42 const { stdout, code } = await runPluginCli(['plugin', 'list']); 43 expect(code).toBe(0); 44 expect(stdout).toContain('No plugins installed'); 45 }); 46 47 // ── plugin install ── 48 it('plugin install clones and sets up a real plugin', async () => { 49 const { stdout, code } = await runPluginCli(['plugin', 'install', PLUGIN_SOURCE], { 50 timeout: 60_000, 51 }); 52 expect(code).toBe(0); 53 expect(stdout).toContain('installed successfully'); 54 expect(stdout).toContain(PLUGIN_NAME); 55 56 // Verify the plugin directory was created 57 expect(fs.existsSync(PLUGIN_DIR)).toBe(true); 58 59 // Verify lock file was updated 60 const lock = JSON.parse(fs.readFileSync(LOCK_FILE, 'utf-8')); 61 expect(lock[PLUGIN_NAME]).toBeDefined(); 62 expect(lock[PLUGIN_NAME].commitHash).toBeTruthy(); 63 expect(lock[PLUGIN_NAME].source).toMatchObject({ 64 kind: 'git', 65 }); 66 expect(lock[PLUGIN_NAME].source.url).toContain('opencli-plugin-hot-digest'); 67 expect(lock[PLUGIN_NAME].installedAt).toBeTruthy(); 68 }, 60_000); 69 70 // ── plugin list (after install) ── 71 it('plugin list shows the installed plugin', async () => { 72 const { stdout, code } = await runPluginCli(['plugin', 'list']); 73 expect(code).toBe(0); 74 expect(stdout).toContain(PLUGIN_NAME); 75 }); 76 77 it('plugin list -f json returns structured data', async () => { 78 const { stdout, code } = await runPluginCli(['plugin', 'list', '-f', 'json']); 79 expect(code).toBe(0); 80 const data = parseJsonOutput(stdout); 81 expect(Array.isArray(data)).toBe(true); 82 83 const plugin = data.find((p: any) => p.name === PLUGIN_NAME); 84 expect(plugin).toBeDefined(); 85 expect(plugin.name).toBe(PLUGIN_NAME); 86 expect(Array.isArray(plugin.commands)).toBe(true); 87 expect(plugin.commands.length).toBeGreaterThan(0); 88 }); 89 90 // ── plugin update ── 91 it('plugin update succeeds on an installed plugin', async () => { 92 const { stdout, code } = await runPluginCli(['plugin', 'update', PLUGIN_NAME], { 93 timeout: 30_000, 94 }); 95 expect(code).toBe(0); 96 expect(stdout).toContain('updated successfully'); 97 98 // Verify lock file has updatedAt 99 const lock = JSON.parse(fs.readFileSync(LOCK_FILE, 'utf-8')); 100 expect(lock[PLUGIN_NAME].updatedAt).toBeTruthy(); 101 }, 30_000); 102 103 // ── plugin uninstall ── 104 it('plugin uninstall removes the plugin', async () => { 105 const { stdout, code } = await runPluginCli(['plugin', 'uninstall', PLUGIN_NAME]); 106 expect(code).toBe(0); 107 expect(stdout).toContain('uninstalled'); 108 109 // Verify directory was removed 110 expect(fs.existsSync(PLUGIN_DIR)).toBe(false); 111 112 // Verify lock entry was removed 113 const lock = JSON.parse(fs.readFileSync(LOCK_FILE, 'utf-8')); 114 expect(lock[PLUGIN_NAME]).toBeUndefined(); 115 }); 116 117 // ── error paths ── 118 it('plugin install rejects invalid source', async () => { 119 const { stderr, code } = await runPluginCli(['plugin', 'install', 'invalid-source-format']); 120 expect(code).toBe(1); 121 expect(stderr).toContain('Invalid plugin source'); 122 }); 123 124 it('plugin uninstall rejects non-existent plugin', async () => { 125 const { stderr, code } = await runPluginCli(['plugin', 'uninstall', '__nonexistent_plugin_xyz__']); 126 expect(code).toBe(1); 127 expect(stderr).toContain('not installed'); 128 }); 129 130 it('plugin update rejects non-existent plugin', async () => { 131 const { stderr, code } = await runPluginCli(['plugin', 'update', '__nonexistent_plugin_xyz__']); 132 expect(code).toBe(1); 133 }); 134 135 it('plugin update without name or --all shows error', async () => { 136 const { stderr, code } = await runPluginCli(['plugin', 'update']); 137 expect(code).toBe(2); 138 expect(stderr).toContain('specify a plugin name'); 139 }); 140 });