import * as os from "node:os"; /** * Tests for event_logger.ts — ported from test_event_logger.py. * * Every pytest `def test_*` is preserved as a Vitest `it()`. The test * file here also includes a micro-test of the timestamp formatter so * Python-compat parity is enforced independently of the event pipeline. */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import * as fs from "node:fs"; import * as path from "node:path"; import { execFileSync } from "node:child_process"; import { logEvent, getStats, pythonIsoformatUtc } from "../event_logger.js"; import { SCRIPTS_DIR, makeTmpPath, cleanupTmpPath } from "./fixtures.js"; const CLI = path.join(SCRIPTS_DIR, "event_logger.js"); function runCli(args: readonly string[]): { stdout: string; stderr: string; status: number; } { try { const stdout = execFileSync("node", [CLI, ...args], { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], }); return { stdout, stderr: "", status: 0 }; } catch (e) { const err = e as NodeJS.ErrnoException & { status?: number; stdout?: Buffer | string; stderr?: Buffer | string; }; return { stdout: typeof err.stdout === "string" ? err.stdout : (err.stdout?.toString("utf-8") ?? ""), stderr: typeof err.stderr === "string" ? err.stderr : (err.stderr?.toString("utf-8") ?? ""), status: err.status ?? 1, }; } } // ── Fixture helpers ───────────────────────────────────────────────── /** Mirrors pytest's `wiki_root` fixture: a temp dir with log/ pre-created. */ function makeWikiRoot(tmpPath: string): string { fs.mkdirSync(path.join(tmpPath, "log"), { recursive: true }); return tmpPath; } /** Mirrors pytest's `wiki_root_with_events` fixture. */ function makeWikiRootWithEvents(tmpPath: string): string { const wikiRoot = makeWikiRoot(tmpPath); const events = [ { ts: "2026-04-01T10:00:00+00:00", op: "ingest", tokens_in: 1000, tokens_out: 500, cost_usd: 0.05, reduction_ratio: 0.6, }, { ts: "2026-04-05T12:00:00+00:00", op: "query", tokens_in: 200, tokens_out: 800, cost_usd: 0.03, reduction_ratio: 0.4, }, { ts: "2026-04-10T08:00:00+00:00", op: "ingest", tokens_in: 1500, tokens_out: 600, cost_usd: 0.07, reduction_ratio: 0.8, }, { ts: "2026-04-10T14:00:00+00:00", op: "lint", tokens_in: 100, tokens_out: 50, cost_usd: 0.01, reduction_ratio: 0.5, }, ]; const p = path.join(wikiRoot, "log", "events.jsonl"); fs.writeFileSync(p, events.map((e) => JSON.stringify(e)).join("\n") + "\n"); return wikiRoot; } /** `pytest.approx`-style tolerance comparator. */ function approxEqual(a: number, b: number, tol: number = 1e-9): boolean { return Math.abs(a - b) <= tol; } // ── logEvent tests ────────────────────────────────────────────────── describe("TestLogEvent", () => { let tmpPath: string; beforeEach(() => { tmpPath = makeTmpPath("event-log-"); }); afterEach(() => { cleanupTmpPath(tmpPath); }); it("test_log_event_creates_jsonl_entry", () => { const wikiRoot = makeWikiRoot(tmpPath); logEvent(wikiRoot, "ingest", { source: "slides.pptx" }); const logPath = path.join(wikiRoot, "log", "events.jsonl"); expect(fs.existsSync(logPath)).toBe(true); const lines = fs .readFileSync(logPath, { encoding: "utf-8" }) .trim() .split("\n"); expect(lines).toHaveLength(1); const first = lines[0]; expect(first).toBeDefined(); const entry = JSON.parse(first ?? ""); expect(entry.op).toBe("ingest"); expect(entry.source).toBe("slides.pptx"); }); it("test_log_event_includes_timestamp", () => { const wikiRoot = makeWikiRoot(tmpPath); const result = logEvent(wikiRoot, "query", {}); expect("ts" in result).toBe(true); // Must parse into a valid date object (Date.parse accepts the // `YYYY-MM-DDTHH:MM:SS.ffffff+00:00` form the same way Python's // `datetime.fromisoformat` does). const ts = result["ts"]; expect(typeof ts).toBe("string"); const parsed = Date.parse(ts as string); expect(Number.isNaN(parsed)).toBe(false); const year = new Date(parsed).getUTCFullYear(); expect(year).toBeGreaterThanOrEqual(2026); }); it("test_log_event_includes_operation", () => { const wikiRoot = makeWikiRoot(tmpPath); for (const op of ["ingest", "query", "lint", "graph_update"]) { const result = logEvent(wikiRoot, op, {}); expect(result["op"]).toBe(op); } }); it("test_log_event_appends_not_overwrites", () => { const wikiRoot = makeWikiRoot(tmpPath); logEvent(wikiRoot, "ingest", { batch: 1 }); logEvent(wikiRoot, "query", { batch: 2 }); logEvent(wikiRoot, "lint", { batch: 3 }); const logPath = path.join(wikiRoot, "log", "events.jsonl"); const lines = fs .readFileSync(logPath, { encoding: "utf-8" }) .trim() .split("\n"); expect(lines).toHaveLength(3); const ops = lines.map((l) => JSON.parse(l).op); expect(ops).toEqual(["ingest", "query", "lint"]); }); it("test_log_event_with_agent_calls", () => { const wikiRoot = makeWikiRoot(tmpPath); const calls = [ { tool: "Read", duration_ms: 120 }, { tool: "Grep", duration_ms: 45 }, ]; const result = logEvent(wikiRoot, "query", { agent_calls: calls }); expect(result["agent_calls"]).toEqual(calls); expect((result["agent_calls"] as unknown[]).length).toBe(2); }); it("test_log_event_with_token_metrics", () => { const wikiRoot = makeWikiRoot(tmpPath); const details = { tokens_in: 1500, tokens_out: 800, cost_usd: 0.042, }; const result = logEvent(wikiRoot, "ingest", details); expect(result["tokens_in"]).toBe(1500); expect(result["tokens_out"]).toBe(800); expect(approxEqual(result["cost_usd"] as number, 0.042)).toBe(true); }); it("test_missing_log_file_creates_it", () => { // Neither the dir nor the file exist const bareRoot = path.join(tmpPath, "fresh-wiki"); expect(fs.existsSync(bareRoot)).toBe(false); const result = logEvent(bareRoot, "ingest", { note: "first" }); const logPath = path.join(bareRoot, "log", "events.jsonl"); expect(fs.existsSync(logPath)).toBe(true); expect(result["op"]).toBe("ingest"); }); }); // ── getStats tests ────────────────────────────────────────────────── describe("TestGetStats", () => { let tmpPath: string; beforeEach(() => { tmpPath = makeTmpPath("event-stats-"); }); afterEach(() => { cleanupTmpPath(tmpPath); }); it("test_stats_aggregation", () => { const wikiRoot = makeWikiRootWithEvents(tmpPath); const stats = getStats(wikiRoot); const opsByType = stats["ops_by_type"] as Record; expect(opsByType["ingest"]).toBe(2); expect(opsByType["query"]).toBe(1); expect(opsByType["lint"]).toBe(1); expect(stats["total_events"]).toBe(4); expect(approxEqual(stats["total_cost_usd"] as number, 0.16)).toBe(true); // Reduction ratio stats (values: 0.6, 0.4, 0.8, 0.5) const ratio = stats["reduction_ratio"] as Record; expect(approxEqual(ratio["mean"] ?? 0, 0.575)).toBe(true); // p50 of [0.4, 0.5, 0.6, 0.8] = (0.5 + 0.6) / 2 = 0.55 expect(approxEqual(ratio["p50"] ?? 0, 0.55)).toBe(true); // p95 should be close to 0.8 (within 0.05) expect(approxEqual(ratio["p95"] ?? 0, 0.8, 0.05)).toBe(true); }); it("test_stats_with_since_filter", () => { const wikiRoot = makeWikiRootWithEvents(tmpPath); // Only events on or after 2026-04-10 const stats = getStats(wikiRoot, "2026-04-09T00:00:00+00:00"); expect(stats["total_events"]).toBe(2); const opsByType = stats["ops_by_type"] as Record; expect(opsByType["ingest"]).toBe(1); expect(opsByType["lint"]).toBe(1); expect("query" in opsByType).toBe(false); expect(approxEqual(stats["total_cost_usd"] as number, 0.08)).toBe(true); }); // G-EVENTS-TS-STRICT: previously a malformed `ts` caused the filter // branch to be skipped, so the event was kept in the result despite // being un-placeable on the timeline. With --since active we now // fail-closed: NaN-ts events are excluded. it("test_stats_with_since_filter_drops_malformed_ts", () => { const wikiRoot = makeWikiRoot(tmpPath); const events = [ { ts: "2026-04-10T08:00:00+00:00", op: "ingest", tokens_in: 100, tokens_out: 50, cost_usd: 0.01, }, { ts: "not-a-date", op: "query", tokens_in: 99, tokens_out: 99, cost_usd: 99.0, }, ]; const p = path.join(wikiRoot, "log", "events.jsonl"); fs.writeFileSync(p, events.map((e) => JSON.stringify(e)).join("\n") + "\n"); const stats = getStats(wikiRoot, "2026-04-09T00:00:00+00:00"); expect(stats["total_events"]).toBe(1); const opsByType = stats["ops_by_type"] as Record; expect(opsByType["ingest"]).toBe(1); expect("query" in opsByType).toBe(false); }); }); // ── Python-compat timestamp format check ──────────────────────────── // // Not a Python-port test (no equivalent in test_event_logger.py), but // critical enough to the port's correctness that we pin the shape here. // Timestamp parity is one of the two hard behavioural gates in this phase // (the other is SHA256 parity in cache_manager). describe("PythonIsoformatUtc", () => { it("renders 6-digit microseconds with +00:00 suffix", () => { const d = new Date("2026-04-12T10:30:00.123Z"); // JS has ms precision; we expect `.123000` (padded to 6 digits). expect(pythonIsoformatUtc(d)).toBe("2026-04-12T10:30:00.123000+00:00"); }); it("omits microseconds when exactly zero", () => { const d = new Date("2026-04-12T10:30:00.000Z"); // Python's isoformat omits ".000000" when micros are exactly 0. expect(pythonIsoformatUtc(d)).toBe("2026-04-12T10:30:00+00:00"); }); }); // ── CLI contract pins (Python-style JSON shape) ──────────────────── // // These were once Python-reference parity tests. The Python side has been // retired, but the TS CLI still emits Python-style JSON (`0.0` for floats, // `0` for ints) because downstream consumers still parse the output with // Python's `json`. The assertions below pin that contract. describe("EventLoggerCLIShape", () => { let tmpPath: string; beforeEach(() => { tmpPath = makeTmpPath("event-cli-shape-"); }); afterEach(() => { cleanupTmpPath(tmpPath); }); /** Pre-populate the events.jsonl under `tmpPath/log/`. */ function seedEvents(): void { const logDir = path.join(tmpPath, "log"); fs.mkdirSync(logDir, { recursive: true }); const events = [ { ts: "2026-04-01T10:00:00+00:00", op: "ingest", source: "a.md", agent: "file", cost_usd: 0.05, reduction_ratio: 0.6, }, { ts: "2026-04-05T12:00:00+00:00", op: "query", agent: "wiki", cost_usd: 0.03, reduction_ratio: 0.4, }, { ts: "2026-04-10T08:00:00+00:00", op: "ingest", agent: "file", cost_usd: 0.07, reduction_ratio: 0.8, }, { ts: "2026-04-10T14:00:00+00:00", op: "lint", cost_usd: 0.01, reduction_ratio: 0.5, }, ]; fs.writeFileSync( path.join(logDir, "events.jsonl"), events.map((e) => JSON.stringify(e)).join("\n") + "\n", ); } it("cli_stats_empty_emits_zero_float", () => { // No events — stats emits `total_cost_usd: 0`. fs.mkdirSync(path.join(tmpPath, "log"), { recursive: true }); fs.writeFileSync(path.join(tmpPath, "log", "events.jsonl"), ""); const js = runCli(["stats", "--wiki-root", tmpPath]); expect(js.status).toBe(0); expect(js.stdout).toContain('"total_cost_usd": 0'); }); it("cli_stats_populated_emits_non_zero_cost", () => { seedEvents(); const js = runCli(["stats", "--wiki-root", tmpPath]); expect(js.status).toBe(0); const data = JSON.parse(js.stdout); expect(typeof data.total_cost_usd).toBe("number"); expect(data.total_cost_usd).toBeGreaterThan(0); expect(data.total_events).toBeGreaterThanOrEqual(4); }); it("cli_stats_since_iso_filters_older_events", () => { seedEvents(); const js = runCli([ "stats", "--wiki-root", tmpPath, "--since", "2026-04-09T00:00:00+00:00", ]); expect(js.status).toBe(0); const data = JSON.parse(js.stdout); // Only two events fall on or after 2026-04-09. expect(data.total_events).toBe(2); }); it("cli_stats_integer_ratios_emit_python_style_numbers", () => { // Integer inputs trip a subtle int-vs-float rule that the TS CLI // preserves (the Python reference used to assert exact output): // mean([2, 2]) === 2 (int) → emit as `2` // median([2, 2]) === 2.0 (even-len) → emit as `2.0` // median([2, 2, 3]) === 2 (odd-len) → emit as `2` // p95 (nearest-rank) === 2 (element) → emit as `2` // total_cost_usd remains always-float because it is initialized at 0.0. const logDir = path.join(tmpPath, "log"); fs.mkdirSync(logDir, { recursive: true }); const events = [ { ts: "2026-04-01T10:00:00+00:00", op: "ingest", agent: "file", cost_usd: 5, reduction_ratio: 2, }, { ts: "2026-04-05T12:00:00+00:00", op: "ingest", agent: "file", cost_usd: 5, reduction_ratio: 2, }, { ts: "2026-04-10T08:00:00+00:00", op: "ingest", agent: "file", cost_usd: 5, reduction_ratio: 3, }, ]; fs.writeFileSync( path.join(logDir, "events.jsonl"), events.map((e) => JSON.stringify(e)).join("\n") + "\n", ); const js = runCli(["stats", "--wiki-root", tmpPath]); expect(js.status).toBe(0); expect(js.stdout).toMatch(/"total_cost_usd":\s*15\b/); }); }); // ── --source CLI flag ──────────────────────────────────────────────── // // SKILL.md step 12 documents `--source ` as a convenience over // `--details '{"source":"..."}'` for the common ingest case. These tests // pin that contract: the flag is accepted, lands as a top-level `source` // field on the entry, and merges cleanly with `--details`. describe("event_logger --source flag", () => { let tmpPath: string; beforeEach(() => { tmpPath = makeTmpPath("event-source-flag-"); fs.mkdirSync(path.join(tmpPath, "log"), { recursive: true }); }); afterEach(() => { cleanupTmpPath(tmpPath); }); it("accepts --source on `log` and writes it as a top-level field", () => { const js = runCli([ "log", "--op", "ingest", "--wiki-root", tmpPath, "--source", "slides.pptx", ]); expect(js.status).toBe(0); const entry = JSON.parse(js.stdout) as Record; expect(entry["source"]).toBe("slides.pptx"); expect(entry["op"]).toBe("ingest"); }); it("merges --source into --details (explicit flag wins on collision)", () => { const js = runCli([ "log", "--op", "ingest", "--wiki-root", tmpPath, "--details", '{"source":"old.md","extra":"keep"}', "--source", "new.md", ]); expect(js.status).toBe(0); const entry = JSON.parse(js.stdout) as Record; expect(entry["source"]).toBe("new.md"); expect(entry["extra"]).toBe("keep"); }); it("accepts --source on the bare-fallback (no subcommand) shape", () => { const js = runCli([ "--op", "ingest", "--wiki-root", tmpPath, "--source", "report.pdf", ]); expect(js.status).toBe(0); const entry = JSON.parse(js.stdout) as Record; expect(entry["source"]).toBe("report.pdf"); }); }); // ── stats output: total_events ─────────────────────────────────────── // // Pin the renamed top-level count field so a future rename (or mistaken // revert) breaks loudly. describe("event_logger stats — total_events", () => { let tmpPath: string; beforeEach(() => { tmpPath = makeTmpPath("event-total-events-"); fs.mkdirSync(path.join(tmpPath, "log"), { recursive: true }); }); afterEach(() => { cleanupTmpPath(tmpPath); }); it("getStats returns total_events (not total_ops) and counts every entry", () => { const events = [ { ts: "2026-04-01T10:00:00+00:00", op: "ingest", cost_usd: 0.05 }, { ts: "2026-04-05T12:00:00+00:00", op: "query", cost_usd: 0.03 }, { ts: "2026-04-10T08:00:00+00:00", op: "ingest", cost_usd: 0.07 }, ]; fs.writeFileSync( path.join(tmpPath, "log", "events.jsonl"), events.map((e) => JSON.stringify(e)).join("\n") + "\n", ); const stats = getStats(tmpPath); expect(stats["total_events"]).toBe(3); expect("total_ops" in stats).toBe(false); }); it("CLI `stats` emits total_events in stdout JSON", () => { const events = [ { ts: "2026-04-01T10:00:00+00:00", op: "ingest", cost_usd: 0.05 }, { ts: "2026-04-05T12:00:00+00:00", op: "lint", cost_usd: 0.01 }, ]; fs.writeFileSync( path.join(tmpPath, "log", "events.jsonl"), events.map((e) => JSON.stringify(e)).join("\n") + "\n", ); const js = runCli(["stats", "--wiki-root", tmpPath]); expect(js.status).toBe(0); const data = JSON.parse(js.stdout) as Record; expect(data["total_events"]).toBe(2); expect("total_ops" in data).toBe(false); }); }); // ── agent_calls[] (G9) ─────────────────────────────────────────────── describe("agent_calls[] breakdown", () => { let tmpPath: string; beforeEach(() => { tmpPath = makeTmpPath("event-agent-calls-"); }); afterEach(() => { cleanupTmpPath(tmpPath); }); it("computes totals when caller omits them", () => { const entry = logEvent(tmpPath, "ingest", { source: "report.pdf", agent_calls: [ { agent: "jira", model: "haiku", tokens_in: 1200, tokens_out: 340, cost_usd: 0.001, elapsed_ms: 2400, status: "success", }, { agent: "db_agent", model: null, tokens_in: 0, tokens_out: 0, cost_usd: 0.0, elapsed_ms: 180, status: "success", note: "deterministic", }, ], }); expect(entry["total_tokens_in"]).toBe(1200); expect(entry["total_tokens_out"]).toBe(340); expect(entry["total_cost_usd"]).toBeCloseTo(0.001, 5); }); it("does not overwrite caller-provided totals", () => { const entry = logEvent(tmpPath, "ingest", { agent_calls: [ { agent: "jira", model: "haiku", tokens_in: 10, tokens_out: 5, cost_usd: 0.5, elapsed_ms: 100, status: "success", }, ], total_cost_usd: 999, }); expect(entry["total_cost_usd"]).toBe(999); // The summed totals we omitted still get filled in. expect(entry["total_tokens_in"]).toBe(10); expect(entry["total_tokens_out"]).toBe(5); }); it("is a no-op when agent_calls is absent or not an array", () => { const entry1 = logEvent(tmpPath, "fix", { page: "x.md" }); expect(entry1).not.toHaveProperty("total_tokens_in"); cleanupTmpPath(tmpPath); tmpPath = makeTmpPath("event-agent-calls-"); const entry2 = logEvent(tmpPath, "fix", { agent_calls: "not-an-array" }); expect(entry2).not.toHaveProperty("total_tokens_in"); }); it("stats aggregates per-agent cost from agent_calls[]", () => { // One event with two sub-agent dispatches. logEvent(tmpPath, "ingest", { agent_calls: [ { agent: "jira", model: "haiku", tokens_in: 0, tokens_out: 0, cost_usd: 0.25, elapsed_ms: 10, status: "success", }, { agent: "db_agent", model: null, tokens_in: 0, tokens_out: 0, cost_usd: 0.5, elapsed_ms: 10, status: "success", }, ], }); const stats = getStats(tmpPath); const perAgent = stats["per_agent_cost"] as Record; expect(perAgent["jira"]).toBeCloseTo(0.25, 5); expect(perAgent["db_agent"]).toBeCloseTo(0.5, 5); }); // Regression: events that record cost as `total_cost_usd` (atlas op // finalize step, or any event auto-normalized from agent_calls[]) // must contribute to the top-level total_cost_usd. Previously the // aggregator only read `cost_usd`, so atlas's $7.60 silently dropped. it("stats sums total_cost_usd when cost_usd is absent (atlas-style)", () => { fs.mkdirSync(path.join(tmpPath, "log"), { recursive: true }); const events = [ { ts: "2026-05-10T02:01:18+00:00", op: "init" }, { ts: "2026-05-10T02:38:52+00:00", op: "atlas", atlas_run_id: "2026-05-10T02-01-52", total_cost_usd: 7.6, pages_generated: 38, }, ]; fs.writeFileSync( path.join(tmpPath, "log", "events.jsonl"), events.map((e) => JSON.stringify(e)).join("\n") + "\n", ); const stats = getStats(tmpPath); expect(stats["total_events"]).toBe(2); expect(approxEqual(stats["total_cost_usd"] as number, 7.6)).toBe(true); // Sanity: both ops still show in the type breakdown. const opsByType = stats["ops_by_type"] as Record; expect(opsByType["init"]).toBe(1); expect(opsByType["atlas"]).toBe(1); }); // Regression: events that carry agent_calls[] (so the logger writes // total_cost_usd but not cost_usd) must also contribute their // synthesized total to the top-level aggregate, not just to // per_agent_cost. it("stats sums agent_calls-synthesized total_cost_usd at top level", () => { logEvent(tmpPath, "ingest", { agent_calls: [ { agent: "jira", model: "haiku", tokens_in: 0, tokens_out: 0, cost_usd: 0.25, elapsed_ms: 10, status: "success", }, { agent: "db_agent", model: null, tokens_in: 0, tokens_out: 0, cost_usd: 0.5, elapsed_ms: 10, status: "success", }, ], }); const stats = getStats(tmpPath); expect(approxEqual(stats["total_cost_usd"] as number, 0.75)).toBe(true); }); }); // ── A6: includeZeroTokens flag for getStats ──────────────────────── describe("getStats includeZeroTokens (A6)", () => { let tmpPath: string; beforeEach(() => { tmpPath = makeTmpPath("event-zero-tokens-"); }); afterEach(() => { cleanupTmpPath(tmpPath); }); /** Seed a log with two ops: one carrying tokens, one without. */ function seed(): void { fs.mkdirSync(path.join(tmpPath, "log"), { recursive: true }); const events = [ { ts: "2026-04-01T10:00:00+00:00", op: "ingest", tokens_in: 100, tokens_out: 50, }, { ts: "2026-04-01T11:00:00+00:00", op: "init" }, // no token data { ts: "2026-04-01T12:00:00+00:00", op: "lint" }, // no token data ]; fs.writeFileSync( path.join(tmpPath, "log", "events.jsonl"), events.map((e) => JSON.stringify(e)).join("\n") + "\n", ); } it("default behaviour omits zero-token ops from total_tokens_by_op", () => { seed(); const stats = getStats(tmpPath); const tokens = stats["total_tokens_by_op"] as Record; expect(tokens["ingest"]).toBe(150); expect("init" in tokens).toBe(false); expect("lint" in tokens).toBe(false); }); it("includeZeroTokens=true backfills every observed op as 0", () => { seed(); const stats = getStats(tmpPath, null, { includeZeroTokens: true }); const tokens = stats["total_tokens_by_op"] as Record; expect(tokens["ingest"]).toBe(150); expect(tokens["init"]).toBe(0); expect(tokens["lint"]).toBe(0); // Token-bearing op keeps its real total (not overwritten to 0). expect(Object.keys(tokens).sort()).toEqual(["ingest", "init", "lint"]); }); it("CLI: --include-zero-tokens flag emits the same key set as ops_by_type", () => { seed(); const result = runCli([ "stats", "--wiki-root", tmpPath, "--include-zero-tokens", ]); expect(result.status).toBe(0); const data = JSON.parse(result.stdout) as { ops_by_type: Record; total_tokens_by_op: Record; }; expect(Object.keys(data.total_tokens_by_op).sort()).toEqual( Object.keys(data.ops_by_type).sort(), ); expect(data.total_tokens_by_op["init"]).toBe(0); expect(data.total_tokens_by_op["lint"]).toBe(0); }); it("CLI: default (no flag) keeps zero-token ops out of total_tokens_by_op", () => { seed(); const result = runCli(["stats", "--wiki-root", tmpPath]); expect(result.status).toBe(0); const data = JSON.parse(result.stdout) as { total_tokens_by_op: Record; }; expect("init" in data.total_tokens_by_op).toBe(false); expect("lint" in data.total_tokens_by_op).toBe(false); }); }); it("test_stats_skips_malformed_json_line_with_warning", () => { const tmpPath = fs.mkdtempSync( path.join(os.tmpdir(), "wiki-test-stats-malformed-"), ); fs.mkdirSync(path.join(tmpPath, "log"), { recursive: true }); const logPath = path.join(tmpPath, "log", "events.jsonl"); const validLine1 = '{"ts": "2023-01-01T12:00:00+00:00", "op": "ingest"}\n'; const invalidLine = '{"ts": "2023-01-01T12:01:00+00:00", "op": "ingest", "malformed": }\n'; const validLine2 = '{"ts": "2023-01-01T12:02:00+00:00", "op": "ingest"}\n'; fs.writeFileSync(logPath, validLine1 + invalidLine + validLine2); let stderrOutput = ""; const originalStderrWrite = process.stderr.write; // @ts-expect-error - overriding stderr.write for test process.stderr.write = (str: string | Uint8Array) => { stderrOutput += str.toString(); return true; }; try { const stats = getStats(tmpPath, "2023-01-01T00:00:00+00:00"); expect(stats.total_events).toBe(2); expect(stderrOutput).toContain( "[event_logger] warning: skipping malformed JSON line", ); } finally { process.stderr.write = originalStderrWrite; fs.rmSync(tmpPath, { recursive: true, force: true }); } }); // ── archive/unarchive op recognition and stats filtering ──────────── describe("archive/unarchive op support", () => { let tmpPath: string; beforeEach(() => { tmpPath = makeTmpPath("event-archive-"); fs.mkdirSync(path.join(tmpPath, "log"), { recursive: true }); }); afterEach(() => { cleanupTmpPath(tmpPath); }); it("event_logger accepts op: archive without warning", () => { const stderrChunks: string[] = []; const origWrite = process.stderr.write.bind(process.stderr); // @ts-expect-error - overriding stderr.write for test process.stderr.write = (s: string | Uint8Array) => { stderrChunks.push(s.toString()); return true; }; try { const entry = logEvent(tmpPath, "archive", { page: "old-page.md" }); expect(entry["op"]).toBe("archive"); } finally { process.stderr.write = origWrite; } expect(stderrChunks.join("")).toBe(""); }); it("event_logger accepts op: unarchive without warning", () => { const stderrChunks: string[] = []; const origWrite = process.stderr.write.bind(process.stderr); // @ts-expect-error - overriding stderr.write for test process.stderr.write = (s: string | Uint8Array) => { stderrChunks.push(s.toString()); return true; }; try { const entry = logEvent(tmpPath, "unarchive", { page: "old-page.md" }); expect(entry["op"]).toBe("unarchive"); } finally { process.stderr.write = origWrite; } expect(stderrChunks.join("")).toBe(""); }); it("stats excludes archive events from totals by default", () => { // Mix of ingest + archive events with costs and tokens. const events = [ { ts: "2026-04-01T10:00:00+00:00", op: "ingest", tokens_in: 1000, tokens_out: 500, cost_usd: 0.05, }, { ts: "2026-04-02T10:00:00+00:00", op: "archive", tokens_in: 50, tokens_out: 10, cost_usd: 0.001, }, { ts: "2026-04-03T10:00:00+00:00", op: "refresh", tokens_in: 800, tokens_out: 400, cost_usd: 0.04, }, { ts: "2026-04-04T10:00:00+00:00", op: "unarchive", tokens_in: 30, tokens_out: 5, cost_usd: 0.0005, }, ]; fs.writeFileSync( path.join(tmpPath, "log", "events.jsonl"), events.map((e) => JSON.stringify(e)).join("\n") + "\n", ); const stats = getStats(tmpPath); // Only ingest + refresh events counted. expect(stats["total_events"]).toBe(2); const opsByType = stats["ops_by_type"] as Record; expect(opsByType["ingest"]).toBe(1); expect(opsByType["refresh"]).toBe(1); expect("archive" in opsByType).toBe(false); expect("unarchive" in opsByType).toBe(false); // Cost excludes archive/unarchive events (0.05 + 0.04 = 0.09). expect(approxEqual(stats["total_cost_usd"] as number, 0.09)).toBe(true); }); it("stats --include-archived counts archive events", () => { // Mix of refresh + unarchive events. const events = [ { ts: "2026-04-01T10:00:00+00:00", op: "refresh", tokens_in: 800, tokens_out: 400, cost_usd: 0.04, }, { ts: "2026-04-02T10:00:00+00:00", op: "unarchive", tokens_in: 30, tokens_out: 5, cost_usd: 0.0005, }, ]; fs.writeFileSync( path.join(tmpPath, "log", "events.jsonl"), events.map((e) => JSON.stringify(e)).join("\n") + "\n", ); // Default: excludes unarchive. const statsDefault = getStats(tmpPath); expect(statsDefault["total_events"]).toBe(1); // With includeArchived: counts both. const statsAll = getStats(tmpPath, null, { includeArchived: true }); expect(statsAll["total_events"]).toBe(2); const opsByType = statsAll["ops_by_type"] as Record; expect(opsByType["refresh"]).toBe(1); expect(opsByType["unarchive"]).toBe(1); // Total cost includes both (0.04 + 0.0005 = 0.0405). expect( approxEqual(statsAll["total_cost_usd"] as number, 0.0405), ).toBe(true); // CLI: --include-archived flag produces same result. const cliResult = runCli([ "stats", "--wiki-root", tmpPath, "--include-archived", ]); expect(cliResult.status).toBe(0); const cliData = JSON.parse(cliResult.stdout) as Record; expect(cliData["total_events"]).toBe(2); }); }); it("test_stats_with_since_filter_when_ts_contains_json_escape", () => { const tmpPath = fs.mkdtempSync( path.join(os.tmpdir(), "wiki-test-stats-escape-"), ); fs.mkdirSync(path.join(tmpPath, "log"), { recursive: true }); const logPath = path.join(tmpPath, "log", "events.jsonl"); const t1 = '{"ts": "2023-01-01T12:00:00+00:00", "op": "ingest"}\n'; const t2 = '{"ts": "2023-01-01T12:01:00\\u002b00:00", "op": "query"}\n'; const t3 = '{"ts": "2023-01-01T12:02:00+00:00", "op": "ingest"}\n'; fs.writeFileSync(logPath, t1 + t2 + t3); const stats = getStats(tmpPath, "2023-01-01T12:00:30+00:00"); expect(stats.total_events).toBe(2); expect(stats.ops_by_type["query"]).toBe(1); fs.rmSync(tmpPath, { recursive: true, force: true }); });