Files
claude-mem/tests/infrastructure/plugin-distribution.test.ts
T
Alex Newman 07ab7000a8 fix: patch 7 critical bugs affecting all non-dev-machine users and Windows
1. Fix esbuild inlining build-machine __dirname as string literal — use
   CJS-compatible runtime banner with require("node:url").fileURLToPath
   across worker-service, mcp-server, and context-generator builds.

2. Fix isMainModule check missing .cjs extension and Windows backslash
   path normalization.

3. Wrap extractLastMessage in try-catch to prevent infinite Stop hook
   feedback loop on malformed transcripts (exit 0 instead of exit 2).

4. Replace heavy SessionEnd hook (Node→Bun→1.7MB CJS→HTTP) with
   lightweight inline node -e one-liner (~200ms vs >1s).

5. Add 7 Gemini/OpenRouter error patterns to unrecoverablePatterns
   circuit breaker to prevent 77K+ retry loops on expired API keys.

6. Preserve CLAUDE_CODE_OAUTH_TOKEN and CLAUDE_CODE_GIT_BASH_PATH in
   sanitizeEnv instead of stripping them with the CLAUDE_CODE_ prefix.

7. Use PowerShell -EncodedCommand for spawnDaemon to fix path quoting
   when Windows usernames contain spaces.

Closes #1515, #1495, #1475, #1465, #1500, #1513, #1512, #1450, #1460,
#1486, #1449, #1481, #1451, #1480, #1453, #1445

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:20:29 -07:00

139 lines
5.3 KiB
TypeScript

import { describe, it, expect } from 'bun:test';
import { readFileSync, existsSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '../..');
/**
* Regression tests for plugin distribution completeness.
* Ensures all required files (skills, hooks, manifests) are present
* and correctly structured for end-user installs.
*
* Prevents issue #1187 (missing skills/ directory after install).
*/
describe('Plugin Distribution - Skills', () => {
const skillPath = path.join(projectRoot, 'plugin/skills/mem-search/SKILL.md');
it('should include plugin/skills/mem-search/SKILL.md', () => {
expect(existsSync(skillPath)).toBe(true);
});
it('should have valid YAML frontmatter with name and description', () => {
const content = readFileSync(skillPath, 'utf-8');
// Must start with YAML frontmatter
expect(content.startsWith('---\n')).toBe(true);
// Extract frontmatter
const frontmatterEnd = content.indexOf('\n---\n', 4);
expect(frontmatterEnd).toBeGreaterThan(0);
const frontmatter = content.slice(4, frontmatterEnd);
expect(frontmatter).toContain('name:');
expect(frontmatter).toContain('description:');
});
it('should reference the 3-layer search workflow', () => {
const content = readFileSync(skillPath, 'utf-8');
// The skill must document the search → timeline → get_observations workflow
expect(content).toContain('search');
expect(content).toContain('timeline');
expect(content).toContain('get_observations');
});
});
describe('Plugin Distribution - Required Files', () => {
const requiredFiles = [
'plugin/hooks/hooks.json',
'plugin/.claude-plugin/plugin.json',
'plugin/skills/mem-search/SKILL.md',
];
for (const filePath of requiredFiles) {
it(`should include ${filePath}`, () => {
const fullPath = path.join(projectRoot, filePath);
expect(existsSync(fullPath)).toBe(true);
});
}
});
describe('Plugin Distribution - hooks.json Integrity', () => {
it('should have valid JSON in hooks.json', () => {
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
const content = readFileSync(hooksPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.hooks).toBeDefined();
});
it('should reference CLAUDE_PLUGIN_ROOT in all hook commands (except inline hooks)', () => {
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
// SessionEnd uses a lightweight inline node -e command (no plugin root needed)
const inlineHookEvents = new Set(['SessionEnd']);
for (const [eventName, matchers] of Object.entries(parsed.hooks)) {
if (inlineHookEvents.has(eventName)) continue;
for (const matcher of matchers as any[]) {
for (const hook of matcher.hooks) {
if (hook.type === 'command') {
expect(hook.command).toContain('${CLAUDE_PLUGIN_ROOT}');
}
}
}
}
});
it('should include CLAUDE_PLUGIN_ROOT fallback in all hook commands except inline hooks (#1215)', () => {
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
const expectedFallbackPath = '$HOME/.claude/plugins/marketplaces/thedotmack/plugin';
const inlineHookEvents = new Set(['SessionEnd']);
for (const [eventName, matchers] of Object.entries(parsed.hooks)) {
if (inlineHookEvents.has(eventName)) continue;
for (const matcher of matchers as any[]) {
for (const hook of matcher.hooks) {
if (hook.type === 'command') {
expect(hook.command).toContain(expectedFallbackPath);
}
}
}
}
});
it('should use lightweight inline node command for SessionEnd hook', () => {
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
const sessionEndHooks = parsed.hooks.SessionEnd;
expect(sessionEndHooks).toBeDefined();
expect(sessionEndHooks.length).toBe(1);
const command = sessionEndHooks[0].hooks[0].command;
expect(command).toContain('node -e');
expect(command).toContain('/api/sessions/complete');
expect(sessionEndHooks[0].hooks[0].timeout).toBeLessThanOrEqual(10);
});
});
describe('Plugin Distribution - package.json Files Field', () => {
it('should include "plugin" in root package.json files field', () => {
const packageJsonPath = path.join(projectRoot, 'package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
expect(packageJson.files).toBeDefined();
expect(packageJson.files).toContain('plugin');
});
});
describe('Plugin Distribution - Build Script Verification', () => {
it('should verify distribution files in build-hooks.js', () => {
const buildScriptPath = path.join(projectRoot, 'scripts/build-hooks.js');
const content = readFileSync(buildScriptPath, 'utf-8');
// Build script must check for critical distribution files
expect(content).toContain('plugin/skills/mem-search/SKILL.md');
expect(content).toContain('plugin/hooks/hooks.json');
expect(content).toContain('plugin/.claude-plugin/plugin.json');
});
});