// @vitest-environment jsdom // // Test File 9 — save.ts save-on-submit detection (T-SV01–T-SV10). // // setupSaveDetection installs a document submit listener that scrapes the // submitted username/password, looks up a matching vault entry // (AUTOFILL_QUERY → GET_ENTRY_FOR_FILL), and shows a save/update banner unless // the credentials already match. The banner itself is a CLOSED shadow root, and // its Save/Update buttons (which send ADD_ENTRY/UPDATE_ENTRY) are unreachable // from outside — so those acceptance flows are routed to the e2e. What we pin // here is the decision: does the banner mount, and is the lookup correct? import "./_setup"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { setupSaveDetection } from "./save"; import { resetChromeMock, setMessageResponder, sentMessages, makeInput } from "./_setup"; // Mounted save/update banner host (top-right, max z-index, closed shadow). function bannerHost(): HTMLElement | null { return document.body.querySelector('div[style*="2147483647"]'); } // Build and submit a form with the given fields; returns after the async // save-detection chain settles. async function submitForm(fields: { user?: string; pass?: string; type?: string }): Promise { const form = document.createElement("form"); document.body.appendChild(form); if (fields.user !== undefined) { const u = makeInput({ type: "text", name: "username", autocomplete: "username" }, form); u.value = fields.user; } if (fields.pass !== undefined) { const p = makeInput({ type: fields.type ?? "password", name: "password" }, form); p.value = fields.pass; } form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); await new Promise((r) => setTimeout(r, 30)); // let the AUTOFILL_QUERY/GET_ENTRY roundtrip settle } beforeAll(() => { setupSaveDetection(); // one document submit listener for the whole file }); beforeEach(() => { document.body.innerHTML = ""; resetChromeMock(); }); afterEach(() => { document.body.innerHTML = ""; }); describe("T-SV01, T-SV03: banner mounts for new and changed credentials", () => { // T-SV01: submitting credentials with no matching vault entry → save banner. it("T-SV01: new credentials → save banner mounts", async () => { setMessageResponder((msg) => (msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [] } : { ok: true, data: null })); await submitForm({ user: "alice", pass: "s3cret" }); expect(bannerHost()).not.toBeNull(); }); // T-SV03: submitting a changed password for an existing entry → update banner. it("T-SV03: changed password for an existing entry → banner mounts (update)", async () => { setMessageResponder((msg) => { if (msg.type === "AUTOFILL_QUERY") return { ok: true, data: [{ id: "e1" }] }; if (msg.type === "GET_ENTRY_FOR_FILL") return { ok: true, data: { id: "e1", title: "Acme", password: "old-password" } }; return { ok: true, data: null }; }); await submitForm({ user: "alice", pass: "new-password" }); expect(bannerHost()).not.toBeNull(); }); }); describe("T-SV02, T-SV04, T-SV05: no banner cases", () => { // T-SV02: the submitted password already matches the saved entry → no banner. it("T-SV02: credentials already saved (password matches) → no banner", async () => { setMessageResponder((msg) => { if (msg.type === "AUTOFILL_QUERY") return { ok: true, data: [{ id: "e1" }] }; if (msg.type === "GET_ENTRY_FOR_FILL") return { ok: true, data: { id: "e1", title: "Acme", password: "s3cret" } }; return { ok: true, data: null }; }); await submitForm({ user: "alice", pass: "s3cret" }); expect(bannerHost()).toBeNull(); }); // T-SV04: a search form has no password field — save detection ignores it and // never even runs the vault lookup. it("T-SV04: search form (no password) → no banner, no vault lookup", async () => { setMessageResponder(() => ({ ok: true, data: [] })); const form = document.createElement("form"); document.body.appendChild(form); const q = makeInput({ type: "search", name: "q" }, form); q.value = "kittens"; form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); await new Promise((r) => setTimeout(r, 30)); expect(bannerHost()).toBeNull(); expect(sentMessages.some((m) => m.type === "AUTOFILL_QUERY")).toBe(false); }); // T-SV05: an empty password short-circuits before any lookup. it("T-SV05: empty password → no banner, no vault lookup", async () => { setMessageResponder(() => ({ ok: true, data: [] })); await submitForm({ user: "alice", pass: "" }); expect(bannerHost()).toBeNull(); expect(sentMessages.some((m) => m.type === "AUTOFILL_QUERY")).toBe(false); }); }); describe("T-SV10: domain-matched lookup (MV-1 surface)", () => { // T-SV10: the lookup queries AUTOFILL_QUERY with the page domain, then fetches // the full entry by the returned id via GET_ENTRY_FOR_FILL. it("T-SV10: sends AUTOFILL_QUERY{domain} then GET_ENTRY_FOR_FILL{id}", async () => { setMessageResponder((msg) => { if (msg.type === "AUTOFILL_QUERY") return { ok: true, data: [{ id: "e1" }] }; if (msg.type === "GET_ENTRY_FOR_FILL") return { ok: true, data: { id: "e1", title: "Acme", password: "old" } }; return { ok: true, data: null }; }); await submitForm({ user: "alice", pass: "new" }); const query = sentMessages.find((m) => m.type === "AUTOFILL_QUERY"); expect(query?.payload?.domain).toBeTruthy(); const fetch = sentMessages.find((m) => m.type === "GET_ENTRY_FOR_FILL"); expect(fetch?.payload?.id).toBe("e1"); }); });