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 setSyncRunner,7 _resetSyncTriggerForTest,8 getSyncState,9 hydratePersistedSyncState,10} from "./sync-trigger";1112function makeStorageStub() {13 const sync = new Map<string, unknown>();14 const local = new Map<string, unknown>();15 function area(store: Map<string, unknown>) {16 return {17 async get(key?: string | string[] | null) {18 if (key === null || key === undefined) {19 const all: Record<string, unknown> = {};20 for (const [k, v] of store) all[k] = v;21 return all;22 }23 const keys = Array.isArray(key) ? key : [key];24 const out: Record<string, unknown> = {};25 for (const k of keys) if (store.has(k)) out[k] = store.get(k);26 return out;27 },28 async set(values: Record<string, unknown>) {29 for (const [k, v] of Object.entries(values)) store.set(k, v);30 },31 async remove(key: string | string[]) {32 const keys = Array.isArray(key) ? key : [key];33 for (const k of keys) store.delete(k);34 },35 };36 }37 (globalThis as unknown as { chrome: unknown }).chrome = {38 storage: { sync: area(sync), local: area(local) },39 runtime: { sendMessage: () => Promise.resolve() },40 };41}4243const ANDROID: PairedDevice = {44 deviceId: "AAA-android",45 deviceName: "Pixel",46 deviceType: "android",47 longTermPublicKeyHex: "a".repeat(64),48 trustSource: "direct",49 pairedAt: 1700000000000,50};5152const IOS: PairedDevice = {53 deviceId: "BBB-ios",54 deviceName: "iPhone",55 deviceType: "ios",56 longTermPublicKeyHex: "b".repeat(64),57 trustSource: "direct",58 pairedAt: 1700000000000,59};6061const EXT: PairedDevice = {62 deviceId: "CCC-ext",63 deviceName: "Other Chrome",64 deviceType: "extension",65 longTermPublicKeyHex: "c".repeat(64),66 pairedAt: 1700000000000,67};6869describe("triggerSyncIfReachable", () => {70 beforeEach(() => {71 makeStorageStub();72 _resetSyncTriggerForTest();73 });7475 it("does not call the runner when no mobile hosts are paired", async () => {76 await upsertPairedDevice(EXT);77 const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 }));78 setSyncRunner(runner);79 await triggerSyncIfReachable({ reason: "manual" });80 expect(runner).not.toHaveBeenCalled();81 });8283 it("calls the runner once per mobile host on a single trigger", async () => {84 await upsertPairedDevice(ANDROID);85 await upsertPairedDevice(IOS);86 await upsertPairedDevice(EXT);87 const seen: string[] = [];88 setSyncRunner(async (device) => {89 seen.push(device.deviceId);90 return { ok: true, entriesReceived: 1, entriesSent: 0 };91 });92 await triggerSyncIfReachable({ reason: "manual" });93 expect(seen.sort()).toEqual([ANDROID.deviceId, IOS.deviceId]);94 });9596 it("marks lastSyncedAt on each host that completes successfully", async () => {97 await upsertPairedDevice(ANDROID);98 await upsertPairedDevice(IOS);99 setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));100 await triggerSyncIfReachable({ reason: "manual" });101 const a = await findPairedDeviceById(ANDROID.deviceId);102 const b = await findPairedDeviceById(IOS.deviceId);103 expect(typeof a?.lastSyncedAt).toBe("number");104 expect(typeof b?.lastSyncedAt).toBe("number");105 });106107 it("does not start a second sync if one is already in flight (non-force)", async () => {108 await upsertPairedDevice(ANDROID);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 const a = triggerSyncIfReachable({ reason: "vault_change" });115 const b = triggerSyncIfReachable({ reason: "vault_change" });116 await Promise.all([a, b]);117 expect(runner).toHaveBeenCalledTimes(1);118 });119120 it("force:true bypasses the syncInFlight guard for manual triggers", async () => {121 await upsertPairedDevice(ANDROID);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 const a = triggerSyncIfReachable({ reason: "vault_change" });128 const b = triggerSyncIfReachable({ reason: "manual", force: true });129 await Promise.all([a, b]);130 expect(runner).toHaveBeenCalledTimes(2);131 });132133 it("transitions to error state when no host succeeds", async () => {134 await upsertPairedDevice(ANDROID);135 setSyncRunner(async () => {136 throw new Error("unreachable");137 });138 await triggerSyncIfReachable({ reason: "manual" });139 expect(getSyncState().status).toBe("error");140 expect(getSyncState().error).toBe("unreachable");141 });142143 it("returns to idle state on success with aggregated entry counts", async () => {144 await upsertPairedDevice(ANDROID);145 await upsertPairedDevice(IOS);146 setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 2 }));147 await triggerSyncIfReachable({ reason: "manual" });148 const s = getSyncState();149 expect(s.status).toBe("idle");150 expect(s.entriesReceived).toBe(6);151 expect(s.entriesSent).toBe(4);152 expect(typeof s.lastSyncAt).toBe("number");153 });154155 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 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 });165166 it("syncs a vouched device — mesh introduction is the point of vouching", async () => {167 168 169 170 171 172 const vouched: PairedDevice = {173 ...ANDROID,174 deviceId: "VVV-vouched",175 trustSource: "vouched",176 };177 await upsertPairedDevice(vouched);178 const seen: string[] = [];179 setSyncRunner(async (d) => { seen.push(d.deviceId); return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });180 await triggerSyncIfReachable({ reason: "foreground" });181 expect(seen).toEqual(["VVV-vouched"]);182 });183184 it("never treats another extension as a sync host — it cannot listen", async () => {185 await upsertPairedDevice({186 ...ANDROID,187 deviceId: "EXT-other",188 deviceType: "extension",189 trustSource: "direct",190 });191 let ran = false;192 setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });193 await triggerSyncIfReachable({ reason: "foreground" });194 expect(ran).toBe(false);195 });196197 it("persists peach_last_sync_result to storage after a successful sync", async () => {198 await upsertPairedDevice(ANDROID);199 setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 1 }));200 await triggerSyncIfReachable({ reason: "manual" });201 202 const raw = await (globalThis as unknown as { chrome: { storage: { local: { get(k: string): Promise<Record<string, unknown>> } } } })203 .chrome.storage.local.get("peach_last_sync_result");204 expect(raw.peach_last_sync_result).toMatchObject({205 outcome: "completed_changes",206 entriesReceived: 3,207 entriesSent: 1,208 });209 expect(typeof (raw.peach_last_sync_result as { at: unknown }).at).toBe("number");210 });211212 213 214 215 describe("reported outcomes", () => {216 it("reports no_paired_devices when the registry is empty", async () => {217 setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));218 await triggerSyncIfReachable({ reason: "manual" });219 const s = getSyncState();220 expect(s.outcome).toBe("no_paired_devices");221 expect(s.status).toBe("idle");222 expect(typeof s.finishedAt).toBe("number");223 });224225 it("reports no_eligible_host naming the deviceType and trustSource found", async () => {226 227 228 await upsertPairedDevice({ ...EXT, deviceName: "Other Chrome" });229 await upsertPairedDevice({230 ...ANDROID,231 deviceId: "untrusted-android",232 deviceName: "Pixel",233 trustSource: undefined,234 });235 setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));236 await triggerSyncIfReachable({ reason: "manual" });237 const s = getSyncState();238 expect(s.outcome).toBe("no_eligible_host");239 expect(s.status).toBe("error");240 expect(s.detail).toContain("Other Chrome [type=extension, trust=unset]");241 expect(s.detail).toContain("Pixel [type=android, trust=unset]");242 });243244 it("reports runner_unavailable when the runner slot is never wired", async () => {245 await upsertPairedDevice(ANDROID);246 setSyncRunner(null);247 await triggerSyncIfReachable({ reason: "manual" });248 const s = getSyncState();249 expect(s.outcome).toBe("runner_unavailable");250 expect(s.status).toBe("error");251 });252253 it("distinguishes completed_no_changes from a failure", async () => {254 await upsertPairedDevice(ANDROID);255 setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));256 await triggerSyncIfReachable({ reason: "manual" });257 const s = getSyncState();258 expect(s.outcome).toBe("completed_no_changes");259 expect(s.status).toBe("idle");260 expect(s.error).toBeUndefined();261 });262263 it("surfaces a runner throw as its own message rather than swallowing it", async () => {264 await upsertPairedDevice(ANDROID);265 setSyncRunner(async () => {266 throw new Error("relay_sync_failed:WebSocket connect timed out");267 });268 await triggerSyncIfReachable({ reason: "manual" });269 const s = getSyncState();270 expect(s.outcome).toBe("relay_unreachable");271 expect(s.detail).toBe("relay_sync_failed:WebSocket connect timed out");272 expect(s.error).toBe("relay_sync_failed:WebSocket connect timed out");273 });274275 it.each([276 ["sync_disabled", "sync_disabled"],277 ["private_relay_unset", "private_relay_unset"],278 ["vault_id_mismatch", "vault_id_mismatch"],279 ["sync_timeout", "sync_timeout"],280 ["VAULT_LOCKED", "vault_locked"],281 ["relay_sync_failed:sync_handshake_timeout", "handshake_failed"],282 ["something nobody predicted", "run_failed"],283 ])("classifies runner error %s as %s", async (error, outcome) => {284 await upsertPairedDevice(ANDROID);285 setSyncRunner(async () => ({ ok: false, error }));286 await triggerSyncIfReachable({ reason: "manual", force: true });287 expect(getSyncState().outcome).toBe(outcome);288 });289290 it("keeps the in-flight run's state when a concurrent trigger is dropped", async () => {291 await upsertPairedDevice(ANDROID);292 setSyncRunner(async () => {293 await new Promise<void>((r) => setTimeout(r, 30));294 return { ok: true as const, entriesReceived: 0, entriesSent: 0 };295 });296 const first = triggerSyncIfReachable({ reason: "manual" });297 while (getSyncState().status !== "syncing") await Promise.resolve();298 await triggerSyncIfReachable({ reason: "vault_change" });299 300 expect(getSyncState().status).toBe("syncing");301 await first;302 expect(getSyncState().outcome).toBe("completed_no_changes");303 });304305 it("restores the last outcome across a SW restart so a failure stays inspectable", async () => {306 await upsertPairedDevice(ANDROID);307 setSyncRunner(async () => ({ ok: false, error: "vault_id_mismatch" }));308 await triggerSyncIfReachable({ reason: "manual" });309 _resetSyncTriggerForTest();310 expect(getSyncState().outcome).toBeUndefined();311 await hydratePersistedSyncState();312 const s = getSyncState();313 expect(s.outcome).toBe("vault_id_mismatch");314 expect(s.detail).toBe("vault_id_mismatch");315 expect(typeof s.finishedAt).toBe("number");316 });317 });318319 it("hydratePersistedSyncState restores lastSyncAt across SW restart simulation", async () => {320 321 await (globalThis as unknown as { chrome: { storage: { local: { set(v: Record<string, unknown>): Promise<void> } } } })322 .chrome.storage.local.set({ peach_last_sync_at: 1_700_005_000_000 });323 _resetSyncTriggerForTest();324 expect(getSyncState().lastSyncAt).toBeUndefined();325 await hydratePersistedSyncState();326 expect(getSyncState().lastSyncAt).toBe(1_700_005_000_000);327 });328});