visual-compliance.spec.ts

25.4 KB · raw

1import { test, expect, 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");1011async function makeContext() {12  const { chromium } = await import("playwright");13  const userDataDir = join(os.tmpdir(), `peach-vc-${Date.now()}`);14  await fs.mkdir(userDataDir, { recursive: true });15  return chromium.launchPersistentContext(userDataDir, {16    headless: false,17    args: [`--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`],18  });19}2021async function waitForSW(ctx: BrowserContext, maxWait = 15000): Promise<string> {22  const start = Date.now();23  let sw = ctx.serviceWorkers()[0];24  while (!sw && Date.now() - start < maxWait) {25    await new Promise(r => setTimeout(r, 500));26    sw = ctx.serviceWorkers()[0];27  }28  if (!sw) throw new Error(`No SW after ${maxWait}ms`);29  return new URL(sw.url()).host;30}3132async function setupVault(ctx: BrowserContext): Promise<{ popup: any, extId: string }> {33  const extId = await waitForSW(ctx);34  const popup = await ctx.newPage();35  await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);36  await popup.waitForTimeout(3000);37  const pw = popup.locator("#master-password");38  if (await pw.isVisible()) {39    await pw.fill("password123");40    await popup.locator("#confirm-password").fill("password123");41    await popup.getByRole("button", { name: /create vault/i }).click();42    await popup.waitForTimeout(2000);43  }44  return { popup, extId };45}4647test.describe("Animation & Transition Compliance", () => {48  let ctx: BrowserContext;49  test.afterEach(async () => { await ctx.close(); });50  test.beforeEach(async () => { ctx = await makeContext(); });5152  test("VAP01 - lock screen animations use only opacity and transform", async () => {53    const cssFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>54      f.filter(f => f.endsWith(".css"))55    );56    const nonAnimatableProps: string[] = [];57    for (const file of cssFiles) {58      const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");59      const keyframes = content.match(/@keyframes[\s\S]*?\{[\s\S]*?\}/g) || [];60      for (const kf of keyframes) {61        const props = kf.match(/[\w-]+:\s*[^;]+;/g) || [];62        for (const prop of props) {63          const name = prop.split(":")[0].trim();64          if (!["opacity", "transform", "color", "background-color", "border-color", "stroke", "fill"].includes(name)) {65            nonAnimatableProps.push(`${file}: ${name} in @keyframes`);66          }67        }68      }69    }70    expect(nonAnimatableProps).toHaveLength(0);71    await ctx.close();72  });7374  test("VAP02 - spring easing variable used for animations", async () => {75    const cssFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>76      f.filter(f => f.endsWith(".css"))77    );78    const bezierWithoutVar: string[] = [];79    for (const file of cssFiles) {80      const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");81      const beziers = content.match(/cubic-bezier\([^)]+\)/g) || [];82      for (const b of beziers) {83        if (!content.includes(`--spring`) && !content.includes(`--retreat`) && !content.includes(`--settle`)) {84          bezierWithoutVar.push(`${file}: ${b}`);85        }86      }87    }88    expect(bezierWithoutVar).toHaveLength(0);89    await ctx.close();90  });9192  test("VAP03 - no animation on 'display' property", async () => {93    const cssFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>94      f.filter(f => f.endsWith(".css"))95    );96    const displayAnimations: string[] = [];97    for (const file of cssFiles) {98      const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");99      const keyframes = content.match(/@keyframes[\s\S]*?\{[\s\S]*?\}/g) || [];100      for (const kf of keyframes) {101        if (kf.includes("display")) {102          displayAnimations.push(file);103        }104      }105    }106    expect(displayAnimations).toHaveLength(0);107    await ctx.close();108  });109110  test("VAP04 - unlock transition is smooth (no jank)", async () => {111    await setupVault(ctx);112    const newPage = await ctx.newPage();113    const extId = await waitForSW(ctx);114    await newPage.goto(`chrome-extension://${extId}/src/popup/index.html`);115    await newPage.waitForTimeout(2000);116    const start = Date.now();117    await newPage.locator("#master-password").fill("password123");118    await newPage.getByRole("button", { name: /unlock/i }).click();119    await newPage.waitForTimeout(100);120    const entries = newPage.locator("[class*=entry]");121    await entries.first().waitFor({ state: "visible", timeout: 5000 });122    const elapsed = Date.now() - start;123    expect(elapsed).toBeLessThan(3000);124    await newPage.close();125    await ctx.close();126  });127128  test("VAP05 - entry detail open animation is under 300ms", async () => {129    const { popup } = await setupVault(ctx);130    await popup.locator('input[placeholder="search vault..."]').click();131    await popup.getByRole("button", { name: /new entry/i }).click();132    await popup.locator(".peach-new-name-input").fill("AnimTest");133    await popup.locator('input[placeholder="domain.com"]').fill("https://anim.com");134    await popup.locator('input[placeholder="username or email"]').fill("[email protected]");135    await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass");136    await popup.getByRole("button", { name: /create/i }).click();137    await popup.waitForTimeout(1500);138    const start = Date.now();139    await popup.locator(".peach-entry-name-button").first().click();140    await popup.waitForTimeout(100);141    const usernameField = popup.locator(".peach-field-value").first();142    await usernameField.waitFor({ state: "visible", timeout: 3000 });143    const elapsed = Date.now() - start;144    expect(elapsed).toBeLessThan(800);145    await ctx.close();146  });147});148149test.describe("Typography & Layout", () => {150  let ctx: BrowserContext;151  test.afterEach(async () => { await ctx.close(); });152  test.beforeEach(async () => { ctx = await makeContext(); });153154  test("VTL01 - font-size consistent in popup", async () => {155    const { popup } = await setupVault(ctx);156    const sizes = await popup.evaluate(() => {157      const elements = document.querySelectorAll("*");158      const sizes: number[] = [];159      elements.forEach(el => {160        const fs = window.getComputedStyle(el).fontSize;161        if (fs) sizes.push(parseFloat(fs));162      });163      return [...new Set(sizes)].slice(0, 20);164    });165    expect(sizes.length).toBeGreaterThan(0);166    await ctx.close();167  });168169  test("VTL02 - no font loaded from Google CDN", async () => {170    const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>171      f.filter(f => f.endsWith(".css") || f.endsWith(".html"))172    );173    let foundGoogleFont = false;174    for (const file of distFiles) {175      const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8").catch(() => "");176      const htmlContent = await fs.readFile(join(__dirname, "..", "dist", "src", "popup", "index.html"), "utf-8").catch(() => "");177      const combined = content + htmlContent;178      if (combined.includes("fonts.googleapis.com") || combined.includes("fonts.gstatic.com")) {179        foundGoogleFont = true;180        break;181      }182    }183    expect(foundGoogleFont).toBeFalsy();184    await ctx.close();185  });186187  test("VTL03 - font-display uses system fonts", async () => {188    const { popup } = await setupVault(ctx);189    const fontFamily = await popup.evaluate(() => {190      return window.getComputedStyle(document.body).fontFamily;191    });192    expect(fontFamily).toBeTruthy();193    await ctx.close();194  });195196  test("VTL04 - popup width is fixed at 320px", async () => {197    const { popup } = await setupVault(ctx);198    const width = await popup.evaluate(() => document.body?.parentElement?.clientWidth || 0);199    expect(width).toBe(320);200    await ctx.close();201  });202203  test("VTL05 - content area has proper padding", async () => {204    const { popup } = await setupVault(ctx);205    const contentFrame = popup.locator(".peach-content-frame");206    const padding = await contentFrame.evaluate((el: Element) => window.getComputedStyle(el).padding);207    expect(padding).toBeTruthy();208    await ctx.close();209  });210211  test("VTL06 - no horizontal overflow on body", async () => {212    const { popup } = await setupVault(ctx);213    const overflow = await popup.evaluate(() => {214      return window.getComputedStyle(document.body).overflowX;215    });216    expect(overflow).toBe("hidden");217    await ctx.close();218  });219220  test("VTL07 - entry list items have consistent height", async () => {221    const { popup } = await setupVault(ctx);222    for (const name of ["H1", "H2", "H3"]) {223      await popup.locator('input[placeholder="search vault..."]').click();224      await popup.getByRole("button", { name: /new entry/i }).click();225      await popup.locator(".peach-new-name-input").fill(name);226      await popup.locator('input[placeholder="domain.com"]').fill(`https://${name.toLowerCase()}.com`);227      await popup.locator('input[placeholder="username or email"]').fill(`${name.toLowerCase()}@user.com`);228      await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass");229      await popup.getByRole("button", { name: /create/i }).click();230      await popup.waitForTimeout(800);231    }232    const rowHeights = await popup.evaluate(() => {233      const rows = document.querySelectorAll(".peach-entry-row");234      return Array.from(rows).map(r => r.getBoundingClientRect().height);235    });236    const unique = [...new Set(rowHeights)];237    expect(unique.length).toBeLessThanOrEqual(2);238    await ctx.close();239  });240241  test("VTL08 - search bar is always visible", async () => {242    const { popup } = await setupVault(ctx);243    const search = popup.locator(".peach-search-bar, .peach-search-input");244    await expect(search).toBeVisible();245    await ctx.close();246  });247});248249test.describe("Scrollbar Styling", () => {250  let ctx: BrowserContext;251  test.afterEach(async () => { await ctx.close(); });252  test.beforeEach(async () => { ctx = await makeContext(); });253254  test("VSB01 - scrollbar width is 3px or less", async () => {255    const cssFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>256      f.filter(f => f.endsWith(".css"))257    );258    for (const file of cssFiles) {259      const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");260      const widthMatch = content.match(/::-webkit-scrollbar\s*\{[\s\S]*?width:\s*(\d+)px/);261      if (widthMatch) {262        expect(parseInt(widthMatch[1])).toBeLessThanOrEqual(4);263      }264    }265    await ctx.close();266  });267268  test("VSB02 - scrollbar has subtle color (not bright)", async () => {269    const cssFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>270      f.filter(f => f.endsWith(".css"))271    );272    for (const file of cssFiles) {273      const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");274      if (content.includes("::-webkit-scrollbar")) {275        const hasVar = content.includes("var(--t");276        expect(hasVar).toBeTruthy();277      }278    }279    await ctx.close();280  });281282  test("VSB03 - scrollbar uses border-radius (rounded)", async () => {283    const cssFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>284      f.filter(f => f.endsWith(".css"))285    );286    for (const file of cssFiles) {287      const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");288      if (content.includes("::-webkit-scrollbar-thumb")) {289        const hasBorderRadius = content.includes("border-radius") || content.includes("border-radius:");290        expect(hasBorderRadius).toBeTruthy();291      }292    }293    await ctx.close();294  });295296  test("VSB04 - content frame scrolls when overflow", async () => {297    const { popup } = await setupVault(ctx);298    for (let i = 0; i < 20; i++) {299      await popup.locator('input[placeholder="search vault..."]').click();300      await popup.getByRole("button", { name: /new entry/i }).click();301      await popup.locator(".peach-new-name-input").fill(`ScrollTest${i}`);302      await popup.locator('input[placeholder="domain.com"]').fill(`https://scroll${i}.com`);303      await popup.locator('input[placeholder="username or email"]').fill(`scroll${i}@user.com`);304      await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass");305      await popup.getByRole("button", { name: /create/i }).click();306      await popup.waitForTimeout(400);307    }308    const scroller = popup.locator(".peach-content-frame");309    const canScroll = await scroller.evaluate((el: Element) => el.scrollHeight > el.clientHeight);310    expect(canScroll).toBeTruthy();311    await ctx.close();312  });313314  test("VSB05 - scrollbar appears on hover", async () => {315    const { popup } = await setupVault(ctx);316    for (let i = 0; i < 15; i++) {317      await popup.locator('input[placeholder="search vault..."]').click();318      await popup.getByRole("button", { name: /new entry/i }).click();319      await popup.locator(".peach-new-name-input").fill(`HoverScroll${i}`);320      await popup.locator('input[placeholder="domain.com"]').fill(`https://hover${i}.com`);321      await popup.locator('input[placeholder="username or email"]').fill(`hover${i}@user.com`);322      await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass");323      await popup.getByRole("button", { name: /create/i }).click();324      await popup.waitForTimeout(400);325    }326    const scroller = popup.locator(".peach-content-frame");327    await scroller.hover();328    await popup.waitForTimeout(200);329    const scrollThumb = scroller.locator("::-webkit-scrollbar-thumb");330    await scrollThumb.isVisible().catch(() => false);331    await ctx.close();332  });333});334335test.describe("Color & Theme Compliance", () => {336  let ctx: BrowserContext;337  test.afterEach(async () => { await ctx.close(); });338  test.beforeEach(async () => { ctx = await makeContext(); });339340  test("VCT01 - all colors use CSS variables", async () => {341    const cssFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>342      f.filter(f => f.endsWith(".css"))343    );344    const hardcodedColors: string[] = [];345    for (const file of cssFiles) {346      const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");347      const hexColors = content.match(/#[0-9a-fA-F]{3,8}/g) || [];348      for (const color of hexColors) {349        if (!content.includes(`--${color.replace("#", "")}`) &&350            !color.match(/^#000$|^#fff$|^#fff0000$/i)) {351          const propMatch = content.substring(content.indexOf(color) - 50, content.indexOf(color)).match(/[a-z-]+:\s*[^;]*/i);352          if (propMatch && !propMatch[0].includes("var(")) {353            hardcodedColors.push(`${file}: ${color}`);354          }355        }356      }357    }358    expect(hardcodedColors.slice(0, 5)).toHaveLength(0);359    await ctx.close();360  });361362  test("VCT02 - accent color variable (--ac) is used", async () => {363    const cssFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>364      f.filter(f => f.endsWith(".css"))365    );366    let foundAC = false;367    for (const file of cssFiles) {368      const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");369      if (content.includes("--ac") || content.includes("var(--ac)")) {370        foundAC = true;371        break;372      }373    }374    expect(foundAC).toBeTruthy();375    await ctx.close();376  });377378  test("VCT03 - tint variables (--t0 through --t4) all defined", async () => {379    await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>380      f.filter(f => f.endsWith(".css"))381    );382    const varFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>383      f.filter(f => f.includes("vars") || f.includes("variables"))384    );385    const varsCss = varFiles.length > 0386      ? await fs.readFile(join(__dirname, "..", "dist", "assets", varFiles[0]), "utf-8").catch(() => "")387      : "";388    if (varsCss) {389      expect(varsCss).toContain("--t0");390      expect(varsCss).toContain("--t1");391      expect(varsCss).toContain("--t2");392      expect(varsCss).toContain("--t3");393      expect(varsCss).toContain("--t4");394    }395    await ctx.close();396  });397398  test("VCT04 - no inline styles use !important", async () => {399    const { popup } = await setupVault(ctx);400    const importantStyles = await popup.evaluate(() => {401      const elements = document.querySelectorAll("*");402      let count = 0;403      elements.forEach(el => {404        const style = (el as HTMLElement).style;405        for (const prop of Object.keys(style)) {406          if ((style as any)[prop]?.includes("!important")) {407            count++;408          }409        }410      });411      return count;412    });413    expect(importantStyles).toBe(0);414    await ctx.close();415  });416417  test("VCT05 - focus ring uses accent color", async () => {418    const { popup } = await setupVault(ctx);419    await popup.locator('input[placeholder="search vault..."]').click();420    await popup.keyboard.press("Tab");421    await popup.waitForTimeout(100);422    const outlineColor = await popup.evaluate(() => {423      const el = document.activeElement as HTMLElement;424      return window.getComputedStyle(el).outlineColor;425    });426    expect(outlineColor).toBeTruthy();427    await ctx.close();428  });429});430431test.describe("Performance Metrics", () => {432  let ctx: BrowserContext;433  test.afterEach(async () => { await ctx.close(); });434  test.beforeEach(async () => { ctx = await makeContext(); });435436  test("VPM01 - page renders within 1 second", async () => {437    const extId = await waitForSW(ctx);438    const start = Date.now();439    const newPage = await ctx.newPage();440    await newPage.goto(`chrome-extension://${extId}/src/popup/index.html`);441    await newPage.waitForTimeout(3000);442    const elapsed = Date.now() - start;443    expect(elapsed).toBeLessThan(4000);444    await ctx.close();445  });446447  test("VPM02 - vault unlocks within 3 seconds", async () => {448    await setupVault(ctx);449    const newPage = await ctx.newPage();450    const extId = await waitForSW(ctx);451    await newPage.goto(`chrome-extension://${extId}/src/popup/index.html`);452    await newPage.waitForTimeout(2000);453    const start = Date.now();454    await newPage.locator("#master-password").fill("password123");455    await newPage.getByRole("button", { name: /unlock/i }).click();456    await newPage.waitForTimeout(500);457    const entries = newPage.locator("[class*=entry]");458    await entries.first().waitFor({ state: "visible", timeout: 5000 });459    const elapsed = Date.now() - start;460    expect(elapsed).toBeLessThan(4000);461    await ctx.close();462  });463464  test("VPM03 - 50 entries render without lag", async () => {465    const { popup } = await setupVault(ctx);466    for (let i = 0; i < 50; i++) {467      await popup.locator('input[placeholder="search vault..."]').click();468      await popup.getByRole("button", { name: /new entry/i }).click();469      await popup.locator(".peach-new-name-input").fill(`Perf${i}`);470      await popup.locator('input[placeholder="domain.com"]').fill(`https://perf${i}.com`);471      await popup.locator('input[placeholder="username or email"]').fill(`perf${i}@user.com`);472      await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass");473      await popup.getByRole("button", { name: /create/i }).click();474      await popup.waitForTimeout(200);475    }476    const start = Date.now();477    await popup.locator('input[placeholder="search vault..."]').click();478    await popup.locator('input[placeholder="search vault..."]').fill("Perf25");479    await popup.waitForTimeout(500);480    const elapsed = Date.now() - start;481    expect(elapsed).toBeLessThan(1000);482    await ctx.close();483  });484485  test("VPM04 - CSS file size is reasonable (<100KB)", async () => {486    const cssFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>487      f.filter(f => f.endsWith(".css"))488    );489    for (const file of cssFiles) {490      const stats = await fs.stat(join(__dirname, "..", "dist", "assets", file));491      expect(stats.size).toBeLessThan(100 * 1024);492    }493    await ctx.close();494  });495});496497test.describe("Dark Theme Consistency", () => {498  let ctx: BrowserContext;499  test.afterEach(async () => { await ctx.close(); });500  test.beforeEach(async () => { ctx = await makeContext(); });501502  test("VDT01 - background uses dark theme color", async () => {503    const { popup } = await setupVault(ctx);504    const bg = await popup.evaluate(() => window.getComputedStyle(document.body).backgroundColor);505    expect(bg).toBeTruthy();506    await ctx.close();507  });508509  test("VDT02 - text contrast meets WCAG AA", async () => {510    const { popup } = await setupVault(ctx);511    const contrast = await popup.evaluate(() => {512      const bg = window.getComputedStyle(document.body).backgroundColor;513      const text = window.getComputedStyle(document.body).color;514      return { bg, text };515    });516    expect(contrast.bg).toBeTruthy();517    expect(contrast.text).toBeTruthy();518    await ctx.close();519  });520521  test("VDT03 - input fields have dark background", async () => {522    const { popup } = await setupVault(ctx);523    const inputBg = await popup.evaluate(() => {524      const input = document.querySelector("input") as HTMLInputElement;525      return input ? window.getComputedStyle(input).backgroundColor : null;526    });527    expect(inputBg).toBeTruthy();528    await ctx.close();529  });530531  test("VDT04 - buttons have proper contrast", async () => {532    const { popup } = await setupVault(ctx);533    const button = popup.locator("button").first();534    const contrast = await button.evaluate((el: Element) => {535      const styles = window.getComputedStyle(el);536      return {537        color: styles.color,538        backgroundColor: styles.backgroundColor539      };540    });541    expect(contrast.color).toBeTruthy();542    await ctx.close();543  });544545  test("VDT05 - overlay backdrop is semi-transparent dark", async () => {546    const { popup } = await setupVault(ctx);547    await popup.locator(".peach-icon-btn[aria-label='Settings']").click();548    await popup.waitForTimeout(500);549    const overlay = popup.locator(".peach-overlay-view");550    const bg = await overlay.evaluate((el: Element) => window.getComputedStyle(el).backgroundColor);551    expect(bg).toBeTruthy();552    await ctx.close();553  });554});555556test.describe("Spacing & Layout", () => {557  let ctx: BrowserContext;558  test.afterEach(async () => { await ctx.close(); });559  test.beforeEach(async () => { ctx = await makeContext(); });560561  test("VSP01 - consistent 8px grid spacing", async () => {562    const { popup } = await setupVault(ctx);563    const spacings = await popup.evaluate(() => {564      const elements = document.querySelectorAll(".peach-stage > *");565      return Array.from(elements).map((el: Element) => {566        const style = window.getComputedStyle(el);567        return parseFloat(style.gap || style.marginBottom || "0");568      }).filter(v => v > 0);569    });570    const allAligned = spacings.every((s: number) => s % 4 === 0);571    expect(allAligned || spacings.length === 0).toBeTruthy();572    await ctx.close();573  });574575  test("VSP02 - entry row padding is consistent", async () => {576    const { popup } = await setupVault(ctx);577    for (const name of ["Space1", "Space2", "Space3"]) {578      await popup.locator('input[placeholder="search vault..."]').click();579      await popup.getByRole("button", { name: /new entry/i }).click();580      await popup.locator(".peach-new-name-input").fill(name);581      await popup.locator('input[placeholder="domain.com"]').fill(`https://${name.toLowerCase()}.com`);582      await popup.locator('input[placeholder="username or email"]').fill(`${name.toLowerCase()}@user.com`);583      await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass");584      await popup.getByRole("button", { name: /create/i }).click();585      await popup.waitForTimeout(800);586    }587    const paddings = await popup.evaluate(() => {588      const rows = document.querySelectorAll(".peach-entry-row");589      return Array.from(rows).map(r => window.getComputedStyle(r).padding);590    });591    const unique = [...new Set(paddings)];592    expect(unique.length).toBeLessThanOrEqual(3);593    await ctx.close();594  });595596  test("VSP03 - icon buttons have proper hit area", async () => {597    const { popup } = await setupVault(ctx);598    const settingsBtn = popup.locator(".peach-icon-btn[aria-label='Settings']");599    const box = await settingsBtn.boundingBox();600    expect(box?.width).toBeGreaterThanOrEqual(24);601    expect(box?.height).toBeGreaterThanOrEqual(24);602    await ctx.close();603  });604605  test("VSP04 - content area has no unexpected clipping", async () => {606    const { popup } = await setupVault(ctx);607    for (let i = 0; i < 10; i++) {608      await popup.locator('input[placeholder="search vault..."]').click();609      await popup.getByRole("button", { name: /new entry/i }).click();610      await popup.locator(".peach-new-name-input").fill(`Clip${i}`);611      await popup.locator('input[placeholder="domain.com"]').fill(`https://clip${i}.com`);612      await popup.locator('input[placeholder="username or email"]').fill(`clip${i}@user.com`);613      await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass");614      await popup.getByRole("button", { name: /create/i }).click();615      await popup.waitForTimeout(500);616    }617    const stage = popup.locator(".peach-stage");618    const stageBox = await stage.boundingBox();619    const lastEntry = popup.locator(".peach-entry-row").last();620    const entryBox = await lastEntry.boundingBox();621    expect(entryBox?.bottom).toBeLessThanOrEqual((stageBox?.bottom ?? 0) + 10);622    await ctx.close();623  });624});