test(stats): M6 red — trekbrief/trekplan stats records must land with CLAUDE_PLUGIN_DATA empty
Phase 7 of /trekbrief and Phase 12 of /trekplan say "skip silently" when CLAUDE_PLUGIN_DATA is unset, and the Bash tool env never carries it — the same silent skip that left brief-approved at 0 records. Økt 2's countable form (brief-approved + intent_approved: true + a trekplan record with the same slug) cannot be counted until those records land where the yardstick reads. 7 tests, 7 red: no stats-append line in either command. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
66e1fa1937
commit
a358059997
1 changed files with 174 additions and 0 deletions
174
tests/lib/stats-landing.test.mjs
Normal file
174
tests/lib/stats-landing.test.mjs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// tests/lib/stats-landing.test.mjs
|
||||
// Veikart steg 1, økt 2's countable form: after a real /trekbrief → /trekplan run
|
||||
// there must be (1) a `brief-approved` record with the slug, (2) a trekbrief-stats
|
||||
// record with `intent_approved: true`, and (3) a trekplan-stats record with the
|
||||
// same slug — all in the directory scripts/yardstick.mjs reads.
|
||||
//
|
||||
// The Bash tool's env carries no CLAUDE_PLUGIN_DATA. Phase 7 of /trekbrief and
|
||||
// Phase 12 of /trekplan used to say "if ${CLAUDE_PLUGIN_DATA} is not set, skip
|
||||
// silently" — the same silent skip that left brief-approved at 0 records. These
|
||||
// tests execute the command lines the prose spells, with CLAUDE_PLUGIN_DATA
|
||||
// EMPTY, and require the records to land in the yardstick's directory.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
|
||||
const DATA_REL = join('.claude', 'plugins', 'data', 'voyage-ktg-plugin-marketplace');
|
||||
|
||||
/** The text of `## <heading>` up to the next `## ` heading. */
|
||||
function section(rel, heading) {
|
||||
const text = read(rel);
|
||||
const start = text.indexOf(`\n## ${heading}`);
|
||||
assert.ok(start >= 0, `${rel} must have "## ${heading}"`);
|
||||
const next = text.indexOf('\n## ', start + 1);
|
||||
return next < 0 ? text.slice(start) : text.slice(start, next);
|
||||
}
|
||||
|
||||
/** The one line in `sec` that runs stats-append.mjs for `kind`. */
|
||||
function appendLine(sec, kind, rel) {
|
||||
const lines = sec.split('\n').filter((l) =>
|
||||
/^\s*node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/stats\/stats-append\.mjs\b/.test(l) && l.includes(` ${kind}`));
|
||||
assert.equal(lines.length, 1, `${rel} must carry exactly one \`stats-append.mjs ${kind}\` line; found ${lines.length}`);
|
||||
return lines[0].trim().replace(/\s*<<-?'?JSON'?\s*$/, '');
|
||||
}
|
||||
|
||||
/** Run a prose command line with a record on stdin, CLAUDE_PLUGIN_DATA empty, HOME = home. */
|
||||
function runLine(line, record, home) {
|
||||
const cmd = line.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT);
|
||||
return spawnSync('bash', ['-c', cmd], {
|
||||
encoding: 'utf8',
|
||||
input: JSON.stringify(record),
|
||||
env: { ...process.env, CLAUDE_PLUGIN_DATA: '', HOME: home },
|
||||
});
|
||||
}
|
||||
|
||||
function records(file) {
|
||||
return readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
||||
}
|
||||
|
||||
const TB = 'commands/trekbrief.md';
|
||||
const TP = 'commands/trekplan.md';
|
||||
|
||||
test('M6: /trekbrief Phase 7 writes through stats-append (code), not "skip silently" (prose)', () => {
|
||||
const sec = section(TB, 'Phase 7');
|
||||
appendLine(sec, 'trekbrief', TB);
|
||||
assert.doesNotMatch(sec, /skip silently/i, 'a silent skip is how the record never landed');
|
||||
assert.match(sec, /"intent_approved"/);
|
||||
assert.match(sec, /"slug"/);
|
||||
});
|
||||
|
||||
test('M6: /trekplan Phase 12 writes through stats-append (code), not "skip silently" (prose)', () => {
|
||||
const sec = section(TP, 'Phase 12');
|
||||
appendLine(sec, 'trekplan', TP);
|
||||
assert.doesNotMatch(sec, /skip (tracking )?silently/i);
|
||||
assert.match(sec, /"slug"/);
|
||||
});
|
||||
|
||||
test('M6: with CLAUDE_PLUGIN_DATA empty the trekbrief record (intent_approved) lands where the yardstick reads', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'stats-landing-'));
|
||||
try {
|
||||
const line = appendLine(section(TB, 'Phase 7'), 'trekbrief', TB);
|
||||
const r = runLine(line, { ts: '2026-09-23T10:00:00Z', slug: 'dark-mode-toggle', intent_approved: true }, home);
|
||||
assert.equal(r.status, 0, `stats-append failed: ${r.stdout} ${r.stderr}`);
|
||||
const file = join(home, DATA_REL, 'trekbrief-stats.jsonl');
|
||||
assert.ok(existsSync(file), `record must land in ${file}`);
|
||||
const recs = records(file);
|
||||
assert.equal(recs.length, 1);
|
||||
assert.equal(recs[0].intent_approved, true);
|
||||
assert.equal(recs[0].slug, 'dark-mode-toggle');
|
||||
} finally { rmSync(home, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('M6: a failed write is reported, never silent (exit 1 + reason on stdout)', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'stats-landing-'));
|
||||
try {
|
||||
// the data dir path is occupied by a FILE, so the directory cannot be created
|
||||
mkdirSync(join(home, '.claude', 'plugins', 'data'), { recursive: true });
|
||||
writeFileSync(join(home, DATA_REL), 'not a directory');
|
||||
const line = appendLine(section(TB, 'Phase 7'), 'trekbrief', TB);
|
||||
const r = runLine(line, { ts: '2026-09-23T10:00:00Z', slug: 'x', intent_approved: false }, home);
|
||||
assert.equal(r.status, 1);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.written, false);
|
||||
assert.ok(out.reason && out.reason.length > 0, r.stdout);
|
||||
} finally { rmSync(home, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('M6: stats-append refuses a record that is not a JSON object, and an unknown kind', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'stats-landing-'));
|
||||
try {
|
||||
const cli = join(ROOT, 'lib', 'stats', 'stats-append.mjs');
|
||||
const env = { ...process.env, CLAUDE_PLUGIN_DATA: '', HOME: home };
|
||||
const bad = spawnSync('node', [cli, 'trekbrief'], { encoding: 'utf8', input: 'not json', env });
|
||||
assert.equal(bad.status, 1, bad.stdout + bad.stderr);
|
||||
assert.equal(JSON.parse(bad.stdout).written, false);
|
||||
const unknown = spawnSync('node', [cli, 'no-such-command'], { encoding: 'utf8', input: '{}', env });
|
||||
assert.equal(unknown.status, 1);
|
||||
assert.ok(!existsSync(join(home, DATA_REL, 'no-such-command-stats.jsonl')));
|
||||
} finally { rmSync(home, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('M6: økt 2\'s countable form lands end to end with CLAUDE_PLUGIN_DATA empty (stamp → trekbrief → trekplan)', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'stats-landing-'));
|
||||
const proj = mkdtempSync(join(tmpdir(), 'stats-landing-proj-'));
|
||||
try {
|
||||
writeFileSync(join(proj, 'brief.md'), `---
|
||||
type: trekbrief
|
||||
brief_version: "2.1"
|
||||
task: "Add a dark-mode toggle to a recipe app"
|
||||
slug: dark-mode-toggle
|
||||
---
|
||||
|
||||
## Intent
|
||||
|
||||
Readers cook in dim kitchens and the bright page glares.
|
||||
|
||||
## Goal
|
||||
|
||||
A toggle in settings, defaulting to the system theme.
|
||||
`);
|
||||
// (1) the Phase 4h stamp line, as trekbrief.md spells it
|
||||
const stampLine = read(TB).split('\n').find((l) =>
|
||||
/^\s*node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/validators\/intent-approval\.mjs\b.*--stamp/.test(l));
|
||||
assert.ok(stampLine, 'trekbrief.md must carry the --stamp line');
|
||||
let cmd = stampLine.trim().split('${CLAUDE_PLUGIN_ROOT}').join(ROOT);
|
||||
cmd = cmd.split('{BRIEF_PATH}').join(join(proj, 'brief.md')).split('{PROJECT_DIR}').join(proj);
|
||||
const s = spawnSync('bash', ['-c', cmd], { encoding: 'utf8', env: { ...process.env, CLAUDE_PLUGIN_DATA: '', HOME: home } });
|
||||
assert.equal(s.status, 0, s.stdout + s.stderr);
|
||||
// (2) and (3): the stats lines
|
||||
assert.equal(runLine(appendLine(section(TB, 'Phase 7'), 'trekbrief', TB),
|
||||
{ ts: '2026-09-23T10:00:00Z', slug: 'dark-mode-toggle', intent_approved: true }, home).status, 0);
|
||||
assert.equal(runLine(appendLine(section(TP, 'Phase 12'), 'trekplan', TP),
|
||||
{ ts: '2026-09-23T10:05:00Z', slug: 'dark-mode-toggle', outcome: 'save' }, home).status, 0);
|
||||
|
||||
const { loadStats } = await import(join(ROOT, 'scripts', 'yardstick.mjs'));
|
||||
const stats = loadStats(join(home, DATA_REL));
|
||||
const all = stats.files.flatMap((f) => f.records.map((r) => ({ file: f.name, r })));
|
||||
const approved = all.filter(({ r }) => r.event === 'brief-approved' && r.payload?.slug === 'dark-mode-toggle');
|
||||
const brief = all.filter(({ file, r }) => file === 'trekbrief-stats.jsonl' && r.intent_approved === true && r.slug === 'dark-mode-toggle');
|
||||
const plan = all.filter(({ file, r }) => file === 'trekplan-stats.jsonl' && r.slug === 'dark-mode-toggle');
|
||||
assert.equal(approved.length, 1, 'brief-approved with slug');
|
||||
assert.equal(brief.length, 1, 'trekbrief record with intent_approved: true');
|
||||
assert.equal(plan.length, 1, 'trekplan record with the same slug');
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
rmSync(proj, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('M6: stats-append and intent-approval resolve the same data dir as the yardstick', async () => {
|
||||
const { resolveStatsDir } = await import(join(ROOT, 'lib', 'stats', 'stats-append.mjs'));
|
||||
const { resolveApprovalDataDir } = await import(join(ROOT, 'lib', 'validators', 'intent-approval.mjs'));
|
||||
const env = { HOME: '/home/someone', CLAUDE_PLUGIN_DATA: '' };
|
||||
assert.equal(resolveStatsDir(env), join('/home/someone', DATA_REL));
|
||||
assert.equal(resolveApprovalDataDir(env), resolveStatsDir(env));
|
||||
assert.equal(resolveStatsDir({ HOME: '/h', CLAUDE_PLUGIN_DATA: '/x/y' }), '/x/y');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue