vault.ts

9.6 KB · raw

1/**2 * Shared vault setup for the Playwright specs.3 *4 * Every spec used to carry its own copy of this flow, and most copies still5 * filled "password123" — which fails the create-vault gate (12 chars AND6 * zxcvbn score >= 2), so the button stayed disabled, no vault was created, and7 * the rest of the spec ran against a locked popup while still reporting green.8 * setupVault() below asserts it reached the unlocked vault, so that failure9 * mode is now a loud error instead of a silent pass.10 */11import { expect, type BrowserContext, type Page } from "@playwright/test";12import { fileURLToPath } from "url";13import { dirname, join } from "path";14import os from "os";15import fs from "fs/promises";1617const __filename = fileURLToPath(import.meta.url);18const __dirname = dirname(__filename);19export const EXTENSION_PATH = join(__dirname, "..", "..", "dist");2021/** Passes both the 12-character minimum and the zxcvbn strength gate. */22export const VAULT_PASSWORD = "correct horse battery staple";2324/** The unlocked vault's tell — present only once the vault view is mounted. */25export const UNLOCKED = ".peach-lock-btn[aria-label='Lock vault']";2627export async function makeContext(prefix = "peach"): Promise<BrowserContext> {28  const { chromium } = await import("playwright");29  const userDataDir = join(30    os.tmpdir(),31    `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`,32  );33  await fs.mkdir(userDataDir, { recursive: true });34  return chromium.launchPersistentContext(userDataDir, {35    headless: false,36    args: [37      `--load-extension=${EXTENSION_PATH}`,38      `--disable-extensions-except=${EXTENSION_PATH}`,39    ],40  });41}4243export async function waitForSW(ctx: BrowserContext, maxWait = 15000): Promise<string> {44  const start = Date.now();45  let sw = ctx.serviceWorkers()[0];46  while (!sw && Date.now() - start < maxWait) {47    await new Promise((r) => setTimeout(r, 500));48    sw = ctx.serviceWorkers()[0];49  }50  if (!sw) throw new Error(`No SW after ${maxWait}ms`);51  return new URL(sw.url()).host;52}5354export async function loadPopup(ctx: BrowserContext): Promise<{ popup: Page; extId: string }> {55  const extId = await waitForSW(ctx);56  const popup = await ctx.newPage();57  await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);58  await popup.waitForTimeout(3000);59  return { popup, extId };60}6162/** Click through the intro carousel until the create-vault form is on screen. */63async function reachCreateScreen(popup: Page): Promise<void> {64  // Final card's nav button reads "Create vault"; card-body taps are guarded65  // there, so the carousel only advances via the button.66  const advanceButton = popup.getByRole("button", { name: /next|get started|create vault/i });67  // Create screen uses #master-password; the unlock/lock screen uses68  // #unlock-password. Keep this one pointed at the create field.69  const pw = popup.locator("#master-password");70  for (let i = 0; i < 5 && !(await pw.isVisible().catch(() => false)); i++) {71    if (await advanceButton.isVisible({ timeout: 500 }).catch(() => false)) {72      await advanceButton.click();73      await popup.waitForTimeout(300);74      continue;75    }76    await popup.locator(".peach-onboarding-card").click();77    await popup.waitForTimeout(300);78  }79  if (!(await pw.isVisible().catch(() => false))) {80    throw new Error("setupVault: never reached the create-vault screen (#master-password)");81  }82}8384/**85 * Create a vault and land in the unlocked view.86 *87 * confirmRecovery: false stops on the recovery-words screen instead — only for88 * specs that assert on that screen itself.89 */90export async function setupVault(91  ctx: BrowserContext,92  password = VAULT_PASSWORD,93  opts: { confirmRecovery?: boolean } = {},94): Promise<{ popup: Page; extId: string }> {95  const { confirmRecovery = true } = opts;96  const { popup, extId } = await loadPopup(ctx);97  await reachCreateScreen(popup);98  await popup.locator("#master-password").fill(password);99  await popup.locator("#confirm-password").fill(password);100  // The submit button's label is replaced by the blocker message while the101  // gate is unmet ("need 4 more characters"), so locate it by class rather102  // than by name — and surface that label if it never becomes enabled.103  const createBtn = popup.locator(".peach-newvault-submit");104  if (!(await createBtn.isEnabled().catch(() => false))) {105    await expect(createBtn, `create-vault gate not met: "${await createBtn.textContent()}"`)106      .toBeEnabled({ timeout: 5000 });107  }108  await createBtn.click();109  // Vault creation runs Argon2id, which can exceed a few seconds under load —110  // give the recovery display room so this doesn't flake.111  await expect(popup.locator(".lock-screen--recovery-display")).toBeVisible({ timeout: 30000 });112  if (confirmRecovery) await completeRecoveryConfirmation(popup);113  return { popup, extId };114}115116export async function completeRecoveryConfirmation(popup: Page): Promise<void> {117  const recoveryWords = popup.locator(".peach-recovery-word-text");118  const confirmInputs = popup.locator(".peach-recovery-confirm-input");119  const confirmButton = popup.getByRole("button", { name: /i wrote them down/i });120121  await expect(confirmInputs).toHaveCount(3);122  // The confirmation inputs stay disabled until the 12 words render, which123  // is slow when several browsers derive Argon2 at once.124  await expect(confirmInputs.first()).toBeEnabled({ timeout: 30000 });125126  const words = await recoveryWords.allTextContents();127  const confirmedWords = [words[2], words[6], words[10]];128  for (let i = 0; i < confirmedWords.length; i++) {129    const word = confirmedWords[i]?.trim();130    if (!word) throw new Error(`missing recovery word at position ${i}`);131    await confirmInputs.nth(i).fill(word);132  }133134  await expect(confirmButton).toBeEnabled({ timeout: 2000 });135  await confirmButton.click();136  await expect(137    popup.locator(UNLOCKED),138    "setupVault: recovery confirmation did not land in the unlocked vault",139  ).toBeVisible({ timeout: 10000 });140}141142export async function lockVault(popup: Page): Promise<void> {143  const lockBtn = popup.locator(UNLOCKED);144  await expect(lockBtn).toBeVisible({ timeout: 15000 });145  await lockBtn.click();146  await expect(popup.locator("#unlock-password")).toBeVisible({ timeout: 15000 });147}148149export async function unlockVault(popup: Page, password = VAULT_PASSWORD): Promise<void> {150  await popup.locator("#unlock-password").fill(password);151  await popup.getByRole("button", { name: /unlock/i }).click();152  await expect(popup.locator(UNLOCKED)).toBeVisible({ timeout: 20000 });153}154155export async function openSettings(popup: Page): Promise<void> {156  await popup.locator(".peach-icon-btn[aria-label='Settings']").click();157  await expect(popup.locator(".stk-shell")).toBeVisible({ timeout: 10000 });158}159160/**161 * Open the entry list. The top search bar is readOnly — clicking it is what162 * mounts the list overlay, which is where the real search input and the163 * new-entry button live.164 */165export async function openList(popup: Page): Promise<void> {166  if (await popup.locator(".peach-overlay-view .peach-search-input").isVisible().catch(() => false)) return;167  await popup.locator(".peach-search-wrap .peach-search-input").click();168  await expect(popup.locator(".peach-overlay-view .peach-search-input")).toBeVisible({ timeout: 5000 });169}170171/** The editable search field, inside the list overlay. */172export function listSearch(popup: Page) {173  return popup.locator(".peach-overlay-view .peach-search-input");174}175176/**177 * Fill and submit the new-entry form. Selectors follow Add.tsx: the title field178 * is .peach-new-title-input (it was .peach-new-name-input in an older UI, which179 * is why the specs that still use that class fail to find it).180 */181export async function createEntryViaForm(182  popup: Page,183  entry: { title: string; url?: string; username?: string; password?: string; totp?: string; notes?: string },184): Promise<void> {185  await openList(popup);186  await popup.locator(".peach-new-entry-row").click();187  await popup.locator(".peach-new-title-input").fill(entry.title);188  if (entry.url) await popup.locator(".peach-domain-value--edit").fill(entry.url);189  if (entry.username) await popup.locator('input[placeholder="johndoe"]').fill(entry.username);190  if (entry.password) await popup.locator('input[placeholder="password"]').fill(entry.password);191  // 2FA and notes are behind "+ add" links until they hold a value.192  if (entry.totp) {193    await popup.getByRole("button", { name: /add 2FA/i }).click();194    await popup.locator('input[placeholder*="BASE32" i]').fill(entry.totp);195  }196  if (entry.notes) {197    await popup.getByRole("button", { name: /add notes/i }).click();198    await popup.locator(".peach-new-textarea").fill(entry.notes);199  }200  const create = popup.getByRole("button", { name: /^create$/i });201  await expect(create, "create button stayed disabled — the entry form rejected the input").toBeEnabled();202  await create.click();203  await expect(popup.locator(".peach-new-title-input")).toHaveCount(0, { timeout: 10000 });204}205206/** Add an entry through the background message API (faster than the new-entry form). */207export async function addEntry(208  popup: Page,209  entry: { title: string; url?: string; username?: string; password?: string; totp?: string },210): Promise<void> {211  const result = await popup.evaluate(async (payload) => {212    return new Promise<{ ok?: boolean; error?: string }>((resolve) => {213      chrome.runtime.sendMessage({ type: "ADD_ENTRY", payload: { type: "login", ...payload } }, resolve);214    });215  }, entry);216  // A locked vault answers { ok: false, error: "vault_locked" } — without this217  // check the entry silently never exists and the spec asserts nothing.218  if (!result?.ok) throw new Error(`addEntry(${entry.title}) failed: ${result?.error ?? "no response"}`);219}