Files
jackwener__opencli/tests/e2e/plugin-management.test.ts
AlexYue 78d52d984b test(plugin): add E2E integration tests for plugin lifecycle (#389)
* test(plugin): add E2E integration tests for plugin lifecycle

Add plugin-management.test.ts covering the full plugin lifecycle
using real GitHub clone of opencli-plugin-hot-digest:
- plugin install from github:ByteYue/opencli-plugin-hot-digest
- plugin list (table and JSON formats)
- plugin update on installed plugin
- plugin uninstall with cleanup verification
- error paths: invalid source, non-existent plugin, missing args

Tests safely backup/restore existing plugin state to avoid
interfering with user's real installed plugins.

Update TESTING.md to document the new test file.

* test(plugin): isolate lifecycle e2e from user home

* fix(plugin): respect HOME env var for test isolation

The E2E tests for plugin management were failing because os.homedir()
doesn't respect the HOME environment variable. This made test isolation
impossible since all tests would use the real ~/.opencli directory.

Added getHomeDir() helper that checks process.env.HOME first before
falling back to os.homedir(). Updated readLockFile() and writeLockFile()
to use this new function.

Fixes test failures in plugin-management.test.ts where:
- plugin install would write to real home instead of temp dir
- lock file assertions would fail with ENOENT

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-25 00:23:51 +08:00

138 lines
4.9 KiB
TypeScript

/**
* E2E integration tests for plugin management commands.
* Uses a real GitHub plugin (opencli-plugin-hot-digest) to verify the full
* install → list → update → uninstall lifecycle in an isolated HOME.
*/
import { describe, it, expect, afterAll } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { runCli, parseJsonOutput } from './helpers.js';
const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-plugin-e2e-'));
const OPENCLI_HOME = path.join(TEST_HOME, '.opencli');
const PLUGINS_DIR = path.join(OPENCLI_HOME, 'plugins');
const PLUGIN_SOURCE = 'github:ByteYue/opencli-plugin-hot-digest';
const PLUGIN_NAME = 'hot-digest';
const PLUGIN_DIR = path.join(PLUGINS_DIR, PLUGIN_NAME);
const LOCK_FILE = path.join(OPENCLI_HOME, 'plugins.lock.json');
function runPluginCli(
args: string[],
opts: { timeout?: number; env?: Record<string, string> } = {},
) {
return runCli(args, {
...opts,
env: {
HOME: TEST_HOME,
USERPROFILE: TEST_HOME,
...opts.env,
},
});
}
describe('plugin management E2E', () => {
afterAll(() => {
fs.rmSync(TEST_HOME, { recursive: true, force: true });
});
// ── plugin list (empty) ──
it('plugin list shows "No plugins installed" when none exist', async () => {
const { stdout, code } = await runPluginCli(['plugin', 'list']);
expect(code).toBe(0);
expect(stdout).toContain('No plugins installed');
});
// ── plugin install ──
it('plugin install clones and sets up a real plugin', async () => {
const { stdout, code } = await runPluginCli(['plugin', 'install', PLUGIN_SOURCE], {
timeout: 60_000,
});
expect(code).toBe(0);
expect(stdout).toContain('installed successfully');
expect(stdout).toContain(PLUGIN_NAME);
// Verify the plugin directory was created
expect(fs.existsSync(PLUGIN_DIR)).toBe(true);
// Verify lock file was updated
const lock = JSON.parse(fs.readFileSync(LOCK_FILE, 'utf-8'));
expect(lock[PLUGIN_NAME]).toBeDefined();
expect(lock[PLUGIN_NAME].commitHash).toBeTruthy();
expect(lock[PLUGIN_NAME].source).toContain('opencli-plugin-hot-digest');
expect(lock[PLUGIN_NAME].installedAt).toBeTruthy();
}, 60_000);
// ── plugin list (after install) ──
it('plugin list shows the installed plugin', async () => {
const { stdout, code } = await runPluginCli(['plugin', 'list']);
expect(code).toBe(0);
expect(stdout).toContain(PLUGIN_NAME);
});
it('plugin list -f json returns structured data', async () => {
const { stdout, code } = await runPluginCli(['plugin', 'list', '-f', 'json']);
expect(code).toBe(0);
const data = parseJsonOutput(stdout);
expect(Array.isArray(data)).toBe(true);
const plugin = data.find((p: any) => p.name === PLUGIN_NAME);
expect(plugin).toBeDefined();
expect(plugin.name).toBe(PLUGIN_NAME);
expect(Array.isArray(plugin.commands)).toBe(true);
expect(plugin.commands.length).toBeGreaterThan(0);
});
// ── plugin update ──
it('plugin update succeeds on an installed plugin', async () => {
const { stdout, code } = await runPluginCli(['plugin', 'update', PLUGIN_NAME], {
timeout: 30_000,
});
expect(code).toBe(0);
expect(stdout).toContain('updated successfully');
// Verify lock file has updatedAt
const lock = JSON.parse(fs.readFileSync(LOCK_FILE, 'utf-8'));
expect(lock[PLUGIN_NAME].updatedAt).toBeTruthy();
}, 30_000);
// ── plugin uninstall ──
it('plugin uninstall removes the plugin', async () => {
const { stdout, code } = await runPluginCli(['plugin', 'uninstall', PLUGIN_NAME]);
expect(code).toBe(0);
expect(stdout).toContain('uninstalled');
// Verify directory was removed
expect(fs.existsSync(PLUGIN_DIR)).toBe(false);
// Verify lock entry was removed
const lock = JSON.parse(fs.readFileSync(LOCK_FILE, 'utf-8'));
expect(lock[PLUGIN_NAME]).toBeUndefined();
});
// ── error paths ──
it('plugin install rejects invalid source', async () => {
const { stderr, code } = await runPluginCli(['plugin', 'install', 'invalid-source-format']);
expect(code).toBe(1);
expect(stderr).toContain('Invalid plugin source');
});
it('plugin uninstall rejects non-existent plugin', async () => {
const { stderr, code } = await runPluginCli(['plugin', 'uninstall', '__nonexistent_plugin_xyz__']);
expect(code).toBe(1);
expect(stderr).toContain('not installed');
});
it('plugin update rejects non-existent plugin', async () => {
const { stderr, code } = await runPluginCli(['plugin', 'update', '__nonexistent_plugin_xyz__']);
expect(code).toBe(1);
});
it('plugin update without name or --all shows error', async () => {
const { stderr, code } = await runPluginCli(['plugin', 'update']);
expect(code).toBe(1);
expect(stderr).toContain('specify a plugin name');
});
});