1. A step of only tests can never be proven red. One reading, stepTestsOnly (lib/parsers/plan-schema.mjs, test paths from the new lib/util/test-path.mjs): the plan validator warns PLAN_STEP_TESTS_ONLY, red-proof --plan/--step adds tests_only to a not-red-at-base, and /trekexecute and the step helper send such a step straight up to the plan instead of into fix rounds that cannot succeed. /trekplan, the plan template and plan-critic (dimension 8, still 12) say the test belongs in the step that builds the behaviour. 2. Timing tests under load: the hook helper's kill limit is 30 s and per call overridable; SC #14 passes on the fastest of up to three runs against the same 800 ms limit (tests/helpers/timing.mjs). 3. The example plan's On failure lines open with a K11 word. 4. /trekreview saves the reviewer's whole answer for the findings check and names FINDING_PROSE_LINE_OUT_OF_RANGE (error: re-ask; warning: report). 5. The criteria runner's commands run without NODE_TEST_CONTEXT (commandEnv, moved to lib/util/command-env.mjs). 6. The resolver returns effort only when the brief's phase signal sets one; the four commands said it always did. Also: the K2 hook tests used fixed session ids in a per-user temp dir, so two suites running at once took each other's approval records; the ids are now unique per run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
134 lines
5 KiB
JavaScript
134 lines
5 KiB
JavaScript
// 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';
|
|
import { fastestWithin } from '../helpers/timing.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 {
|
|
// The fastest of up to three cold runs against the same 800 ms limit
|
|
// (tests/helpers/timing.mjs): load only adds time, so a real slowness
|
|
// still fails every run, and one run slowed by a busy machine does not.
|
|
const t = await fastestWithin(800, async () => {
|
|
const start = performance.now();
|
|
const r = await runHookWithEnv(HOOK_PATH, '{}', {
|
|
VOYAGE_EXPORT_MODE: 'textfile',
|
|
CLAUDE_PLUGIN_DATA: dataDir,
|
|
});
|
|
assert.equal(r.code, 0);
|
|
return performance.now() - start;
|
|
});
|
|
// 200ms NFR with extra headroom for cold-start node spawn (~100ms typical)
|
|
assert.ok(t.ok,
|
|
`textfile export tail-latency too slow: fastest ${t.fastest.toFixed(0)}ms of ${t.runs.map((x) => x.toFixed(0)).join('/')}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);
|
|
});
|