GitNexus/gitnexus/test/integration/analyzer-identity-cli.test.ts

189 lines
7 KiB
TypeScript

import { execFileSync, fork } from 'node:child_process';
import { createHash } from 'node:crypto';
import { readFile, realpath } from 'node:fs/promises';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import type { AnalyzerRunnerIdentity } from '../../src/storage/repo-manager.js';
import { getStoragePaths, loadMeta } from '../../src/storage/repo-manager.js';
import { setupMiniRepo } from '../helpers/mini-repo.js';
import { createTempDir } from '../helpers/test-db.js';
import { normalizeAnalyzerRunnerIdentityForComparison } from '../../src/core/analyzer-identity.js';
function runAnalyzeWorker(
workerEntry: string,
repoPath: string,
env: NodeJS.ProcessEnv,
force: boolean,
): Promise<{ alreadyUpToDate?: boolean }> {
return new Promise((resolve, reject) => {
const child = fork(workerEntry, [], {
cwd: repoPath,
env,
// Match the CLI subprocess's runtime conditions. Inheriting Vitest's
// development conditions would intentionally select a different identity.
execArgv: [],
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
});
let stderr = '';
const timer = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error(`analyze worker timed out: ${stderr}`));
}, 240_000);
child.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on('message', (message: unknown) => {
const result = message as {
type?: string;
message?: string;
result?: { alreadyUpToDate?: boolean };
};
if (result.type === 'complete') {
clearTimeout(timer);
resolve(result.result ?? {});
} else if (result.type === 'error') {
clearTimeout(timer);
reject(new Error(result.message ?? stderr));
}
});
child.on('error', (error) => {
clearTimeout(timer);
reject(error);
});
child.send({
type: 'start',
repoPath,
options: {
force,
skipAgentsMd: true,
skipSkills: true,
workerPoolSize: 1,
},
});
});
}
describe('CLI analyzer identity receipt', () => {
it('keeps CLI and server-worker entrypoints fresh in both directions', async () => {
const repo = await setupMiniRepo();
const isolatedHome = await createTempDir();
const packageRoot = path.resolve(__dirname, '..', '..');
const cliEntry = await realpath(path.join(packageRoot, 'dist', 'cli', 'index.js'));
const workerEntry = await realpath(
path.join(packageRoot, 'dist', 'server', 'analyze-worker.js'),
);
const runtimePath = await realpath(process.execPath);
const packageVersion = (
JSON.parse(await readFile(path.join(packageRoot, 'package.json'), 'utf8')) as {
version: string;
}
).version;
const env = {
...process.env,
GITNEXUS_HOME: isolatedHome.dbPath,
GITNEXUS_LANG: 'en',
GITNEXUS_LBUG_EXTENSION_INSTALL: 'never',
};
try {
execFileSync(
process.execPath,
[cliEntry, 'analyze', repo.dbPath, '--index-only', '--force', '--workers', '1'],
{ cwd: packageRoot, env, stdio: 'pipe', timeout: 240_000 },
);
const meta = await loadMeta(getStoragePaths(repo.dbPath).storagePath);
expect(meta?.runnerIdentity).toMatchObject({
schemaVersion: 4,
runtime: {
executablePath: runtimePath,
version: process.version,
platform: process.platform,
architecture: process.arch,
modulesAbi: process.versions.modules ?? 'unknown',
libc: expect.any(String),
},
cliVersion: packageVersion,
invokedArtifact: {
path: cliEntry,
digest: `sha256:${createHash('sha256')
.update(await readFile(cliEntry))
.digest('hex')}`,
},
build: {
kind: 'distribution',
rootPath: path.join(packageRoot, 'dist'),
canonicalization: 'gitnexus-analyzer-build-v2',
digest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
},
dependencyRuntime: {
manifestPath: path.join(packageRoot, 'package.json'),
lockfilePath: path.join(packageRoot, 'package-lock.json'),
canonicalization: 'gitnexus-analyzer-dependency-runtime-v4',
packageCount: expect.any(Number),
artifactCount: expect.any(Number),
digest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
},
});
expect(meta?.parseCacheIdentity?.base).toMatch(/^sha256:[a-f0-9]{64}$/);
// A server worker sees a different process.argv[1], but that entrypoint
// is diagnostic and already covered by the common build digest. It must
// take the unchanged fast path after a CLI-authored index.
const workerFastPath = await runAnalyzeWorker(workerEntry, repo.dbPath, env, false);
expect(workerFastPath.alreadyUpToDate).toBe(true);
expect((await loadMeta(getStoragePaths(repo.dbPath).storagePath))?.runnerIdentity).toEqual(
meta?.runnerIdentity,
);
// Reverse the author: a forced worker run stamps its own entrypoint, and
// both CLI status and CLI analyze must still regard the semantic runner
// identity as current rather than rebuilding solely to swap diagnostics.
await runAnalyzeWorker(workerEntry, repo.dbPath, env, true);
const workerMeta = await loadMeta(getStoragePaths(repo.dbPath).storagePath);
expect(workerMeta?.runnerIdentity?.invokedArtifact.path).toBe(workerEntry);
expect(workerMeta?.parseCacheIdentity).toEqual(meta?.parseCacheIdentity);
const status = execFileSync(process.execPath, [cliEntry, 'status', '--json'], {
cwd: repo.dbPath,
env,
encoding: 'utf8',
timeout: 60_000,
});
const parsedStatus = JSON.parse(status) as {
index: {
runnerIdentity: AnalyzerRunnerIdentity | null;
runnerIdentityStatus: string;
};
current: { runnerIdentity: AnalyzerRunnerIdentity };
status: string;
};
expect(parsedStatus.index.runnerIdentity).toEqual(workerMeta?.runnerIdentity);
expect(parsedStatus.current.runnerIdentity.invokedArtifact.path).toBe(cliEntry);
expect(parsedStatus.current.runnerIdentity).not.toEqual(workerMeta?.runnerIdentity);
expect(
normalizeAnalyzerRunnerIdentityForComparison(parsedStatus.current.runnerIdentity),
).toEqual(normalizeAnalyzerRunnerIdentityForComparison(workerMeta?.runnerIdentity));
expect(parsedStatus.index.runnerIdentityStatus).toBe('current');
expect(parsedStatus.status).toBe('up-to-date');
execFileSync(
process.execPath,
[cliEntry, 'analyze', repo.dbPath, '--index-only', '--workers', '1'],
{
cwd: packageRoot,
env,
stdio: 'pipe',
timeout: 240_000,
},
);
expect((await loadMeta(getStoragePaths(repo.dbPath).storagePath))?.runnerIdentity).toEqual(
workerMeta?.runnerIdentity,
);
} finally {
await repo.cleanup();
await isolatedHome.cleanup();
}
}, 300_000);
});