ci: add macOS to cross-platform test matrix (#208)

* ci: add macOS to cross-platform test matrix

* ci: run integration tests on all platforms, add macOS to matrix

* ci: add build step before cross-platform integration tests

Worker pool requires compiled parse-worker.js in dist/.
Without build, falls back to sequential parsing which times out
on macOS runners.

* fix(pipeline): resolve worker path to dist/ when running under vitest

import.meta.url points to src/ under vitest where no .js exists.
Fall back to dist/core/ingestion/workers/parse-worker.js so worker
threads spawn correctly on all platforms instead of sequential fallback
that times out on slower macOS CI runners.

* ci: split cross-platform unit and integration tests into parallel jobs

* test: add integration tests for worker pool and hooks e2e

- worker-pool.test.ts: 7 tests verifying dist/ worker spawning,
  multi-file parsing, progress reporting, and clean termination
- hooks-e2e.test.ts: 28 tests with real git repos testing staleness
  detection, embeddings flag, mutation regex, cwd validation,
  and .gitnexus directory discovery

* refactor: extract shared hook test helpers and simplify worker fallback

- Extract runHook/parseHookOutput into test/utils/hook-test-helpers.ts
- Deduplicate fileURLToPath calls in pipeline.ts worker resolution
- Add isDev logging for worker pool creation failures

* fix(test): accept timeout as valid outcome for PreToolUse CLI spawn

The Plugin hook spawns `gitnexus augment` which may hang on macOS
when the CLI is unavailable, causing a 10s timeout (status=null)
instead of a clean exit (status=0). Accept both as non-crash outcomes.
This commit is contained in:
Gergő Magyar 2026-03-07 10:38:32 +00:00 • committed by GitHub
parent 7fa037628d
commit f50eb44beb
6 changed files with 522 additions and 45 deletions

View file

@ -36,25 +36,10 @@ jobs:
- run: npx vitest run test/unit --coverage --coverage.thresholdAutoUpdate=false
working-directory: gitnexus
integration-tests:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: gitnexus/package-lock.json
- run: npm ci
working-directory: gitnexus
- run: npx vitest run test/integration
working-directory: gitnexus
cross-platform:
cross-platform-unit:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
@ -67,3 +52,23 @@ jobs:
working-directory: gitnexus
- run: npx vitest run test/unit
working-directory: gitnexus
cross-platform-integration:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: gitnexus/package-lock.json
- run: npm ci
working-directory: gitnexus
- run: npm run build
working-directory: gitnexus
- run: npx vitest run test/integration
working-directory: gitnexus

View file

@ -13,6 +13,9 @@ import { walkRepositoryPaths, readFileContents } from './filesystem-walker.js';
import { getLanguageFromFilename } from './utils.js';
import { isLanguageAvailable } from '../tree-sitter/parser-loader.js';
import { createWorkerPool, WorkerPool } from './workers/worker-pool.js';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const isDev = process.env.NODE_ENV === 'development';
@ -149,10 +152,19 @@ export const runPipelineFromRepo = async (
// Create worker pool once, reuse across chunks
let workerPool: WorkerPool | undefined;
try {
const workerUrl = new URL('./workers/parse-worker.js', import.meta.url);
let workerUrl = new URL('./workers/parse-worker.js', import.meta.url);
// When running under vitest, import.meta.url points to src/ where no .js exists.
// Fall back to the compiled dist/ worker so the pool can spawn real worker threads.
const thisDir = fileURLToPath(new URL('.', import.meta.url));
if (!fs.existsSync(fileURLToPath(workerUrl))) {
const distWorker = path.resolve(thisDir, '..', '..', '..', 'dist', 'core', 'ingestion', 'workers', 'parse-worker.js');
if (fs.existsSync(distWorker)) {
workerUrl = pathToFileURL(distWorker) as URL;
}
}
workerPool = createWorkerPool(workerUrl);
} catch (err) {
// Worker pool creation failed — sequential fallback
if (isDev) console.warn('Worker pool creation failed, using sequential fallback:', (err as Error).message);
}
let filesParsedSoFar = 0;

View file

@ -0,0 +1,316 @@
/**
* Integration Tests: Claude Code Hooks End-to-End
*
* Tests the hook scripts with real git repos and .gitnexus directories.
* Unlike unit/hooks.test.ts which tests source code patterns and simple
* stdin/stdout, these tests verify actual behavior with filesystem state.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { runHook, parseHookOutput } from '../utils/hook-test-helpers.js';
// ─── Paths to both hook variants ────────────────────────────────────
const CJS_HOOK = path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'gitnexus-hook.cjs');
const PLUGIN_HOOK = path.resolve(__dirname, '..', '..', '..', 'gitnexus-claude-plugin', 'hooks', 'gitnexus-hook.js');
const HOOKS = [
{ name: 'CJS', path: CJS_HOOK },
...(fs.existsSync(PLUGIN_HOOK) ? [{ name: 'Plugin', path: PLUGIN_HOOK }] : []),
];
// ─── Temp git repo with .gitnexus ───────────────────────────────────
let tmpDir: string;
let gitNexusDir: string;
beforeAll(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hooks-e2e-'));
gitNexusDir = path.join(tmpDir, '.gitnexus');
fs.mkdirSync(gitNexusDir, { recursive: true });
// Initialize a real git repo
spawnSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' });
spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir, stdio: 'pipe' });
spawnSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir, stdio: 'pipe' });
// Create a file and commit so HEAD exists
fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'hello');
spawnSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'pipe' });
spawnSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' });
});
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
// ─── Tests ──────────────────────────────────────────────────────────
describe.each(HOOKS)('hooks e2e ($name)', ({ name, path: hookPath }) => {
describe('PostToolUse staleness detection', () => {
it('detects stale index when meta.json lastCommit differs from HEAD', () => {
// Write meta.json with an old commit hash
fs.writeFileSync(
path.join(gitNexusDir, 'meta.json'),
JSON.stringify({ lastCommit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', stats: {} }),
);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: tmpDir,
});
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).toContain('stale');
expect(output!.additionalContext).toContain('npx gitnexus analyze');
});
it('stays silent when meta.json lastCommit matches HEAD', () => {
// Get current HEAD
const headResult = spawnSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
});
const head = headResult.stdout.trim();
// Write meta.json with matching commit
fs.writeFileSync(
path.join(gitNexusDir, 'meta.json'),
JSON.stringify({ lastCommit: head, stats: {} }),
);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: tmpDir,
});
const output = parseHookOutput(result.stdout);
expect(output).toBeNull();
});
it('includes --embeddings flag when previous index had embeddings', () => {
fs.writeFileSync(
path.join(gitNexusDir, 'meta.json'),
JSON.stringify({ lastCommit: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', stats: { embeddings: 42 } }),
);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: tmpDir,
});
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).toContain('--embeddings');
});
it('treats missing meta.json as stale', () => {
// Remove meta.json
const metaPath = path.join(gitNexusDir, 'meta.json');
if (fs.existsSync(metaPath)) fs.unlinkSync(metaPath);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: tmpDir,
});
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).toContain('stale');
});
it('ignores failed git commands (exit_code !== 0)', () => {
fs.writeFileSync(
path.join(gitNexusDir, 'meta.json'),
JSON.stringify({ lastCommit: 'cccccccccccccccccccccccccccccccccccccccc', stats: {} }),
);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 1 },
cwd: tmpDir,
});
const output = parseHookOutput(result.stdout);
expect(output).toBeNull();
});
it('ignores non-mutation git commands', () => {
fs.writeFileSync(
path.join(gitNexusDir, 'meta.json'),
JSON.stringify({ lastCommit: 'dddddddddddddddddddddddddddddddddddddddd', stats: {} }),
);
const nonMutations = ['git status', 'git log', 'git diff', 'git branch', 'git stash'];
for (const cmd of nonMutations) {
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: cmd },
tool_output: { exit_code: 0 },
cwd: tmpDir,
});
const output = parseHookOutput(result.stdout);
expect(output).toBeNull();
}
});
it('detects all 5 git mutation types', () => {
fs.writeFileSync(
path.join(gitNexusDir, 'meta.json'),
JSON.stringify({ lastCommit: 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', stats: {} }),
);
const mutations = ['git commit -m "x"', 'git merge feature', 'git rebase main', 'git cherry-pick abc', 'git pull origin main'];
for (const cmd of mutations) {
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: cmd },
tool_output: { exit_code: 0 },
cwd: tmpDir,
});
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).toContain('stale');
}
});
});
describe('PreToolUse — silent without gitnexus CLI', () => {
// PreToolUse tries to spawn `gitnexus augment` which won't be available in CI.
// Verify it fails gracefully (no output, no crash).
it('handles Grep pattern gracefully when CLI is unavailable', () => {
const result = runHook(hookPath, {
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'handleRequest' },
cwd: tmpDir,
});
// Should not crash — status is 0 if it exits cleanly, or null if the
// spawned `gitnexus augment` hangs and the 10s timeout kills the process.
expect(result.status === 0 || result.status === null).toBe(true);
});
it('ignores patterns shorter than 3 chars', () => {
const result = runHook(hookPath, {
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'ab' },
cwd: tmpDir,
});
expect(result.status).toBe(0);
const output = parseHookOutput(result.stdout);
expect(output).toBeNull();
});
it('ignores non-search tools', () => {
const result = runHook(hookPath, {
hook_event_name: 'PreToolUse',
tool_name: 'Read',
tool_input: { file_path: '/some/file.ts' },
cwd: tmpDir,
});
expect(result.status).toBe(0);
const output = parseHookOutput(result.stdout);
expect(output).toBeNull();
});
});
describe('cwd validation', () => {
it('rejects relative cwd silently for PostToolUse', () => {
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "x"' },
tool_output: { exit_code: 0 },
cwd: 'relative/path',
});
const output = parseHookOutput(result.stdout);
expect(output).toBeNull();
});
it('rejects relative cwd silently for PreToolUse', () => {
const result = runHook(hookPath, {
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'testPattern' },
cwd: 'relative/path',
});
const output = parseHookOutput(result.stdout);
expect(output).toBeNull();
});
});
describe('directory without .gitnexus', () => {
// The hook walks up 5 parent directories looking for .gitnexus.
// To guarantee none is found, create a deeply nested temp dir at the
// filesystem root where no .gitnexus could exist in any ancestor.
let noGitNexusDir: string;
beforeAll(() => {
// Use a root-level temp path so parent traversal can't find .gitnexus
const root = os.platform() === 'win32' ? 'C:\\' : '/tmp';
const base = path.join(root, `no-gitnexus-${Date.now()}`);
// Nest 6 levels deep (hook walks up 5) to ensure isolation
noGitNexusDir = path.join(base, 'a', 'b', 'c', 'd', 'e', 'f');
fs.mkdirSync(noGitNexusDir, { recursive: true });
spawnSync('git', ['init'], { cwd: noGitNexusDir, stdio: 'pipe' });
});
afterAll(() => {
// Clean up from the base directory
const root = os.platform() === 'win32' ? 'C:\\' : '/tmp';
const base = path.join(root, path.basename(path.resolve(noGitNexusDir, '..', '..', '..', '..', '..', '..')));
fs.rmSync(base, { recursive: true, force: true });
});
it('ignores PostToolUse when no .gitnexus directory exists', () => {
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "x"' },
tool_output: { exit_code: 0 },
cwd: noGitNexusDir,
});
const output = parseHookOutput(result.stdout);
expect(output).toBeNull();
});
it('ignores PreToolUse when no .gitnexus directory exists', () => {
const result = runHook(hookPath, {
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'somePattern' },
cwd: noGitNexusDir,
});
const output = parseHookOutput(result.stdout);
expect(output).toBeNull();
});
});
});

View file

@ -0,0 +1,134 @@
/**
* Integration Tests: Worker Pool & Parse Worker
*
* Verifies that the worker pool can spawn real worker threads using the
* compiled dist/ parse-worker.js and process files correctly.
* This is critical for cross-platform CI where vitest runs from src/
* but workers need compiled .js files.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { createWorkerPool, WorkerPool } from '../../src/core/ingestion/workers/worker-pool.js';
import { pathToFileURL } from 'node:url';
import path from 'node:path';
import fs from 'node:fs';
const DIST_WORKER = path.resolve(__dirname, '..', '..', 'dist', 'core', 'ingestion', 'workers', 'parse-worker.js');
const hasDistWorker = fs.existsSync(DIST_WORKER);
describe('worker pool integration', () => {
let pool: WorkerPool | undefined;
afterEach(async () => {
if (pool) {
await pool.terminate();
pool = undefined;
}
});
it.skipIf(!hasDistWorker)('creates a worker pool from dist/ worker', () => {
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
pool = createWorkerPool(workerUrl, 1);
expect(pool.size).toBe(1);
});
it.skipIf(!hasDistWorker)('dispatches an empty batch without error', async () => {
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
pool = createWorkerPool(workerUrl, 1);
const results = await pool.dispatch([]);
expect(results).toEqual([]);
});
it.skipIf(!hasDistWorker)('parses a single TypeScript file through worker', async () => {
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
pool = createWorkerPool(workerUrl, 1);
const fixtureFile = path.resolve(__dirname, '..', 'fixtures', 'mini-repo', 'src', 'validator.ts');
const content = fs.readFileSync(fixtureFile, 'utf-8');
const results = await pool.dispatch<any, any>([
{ path: 'src/validator.ts', content },
]);
// Worker returns an array of results (one per worker chunk)
expect(results).toHaveLength(1);
const result = results[0];
expect(result.fileCount).toBe(1);
expect(result.nodes.length).toBeGreaterThan(0);
// Should find the validateInput function
const names = result.nodes.map((n: any) => n.properties.name);
expect(names).toContain('validateInput');
});
it.skipIf(!hasDistWorker)('parses multiple files across workers', async () => {
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
pool = createWorkerPool(workerUrl, 2);
const fixturesDir = path.resolve(__dirname, '..', 'fixtures', 'mini-repo', 'src');
const files = fs.readdirSync(fixturesDir)
.filter(f => f.endsWith('.ts'))
.map(f => ({
path: `src/${f}`,
content: fs.readFileSync(path.join(fixturesDir, f), 'utf-8'),
}));
expect(files.length).toBeGreaterThanOrEqual(4);
const results = await pool.dispatch<any, any>(files);
// Each worker chunk returns a result
expect(results.length).toBeGreaterThan(0);
// Total files parsed should match input
const totalParsed = results.reduce((sum: number, r: any) => sum + r.fileCount, 0);
expect(totalParsed).toBe(files.length);
// Should find symbols from multiple files
const allNames = results.flatMap((r: any) => r.nodes.map((n: any) => n.properties.name));
expect(allNames).toContain('handleRequest');
expect(allNames).toContain('validateInput');
expect(allNames).toContain('saveToDb');
expect(allNames).toContain('formatResponse');
});
it.skipIf(!hasDistWorker)('reports progress during parsing', async () => {
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
pool = createWorkerPool(workerUrl, 1);
const fixturesDir = path.resolve(__dirname, '..', 'fixtures', 'mini-repo', 'src');
const files = fs.readdirSync(fixturesDir)
.filter(f => f.endsWith('.ts'))
.map(f => ({
path: `src/${f}`,
content: fs.readFileSync(path.join(fixturesDir, f), 'utf-8'),
}));
const progressCalls: number[] = [];
await pool.dispatch<any, any>(files, (filesProcessed) => {
progressCalls.push(filesProcessed);
});
// Progress callbacks are best-effort — with a small batch the worker may
// process all files before the progress message is delivered. Just verify
// that if progress was reported, the values are sensible.
if (progressCalls.length > 0) {
expect(progressCalls[progressCalls.length - 1]).toBe(files.length);
}
});
it.skipIf(!hasDistWorker)('terminates cleanly', async () => {
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
pool = createWorkerPool(workerUrl, 2);
await pool.terminate();
pool = undefined; // already terminated
});
it('fails gracefully with invalid worker path', () => {
const badUrl = pathToFileURL('/nonexistent/worker.js') as URL;
// createWorkerPool creates workers eagerly — the Worker constructor
// may throw or the worker may exit with an error
expect(() => {
pool = createWorkerPool(badUrl, 1);
}).not.toThrow(); // Workers fail asynchronously, not in constructor
});
});

View file

@ -21,38 +21,13 @@ import { spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { runHook, parseHookOutput } from '../utils/hook-test-helpers.js';
// ─── Paths to both hook variants ────────────────────────────────────
const CJS_HOOK = path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'gitnexus-hook.cjs');
const PLUGIN_HOOK = path.resolve(__dirname, '..', '..', '..', 'gitnexus-claude-plugin', 'hooks', 'gitnexus-hook.js');
// ─── Helper: run a hook script with JSON input on stdin ─────────────
function runHook(hookPath: string, input: Record<string, any>): { stdout: string; stderr: string; status: number | null } {
const result = spawnSync(process.execPath, [hookPath], {
input: JSON.stringify(input),
encoding: 'utf-8',
timeout: 10000,
stdio: ['pipe', 'pipe', 'pipe'],
});
return {
stdout: result.stdout || '',
stderr: result.stderr || '',
status: result.status,
};
}
function parseHookOutput(stdout: string): { hookEventName?: string; additionalContext?: string } | null {
if (!stdout.trim()) return null;
try {
const parsed = JSON.parse(stdout.trim());
return parsed.hookSpecificOutput || null;
} catch {
return null;
}
}
// ─── Test fixtures: temporary .gitnexus directory ───────────────────
let tmpDir: string;

View file

@ -0,0 +1,35 @@
/**
* Shared helpers for hook test files (unit + integration).
*/
import { spawnSync } from 'child_process';
export function runHook(
hookPath: string,
input: Record<string, any>,
cwd?: string,
): { stdout: string; stderr: string; status: number | null } {
const result = spawnSync(process.execPath, [hookPath], {
input: JSON.stringify(input),
encoding: 'utf-8',
timeout: 10000,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
});
return {
stdout: result.stdout || '',
stderr: result.stderr || '',
status: result.status,
};
}
export function parseHookOutput(
stdout: string,
): { hookEventName?: string; additionalContext?: string } | null {
if (!stdout.trim()) return null;
try {
const parsed = JSON.parse(stdout.trim());
return parsed.hookSpecificOutput || null;
} catch {
return null;
}
}