93a60129744a
test: add IV.4 coordinator (HELLO fields, equal-version, clock skew), IV.5 dispatcher, IV.7 static safety assertions; fix test fixtures for new type shapes (v3.6.103)
- time
- 2026-05-22 22:16 UTC · 5 months ago
- machine
- koh2
- parent
- d14b4d0b8ef7
↓ peach-browser-93a60129.tar.gzatlas at this save
files changed7 files
src/background/chirp-sync-coordinator.test.ts+80 −1src/background/chirp-sync-runner.test.ts+5 −5src/background/chirp-sync-static.test.ts+99 −0src/background/sync-trigger.test.ts+47 −0src/background/vault.ts+1 −1src/popup/chirp-pair-capacitor.test.ts+4 −1src/shared/sync-identity.test.ts+2 −2src/background/chirp-sync-coordinator.test.ts+80 −1
@@ -1,4 +1,4 @@1-import { describe, expect, it } from "vitest";1+import { describe, expect, it, vi } from "vitest";22 import { runChirpSyncInitiator, type ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";33 import type { ChirpSyncMessage, SignedDeviceManifest } from "../shared/sync-types";44 import type { ChirpSyncSession } from "../shared/chirp-sync-transport";@@ -78,6 +78,85 @@7878 appliedTombstones,7979 };8080 }81+82+describe("runChirpSyncInitiator — HELLO fields and version optimisation", () => {83+ it("sends HELLO with version and deviceTime", async () => {84+ const [session, peer] = inMemoryPair();85+ const vault = makeVaultAdapter({ localVersion: 7 });86+87+ const initPromise = runChirpSyncInitiator(session, vault);88+89+ const sentHello = await peer.receive();90+ expect(sentHello.type).toBe("SYNC_HELLO");91+ if (sentHello.type === "SYNC_HELLO") {92+ expect(sentHello.version).toBe(7);93+ expect(typeof sentHello.deviceTime).toBe("number");94+ expect(sentHello.deviceTime).toBeGreaterThan(0);95+ }96+ // Send a mismatched vaultId to abort early so the test doesn't hang.97+ await peer.send({ type: "SYNC_HELLO", vaultId: "different-vault", deviceId: "p", version: 7, deviceTime: Date.now() });98+ await peer.receive(); // SYNC_ERROR99+ await initPromise;100+ });101+102+ it("skips manifest exchange and returns completed when versions are equal", async () => {103+ const [session, peer] = inMemoryPair();104+ const vault = makeVaultAdapter({ localVersion: 10 });105+106+ const initPromise = runChirpSyncInitiator(session, vault);107+108+ await peer.receive(); // SYNC_HELLO109+ await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 10, deviceTime: Date.now() });110+111+ // Coordinator should send SYNC_DONE without any manifest exchange.112+ const done = await peer.receive();113+ expect(done.type).toBe("SYNC_DONE");114+115+ // No SYNC_MANIFEST was sent — peer's queue only has the DONE.116+ await peer.send({ type: "SYNC_DONE" });117+118+ const outcome = await initPromise;119+ expect(outcome.status).toBe("completed");120+ expect(outcome.entriesReceived).toBe(0);121+ expect(outcome.entriesSent).toBe(0);122+ });123+124+ it("logs a clock-skew warning but does not abort when clocks differ by > 120s", async () => {125+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);126+ const [session, peer] = inMemoryPair();127+ const vault = makeVaultAdapter({ localVersion: 5 });128+129+ const initPromise = runChirpSyncInitiator(session, vault);130+131+ await peer.receive(); // SYNC_HELLO132+ // Peer reports a deviceTime 200 s in the past — beyond the 120 s threshold.133+ await peer.send({134+ type: "SYNC_HELLO",135+ vaultId: "vault-abc",136+ deviceId: "p",137+ version: 3, // different version so exchange proceeds138+ deviceTime: Date.now() - 200_000,139+ });140+ // Yield so the coordinator's async continuation can run the clock-skew check.141+ await new Promise((r) => setTimeout(r, 0));142+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("clock skew"));143+144+ // Drive the sync to completion so the test doesn't leak.145+ await peer.receive(); // SYNC_MANIFEST146+ await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });147+ await peer.receive(); // SYNC_REQUEST148+ await peer.send({ type: "SYNC_REQUEST", entryIds: [] });149+ await peer.receive(); // SYNC_ENTRIES150+ await peer.receive(); // SYNC_TOMBSTONES151+ await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });152+ await peer.receive(); // SYNC_DONE153+ await peer.send({ type: "SYNC_DONE" });154+155+ const outcome = await initPromise;156+ expect(outcome.status).toBe("completed");157+ warnSpy.mockRestore();158+ });159+});81160 82161 describe("runChirpSyncInitiator", () => {83162 it("completes a full sync with bi-directional updates", async () => {src/background/chirp-sync-runner.test.ts+5 −5
@@ -189,7 +189,7 @@189189 return vault;190190 }191191 192- it("throws VaultLockedError when the vault is locked", async () => {192+ it("returns VAULT_LOCKED result (does not throw) when the vault is locked", async () => {193193 const vault = await bootVault();194194 vault.lock();195195 const { runWithDeps } = await import("./chirp-sync-runner");@@ -199,9 +199,8 @@199199 getSubnet: vi.fn(async () => "192.168.1"),200200 createAdapter: emptyAdapter,201201 };202- await expect(runWithDeps(makeDevice(), deps)).rejects.toMatchObject({203- name: "VaultLockedError",204- });202+ const result = await runWithDeps(makeDevice(), deps);203+ expect(result).toEqual({ ok: false, error: "VAULT_LOCKED" });205204 expect(deps.openSession).not.toHaveBeenCalled();206205 });207206 @@ -238,7 +237,7 @@238237 const runPromise = runWithDeps(makeDevice(), deps);239238 await runPeer(peer);240239 const result = await runPromise;241- expect(result).toEqual({ entriesReceived: 0, entriesSent: 0 });240+ expect(result).toEqual({ ok: true, entriesReceived: 0, entriesSent: 0 });242241 expect(openSession).toHaveBeenCalledTimes(1);243242 expect(openSession.mock.calls[0][0]).toEqual({ ip: "192.168.1.42", port: 8765 });244243 expect(probeSubnet).not.toHaveBeenCalled();@@ -396,6 +395,7 @@396395 deviceName: "Pixel",397396 deviceType: "android",398397 longTermPublicKeyHex: "a".repeat(64),398+ trustSource: "direct",399399 pairedAt: 1700000000000,400400 });401401 trigger.setSyncRunner(async () => ({ ok: true, entriesReceived: 1, entriesSent: 0 }));src/background/chirp-sync-static.test.ts+99 −0
@@ -0,0 +1,99 @@1+// IV.7 — Static safety assertions for Chirp Sync.2+// These tests read source files to enforce structural invariants that3+// are cheaper to verify statically than to exercise at runtime.4+5+import { describe, expect, it } from "vitest";6+import { readFileSync } from "node:fs";7+import { resolve } from "node:path";8+9+const here = __dirname;10+const src = (rel: string) => readFileSync(resolve(here, rel), "utf8");11+12+describe("Chirp Sync — static safety assertions", () => {13+ it("no WebSocket server in any browser-extension sync source file", () => {14+ const files = [15+ "./sync-trigger.ts",16+ "./chirp-sync-runner.ts",17+ "./chirp-sync-coordinator.ts",18+ "./chirp-sync-vault-adapter.ts",19+ "../shared/chirp-sync-transport.ts",20+ ];21+ const forbidden = /WebSocketServer|wss\.listen|createServer|http\.listen|net\.listen/;22+ for (const f of files) {23+ expect(src(f), `${f} must not start a WebSocket server`).not.toMatch(forbidden);24+ }25+ });26+27+ it("applyMergedRemoteEntries does not call fireSyncTrigger or TRIGGER_SYNC", () => {28+ const vaultSrc = src("./vault.ts");29+ const fnStart = vaultSrc.indexOf("applyMergedRemoteEntries");30+ expect(fnStart, "applyMergedRemoteEntries must exist in vault.ts").toBeGreaterThan(-1);31+ // Grab a generous slice starting from the function — large enough to cover32+ // the implementation but not so large that it bleeds into unrelated code.33+ const fnBody = vaultSrc.slice(fnStart, fnStart + 4000);34+ expect(fnBody).not.toMatch(/triggerSyncIfReachable|TRIGGER_SYNC|fireSyncTrigger/);35+ });36+37+ it("addEntryDirect / updateEntryDirect / deleteEntryDirect do not call fireSyncTrigger", () => {38+ const vaultSrc = src("./vault.ts");39+ for (const fn of ["addEntryDirect", "updateEntryDirect", "deleteEntryDirect"]) {40+ const start = vaultSrc.indexOf(`function ${fn}`);41+ if (start === -1) continue;42+ // Slice up to the start of the very next export declaration so we don't43+ // bleed into adjacent function bodies or their JSDoc comments.44+ const nextExport = vaultSrc.indexOf("\nexport ", start + fn.length);45+ const end = nextExport > start ? nextExport : start + 600;46+ const body = vaultSrc.slice(start, end);47+ expect(body, `${fn} must not call fireSyncTrigger`).not.toMatch(48+ /triggerSyncIfReachable|TRIGGER_SYNC|fireSyncTrigger/,49+ );50+ }51+ });52+53+ it("device registry is stored in chrome.storage.local, not chrome.storage.sync", () => {54+ const registrySrc = src("./paired-devices.ts");55+ expect(registrySrc).toContain("storage.local");56+ expect(registrySrc).not.toMatch(57+ /storage\.sync[^;]*peach_paired_devices|peach_paired_devices[^;]*storage\.sync/,58+ );59+ });60+61+ it("setSyncRunner and productionSyncRunner are called in entry.ts", () => {62+ const entrySrc = src("./entry.ts");63+ expect(entrySrc).toContain("setSyncRunner");64+ expect(entrySrc).toContain("productionSyncRunner");65+ });66+67+ it("sync-trigger filters to trustSource:'direct' — vouched devices excluded", () => {68+ const triggerSrc = src("./sync-trigger.ts");69+ expect(triggerSrc).toContain('"direct"');70+ expect(triggerSrc).toMatch(/trustSource.*direct|direct.*trustSource/);71+ });72+73+ it("App.tsx wires startMDNSListener with onCleanup registered before the import resolves", () => {74+ const appSrc = src("../popup/App.tsx");75+ expect(appSrc).toContain("startMDNSListener");76+ expect(appSrc).toContain("onCleanup");77+ // onCleanup must appear BEFORE the .then() call that starts the listener,78+ // i.e. stopMDNS must be captured into the closure before dynamic import.79+ const cleanupIdx = appSrc.indexOf("onCleanup(() => stopMDNS");80+ const thenIdx = appSrc.indexOf("startMDNSListener");81+ expect(cleanupIdx).toBeGreaterThan(-1);82+ expect(thenIdx).toBeGreaterThan(-1);83+ expect(cleanupIdx).toBeLessThan(thenIdx);84+ });85+86+ it("vault_id uses the full 64-char BLAKE3 hex digest", () => {87+ const identitySrc = src("../shared/sync-identity.ts");88+ expect(identitySrc).toContain("VAULT_ID_HEX_LENGTH = 64");89+ expect(identitySrc).not.toContain("VAULT_ID_HEX_LENGTH = 16");90+ });91+92+ it("per-entry key derivation uses entryId as part of HKDF info", () => {93+ const adapterSrc = src("./chirp-sync-vault-adapter.ts");94+ expect(adapterSrc).toContain("ENTRY_KEY_INFO_SUFFIX");95+ expect(adapterSrc).toContain("sync-entry-v1");96+ // The info must be derived from entryId — look for the concat pattern.97+ expect(adapterSrc).toMatch(/entryId.*ENTRY_KEY_INFO_SUFFIX|ENTRY_KEY_INFO_SUFFIX.*entryId/);98+ });99+});src/background/sync-trigger.test.ts+47 −0
@@ -6,6 +6,7 @@66 setSyncRunner,77 _resetSyncTriggerForTest,88 getSyncState,9+ hydratePersistedSyncState,910 } from "./sync-trigger";1011 1112 function makeStorageStub() {@@ -44,6 +45,7 @@4445 deviceName: "Pixel",4546 deviceType: "android",4647 longTermPublicKeyHex: "a".repeat(64),48+ trustSource: "direct",4749 pairedAt: 1700000000000,4850 };4951 @@ -52,6 +54,7 @@5254 deviceName: "iPhone",5355 deviceType: "ios",5456 longTermPublicKeyHex: "b".repeat(64),57+ trustSource: "direct",5558 pairedAt: 1700000000000,5659 };5760 @@ -147,5 +150,49 @@147150 expect(s.entriesReceived).toBe(6);148151 expect(s.entriesSent).toBe(4);149152 expect(typeof s.lastSyncAt).toBe("number");153+ });154+155+ it("syncInFlight is cleared in finally even when runner throws", async () => {156+ await upsertPairedDevice(ANDROID);157+ setSyncRunner(async () => { throw new Error("runner exploded"); });158+ await triggerSyncIfReachable({ reason: "manual", force: true });159+ // After the trigger resolves, a second non-force trigger must be able to run.160+ let ran = false;161+ setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });162+ await triggerSyncIfReachable({ reason: "vault_change" });163+ expect(ran).toBe(true);164+ });165+166+ it("does not call runner when only vouched devices are in registry", async () => {167+ const vouched: PairedDevice = {168+ ...ANDROID,169+ deviceId: "VVV-vouched",170+ trustSource: "vouched",171+ };172+ await upsertPairedDevice(vouched);173+ let ran = false;174+ setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });175+ await triggerSyncIfReachable({ reason: "foreground" });176+ expect(ran).toBe(false);177+ });178+179+ it("persists peach_last_sync_result to storage after a successful sync", async () => {180+ await upsertPairedDevice(ANDROID);181+ setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 1 }));182+ await triggerSyncIfReachable({ reason: "manual" });183+ // Read the raw storage value to confirm persistence.184+ const raw = await (globalThis as unknown as { chrome: { storage: { local: { get(k: string): Promise<Record<string, unknown>> } } } })185+ .chrome.storage.local.get("peach_last_sync_result");186+ expect(raw.peach_last_sync_result).toEqual({ entriesReceived: 3, entriesSent: 1 });187+ });188+189+ it("hydratePersistedSyncState restores lastSyncAt across SW restart simulation", async () => {190+ // Write a value directly to storage, then hydrate — simulates SW restart.191+ await (globalThis as unknown as { chrome: { storage: { local: { set(v: Record<string, unknown>): Promise<void> } } } })192+ .chrome.storage.local.set({ peach_last_sync_at: 1_700_005_000_000 });193+ _resetSyncTriggerForTest();194+ expect(getSyncState().lastSyncAt).toBeUndefined();195+ await hydratePersistedSyncState();196+ expect(getSyncState().lastSyncAt).toBe(1_700_005_000_000);150197 });151198 });src/background/vault.ts+1 −1
@@ -1727,7 +1727,7 @@17271727 * Apply a batch of remote entries + tombstones from a chirp-sync peer.17281728 * Last-write-wins by `modifiedAt`; a tombstone supersedes a live entry when17291729 * `deletedAt > modifiedAt` on the local side. Mutates `entries` directly and1730- * persists with reason `sync_received` — does NOT call `fireSyncTrigger`, so1730+ * persists with reason `sync_received` — does NOT fire the sync dispatcher, so17311731 * applying a sync round cannot cascade into another outbound sync.17321732 *17331733 * `applied` counts entries that actually changed local state (new add,src/popup/chirp-pair-capacitor.test.ts+4 −1
@@ -74,7 +74,10 @@7474 // sendMessage runs inside the handler's `await`; let microtasks flush.7575 await new Promise((r) => setTimeout(r, 0));7676 expect(sentMessages).toEqual([77- { type: "TRIGGER_SYNC", payload: { reason: "mdns" } },77+ {78+ type: "TRIGGER_SYNC",79+ payload: { reason: "mdns", deviceId: "pixel-7", discoveredIp: "192.168.1.42", discoveredPort: 8765 },80+ },7881 ]);7982 });8083 src/shared/sync-identity.test.ts+2 −2
@@ -66,9 +66,9 @@6666 expect(a).not.toBe(b);6767 });6868 69- it("returns 16 hex chars (first 8 bytes of BLAKE3)", async () => {69+ it("returns 64 hex chars (full 32-byte BLAKE3 digest)", async () => {7070 const id = await computeVaultId(MASTER);71- expect(id).toMatch(/^[0-9a-f]{16}$/);71+ expect(id).toMatch(/^[0-9a-f]{64}$/);7272 });7373 });7474