Skip to content

Commit e68cf43

Browse files
committed
fix(notifications): close Low #17 with checked-in cross-language fixture
The IPC-stub test could not catch real Rust/TS drift because it reconstructed the payload from CATEGORY_NAMES itself. The proper close: a single fixture file that both sides validate against. src/lib/notifications/__fixtures__/categories.fixture.json — the canonical snapshot of `Category::all().iter().map(display_meta())`. Owns every category's id / priority / group / defaultEnabled. The file leads with a `_schema` field documenting the regen procedure. Two-side guard: - Rust: `test_categories_fixture_matches_rust` (in claudepot-core/src/notifications.rs) builds the live shape from the enum, compares it to the on-disk JSON, and fails if either drifts. Setting `CLAUDEPOT_REGEN_FIXTURES=1` regenerates the file for intentional Rust-side changes. - TS: vitest "Rust metadata mirror via fixture" reads the same file and asserts CATEGORY_NAMES has the same set of ids AND every entry's priority round-trips through priorityForCategory. Future Rust commit adds a category without updating TS? Rust test passes (fixture regenerated), TS test fails — drift caught in CI before any user-visible bug. TS commit adds an entry not in the fixture? TS test fails. Lockstep enforced. Tests: cargo test --workspace green (2082 — added one fixture test); pnpm test green (597 — added one mirror test, total tests in types.test.ts now 23).
1 parent d22627c commit e68cf43

3 files changed

Lines changed: 134 additions & 1 deletion

File tree

crates/claudepot-core/src/notifications.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,4 +555,76 @@ mod tests {
555555
let json = serde_json::to_string(&Surface::OsBanner).unwrap();
556556
assert_eq!(json, "\"osBanner\"");
557557
}
558+
559+
/// Audit-fix Low #17 (full close): the checked-in fixture at
560+
/// `src/lib/notifications/__fixtures__/categories.fixture.json`
561+
/// is the canonical cross-language source of truth. Both sides
562+
/// validate against it:
563+
///
564+
/// - Rust: this test asserts Category::all().iter().map(display_meta())
565+
/// serializes to the same shape.
566+
/// - TS: vitest reads the fixture and compares against
567+
/// CATEGORY_NAMES + priorityForCategory.
568+
///
569+
/// Any drift on either side fails the relevant test before it
570+
/// surfaces as a user-visible bug. To intentionally update the
571+
/// fixture after a Rust change: set `CLAUDEPOT_REGEN_FIXTURES=1`
572+
/// and re-run; the test writes the new shape and you commit it.
573+
#[test]
574+
fn test_categories_fixture_matches_rust() {
575+
use std::path::PathBuf;
576+
let fixture_path: PathBuf = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
577+
.join("..")
578+
.join("..")
579+
.join("src")
580+
.join("lib")
581+
.join("notifications")
582+
.join("__fixtures__")
583+
.join("categories.fixture.json");
584+
585+
// Build the canonical shape from the Rust enum.
586+
let live: Vec<serde_json::Value> = Category::all()
587+
.iter()
588+
.map(|c| {
589+
let meta = c.display_meta();
590+
serde_json::json!({
591+
"id": meta.id,
592+
"priority": meta.priority,
593+
"group": meta.group,
594+
"defaultEnabled": meta.default_enabled,
595+
})
596+
})
597+
.collect();
598+
599+
// Regen mode: env var lets contributors rewrite the fixture
600+
// after a deliberate Rust change. Default mode: asserts.
601+
if std::env::var("CLAUDEPOT_REGEN_FIXTURES").is_ok() {
602+
let doc = serde_json::json!({
603+
"_schema": "Canonical snapshot of `Category::all().iter().map(|c| c.display_meta())` from claudepot-core. Both sides validate against this file: cargo test `test_categories_fixture_matches_rust` asserts the Rust source produces this JSON; vitest `Rust metadata mirror via fixture` asserts the TS mirror agrees. Update by running `cargo test --workspace -- categories_fixture` (writes the file when CLAUDEPOT_REGEN_FIXTURES=1) and committing the diff.",
604+
"categories": live,
605+
});
606+
let pretty = serde_json::to_string_pretty(&doc).unwrap();
607+
std::fs::write(&fixture_path, pretty).expect("write fixture");
608+
return;
609+
}
610+
611+
// Read the on-disk fixture.
612+
let bytes = std::fs::read(&fixture_path)
613+
.unwrap_or_else(|e| panic!("read fixture {}: {e}", fixture_path.display()));
614+
let doc: serde_json::Value =
615+
serde_json::from_slice(&bytes).expect("fixture is valid JSON");
616+
let on_disk = doc
617+
.get("categories")
618+
.and_then(|v| v.as_array())
619+
.expect("fixture has `categories` array")
620+
.clone();
621+
622+
assert_eq!(
623+
on_disk, live,
624+
"Category fixture drift detected. Re-generate with \
625+
`CLAUDEPOT_REGEN_FIXTURES=1 cargo test -p claudepot-core categories_fixture` \
626+
and commit the diff. Mirror also requires updating CATEGORY_NAMES in \
627+
src/lib/notifications/types.ts."
628+
);
629+
}
558630
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"_schema": "Canonical snapshot of `Category::all().iter().map(|c| c.display_meta())` from claudepot-core. Both sides validate against this file: cargo test `test_categories_fixture_matches_rust` asserts the Rust source produces this JSON; vitest `Rust metadata mirror via fixture` asserts the TS mirror agrees. Update by running `cargo test --workspace -- categories_fixture` (writes the file when CLAUDEPOT_REGEN_FIXTURES=1) and committing the diff.",
3+
"categories": [
4+
{"id": "accountAuthRejected", "priority": "p0Blocking", "group": "Setup", "defaultEnabled": true},
5+
{"id": "keychainLocked", "priority": "p0Blocking", "group": "Setup", "defaultEnabled": true},
6+
{"id": "ccSlotDrift", "priority": "p0Blocking", "group": "Setup", "defaultEnabled": true},
7+
{"id": "desktopDrift", "priority": "p0Blocking", "group": "Setup", "defaultEnabled": true},
8+
{"id": "repairConflict", "priority": "p0Blocking", "group": "Setup", "defaultEnabled": true},
9+
{"id": "sessionWaiting", "priority": "p1Stalled", "group": "Live work", "defaultEnabled": true},
10+
{"id": "sessionStuck", "priority": "p1Stalled", "group": "Live work", "defaultEnabled": false},
11+
{"id": "sessionErrorBurst", "priority": "p1Stalled", "group": "Live work", "defaultEnabled": false},
12+
{"id": "opDoneUnfocused", "priority": "p1Stalled", "group": "Live work", "defaultEnabled": false},
13+
{"id": "rotationSuggested", "priority": "p1Stalled", "group": "Live work", "defaultEnabled": true},
14+
{"id": "usageThreshold", "priority": "p1Stalled", "group": "Live work", "defaultEnabled": true},
15+
{"id": "updateInstallReady", "priority": "p1Stalled", "group": "Live work", "defaultEnabled": true},
16+
{"id": "accountVerified", "priority": "p2Acknowledge", "group": "Actions", "defaultEnabled": true},
17+
{"id": "accountSwitched", "priority": "p2Acknowledge", "group": "Actions", "defaultEnabled": true},
18+
{"id": "projectRenamed", "priority": "p2Acknowledge", "group": "Actions", "defaultEnabled": true},
19+
{"id": "projectRepaired", "priority": "p2Acknowledge", "group": "Actions", "defaultEnabled": true},
20+
{"id": "sessionPruned", "priority": "p2Acknowledge", "group": "Actions", "defaultEnabled": true},
21+
{"id": "keyCopied", "priority": "p2Acknowledge", "group": "Actions", "defaultEnabled": true},
22+
{"id": "keyAdded", "priority": "p2Acknowledge", "group": "Actions", "defaultEnabled": true},
23+
{"id": "keyRemoved", "priority": "p2Acknowledge", "group": "Actions", "defaultEnabled": true},
24+
{"id": "configEdited", "priority": "p2Acknowledge", "group": "Actions", "defaultEnabled": true},
25+
{"id": "automationRan", "priority": "p2Acknowledge", "group": "Actions", "defaultEnabled": true},
26+
{"id": "rotationApplied", "priority": "p2Acknowledge", "group": "Actions", "defaultEnabled": true},
27+
{"id": "rotationFailed", "priority": "p2Acknowledge", "group": "Actions", "defaultEnabled": true},
28+
{"id": "bannerResolved", "priority": "p2Acknowledge", "group": "Actions", "defaultEnabled": true},
29+
{"id": "memoryChanged", "priority": "p3Ambient", "group": "Background", "defaultEnabled": true},
30+
{"id": "configTreePatched", "priority": "p3Ambient", "group": "Background", "defaultEnabled": true},
31+
{"id": "serviceStatusChanged", "priority": "p3Ambient", "group": "Background", "defaultEnabled": true},
32+
{"id": "updateAvailable", "priority": "p3Ambient", "group": "Background", "defaultEnabled": true}
33+
]
34+
}

