import { describe, expect, it, beforeEach, vi } from "vitest"; import type { PairedDevice } from "../shared/sync-types"; import { upsertPairedDevice, findPairedDeviceById } from "./paired-devices"; import { triggerSyncIfReachable, setSyncDialTargetSelector, setSyncRunner, _resetSyncTriggerForTest, getSyncState, hydratePersistedSyncState, recordInboundSyncCompletion, } from "./sync-trigger"; function makeStorageStub() { const sync = new Map(); const local = new Map(); function area(store: Map) { return { async get(key?: string | string[] | null) { if (key === null || key === undefined) { const all: Record = {}; for (const [k, v] of store) all[k] = v; return all; } const keys = Array.isArray(key) ? key : [key]; const out: Record = {}; for (const k of keys) if (store.has(k)) out[k] = store.get(k); return out; }, async set(values: Record) { for (const [k, v] of Object.entries(values)) store.set(k, v); }, async remove(key: string | string[]) { const keys = Array.isArray(key) ? key : [key]; for (const k of keys) store.delete(k); }, }; } (globalThis as unknown as { chrome: unknown }).chrome = { storage: { sync: area(sync), local: area(local) }, runtime: { sendMessage: () => Promise.resolve() }, }; } const ANDROID: PairedDevice = { deviceId: "AAA-android", deviceName: "Pixel", deviceType: "android", longTermPublicKeyHex: "a".repeat(64), trustSource: "direct", pairedAt: 1700000000000, }; const IOS: PairedDevice = { deviceId: "BBB-ios", deviceName: "iPhone", deviceType: "ios", longTermPublicKeyHex: "b".repeat(64), trustSource: "direct", pairedAt: 1700000000000, }; const EXT: PairedDevice = { deviceId: "CCC-ext", deviceName: "Other Chrome", deviceType: "extension", longTermPublicKeyHex: "c".repeat(64), pairedAt: 1700000000000, }; describe("triggerSyncIfReachable", () => { beforeEach(() => { makeStorageStub(); _resetSyncTriggerForTest(); }); it("does not call the runner when no mobile hosts are paired", async () => { const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 })); setSyncRunner(runner); await upsertPairedDevice(EXT); await triggerSyncIfReachable({ reason: "manual" }); expect(runner).not.toHaveBeenCalled(); }); it("reports a healthy waiting state when this device owns the listener role", async () => { const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 })); setSyncRunner(runner); setSyncDialTargetSelector(async () => []); await upsertPairedDevice(ANDROID); await triggerSyncIfReachable({ reason: "manual" }); expect(getSyncState()).toMatchObject({ status: "idle", outcome: "waiting_for_peer", detail: "Pixel [type=android, trust=direct]", }); recordInboundSyncCompletion(3, 2); expect(getSyncState()).toMatchObject({ status: "idle", outcome: "completed_changes", entriesReceived: 3, entriesSent: 2, }); expect(runner).not.toHaveBeenCalled(); }); it("calls the runner once per mobile host on a single trigger", async () => { const seen: string[] = []; setSyncRunner(async (device) => { seen.push(device.deviceId); return { ok: true, entriesReceived: 1, entriesSent: 0 }; }); await upsertPairedDevice(ANDROID); await upsertPairedDevice(IOS); await upsertPairedDevice(EXT); await triggerSyncIfReachable({ reason: "manual" }); expect(seen.sort()).toEqual([ANDROID.deviceId, IOS.deviceId]); }); it("marks lastSyncedAt on each host that completes successfully", async () => { setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 })); await upsertPairedDevice(ANDROID); await upsertPairedDevice(IOS); await triggerSyncIfReachable({ reason: "manual" }); const a = await findPairedDeviceById(ANDROID.deviceId); const b = await findPairedDeviceById(IOS.deviceId); expect(typeof a?.lastSyncedAt).toBe("number"); expect(typeof b?.lastSyncedAt).toBe("number"); }); it("does not start a second sync if one is already in flight (non-force)", async () => { const runner = vi.fn(async () => { await new Promise((r) => setTimeout(r, 50)); return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; }); setSyncRunner(runner); await upsertPairedDevice(ANDROID); const a = triggerSyncIfReachable({ reason: "vault_change" }); const b = triggerSyncIfReachable({ reason: "vault_change" }); await Promise.all([a, b]); expect(runner).toHaveBeenCalledTimes(1); }); it("force:true bypasses the syncInFlight guard for manual triggers", async () => { const runner = vi.fn(async () => { await new Promise((r) => setTimeout(r, 30)); return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; }); setSyncRunner(runner); await upsertPairedDevice(ANDROID); const a = triggerSyncIfReachable({ reason: "vault_change" }); const b = triggerSyncIfReachable({ reason: "manual", force: true }); await Promise.all([a, b]); expect(runner).toHaveBeenCalledTimes(2); }); it("transitions to error state when no host succeeds", async () => { setSyncRunner(async () => { throw new Error("unreachable"); }); await upsertPairedDevice(ANDROID); await triggerSyncIfReachable({ reason: "manual" }); expect(getSyncState().status).toBe("error"); expect(getSyncState().error).toBe("unreachable"); }); it("returns to idle state on success with aggregated entry counts", async () => { setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 2 })); await upsertPairedDevice(ANDROID); await upsertPairedDevice(IOS); await triggerSyncIfReachable({ reason: "manual" }); const s = getSyncState(); expect(s.status).toBe("idle"); expect(s.entriesReceived).toBe(6); expect(s.entriesSent).toBe(4); expect(typeof s.lastSyncAt).toBe("number"); }); it("syncInFlight is cleared in finally even when runner throws", async () => { setSyncRunner(async () => { throw new Error("runner exploded"); }); await upsertPairedDevice(ANDROID); await triggerSyncIfReachable({ reason: "manual", force: true }); // After the trigger resolves, a second non-force trigger must be able to run. let ran = false; setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; }); await triggerSyncIfReachable({ reason: "vault_change" }); expect(ran).toBe(true); }); it("syncs a vouched device — mesh introduction is the point of vouching", async () => { // A device only reaches the registry as "vouched" after the user confirms // it out of the MV-7 pending queue (CONFIRM_VOUCHED_DEVICE). Skipping it // here made that confirmation a no-op: the device was listed but never // synced. An UNconfirmed device is still never a target — it is absent // from the registry entirely, which the vault-adapter specs cover. const vouched: PairedDevice = { ...ANDROID, deviceId: "VVV-vouched", trustSource: "vouched", }; const seen: string[] = []; setSyncRunner(async (d) => { seen.push(d.deviceId); return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; }); await upsertPairedDevice(vouched); await triggerSyncIfReachable({ reason: "foreground" }); expect(seen).toEqual(["VVV-vouched"]); }); it("keeps the legacy no-selector test fallback limited to native hosts", async () => { let ran = false; setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; }); await upsertPairedDevice({ ...ANDROID, deviceId: "EXT-other", deviceType: "extension", trustSource: "direct", }); await triggerSyncIfReachable({ reason: "foreground" }); expect(ran).toBe(false); }); it("lets the production role selector choose an extension dial target", async () => { const seen: string[] = []; setSyncRunner(async (device) => { seen.push(device.deviceId); return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; }); setSyncDialTargetSelector(async (devices) => devices.filter((device) => device.deviceType === "extension") ); await upsertPairedDevice({ ...EXT, trustSource: "vouched", }); await triggerSyncIfReachable({ reason: "foreground" }); expect(seen).toEqual(["CCC-ext"]); }); it("persists peach_last_sync_result to storage after a successful sync", async () => { setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 1 })); await upsertPairedDevice(ANDROID); await triggerSyncIfReachable({ reason: "manual" }); // Read the raw storage value to confirm persistence. const raw = await (globalThis as unknown as { chrome: { storage: { local: { get(k: string): Promise> } } } }) .chrome.storage.local.get("peach_last_sync_result"); expect(raw.peach_last_sync_result).toMatchObject({ outcome: "completed_changes", entriesReceived: 3, entriesSent: 1, }); expect(typeof (raw.peach_last_sync_result as { at: unknown }).at).toBe("number"); }); // Every terminating path must be distinguishable. Before this, "nobody to // sync with", "runner not wired" and "synced, nothing to do" were all a bare // `return` — the user and the logs saw the same nothing in all three cases. describe("reported outcomes", () => { it("reports no_paired_devices when the registry is empty", async () => { setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 })); await triggerSyncIfReachable({ reason: "manual" }); const s = getSyncState(); expect(s.outcome).toBe("no_paired_devices"); expect(s.status).toBe("idle"); expect(typeof s.finishedAt).toBe("number"); }); it("reports no_eligible_host naming the deviceType and trustSource found", async () => { // This is the diagnostic that matters on real hardware: a device that // paired fine but whose type/trust puts it outside the host filter. setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 })); await upsertPairedDevice({ ...EXT, deviceName: "Other Chrome" }); await upsertPairedDevice({ ...ANDROID, deviceId: "untrusted-android", deviceName: "Pixel", trustSource: undefined, }); await triggerSyncIfReachable({ reason: "manual" }); const s = getSyncState(); expect(s.outcome).toBe("no_eligible_host"); expect(s.status).toBe("error"); expect(s.detail).toContain("Other Chrome [type=extension, trust=unset]"); expect(s.detail).toContain("Pixel [type=android, trust=unset]"); }); it("reports runner_unavailable when the runner slot is never wired", async () => { setSyncRunner(null); await upsertPairedDevice(ANDROID); await triggerSyncIfReachable({ reason: "manual" }); const s = getSyncState(); expect(s.outcome).toBe("runner_unavailable"); expect(s.status).toBe("error"); }); it("distinguishes completed_no_changes from a failure", async () => { setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 })); await upsertPairedDevice(ANDROID); await triggerSyncIfReachable({ reason: "manual" }); const s = getSyncState(); expect(s.outcome).toBe("completed_no_changes"); expect(s.status).toBe("idle"); expect(s.error).toBeUndefined(); }); it("surfaces a runner throw as its own message rather than swallowing it", async () => { setSyncRunner(async () => { throw new Error("relay_sync_failed:WebSocket connect timed out"); }); await upsertPairedDevice(ANDROID); await triggerSyncIfReachable({ reason: "manual" }); const s = getSyncState(); expect(s.outcome).toBe("relay_unreachable"); expect(s.detail).toBe("relay_sync_failed:WebSocket connect timed out"); // relay_unreachable is now a network-class warning, not a failure — the // detail carries the diagnostic but `error` stays undefined so the // indicator does not turn red for what is a transient miss. expect(s.error).toBeUndefined(); }); it.each([ ["sync_disabled", "sync_disabled"], ["private_relay_unset", "private_relay_unset"], ["vault_id_mismatch", "vault_id_mismatch"], ["PEER_PROTOCOL_OUTDATED", "peer_protocol_outdated"], ["sync_timeout", "sync_timeout"], ["VAULT_LOCKED", "vault_locked"], ["relay_sync_failed:sync_handshake_timeout", "handshake_failed"], ["something nobody predicted", "run_failed"], ])("classifies runner error %s as %s", async (error, outcome) => { setSyncRunner(async () => ({ ok: false, error })); await upsertPairedDevice(ANDROID); await triggerSyncIfReachable({ reason: "manual", force: true }); expect(getSyncState().outcome).toBe(outcome); }); it("keeps the in-flight run's state when a concurrent trigger is dropped", async () => { setSyncRunner(async () => { await new Promise((r) => setTimeout(r, 30)); return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; }); await upsertPairedDevice(ANDROID); const first = triggerSyncIfReachable({ reason: "manual" }); while (getSyncState().status !== "syncing") await Promise.resolve(); await triggerSyncIfReachable({ reason: "vault_change" }); // The dropped trigger must not stamp a terminal outcome over a live run. expect(getSyncState().status).toBe("syncing"); await first; expect(getSyncState().outcome).toBe("completed_no_changes"); }); it("restores the last outcome across a SW restart so a failure stays inspectable", async () => { setSyncRunner(async () => ({ ok: false, error: "vault_id_mismatch" })); await upsertPairedDevice(ANDROID); await triggerSyncIfReachable({ reason: "manual" }); _resetSyncTriggerForTest(); expect(getSyncState().outcome).toBeUndefined(); await hydratePersistedSyncState(); const s = getSyncState(); expect(s.outcome).toBe("vault_id_mismatch"); expect(s.detail).toBe("vault_id_mismatch"); expect(typeof s.finishedAt).toBe("number"); }); }); it("hydratePersistedSyncState restores lastSyncAt across SW restart simulation", async () => { // Write a value directly to storage, then hydrate — simulates SW restart. await (globalThis as unknown as { chrome: { storage: { local: { set(v: Record): Promise } } } }) .chrome.storage.local.set({ peach_last_sync_at: 1_700_005_000_000 }); _resetSyncTriggerForTest(); expect(getSyncState().lastSyncAt).toBeUndefined(); await hydratePersistedSyncState(); expect(getSyncState().lastSyncAt).toBe(1_700_005_000_000); }); }); describe("triggerSyncIfReachable — the §11 sync generation gate", () => { beforeEach(() => { makeStorageStub(); _resetSyncTriggerForTest(); }); function setGeneration(state: string) { return (globalThis as unknown as { chrome: { storage: { local: { set(v: Record): Promise } } } }) .chrome.storage.local.set({ peach_sync_generation_v1: JSON.stringify({ schemaVersion: 1, active: { schemaVersion: 1, generation: 1, state, createdAt: 1 }, }), }); } it("refuses to dial while this device owes a secure reset", async () => { const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 })); setSyncRunner(runner); await upsertPairedDevice(ANDROID); await setGeneration("RESET_REQUIRED"); await triggerSyncIfReachable({ reason: "manual" }); expect(runner).not.toHaveBeenCalled(); expect(getSyncState()).toMatchObject({ status: "error", outcome: "reset_required" }); }); it("refuses to dial when the generation cannot be read", async () => { const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 })); setSyncRunner(runner); await upsertPairedDevice(ANDROID); await (globalThis as unknown as { chrome: { storage: { local: { set(v: Record): Promise } } } }) .chrome.storage.local.set({ peach_sync_generation_v1: "{ not json" }); await triggerSyncIfReachable({ reason: "manual" }); expect(runner).not.toHaveBeenCalled(); expect(getSyncState()).toMatchObject({ outcome: "sync_generation_unknown" }); }); it("still syncs an install that has never reset, and one that already has", async () => { const seen: string[] = []; setSyncRunner(async (device) => { seen.push(device.deviceId); return { ok: true, entriesReceived: 0, entriesSent: 0 }; }); await upsertPairedDevice(ANDROID); // No record yet: the presence of a paired device derives LEGACY_ACTIVE. await triggerSyncIfReachable({ reason: "manual" }); expect(seen).toEqual([ANDROID.deviceId]); await setGeneration("V2_ACTIVE"); seen.length = 0; await triggerSyncIfReachable({ reason: "manual" }); expect(seen).toEqual([ANDROID.deviceId]); }); });