// tests/hooks/otel-export.test.mjs // SC #14: Stop-hook orchestration — opt-in via VOYAGE_EXPORT_MODE. // Fail-soft contract: any error → exit 0, [voyage] stderr, no Stop blocking. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } 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 __dirname = dirname(fileURLToPath(import.meta.url)); const HOOK_PATH = join(__dirname, '..', '..', 'hooks', 'scripts', 'otel-export.mjs'); function setupDataDir() { const dir = mkdtempSync(join(tmpdir(), 'voyage-otel-data-')); // Seed minimal stats files writeFileSync(join(dir, 'trekplan-stats.jsonl'), JSON.stringify({ ts: '2026-05-09T08:00:00.000Z', slug: 'test', mode: 'default', codebase_files: 100, profile: 'premium' }) + '\n'); return dir; } test('SC #14: VOYAGE_EXPORT_MODE=off → silent exit 0, no file written', async () => { const dataDir = setupDataDir(); try { const target = join(dataDir, 'voyage.prom'); const r = await runHookWithEnv(HOOK_PATH, '{}', { VOYAGE_EXPORT_MODE: 'off', CLAUDE_PLUGIN_DATA: dataDir, }); assert.equal(r.code, 0); assert.equal(existsSync(target), false, 'voyage.prom should NOT be written in off-mode'); assert.equal(r.stderr, '', 'no stderr expected'); } finally { rmSync(dataDir, { recursive: true, force: true }); } }); test('SC #14: VOYAGE_EXPORT_MODE unset → silent exit 0 (default off behavior)', async () => { const dataDir = setupDataDir(); try { const target = join(dataDir, 'voyage.prom'); const r = await runHookWithEnv(HOOK_PATH, '{}', { CLAUDE_PLUGIN_DATA: dataDir, VOYAGE_EXPORT_MODE: '', // explicit empty (mimics unset) }); assert.equal(r.code, 0); assert.equal(existsSync(target), false); } finally { rmSync(dataDir, { recursive: true, force: true }); } }); test('SC #14: VOYAGE_EXPORT_MODE=textfile + valid CLAUDE_PLUGIN_DATA → writes voyage.prom', async () => { const dataDir = setupDataDir(); try { const target = join(dataDir, 'voyage.prom'); const r = await runHookWithEnv(HOOK_PATH, '{}', { VOYAGE_EXPORT_MODE: 'textfile', CLAUDE_PLUGIN_DATA: dataDir, }); assert.equal(r.code, 0); assert.equal(existsSync(target), true, `voyage.prom should be written; stderr: ${r.stderr}`); const text = readFileSync(target, 'utf-8'); assert.match(text, /# HELP /); assert.match(text, /# TYPE /); assert.match(text, /voyage_trekplan_codebase_files/); } finally { rmSync(dataDir, { recursive: true, force: true }); } }); test('SC #14: VOYAGE_EXPORT_MODE=invalid → stderr [voyage] warning + exit 0 (NOT blocking)', async () => { const dataDir = setupDataDir(); try { const r = await runHookWithEnv(HOOK_PATH, '{}', { VOYAGE_EXPORT_MODE: 'banana', CLAUDE_PLUGIN_DATA: dataDir, }); assert.equal(r.code, 0, 'invalid mode MUST NOT block Stop'); assert.match(r.stderr, /\[voyage\]/); assert.match(r.stderr, /banana/); } finally { rmSync(dataDir, { recursive: true, force: true }); } }); test('SC #14: VOYAGE_EXPORT_MODE=otlp + invalid endpoint → stderr [voyage] warn + exit 0', async () => { const dataDir = setupDataDir(); try { const r = await runHookWithEnv(HOOK_PATH, '{}', { VOYAGE_EXPORT_MODE: 'otlp', VOYAGE_OTEL_ENDPOINT: 'http://example.com/v1/metrics', // public-http rejected CLAUDE_PLUGIN_DATA: dataDir, }); assert.equal(r.code, 0); assert.match(r.stderr, /\[voyage\]/); } finally { rmSync(dataDir, { recursive: true, force: true }); } }); test('SC #14: tail-latency for textfile mode < 200ms (NFR)', async () => { const dataDir = setupDataDir(); try { const start = performance.now(); const r = await runHookWithEnv(HOOK_PATH, '{}', { VOYAGE_EXPORT_MODE: 'textfile', CLAUDE_PLUGIN_DATA: dataDir, }); const elapsed = performance.now() - start; assert.equal(r.code, 0); // 200ms NFR with extra headroom for cold-start node spawn (~100ms typical) assert.ok(elapsed < 800, `textfile export tail-latency too slow: ${elapsed.toFixed(0)}ms (NFR <200ms in-process; <800ms allowed for cold spawn)`); } finally { rmSync(dataDir, { recursive: true, force: true }); } }); test('SC #14: missing CLAUDE_PLUGIN_DATA → silent exit 0', async () => { const r = await runHookWithEnv(HOOK_PATH, '{}', { VOYAGE_EXPORT_MODE: 'textfile', CLAUDE_PLUGIN_DATA: '', }); assert.equal(r.code, 0); });