Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions test/fixtures/parser-conformance/allowlist.json
Original file line number Diff line number Diff line change
@@ -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/<parserName>.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.",
Expand All @@ -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.",
Expand Down
133 changes: 133 additions & 0 deletions test/fixtures/parser-conformance/hermes.cjs
Original file line number Diff line number Diff line change
@@ -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 >= <cursor> 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/<name>/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 };
},
};
41 changes: 25 additions & 16 deletions test/init-uninstall.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]));
}
Expand Down Expand Up @@ -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);
}
});

Expand All @@ -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);
}
});

Expand Down Expand Up @@ -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);
}
});

Expand Down Expand Up @@ -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);
}
});

Expand Down Expand Up @@ -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);
}
});

Expand Down Expand Up @@ -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);
}
});

Expand Down Expand Up @@ -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);
}
});

Expand Down Expand Up @@ -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);
}
});

Expand Down Expand Up @@ -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);
}
});

Expand Down Expand Up @@ -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);
}
});

Expand Down Expand Up @@ -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);
}
});

Expand Down Expand Up @@ -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);
}
});

Expand Down Expand Up @@ -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);
}
});

Expand Down Expand Up @@ -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);
}
});

Expand Down Expand Up @@ -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);
}
});

Expand Down Expand Up @@ -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);
}
});

Expand Down