// tests/lib/stats-event-emit.test.mjs // Cover lib/stats/event-emit.mjs: // - emit appends a JSONL line with required ISO-8601 ts // - known_event flag distinguishes recognized vs unknown events // - missing CLAUDE_PLUGIN_DATA falls back to the directory stats-append and // scripts/yardstick.mjs use, and a failed write never throws // - CLI shim parses --payload JSON, writes via emit(), exits 1 on any // written:false, and runs through a symlink or a path with a space // - concurrent appends don't corrupt the file (smoke test) // // Isolation: every test that reaches the fallback pins HOME to a temp dir and // empties CLAUDE_PLUGIN_DATA. `env: {}` is never used - resolveStatsDir would fall // back to os.homedir(), the operator's REAL data directory. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { execFileSync, spawnSync } from 'node:child_process'; import { cpSync, mkdtempSync, mkdirSync, rmSync, readFileSync, existsSync, symlinkSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { emit, buildRecord, resolveStatsPath, KNOWN_EVENTS } from '../../lib/stats/event-emit.mjs'; import { resolveStatsDir } from '../../lib/stats/stats-append.mjs'; import { childEnv } from '../helpers/child-env.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const SHIM = join(HERE, '..', '..', 'lib', 'stats', 'event-emit.mjs'); const ISO_8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; function tmp(prefix = 'stats-event-emit-') { return mkdtempSync(join(tmpdir(), prefix)); } const ROOT = join(HERE, '..', '..'); const DATA_REL = join('.claude', 'plugins', 'data', 'voyage-ktg-plugin-marketplace'); /** process.env with HOME pinned and CLAUDE_PLUGIN_DATA empty: never the real data dir. */ function isolatedEnv(home) { const env = childEnv({ CLAUDE_PLUGIN_DATA: '', HOME: home }); delete env.CLAUDE_COORD_DIR; return env; } function runShim(cli, args, env) { return spawnSync(process.execPath, [cli, ...args], { encoding: 'utf-8', env }); } function records(file) { return readFileSync(file, 'utf-8').trim().split('\n').map((l) => JSON.parse(l)); } test('KNOWN_EVENTS contains plan-v2 spec set', () => { for (const e of ['brief-approved', 'main-merge-gate', 'user_input']) { assert.ok(KNOWN_EVENTS.has(e), `missing recognized event: ${e}`); } }); test('buildRecord emits ISO-8601 ts (REQUIRED per SC4)', () => { const r = buildRecord('brief-approved', { foo: 1 }); assert.match(r.ts, ISO_8601_RE); assert.equal(r.event, 'brief-approved'); assert.equal(r.known_event, true); assert.deepEqual(r.payload, { foo: 1 }); }); test('buildRecord marks unrecognized events known_event: false', () => { const r = buildRecord('totally-made-up-event'); assert.equal(r.known_event, false); assert.deepEqual(r.payload, {}); }); test('buildRecord rejects empty event name', () => { assert.throws(() => buildRecord(''), TypeError); assert.throws(() => buildRecord(null), TypeError); }); test('emit appends one JSONL line per call', () => { const dir = tmp(); try { const path = join(dir, 'stats.jsonl'); const r1 = emit('brief-approved', { ok: true }, { path }); const r2 = emit('main-merge-gate', { branch: 'main' }, { path }); assert.equal(r1.written, true); assert.equal(r2.written, true); const lines = readFileSync(path, 'utf-8').trim().split('\n'); assert.equal(lines.length, 2); const a = JSON.parse(lines[0]); const b = JSON.parse(lines[1]); assert.match(a.ts, ISO_8601_RE); assert.match(b.ts, ISO_8601_RE); assert.equal(a.event, 'brief-approved'); assert.equal(b.event, 'main-merge-gate'); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('emit creates the stats directory on demand', () => { const dir = tmp(); try { const path = join(dir, 'nested', 'stats.jsonl'); const r = emit('user_input', {}, { path }); assert.equal(r.written, true); assert.ok(existsSync(path)); } finally { rmSync(dir, { recursive: true, force: true }); } }); // Inverted contract (was: "returns { written: false } (silent skip)"). The silent // skip is how Phase 8's events never landed: the Bash tool env carries no // CLAUDE_PLUGIN_DATA. test('emit with no CLAUDE_PLUGIN_DATA lands in the fallback dir under HOME', () => { const home = tmp(); try { const r = emit('brief-approved', {}, { env: { HOME: home } }); assert.equal(r.written, true, JSON.stringify(r)); assert.equal(r.path, join(home, DATA_REL, 'trekexecute-stats.jsonl')); assert.equal(records(r.path).length, 1); } finally { rmSync(home, { recursive: true, force: true }); } }); // Inverted contract (was: env {} as the "unwritable" case - after the fallback an // empty env resolves to the real home, so it is no longer unwritable). test('emit never throws when stats path is unwritable', () => { const dir = tmp(); try { const file = join(dir, 'a-file'); writeFileSync(file, 'not a directory'); let r; assert.doesNotThrow(() => { r = emit('user_input', { foo: 'bar' }, { path: join(file, 'x.jsonl') }); }); assert.equal(r.written, false); assert.ok(r.reason && r.reason.length > 0, JSON.stringify(r)); mkdirSync(join(dir, '.claude', 'plugins', 'data'), { recursive: true }); writeFileSync(join(dir, DATA_REL), 'not a directory'); assert.doesNotThrow(() => { r = emit('user_input', {}, { env: { HOME: dir } }); }); assert.equal(r.written, false); assert.ok(r.reason && r.reason.length > 0, JSON.stringify(r)); } finally { rmSync(dir, { recursive: true, force: true }); } }); // Inverted contract (was: resolveStatsPath({}) === null). test('resolveStatsPath honors CLAUDE_PLUGIN_DATA, else the stats-append directory', () => { const r = resolveStatsPath({ CLAUDE_PLUGIN_DATA: '/var/data/plugin' }); assert.equal(r, '/var/data/plugin/trekexecute-stats.jsonl'); assert.equal(resolveStatsPath({ HOME: '/h' }), join(resolveStatsDir({ HOME: '/h' }), 'trekexecute-stats.jsonl')); assert.equal(resolveStatsPath({ HOME: '/h', CLAUDE_PLUGIN_DATA: '' }), join('/h', DATA_REL, 'trekexecute-stats.jsonl')); }); // gates_mode_flag_override stays a known event; no command emits it since fix round 4.5a test('the Phase 8 and override events are known events', () => { for (const e of ['main-merge-approved', 'main-merge-declined', 'gates_mode_flag_override']) { assert.equal(buildRecord(e).known_event, true, `${e} is a known event`); } }); // Regression guard: one resolver - event-emit carries no second copy of the fallback path. test('event-emit has no copy of the fallback directory name', () => { assert.doesNotMatch(readFileSync(SHIM, 'utf-8'), /voyage-ktg-plugin-marketplace/); }); test('CLI shim writes via emit when CLAUDE_PLUGIN_DATA is set', () => { const dir = tmp(); try { execFileSync(process.execPath, [ SHIM, '--event', 'brief-approved', '--payload', '{"foo":42}', ], { env: childEnv({ CLAUDE_PLUGIN_DATA: dir }), encoding: 'utf-8', }); const path = join(dir, 'trekexecute-stats.jsonl'); assert.ok(existsSync(path)); const line = readFileSync(path, 'utf-8').trim(); const parsed = JSON.parse(line); assert.equal(parsed.event, 'brief-approved'); assert.deepEqual(parsed.payload, { foo: 42 }); assert.match(parsed.ts, ISO_8601_RE); } finally { rmSync(dir, { recursive: true, force: true }); } }); // Inverted contract (was: "... payload-not-json (exit 0)"): every written:false exits 1. test('CLI shim with malformed --payload returns reason payload-not-json (exit 1)', () => { const home = tmp(); try { const r = runShim(SHIM, ['--event', 'user_input', '--payload', 'not-json{{'], isolatedEnv(home)); assert.equal(r.status, 1, r.stdout + r.stderr); const parsed = JSON.parse(r.stdout.trim()); assert.equal(parsed.written, false); assert.equal(parsed.reason, 'payload-not-json'); } finally { rmSync(home, { recursive: true, force: true }); } }); // `--payload -` reads the payload from stdin, so the command prose can pass it // through a quoted heredoc: the shell expands nothing, and a plan path with a // quote character can no longer close a single-quoted argument. test('CLI shim with --payload - reads the payload from stdin, quote characters and all', () => { const home = tmp(); try { const plan = ".claude/projects/it's a'$(touch PWNED)'b/plan.md"; const r = spawnSync(process.execPath, [SHIM, '--event', 'main-merge-gate', '--payload', '-'], { encoding: 'utf-8', env: isolatedEnv(home), input: JSON.stringify({ plan, wave_count: 1 }) }); assert.equal(r.status, 0, r.stdout + r.stderr); const [rec] = records(join(home, DATA_REL, 'trekexecute-stats.jsonl')); assert.deepEqual(rec.payload, { plan, wave_count: 1 }); } finally { rmSync(home, { recursive: true, force: true }); } }); test('CLI shim with --payload - and a stdin that is not JSON returns payload-not-json (exit 1)', () => { const home = tmp(); try { const r = spawnSync(process.execPath, [SHIM, '--event', 'main-merge-gate', '--payload', '-'], { encoding: 'utf-8', env: isolatedEnv(home), input: 'not-json{{' }); assert.equal(r.status, 1, r.stdout + r.stderr); assert.equal(JSON.parse(r.stdout.trim()).reason, 'payload-not-json'); assert.ok(!existsSync(join(home, DATA_REL, 'trekexecute-stats.jsonl'))); } finally { rmSync(home, { recursive: true, force: true }); } }); // The emit() docstring said failures are "swallowed silently" after the contract // became {written:false, reason}. test('emit() is not documented as swallowing failures silently', () => { assert.doesNotMatch(readFileSync(SHIM, 'utf-8'), /swallowed silently/); }); test('CLI: with CLAUDE_PLUGIN_DATA empty the event lands under HOME', () => { const home = tmp(); try { const r = runShim(SHIM, ['--event', 'main-merge-gate', '--payload', '{}'], isolatedEnv(home)); assert.equal(r.status, 0, r.stdout + r.stderr); assert.equal(JSON.parse(r.stdout.trim()).written, true, r.stdout); const recs = records(join(home, DATA_REL, 'trekexecute-stats.jsonl')); assert.equal(recs.length, 1); assert.equal(recs[0].event, 'main-merge-gate'); assert.equal(recs[0].known_event, true); assert.deepEqual(recs[0].payload, {}); } finally { rmSync(home, { recursive: true, force: true }); } }); test('CLI: with CLAUDE_PLUGIN_DATA absent the event lands under HOME', () => { const home = tmp(); try { const env = isolatedEnv(home); delete env.CLAUDE_PLUGIN_DATA; const r = runShim(SHIM, ['--event', 'main-merge-gate', '--payload', '{}'], env); assert.equal(r.status, 0, r.stdout + r.stderr); assert.equal(records(join(home, DATA_REL, 'trekexecute-stats.jsonl')).length, 1); } finally { rmSync(home, { recursive: true, force: true }); } }); test('CLI: an unwritable target exits 1 with a reason', () => { const home = tmp(); try { mkdirSync(join(home, '.claude', 'plugins', 'data'), { recursive: true }); writeFileSync(join(home, DATA_REL), 'not a directory'); const r = runShim(SHIM, ['--event', 'main-merge-gate', '--payload', '{}'], isolatedEnv(home)); assert.equal(r.status, 1, r.stdout + r.stderr); const out = JSON.parse(r.stdout.trim()); assert.equal(out.written, false); assert.ok(out.reason && out.reason.length > 0, r.stdout); } finally { rmSync(home, { recursive: true, force: true }); } }); test('CLI: a usage error exits 1 with a reason', () => { const home = tmp(); try { const r = runShim(SHIM, [], isolatedEnv(home)); assert.equal(r.status, 1, r.stdout + r.stderr); const out = JSON.parse(r.stdout.trim()); assert.equal(out.written, false); assert.match(out.reason, /usage/); } finally { rmSync(home, { recursive: true, force: true }); } }); test('CLI: event-emit writes when it is run through a symlink', () => { const home = tmp(); const links = tmp('stats-event-emit-link-'); try { const link = join(links, 'event-emit.mjs'); symlinkSync(SHIM, link); const r = runShim(link, ['--event', 'main-merge-gate', '--payload', '{}'], isolatedEnv(home)); assert.ok(r.stdout.trim().length > 0, `no result line - the CLI guard did not fire; stderr: ${r.stderr}`); assert.equal(r.status, 0, r.stdout + r.stderr); assert.ok(existsSync(join(home, DATA_REL, 'trekexecute-stats.jsonl')), `no record landed; stdout: "${r.stdout}"`); } finally { rmSync(home, { recursive: true, force: true }); rmSync(links, { recursive: true, force: true }); } }); test('CLI: event-emit writes when its path contains a space', () => { const home = tmp(); const spaced = tmp('stats event emit space-'); try { cpSync(join(ROOT, 'lib'), join(spaced, 'with space', 'lib'), { recursive: true }); const cli = join(spaced, 'with space', 'lib', 'stats', 'event-emit.mjs'); const r = runShim(cli, ['--event', 'main-merge-gate', '--payload', '{}'], isolatedEnv(home)); assert.ok(r.stdout.trim().length > 0, `no result line - the CLI guard did not fire; stderr: ${r.stderr}`); assert.equal(r.status, 0, r.stdout + r.stderr); assert.ok(existsSync(join(home, DATA_REL, 'trekexecute-stats.jsonl')), `no record landed; stdout: "${r.stdout}"`); } finally { rmSync(home, { recursive: true, force: true }); rmSync(spaced, { recursive: true, force: true }); } }); test('concurrent appends do not corrupt JSONL (smoke)', async () => { const dir = tmp(); try { const path = join(dir, 'stats.jsonl'); const N = 25; await Promise.all( Array.from({ length: N }, (_, i) => Promise.resolve().then(() => emit('user_input', { i }, { path })), ), ); const lines = readFileSync(path, 'utf-8').trim().split('\n'); assert.equal(lines.length, N); for (const l of lines) { const parsed = JSON.parse(l); // throws if any line is corrupt assert.ok('ts' in parsed); assert.equal(parsed.event, 'user_input'); } } finally { rmSync(dir, { recursive: true, force: true }); } });