import { beforeEach, describe, expect, it, vi } from "vitest"; type StorageShape = Record; // Flush the fire-and-forget clearClipboardNow() promise chain. Fake timers // mock timers, not microtasks, so awaiting plain microtasks drains it. async function flushMicrotasks(): Promise { for (let i = 0; i < 25; i++) await Promise.resolve(); } describe("background clipboard", () => { let storage: StorageShape; let session: StorageShape; let clipboardWriteText: ReturnType; let alarmsCreate: ReturnType; let alarmsClear: ReturnType; let alarmListeners: Array<(alarm: { name: string }) => void>; let sessionRemove: ReturnType; beforeEach(async () => { vi.resetModules(); vi.useFakeTimers(); storage = {}; session = {}; clipboardWriteText = vi.fn(async () => undefined); alarmsCreate = vi.fn(async () => undefined); alarmsClear = vi.fn(async () => true); alarmListeners = []; sessionRemove = vi.fn(async (key: string) => { delete session[key]; }); const chromeMock = { runtime: { // Default offscreen reply; tests that need offscreen install their own. sendMessage: vi.fn(async () => ({ ok: true })), }, tabs: { query: vi.fn(async () => []), }, scripting: { executeScript: vi.fn(async () => []), }, alarms: { clear: alarmsClear, create: alarmsCreate, onAlarm: { addListener: vi.fn((fn: (alarm: { name: string }) => void) => { alarmListeners.push(fn); }), }, }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() }, }, storage: { local: { get: vi.fn(async (key?: string | string[]) => { if (!key) return storage; if (typeof key === "string") { return { [key]: storage[key] }; } return key.reduce>((result, entry) => { result[entry] = storage[entry]; return result; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(storage, next); }), remove: vi.fn(async () => undefined), }, session: { get: vi.fn(async (key: string) => ({ [key]: session[key] })), set: vi.fn(async (next: Record) => { Object.assign(session, next); }), remove: sessionRemove, }, }, } as unknown as typeof chrome; (globalThis as { chrome?: typeof chrome }).chrome = chromeMock; vi.stubGlobal("navigator", { clipboard: { writeText: clipboardWriteText, }, }); const vault = await import("./vault"); await vault.ensureSettingsLoaded(); }); it("copyToClipboard writes text to the clipboard", async () => { const { copyToClipboard } = await import("./clipboard"); await copyToClipboard("secret", "Password"); expect(clipboardWriteText).toHaveBeenCalledWith("secret"); }); it("copyToClipboard clears the clipboard after clipboardClearSeconds", async () => { const vault = await import("./vault"); const { copyToClipboard } = await import("./clipboard"); await vault.updateSettings({ clipboardClearSeconds: 1 }); await copyToClipboard("secret", "Password"); await vi.advanceTimersByTimeAsync(1000); expect(clipboardWriteText).toHaveBeenNthCalledWith(2, ""); }); it("copyToClipboard respects custom clipboardClearSeconds", async () => { const vault = await import("./vault"); const { copyToClipboard } = await import("./clipboard"); await vault.updateSettings({ clipboardClearSeconds: 10 }); await copyToClipboard("secret", "Password"); await vi.advanceTimersByTimeAsync(9999); expect(clipboardWriteText).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(2); expect(clipboardWriteText).toHaveBeenNthCalledWith(2, ""); }); it("copyToClipboard defaults to 30 seconds when clipboardClearSeconds is absent", async () => { const { copyToClipboard } = await import("./clipboard"); await copyToClipboard("secret", "Password"); await vi.advanceTimersByTimeAsync(29999); expect(clipboardWriteText).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(2); expect(clipboardWriteText).toHaveBeenNthCalledWith(2, ""); }); // --- HV-2: eviction-proof clear via chrome.alarms + offscreen --- it("HV-2: copyToClipboard arms the clear alarm (delayInMinutes >= 0.5)", async () => { const { copyToClipboard, CLIPBOARD_CLEAR_ALARM } = await import( "./clipboard" ); await copyToClipboard("secret", "Password"); expect(alarmsCreate).toHaveBeenCalledWith( CLIPBOARD_CLEAR_ALARM, expect.objectContaining({ delayInMinutes: expect.any(Number) }), ); const arg = alarmsCreate.mock.calls.at(-1)?.[1] as { delayInMinutes: number; }; expect(arg.delayInMinutes).toBeGreaterThanOrEqual(0.5); }); it("HV-2: alarm handler routes the clear through the offscreen document when present", async () => { const sendMessage = vi.fn(async () => ({ ok: true })); const createDocument = vi.fn(async () => undefined); const closeDocument = vi.fn(async () => undefined); (chrome as unknown as { offscreen: unknown }).offscreen = { createDocument, closeDocument, Reason: { CLIPBOARD: "CLIPBOARD" }, }; (chrome.runtime as unknown as { sendMessage: unknown }).sendMessage = sendMessage; const { setupClipboardClearAlarm, CLIPBOARD_CLEAR_ALARM } = await import( "./clipboard" ); // vault's alarms.ts already registered an autolock onAlarm listener; the // clipboard listener is the one registered now (last). setupClipboardClearAlarm(); const clipListener = alarmListeners.at(-1)!; // PCH-01-021: the alarm only clears when a clear is still owed. session["clipboard-clear-hash"] = "owed"; clipListener({ name: CLIPBOARD_CLEAR_ALARM }); await flushMicrotasks(); expect(createDocument).toHaveBeenCalledOnce(); expect(sendMessage).toHaveBeenCalledWith({ type: "CLEAR_CLIPBOARD" }); // The offscreen clear succeeded, so we never fell back to the active-tab write. expect(clipboardWriteText).not.toHaveBeenCalled(); }); it("HV-2: alarm handler ignores unrelated alarm names", async () => { const { setupClipboardClearAlarm } = await import("./clipboard"); setupClipboardClearAlarm(); const clipListener = alarmListeners.at(-1)!; clipListener({ name: "some-other-alarm" }); await flushMicrotasks(); expect(clipboardWriteText).not.toHaveBeenCalled(); }); it("HV-2: alarm handler falls back to active-tab write when offscreen is absent (Firefox)", async () => { // No chrome.offscreen on this mock — the Firefox path. const { setupClipboardClearAlarm, CLIPBOARD_CLEAR_ALARM } = await import( "./clipboard" ); setupClipboardClearAlarm(); const clipListener = alarmListeners.at(-1)!; // PCH-01-021: the alarm only clears when a clear is still owed. session["clipboard-clear-hash"] = "owed"; clipListener({ name: CLIPBOARD_CLEAR_ALARM }); await flushMicrotasks(); expect(clipboardWriteText).toHaveBeenCalledWith(""); }); // PCH-01-021: a stale alarm with no clear owed must skip the clear and drop // the alarm, avoiding pointless offscreen-document churn on a cold worker. it("PCH-01-021: alarm with no clear-owed flag skips the clear and drops the alarm", async () => { const { setupClipboardClearAlarm, CLIPBOARD_CLEAR_ALARM } = await import( "./clipboard" ); setupClipboardClearAlarm(); const clipListener = alarmListeners.at(-1)!; // Empty session → nothing owed. delete session["clipboard-clear-hash"]; alarmsClear.mockClear(); clipListener({ name: CLIPBOARD_CLEAR_ALARM }); await flushMicrotasks(); expect(clipboardWriteText).not.toHaveBeenCalled(); expect(alarmsClear).toHaveBeenCalledWith(CLIPBOARD_CLEAR_ALARM); }); it("HV-2: a second copy clears the previous alarm before re-arming", async () => { const { copyToClipboard, CLIPBOARD_CLEAR_ALARM } = await import( "./clipboard" ); await copyToClipboard("first", "Password"); alarmsClear.mockClear(); alarmsCreate.mockClear(); await copyToClipboard("second", "Password"); expect(alarmsClear).toHaveBeenCalledWith(CLIPBOARD_CLEAR_ALARM); expect(alarmsCreate).toHaveBeenCalled(); // clear must precede the new create. const clearOrder = alarmsClear.mock.invocationCallOrder[0]; const createOrder = alarmsCreate.mock.invocationCallOrder.at(-1)!; expect(clearOrder).toBeLessThan(createOrder); }); it("HV-2: a failed clear re-arms the alarm and keeps the session hash", async () => { // Offscreen present but the clear fails, and the active-tab fallback also // fails — the secret must not be silently abandoned. (chrome as unknown as { offscreen: unknown }).offscreen = { createDocument: vi.fn(async () => undefined), closeDocument: vi.fn(async () => undefined), Reason: { CLIPBOARD: "CLIPBOARD" }, }; (chrome.runtime as unknown as { sendMessage: unknown }).sendMessage = vi.fn( async () => ({ ok: false }), ); clipboardWriteText.mockRejectedValue(new Error("no clipboard access")); const { setupClipboardClearAlarm, CLIPBOARD_CLEAR_ALARM } = await import( "./clipboard" ); setupClipboardClearAlarm(); const clipListener = alarmListeners.at(-1)!; alarmsCreate.mockClear(); // PCH-01-021: a clear is owed (hash present), so the alarm proceeds to clear. session["clipboard-clear-hash"] = "owed"; clipListener({ name: CLIPBOARD_CLEAR_ALARM }); await flushMicrotasks(); // Re-armed rather than swallowed. expect(alarmsCreate).toHaveBeenCalledWith( CLIPBOARD_CLEAR_ALARM, expect.objectContaining({ delayInMinutes: expect.any(Number) }), ); // Session hash bookkeeping NOT torn down while a clear is still owed. expect(sessionRemove).not.toHaveBeenCalled(); }); });