/** * Shared vault setup for the Playwright specs. * * Every spec used to carry its own copy of this flow, and most copies still * filled "password123" — which fails the create-vault gate (12 chars AND * zxcvbn score >= 2), so the button stayed disabled, no vault was created, and * the rest of the spec ran against a locked popup while still reporting green. * setupVault() below asserts it reached the unlocked vault, so that failure * mode is now a loud error instead of a silent pass. */ import { chromium, expect, type BrowserContext, type Page } from "@playwright/test"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; import os from "os"; import fs from "fs/promises"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); export const EXTENSION_PATH = join(__dirname, "..", "..", "dist-chrome"); /** Passes both the 12-character minimum and the zxcvbn strength gate. */ export const VAULT_PASSWORD = "correct horse battery staple"; /** The unlocked vault's tell — present only once the vault view is mounted. */ export const UNLOCKED = ".peach-lock-btn[aria-label='Lock vault']"; export async function makeContext(prefix = "peach"): Promise { const userDataDir = join( os.tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`, ); await fs.mkdir(userDataDir, { recursive: true }); return chromium.launchPersistentContext(userDataDir, { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, headless: false, args: [ `--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`, ], }); } export async function waitForSW(ctx: BrowserContext, maxWait = 15000): Promise { const start = Date.now(); let sw = ctx.serviceWorkers()[0]; while (!sw && Date.now() - start < maxWait) { await new Promise((r) => setTimeout(r, 500)); sw = ctx.serviceWorkers()[0]; } if (!sw) throw new Error(`No SW after ${maxWait}ms`); return new URL(sw.url()).host; } export async function loadPopup(ctx: BrowserContext): Promise<{ popup: Page; extId: string }> { const extId = await waitForSW(ctx); const popup = await ctx.newPage(); await popup.goto(`chrome-extension://${extId}/src/popup/index.html`); await popup.waitForTimeout(3000); return { popup, extId }; } /** Click through the intro carousel until the create-vault form is on screen. */ async function reachCreateScreen(popup: Page): Promise { // Final card's nav button reads "Create vault"; card-body taps are guarded // there, so the carousel only advances via the button. const advanceButton = popup.getByRole("button", { name: /next|get started|create vault/i }); // Create screen uses #master-password; the unlock/lock screen uses // #unlock-password. Keep this one pointed at the create field. const pw = popup.locator("#master-password"); for (let i = 0; i < 5 && !(await pw.isVisible().catch(() => false)); i++) { if (await advanceButton.isVisible({ timeout: 500 }).catch(() => false)) { await advanceButton.click(); await popup.waitForTimeout(300); continue; } await popup.locator(".peach-onboarding-card").click(); await popup.waitForTimeout(300); } if (!(await pw.isVisible().catch(() => false))) { throw new Error("setupVault: never reached the create-vault screen (#master-password)"); } } /** * Create a vault and land in the unlocked view. * * confirmRecovery: false stops on the recovery-words screen instead — only for * specs that assert on that screen itself. */ export async function setupVault( ctx: BrowserContext, password = VAULT_PASSWORD, opts: { confirmRecovery?: boolean } = {}, ): Promise<{ popup: Page; extId: string }> { const { confirmRecovery = true } = opts; const { popup, extId } = await loadPopup(ctx); await reachCreateScreen(popup); await popup.locator("#master-password").fill(password); await popup.locator("#confirm-password").fill(password); // The submit button's label is replaced by the blocker message while the // gate is unmet ("need 4 more characters"), so locate it by class rather // than by name — and surface that label if it never becomes enabled. const createBtn = popup.locator(".peach-newvault-submit"); if (!(await createBtn.isEnabled().catch(() => false))) { await expect(createBtn, `create-vault gate not met: "${await createBtn.textContent()}"`) .toBeEnabled({ timeout: 5000 }); } await createBtn.click(); // Vault creation runs Argon2id, which can exceed a few seconds under load — // give the recovery display room so this doesn't flake. await expect(popup.locator(".lock-screen--recovery-display")).toBeVisible({ timeout: 30000 }); if (confirmRecovery) await completeRecoveryConfirmation(popup); return { popup, extId }; } export async function completeRecoveryConfirmation(popup: Page): Promise { const recoveryWords = popup.locator(".peach-recovery-word-text"); const confirmInputs = popup.locator(".peach-recovery-confirm-input"); const confirmButton = popup.getByRole("button", { name: /i wrote them down/i }); await expect(confirmInputs).toHaveCount(3); // The confirmation inputs stay disabled until the 12 words render, which // is slow when several browsers derive Argon2 at once. await expect(confirmInputs.first()).toBeEnabled({ timeout: 30000 }); const words = await recoveryWords.allTextContents(); // The three positions are randomized per mount, so read each field's // "word N" label rather than assuming fixed slots. const fields = popup.locator(".peach-recovery-confirm-field"); const fieldCount = await fields.count(); if (fieldCount !== 3) throw new Error(`expected 3 confirm fields, saw ${fieldCount}`); for (let i = 0; i < fieldCount; i++) { const field = fields.nth(i); const numText = await field.locator(".peach-recovery-confirm-num").innerText(); const pos = parseInt(numText.replace(/\D/g, ""), 10); const word = words[pos - 1]?.trim(); if (!word) throw new Error(`missing recovery word for position ${pos}`); await field.locator(".peach-recovery-confirm-input").fill(word); } await expect(confirmButton).toBeEnabled({ timeout: 2000 }); await confirmButton.click(); await expect( popup.locator(UNLOCKED), "setupVault: recovery confirmation did not land in the unlocked vault", ).toBeVisible({ timeout: 10000 }); } export async function lockVault(popup: Page): Promise { const lockBtn = popup.locator(UNLOCKED); await expect(lockBtn).toBeVisible({ timeout: 15000 }); await lockBtn.click(); await expect(popup.locator("#unlock-password")).toBeVisible({ timeout: 15000 }); } export async function unlockVault(popup: Page, password = VAULT_PASSWORD): Promise { await popup.locator("#unlock-password").fill(password); await popup.getByRole("button", { name: /unlock/i }).click(); await expect(popup.locator(UNLOCKED)).toBeVisible({ timeout: 20000 }); } export async function openSettings(popup: Page): Promise { await popup.locator(".peach-icon-btn[aria-label='Settings']").click(); await expect(popup.locator(".stk-shell")).toBeVisible({ timeout: 10000 }); } /** * Open the entry list. The top search bar is readOnly — clicking it is what * mounts the list overlay, which is where the real search input and the * new-entry button live. */ export async function openList(popup: Page): Promise { if (await popup.locator(".peach-overlay-view .peach-search-input").isVisible().catch(() => false)) return; await popup.locator(".peach-search-wrap .peach-search-input").click(); await expect(popup.locator(".peach-overlay-view .peach-search-input")).toBeVisible({ timeout: 5000 }); } /** The editable search field, inside the list overlay. */ export function listSearch(popup: Page) { return popup.locator(".peach-overlay-view .peach-search-input"); } /** * Fill and submit the new-entry form. Selectors follow Add.tsx: the title field * is .peach-new-title-input (it was .peach-new-name-input in an older UI, which * is why the specs that still use that class fail to find it). */ export async function createEntryViaForm( popup: Page, entry: { title: string; url?: string; username?: string; password?: string; totp?: string; notes?: string }, ): Promise { await openList(popup); await popup.locator(".peach-new-entry-row").click(); await popup.locator(".peach-new-title-input").fill(entry.title); if (entry.url) await popup.locator(".peach-domain-value--edit").fill(entry.url); if (entry.username) await popup.locator('input[placeholder="johndoe"]').fill(entry.username); if (entry.password) await popup.locator('input[placeholder="password"]').fill(entry.password); // 2FA and notes are behind "+ add" links until they hold a value. if (entry.totp) { await popup.getByRole("button", { name: /add 2FA/i }).click(); await popup.locator('input[placeholder*="BASE32" i]').fill(entry.totp); } if (entry.notes) { await popup.getByRole("button", { name: /add notes/i }).click(); await popup.locator(".peach-new-textarea").fill(entry.notes); } const create = popup.getByRole("button", { name: /^create$/i }); await expect(create, "create button stayed disabled — the entry form rejected the input").toBeEnabled(); await create.click(); await expect(popup.locator(".peach-new-title-input")).toHaveCount(0, { timeout: 10000 }); } /** Add an entry through the background message API (faster than the new-entry form). */ export async function addEntry( popup: Page, entry: { title: string; url?: string; username?: string; password?: string; totp?: string }, ): Promise { const result = await popup.evaluate(async (payload) => { return new Promise<{ ok?: boolean; error?: string }>((resolve) => { chrome.runtime.sendMessage({ type: "ADD_ENTRY", payload: { type: "login", ...payload } }, resolve); }); }, entry); // A locked vault answers { ok: false, error: "vault_locked" } — without this // check the entry silently never exists and the spec asserts nothing. if (!result?.ok) throw new Error(`addEntry(${entry.title}) failed: ${result?.error ?? "no response"}`); }