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 }; + }, +}; 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); } });