voyage/tests/hooks/otel-export-token-capture.test.mjs

147 lines
6 KiB
JavaScript

// 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 });
}
});