1import { beforeEach, describe, expect, it, vi } from "vitest";23type StorageShape = Record<string, unknown>;4567async function flushMicrotasks(): Promise<void> {8 for (let i = 0; i < 25; i++) await Promise.resolve();9}1011describe("background clipboard", () => {12 let storage: StorageShape;13 let session: StorageShape;14 let clipboardWriteText: ReturnType<typeof vi.fn>;15 let alarmsCreate: ReturnType<typeof vi.fn>;16 let alarmsClear: ReturnType<typeof vi.fn>;17 let alarmListeners: Array<(alarm: { name: string }) => void>;18 let sessionRemove: ReturnType<typeof vi.fn>;1920 beforeEach(async () => {21 vi.resetModules();22 vi.useFakeTimers();23 storage = {};24 session = {};25 clipboardWriteText = vi.fn(async () => undefined);26 alarmsCreate = vi.fn(async () => undefined);27 alarmsClear = vi.fn(async () => true);28 alarmListeners = [];29 sessionRemove = vi.fn(async (key: string) => {30 delete session[key];31 });3233 const chromeMock = {34 runtime: {35 36 sendMessage: vi.fn(async () => ({ ok: true })),37 },38 tabs: {39 query: vi.fn(async () => []),40 },41 scripting: {42 executeScript: vi.fn(async () => []),43 },44 alarms: {45 clear: alarmsClear,46 create: alarmsCreate,47 onAlarm: {48 addListener: vi.fn((fn: (alarm: { name: string }) => void) => {49 alarmListeners.push(fn);50 }),51 },52 },53 idle: {54 setDetectionInterval: vi.fn(),55 onStateChanged: { addListener: vi.fn() },56 },57 storage: {58 local: {59 get: vi.fn(async (key?: string | string[]) => {60 if (!key) return storage;61 if (typeof key === "string") {62 return { [key]: storage[key] };63 }6465 return key.reduce<Record<string, unknown>>((result, entry) => {66 result[entry] = storage[entry];67 return result;68 }, {});69 }),70 set: vi.fn(async (next: Record<string, unknown>) => {71 Object.assign(storage, next);72 }),73 remove: vi.fn(async () => undefined),74 },75 session: {76 get: vi.fn(async (key: string) => ({ [key]: session[key] })),77 set: vi.fn(async (next: Record<string, unknown>) => {78 Object.assign(session, next);79 }),80 remove: sessionRemove,81 },82 },83 } as unknown as typeof chrome;8485 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;86 vi.stubGlobal("navigator", {87 clipboard: {88 writeText: clipboardWriteText,89 },90 });9192 const vault = await import("./vault");93 await vault.ensureSettingsLoaded();94 });9596 it("copyToClipboard writes text to the clipboard", async () => {97 const { copyToClipboard } = await import("./clipboard");9899 await copyToClipboard("secret", "Password");100101 expect(clipboardWriteText).toHaveBeenCalledWith("secret");102 });103104 it("copyToClipboard clears the clipboard after clipboardClearSeconds", async () => {105 const vault = await import("./vault");106 const { copyToClipboard } = await import("./clipboard");107108 await vault.updateSettings({ clipboardClearSeconds: 1 });109 await copyToClipboard("secret", "Password");110111 await vi.advanceTimersByTimeAsync(1000);112113 expect(clipboardWriteText).toHaveBeenNthCalledWith(2, "");114 });115116 it("copyToClipboard respects custom clipboardClearSeconds", async () => {117 const vault = await import("./vault");118 const { copyToClipboard } = await import("./clipboard");119120 await vault.updateSettings({ clipboardClearSeconds: 10 });121 await copyToClipboard("secret", "Password");122123 await vi.advanceTimersByTimeAsync(9999);124 expect(clipboardWriteText).toHaveBeenCalledTimes(1);125126 await vi.advanceTimersByTimeAsync(2);127 expect(clipboardWriteText).toHaveBeenNthCalledWith(2, "");128 });129130 it("copyToClipboard defaults to 30 seconds when clipboardClearSeconds is absent", async () => {131 const { copyToClipboard } = await import("./clipboard");132133 await copyToClipboard("secret", "Password");134135 await vi.advanceTimersByTimeAsync(29999);136 expect(clipboardWriteText).toHaveBeenCalledTimes(1);137138 await vi.advanceTimersByTimeAsync(2);139 expect(clipboardWriteText).toHaveBeenNthCalledWith(2, "");140 });141142 143144 it("HV-2: copyToClipboard arms the clear alarm (delayInMinutes >= 0.5)", async () => {145 const { copyToClipboard, CLIPBOARD_CLEAR_ALARM } = await import(146 "./clipboard"147 );148149 await copyToClipboard("secret", "Password");150151 expect(alarmsCreate).toHaveBeenCalledWith(152 CLIPBOARD_CLEAR_ALARM,153 expect.objectContaining({ delayInMinutes: expect.any(Number) }),154 );155 const arg = alarmsCreate.mock.calls.at(-1)?.[1] as {156 delayInMinutes: number;157 };158 expect(arg.delayInMinutes).toBeGreaterThanOrEqual(0.5);159 });160161 it("HV-2: alarm handler routes the clear through the offscreen document when present", async () => {162 const sendMessage = vi.fn(async () => ({ ok: true }));163 const createDocument = vi.fn(async () => undefined);164 const closeDocument = vi.fn(async () => undefined);165 (chrome as unknown as { offscreen: unknown }).offscreen = {166 createDocument,167 closeDocument,168 Reason: { CLIPBOARD: "CLIPBOARD" },169 };170 (chrome.runtime as unknown as { sendMessage: unknown }).sendMessage =171 sendMessage;172173 const { setupClipboardClearAlarm, CLIPBOARD_CLEAR_ALARM } = await import(174 "./clipboard"175 );176 177 178 setupClipboardClearAlarm();179 const clipListener = alarmListeners.at(-1)!;180181 clipListener({ name: CLIPBOARD_CLEAR_ALARM });182 await flushMicrotasks();183184 expect(createDocument).toHaveBeenCalledOnce();185 expect(sendMessage).toHaveBeenCalledWith({ type: "CLEAR_CLIPBOARD" });186 187 expect(clipboardWriteText).not.toHaveBeenCalled();188 });189190 it("HV-2: alarm handler ignores unrelated alarm names", async () => {191 const { setupClipboardClearAlarm } = await import("./clipboard");192 setupClipboardClearAlarm();193 const clipListener = alarmListeners.at(-1)!;194195 clipListener({ name: "some-other-alarm" });196 await flushMicrotasks();197198 expect(clipboardWriteText).not.toHaveBeenCalled();199 });200201 it("HV-2: alarm handler falls back to active-tab write when offscreen is absent (Firefox)", async () => {202 203 const { setupClipboardClearAlarm, CLIPBOARD_CLEAR_ALARM } = await import(204 "./clipboard"205 );206 setupClipboardClearAlarm();207 const clipListener = alarmListeners.at(-1)!;208209 clipListener({ name: CLIPBOARD_CLEAR_ALARM });210 await flushMicrotasks();211212 expect(clipboardWriteText).toHaveBeenCalledWith("");213 });214215 it("HV-2: a second copy clears the previous alarm before re-arming", async () => {216 const { copyToClipboard, CLIPBOARD_CLEAR_ALARM } = await import(217 "./clipboard"218 );219220 await copyToClipboard("first", "Password");221 alarmsClear.mockClear();222 alarmsCreate.mockClear();223224 await copyToClipboard("second", "Password");225226 expect(alarmsClear).toHaveBeenCalledWith(CLIPBOARD_CLEAR_ALARM);227 expect(alarmsCreate).toHaveBeenCalled();228 229 const clearOrder = alarmsClear.mock.invocationCallOrder[0];230 const createOrder = alarmsCreate.mock.invocationCallOrder.at(-1)!;231 expect(clearOrder).toBeLessThan(createOrder);232 });233234 it("HV-2: a failed clear re-arms the alarm and keeps the session hash", async () => {235 236 237 (chrome as unknown as { offscreen: unknown }).offscreen = {238 createDocument: vi.fn(async () => undefined),239 closeDocument: vi.fn(async () => undefined),240 Reason: { CLIPBOARD: "CLIPBOARD" },241 };242 (chrome.runtime as unknown as { sendMessage: unknown }).sendMessage = vi.fn(243 async () => ({ ok: false }),244 );245 clipboardWriteText.mockRejectedValue(new Error("no clipboard access"));246247 const { setupClipboardClearAlarm, CLIPBOARD_CLEAR_ALARM } = await import(248 "./clipboard"249 );250 setupClipboardClearAlarm();251 const clipListener = alarmListeners.at(-1)!;252 alarmsCreate.mockClear();253254 clipListener({ name: CLIPBOARD_CLEAR_ALARM });255 await flushMicrotasks();256257 258 expect(alarmsCreate).toHaveBeenCalledWith(259 CLIPBOARD_CLEAR_ALARM,260 expect.objectContaining({ delayInMinutes: expect.any(Number) }),261 );262 263 expect(sessionRemove).not.toHaveBeenCalled();264 });265});