import { test, expect, BrowserContext } 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); const EXTENSION_PATH = join(__dirname, "..", "dist"); async function makeContext() { const { chromium } = await import("playwright"); const userDataDir = join(os.tmpdir(), `peach-vc-${Date.now()}`); await fs.mkdir(userDataDir, { recursive: true }); return chromium.launchPersistentContext(userDataDir, { headless: false, args: [`--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`], }); } 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; } async function setupVault(ctx: BrowserContext): Promise<{ popup: any, 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); const pw = popup.locator("#master-password"); if (await pw.isVisible()) { await pw.fill("password123"); await popup.locator("#confirm-password").fill("password123"); await popup.getByRole("button", { name: /create vault/i }).click(); await popup.waitForTimeout(2000); } return { popup, extId }; } test.describe("Animation & Transition Compliance", () => { let ctx: BrowserContext; test.afterEach(async () => { await ctx.close(); }); test.beforeEach(async () => { ctx = await makeContext(); }); test("VAP01 - lock screen animations use only opacity and transform", async () => { const cssFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f => f.filter(f => f.endsWith(".css")) ); const nonAnimatableProps: string[] = []; for (const file of cssFiles) { const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8"); const keyframes = content.match(/@keyframes[\s\S]*?\{[\s\S]*?\}/g) || []; for (const kf of keyframes) { const props = kf.match(/[\w-]+:\s*[^;]+;/g) || []; for (const prop of props) { const name = prop.split(":")[0].trim(); if (!["opacity", "transform", "color", "background-color", "border-color", "stroke", "fill"].includes(name)) { nonAnimatableProps.push(`${file}: ${name} in @keyframes`); } } } } expect(nonAnimatableProps).toHaveLength(0); await ctx.close(); }); test("VAP02 - spring easing variable used for animations", async () => { const cssFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f => f.filter(f => f.endsWith(".css")) ); const bezierWithoutVar: string[] = []; for (const file of cssFiles) { const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8"); const beziers = content.match(/cubic-bezier\([^)]+\)/g) || []; for (const b of beziers) { if (!content.includes(`--spring`) && !content.includes(`--retreat`) && !content.includes(`--settle`)) { bezierWithoutVar.push(`${file}: ${b}`); } } } expect(bezierWithoutVar).toHaveLength(0); await ctx.close(); }); test("VAP03 - no animation on 'display' property", async () => { const cssFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f => f.filter(f => f.endsWith(".css")) ); const displayAnimations: string[] = []; for (const file of cssFiles) { const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8"); const keyframes = content.match(/@keyframes[\s\S]*?\{[\s\S]*?\}/g) || []; for (const kf of keyframes) { if (kf.includes("display")) { displayAnimations.push(file); } } } expect(displayAnimations).toHaveLength(0); await ctx.close(); }); test("VAP04 - unlock transition is smooth (no jank)", async () => { await setupVault(ctx); const newPage = await ctx.newPage(); const extId = await waitForSW(ctx); await newPage.goto(`chrome-extension://${extId}/src/popup/index.html`); await newPage.waitForTimeout(2000); const start = Date.now(); await newPage.locator("#master-password").fill("password123"); await newPage.getByRole("button", { name: /unlock/i }).click(); await newPage.waitForTimeout(100); const entries = newPage.locator("[class*=entry]"); await entries.first().waitFor({ state: "visible", timeout: 5000 }); const elapsed = Date.now() - start; expect(elapsed).toBeLessThan(3000); await newPage.close(); await ctx.close(); }); test("VAP05 - entry detail open animation is under 300ms", async () => { const { popup } = await setupVault(ctx); await popup.locator('input[placeholder="search vault..."]').click(); await popup.getByRole("button", { name: /new entry/i }).click(); await popup.locator(".peach-new-name-input").fill("AnimTest"); await popup.locator('input[placeholder="domain.com"]').fill("https://anim.com"); await popup.locator('input[placeholder="username or email"]').fill("anim@user.com"); await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass"); await popup.getByRole("button", { name: /create/i }).click(); await popup.waitForTimeout(1500); const start = Date.now(); await popup.locator(".peach-entry-name-button").first().click(); await popup.waitForTimeout(100); const usernameField = popup.locator(".peach-field-value").first(); await usernameField.waitFor({ state: "visible", timeout: 3000 }); const elapsed = Date.now() - start; expect(elapsed).toBeLessThan(800); await ctx.close(); }); }); test.describe("Typography & Layout", () => { let ctx: BrowserContext; test.afterEach(async () => { await ctx.close(); }); test.beforeEach(async () => { ctx = await makeContext(); }); test("VTL01 - font-size consistent in popup", async () => { const { popup } = await setupVault(ctx); const sizes = await popup.evaluate(() => { const elements = document.querySelectorAll("*"); const sizes: number[] = []; elements.forEach(el => { const fs = window.getComputedStyle(el).fontSize; if (fs) sizes.push(parseFloat(fs)); }); return [...new Set(sizes)].slice(0, 20); }); expect(sizes.length).toBeGreaterThan(0); await ctx.close(); }); test("VTL02 - no font loaded from Google CDN", async () => { const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f => f.filter(f => f.endsWith(".css") || f.endsWith(".html")) ); let foundGoogleFont = false; for (const file of distFiles) { const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8").catch(() => ""); const htmlContent = await fs.readFile(join(__dirname, "..", "dist", "src", "popup", "index.html"), "utf-8").catch(() => ""); const combined = content + htmlContent; if (combined.includes("fonts.googleapis.com") || combined.includes("fonts.gstatic.com")) { foundGoogleFont = true; break; } } expect(foundGoogleFont).toBeFalsy(); await ctx.close(); }); test("VTL03 - font-display uses system fonts", async () => { const { popup } = await setupVault(ctx); const fontFamily = await popup.evaluate(() => { return window.getComputedStyle(document.body).fontFamily; }); expect(fontFamily).toBeTruthy(); await ctx.close(); }); test("VTL04 - popup width is fixed at 320px", async () => { const { popup } = await setupVault(ctx); const width = await popup.evaluate(() => document.body?.parentElement?.clientWidth || 0); expect(width).toBe(320); await ctx.close(); }); test("VTL05 - content area has proper padding", async () => { const { popup } = await setupVault(ctx); const contentFrame = popup.locator(".peach-content-frame"); const padding = await contentFrame.evaluate((el: Element) => window.getComputedStyle(el).padding); expect(padding).toBeTruthy(); await ctx.close(); }); test("VTL06 - no horizontal overflow on body", async () => { const { popup } = await setupVault(ctx); const overflow = await popup.evaluate(() => { return window.getComputedStyle(document.body).overflowX; }); expect(overflow).toBe("hidden"); await ctx.close(); }); test("VTL07 - entry list items have consistent height", async () => { const { popup } = await setupVault(ctx); for (const name of ["H1", "H2", "H3"]) { await popup.locator('input[placeholder="search vault..."]').click(); await popup.getByRole("button", { name: /new entry/i }).click(); await popup.locator(".peach-new-name-input").fill(name); await popup.locator('input[placeholder="domain.com"]').fill(`https://${name.toLowerCase()}.com`); await popup.locator('input[placeholder="username or email"]').fill(`${name.toLowerCase()}@user.com`); await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass"); await popup.getByRole("button", { name: /create/i }).click(); await popup.waitForTimeout(800); } const rowHeights = await popup.evaluate(() => { const rows = document.querySelectorAll(".peach-entry-row"); return Array.from(rows).map(r => r.getBoundingClientRect().height); }); const unique = [...new Set(rowHeights)]; expect(unique.length).toBeLessThanOrEqual(2); await ctx.close(); }); test("VTL08 - search bar is always visible", async () => { const { popup } = await setupVault(ctx); const search = popup.locator(".peach-search-bar, .peach-search-input"); await expect(search).toBeVisible(); await ctx.close(); }); }); test.describe("Scrollbar Styling", () => { let ctx: BrowserContext; test.afterEach(async () => { await ctx.close(); }); test.beforeEach(async () => { ctx = await makeContext(); }); test("VSB01 - scrollbar width is 3px or less", async () => { const cssFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f => f.filter(f => f.endsWith(".css")) ); for (const file of cssFiles) { const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8"); const widthMatch = content.match(/::-webkit-scrollbar\s*\{[\s\S]*?width:\s*(\d+)px/); if (widthMatch) { expect(parseInt(widthMatch[1])).toBeLessThanOrEqual(4); } } await ctx.close(); }); test("VSB02 - scrollbar has subtle color (not bright)", async () => { const cssFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f => f.filter(f => f.endsWith(".css")) ); for (const file of cssFiles) { const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8"); if (content.includes("::-webkit-scrollbar")) { const hasVar = content.includes("var(--t"); expect(hasVar).toBeTruthy(); } } await ctx.close(); }); test("VSB03 - scrollbar uses border-radius (rounded)", async () => { const cssFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f => f.filter(f => f.endsWith(".css")) ); for (const file of cssFiles) { const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8"); if (content.includes("::-webkit-scrollbar-thumb")) { const hasBorderRadius = content.includes("border-radius") || content.includes("border-radius:"); expect(hasBorderRadius).toBeTruthy(); } } await ctx.close(); }); test("VSB04 - content frame scrolls when overflow", async () => { const { popup } = await setupVault(ctx); for (let i = 0; i < 20; i++) { await popup.locator('input[placeholder="search vault..."]').click(); await popup.getByRole("button", { name: /new entry/i }).click(); await popup.locator(".peach-new-name-input").fill(`ScrollTest${i}`); await popup.locator('input[placeholder="domain.com"]').fill(`https://scroll${i}.com`); await popup.locator('input[placeholder="username or email"]').fill(`scroll${i}@user.com`); await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass"); await popup.getByRole("button", { name: /create/i }).click(); await popup.waitForTimeout(400); } const scroller = popup.locator(".peach-content-frame"); const canScroll = await scroller.evaluate((el: Element) => el.scrollHeight > el.clientHeight); expect(canScroll).toBeTruthy(); await ctx.close(); }); test("VSB05 - scrollbar appears on hover", async () => { const { popup } = await setupVault(ctx); for (let i = 0; i < 15; i++) { await popup.locator('input[placeholder="search vault..."]').click(); await popup.getByRole("button", { name: /new entry/i }).click(); await popup.locator(".peach-new-name-input").fill(`HoverScroll${i}`); await popup.locator('input[placeholder="domain.com"]').fill(`https://hover${i}.com`); await popup.locator('input[placeholder="username or email"]').fill(`hover${i}@user.com`); await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass"); await popup.getByRole("button", { name: /create/i }).click(); await popup.waitForTimeout(400); } const scroller = popup.locator(".peach-content-frame"); await scroller.hover(); await popup.waitForTimeout(200); const scrollThumb = scroller.locator("::-webkit-scrollbar-thumb"); await scrollThumb.isVisible().catch(() => false); await ctx.close(); }); }); test.describe("Color & Theme Compliance", () => { let ctx: BrowserContext; test.afterEach(async () => { await ctx.close(); }); test.beforeEach(async () => { ctx = await makeContext(); }); test("VCT01 - all colors use CSS variables", async () => { const cssFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f => f.filter(f => f.endsWith(".css")) ); const hardcodedColors: string[] = []; for (const file of cssFiles) { const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8"); const hexColors = content.match(/#[0-9a-fA-F]{3,8}/g) || []; for (const color of hexColors) { if (!content.includes(`--${color.replace("#", "")}`) && !color.match(/^#000$|^#fff$|^#fff0000$/i)) { const propMatch = content.substring(content.indexOf(color) - 50, content.indexOf(color)).match(/[a-z-]+:\s*[^;]*/i); if (propMatch && !propMatch[0].includes("var(")) { hardcodedColors.push(`${file}: ${color}`); } } } } expect(hardcodedColors.slice(0, 5)).toHaveLength(0); await ctx.close(); }); test("VCT02 - accent color variable (--ac) is used", async () => { const cssFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f => f.filter(f => f.endsWith(".css")) ); let foundAC = false; for (const file of cssFiles) { const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8"); if (content.includes("--ac") || content.includes("var(--ac)")) { foundAC = true; break; } } expect(foundAC).toBeTruthy(); await ctx.close(); }); test("VCT03 - tint variables (--t0 through --t4) all defined", async () => { await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f => f.filter(f => f.endsWith(".css")) ); const varFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f => f.filter(f => f.includes("vars") || f.includes("variables")) ); const varsCss = varFiles.length > 0 ? await fs.readFile(join(__dirname, "..", "dist", "assets", varFiles[0]), "utf-8").catch(() => "") : ""; if (varsCss) { expect(varsCss).toContain("--t0"); expect(varsCss).toContain("--t1"); expect(varsCss).toContain("--t2"); expect(varsCss).toContain("--t3"); expect(varsCss).toContain("--t4"); } await ctx.close(); }); test("VCT04 - no inline styles use !important", async () => { const { popup } = await setupVault(ctx); const importantStyles = await popup.evaluate(() => { const elements = document.querySelectorAll("*"); let count = 0; elements.forEach(el => { const style = (el as HTMLElement).style; for (const prop of Object.keys(style)) { if ((style as any)[prop]?.includes("!important")) { count++; } } }); return count; }); expect(importantStyles).toBe(0); await ctx.close(); }); test("VCT05 - focus ring uses accent color", async () => { const { popup } = await setupVault(ctx); await popup.locator('input[placeholder="search vault..."]').click(); await popup.keyboard.press("Tab"); await popup.waitForTimeout(100); const outlineColor = await popup.evaluate(() => { const el = document.activeElement as HTMLElement; return window.getComputedStyle(el).outlineColor; }); expect(outlineColor).toBeTruthy(); await ctx.close(); }); }); test.describe("Performance Metrics", () => { let ctx: BrowserContext; test.afterEach(async () => { await ctx.close(); }); test.beforeEach(async () => { ctx = await makeContext(); }); test("VPM01 - page renders within 1 second", async () => { const extId = await waitForSW(ctx); const start = Date.now(); const newPage = await ctx.newPage(); await newPage.goto(`chrome-extension://${extId}/src/popup/index.html`); await newPage.waitForTimeout(3000); const elapsed = Date.now() - start; expect(elapsed).toBeLessThan(4000); await ctx.close(); }); test("VPM02 - vault unlocks within 3 seconds", async () => { await setupVault(ctx); const newPage = await ctx.newPage(); const extId = await waitForSW(ctx); await newPage.goto(`chrome-extension://${extId}/src/popup/index.html`); await newPage.waitForTimeout(2000); const start = Date.now(); await newPage.locator("#master-password").fill("password123"); await newPage.getByRole("button", { name: /unlock/i }).click(); await newPage.waitForTimeout(500); const entries = newPage.locator("[class*=entry]"); await entries.first().waitFor({ state: "visible", timeout: 5000 }); const elapsed = Date.now() - start; expect(elapsed).toBeLessThan(4000); await ctx.close(); }); test("VPM03 - 50 entries render without lag", async () => { const { popup } = await setupVault(ctx); for (let i = 0; i < 50; i++) { await popup.locator('input[placeholder="search vault..."]').click(); await popup.getByRole("button", { name: /new entry/i }).click(); await popup.locator(".peach-new-name-input").fill(`Perf${i}`); await popup.locator('input[placeholder="domain.com"]').fill(`https://perf${i}.com`); await popup.locator('input[placeholder="username or email"]').fill(`perf${i}@user.com`); await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass"); await popup.getByRole("button", { name: /create/i }).click(); await popup.waitForTimeout(200); } const start = Date.now(); await popup.locator('input[placeholder="search vault..."]').click(); await popup.locator('input[placeholder="search vault..."]').fill("Perf25"); await popup.waitForTimeout(500); const elapsed = Date.now() - start; expect(elapsed).toBeLessThan(1000); await ctx.close(); }); test("VPM04 - CSS file size is reasonable (<100KB)", async () => { const cssFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f => f.filter(f => f.endsWith(".css")) ); for (const file of cssFiles) { const stats = await fs.stat(join(__dirname, "..", "dist", "assets", file)); expect(stats.size).toBeLessThan(100 * 1024); } await ctx.close(); }); }); test.describe("Dark Theme Consistency", () => { let ctx: BrowserContext; test.afterEach(async () => { await ctx.close(); }); test.beforeEach(async () => { ctx = await makeContext(); }); test("VDT01 - background uses dark theme color", async () => { const { popup } = await setupVault(ctx); const bg = await popup.evaluate(() => window.getComputedStyle(document.body).backgroundColor); expect(bg).toBeTruthy(); await ctx.close(); }); test("VDT02 - text contrast meets WCAG AA", async () => { const { popup } = await setupVault(ctx); const contrast = await popup.evaluate(() => { const bg = window.getComputedStyle(document.body).backgroundColor; const text = window.getComputedStyle(document.body).color; return { bg, text }; }); expect(contrast.bg).toBeTruthy(); expect(contrast.text).toBeTruthy(); await ctx.close(); }); test("VDT03 - input fields have dark background", async () => { const { popup } = await setupVault(ctx); const inputBg = await popup.evaluate(() => { const input = document.querySelector("input") as HTMLInputElement; return input ? window.getComputedStyle(input).backgroundColor : null; }); expect(inputBg).toBeTruthy(); await ctx.close(); }); test("VDT04 - buttons have proper contrast", async () => { const { popup } = await setupVault(ctx); const button = popup.locator("button").first(); const contrast = await button.evaluate((el: Element) => { const styles = window.getComputedStyle(el); return { color: styles.color, backgroundColor: styles.backgroundColor }; }); expect(contrast.color).toBeTruthy(); await ctx.close(); }); test("VDT05 - overlay backdrop is semi-transparent dark", async () => { const { popup } = await setupVault(ctx); await popup.locator(".peach-icon-btn[aria-label='Settings']").click(); await popup.waitForTimeout(500); const overlay = popup.locator(".peach-overlay-view"); const bg = await overlay.evaluate((el: Element) => window.getComputedStyle(el).backgroundColor); expect(bg).toBeTruthy(); await ctx.close(); }); }); test.describe("Spacing & Layout", () => { let ctx: BrowserContext; test.afterEach(async () => { await ctx.close(); }); test.beforeEach(async () => { ctx = await makeContext(); }); test("VSP01 - consistent 8px grid spacing", async () => { const { popup } = await setupVault(ctx); const spacings = await popup.evaluate(() => { const elements = document.querySelectorAll(".peach-stage > *"); return Array.from(elements).map((el: Element) => { const style = window.getComputedStyle(el); return parseFloat(style.gap || style.marginBottom || "0"); }).filter(v => v > 0); }); const allAligned = spacings.every((s: number) => s % 4 === 0); expect(allAligned || spacings.length === 0).toBeTruthy(); await ctx.close(); }); test("VSP02 - entry row padding is consistent", async () => { const { popup } = await setupVault(ctx); for (const name of ["Space1", "Space2", "Space3"]) { await popup.locator('input[placeholder="search vault..."]').click(); await popup.getByRole("button", { name: /new entry/i }).click(); await popup.locator(".peach-new-name-input").fill(name); await popup.locator('input[placeholder="domain.com"]').fill(`https://${name.toLowerCase()}.com`); await popup.locator('input[placeholder="username or email"]').fill(`${name.toLowerCase()}@user.com`); await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass"); await popup.getByRole("button", { name: /create/i }).click(); await popup.waitForTimeout(800); } const paddings = await popup.evaluate(() => { const rows = document.querySelectorAll(".peach-entry-row"); return Array.from(rows).map(r => window.getComputedStyle(r).padding); }); const unique = [...new Set(paddings)]; expect(unique.length).toBeLessThanOrEqual(3); await ctx.close(); }); test("VSP03 - icon buttons have proper hit area", async () => { const { popup } = await setupVault(ctx); const settingsBtn = popup.locator(".peach-icon-btn[aria-label='Settings']"); const box = await settingsBtn.boundingBox(); expect(box?.width).toBeGreaterThanOrEqual(24); expect(box?.height).toBeGreaterThanOrEqual(24); await ctx.close(); }); test("VSP04 - content area has no unexpected clipping", async () => { const { popup } = await setupVault(ctx); for (let i = 0; i < 10; i++) { await popup.locator('input[placeholder="search vault..."]').click(); await popup.getByRole("button", { name: /new entry/i }).click(); await popup.locator(".peach-new-name-input").fill(`Clip${i}`); await popup.locator('input[placeholder="domain.com"]').fill(`https://clip${i}.com`); await popup.locator('input[placeholder="username or email"]').fill(`clip${i}@user.com`); await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass"); await popup.getByRole("button", { name: /create/i }).click(); await popup.waitForTimeout(500); } const stage = popup.locator(".peach-stage"); const stageBox = await stage.boundingBox(); const lastEntry = popup.locator(".peach-entry-row").last(); const entryBox = await lastEntry.boundingBox(); expect(entryBox?.bottom).toBeLessThanOrEqual((stageBox?.bottom ?? 0) + 10); await ctx.close(); }); });