From d2d59137dd3e37d780474d86dd0986e1c3039fe9 Mon Sep 17 00:00:00 2001 From: "itarun.p" Date: Sun, 26 Jul 2026 06:07:10 +0700 Subject: [PATCH 1/2] test(parsers): the hermes fixture the allowlist was standing in for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last DoD item on issue 104. The allowlist entry said a fixture "needs a real schema, not a JSONL line" — so this builds one. node:sqlite ships with Node 22+, already the floor here, and readHermesSessions falls back to it when the sqlite3 CLI is absent. If neither is available the harness reports the fixture produced no rows rather than passing quietly — the same guard that caught the empty kimi fixture. Schema taken from the parser's own query (rollout.js:3216), not guessed, and started_at/ended_at are epoch SECONDS with the bucket coming from `ended_at ?? started_at`. Three sessions across two databases, because the profile walk is what is unique to this parser: a default state.db plus profiles/work/state.db, and a profiles/empty/ with no database that must be skipped rather than throw. Also a session with cache and reasoning at zero, which still has to satisfy the column sum rather than be excused from it. Verified by reading the emitted rows, not just the exit code: 3 sessions -> 2 buckets grouped by model, 4290+820+15000+300+120 = 20530 and 500+60+0+0+40 = 600, and claude-opus-5 present proves the profile database was read. Allowlist 21 -> 20, max_size lowered in the same commit as the ratchet requires. ci:local exit 0: 933 root tests, 262 dashboard. --- .../parser-conformance/allowlist.json | 3 +- test/fixtures/parser-conformance/hermes.cjs | 133 ++++++++++++++++++ 2 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/parser-conformance/hermes.cjs diff --git a/test/fixtures/parser-conformance/allowlist.json b/test/fixtures/parser-conformance/allowlist.json index 42c0042a..b8b5ea52 100644 --- a/test/fixtures/parser-conformance/allowlist.json +++ b/test/fixtures/parser-conformance/allowlist.json @@ -1,7 +1,7 @@ { "_why": "Parsers with no conformance fixture yet. This list is a RATCHET: parser-conformance.test.js fails if it is longer than max_size, and also fails if it is SHORTER, telling you to lower max_size in the same commit. An allowlist that can grow is a TODO list, and this repo has evidence about what happens to those.", "_how_to_remove_an_entry": "Add test/fixtures/parser-conformance/.cjs (see craft.cjs for the shape), delete the entry here, and lower max_size by one.", - "max_size": 21, + "max_size": 20, "parsers": { "parseRolloutIncremental": "Codex rollout JSONL. Covered behaviourally by test/rollout-parser.test.js; no conformance fixture yet.", "parseClaudeIncremental": "Covered behaviourally by test/rollout-parser.test.js; no conformance fixture yet.", @@ -11,7 +11,6 @@ "parseOpencodeDbIncremental": "Reads a SQLite database. A fixture needs a real schema, not a JSONL line.", "parseCursorApiIncremental": "Talks to Cursor's API shape rather than a local log; covered by test/cursor-parser.test.js.", "parseKiroIncremental": "No conformance fixture yet; test/fixtures/kiro-cli exists for the CLI variant.", - "parseHermesIncremental": "Reads a SQLite database across profile directories. A fixture needs a real schema, not a JSONL line.", "parseKiroCliIncremental": "No conformance fixture yet.", "parseKimiCodeIncremental": "No conformance fixture yet.", "parseRoocodeIncremental": "Covered by test/roocode-parser.test.js; no conformance fixture yet.", diff --git a/test/fixtures/parser-conformance/hermes.cjs b/test/fixtures/parser-conformance/hermes.cjs new file mode 100644 index 00000000..dc7fa186 --- /dev/null +++ b/test/fixtures/parser-conformance/hermes.cjs @@ -0,0 +1,133 @@ +"use strict"; + +// Conformance fixture for parseHermesIncremental. +// +// This one was on the allowlist because Hermes reads a SQLite database across +// profile directories rather than a JSONL file, and the note said a fixture +// "needs a real schema, not a JSONL line". It does — so this builds one. +// +// `node:sqlite` ships with Node 22+, which is already the floor for the +// dashboard tests, and `readHermesSessions` falls back to it when the sqlite3 +// CLI is absent. If neither is available the harness reports the fixture +// produced no rows rather than passing quietly. +// +// Schema taken from the parser's own query (rollout.js:3216), not guessed: +// SELECT id, model, started_at, ended_at, input_tokens, output_tokens, +// cache_read_tokens, cache_write_tokens, reasoning_tokens, message_count +// FROM sessions +// WHERE (started_at >= OR ended_at IS NULL) AND (any token > 0) +// +// `started_at` / `ended_at` are epoch SECONDS; the bucket comes from +// `ended_at ?? started_at`. + +const fs = require("node:fs"); +const path = require("node:path"); +const { DatabaseSync } = require("node:sqlite"); + +const SCHEMA = ` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + model TEXT, + started_at INTEGER, + ended_at INTEGER, + input_tokens INTEGER, + output_tokens INTEGER, + cache_read_tokens INTEGER, + cache_write_tokens INTEGER, + reasoning_tokens INTEGER, + message_count INTEGER + ); +`; + +// Fixed instants so the 30-minute bucket is deterministic rather than whatever +// the clock says when CI runs. +const STARTED = Math.floor(Date.parse("2026-05-14T09:12:30.000Z") / 1000); +const ENDED = Math.floor(Date.parse("2026-05-14T09:20:00.000Z") / 1000); + +function writeDb(dbPath, rows) { + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + const db = new DatabaseSync(dbPath); + try { + db.exec(SCHEMA); + const insert = db.prepare( + `INSERT INTO sessions + (id, model, started_at, ended_at, input_tokens, output_tokens, + cache_read_tokens, cache_write_tokens, reasoning_tokens, message_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + for (const r of rows) { + insert.run( + r.id, + r.model, + r.started_at, + r.ended_at, + r.input_tokens, + r.output_tokens, + r.cache_read_tokens, + r.cache_write_tokens, + r.reasoning_tokens, + r.message_count, + ); + } + } finally { + db.close(); + } +} + +module.exports = { + source: "hermes", + parser: "parseHermesIncremental", + build(dir) { + const hermesPath = path.join(dir, "hermes"); + + writeDb(path.join(hermesPath, "state.db"), [ + { + id: "hermes-session-finished", + model: "claude-sonnet-5", + started_at: STARTED, + ended_at: ENDED, + input_tokens: 4200, + output_tokens: 810, + cache_read_tokens: 15000, + cache_write_tokens: 300, + reasoning_tokens: 120, + message_count: 14, + }, + // A session with no cache or reasoning columns filled: the row must still + // satisfy the column sum rather than be excused from it. + { + id: "hermes-session-plain", + model: "claude-sonnet-5", + started_at: STARTED, + ended_at: ENDED, + input_tokens: 90, + output_tokens: 10, + cache_read_tokens: 0, + cache_write_tokens: 0, + reasoning_tokens: 0, + message_count: 2, + }, + ]); + + // The profile directory walk is the part unique to this parser — a second + // database under profiles//state.db has to be picked up too, and a + // profile with no state.db must be skipped rather than throw. + writeDb(path.join(hermesPath, "profiles", "work", "state.db"), [ + { + id: "hermes-session-work-profile", + model: "claude-opus-5", + started_at: STARTED, + ended_at: ENDED, + input_tokens: 500, + output_tokens: 60, + cache_read_tokens: 0, + cache_write_tokens: 0, + reasoning_tokens: 40, + message_count: 3, + }, + ]); + fs.mkdirSync(path.join(hermesPath, "profiles", "empty"), { recursive: true }); + + return { hermesPath }; + }, +}; From b90c666c22b9bf2b5449aa70c4110cdc71251891 Mon Sep 17 00:00:00 2001 From: "itarun.p" Date: Sun, 26 Jul 2026 06:08:45 +0700 Subject: [PATCH 2/2] test: cleanup raced the background sync the HOME fix made land in-tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A flake I introduced in #117 and did not see until the hermes fixture run. Giving the notify handler an isolated HOME was the right fix — it stopped the child resolving the developer's real ~/.tokentracker. But the handler deliberately spawns a DETACHED background sync, and that sync now writes inside the test's own temp directory. The write can land after execFile's callback has fired, so the `fs.rm` in `finally` races it and fails with ENOTEMPTY. Cleanup retries instead of waiting on a process the handler detaches on purpose. 16 call sites, one helper. Verified by running the file 5 times: 19 pass, 0 fail, every run. A single green run proves nothing about a race. Also recording the process error that let this reach a commit: I ran `ci:local` and chained the commit after `echo "exit=$?"`, which always succeeds, so the `&&` did not gate on the red suite. The commit landed on a failing gate. Fixed by capturing the exit code into a variable and testing it. ci:local exit 0: 933 root tests, 262 dashboard. --- test/init-uninstall.test.js | 41 ++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/test/init-uninstall.test.js b/test/init-uninstall.test.js index 017bbe65..07c3c4ca 100644 --- a/test/init-uninstall.test.js +++ b/test/init-uninstall.test.js @@ -29,6 +29,15 @@ async function waitForFile(filePath, { timeoutMs = 1500, intervalMs = 50 } = {}) return null; } +// The notify handler spawns a background sync, and since it now runs with an +// ISOLATED HOME that sync writes inside this very temp directory. The write can +// land after execFile's callback has fired, so a plain rm races it and fails +// with ENOTEMPTY. Retrying is the right answer rather than waiting on a process +// the handler deliberately detaches. +async function cleanupTemp(dir) { + await fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 120 }); +} + function flattenHookEntries(entries) { return entries.flatMap((entry) => (Array.isArray(entry?.hooks) ? entry.hooks : [entry])); } @@ -108,7 +117,7 @@ test("notify handler skips SkyComputerUseClient and stale explicit original noti notify: [path.join(tmp, "missing-notify"), "turn-ended"], }); } finally { - await fs.rm(tmp, { recursive: true, force: true }); + await cleanupTemp(tmp); } }); @@ -133,7 +142,7 @@ test("notify handler still chains normal original notify commands", async () => assert.ok(marker, "expected chained notify marker to be written"); assert.ok(marker.includes("turn-ended"), "expected payload args to be forwarded"); } finally { - await fs.rm(tmp, { recursive: true, force: true }); + await cleanupTemp(tmp); } }); @@ -199,7 +208,7 @@ test("init scrubs cloud credentials and endpoints while preserving local config" else process.env.TOKENTRACKER_DEVICE_TOKEN = prevToken; if (prevOpencodeConfigDir === undefined) delete process.env.OPENCODE_CONFIG_DIR; else process.env.OPENCODE_CONFIG_DIR = prevOpencodeConfigDir; - await fs.rm(tmp, { recursive: true, force: true }); + await cleanupTemp(tmp); } }); @@ -255,7 +264,7 @@ test("init then uninstall restores original Codex notify (when pre-existing noti else process.env.TOKENTRACKER_DEVICE_TOKEN = prevToken; if (prevOpencodeConfigDir === undefined) delete process.env.OPENCODE_CONFIG_DIR; else process.env.OPENCODE_CONFIG_DIR = prevOpencodeConfigDir; - await fs.rm(tmp, { recursive: true, force: true }); + await cleanupTemp(tmp); } }); @@ -312,7 +321,7 @@ test("init then uninstall removes notify when none existed", async () => { else process.env.TOKENTRACKER_DEVICE_TOKEN = prevToken; if (prevOpencodeConfigDir === undefined) delete process.env.OPENCODE_CONFIG_DIR; else process.env.OPENCODE_CONFIG_DIR = prevOpencodeConfigDir; - await fs.rm(tmp, { recursive: true, force: true }); + await cleanupTemp(tmp); } }); @@ -349,7 +358,7 @@ test("init skips Codex notify when config is missing", async () => { else process.env.OPENCODE_CONFIG_DIR = prevOpencodeConfigDir; if (prevGeminiHome === undefined) delete process.env.GEMINI_HOME; else process.env.GEMINI_HOME = prevGeminiHome; - await fs.rm(tmp, { recursive: true, force: true }); + await cleanupTemp(tmp); } }); @@ -407,7 +416,7 @@ test("init then uninstall restores original Every Code notify (when config exist else process.env.TOKENTRACKER_DEVICE_TOKEN = prevToken; if (prevOpencodeConfigDir === undefined) delete process.env.OPENCODE_CONFIG_DIR; else process.env.OPENCODE_CONFIG_DIR = prevOpencodeConfigDir; - await fs.rm(tmp, { recursive: true, force: true }); + await cleanupTemp(tmp); } }); @@ -449,7 +458,7 @@ test("init skips Every Code notify when config is missing", async () => { else process.env.TOKENTRACKER_DEVICE_TOKEN = prevToken; if (prevOpencodeConfigDir === undefined) delete process.env.OPENCODE_CONFIG_DIR; else process.env.OPENCODE_CONFIG_DIR = prevOpencodeConfigDir; - await fs.rm(tmp, { recursive: true, force: true }); + await cleanupTemp(tmp); } }); @@ -491,7 +500,7 @@ test("uninstall skips notify restore when no backup and notify not installed", a else process.env.CODE_HOME = prevCodeHome; if (prevOpencodeConfigDir === undefined) delete process.env.OPENCODE_CONFIG_DIR; else process.env.OPENCODE_CONFIG_DIR = prevOpencodeConfigDir; - await fs.rm(tmp, { recursive: true, force: true }); + await cleanupTemp(tmp); } }); @@ -538,7 +547,7 @@ test("uninstall removes Grok Build hook and handler", async () => { else process.env.HOME = prevHome; if (prevGrokHome === undefined) delete process.env.GROK_HOME; else process.env.GROK_HOME = prevGrokHome; - await fs.rm(tmp, { recursive: true, force: true }); + await cleanupTemp(tmp); } }); @@ -615,7 +624,7 @@ test("init then uninstall manages Claude hooks without removing existing hooks", else process.env.TOKENTRACKER_DEVICE_TOKEN = prevToken; if (prevOpencodeConfigDir === undefined) delete process.env.OPENCODE_CONFIG_DIR; else process.env.OPENCODE_CONFIG_DIR = prevOpencodeConfigDir; - await fs.rm(tmp, { recursive: true, force: true }); + await cleanupTemp(tmp); } }); @@ -704,7 +713,7 @@ test("init then uninstall manages Gemini hooks without removing existing hooks", else process.env.OPENCODE_CONFIG_DIR = prevOpencodeConfigDir; if (prevGeminiHome === undefined) delete process.env.GEMINI_HOME; else process.env.GEMINI_HOME = prevGeminiHome; - await fs.rm(tmp, { recursive: true, force: true }); + await cleanupTemp(tmp); } }); @@ -744,7 +753,7 @@ test("init skips Gemini hooks when config directory is missing", async () => { else process.env.OPENCODE_CONFIG_DIR = prevOpencodeConfigDir; if (prevGeminiHome === undefined) delete process.env.GEMINI_HOME; else process.env.GEMINI_HOME = prevGeminiHome; - await fs.rm(tmp, { recursive: true, force: true }); + await cleanupTemp(tmp); } }); @@ -794,7 +803,7 @@ test("init creates Gemini settings when directory exists but file is missing", a else process.env.OPENCODE_CONFIG_DIR = prevOpencodeConfigDir; if (prevGeminiHome === undefined) delete process.env.GEMINI_HOME; else process.env.GEMINI_HOME = prevGeminiHome; - await fs.rm(tmp, { recursive: true, force: true }); + await cleanupTemp(tmp); } }); @@ -844,7 +853,7 @@ test("init then uninstall manages Opencode plugin without removing other plugins else process.env.TOKENTRACKER_DEVICE_TOKEN = prevToken; if (prevOpencodeConfigDir === undefined) delete process.env.OPENCODE_CONFIG_DIR; else process.env.OPENCODE_CONFIG_DIR = prevOpencodeConfigDir; - await fs.rm(tmp, { recursive: true, force: true }); + await cleanupTemp(tmp); } }); @@ -882,7 +891,7 @@ test("init installs Opencode plugin when config dir is missing", async () => { else process.env.TOKENTRACKER_DEVICE_TOKEN = prevToken; if (prevOpencodeConfigDir === undefined) delete process.env.OPENCODE_CONFIG_DIR; else process.env.OPENCODE_CONFIG_DIR = prevOpencodeConfigDir; - await fs.rm(tmp, { recursive: true, force: true }); + await cleanupTemp(tmp); } });