Files
thedotmack--claude-mem/tests/plugin-version-check-ensure-deps.test.ts
wehub-resource-sync f9447f8e5f
CI / typecheck · build · test · bundle-size (push) Failing after 1s
CI / clean-room dependency closure smoke (push) Failing after 1s
CI / server-runtime e2e (docker · pg + valkey) (push) Failing after 2s
Deploy Install Scripts / deploy (push) Failing after 2s
Windows / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:07:03 +08:00

172 lines
7.0 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { spawn } from 'child_process';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join, resolve } from 'path';
const REPO_ROOT = resolve(import.meta.dir, '..');
const VERSION_CHECK_PATH = join(REPO_ROOT, 'plugin', 'scripts', 'version-check.js');
const SPAWN_TIMEOUT_MS = 15_000;
const INSTALL_DIAGNOSTIC = '[version-check] installing plugin dependencies';
const INSTALL_SUCCESS_DIAGNOSTIC = '[version-check] plugin dependencies installed successfully';
const INSTALL_FAILURE_DIAGNOSTIC = '[version-check] bun install failed';
const FAKE_INSTALLED_MARKER_REL = join('node_modules', 'zod', 'v3', 'index.js');
const SKIP_NON_UNIX = process.platform === 'win32';
let tmpRoot: string;
function runVersionCheck(pluginRoot: string, fakeBinDir: string): Promise<{ stderr: string; stdout: string; code: number | null }> {
return new Promise((resolveResult, reject) => {
const child = spawn(process.execPath, [VERSION_CHECK_PATH], {
cwd: pluginRoot,
env: {
...process.env,
PATH: `${fakeBinDir}:${process.env.PATH ?? ''}`,
CLAUDE_PLUGIN_ROOT: pluginRoot,
CLAUDE_MEM_DATA_DIR: join(pluginRoot, '.claude-mem'),
CLAUDE_CONFIG_DIR: join(pluginRoot, '.claude'),
},
stdio: ['pipe', 'pipe', 'pipe'],
});
let stderr = '';
let stdout = '';
let timer: ReturnType<typeof setTimeout> | null = null;
try {
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
child.stdin.end();
timer = setTimeout(() => {
try { child.kill('SIGKILL'); } catch {}
reject(new Error(`version-check subprocess exceeded ${SPAWN_TIMEOUT_MS}ms`));
}, SPAWN_TIMEOUT_MS);
child.on('close', (code) => {
if (timer) clearTimeout(timer);
resolveResult({ stderr, stdout, code });
});
child.on('error', (err) => {
if (timer) clearTimeout(timer);
reject(err);
});
} catch (err) {
if (timer) clearTimeout(timer);
try { child.kill('SIGKILL'); } catch {}
reject(err);
}
});
}
type BunBehavior = 'success' | 'partial-then-fail';
function makeFreshPlugin(name: string, bunBehavior: BunBehavior = 'success'): { pluginRoot: string; fakeBinDir: string } {
const pluginRoot = join(tmpRoot, name);
mkdirSync(pluginRoot, { recursive: true });
writeFileSync(join(pluginRoot, 'package.json'), JSON.stringify({
name: 'fake-plugin',
version: '0.0.0',
dependencies: { zod: '^3.0.0' },
}));
writeFileSync(join(pluginRoot, '.install-version'), JSON.stringify({ version: '0.0.0' }));
const fakeBinDir = join(pluginRoot, '.bin');
mkdirSync(fakeBinDir, { recursive: true });
// Fake bun behaviors:
// - success: creates the install marker file and exits 0 (happy path).
// - partial-then-fail: creates the partial node_modules dir THEN exits
// non-zero. Mirrors real bun's behavior under network timeout / OOM /
// registry 5xx where node_modules already exists when the failure
// surfaces. Required to cover the gh #2650 review-fix path that
// cleans up the partial dir so the next Setup run can retry.
const fakeBunPath = join(fakeBinDir, 'bun');
const installBody = bunBehavior === 'success'
? [
` mkdir -p "${pluginRoot}/node_modules/zod/v3"`,
` : > "${pluginRoot}/node_modules/zod/v3/index.js"`,
' exit 0',
]
: [
` mkdir -p "${pluginRoot}/node_modules"`,
' echo "fake bun install failure mid-fetch" 1>&2',
' exit 42',
];
const fakeBunScript = [
'#!/usr/bin/env bash',
'if [ "$1" = "install" ]; then',
...installBody,
'fi',
'exit 0',
].join('\n') + '\n';
writeFileSync(fakeBunPath, fakeBunScript);
chmodSync(fakeBunPath, 0o755);
return { pluginRoot, fakeBinDir };
}
beforeAll(() => {
tmpRoot = mkdtempSync(join(tmpdir(), 'version-check-deps-'));
});
afterAll(() => {
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch {}
});
describe.skipIf(SKIP_NON_UNIX)('version-check Setup-phase ensurePluginDependencies (gh #2649)', () => {
test('installs plugin dependencies when node_modules is missing on fresh extract', async () => {
// This is the gh #2640 / #2637 scenario: marketplace extracts files but
// never runs `bun install`. Setup MUST detect the missing node_modules and
// invoke dependency installation, otherwise the next hook (SessionStart
// worker spawn) crashes with `Cannot find module 'zod/v3'`.
const { pluginRoot, fakeBinDir } = makeFreshPlugin('plugin-fresh');
const { stderr, code } = await runVersionCheck(pluginRoot, fakeBinDir);
expect(code).toBe(0);
expect(stderr).toContain(INSTALL_DIAGNOSTIC);
expect(stderr).toContain(INSTALL_SUCCESS_DIAGNOSTIC);
expect(existsSync(join(pluginRoot, FAKE_INSTALLED_MARKER_REL))).toBe(true);
});
test('cleans up partial node_modules after a failed install so next Setup can retry (gh #2650 review)', async () => {
// Reproduces the Greptile review concern: `bun install` often creates
// the node_modules directory BEFORE it fails (mid-fetch network
// timeout, registry 5xx, OOM kill). Without explicit cleanup, the
// `existsSync(node_modules)` guard would permanently short-circuit
// every subsequent Setup run and the user has no recovery path short
// of a manual `rm -rf node_modules`. Verify that after a failed
// install the partial dir is removed.
const { pluginRoot, fakeBinDir } = makeFreshPlugin('plugin-partial-fail', 'partial-then-fail');
// Sanity-check the failure path: node_modules MUST exist before our
// cleanup runs (otherwise we are not exercising the gh #2650 scenario).
// Run version-check once and confirm both the failure diagnostic and
// the post-cleanup absence of node_modules.
const { stderr, code } = await runVersionCheck(pluginRoot, fakeBinDir);
expect(code).toBe(0);
expect(stderr).toContain(INSTALL_FAILURE_DIAGNOSTIC);
expect(stderr).toContain('exit 42');
expect(existsSync(join(pluginRoot, 'node_modules'))).toBe(false);
});
test('skips install when node_modules is already present', async () => {
// Setup runs on every Claude Code launch. If node_modules already exists,
// the install MUST be skipped — otherwise we re-run a 100 MB+ install on
// every cold start and burn the user's bandwidth.
const { pluginRoot, fakeBinDir } = makeFreshPlugin('plugin-already-installed');
mkdirSync(join(pluginRoot, 'node_modules'), { recursive: true });
const { stderr, code } = await runVersionCheck(pluginRoot, fakeBinDir);
expect(code).toBe(0);
expect(stderr).not.toContain(INSTALL_DIAGNOSTIC);
// The fake bun would have created zod/v3/index.js if invoked — its
// absence proves the install path was not taken.
expect(existsSync(join(pluginRoot, FAKE_INSTALLED_MARKER_REL))).toBe(false);
});
});