// tests/hooks/otel-export-otlp.test.mjs // SC #13: lib/exporters/otlp-format.mjs returns OTLP/JSON v1.0 metrics payload // with INTEGER (not string) enum constants and timeUnixNano as decimal STRING // (JS precision-loss mitigation per research/01 + risk-assessor CRITICAL 2). import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { transformToOtlpJson, AGG_TEMPORALITY_CUMULATIVE, AGG_TEMPORALITY_DELTA, AGG_TEMPORALITY_UNSPECIFIED, } from '../../lib/exporters/otlp-format.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const FIXTURES = join(__dirname, '..', 'fixtures'); function loadJsonl(name) { const text = readFileSync(join(FIXTURES, name), 'utf-8'); return text.trim().split('\n').filter(Boolean).map(l => JSON.parse(l)); } test('SC #13: aggregationTemporality is INTEGER (typeof === "number"), not string', () => { const records = loadJsonl('stats-sample.jsonl'); const payload = transformToOtlpJson(records); // Find a sum metric (steps_passed is a counter) const metrics = payload.resourceMetrics[0].scopeMetrics[0].metrics; const sumMetric = metrics.find(m => 'sum' in m); assert.ok(sumMetric, `expected at least one sum-metric in payload, got ${metrics.length} metrics`); // CRITICAL: this assertion is the heart of SC #13 — typeof MUST be 'number' assert.equal(typeof sumMetric.sum.aggregationTemporality, 'number', `aggregationTemporality must be INTEGER (typeof number), got ${typeof sumMetric.sum.aggregationTemporality}`); assert.equal(sumMetric.sum.aggregationTemporality, AGG_TEMPORALITY_CUMULATIVE); assert.equal(sumMetric.sum.aggregationTemporality, 2); }); test('SC #13: enum constants exported as integer literals (drift-pin)', () => { assert.equal(typeof AGG_TEMPORALITY_UNSPECIFIED, 'number'); assert.equal(AGG_TEMPORALITY_UNSPECIFIED, 0); assert.equal(typeof AGG_TEMPORALITY_DELTA, 'number'); assert.equal(AGG_TEMPORALITY_DELTA, 1); assert.equal(typeof AGG_TEMPORALITY_CUMULATIVE, 'number'); assert.equal(AGG_TEMPORALITY_CUMULATIVE, 2); }); test('SC #13: timeUnixNano is decimal STRING (typeof === "string"), JS precision-loss mitigation', () => { const records = loadJsonl('stats-sample.jsonl'); const payload = transformToOtlpJson(records); const metrics = payload.resourceMetrics[0].scopeMetrics[0].metrics; // Pick first metric with a data point const m = metrics.find(x => (x.sum?.dataPoints?.length || x.gauge?.dataPoints?.length) > 0); const dp = (m.sum || m.gauge).dataPoints[0]; assert.equal(typeof dp.timeUnixNano, 'string', `timeUnixNano must be decimal STRING, got ${typeof dp.timeUnixNano}: ${dp.timeUnixNano}`); assert.equal(typeof dp.startTimeUnixNano, 'string'); // Should be a valid decimal-digit string assert.match(dp.timeUnixNano, /^\d+$/); }); test('SC #13: structural shape — resourceMetrics[].scopeMetrics[].metrics[] hierarchy', () => { const records = loadJsonl('stats-sample.jsonl'); const payload = transformToOtlpJson(records); assert.ok(Array.isArray(payload.resourceMetrics)); assert.ok(payload.resourceMetrics.length >= 1); assert.ok(payload.resourceMetrics[0].resource); assert.ok(Array.isArray(payload.resourceMetrics[0].scopeMetrics)); assert.ok(payload.resourceMetrics[0].scopeMetrics[0].scope); assert.equal(payload.resourceMetrics[0].scopeMetrics[0].scope.name, 'voyage'); assert.ok(Array.isArray(payload.resourceMetrics[0].scopeMetrics[0].metrics)); }); test('Empty input: returns valid OTLP envelope with empty metrics array', () => { const payload = transformToOtlpJson([]); assert.ok(Array.isArray(payload.resourceMetrics)); assert.equal(payload.resourceMetrics[0].scopeMetrics[0].metrics.length, 0); }); test('isSum heuristic: counter-named metrics get sum + isMonotonic; others get gauge', () => { const records = [ { _schema_id: 'test', ts: '2026-05-09T08:00:00.000Z', steps_total: 10 }, // counter { _schema_id: 'test', ts: '2026-05-09T08:00:00.000Z', cpu_pct: 42.5 }, // gauge ]; const payload = transformToOtlpJson(records); const metrics = payload.resourceMetrics[0].scopeMetrics[0].metrics; const totalMetric = metrics.find(m => m.name.endsWith('steps_total')); const cpuMetric = metrics.find(m => m.name.endsWith('cpu_pct')); assert.ok(totalMetric.sum, 'counter should have sum'); assert.equal(totalMetric.sum.isMonotonic, true); assert.equal(typeof totalMetric.sum.aggregationTemporality, 'number'); assert.ok(cpuMetric.gauge, 'non-counter should have gauge'); assert.ok(!cpuMetric.sum, 'gauge should not have sum'); }); test('Allowlist redacted: callers strip command_excerpt before passing — verify nothing leaks', () => { const record = { _schema_id: 'post_bash_stats', ts: '2026-05-09T08:00:00.000Z', duration_ms: 152, success: true, }; const payload = transformToOtlpJson([record]); const json = JSON.stringify(payload); assert.ok(!json.includes('command_excerpt')); assert.ok(!json.includes('session_id')); // Should contain duration_ms metric assert.match(json, /post_bash_stats\.duration_ms/); });