src/lib/notifications/types.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,34 @@ describe("category → priority bindings", () => {
192192
// expectation; the Rust side has its own
193193
// `test_priority_exhaustive_for_every_category` and
194194
// `test_all_returns_every_variant` to guard the source-of-truth.
195-
describe("Rust metadata mirror via IPC stub", () => {
195+
describe("Rust metadata mirror via fixture", () => {
196+
it("CATEGORY_NAMES + priorityForCategory agree with the checked-in Rust-side fixture", async () => {
197+
// Audit-fix Low #17 (full close): the fixture file is the
198+
// canonical cross-language source of truth, validated by Rust
199+
// (`test_categories_fixture_matches_rust`) and by us here.
200+
// Drift on either side fails the relevant test.
201+
const fixture = (await import(
202+
"./__fixtures__/categories.fixture.json"
203+
)) as {
204+
categories: ReadonlyArray<{
205+
id: Category;
206+
priority: Priority;
207+
group: string;
208+
defaultEnabled: boolean;
209+
}>;
210+
};
211+
const ids = fixture.categories.map((c) => c.id);
212+
// Set equality: every Rust category appears in the TS mirror,
213+
// and vice versa.
214+
expect(new Set(ids)).toEqual(new Set(CATEGORY_NAMES));
215+
// Per-entry priority alignment: a future Rust commit that
216+
// changes a category's priority without updating TS fails
217+
// here even before the renderer ships a real IPC call.
218+
for (const c of fixture.categories) {
219+
expect(priorityForCategory(c.id)).toBe(c.priority);
220+
}
221+
});
222+
196223
it("the renderer can consume a synthetic IPC payload and round-trips through priorityForCategory", async () => {
197224
type RuntimeMeta = {
198225
id: Category;

0 commit comments

Comments
 (0)