test: coverage fill — 95 tests for six zero-coverage surfaces

- eval CLI family (eval-list/compare/summary + eval-select smoke): the
  primary interface to eval results had no tests; isolation via a fake
  gstack-slug under a mkdtemp HOME (the scripts' real resolution path —
  they do NOT honor GSTACK_EVAL_DIR; only EvalCollector does). Pinned
  current behavior: eval-list does NOT exclude _partial runs (documented
  improvement candidate)
- slop-diff (runs on every /review + quality-gate): fixture git repo +
  first-on-PATH npx stub (never downloads real slop-scan); no-diff
  early exit, missing-scanner fallback, fingerprint line-insensitivity,
  merge-base worktree scan
- bin/gstack-code-intelligence CLI arg surface (lib was covered, the
  284-line CLI wasn't): select/consent/suggest/index/search gating;
  pinned: --help routes to usage failure exit 1 (no handler)
- browse media-extract: the page.evaluate callback exercised in-process
  against a mock DOM (no exports added) — lazy-src fallback chain,
  HLS/DASH detection, bg-image url() parsing, 500-element cap
- browse session-cookie-store: factory contract (cookieName/ttlMs/
  maxSessions eviction, cross-store isolation, mint→validate
  round-trip); store is in-memory — no fs cases exist
- lib/version-source direct unit tests (gstack-version-bump.test.ts
  spawns the bin, never imports the lib): parse/format/cmp/bump
  coercion, npm 4→3 translation, #2501 mangled-JSON regression class

All hermetic (mkdtemp homes, runBin child isolation); windows curation
correctly partitions the six.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-29 05:28:51 +00:00
co-authored by Claude Fable 5
parent 6841183c35
commit 1055561cae
6 changed files with 1388 additions and 0 deletions
+205
View File
@@ -0,0 +1,205 @@
/**
* bin/gstack-code-intelligence — CLI surface smoke tests.
*
* lib/code-intelligence/* is covered by test/code-intelligence.test.ts, which
* also drives the CLI's `index` and `search` consent/policy refusal paths.
* This file covers the argument-handling surface those tests skip: usage on
* bad/missing subcommands, `select` and `consent` validation + state writes,
* and the `suggest` offer gate — all hermetic under a mkdtemp GSTACK_HOME
* (the selection store lives at $GSTACK_HOME/code-intelligence.json), and all
* on paths that never call detectAvailable(), so nothing probes providers or
* the network.
*
* Note: the CLI has no `--help` flag — every unrecognized action (including
* `--help`) routes to the usage message on stderr with exit 1. Pinned below.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { runBin } from './helpers/run-bin';
const ROOT = path.resolve(import.meta.dir, '..');
const CLI = path.join(ROOT, 'bin', 'gstack-code-intelligence');
let home: string;
let workDir: string;
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-cli-home-'));
workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-cli-work-'));
});
afterEach(() => {
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(workDir, { recursive: true, force: true });
});
function runCli(...args: string[]) {
return runBin('bun', [CLI, ...args], { cwd: workDir, gstackHome: home, home });
}
function readStore(): { provider: string | null; consents: Record<string, boolean>; declined: boolean } {
return JSON.parse(fs.readFileSync(path.join(home, 'code-intelligence.json'), 'utf-8'));
}
describe('gstack-code-intelligence: usage surface', () => {
test('no arguments: usage on stderr, exit 1', () => {
const result = runCli();
expect(result.status).toBe(1);
expect(result.stderr).toContain('gstack-code-intelligence:');
expect(result.stderr).toContain('Usage:');
expect(result.stderr).toContain('select <provider>');
expect(result.stdout).toBe('');
});
test('unknown subcommand: usage on stderr, exit 1', () => {
const result = runCli('frobnicate');
expect(result.status).toBe(1);
expect(result.stderr).toContain('Usage:');
});
test('--help has no exit-0 handler — it routes to the usage failure (current behavior)', () => {
const result = runCli('--help');
expect(result.status).toBe(1);
expect(result.stderr).toContain('Usage:');
});
});
describe('gstack-code-intelligence: select', () => {
test('invalid provider is rejected with the select usage line', () => {
const result = runCli('select', 'bogus-provider');
expect(result.status).toBe(1);
expect(result.stderr).toContain('Usage: select <gbrain|sourcebot|graphify|none>');
expect(fs.existsSync(path.join(home, 'code-intelligence.json'))).toBe(false);
});
test('select with no argument is rejected the same way', () => {
const result = runCli('select');
expect(result.status).toBe(1);
expect(result.stderr).toContain('Usage: select <gbrain|sourcebot|graphify|none>');
});
test('select none records the decline so the offer is never repeated', () => {
const result = runCli('select', 'none');
expect(result.status).toBe(0);
expect(result.stdout).toContain('declined');
expect(result.stdout).toContain('will not ask again');
const store = readStore();
expect(store.provider).toBeNull();
expect(store.declined).toBe(true);
});
test('selecting the local provider persists it without an off-machine warning', () => {
const result = runCli('select', 'graphify');
expect(result.status).toBe(0);
expect(result.stdout).toContain('selected Graphify.');
expect(result.stdout).not.toContain('off this machine');
const store = readStore();
expect(store.provider).toBe('graphify');
expect(store.declined).toBe(false);
});
test('selecting a non-local provider warns that content leaves the machine', () => {
const result = runCli('select', 'gbrain');
expect(result.status).toBe(0);
expect(result.stdout).toContain('selected GBrain.');
expect(result.stdout).toContain('off this machine');
expect(readStore().provider).toBe('gbrain');
});
});
describe('gstack-code-intelligence: consent', () => {
test('the yes/no value is required — a bare path records NOTHING', () => {
const result = runCli('consent', workDir);
expect(result.status).toBe(1);
expect(result.stderr).toContain('never assumed');
expect(fs.existsSync(path.join(home, 'code-intelligence.json'))).toBe(false);
});
test('an unknown value records NOTHING', () => {
const result = runCli('consent', workDir, 'maybe');
expect(result.status).toBe(1);
expect(result.stderr).toContain('never assumed');
expect(fs.existsSync(path.join(home, 'code-intelligence.json'))).toBe(false);
});
test('consent yes persists true for the resolved repo path', () => {
const result = runCli('consent', workDir, 'yes');
expect(result.status).toBe(0);
expect(result.stdout).toContain('indexing consent recorded');
expect(readStore().consents[fs.realpathSync(workDir)] ?? readStore().consents[workDir]).toBe(true);
});
test('consent no persists an explicit DENIED — a "no" is a durable answer too', () => {
const result = runCli('consent', workDir, 'no');
expect(result.status).toBe(0);
expect(result.stdout).toContain('DENIED');
expect(readStore().consents[fs.realpathSync(workDir)] ?? readStore().consents[workDir]).toBe(false);
});
test('consent with no path defaults to the cwd', () => {
const result = runCli('consent', 'yes');
expect(result.status).toBe(0);
const consents = readStore().consents;
const keys = Object.keys(consents);
expect(keys.length).toBe(1);
// resolve(cwd) — the child's cwd is workDir (possibly via a symlinked tmp).
expect([workDir, fs.realpathSync(workDir)]).toContain(keys[0]);
expect(consents[keys[0]]).toBe(true);
});
});
describe('gstack-code-intelligence: suggest (offer gate)', () => {
test('a non-repo directory never triggers the offer (--json)', () => {
const result = runCli('suggest', workDir, '--json');
expect(result.status).toBe(0);
const parsed = JSON.parse(result.stdout);
expect(parsed.offer).toBe(false);
expect(parsed.reason).toBe('not-a-repo');
expect(parsed.fileCount).toBeNull();
expect([workDir, fs.realpathSync(workDir)]).toContain(parsed.repoPath);
});
test('a selected provider suppresses the offer before any repo probing', () => {
expect(runCli('select', 'graphify').status).toBe(0);
const result = runCli('suggest', workDir, '--json');
expect(result.status).toBe(0);
const parsed = JSON.parse(result.stdout);
expect(parsed.offer).toBe(false);
expect(parsed.reason).toBe('provider-selected');
});
test('an explicit decline suppresses the offer permanently', () => {
expect(runCli('select', 'none').status).toBe(0);
const result = runCli('suggest', workDir, '--json');
expect(result.status).toBe(0);
expect(JSON.parse(result.stdout).reason).toBe('declined');
});
test('human-readable no-offer output names the reason', () => {
const result = runCli('suggest', workDir);
expect(result.status).toBe(0);
expect(result.stdout).toContain('no offer (not-a-repo)');
});
});
describe('gstack-code-intelligence: provider-requiring commands without a selection', () => {
test('index refuses when no provider is selected', () => {
const result = runCli('index', workDir);
expect(result.status).toBe(1);
expect(result.stderr).toContain('no provider selected');
});
test('search refuses when no provider is selected', () => {
const result = runCli('search', 'anything');
expect(result.status).toBe(1);
expect(result.stderr).toContain('no provider selected');
});
test('search with no query prints the search usage', () => {
const result = runCli('search');
expect(result.status).toBe(1);
expect(result.stderr).toContain('Usage: search <query...>');
});
});
+387
View File
@@ -0,0 +1,387 @@
/**
* The eval CLI family — scripts/eval-select.ts, eval-list.ts, eval-compare.ts,
* eval-summary.ts — the primary interface to eval results.
*
* Isolation mechanisms (each verified against the source, not assumed):
*
* - eval-list / eval-compare / eval-summary resolve their eval dir via
* getProjectEvalDir() (test/helpers/eval-store.ts), which probes the
* CWD-RELATIVE `.claude/skills/gstack/bin/gstack-slug` first, then
* `~/.claude/...` (~ = $HOME of the child). They do NOT honor
* GSTACK_EVAL_DIR (only EvalCollector does). So the real isolation
* mechanism is: cwd = a temp HOME containing a fake gstack-slug that
* prints `SLUG=<fixture>`, routing every read to
* $HOME/.gstack/projects/<fixture>/evals — fully hermetic, and it
* exercises the primary (project-scoped) dir resolution path.
* (test/eval-list-cli.test.ts already covers the legacy-fallback dir +
* --limit validation; this file deliberately does not duplicate that.)
*
* - eval-select has NO isolation mechanism for its git diff: ROOT is
* hardcoded to the repo containing the script (import.meta.dir/..), so
* the CLI is smoke-tested against this repo with `--base HEAD` using
* shape invariants that hold for any working-tree state, and the
* "global touchfile ⇒ run everything" behavior is tested through the
* pure, importable selectTests() the CLI is a thin wrapper over.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { runBin } from './helpers/run-bin';
import { selectTests, E2E_TOUCHFILES, LLM_JUDGE_TOUCHFILES, GLOBAL_TOUCHFILES } from './helpers/touchfiles';
const ROOT = path.resolve(import.meta.dir, '..');
const SCRIPT = (name: string) => path.join(ROOT, 'scripts', name);
const SLUG = 'eval-cli-fixture';
let tmpHome: string;
let evalDir: string;
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-eval-family-'));
// Fake gstack-slug at the cwd-relative probe path so getProjectEvalDir()
// deterministically resolves the project-scoped dir under the temp HOME.
const slugBin = path.join(tmpHome, '.claude', 'skills', 'gstack', 'bin');
fs.mkdirSync(slugBin, { recursive: true });
fs.writeFileSync(path.join(slugBin, 'gstack-slug'), `#!/usr/bin/env bash\necho "SLUG=${SLUG}"\n`, { mode: 0o755 });
evalDir = path.join(tmpHome, '.gstack', 'projects', SLUG, 'evals');
fs.mkdirSync(evalDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpHome, { recursive: true, force: true });
});
function runEvalCli(script: string, ...args: string[]) {
return runBin('bun', [SCRIPT(script), ...args], {
cwd: tmpHome,
home: tmpHome,
gstackHome: path.join(tmpHome, '.gstack'),
});
}
interface FixtureTest {
name: string;
passed: boolean;
cost?: number;
turns?: number;
duration?: number;
}
/** Write a run file in the collector's shapes: finalized `{version}-{branch}-{tier}-{ts}.json` or `_partial-e2e.json`. */
function writeRun(dir: string, opts: {
version?: string;
branch?: string;
tier?: 'e2e' | 'llm-judge';
timestamp: string;
tests: FixtureTest[];
partial?: boolean;
}): string {
const version = opts.version ?? '1.0.0';
const branch = opts.branch ?? 'featx';
const tier = opts.tier ?? 'e2e';
const tests = opts.tests.map(t => ({
name: t.name,
suite: 'fixture',
tier,
passed: t.passed,
duration_ms: t.duration ?? 1000,
cost_usd: t.cost ?? 0.5,
turns_used: t.turns ?? 5,
}));
const body = {
schema_version: 1,
version,
branch,
git_sha: 'abc1234',
timestamp: opts.timestamp,
hostname: 'fixture-host',
tier,
total_tests: tests.length,
passed: tests.filter(t => t.passed).length,
failed: tests.filter(t => !t.passed).length,
total_cost_usd: tests.reduce((s, t) => s + t.cost_usd, 0),
total_duration_ms: tests.reduce((s, t) => s + t.duration_ms, 0),
tests,
...(opts.partial ? { _partial: true } : {}),
};
const dateStr = opts.timestamp.replace(/[:.]/g, '').replace('T', '-').slice(0, 15);
const filename = opts.partial ? '_partial-e2e.json' : `${version}-${branch}-${tier}-${dateStr}.json`;
fs.mkdirSync(dir, { recursive: true });
const filepath = path.join(dir, filename);
fs.writeFileSync(filepath, JSON.stringify(body, null, 2) + '\n');
return filepath;
}
// ── eval-select ──────────────────────────────────────────────────────────────
describe('eval:select CLI (scripts/eval-select.ts)', () => {
test('--json parses and its selection partitions the full touchfile maps', () => {
// --base HEAD makes the committed diff empty; uncommitted/untracked files
// in the working tree may still appear, so assert shape invariants that
// hold for ANY tree state rather than pinning specific selections.
const result = runBin('bun', [SCRIPT('eval-select.ts'), '--json', '--base', 'HEAD'], { cwd: ROOT });
expect(result.status).toBe(0);
const parsed = JSON.parse(result.stdout);
expect(parsed.base).toBe('HEAD');
if (parsed.changed_files === 0) {
// Pristine tree: the no-diff shape reports run-all for both tiers.
expect(parsed.e2e).toBe('all');
expect(parsed.llm_judge).toBe('all');
expect(parsed.reason).toContain('all tests');
} else {
expect(Array.isArray(parsed.changed_files)).toBe(true);
expect(parsed.changed_files.length).toBeGreaterThan(0);
for (const [selection, map] of [
[parsed.e2e, E2E_TOUCHFILES],
[parsed.llm_judge, LLM_JUDGE_TOUCHFILES],
] as const) {
const total = Object.keys(map).length;
expect(Array.isArray(selection.selected)).toBe(true);
expect(Array.isArray(selection.skipped)).toBe(true);
// selected + skipped always partition the map: disjoint, complete.
expect(selection.selected.length + selection.skipped.length).toBe(total);
const overlap = selection.selected.filter((name: string) => selection.skipped.includes(name));
expect(overlap).toEqual([]);
expect(typeof selection.reason).toBe('string');
expect(selection.count).toBe(`${selection.selected.length}/${total}`);
}
expect(Array.isArray(parsed.e2e.removed_tests)).toBe(true);
}
});
test('human-readable mode prints the base and per-tier headers', () => {
const result = runBin('bun', [SCRIPT('eval-select.ts'), '--base', 'HEAD'], { cwd: ROOT });
expect(result.status).toBe(0);
expect(result.stdout).toContain('Base: HEAD');
// Either the no-diff line or the two selection headers.
const hasNoDiff = result.stdout.includes('No changed files detected');
if (!hasNoDiff) {
expect(result.stdout).toContain('E2E: selected');
expect(result.stdout).toContain('LLM-judge: selected');
}
});
test('a global-touchfile diff selects ALL tests with a global reason (pure selectTests)', () => {
// eval-select is a thin wrapper over selectTests(); the CLI cannot be
// pointed at a fixture repo (ROOT is hardcoded), so the run-all-on-global
// behavior is pinned through the same imported function it calls.
expect(GLOBAL_TOUCHFILES).toContain('test/helpers/eval-store.ts');
const selection = selectTests(['test/helpers/eval-store.ts'], E2E_TOUCHFILES, GLOBAL_TOUCHFILES);
expect(selection.reason).toBe('global: test/helpers/eval-store.ts');
expect(selection.selected.sort()).toEqual(Object.keys(E2E_TOUCHFILES).sort());
expect(selection.skipped).toEqual([]);
});
test('a per-test touchfile diff selects only the dependent test', () => {
const touchfiles = {
'test-a': ['src/feature-a.ts', 'src/shared/**'],
'test-b': ['src/feature-b.ts'],
};
const globals = ['helpers/global-runner.ts'];
const hitA = selectTests(['src/feature-a.ts'], touchfiles, globals);
expect(hitA.selected).toEqual(['test-a']);
expect(hitA.skipped).toEqual(['test-b']);
expect(hitA.reason).toBe('diff');
const hitGlob = selectTests(['src/shared/deep/util.ts'], touchfiles, globals);
expect(hitGlob.selected).toEqual(['test-a']);
const miss = selectTests(['docs/README.md'], touchfiles, globals);
expect(miss.selected).toEqual([]);
expect(miss.skipped.sort()).toEqual(['test-a', 'test-b']);
});
});
// ── eval-list ────────────────────────────────────────────────────────────────
describe('eval:list CLI (scripts/eval-list.ts)', () => {
test('empty eval dir prints the getting-started hint and exits 0', () => {
const result = runEvalCli('eval-list.ts');
expect(result.status).toBe(0);
expect(result.stdout).toContain('No eval runs yet');
});
test('lists finalized runs from the flat dir AND one level of shards/<slug>/', () => {
writeRun(evalDir, { branch: 'flat-branch', timestamp: '2026-01-01T01:00:00Z', tests: [{ name: 't1', passed: true, cost: 1.5, turns: 7 }] });
writeRun(path.join(evalDir, 'shards', 'shard-a'), { branch: 'shard-branch', timestamp: '2026-01-02T01:00:00Z', tests: [{ name: 't2', passed: true, cost: 0.5, turns: 3 }] });
const result = runEvalCli('eval-list.ts');
expect(result.status).toBe(0);
expect(result.stdout).toContain('Eval History (2 total runs)');
expect(result.stdout).toContain('flat-branch');
expect(result.stdout).toContain('shard-branch');
// Sorted by timestamp descending: the shard run (newer) is listed first.
expect(result.stdout.indexOf('shard-branch')).toBeLessThan(result.stdout.indexOf('flat-branch'));
// Reads route to the project-scoped dir resolved via the fake gstack-slug.
expect(result.stdout).toContain(path.join('projects', SLUG, 'evals'));
});
test('--branch and --tier filter the listing', () => {
writeRun(evalDir, { branch: 'keep-me', tier: 'e2e', timestamp: '2026-01-01T01:00:00Z', tests: [{ name: 't1', passed: true }] });
writeRun(evalDir, { branch: 'drop-me', tier: 'llm-judge', timestamp: '2026-01-02T01:00:00Z', tests: [{ name: 't2', passed: true }] });
const byBranch = runEvalCli('eval-list.ts', '--branch', 'keep-me');
expect(byBranch.status).toBe(0);
expect(byBranch.stdout).toContain('Eval History (1 total runs)');
expect(byBranch.stdout).toContain('keep-me');
expect(byBranch.stdout).not.toContain('drop-me');
const byTier = runEvalCli('eval-list.ts', '--tier', 'llm-judge');
expect(byTier.status).toBe(0);
expect(byTier.stdout).toContain('drop-me');
expect(byTier.stdout).not.toContain('keep-me');
});
test('DOCUMENTS CURRENT BEHAVIOR: in-progress _partial accumulators appear in the listing', () => {
// eval-list.ts applies NO isPartialEval filter (unlike eval-compare and
// every baseline lookup in eval-store.ts), so the in-progress accumulator
// is listed as if it were a run. If eval-list ever grows a partial filter,
// update this test to assert exclusion — that would be an improvement,
// not a regression.
writeRun(evalDir, { branch: 'finalized-run', timestamp: '2026-01-01T01:00:00Z', tests: [{ name: 't1', passed: true }] });
writeRun(evalDir, { branch: 'partial-sentinel', timestamp: '2026-01-03T01:00:00Z', tests: [{ name: 't1', passed: false }], partial: true });
const result = runEvalCli('eval-list.ts');
expect(result.status).toBe(0);
expect(result.stdout).toContain('finalized-run');
expect(result.stdout).toContain('Eval History (2 total runs)');
expect(result.stdout).toContain('partial-sentinel');
});
});
// ── eval-compare ─────────────────────────────────────────────────────────────
describe('eval:compare CLI (scripts/eval-compare.ts)', () => {
test('empty eval dir prints the getting-started hint and exits 0', () => {
const result = runEvalCli('eval-compare.ts');
expect(result.status).toBe(0);
expect(result.stdout).toContain('No eval runs yet');
});
test('a single run is not enough to compare (exit 0 with guidance)', () => {
writeRun(evalDir, { timestamp: '2026-01-01T01:00:00Z', tests: [{ name: 't1', passed: true }] });
const result = runEvalCli('eval-compare.ts');
expect(result.status).toBe(0);
expect(result.stdout).toContain('Need at least 2 eval runs');
});
test('no args: compares the two most recent FINALIZED runs and reports deltas; the fresher partial is never a side', () => {
writeRun(evalDir, {
timestamp: '2026-01-01T01:00:00Z',
tests: [
{ name: 't-stable', passed: true, cost: 1.0, turns: 5 },
{ name: 't-flaky', passed: false, cost: 1.0, turns: 5 },
{ name: 't-regressed', passed: true, cost: 1.0, turns: 5 },
],
});
writeRun(evalDir, {
timestamp: '2026-01-02T01:00:00Z',
tests: [
{ name: 't-stable', passed: true, cost: 1.0, turns: 5 },
{ name: 't-flaky', passed: true, cost: 1.0, turns: 5 },
{ name: 't-regressed', passed: false, cost: 1.0, turns: 5 },
],
});
// Freshest timestamp of all — if partials leaked into selection, this
// would be picked as the "after" run (or the baseline) and its sentinel
// branch would show up in the header line.
writeRun(evalDir, {
branch: 'partial-sentinel',
timestamp: '2026-01-03T01:00:00Z',
tests: [{ name: 't-stable', passed: false }],
partial: true,
});
const result = runEvalCli('eval-compare.ts');
expect(result.status).toBe(0);
expect(result.stdout).not.toContain('partial-sentinel');
expect(result.stdout).toContain('1 improved');
expect(result.stdout).toContain('1 regressed');
expect(result.stdout).toContain('1 unchanged');
expect(result.stdout).toContain('REGRESSION: "t-regressed" was passing, now fails.');
expect(result.stdout).toContain('Fixed: "t-flaky" now passes.');
});
test('two explicit filenames resolve relative to the eval dir and compare in the given order', () => {
const before = writeRun(evalDir, {
timestamp: '2026-01-01T01:00:00Z',
tests: [{ name: 't-x', passed: true, cost: 1.0 }],
});
const after = writeRun(evalDir, {
timestamp: '2026-01-02T01:00:00Z',
tests: [{ name: 't-x', passed: false, cost: 3.0 }],
});
const result = runEvalCli('eval-compare.ts', path.basename(before), path.basename(after));
expect(result.status).toBe(0);
expect(result.stdout).toContain('1 regressed');
expect(result.stdout).toContain('REGRESSION: "t-x" was passing, now fails.');
// Cost delta: 1.00 → 3.00 = +$2.00
expect(result.stdout).toContain('+$2.00');
});
test('a missing explicit file fails with exit 1 and names the resolved path', () => {
writeRun(evalDir, { timestamp: '2026-01-01T01:00:00Z', tests: [{ name: 't1', passed: true }] });
writeRun(evalDir, { timestamp: '2026-01-02T01:00:00Z', tests: [{ name: 't1', passed: true }] });
const result = runEvalCli('eval-compare.ts', 'does-not-exist.json', 'also-missing.json');
expect(result.status).toBe(1);
expect(result.stderr).toContain('File not found:');
expect(result.stderr).toContain('does-not-exist.json');
});
});
// ── eval-summary ─────────────────────────────────────────────────────────────
describe('eval:summary CLI (scripts/eval-summary.ts)', () => {
test('empty eval dir prints the getting-started hint and exits 0', () => {
const result = runEvalCli('eval-summary.ts');
expect(result.status).toBe(0);
expect(result.stdout).toContain('No eval runs yet');
});
test('aggregates run counts, spend, and flaky tests across tiers', () => {
writeRun(evalDir, {
tier: 'e2e',
branch: 'branch-one',
timestamp: '2026-01-01T01:00:00Z',
tests: [
{ name: 't-flaky', passed: true, cost: 0.5, turns: 4, duration: 10_000 },
{ name: 't-solid', passed: true, cost: 0.5, turns: 6, duration: 20_000 },
],
});
writeRun(evalDir, {
tier: 'e2e',
branch: 'branch-one',
timestamp: '2026-01-02T01:00:00Z',
tests: [
{ name: 't-flaky', passed: false, cost: 1.0, turns: 8, duration: 30_000 },
{ name: 't-solid', passed: true, cost: 1.0, turns: 6, duration: 20_000 },
],
});
writeRun(evalDir, {
tier: 'llm-judge',
branch: 'branch-two',
timestamp: '2026-01-03T01:00:00Z',
tests: [{ name: 'judge-1', passed: true, cost: 0.5 }],
});
const result = runEvalCli('eval-summary.ts');
expect(result.status).toBe(0);
// 3 runs total: 2 e2e + 1 llm-judge.
expect(result.stdout).toContain('3 (2 e2e, 1 llm-judge)');
// Total spend: (0.5+0.5) + (1.0+1.0) + 0.5 = 3.50
expect(result.stdout).toContain('$3.50');
// t-flaky passed once and failed once → flagged flaky, keyed by tier.
expect(result.stdout).toContain('Flaky tests (1):');
expect(result.stdout).toContain('e2e:t-flaky');
expect(result.stdout).not.toContain('e2e:t-solid');
// Date range spans first → last timestamp.
expect(result.stdout).toContain('2026-01-01 01:00');
expect(result.stdout).toContain('2026-01-03 01:00');
expect(result.stdout).toContain(path.join('projects', SLUG, 'evals'));
});
});
+162
View File
@@ -0,0 +1,162 @@
/**
* scripts/slop-diff.ts — new-findings-only slop report, run on every /review
* and quality gate.
*
* Isolation: every git call in the script inherits the child's cwd (no
* explicit cwd is passed to spawnSync), so pointing the CLI at a tiny fixture
* repo is just `cwd: fixtureRepo`. The `npx slop-scan` dependency is stubbed
* with a PATH-prepended fake so no test ever downloads or runs the real
* scanner — the stub also makes the "scanner missing", "invalid JSON", and
* "real findings" paths deterministic.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { runBin } from './helpers/run-bin';
const ROOT = path.resolve(import.meta.dir, '..');
const SLOP_DIFF = path.join(ROOT, 'scripts', 'slop-diff.ts');
let repo: string;
let stubDir: string;
function git(...args: string[]): void {
const result = runBin('git', args, { cwd: repo });
if (result.status !== 0) {
throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`);
}
}
// POSIX-only on purpose: the npx stub is a shebang script, and Windows
// CreateProcess cannot exec shebangs (a PATH `npx` without .cmd would fall
// through to the REAL npx and try to download slop-scan). The quoted
// '/bin/bash' below is what the Windows-fragile content scanner in
// scripts/test-free-shards.ts keys on to exclude this file from the
// windows-safe subset.
const BASH = '/bin/bash';
/** Install a fake `npx` first on PATH. Body is a bash script fragment. */
function stubNpx(body: string): void {
fs.writeFileSync(path.join(stubDir, 'npx'), `#!${BASH}\n${body}\n`, { mode: 0o755 });
}
function runSlopDiff(...args: string[]) {
return runBin('bun', [SLOP_DIFF, ...args], {
cwd: repo,
env: { PATH: `${stubDir}:${process.env.PATH}` },
// Two scans + a worktree add/remove; generous but bounded.
timeoutMs: 90_000,
});
}
beforeEach(() => {
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'slop-diff-repo-'));
stubDir = fs.mkdtempSync(path.join(os.tmpdir(), 'slop-diff-npx-'));
git('-c', 'init.defaultBranch=main', 'init', '-q');
git('config', 'user.email', 'fixture@example.com');
git('config', 'user.name', 'Fixture');
fs.writeFileSync(path.join(repo, 'README.md'), '# fixture\n');
git('add', 'README.md');
git('commit', '-q', '-m', 'initial');
// A default stub so no test path can ever reach a real npx/network.
stubNpx('exit 1');
});
afterEach(() => {
fs.rmSync(repo, { recursive: true, force: true });
fs.rmSync(stubDir, { recursive: true, force: true });
});
/** Commit a changed file on a feature branch so `main...HEAD` is non-empty. */
function commitFeatureChange(): void {
git('checkout', '-q', '-b', 'feature');
fs.mkdirSync(path.join(repo, 'src'), { recursive: true });
fs.writeFileSync(path.join(repo, 'src', 'app.ts'), 'export const x = 1;\n');
git('add', 'src/app.ts');
git('commit', '-q', '-m', 'feature change');
}
describe('slop:diff CLI (scripts/slop-diff.ts)', () => {
test('no changes vs the base branch: exits 0 without ever invoking the scanner', () => {
// HEAD == main → empty diff → early exit before any npx call. The stub
// exits 1, so if the scanner were invoked the output would differ.
const result = runSlopDiff();
expect(result.status).toBe(0);
expect(result.stdout).toContain('No files changed vs main');
expect(result.stdout).toContain('nothing to check');
});
test('missing slop-scan (npx produces no output): graceful message, exit 0', () => {
commitFeatureChange();
// Default stub: exit 1, no stdout → the script's fallback path.
const result = runSlopDiff();
expect(result.status).toBe(0);
expect(result.stdout).toContain('slop-scan not available');
expect(result.stdout).toContain('npm i -g slop-scan');
});
test('scanner emitting invalid JSON: graceful message, exit 0', () => {
commitFeatureChange();
stubNpx('echo "this is not json"');
const result = runSlopDiff();
expect(result.status).toBe(0);
expect(result.stdout).toContain('slop-scan returned invalid JSON');
});
test('reports only NEW findings in changed files, diffed against the merge-base scan', () => {
commitFeatureChange();
// The stub is invoked twice: `npx slop-scan scan . --json` for HEAD and
// `npx slop-scan scan <tmp-worktree> --json` for the merge-base. Branch on
// the scan target ($3): HEAD gets one finding in the changed file plus one
// in an UNCHANGED file (which must be filtered out); the base gets none.
stubNpx([
'if [ "$3" = "." ]; then',
` echo '{"findings":[`
+ `{"ruleId":"empty-catch","path":"src/app.ts","evidence":["line 3: empty catch, boundary=none"]},`
+ `{"ruleId":"empty-catch","path":"README.md","evidence":["line 1: empty catch, boundary=none"]}`
+ `]}'`,
'else',
' echo \'{"findings":[]}\'',
'fi',
].join('\n'));
const result = runSlopDiff();
expect(result.status).toBe(0);
expect(result.stdout).toContain('1 new findings');
expect(result.stdout).toContain('src/app.ts');
expect(result.stdout).toContain('empty-catch');
expect(result.stdout).toContain('line 3: empty catch, boundary=none');
// README.md was not part of the branch diff — its finding is not "new".
expect(result.stdout).not.toContain('README.md');
expect(result.stdout).toContain('Net: +1 new, -0 removed');
});
test('a finding present at the merge-base is not new, even when line numbers shift', () => {
commitFeatureChange();
// Same (rule, file, evidence-modulo-line-number) on both sides: HEAD says
// line 42, base says line 3 — the line-number-insensitive fingerprint must
// treat them as the same finding.
stubNpx([
'if [ "$3" = "." ]; then',
' echo \'{"findings":[{"ruleId":"empty-catch","path":"src/app.ts","evidence":["line 42: empty catch, boundary=none"]}]}\'',
'else',
// The base scan sees worktree-absolute paths; the script remaps them by
// stripping the worktree prefix, so emit the path under the scan target.
' echo "{\\"findings\\":[{\\"ruleId\\":\\"empty-catch\\",\\"path\\":\\"$3/src/app.ts\\",\\"evidence\\":[\\"line 3: empty catch, boundary=none\\"]}]}"',
'fi',
].join('\n'));
const result = runSlopDiff();
expect(result.status).toBe(0);
expect(result.stdout).toContain('no new findings');
});
test('an explicit base argument overrides main', () => {
// Diff feature...feature is empty even though feature differs from main.
commitFeatureChange();
const result = runSlopDiff('feature');
expect(result.status).toBe(0);
expect(result.stdout).toContain('No files changed vs feature');
});
});
+181
View File
@@ -0,0 +1,181 @@
/**
* Direct unit tests for lib/version-source.ts — the single owner of the
* 4-digit VERSION ↔ 3-digit npm translation and of version-path
* interpretation (raw text vs JSON `.version`).
*
* Before this file, lib/version-source.ts was exercised only INDIRECTLY
* through bin/gstack-version-bump (test/gstack-version-bump.test.ts spawns
* the bin; nothing imported the lib). These tests pin the translation rules
* documented in the module header so a regression is attributed to the lib,
* not to whichever CLI happened to surface it.
*/
import { describe, test, expect } from 'bun:test';
import {
parseVersion,
versionWidth,
fmtVersion,
cmpVersion,
bumpVersion,
bumpWasCoerced,
npmVersion,
isJsonVersionPath,
extractVersion,
setVersionInJson,
type Version,
} from '../lib/version-source';
describe('parseVersion', () => {
test('4-digit versions parse to all four components', () => {
expect(parseVersion('1.67.0.0')).toEqual([1, 67, 0, 0]);
expect(parseVersion('12.3.45.6')).toEqual([12, 3, 45, 6]);
});
test('3-digit versions pad MICRO to 0 so comparison stays uniform', () => {
expect(parseVersion('1.2.3')).toEqual([1, 2, 3, 0]);
});
test('surrounding whitespace is tolerated (file reads carry newlines)', () => {
expect(parseVersion(' 1.2.3.4\n')).toEqual([1, 2, 3, 4]);
});
test('anything else is null, never a guess', () => {
for (const bad of ['1.2', 'v1.2.3', '1.2.3.4.5', '1.2.3-rc1', 'abc', '', '{"name":"frontend"']) {
expect(parseVersion(bad)).toBeNull();
}
});
});
describe('versionWidth + fmtVersion', () => {
test('width reflects how many components the string actually had', () => {
expect(versionWidth('1.2.3.4')).toBe(4);
expect(versionWidth(' 1.2.3.4 ')).toBe(4);
expect(versionWidth('1.2.3')).toBe(3);
});
test('formatting round-trips at each width', () => {
const v: Version = [1, 67, 2, 5];
expect(fmtVersion(v, 4)).toBe('1.67.2.5');
expect(fmtVersion(v, 3)).toBe('1.67.2');
expect(fmtVersion(v)).toBe('1.67.2.5'); // default width 4
});
test('parse → fmt round-trip preserves the original string at its own width', () => {
for (const s of ['1.67.0.0', '2.0.1']) {
expect(fmtVersion(parseVersion(s)!, versionWidth(s))).toBe(s);
}
});
});
describe('cmpVersion', () => {
test('orders component-wise, MICRO included', () => {
const parse = (s: string) => parseVersion(s)!;
expect(cmpVersion(parse('1.2.3.4'), parse('1.2.3.4'))).toBe(0);
expect(cmpVersion(parse('1.2.3.5'), parse('1.2.3.4'))).toBeGreaterThan(0);
expect(cmpVersion(parse('1.2.3.4'), parse('1.3.0.0'))).toBeLessThan(0);
expect(cmpVersion(parse('2.0.0.0'), parse('1.99.99.99'))).toBeGreaterThan(0);
// Padded 3-digit compares equal to its explicit .0 form.
expect(cmpVersion(parse('1.2.3'), parse('1.2.3.0'))).toBe(0);
});
});
describe('bumpVersion + bumpWasCoerced', () => {
const base = parseVersion('1.2.3.4')!;
test('each level zeroes everything below it', () => {
expect(bumpVersion(base, 'major')).toEqual([2, 0, 0, 0]);
expect(bumpVersion(base, 'minor')).toEqual([1, 3, 0, 0]);
expect(bumpVersion(base, 'patch')).toEqual([1, 2, 4, 0]);
expect(bumpVersion(base, 'micro')).toEqual([1, 2, 3, 5]);
});
test('micro in a 3-digit repo is carried out as PATCH — never a silent no-op', () => {
const v = parseVersion('1.2.3')!;
expect(bumpVersion(v, 'micro', 3)).toEqual([1, 2, 4, 0]);
expect(fmtVersion(bumpVersion(v, 'micro', 3), 3)).toBe('1.2.4');
});
test('bumpWasCoerced is true exactly for micro-at-width-3', () => {
expect(bumpWasCoerced('micro', 3)).toBe(true);
expect(bumpWasCoerced('micro', 4)).toBe(false);
expect(bumpWasCoerced('patch', 3)).toBe(false);
expect(bumpWasCoerced('major', 3)).toBe(false);
});
});
describe('npmVersion (4-digit VERSION → 3-digit npm translation)', () => {
test('truncates the MICRO component', () => {
expect(npmVersion('1.67.0.0')).toBe('1.67.0');
expect(npmVersion('1.67.2.5')).toBe('1.67.2');
});
test('3-digit versions pass through unchanged', () => {
expect(npmVersion('1.2.3')).toBe('1.2.3');
});
test('trims before translating', () => {
expect(npmVersion(' 1.2.3.4\n')).toBe('1.2.3');
});
});
describe('isJsonVersionPath', () => {
test('detection is by shape (.json suffix), case-insensitive, trimmed', () => {
expect(isJsonVersionPath('package.json')).toBe(true);
expect(isJsonVersionPath('frontend/package.JSON')).toBe(true);
expect(isJsonVersionPath(' pkg.json ')).toBe(true);
expect(isJsonVersionPath('VERSION')).toBe(false);
expect(isJsonVersionPath('version.txt')).toBe(false);
expect(isJsonVersionPath('jsonfile')).toBe(false);
});
});
describe('extractVersion', () => {
test('non-JSON paths read as text with ALL whitespace stripped', () => {
expect(extractVersion('1.67.0.0\n', 'VERSION')).toBe('1.67.0.0');
expect(extractVersion(' 1.2.3 \r\n', 'VERSION')).toBe('1.2.3');
});
test('JSON paths read the .version field, not the raw bytes (#2501 regression class)', () => {
const pkg = '{\n "name": "frontend",\n "version": "2.0.1"\n}\n';
expect(extractVersion(pkg, 'frontend/package.json')).toBe('2.0.1');
// The old whitespace-strip-as-text behavior would return mangled JSON.
expect(extractVersion(pkg, 'frontend/package.json')).not.toContain('{');
});
test('JSON without a usable version yields "" for the caller\'s own fallback', () => {
expect(extractVersion('{"name":"x"}', 'package.json')).toBe('');
expect(extractVersion('{"version": 42}', 'package.json')).toBe('');
expect(extractVersion('not json at all', 'package.json')).toBe('');
});
test('JSON version values are trimmed', () => {
expect(extractVersion('{"version": " 1.2.3 "}', 'package.json')).toBe('1.2.3');
});
});
describe('setVersionInJson', () => {
test('rewrites only the version, preserving key order, 2-space indent, trailing newline', () => {
const raw = '{"name":"frontend","version":"1.0.0","private":true,"scripts":{"build":"x"}}';
const out = setVersionInJson(raw, '1.1.0');
expect(out).toBe([
'{',
' "name": "frontend",',
' "version": "1.1.0",',
' "private": true,',
' "scripts": {',
' "build": "x"',
' }',
'}',
'',
].join('\n'));
});
test('round-trips with extractVersion', () => {
const out = setVersionInJson('{"name":"x","version":"1.0.0"}', '2.3.4');
expect(extractVersion(out, 'package.json')).toBe('2.3.4');
});
test('adds a version field when the manifest had none', () => {
const out = setVersionInJson('{"name":"x"}', '0.1.0');
expect(JSON.parse(out)).toEqual({ name: 'x', version: '0.1.0' });
});
});