// tests/hooks/otel-export-token-capture.test.mjs // SKAL-2 Step 4 — opt-in main-context token/cost capture folded into the Stop // hook (otel-export.mjs), gated by VOYAGE_TOKEN_METER. // // Fail-open contract: any error → exit 0, no token file, Stop never blocked. // Pattern: tests/hooks/otel-export.test.mjs (setupDataDir + runHookWithEnv). import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { runHookWithEnv } from '../helpers/hook-helper.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const HOOK_PATH = join(HERE, '..', '..', 'hooks', 'scripts', 'otel-export.mjs'); const FIXTURE = join(HERE, '..', 'fixtures', 'token-usage', 'sample-transcript.jsonl'); const TOKEN_FILE = 'token-usage-stats.jsonl'; const mkDataDir = () => mkdtempSync(join(tmpdir(), 'voyage-token-capture-')); const stdinFor = (extra = {}) => JSON.stringify({ transcript_path: FIXTURE, session_id: 'sess-1', ...extra }); const readToken = (dir) => readFileSync(join(dir, TOKEN_FILE), 'utf-8').trim().split('\n').filter(Boolean).map(JSON.parse); test('VOYAGE_TOKEN_METER=1 → writes token-usage-stats.jsonl with expected totals + scope:main-context', async () => { const dir = mkDataDir(); try { const r = await runHookWithEnv(HOOK_PATH, stdinFor(), { VOYAGE_TOKEN_METER: '1', VOYAGE_EXPORT_MODE: 'off', CLAUDE_PLUGIN_DATA: dir, }); assert.equal(r.code, 0); assert.equal(existsSync(join(dir, TOKEN_FILE)), true, 'token file must be written'); const recs = readToken(dir); assert.equal(recs.length, 1); const rec = recs[0]; // Fixture totals: dedup-by-requestId + sidechain-exclusion (verified in Step 1). assert.equal(rec.tokens_input, 3000); assert.equal(rec.tokens_output, 500); assert.equal(rec.tokens_cache_creation, 1000); assert.equal(rec.tokens_cache_read, 6000); assert.equal(rec.scope, 'main-context'); assert.equal(rec.model, 'claude-opus-4-8'); assert.ok(Math.abs(rec.cost_usd - 0.03675) < 1e-9, `cost ${rec.cost_usd}`); assert.equal(rec.session_id, 'sess-1'); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('UPSERT — running twice for the same session_id keeps ONE line (not two)', async () => { const dir = mkDataDir(); try { const env = { VOYAGE_TOKEN_METER: '1', VOYAGE_EXPORT_MODE: 'off', CLAUDE_PLUGIN_DATA: dir }; await runHookWithEnv(HOOK_PATH, stdinFor(), env); const r2 = await runHookWithEnv(HOOK_PATH, stdinFor(), env); assert.equal(r2.code, 0); const recs = readToken(dir); assert.equal(recs.length, 1, 'upsert must NOT append a second line for the same session'); assert.equal(recs[0].tokens_input, 3000); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('UPSERT — distinct session_ids append (one line per session)', async () => { const dir = mkDataDir(); try { const env = { VOYAGE_TOKEN_METER: '1', VOYAGE_EXPORT_MODE: 'off', CLAUDE_PLUGIN_DATA: dir }; await runHookWithEnv(HOOK_PATH, stdinFor({ session_id: 'A' }), env); await runHookWithEnv(HOOK_PATH, stdinFor({ session_id: 'B' }), env); const recs = readToken(dir); assert.equal(recs.length, 2); assert.deepEqual(recs.map(r => r.session_id).sort(), ['A', 'B']); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('OPT-IN — VOYAGE_TOKEN_METER unset → NO token file written', async () => { const dir = mkDataDir(); try { const r = await runHookWithEnv(HOOK_PATH, stdinFor(), { VOYAGE_TOKEN_METER: '', // explicit empty mimics unset VOYAGE_EXPORT_MODE: 'off', CLAUDE_PLUGIN_DATA: dir, }); assert.equal(r.code, 0); assert.equal(existsSync(join(dir, TOKEN_FILE)), false, 'no token file without opt-in'); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('FAIL-OPEN — malformed stdin → exit 0, no token file, no throw', async () => { const dir = mkDataDir(); try { const r = await runHookWithEnv(HOOK_PATH, 'not valid json {{{', { VOYAGE_TOKEN_METER: '1', VOYAGE_EXPORT_MODE: 'off', CLAUDE_PLUGIN_DATA: dir, }); assert.equal(r.code, 0); assert.equal(existsSync(join(dir, TOKEN_FILE)), false); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('FAIL-OPEN — missing transcript file → exit 0, no token file', async () => { const dir = mkDataDir(); try { const r = await runHookWithEnv( HOOK_PATH, JSON.stringify({ transcript_path: '/no/such/transcript.jsonl', session_id: 'x' }), { VOYAGE_TOKEN_METER: '1', VOYAGE_EXPORT_MODE: 'off', CLAUDE_PLUGIN_DATA: dir }, ); assert.equal(r.code, 0); assert.equal(existsSync(join(dir, TOKEN_FILE)), false); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('capture coexists with export — VOYAGE_TOKEN_METER=1 + textfile mode writes BOTH', async () => { const dir = mkDataDir(); try { // Seed a stats file so the export path has something to write. writeFileSync(join(dir, 'trekplan-stats.jsonl'), JSON.stringify({ ts: '2026-06-26T08:00:00.000Z', slug: 't', mode: 'default', codebase_files: 10, profile: 'premium' }) + '\n'); const r = await runHookWithEnv(HOOK_PATH, stdinFor(), { VOYAGE_TOKEN_METER: '1', VOYAGE_EXPORT_MODE: 'textfile', CLAUDE_PLUGIN_DATA: dir, }); assert.equal(r.code, 0); assert.equal(existsSync(join(dir, TOKEN_FILE)), true, 'token file written'); assert.equal(existsSync(join(dir, 'voyage.prom')), true, 'export still completed'); // The exported textfile should now also carry the token metric (STATS_FILES wiring). const prom = readFileSync(join(dir, 'voyage.prom'), 'utf-8'); assert.match(prom, /voyage_token_usage_tokens_input/); assert.ok(!prom.includes('sess-1'), 'session_id must not leak into export (CWE-212)'); } finally { rmSync(dir, { recursive: true, force: true }); } });