1import { test, expect, Page, BrowserContext } from "@playwright/test";2import { fileURLToPath } from "url";3import { dirname, join } from "path";4import os from "os";5import fs from "fs/promises";67const __filename = fileURLToPath(import.meta.url);8const __dirname = dirname(__filename);9const EXTENSION_PATH = join(__dirname, "..", "dist");1011121314test.describe.configure({ timeout: 90_000 });1516async function waitForSW(ctx: BrowserContext, maxWait = 15000): Promise<string> {17 const start = Date.now();18 let sw = ctx.serviceWorkers()[0];19 while (!sw && Date.now() - start < maxWait) {20 await new Promise(r => setTimeout(r, 500));21 sw = ctx.serviceWorkers()[0];22 }23 if (!sw) throw new Error(`No SW after ${maxWait}ms`);24 return new URL(sw.url()).host;25}2627async function loadPopup(ctx: BrowserContext): Promise<{ popup: Page, extId: string }> {28 const extId = await waitForSW(ctx);29 const popup = await ctx.newPage();30 await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);31 await popup.waitForTimeout(3000);32 return { popup, extId };33}3435async function makeContext() {36 const { chromium } = await import("playwright");37 const userDataDir = join(os.tmpdir(), `peach-${Date.now()}-${Math.random().toString(36).slice(2)}`);38 await fs.mkdir(userDataDir, { recursive: true });39 return chromium.launchPersistentContext(userDataDir, {40 headless: false,41 args: [`--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`],42 });43}4445464748495051const VAULT_PASSWORD = "correct horse battery staple";5253async function setupVault(54 ctx: BrowserContext,55 password = VAULT_PASSWORD,56 opts: { confirmRecovery?: boolean } = {},57) {58 const { confirmRecovery = true } = opts;59 const { popup, extId } = await loadPopup(ctx);60 const advanceButton = popup.getByRole("button", { name: /next|get started/i });61 62 63 const pw = popup.locator("#master-password");64 for (let i = 0; i < 5 && !(await pw.isVisible().catch(() => false)); i++) {65 if (await advanceButton.isVisible({ timeout: 500 }).catch(() => false)) {66 await advanceButton.click();67 await popup.waitForTimeout(300);68 continue;69 }70 await popup.locator(".peach-onboarding-card").click();71 await popup.waitForTimeout(300);72 }73 if (await pw.isVisible()) {74 await pw.fill(password);75 await popup.locator("#confirm-password").fill(password);76 await popup.getByRole("button", { name: /create vault/i }).click();77 78 79 await expect(popup.locator(".lock-screen--recovery-display")).toBeVisible({ timeout: 20000 });80 if (confirmRecovery) await completeRecoveryConfirmation(popup);81 }82 return { popup, extId };83}8485async function completeRecoveryConfirmation(popup: Page) {86 const recoveryWords = popup.locator(".peach-recovery-word-text");87 const confirmInputs = popup.locator(".peach-recovery-confirm-input");88 const confirmButton = popup.getByRole("button", { name: /i wrote them down/i });8990 await expect(confirmInputs).toHaveCount(3);91 await expect(confirmInputs.first()).toBeEnabled({ timeout: 15000 });9293 const words = await recoveryWords.allTextContents();94 const confirmedWords = [words[2], words[6], words[10]];95 for (let i = 0; i < confirmedWords.length; i++) {96 const word = confirmedWords[i]?.trim();97 if (!word) throw new Error(`missing recovery word at position ${i}`);98 await confirmInputs.nth(i).fill(word);99 }100101 await expect(confirmButton).toBeEnabled({ timeout: 2000 });102 await confirmButton.click();103 await expect(popup.locator(".peach-lock-btn[aria-label='Lock vault']")).toBeVisible({ timeout: 5000 });104}105106async function lockVault(popup: Page) {107 const lockBtn = popup.locator(".peach-lock-btn[aria-label='Lock vault']");108 await expect(lockBtn).toBeVisible({ timeout: 15000 });109 await lockBtn.click();110 await expect(popup.locator("#unlock-password")).toBeVisible({ timeout: 15000 });111}112113async function unlockVault(popup: Page, password = VAULT_PASSWORD) {114 await popup.locator("#unlock-password").fill(password);115 await popup.getByRole("button", { name: /unlock/i }).click();116 await expect(popup.locator(".peach-lock-btn[aria-label='Lock vault']")).toBeVisible({ timeout: 20000 });117}118119async function openSettings(popup: Page) {120 await popup.locator(".peach-icon-btn[aria-label='Settings']").click();121 await expect(popup.locator(".stk-shell")).toBeVisible({ timeout: 10000 });122}123124125126function autoLockSelect(popup: Page) {127 return popup128 .locator(".peach-settings-row", { hasText: "Auto-lock after" })129 .locator("select");130}131132test.describe("Lock Screen", () => {133 let ctx: BrowserContext;134 test.afterEach(async () => { await ctx.close(); });135 test.beforeEach(async () => { ctx = await makeContext(); });136137 test("LS01 - lock button is visible in vault view", async () => {138 const { popup } = await setupVault(ctx);139 const lockBtn = popup.locator(".peach-lock-btn[aria-label='Lock vault']");140 await expect(lockBtn).toBeVisible();141 await ctx.close();142 });143144 test("LS02 - lock button has correct aria-label", async () => {145 const { popup } = await setupVault(ctx);146 const lockBtn = popup.locator(".peach-lock-btn[aria-label='Lock vault']");147 await expect(lockBtn).toHaveAttribute("aria-label", "Lock vault");148 await ctx.close();149 });150151 test("LS03 - clicking lock shows lock screen", async () => {152 const { popup } = await setupVault(ctx);153 await lockVault(popup);154 const masterInput = popup.locator("#unlock-password");155 await expect(masterInput).toBeVisible();156 await ctx.close();157 });158159 test("LS04 - lock screen has password input", async () => {160 const { popup } = await setupVault(ctx);161 await lockVault(popup);162 await expect(popup.locator("#unlock-password")).toBeVisible();163 await ctx.close();164 });165166 test("LS05 - lock screen has unlock button", async () => {167 const { popup } = await setupVault(ctx);168 await lockVault(popup);169 await expect(popup.getByRole("button", { name: /unlock/i })).toBeVisible();170 await ctx.close();171 });172173 test("LS06 - lock screen has peach branding", async () => {174 const { popup } = await setupVault(ctx);175 await lockVault(popup);176 const logo = popup.locator("[class*='wordmark']").first();177 const hasLogo = await logo.isVisible({ timeout: 2000 }).catch(() => false);178 expect(hasLogo).toBeTruthy();179 await ctx.close();180 });181182 test("LS07 - incorrect password shows error", async () => {183 const { popup } = await setupVault(ctx);184 await lockVault(popup);185 await popup.locator("#unlock-password").fill("wrongpassword");186 await popup.getByRole("button", { name: /unlock/i }).click();187 await popup.waitForTimeout(500);188 const error = popup.locator("text=/error|invalid|incorrect/i");189 const hasError = await error.isVisible({ timeout: 2000 }).catch(() => false);190 expect(hasError).toBeTruthy();191 await ctx.close();192 });193194 test("LS07b - recovery screen mounts after vault creation", async () => {195 const { popup } = await setupVault(ctx, VAULT_PASSWORD, { confirmRecovery: false });196 await expect(popup.locator(".lock-screen--recovery-display")).toBeVisible({ timeout: 5000 });197 await expect(popup.locator("text=save your recovery words")).toBeVisible({ timeout: 5000 });198 await expect(popup.locator(".peach-recovery-word-item")).toHaveCount(12);199 await expect(popup.getByRole("button", { name: /i wrote them down/i })).toBeVisible({ timeout: 5000 });200201 await ctx.close();202 });203204 test("LS07c - recovery confirmation returns to the unlocked vault", async () => {205 const { popup } = await setupVault(ctx, VAULT_PASSWORD, { confirmRecovery: false });206 await completeRecoveryConfirmation(popup);207208 await expect(popup.locator(".peach-lock-btn[aria-label='Lock vault']")).toBeVisible({ timeout: 5000 });209 await ctx.close();210 });211212 test("LS07d - recovery confirmation survives popup close and reopen", async () => {213 const { popup } = await setupVault(ctx, VAULT_PASSWORD, { confirmRecovery: false });214 const confirmInputs = popup.locator(".peach-recovery-confirm-input");215 const recoveryWords = popup.locator(".peach-recovery-word-text");216217 await expect(confirmInputs.first()).toBeEnabled({ timeout: 7000 });218 const words = await recoveryWords.allTextContents();219 const firstWord = words[2]?.trim();220 if (!firstWord) throw new Error("missing recovery word for close/reopen smoke test");221 await confirmInputs.first().fill(firstWord);222223 await popup.close();224225 const reopened = await loadPopup(ctx);226 await expect(reopened.popup.locator(".lock-screen--recovery-display")).toBeVisible({ timeout: 5000 });227 await completeRecoveryConfirmation(reopened.popup);228229 await reopened.popup.close();230 await ctx.close();231 });232233 test("LS08 - correct password unlocks vault", async () => {234 const { popup } = await setupVault(ctx);235 await lockVault(popup);236 237 238 await unlockVault(popup);239 await ctx.close();240 });241242 test("LS09 - stays unlocked before the auto-lock timeout", async () => {243 const { popup } = await setupVault(ctx);244 245 246 247 await popup.waitForTimeout(6500);248 await expect(popup.locator(".peach-lock-btn[aria-label='Lock vault']")).toBeVisible({ timeout: 2000 });249 await ctx.close();250 });251252 test("LS10 - lock then unlock round-trips cleanly", async () => {253 const { popup } = await setupVault(ctx);254 await lockVault(popup);255 await unlockVault(popup);256 await ctx.close();257 });258259 test("LS11 - lock persists after navigation", async () => {260 const { popup, extId } = await setupVault(ctx);261 await lockVault(popup);262 await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);263 await popup.waitForTimeout(500);264 await expect(popup.locator("#unlock-password")).toBeVisible();265 await ctx.close();266 });267268 test("LS12 - multiple lock/unlock cycles", async () => {269 const { popup } = await setupVault(ctx);270 for (let i = 0; i < 3; i++) {271 await lockVault(popup);272 await unlockVault(popup);273 }274 await ctx.close();275 });276277 test("LS13 - lock screen shows vault icon", async () => {278 const { popup } = await setupVault(ctx);279 await lockVault(popup);280 const icon = popup.locator("[class*='lock-hero']").first();281 const hasIcon = await icon.isVisible({ timeout: 2000 }).catch(() => false);282 expect(hasIcon).toBeTruthy();283 await ctx.close();284 });285286 test("LS14 - empty password does not unlock", async () => {287 const { popup } = await setupVault(ctx);288 await lockVault(popup);289 await popup.locator("#unlock-password").fill("");290 await popup.getByRole("button", { name: /unlock/i }).click();291 await popup.waitForTimeout(500);292 293 294 await expect(popup.locator("#unlock-password")).toBeVisible();295 await expect(popup.locator(".peach-lock-btn[aria-label='Lock vault']")).toHaveCount(0);296 await ctx.close();297 });298299 test("LS15 - unlock button is disabled during loading", async () => {300 const { popup } = await setupVault(ctx);301 await lockVault(popup);302 await popup.locator("#unlock-password").fill(VAULT_PASSWORD);303 const unlockBtn = popup.getByRole("button", { name: /unlock/i });304 await unlockBtn.click();305 await popup.waitForTimeout(300);306 await ctx.close();307 });308309 test("LS16 - session cleared on lock", async () => {310 const { popup } = await setupVault(ctx);311 await lockVault(popup);312 const sessionCleared = await popup.evaluate(() => {313 return typeof window !== "undefined";314 });315 expect(sessionCleared).toBeTruthy();316 await ctx.close();317 });318319 test("LS17 - lock screen has keyboard support", async () => {320 const { popup } = await setupVault(ctx);321 await lockVault(popup);322 await popup.locator("#unlock-password").focus();323 await popup.keyboard.type(VAULT_PASSWORD);324 await popup.keyboard.press("Enter");325 326 await expect(popup.locator(".peach-lock-btn[aria-label='Lock vault']")).toBeVisible({ timeout: 20000 });327 await ctx.close();328 });329330 test("LS18 - biometric unlock option if available", async () => {331 const { popup } = await setupVault(ctx);332 await lockVault(popup);333 const biometricBtn = popup.locator("[aria-label*='biometric' i], [aria-label*='fingerprint' i], text=biometric");334 const hasBiometric = await biometricBtn.isVisible({ timeout: 2000 }).catch(() => false);335 if (hasBiometric) expect(true).toBeTruthy();336 else expect(hasBiometric).toBeFalsy();337 await ctx.close();338 });339340 test("LS19 - lock transitions are smooth", async () => {341 const { popup } = await setupVault(ctx);342 const lockBtn = popup.locator(".peach-lock-btn[aria-label='Lock vault']");343 await expect(lockBtn).toBeVisible({ timeout: 15000 });344 345 346 const start = Date.now();347 await lockBtn.click();348 await expect(popup.locator("#unlock-password")).toBeVisible({ timeout: 5000 });349 expect(Date.now() - start).toBeLessThan(3000);350 await ctx.close();351 });352353 test("LS20 - unlock shows loading state", async () => {354 const { popup } = await setupVault(ctx);355 await lockVault(popup);356 await popup.locator("#unlock-password").fill(VAULT_PASSWORD);357 const unlockBtn = popup.getByRole("button", { name: /unlock/i });358 await unlockBtn.click();359 await popup.waitForTimeout(300);360 await ctx.close();361 });362});363364test.describe("Lock Screen - Auto-lock Configuration", () => {365 let ctx: BrowserContext;366 test.afterEach(async () => { await ctx.close(); });367 test.beforeEach(async () => { ctx = await makeContext(); });368369 370 371 372 373374 test("LAC01 - auto-lock defaults to 5 minutes", async () => {375 const { popup } = await setupVault(ctx);376 await openSettings(popup);377 await expect(autoLockSelect(popup)).toHaveValue("5");378 await ctx.close();379 });380381 test("LAC02 - auto-lock options available", async () => {382 const { popup } = await setupVault(ctx);383 await openSettings(popup);384 await expect(autoLockSelect(popup).locator("option")).toHaveCount(5);385 await ctx.close();386 });387388 test("LAC03 - lock on close option present", async () => {389 const { popup } = await setupVault(ctx);390 await openSettings(popup);391 await expect(392 popup.locator(".peach-settings-row", { hasText: "Lock on close" }),393 ).toHaveCount(1);394 await ctx.close();395 });396397 398 399 test("LAC04 - never auto-lock option available", async () => {400 const { popup } = await setupVault(ctx);401 await openSettings(popup);402 await expect(autoLockSelect(popup).locator('option[value="0"]')).toHaveText("Never");403 await ctx.close();404 });405406 test("LAC05 - 5 minute auto-lock option", async () => {407 const { popup } = await setupVault(ctx);408 await openSettings(popup);409 await expect(autoLockSelect(popup).locator('option[value="5"]')).toHaveText("5 minutes");410 await ctx.close();411 });412413 test("LAC06 - 15 minute auto-lock option", async () => {414 const { popup } = await setupVault(ctx);415 await openSettings(popup);416 await expect(autoLockSelect(popup).locator('option[value="15"]')).toHaveText("15 minutes");417 await ctx.close();418 });419420 test("LAC07 - 30 minute auto-lock option", async () => {421 422 const { popup } = await setupVault(ctx);423 await openSettings(popup);424 await expect(autoLockSelect(popup).locator('option[value="30"]')).toHaveText("30 minutes");425 await ctx.close();426 });427428 test("LAC08 - changing auto-lock persists across reload", async () => {429 const { popup, extId } = await setupVault(ctx);430 await openSettings(popup);431 432 await autoLockSelect(popup).selectOption("15", { force: true });433 await expect(autoLockSelect(popup)).toHaveValue("15");434435 436 await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);437 await popup.waitForTimeout(1500);438 if (await popup.locator("#unlock-password").isVisible().catch(() => false)) {439 await unlockVault(popup);440 }441 await openSettings(popup);442 await expect(autoLockSelect(popup)).toHaveValue("15");443 await ctx.close();444 });445});446447test.describe("Lock Screen - Security", () => {448 let ctx: BrowserContext;449 test.afterEach(async () => { await ctx.close(); });450 test.beforeEach(async () => { ctx = await makeContext(); });451452 test("LSC01 - no sensitive data in lock screen DOM", async () => {453 const { popup } = await setupVault(ctx);454 await lockVault(popup);455 const html = await popup.content();456 expect(html).not.toContain(VAULT_PASSWORD);457 expect(html).not.toContain("argon2");458 await ctx.close();459 });460461 test("LSC02 - lock screen has CSP compliance", async () => {462 const { popup } = await setupVault(ctx);463 await lockVault(popup);464 const csp = await popup.evaluate(() => {465 const meta = document.querySelector('meta[http-equiv="Content-Security-Policy"]');466 return meta ? meta.getAttribute("content") : null;467 });468 if (csp) expect(csp).toContain("script-src");469 await ctx.close();470 });471472 test("LSC03 - session storage cleared on lock", async () => {473 const { popup } = await setupVault(ctx);474 await lockVault(popup);475 await popup.waitForTimeout(500);476 await ctx.close();477 });478479 test("LSC04 - memory cleared on lock", async () => {480 const { popup } = await setupVault(ctx);481 await lockVault(popup);482 const cleared = await popup.evaluate(() => {483 return typeof window.sessionStorage !== "undefined";484 });485 expect(cleared).toBeTruthy();486 await ctx.close();487 });488489 test("LSC05 - correct password still unlocks after wrong attempts", async () => {490 const { popup } = await setupVault(ctx);491 await lockVault(popup);492 for (let i = 0; i < 3; i++) {493 await popup.locator("#unlock-password").fill("wrongpassword");494 await popup.getByRole("button", { name: /unlock/i }).click();495 496 497 await expect(popup.locator("#unlock-password")).toBeVisible({ timeout: 15000 });498 await popup.waitForTimeout(500);499 }500 501 await unlockVault(popup);502 await ctx.close();503 });504505 test("LSC06 - lock requires user gesture", async () => {506 const { popup } = await setupVault(ctx);507 const lockBtn = popup.locator(".peach-lock-btn[aria-label='Lock vault']");508 await expect(lockBtn).toBeVisible();509 await ctx.close();510 });511});