1234567891011import { chromium, expect, type BrowserContext, type Page } from "@playwright/test";12import { fileURLToPath } from "node:url";13import { dirname, join } from "node:path";14import os from "node:os";15import fs from "node:fs/promises";1617const __filename = fileURLToPath(import.meta.url);18const __dirname = dirname(__filename);19export const EXTENSION_PATH = join(__dirname, "..", "..", "dist-chrome");2021export const VAULT_PASSWORD = "correct horse battery staple";222324export const UNLOCKED = ".peach-lock-btn[aria-label='Lock vault']";2526export async function makeContext(prefix = "peach"): Promise<BrowserContext> {27 const userDataDir = join(28 os.tmpdir(),29 `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`,30 );31 await fs.mkdir(userDataDir, { recursive: true });32 return chromium.launchPersistentContext(userDataDir, {33 executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,34 headless: process.env.PEACH_RELEASE_BROWSER_GATE === "1",35 args: [36 `--load-extension=${EXTENSION_PATH}`,37 `--disable-extensions-except=${EXTENSION_PATH}`,38 ],39 });40}4142export async function waitForSW(ctx: BrowserContext, maxWait = 15000): Promise<string> {43 const start = Date.now();44 let sw = ctx.serviceWorkers()[0];45 while (!sw && Date.now() - start < maxWait) {46 await new Promise((r) => setTimeout(r, 500));47 sw = ctx.serviceWorkers()[0];48 }49 if (!sw) throw new Error(`No SW after ${maxWait}ms`);50 return new URL(sw.url()).host;51}5253export async function loadPopup(ctx: BrowserContext): Promise<{ popup: Page; extId: string }> {54 const extId = await waitForSW(ctx);55 const popup = await ctx.newPage();56 await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);57 await popup.waitForTimeout(3000);58 return { popup, extId };59}606162export async function reachCreateScreen(popup: Page): Promise<void> {63 const advanceButton = popup.getByRole("button", { name: /next|get started|create vault/i });64 const pw = popup.locator("#master-password");65 for (let i = 0; i < 5 && !(await pw.isVisible().catch(() => false)); i++) {66 await expect(advanceButton).toBeVisible({ timeout: 5000 });67 await advanceButton.click();68 await popup.waitForTimeout(300);69 }70 if (!(await pw.isVisible().catch(() => false))) {71 throw new Error("setupVault: never reached the create-vault screen (#master-password)");72 }73}7475767778798081export async function setupVault(82 ctx: BrowserContext,83 password = VAULT_PASSWORD,84 opts: { confirmRecovery?: boolean } = {},85): Promise<{ popup: Page; extId: string }> {86 const { confirmRecovery = true } = opts;87 const { popup, extId } = await loadPopup(ctx);88 await reachCreateScreen(popup);89 await popup.locator("#master-password").fill(password);90 await popup.locator("#confirm-password").fill(password);91 92 93 94 const createBtn = popup.locator(".peach-newvault-submit");95 if (!(await createBtn.isEnabled().catch(() => false))) {96 await expect(createBtn, `create-vault gate not met: "${await createBtn.textContent()}"`)97 .toBeEnabled({ timeout: 5000 });98 }99 await createBtn.click();100 101 102 await expect(popup.locator(".lock-screen--recovery-display")).toBeVisible({ timeout: 30000 });103 if (confirmRecovery) await completeRecoveryConfirmation(popup);104 return { popup, extId };105}106107export async function completeRecoveryConfirmation(popup: Page): Promise<void> {108 const recoveryWords = popup.locator(".peach-recovery-word-text");109 const confirmInputs = popup.locator(".peach-recovery-confirm-input");110 const confirmButton = popup.getByRole("button", { name: /i wrote them down/i });111112 await expect(confirmInputs).toHaveCount(3);113 114 115 await expect(confirmInputs.first()).toBeEnabled({ timeout: 30000 });116117 const words = await recoveryWords.allTextContents();118 119 120 const fields = popup.locator(".peach-recovery-confirm-field");121 const fieldCount = await fields.count();122 if (fieldCount !== 3) throw new Error(`expected 3 confirm fields, saw ${fieldCount}`);123 for (let i = 0; i < fieldCount; i++) {124 const field = fields.nth(i);125 const numText = await field.locator(".peach-recovery-confirm-num").innerText();126 const pos = parseInt(numText.replace(/\D/g, ""), 10);127 const word = words[pos - 1]?.trim();128 if (!word) throw new Error(`missing recovery word for position ${pos}`);129 await field.locator(".peach-recovery-confirm-input").fill(word);130 }131132 await expect(confirmButton).toBeEnabled({ timeout: 2000 });133 await confirmButton.click();134 await expect(135 popup.locator(UNLOCKED),136 "setupVault: recovery confirmation did not land in the unlocked vault",137 ).toBeVisible({ timeout: 10000 });138}139140export async function lockVault(popup: Page): Promise<void> {141 const lockBtn = popup.locator(UNLOCKED);142 await expect(lockBtn).toBeVisible({ timeout: 15000 });143 await lockBtn.click();144 await expect(popup.locator("#unlock-password")).toBeVisible({ timeout: 15000 });145}146147export async function unlockVault(popup: Page, password = VAULT_PASSWORD): Promise<void> {148 await popup.locator("#unlock-password").fill(password);149 await popup.getByRole("button", { name: /unlock/i }).click();150 await expect(popup.locator(UNLOCKED)).toBeVisible({ timeout: 20000 });151}152153export async function openSettings(popup: Page): Promise<void> {154 await popup.locator(".peach-icon-btn[aria-label='Settings']").click();155 await expect(popup.locator(".stk-shell")).toBeVisible({ timeout: 10000 });156}157158export async function setLockOnClose(popup: Page, enabled: boolean): Promise<void> {159 const response = await popup.evaluate(async (lockOnClose) => {160 return new Promise<{ ok?: boolean; error?: string }>((resolve) => {161 chrome.runtime.sendMessage({ type: "UPDATE_SETTINGS", payload: { lockOnClose } }, resolve);162 });163 }, enabled);164 if (!response?.ok) throw new Error(`setLockOnClose failed: ${response?.error ?? "no response"}`);165}166167export async function waitForLocked(ctx: BrowserContext): Promise<void> {168 await expect.poll(async () => {169 const worker = ctx.serviceWorkers()[0];170 if (!worker) return false;171 return worker.evaluate(async () => {172 const stored = await chrome.storage.session.get("peach_session_dk");173 return stored.peach_session_dk === undefined;174 });175 }).toBe(true);176}177178179180181182183export async function openList(popup: Page): Promise<void> {184 if (await popup.locator(".peach-overlay-view .peach-search-input").isVisible().catch(() => false)) return;185 await popup.locator(".peach-search-wrap .peach-search-input").click();186 await expect(popup.locator(".peach-overlay-view .peach-search-input")).toBeVisible({ timeout: 5000 });187}188189190export function listSearch(popup: Page) {191 return popup.locator(".peach-overlay-view .peach-search-input");192}193194195196197198199export async function createEntryViaForm(200 popup: Page,201 entry: { title: string; url?: string; username?: string; password?: string; totp?: string; notes?: string },202): Promise<void> {203 await openList(popup);204 await popup.locator(".peach-new-entry-row").click();205 await popup.locator(".peach-new-title-input").fill(entry.title);206 if (entry.url) await popup.locator(".peach-domain-value--edit").fill(entry.url);207 if (entry.username) await popup.locator('input[placeholder="johndoe"]').fill(entry.username);208 if (entry.password) await popup.locator('input[placeholder="password"]').fill(entry.password);209 210 if (entry.totp) {211 await popup.getByRole("button", { name: /add 2FA/i }).click();212 await popup.locator('input[placeholder*="BASE32" i]').fill(entry.totp);213 }214 if (entry.notes) {215 await popup.getByRole("button", { name: /add notes/i }).click();216 await popup.locator(".peach-new-textarea").fill(entry.notes);217 }218 const create = popup.getByRole("button", { name: /^create$/i });219 await expect(create, "create button stayed disabled — the entry form rejected the input").toBeEnabled();220 await create.click();221 await expect(popup.locator(".peach-new-title-input")).toHaveCount(0, { timeout: 10000 });222}223224225export async function addEntry(226 popup: Page,227 entry: { title: string; url?: string; username?: string; password?: string; totp?: string },228): Promise<void> {229 const result = await popup.evaluate(async (payload) => {230 return new Promise<{ ok?: boolean; error?: string }>((resolve) => {231 chrome.runtime.sendMessage({ type: "ADD_ENTRY", payload: { type: "login", ...payload } }, resolve);232 });233 }, entry);234 235 236 if (!result?.ok) throw new Error(`addEntry(${entry.title}) failed: ${result?.error ?? "no response"}`);237}