1import { describe, expect, it, beforeEach, vi } from "vitest";2import type { PairedDevice } from "../shared/sync-types";3import { upsertPairedDevice, findPairedDeviceById } from "./paired-devices";4import {5 triggerSyncIfReachable,6 setSyncDialTargetSelector,7 setSyncRunner,8 _resetSyncTriggerForTest,9 getSyncState,10 hydratePersistedSyncState,11 recordInboundSyncCompletion,12} from "./sync-trigger";1314function makeStorageStub() {15 const sync = new Map<string, unknown>();16 const local = new Map<string, unknown>();17 function area(store: Map<string, unknown>) {18 return {19 async get(key?: string | string[] | null) {20 if (key === null || key === undefined) {21 const all: Record<string, unknown> = {};22 for (const [k, v] of store) all[k] = v;23 return all;24 }25 const keys = Array.isArray(key) ? key : [key];26 const out: Record<string, unknown> = {};27 for (const k of keys) if (store.has(k)) out[k] = store.get(k);28 return out;29 },30 async set(values: Record<string, unknown>) {31 for (const [k, v] of Object.entries(values)) store.set(k, v);32 },33 async remove(key: string | string[]) {34 const keys = Array.isArray(key) ? key : [key];35 for (const k of keys) store.delete(k);36 },37 };38 }39 (globalThis as unknown as { chrome: unknown }).chrome = {40 storage: { sync: area(sync), local: area(local) },41 runtime: { sendMessage: () => Promise.resolve() },42 };43}4445const ANDROID: PairedDevice = {46 deviceId: "AAA-android",47 deviceName: "Pixel",48 deviceType: "android",49 longTermPublicKeyHex: "a".repeat(64),50 trustSource: "direct",51 pairedAt: 1700000000000,52};5354const IOS: PairedDevice = {55 deviceId: "BBB-ios",56 deviceName: "iPhone",57 deviceType: "ios",58 longTermPublicKeyHex: "b".repeat(64),59 trustSource: "direct",60 pairedAt: 1700000000000,61};6263const EXT: PairedDevice = {64 deviceId: "CCC-ext",65 deviceName: "Other Chrome",66 deviceType: "extension",67 longTermPublicKeyHex: "c".repeat(64),68 pairedAt: 1700000000000,69};7071describe("triggerSyncIfReachable", () => {72 beforeEach(() => {73 makeStorageStub();74 _resetSyncTriggerForTest();75 });7677 it("does not call the runner when no mobile hosts are paired", async () => {78 const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 }));79 setSyncRunner(runner);80 await upsertPairedDevice(EXT);81 await triggerSyncIfReachable({ reason: "manual" });82 expect(runner).not.toHaveBeenCalled();83 });8485 it("reports a healthy waiting state when this device owns the listener role", async () => {86 const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 }));87 setSyncRunner(runner);88 setSyncDialTargetSelector(async () => []);89 await upsertPairedDevice(ANDROID);9091 await triggerSyncIfReachable({ reason: "manual" });9293 expect(getSyncState()).toMatchObject({94 status: "idle",95 outcome: "waiting_for_peer",96 detail: "Pixel [type=android, trust=direct]",97 });98 recordInboundSyncCompletion(3, 2);99 expect(getSyncState()).toMatchObject({100 status: "idle",101 outcome: "completed_changes",102 entriesReceived: 3,103 entriesSent: 2,104 });105106 expect(runner).not.toHaveBeenCalled();107 });108109 it("calls the runner once per mobile host on a single trigger", async () => {110 const seen: string[] = [];111 setSyncRunner(async (device) => {112 seen.push(device.deviceId);113 return { ok: true, entriesReceived: 1, entriesSent: 0 };114 });115 await upsertPairedDevice(ANDROID);116 await upsertPairedDevice(IOS);117 await upsertPairedDevice(EXT);118 await triggerSyncIfReachable({ reason: "manual" });119 expect(seen.sort()).toEqual([ANDROID.deviceId, IOS.deviceId]);120 });121122 it("marks lastSyncedAt on each host that completes successfully", async () => {123 setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));124 await upsertPairedDevice(ANDROID);125 await upsertPairedDevice(IOS);126 await triggerSyncIfReachable({ reason: "manual" });127 const a = await findPairedDeviceById(ANDROID.deviceId);128 const b = await findPairedDeviceById(IOS.deviceId);129 expect(typeof a?.lastSyncedAt).toBe("number");130 expect(typeof b?.lastSyncedAt).toBe("number");131 });132133 it("does not start a second sync if one is already in flight (non-force)", async () => {134 const runner = vi.fn(async () => {135 await new Promise<void>((r) => setTimeout(r, 50));136 return { ok: true as const, entriesReceived: 0, entriesSent: 0 };137 });138 setSyncRunner(runner);139 await upsertPairedDevice(ANDROID);140 const a = triggerSyncIfReachable({ reason: "vault_change" });141 const b = triggerSyncIfReachable({ reason: "vault_change" });142 await Promise.all([a, b]);143 expect(runner).toHaveBeenCalledTimes(1);144 });145146 it("force:true bypasses the syncInFlight guard for manual triggers", async () => {147 const runner = vi.fn(async () => {148 await new Promise<void>((r) => setTimeout(r, 30));149 return { ok: true as const, entriesReceived: 0, entriesSent: 0 };150 });151 setSyncRunner(runner);152 await upsertPairedDevice(ANDROID);153 const a = triggerSyncIfReachable({ reason: "vault_change" });154 const b = triggerSyncIfReachable({ reason: "manual", force: true });155 await Promise.all([a, b]);156 expect(runner).toHaveBeenCalledTimes(2);157 });158159 it("transitions to error state when no host succeeds", async () => {160 setSyncRunner(async () => {161 throw new Error("unreachable");162 });163 await upsertPairedDevice(ANDROID);164 await triggerSyncIfReachable({ reason: "manual" });165 expect(getSyncState().status).toBe("error");166 expect(getSyncState().error).toBe("unreachable");167 });168169 it("returns to idle state on success with aggregated entry counts", async () => {170 setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 2 }));171 await upsertPairedDevice(ANDROID);172 await upsertPairedDevice(IOS);173 await triggerSyncIfReachable({ reason: "manual" });174 const s = getSyncState();175 expect(s.status).toBe("idle");176 expect(s.entriesReceived).toBe(6);177 expect(s.entriesSent).toBe(4);178 expect(typeof s.lastSyncAt).toBe("number");179 });180181 it("syncInFlight is cleared in finally even when runner throws", async () => {182 setSyncRunner(async () => { throw new Error("runner exploded"); });183 await upsertPairedDevice(ANDROID);184 await triggerSyncIfReachable({ reason: "manual", force: true });185 186 let ran = false;187 setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });188 await triggerSyncIfReachable({ reason: "vault_change" });189 expect(ran).toBe(true);190 });191192 it("syncs a vouched device — mesh introduction is the point of vouching", async () => {193 194 195 196 197 198 const vouched: PairedDevice = {199 ...ANDROID,200 deviceId: "VVV-vouched",201 trustSource: "vouched",202 };203 const seen: string[] = [];204 setSyncRunner(async (d) => { seen.push(d.deviceId); return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });205 await upsertPairedDevice(vouched);206 await triggerSyncIfReachable({ reason: "foreground" });207 expect(seen).toEqual(["VVV-vouched"]);208 });209210 it("keeps the legacy no-selector test fallback limited to native hosts", async () => {211 let ran = false;212 setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });213 await upsertPairedDevice({214 ...ANDROID,215 deviceId: "EXT-other",216 deviceType: "extension",217 trustSource: "direct",218 });219 await triggerSyncIfReachable({ reason: "foreground" });220 expect(ran).toBe(false);221 });222223 it("lets the production role selector choose an extension dial target", async () => {224 const seen: string[] = [];225 setSyncRunner(async (device) => {226 seen.push(device.deviceId);227 return { ok: true as const, entriesReceived: 0, entriesSent: 0 };228 });229 setSyncDialTargetSelector(async (devices) =>230 devices.filter((device) => device.deviceType === "extension")231 );232 await upsertPairedDevice({233 ...EXT,234 trustSource: "vouched",235 });236237 await triggerSyncIfReachable({ reason: "foreground" });238239 expect(seen).toEqual(["CCC-ext"]);240 });241242 it("persists peach_last_sync_result to storage after a successful sync", async () => {243 setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 1 }));244 await upsertPairedDevice(ANDROID);245 await triggerSyncIfReachable({ reason: "manual" });246 247 const raw = await (globalThis as unknown as { chrome: { storage: { local: { get(k: string): Promise<Record<string, unknown>> } } } })248 .chrome.storage.local.get("peach_last_sync_result");249 expect(raw.peach_last_sync_result).toMatchObject({250 outcome: "completed_changes",251 entriesReceived: 3,252 entriesSent: 1,253 });254 expect(typeof (raw.peach_last_sync_result as { at: unknown }).at).toBe("number");255 });256257 258 259 260 describe("reported outcomes", () => {261 it("reports no_paired_devices when the registry is empty", async () => {262 setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));263 await triggerSyncIfReachable({ reason: "manual" });264 const s = getSyncState();265 expect(s.outcome).toBe("no_paired_devices");266 expect(s.status).toBe("idle");267 expect(typeof s.finishedAt).toBe("number");268 });269270 it("reports no_eligible_host naming the deviceType and trustSource found", async () => {271 272 273 setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));274 await upsertPairedDevice({ ...EXT, deviceName: "Other Chrome" });275 await upsertPairedDevice({276 ...ANDROID,277 deviceId: "untrusted-android",278 deviceName: "Pixel",279 trustSource: undefined,280 });281 await triggerSyncIfReachable({ reason: "manual" });282 const s = getSyncState();283 expect(s.outcome).toBe("no_eligible_host");284 expect(s.status).toBe("error");285 expect(s.detail).toContain("Other Chrome [type=extension, trust=unset]");286 expect(s.detail).toContain("Pixel [type=android, trust=unset]");287 });288289 it("reports runner_unavailable when the runner slot is never wired", async () => {290 setSyncRunner(null);291 await upsertPairedDevice(ANDROID);292 await triggerSyncIfReachable({ reason: "manual" });293 const s = getSyncState();294 expect(s.outcome).toBe("runner_unavailable");295 expect(s.status).toBe("error");296 });297298 it("distinguishes completed_no_changes from a failure", async () => {299 setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));300 await upsertPairedDevice(ANDROID);301 await triggerSyncIfReachable({ reason: "manual" });302 const s = getSyncState();303 expect(s.outcome).toBe("completed_no_changes");304 expect(s.status).toBe("idle");305 expect(s.error).toBeUndefined();306 });307308 it("surfaces a runner throw as its own message rather than swallowing it", async () => {309 setSyncRunner(async () => {310 throw new Error("relay_sync_failed:WebSocket connect timed out");311 });312 await upsertPairedDevice(ANDROID);313 await triggerSyncIfReachable({ reason: "manual" });314 const s = getSyncState();315 expect(s.outcome).toBe("relay_unreachable");316 expect(s.detail).toBe("relay_sync_failed:WebSocket connect timed out");317 318 319 320 expect(s.error).toBeUndefined();321 });322323 it.each([324 ["sync_disabled", "sync_disabled"],325 ["private_relay_unset", "private_relay_unset"],326 ["vault_id_mismatch", "vault_id_mismatch"],327 ["PEER_PROTOCOL_OUTDATED", "peer_protocol_outdated"],328 ["sync_timeout", "sync_timeout"],329 ["VAULT_LOCKED", "vault_locked"],330 ["relay_sync_failed:sync_handshake_timeout", "handshake_failed"],331 ["something nobody predicted", "run_failed"],332 ])("classifies runner error %s as %s", async (error, outcome) => {333 setSyncRunner(async () => ({ ok: false, error }));334 await upsertPairedDevice(ANDROID);335 await triggerSyncIfReachable({ reason: "manual", force: true });336 expect(getSyncState().outcome).toBe(outcome);337 });338339 it("keeps the in-flight run's state when a concurrent trigger is dropped", async () => {340 setSyncRunner(async () => {341 await new Promise<void>((r) => setTimeout(r, 30));342 return { ok: true as const, entriesReceived: 0, entriesSent: 0 };343 });344 await upsertPairedDevice(ANDROID);345 const first = triggerSyncIfReachable({ reason: "manual" });346 while (getSyncState().status !== "syncing") await Promise.resolve();347 await triggerSyncIfReachable({ reason: "vault_change" });348 349 expect(getSyncState().status).toBe("syncing");350 await first;351 expect(getSyncState().outcome).toBe("completed_no_changes");352 });353354 it("restores the last outcome across a SW restart so a failure stays inspectable", async () => {355 setSyncRunner(async () => ({ ok: false, error: "vault_id_mismatch" }));356 await upsertPairedDevice(ANDROID);357 await triggerSyncIfReachable({ reason: "manual" });358 _resetSyncTriggerForTest();359 expect(getSyncState().outcome).toBeUndefined();360 await hydratePersistedSyncState();361 const s = getSyncState();362 expect(s.outcome).toBe("vault_id_mismatch");363 expect(s.detail).toBe("vault_id_mismatch");364 expect(typeof s.finishedAt).toBe("number");365 });366 });367368 it("hydratePersistedSyncState restores lastSyncAt across SW restart simulation", async () => {369 370 await (globalThis as unknown as { chrome: { storage: { local: { set(v: Record<string, unknown>): Promise<void> } } } })371 .chrome.storage.local.set({ peach_last_sync_at: 1_700_005_000_000 });372 _resetSyncTriggerForTest();373 expect(getSyncState().lastSyncAt).toBeUndefined();374 await hydratePersistedSyncState();375 expect(getSyncState().lastSyncAt).toBe(1_700_005_000_000);376 });377});378379describe("triggerSyncIfReachable — the §11 sync generation gate", () => {380 beforeEach(() => {381 makeStorageStub();382 _resetSyncTriggerForTest();383 });384385 function setGeneration(state: string) {386 return (globalThis as unknown as { chrome: { storage: { local: { set(v: Record<string, unknown>): Promise<void> } } } })387 .chrome.storage.local.set({388 peach_sync_generation_v1: JSON.stringify({389 schemaVersion: 1,390 active: { schemaVersion: 1, generation: 1, state, createdAt: 1 },391 }),392 });393 }394395 it("refuses to dial while this device owes a secure reset", async () => {396 const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 }));397 setSyncRunner(runner);398 await upsertPairedDevice(ANDROID);399 await setGeneration("RESET_REQUIRED");400401 await triggerSyncIfReachable({ reason: "manual" });402403 expect(runner).not.toHaveBeenCalled();404 expect(getSyncState()).toMatchObject({ status: "error", outcome: "reset_required" });405 });406407 it("refuses to dial when the generation cannot be read", async () => {408 const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 }));409 setSyncRunner(runner);410 await upsertPairedDevice(ANDROID);411 await (globalThis as unknown as { chrome: { storage: { local: { set(v: Record<string, unknown>): Promise<void> } } } })412 .chrome.storage.local.set({ peach_sync_generation_v1: "{ not json" });413414 await triggerSyncIfReachable({ reason: "manual" });415416 expect(runner).not.toHaveBeenCalled();417 expect(getSyncState()).toMatchObject({ outcome: "sync_generation_unknown" });418 });419420 it("still syncs an install that has never reset, and one that already has", async () => {421 const seen: string[] = [];422 setSyncRunner(async (device) => {423 seen.push(device.deviceId);424 return { ok: true, entriesReceived: 0, entriesSent: 0 };425 });426 await upsertPairedDevice(ANDROID);427428 429 await triggerSyncIfReachable({ reason: "manual" });430 expect(seen).toEqual([ANDROID.deviceId]);431432 await setGeneration("V2_ACTIVE");433 seen.length = 0;434 await triggerSyncIfReachable({ reason: "manual" });435 expect(seen).toEqual([ANDROID.deviceId]);436 });437});