/ tests / e2e / plugin-management.test.ts
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  });