mirror of
https://github.com/proffesor-for-testing/agentic-qe.git
synced 2026-09-19 08:45:47 +08:00
abdc867853
Closes #383 item 2. `aqe upgrade` is a read-only advisory command that detects which optional native bindings load on this platform and recommends installs / flag changes. It does NOT mutate feature flags, env vars, or config files — it only tells the user what they'd gain. Detects: - better-sqlite3, web-tree-sitter (required) - hnswlib-node (canonical HNSW, default since ADR-090) - @ruvector/rvf-node (persistent HNSW + RVF export) - @ruvector/solver-node (sublinear PageRank) - @ruvector/attention (Flash Attention SIMD) - @ruvector/gnn (GraphMAE acceleration) Output: - Platform + AQE version - Per-package status (loaded / missing / required-missing) with the first line of any load error - Current flag state (after RUVECTOR_* env overrides) - Recommendations: install hints for missing optionals, warnings when a RUVECTOR_* forces a flag on but the native is missing - Summary line with required-OK status and optional loaded/total Flags: --json machine-readable report --strict exit 1 if any recommended optional native is missing Exit codes: 0 all good (or --strict not passed with optionals missing) 1 --strict and ≥1 optional missing 2 a required native failed to load Tests: - 17 unit tests (tests/unit/cli-upgrade.test.ts) exercise pure detection / recommendation / exit-code logic with injected probes - 5 integration tests (tests/integration/cli/upgrade-command.test.ts) spawn the built CLI and assert the JSON contract, human sections, exit-code contract, and RUVECTOR_* env override plumbing Also fixes a pre-existing lint error (unused originalConsoleInfo in src/cli/index.ts) per the edit-touched-file convention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
267 lines
9.2 KiB
TypeScript
267 lines
9.2 KiB
TypeScript
/**
|
|
* Unit tests for `aqe upgrade` — pure logic.
|
|
*
|
|
* The goal is to drive detection / recommendation rules via injected probes
|
|
* and env maps so they can be exhaustively exercised without touching real
|
|
* native deps.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
|
|
import {
|
|
buildRecommendations,
|
|
buildReport,
|
|
detectNatives,
|
|
exitCodeFor,
|
|
readEnvOverrides,
|
|
NATIVE_CATALOG,
|
|
type LoadProbe,
|
|
type NativeCheck,
|
|
type UpgradeReport,
|
|
} from '../../src/cli/commands/upgrade.js';
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Helpers
|
|
// ----------------------------------------------------------------------------
|
|
|
|
function probeWith(loaded: readonly string[], missing: readonly string[] = []): LoadProbe {
|
|
const allMissing = new Set(missing);
|
|
const allLoaded = new Set(loaded);
|
|
return (name) => {
|
|
if (allLoaded.has(name)) return { ok: true };
|
|
if (allMissing.has(name)) {
|
|
const err = new Error(`Cannot find module '${name}'`) as NodeJS.ErrnoException;
|
|
err.code = 'MODULE_NOT_FOUND';
|
|
return { ok: false, error: err };
|
|
}
|
|
// Default for unlisted packages: treat as missing so we don't accidentally
|
|
// depend on the local machine.
|
|
const err = new Error(`Cannot find module '${name}'`) as NodeJS.ErrnoException;
|
|
err.code = 'MODULE_NOT_FOUND';
|
|
return { ok: false, error: err };
|
|
};
|
|
}
|
|
|
|
const ALL_PACKAGES = NATIVE_CATALOG.map((c) => c.packageName);
|
|
const REQUIRED_PACKAGES = NATIVE_CATALOG.filter((c) => c.required).map((c) => c.packageName);
|
|
|
|
const HAPPY_FLAGS: UpgradeReport['flags'] = {
|
|
useRVFPatternStore: true,
|
|
useSublinearSolver: true,
|
|
useNativeHNSW: true,
|
|
useGraphMAEEmbeddings: true,
|
|
useQEFlashAttention: true,
|
|
};
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// detectNatives
|
|
// ----------------------------------------------------------------------------
|
|
|
|
describe('detectNatives', () => {
|
|
it('marks packages that load as "loaded"', () => {
|
|
const results = detectNatives(NATIVE_CATALOG, probeWith(ALL_PACKAGES));
|
|
expect(results.every((r) => r.status === 'loaded')).toBe(true);
|
|
});
|
|
|
|
it('marks required packages as "required-missing" when absent', () => {
|
|
const results = detectNatives(NATIVE_CATALOG, probeWith([]));
|
|
for (const r of results) {
|
|
if (r.required) expect(r.status).toBe('required-missing');
|
|
else expect(r.status).toBe('missing');
|
|
}
|
|
});
|
|
|
|
it('captures the load error message verbatim', () => {
|
|
const results = detectNatives(NATIVE_CATALOG, probeWith([]));
|
|
for (const r of results) {
|
|
expect(r.loadError).toMatch(/Cannot find module/);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// readEnvOverrides
|
|
// ----------------------------------------------------------------------------
|
|
|
|
describe('readEnvOverrides', () => {
|
|
it('returns an empty list when no RUVECTOR_* env vars are set', () => {
|
|
expect(readEnvOverrides({})).toEqual([]);
|
|
});
|
|
|
|
it('picks up known RUVECTOR_* overrides and maps them to flag names', () => {
|
|
const overrides = readEnvOverrides({
|
|
RUVECTOR_USE_RVF_PATTERN_STORE: 'true',
|
|
RUVECTOR_USE_SUBLINEAR_SOLVER: 'false',
|
|
UNRELATED_VAR: 'hello',
|
|
});
|
|
const envVars = overrides.map((o) => o.envVar).sort();
|
|
expect(envVars).toEqual([
|
|
'RUVECTOR_USE_RVF_PATTERN_STORE',
|
|
'RUVECTOR_USE_SUBLINEAR_SOLVER',
|
|
]);
|
|
const rvf = overrides.find((o) => o.envVar === 'RUVECTOR_USE_RVF_PATTERN_STORE');
|
|
expect(rvf?.flagName).toBe('useRVFPatternStore');
|
|
expect(rvf?.value).toBe('true');
|
|
});
|
|
});
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// buildRecommendations
|
|
// ----------------------------------------------------------------------------
|
|
|
|
describe('buildRecommendations', () => {
|
|
it('emits a positive info line when nothing is missing', () => {
|
|
const natives = detectNatives(NATIVE_CATALOG, probeWith(ALL_PACKAGES));
|
|
const recs = buildRecommendations({ natives, flags: HAPPY_FLAGS, envOverrides: [] });
|
|
expect(recs.some((r) => r.severity === 'info' && /no action/i.test(r.message))).toBe(true);
|
|
expect(recs.every((r) => r.severity !== 'error')).toBe(true);
|
|
});
|
|
|
|
it('reports error severity for missing required deps with an install action', () => {
|
|
// Load only optionals, miss the required ones.
|
|
const optionals = NATIVE_CATALOG.filter((c) => !c.required).map((c) => c.packageName);
|
|
const natives = detectNatives(NATIVE_CATALOG, probeWith(optionals));
|
|
const recs = buildRecommendations({ natives, flags: HAPPY_FLAGS, envOverrides: [] });
|
|
|
|
for (const required of REQUIRED_PACKAGES) {
|
|
const match = recs.find(
|
|
(r) => r.severity === 'error' && r.message.includes(required),
|
|
);
|
|
expect(match).toBeDefined();
|
|
expect(match?.action).toBe(`npm install ${required}`);
|
|
}
|
|
});
|
|
|
|
it('emits warn severity per missing optional with install action', () => {
|
|
// Load only required, miss all optionals.
|
|
const natives = detectNatives(NATIVE_CATALOG, probeWith(REQUIRED_PACKAGES));
|
|
const recs = buildRecommendations({ natives, flags: HAPPY_FLAGS, envOverrides: [] });
|
|
|
|
const optionalCatalog = NATIVE_CATALOG.filter((c: NativeCheck) => !c.required);
|
|
for (const opt of optionalCatalog) {
|
|
const match = recs.find(
|
|
(r) => r.severity === 'warn' && r.message.includes(opt.packageName),
|
|
);
|
|
expect(match, `missing warning for ${opt.packageName}`).toBeDefined();
|
|
expect(match?.action).toBe(`npm install ${opt.packageName}`);
|
|
}
|
|
});
|
|
|
|
it('warns when RUVECTOR_* forces a flag on but the native is missing', () => {
|
|
const natives = detectNatives(NATIVE_CATALOG, probeWith(REQUIRED_PACKAGES));
|
|
const recs = buildRecommendations({
|
|
natives,
|
|
flags: HAPPY_FLAGS,
|
|
envOverrides: [
|
|
{ envVar: 'RUVECTOR_USE_RVF_PATTERN_STORE', value: 'true', flagName: 'useRVFPatternStore' },
|
|
],
|
|
});
|
|
|
|
const conflict = recs.find(
|
|
(r) =>
|
|
r.severity === 'warn' &&
|
|
r.message.includes('RUVECTOR_USE_RVF_PATTERN_STORE') &&
|
|
r.message.includes('silently fall back'),
|
|
);
|
|
expect(conflict).toBeDefined();
|
|
});
|
|
|
|
it('does not emit a conflict warning when the flag override value is "false"', () => {
|
|
const natives = detectNatives(NATIVE_CATALOG, probeWith(REQUIRED_PACKAGES));
|
|
const recs = buildRecommendations({
|
|
natives,
|
|
flags: HAPPY_FLAGS,
|
|
envOverrides: [
|
|
{ envVar: 'RUVECTOR_USE_RVF_PATTERN_STORE', value: 'false', flagName: 'useRVFPatternStore' },
|
|
],
|
|
});
|
|
const conflict = recs.find((r) => r.message.includes('silently fall back'));
|
|
expect(conflict).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// buildReport + exitCodeFor
|
|
// ----------------------------------------------------------------------------
|
|
|
|
describe('buildReport', () => {
|
|
it('summarises required vs optional counts', () => {
|
|
const report = buildReport({
|
|
aqeVersion: '9.9.9',
|
|
probe: probeWith(REQUIRED_PACKAGES),
|
|
env: {},
|
|
flags: HAPPY_FLAGS,
|
|
});
|
|
const optionalCount = NATIVE_CATALOG.filter((c) => !c.required).length;
|
|
expect(report.summary.requiredOk).toBe(true);
|
|
expect(report.summary.optionalLoadedCount).toBe(0);
|
|
expect(report.summary.optionalMissingCount).toBe(optionalCount);
|
|
});
|
|
|
|
it('reports requiredOk=false when a required dep fails to load', () => {
|
|
// Only load optionals.
|
|
const optionals = NATIVE_CATALOG.filter((c) => !c.required).map((c) => c.packageName);
|
|
const report = buildReport({
|
|
aqeVersion: '9.9.9',
|
|
probe: probeWith(optionals),
|
|
env: {},
|
|
flags: HAPPY_FLAGS,
|
|
});
|
|
expect(report.summary.requiredOk).toBe(false);
|
|
});
|
|
|
|
it('carries aqeVersion, platform + node through to the report', () => {
|
|
const report = buildReport({
|
|
aqeVersion: '9.9.9',
|
|
probe: probeWith(ALL_PACKAGES),
|
|
env: {},
|
|
flags: HAPPY_FLAGS,
|
|
});
|
|
expect(report.aqeVersion).toBe('9.9.9');
|
|
expect(report.platform.os).toBeTruthy();
|
|
expect(report.platform.arch).toBeTruthy();
|
|
expect(report.platform.node).toMatch(/^v/);
|
|
});
|
|
});
|
|
|
|
describe('exitCodeFor', () => {
|
|
const reportOk = (): UpgradeReport => buildReport({
|
|
aqeVersion: 'x',
|
|
probe: probeWith(ALL_PACKAGES),
|
|
env: {},
|
|
flags: HAPPY_FLAGS,
|
|
});
|
|
|
|
const reportOptionalMissing = (): UpgradeReport => buildReport({
|
|
aqeVersion: 'x',
|
|
probe: probeWith(REQUIRED_PACKAGES),
|
|
env: {},
|
|
flags: HAPPY_FLAGS,
|
|
});
|
|
|
|
const reportRequiredMissing = (): UpgradeReport => buildReport({
|
|
aqeVersion: 'x',
|
|
probe: probeWith([]),
|
|
env: {},
|
|
flags: HAPPY_FLAGS,
|
|
});
|
|
|
|
it('returns 0 when everything is loaded (strict or not)', () => {
|
|
expect(exitCodeFor(reportOk(), false)).toBe(0);
|
|
expect(exitCodeFor(reportOk(), true)).toBe(0);
|
|
});
|
|
|
|
it('returns 0 when optionals missing and strict=false', () => {
|
|
expect(exitCodeFor(reportOptionalMissing(), false)).toBe(0);
|
|
});
|
|
|
|
it('returns 1 when optionals missing and strict=true', () => {
|
|
expect(exitCodeFor(reportOptionalMissing(), true)).toBe(1);
|
|
});
|
|
|
|
it('returns 2 when required is missing (regardless of strict)', () => {
|
|
expect(exitCodeFor(reportRequiredMissing(), false)).toBe(2);
|
|
expect(exitCodeFor(reportRequiredMissing(), true)).toBe(2);
|
|
});
|
|
});
|