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 182 session["clipboard-clear-hash"] = "owed";183 clipListener({ name: CLIPBOARD_CLEAR_ALARM });184 await flushMicrotasks();185186 expect(createDocument).toHaveBeenCalledOnce();187 expect(sendMessage).toHaveBeenCalledWith({ type: "CLEAR_CLIPBOARD" });188 189 expect(clipboardWriteText).not.toHaveBeenCalled();190 });191192 it("HV-2: alarm handler ignores unrelated alarm names", async () => {193 const { setupClipboardClearAlarm } = await import("./clipboard");194 setupClipboardClearAlarm();195 const clipListener = alarmListeners.at(-1)!;196197 clipListener({ name: "some-other-alarm" });198 await flushMicrotasks();199200 expect(clipboardWriteText).not.toHaveBeenCalled();201 });202203 it("HV-2: alarm handler falls back to active-tab write when offscreen is absent (Firefox)", async () => {204 205 const { setupClipboardClearAlarm, CLIPBOARD_CLEAR_ALARM } = await import(206 "./clipboard"207 );208 setupClipboardClearAlarm();209 const clipListener = alarmListeners.at(-1)!;210211 212 session["clipboard-clear-hash"] = "owed";213 clipListener({ name: CLIPBOARD_CLEAR_ALARM });214 await flushMicrotasks();215216 expect(clipboardWriteText).toHaveBeenCalledWith("");217 });218219 220 221 it("PCH-01-021: alarm with no clear-owed flag skips the clear and drops the alarm", async () => {222 const { setupClipboardClearAlarm, CLIPBOARD_CLEAR_ALARM } = await import(223 "./clipboard"224 );225 setupClipboardClearAlarm();226 const clipListener = alarmListeners.at(-1)!;227228 229 delete session["clipboard-clear-hash"];230 alarmsClear.mockClear();231 clipListener({ name: CLIPBOARD_CLEAR_ALARM });232 await flushMicrotasks();233234 expect(clipboardWriteText).not.toHaveBeenCalled();235 expect(alarmsClear).toHaveBeenCalledWith(CLIPBOARD_CLEAR_ALARM);236 });237238 it("HV-2: a second copy clears the previous alarm before re-arming", async () => {239 const { copyToClipboard, CLIPBOARD_CLEAR_ALARM } = await import(240 "./clipboard"241 );242243 await copyToClipboard("first", "Password");244 alarmsClear.mockClear();245 alarmsCreate.mockClear();246247 await copyToClipboard("second", "Password");248249 expect(alarmsClear).toHaveBeenCalledWith(CLIPBOARD_CLEAR_ALARM);250 expect(alarmsCreate).toHaveBeenCalled();251 252 const clearOrder = alarmsClear.mock.invocationCallOrder[0];253 const createOrder = alarmsCreate.mock.invocationCallOrder.at(-1)!;254 expect(clearOrder).toBeLessThan(createOrder);255 });256257 it("HV-2: a failed clear re-arms the alarm and keeps the session hash", async () => {258 259 260 (chrome as unknown as { offscreen: unknown }).offscreen = {261 createDocument: vi.fn(async () => undefined),262 closeDocument: vi.fn(async () => undefined),263 Reason: { CLIPBOARD: "CLIPBOARD" },264 };265 (chrome.runtime as unknown as { sendMessage: unknown }).sendMessage = vi.fn(266 async () => ({ ok: false }),267 );268 clipboardWriteText.mockRejectedValue(new Error("no clipboard access"));269270 const { setupClipboardClearAlarm, CLIPBOARD_CLEAR_ALARM } = await import(271 "./clipboard"272 );273 setupClipboardClearAlarm();274 const clipListener = alarmListeners.at(-1)!;275 alarmsCreate.mockClear();276277 278 session["clipboard-clear-hash"] = "owed";279 clipListener({ name: CLIPBOARD_CLEAR_ALARM });280 await flushMicrotasks();281282 283 expect(alarmsCreate).toHaveBeenCalledWith(284 CLIPBOARD_CLEAR_ALARM,285 expect.objectContaining({ delayInMinutes: expect.any(Number) }),286 );287 288 expect(sessionRemove).not.toHaveBeenCalled();289 });290});