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("calls the runner once per mobile host on a single trigger", async () => {85 const seen: string[] = [];86 setSyncRunner(async (device) => {87 seen.push(device.deviceId);88 return { ok: true, entriesReceived: 1, entriesSent: 0 };89 });90 await upsertPairedDevice(ANDROID);91 await upsertPairedDevice(IOS);92 await upsertPairedDevice(EXT);93 await triggerSyncIfReachable({ reason: "manual" });94 expect(seen.sort()).toEqual([ANDROID.deviceId, IOS.deviceId]);95 });9697 it("marks lastSyncedAt on each host that completes successfully", async () => {98 setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));99 await upsertPairedDevice(ANDROID);100 await upsertPairedDevice(IOS);101 await triggerSyncIfReachable({ reason: "manual" });102 const a = await findPairedDeviceById(ANDROID.deviceId);103 const b = await findPairedDeviceById(IOS.deviceId);104 expect(typeof a?.lastSyncedAt).toBe("number");105 expect(typeof b?.lastSyncedAt).toBe("number");106 });107108 it("does not start a second sync if one is already in flight (non-force)", async () => {109 const runner = vi.fn(async () => {110 await new Promise<void>((r) => setTimeout(r, 50));111 return { ok: true as const, entriesReceived: 0, entriesSent: 0 };112 });113 setSyncRunner(runner);114 await upsertPairedDevice(ANDROID);115 const a = triggerSyncIfReachable({ reason: "vault_change" });116 const b = triggerSyncIfReachable({ reason: "vault_change" });117 await Promise.all([a, b]);118 expect(runner).toHaveBeenCalledTimes(1);119 });120121 it("force:true bypasses the syncInFlight guard for manual triggers", async () => {122 const runner = vi.fn(async () => {123 await new Promise<void>((r) => setTimeout(r, 30));124 return { ok: true as const, entriesReceived: 0, entriesSent: 0 };125 });126 setSyncRunner(runner);127 await upsertPairedDevice(ANDROID);128 const a = triggerSyncIfReachable({ reason: "vault_change" });129 const b = triggerSyncIfReachable({ reason: "manual", force: true });130 await Promise.all([a, b]);131 expect(runner).toHaveBeenCalledTimes(2);132 });133134 it("transitions to error state when no host succeeds", async () => {135 setSyncRunner(async () => {136 throw new Error("unreachable");137 });138 await upsertPairedDevice(ANDROID);139 await triggerSyncIfReachable({ reason: "manual" });140 expect(getSyncState().status).toBe("error");141 expect(getSyncState().error).toBe("unreachable");142 });143144 it("returns to idle state on success with aggregated entry counts", async () => {145 setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 2 }));146 await upsertPairedDevice(ANDROID);147 await upsertPairedDevice(IOS);148 await triggerSyncIfReachable({ reason: "manual" });149 const s = getSyncState();150 expect(s.status).toBe("idle");151 expect(s.entriesReceived).toBe(6);152 expect(s.entriesSent).toBe(4);153 expect(typeof s.lastSyncAt).toBe("number");154 });155156 it("syncInFlight is cleared in finally even when runner throws", async () => {157 setSyncRunner(async () => { throw new Error("runner exploded"); });158 await upsertPairedDevice(ANDROID);159 await triggerSyncIfReachable({ reason: "manual", force: true });160 161 let ran = false;162 setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });163 await triggerSyncIfReachable({ reason: "vault_change" });164 expect(ran).toBe(true);165 });166167 it("syncs a vouched device — mesh introduction is the point of vouching", async () => {168 169 170 171 172 173 const vouched: PairedDevice = {174 ...ANDROID,175 deviceId: "VVV-vouched",176 trustSource: "vouched",177 };178 const seen: string[] = [];179 setSyncRunner(async (d) => { seen.push(d.deviceId); return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });180 await upsertPairedDevice(vouched);181 await triggerSyncIfReachable({ reason: "foreground" });182 expect(seen).toEqual(["VVV-vouched"]);183 });184185 it("keeps the legacy no-selector test fallback limited to native hosts", async () => {186 let ran = false;187 setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });188 await upsertPairedDevice({189 ...ANDROID,190 deviceId: "EXT-other",191 deviceType: "extension",192 trustSource: "direct",193 });194 await triggerSyncIfReachable({ reason: "foreground" });195 expect(ran).toBe(false);196 });197198 it("lets the production role selector choose an extension dial target", async () => {199 const seen: string[] = [];200 setSyncRunner(async (device) => {201 seen.push(device.deviceId);202 return { ok: true as const, entriesReceived: 0, entriesSent: 0 };203 });204 setSyncDialTargetSelector(async (devices) =>205 devices.filter((device) => device.deviceType === "extension")206 );207 await upsertPairedDevice({208 ...EXT,209 trustSource: "vouched",210 });211212 await triggerSyncIfReachable({ reason: "foreground" });213214 expect(seen).toEqual(["CCC-ext"]);215 });216217 it("persists peach_last_sync_result to storage after a successful sync", async () => {218 setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 1 }));219 await upsertPairedDevice(ANDROID);220 await triggerSyncIfReachable({ reason: "manual" });221 222 const raw = await (globalThis as unknown as { chrome: { storage: { local: { get(k: string): Promise<Record<string, unknown>> } } } })223 .chrome.storage.local.get("peach_last_sync_result");224 expect(raw.peach_last_sync_result).toMatchObject({225 outcome: "completed_changes",226 entriesReceived: 3,227 entriesSent: 1,228 });229 expect(typeof (raw.peach_last_sync_result as { at: unknown }).at).toBe("number");230 });231232 233 234 235 describe("reported outcomes", () => {236 it("reports no_paired_devices when the registry is empty", async () => {237 setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));238 await triggerSyncIfReachable({ reason: "manual" });239 const s = getSyncState();240 expect(s.outcome).toBe("no_paired_devices");241 expect(s.status).toBe("idle");242 expect(typeof s.finishedAt).toBe("number");243 });244245 it("reports no_eligible_host naming the deviceType and trustSource found", async () => {246 247 248 setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));249 await upsertPairedDevice({ ...EXT, deviceName: "Other Chrome" });250 await upsertPairedDevice({251 ...ANDROID,252 deviceId: "untrusted-android",253 deviceName: "Pixel",254 trustSource: undefined,255 });256 await triggerSyncIfReachable({ reason: "manual" });257 const s = getSyncState();258 expect(s.outcome).toBe("no_eligible_host");259 expect(s.status).toBe("error");260 expect(s.detail).toContain("Other Chrome [type=extension, trust=unset]");261 expect(s.detail).toContain("Pixel [type=android, trust=unset]");262 });263264 it("reports runner_unavailable when the runner slot is never wired", async () => {265 setSyncRunner(null);266 await upsertPairedDevice(ANDROID);267 await triggerSyncIfReachable({ reason: "manual" });268 const s = getSyncState();269 expect(s.outcome).toBe("runner_unavailable");270 expect(s.status).toBe("error");271 });272273 it("distinguishes completed_no_changes from a failure", async () => {274 setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));275 await upsertPairedDevice(ANDROID);276 await triggerSyncIfReachable({ reason: "manual" });277 const s = getSyncState();278 expect(s.outcome).toBe("completed_no_changes");279 expect(s.status).toBe("idle");280 expect(s.error).toBeUndefined();281 });282283 it("surfaces a runner throw as its own message rather than swallowing it", async () => {284 setSyncRunner(async () => {285 throw new Error("relay_sync_failed:WebSocket connect timed out");286 });287 await upsertPairedDevice(ANDROID);288 await triggerSyncIfReachable({ reason: "manual" });289 const s = getSyncState();290 expect(s.outcome).toBe("relay_unreachable");291 expect(s.detail).toBe("relay_sync_failed:WebSocket connect timed out");292 293 294 295 expect(s.error).toBeUndefined();296 });297298 it.each([299 ["sync_disabled", "sync_disabled"],300 ["private_relay_unset", "private_relay_unset"],301 ["vault_id_mismatch", "vault_id_mismatch"],302 ["sync_timeout", "sync_timeout"],303 ["VAULT_LOCKED", "vault_locked"],304 ["relay_sync_failed:sync_handshake_timeout", "handshake_failed"],305 ["something nobody predicted", "run_failed"],306 ])("classifies runner error %s as %s", async (error, outcome) => {307 setSyncRunner(async () => ({ ok: false, error }));308 await upsertPairedDevice(ANDROID);309 await triggerSyncIfReachable({ reason: "manual", force: true });310 expect(getSyncState().outcome).toBe(outcome);311 });312313 it("keeps the in-flight run's state when a concurrent trigger is dropped", async () => {314 setSyncRunner(async () => {315 await new Promise<void>((r) => setTimeout(r, 30));316 return { ok: true as const, entriesReceived: 0, entriesSent: 0 };317 });318 await upsertPairedDevice(ANDROID);319 const first = triggerSyncIfReachable({ reason: "manual" });320 while (getSyncState().status !== "syncing") await Promise.resolve();321 await triggerSyncIfReachable({ reason: "vault_change" });322 323 expect(getSyncState().status).toBe("syncing");324 await first;325 expect(getSyncState().outcome).toBe("completed_no_changes");326 });327328 it("restores the last outcome across a SW restart so a failure stays inspectable", async () => {329 setSyncRunner(async () => ({ ok: false, error: "vault_id_mismatch" }));330 await upsertPairedDevice(ANDROID);331 await triggerSyncIfReachable({ reason: "manual" });332 _resetSyncTriggerForTest();333 expect(getSyncState().outcome).toBeUndefined();334 await hydratePersistedSyncState();335 const s = getSyncState();336 expect(s.outcome).toBe("vault_id_mismatch");337 expect(s.detail).toBe("vault_id_mismatch");338 expect(typeof s.finishedAt).toBe("number");339 });340 });341342 it("hydratePersistedSyncState restores lastSyncAt across SW restart simulation", async () => {343 344 await (globalThis as unknown as { chrome: { storage: { local: { set(v: Record<string, unknown>): Promise<void> } } } })345 .chrome.storage.local.set({ peach_last_sync_at: 1_700_005_000_000 });346 _resetSyncTriggerForTest();347 expect(getSyncState().lastSyncAt).toBeUndefined();348 await hydratePersistedSyncState();349 expect(getSyncState().lastSyncAt).toBe(1_700_005_000_000);350 });351});