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} from "./sync-trigger";1213function makeStorageStub() {14 const sync = new Map<string, unknown>();15 const local = new Map<string, unknown>();16 function area(store: Map<string, unknown>) {17 return {18 async get(key?: string | string[] | null) {19 if (key === null || key === undefined) {20 const all: Record<string, unknown> = {};21 for (const [k, v] of store) all[k] = v;22 return all;23 }24 const keys = Array.isArray(key) ? key : [key];25 const out: Record<string, unknown> = {};26 for (const k of keys) if (store.has(k)) out[k] = store.get(k);27 return out;28 },29 async set(values: Record<string, unknown>) {30 for (const [k, v] of Object.entries(values)) store.set(k, v);31 },32 async remove(key: string | string[]) {33 const keys = Array.isArray(key) ? key : [key];34 for (const k of keys) store.delete(k);35 },36 };37 }38 (globalThis as unknown as { chrome: unknown }).chrome = {39 storage: { sync: area(sync), local: area(local) },40 runtime: { sendMessage: () => Promise.resolve() },41 };42}4344const ANDROID: PairedDevice = {45 deviceId: "AAA-android",46 deviceName: "Pixel",47 deviceType: "android",48 longTermPublicKeyHex: "a".repeat(64),49 trustSource: "direct",50 pairedAt: 1700000000000,51};5253const IOS: PairedDevice = {54 deviceId: "BBB-ios",55 deviceName: "iPhone",56 deviceType: "ios",57 longTermPublicKeyHex: "b".repeat(64),58 trustSource: "direct",59 pairedAt: 1700000000000,60};6162const EXT: PairedDevice = {63 deviceId: "CCC-ext",64 deviceName: "Other Chrome",65 deviceType: "extension",66 longTermPublicKeyHex: "c".repeat(64),67 pairedAt: 1700000000000,68};6970describe("triggerSyncIfReachable", () => {71 beforeEach(() => {72 makeStorageStub();73 _resetSyncTriggerForTest();74 });7576 it("does not call the runner when no mobile hosts are paired", async () => {77 const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 }));78 setSyncRunner(runner);79 await upsertPairedDevice(EXT);80 await triggerSyncIfReachable({ reason: "manual" });81 expect(runner).not.toHaveBeenCalled();82 });8384 it("reports a healthy waiting state when this device owns the listener role", async () => {85 const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 }));86 setSyncRunner(runner);87 setSyncDialTargetSelector(async () => []);88 await upsertPairedDevice(ANDROID);8990 await triggerSyncIfReachable({ reason: "manual" });9192 expect(runner).not.toHaveBeenCalled();93 expect(getSyncState()).toMatchObject({94 status: "idle",95 outcome: "waiting_for_peer",96 detail: "Pixel [type=android, trust=direct]",97 });98 });99100 it("calls the runner once per mobile host on a single trigger", async () => {101 const seen: string[] = [];102 setSyncRunner(async (device) => {103 seen.push(device.deviceId);104 return { ok: true, entriesReceived: 1, entriesSent: 0 };105 });106 await upsertPairedDevice(ANDROID);107 await upsertPairedDevice(IOS);108 await upsertPairedDevice(EXT);109 await triggerSyncIfReachable({ reason: "manual" });110 expect(seen.sort()).toEqual([ANDROID.deviceId, IOS.deviceId]);111 });112113 it("marks lastSyncedAt on each host that completes successfully", async () => {114 setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));115 await upsertPairedDevice(ANDROID);116 await upsertPairedDevice(IOS);117 await triggerSyncIfReachable({ reason: "manual" });118 const a = await findPairedDeviceById(ANDROID.deviceId);119 const b = await findPairedDeviceById(IOS.deviceId);120 expect(typeof a?.lastSyncedAt).toBe("number");121 expect(typeof b?.lastSyncedAt).toBe("number");122 });123124 it("does not start a second sync if one is already in flight (non-force)", async () => {125 const runner = vi.fn(async () => {126 await new Promise<void>((r) => setTimeout(r, 50));127 return { ok: true as const, entriesReceived: 0, entriesSent: 0 };128 });129 setSyncRunner(runner);130 await upsertPairedDevice(ANDROID);131 const a = triggerSyncIfReachable({ reason: "vault_change" });132 const b = triggerSyncIfReachable({ reason: "vault_change" });133 await Promise.all([a, b]);134 expect(runner).toHaveBeenCalledTimes(1);135 });136137 it("force:true bypasses the syncInFlight guard for manual triggers", async () => {138 const runner = vi.fn(async () => {139 await new Promise<void>((r) => setTimeout(r, 30));140 return { ok: true as const, entriesReceived: 0, entriesSent: 0 };141 });142 setSyncRunner(runner);143 await upsertPairedDevice(ANDROID);144 const a = triggerSyncIfReachable({ reason: "vault_change" });145 const b = triggerSyncIfReachable({ reason: "manual", force: true });146 await Promise.all([a, b]);147 expect(runner).toHaveBeenCalledTimes(2);148 });149150 it("transitions to error state when no host succeeds", async () => {151 setSyncRunner(async () => {152 throw new Error("unreachable");153 });154 await upsertPairedDevice(ANDROID);155 await triggerSyncIfReachable({ reason: "manual" });156 expect(getSyncState().status).toBe("error");157 expect(getSyncState().error).toBe("unreachable");158 });159160 it("returns to idle state on success with aggregated entry counts", async () => {161 setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 2 }));162 await upsertPairedDevice(ANDROID);163 await upsertPairedDevice(IOS);164 await triggerSyncIfReachable({ reason: "manual" });165 const s = getSyncState();166 expect(s.status).toBe("idle");167 expect(s.entriesReceived).toBe(6);168 expect(s.entriesSent).toBe(4);169 expect(typeof s.lastSyncAt).toBe("number");170 });171172 it("syncInFlight is cleared in finally even when runner throws", async () => {173 setSyncRunner(async () => { throw new Error("runner exploded"); });174 await upsertPairedDevice(ANDROID);175 await triggerSyncIfReachable({ reason: "manual", force: true });176 177 let ran = false;178 setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });179 await triggerSyncIfReachable({ reason: "vault_change" });180 expect(ran).toBe(true);181 });182183 it("syncs a vouched device — mesh introduction is the point of vouching", async () => {184 185 186 187 188 189 const vouched: PairedDevice = {190 ...ANDROID,191 deviceId: "VVV-vouched",192 trustSource: "vouched",193 };194 const seen: string[] = [];195 setSyncRunner(async (d) => { seen.push(d.deviceId); return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });196 await upsertPairedDevice(vouched);197 await triggerSyncIfReachable({ reason: "foreground" });198 expect(seen).toEqual(["VVV-vouched"]);199 });200201 it("keeps the legacy no-selector test fallback limited to native hosts", async () => {202 let ran = false;203 setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });204 await upsertPairedDevice({205 ...ANDROID,206 deviceId: "EXT-other",207 deviceType: "extension",208 trustSource: "direct",209 });210 await triggerSyncIfReachable({ reason: "foreground" });211 expect(ran).toBe(false);212 });213214 it("lets the production role selector choose an extension dial target", async () => {215 const seen: string[] = [];216 setSyncRunner(async (device) => {217 seen.push(device.deviceId);218 return { ok: true as const, entriesReceived: 0, entriesSent: 0 };219 });220 setSyncDialTargetSelector(async (devices) =>221 devices.filter((device) => device.deviceType === "extension")222 );223 await upsertPairedDevice({224 ...EXT,225 trustSource: "vouched",226 });227228 await triggerSyncIfReachable({ reason: "foreground" });229230 expect(seen).toEqual(["CCC-ext"]);231 });232233 it("persists peach_last_sync_result to storage after a successful sync", async () => {234 setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 1 }));235 await upsertPairedDevice(ANDROID);236 await triggerSyncIfReachable({ reason: "manual" });237 238 const raw = await (globalThis as unknown as { chrome: { storage: { local: { get(k: string): Promise<Record<string, unknown>> } } } })239 .chrome.storage.local.get("peach_last_sync_result");240 expect(raw.peach_last_sync_result).toMatchObject({241 outcome: "completed_changes",242 entriesReceived: 3,243 entriesSent: 1,244 });245 expect(typeof (raw.peach_last_sync_result as { at: unknown }).at).toBe("number");246 });247248 249 250 251 describe("reported outcomes", () => {252 it("reports no_paired_devices when the registry is empty", async () => {253 setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));254 await triggerSyncIfReachable({ reason: "manual" });255 const s = getSyncState();256 expect(s.outcome).toBe("no_paired_devices");257 expect(s.status).toBe("idle");258 expect(typeof s.finishedAt).toBe("number");259 });260261 it("reports no_eligible_host naming the deviceType and trustSource found", async () => {262 263 264 setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));265 await upsertPairedDevice({ ...EXT, deviceName: "Other Chrome" });266 await upsertPairedDevice({267 ...ANDROID,268 deviceId: "untrusted-android",269 deviceName: "Pixel",270 trustSource: undefined,271 });272 await triggerSyncIfReachable({ reason: "manual" });273 const s = getSyncState();274 expect(s.outcome).toBe("no_eligible_host");275 expect(s.status).toBe("error");276 expect(s.detail).toContain("Other Chrome [type=extension, trust=unset]");277 expect(s.detail).toContain("Pixel [type=android, trust=unset]");278 });279280 it("reports runner_unavailable when the runner slot is never wired", async () => {281 setSyncRunner(null);282 await upsertPairedDevice(ANDROID);283 await triggerSyncIfReachable({ reason: "manual" });284 const s = getSyncState();285 expect(s.outcome).toBe("runner_unavailable");286 expect(s.status).toBe("error");287 });288289 it("distinguishes completed_no_changes from a failure", async () => {290 setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));291 await upsertPairedDevice(ANDROID);292 await triggerSyncIfReachable({ reason: "manual" });293 const s = getSyncState();294 expect(s.outcome).toBe("completed_no_changes");295 expect(s.status).toBe("idle");296 expect(s.error).toBeUndefined();297 });298299 it("surfaces a runner throw as its own message rather than swallowing it", async () => {300 setSyncRunner(async () => {301 throw new Error("relay_sync_failed:WebSocket connect timed out");302 });303 await upsertPairedDevice(ANDROID);304 await triggerSyncIfReachable({ reason: "manual" });305 const s = getSyncState();306 expect(s.outcome).toBe("relay_unreachable");307 expect(s.detail).toBe("relay_sync_failed:WebSocket connect timed out");308 309 310 311 expect(s.error).toBeUndefined();312 });313314 it.each([315 ["sync_disabled", "sync_disabled"],316 ["private_relay_unset", "private_relay_unset"],317 ["vault_id_mismatch", "vault_id_mismatch"],318 ["sync_timeout", "sync_timeout"],319 ["VAULT_LOCKED", "vault_locked"],320 ["relay_sync_failed:sync_handshake_timeout", "handshake_failed"],321 ["something nobody predicted", "run_failed"],322 ])("classifies runner error %s as %s", async (error, outcome) => {323 setSyncRunner(async () => ({ ok: false, error }));324 await upsertPairedDevice(ANDROID);325 await triggerSyncIfReachable({ reason: "manual", force: true });326 expect(getSyncState().outcome).toBe(outcome);327 });328329 it("keeps the in-flight run's state when a concurrent trigger is dropped", async () => {330 setSyncRunner(async () => {331 await new Promise<void>((r) => setTimeout(r, 30));332 return { ok: true as const, entriesReceived: 0, entriesSent: 0 };333 });334 await upsertPairedDevice(ANDROID);335 const first = triggerSyncIfReachable({ reason: "manual" });336 while (getSyncState().status !== "syncing") await Promise.resolve();337 await triggerSyncIfReachable({ reason: "vault_change" });338 339 expect(getSyncState().status).toBe("syncing");340 await first;341 expect(getSyncState().outcome).toBe("completed_no_changes");342 });343344 it("restores the last outcome across a SW restart so a failure stays inspectable", async () => {345 setSyncRunner(async () => ({ ok: false, error: "vault_id_mismatch" }));346 await upsertPairedDevice(ANDROID);347 await triggerSyncIfReachable({ reason: "manual" });348 _resetSyncTriggerForTest();349 expect(getSyncState().outcome).toBeUndefined();350 await hydratePersistedSyncState();351 const s = getSyncState();352 expect(s.outcome).toBe("vault_id_mismatch");353 expect(s.detail).toBe("vault_id_mismatch");354 expect(typeof s.finishedAt).toBe("number");355 });356 });357358 it("hydratePersistedSyncState restores lastSyncAt across SW restart simulation", async () => {359 360 await (globalThis as unknown as { chrome: { storage: { local: { set(v: Record<string, unknown>): Promise<void> } } } })361 .chrome.storage.local.set({ peach_last_sync_at: 1_700_005_000_000 });362 _resetSyncTriggerForTest();363 expect(getSyncState().lastSyncAt).toBeUndefined();364 await hydratePersistedSyncState();365 expect(getSyncState().lastSyncAt).toBe(1_700_005_000_000);366 });367});