a11y-totp-vault.spec.ts

18.5 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-a11y-${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("Accessibility", () => {4849  test("A11Y01 - all buttons have accessible names", async () => {50    const ctx = await makeContext();51    const { popup } = await setupVault(ctx);52    const buttons = popup.locator("button");53    const count = await buttons.count();54    let allAccessible = true;55    for (let i = 0; i < Math.min(count, 20); i++) {56      const btn = buttons.nth(i);57      const ariaLabel = await btn.getAttribute("aria-label");58      const text = await btn.textContent().catch(() => "");59      if (!ariaLabel && !text?.trim()) {60        allAccessible = false;61        break;62      }63    }64    expect(allAccessible).toBe(true);65    await ctx.close();66  });6768  test("A11Y02 - form inputs have associated labels", async () => {69    const ctx = await makeContext();70    const { popup } = await setupVault(ctx);71    const inputs = popup.locator("input:not([type='hidden']):not([type='checkbox'])");72    const count = await inputs.count();73    let allLabeled = true;74    for (let i = 0; i < Math.min(count, 10); i++) {75      const input = inputs.nth(i);76      const id = await input.getAttribute("id");77      const ariaLabel = await input.getAttribute("aria-label");78      const ariaLabelledby = await input.getAttribute("aria-labelledby");79      const placeholder = await input.getAttribute("placeholder");80      if (!id && !ariaLabel && !ariaLabelledby && !placeholder) {81        allLabeled = false;82        break;83      }84    }85    expect(allLabeled).toBe(true);86    await ctx.close();87  });8889  test("A11Y03 - color contrast for text elements", async () => {90    const ctx = await makeContext();91    const { popup } = await setupVault(ctx);92    const textColor = await popup.evaluate(() => {93      const el = document.querySelector(".peach-entry-name, .peach-label, body");94      return window.getComputedStyle(el!).color;95    });96    const bgColor = await popup.evaluate(() => {97      return window.getComputedStyle(document.body).backgroundColor;98    });99    expect(textColor).toBeTruthy();100    expect(bgColor).toBeTruthy();101    await ctx.close();102  });103104  test("A11Y04 - focus indicators visible on interactive elements", async () => {105    const ctx = await makeContext();106    const { popup } = await setupVault(ctx);107    await popup.locator('input[placeholder="search vault..."]').click();108    await popup.keyboard.press("Tab");109    const focusedEl = await popup.evaluate(() => document.activeElement?.tagName);110    expect(focusedEl).toBeTruthy();111    await ctx.close();112  });113114  test("A11Y05 - ARIA roles used correctly", async () => {115    const ctx = await makeContext();116    const { popup } = await setupVault(ctx);117    const switchRole = popup.locator("[role='switch']");118    const optionRole = popup.locator("[role='option']");119    const hasSwitch = await switchRole.count();120    const hasOption = await optionRole.count();121    expect(hasSwitch + hasOption).toBeGreaterThan(0);122    await ctx.close();123  });124125  test("A11Y06 - screen reader announces copy feedback", async () => {126    const ctx = await makeContext();127    const { popup } = await setupVault(ctx);128    await popup.locator('input[placeholder="search vault..."]').click();129    await popup.getByRole("button", { name: /new entry/i }).click();130    await popup.locator(".peach-new-name-input").fill("A11YTest");131    await popup.locator('input[placeholder="domain.com"]').fill("https://a11y.com");132    await popup.locator('input[placeholder="username or email"]').fill("[email protected]");133    const pwdField = popup.locator(".peach-new-field-input[placeholder='password']");134    await pwdField.fill("pass123");135    await popup.getByRole("button", { name: /create/i }).click();136    await popup.waitForTimeout(1500);137    const copyBtn = popup.locator(".peach-copy-btn").first();138    if (await copyBtn.isVisible({ timeout: 2000 }).catch(() => false)) {139      await copyBtn.click();140      await popup.waitForTimeout(300);141    }142    await ctx.close();143  });144145  test("A11Y07 - landmark regions identified", async () => {146    const ctx = await makeContext();147    const { popup } = await setupVault(ctx);148    const mainLandmark = popup.locator("main, [role='main']");149    const hasMain = await mainLandmark.count();150    expect(hasMain).toBeGreaterThan(0);151    await ctx.close();152  });153154  test("A11Y08 - lang attribute on html element", async () => {155    const ctx = await makeContext();156    const extId = await waitForSW(ctx);157    const popup = await ctx.newPage();158    await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);159    await popup.waitForTimeout(2000);160    const lang = await popup.getAttribute("html", "lang");161    expect(lang).toBeTruthy();162    await ctx.close();163  });164});165166test.describe("TOTP Functionality", () => {167168  test("TOTP01 - TOTP field accepts base32 secret", async () => {169    const ctx = await makeContext();170    const { popup } = await setupVault(ctx);171    await popup.locator('input[placeholder="search vault..."]').click();172    await popup.getByRole("button", { name: /new entry/i }).click();173    await popup.locator(".peach-new-name-input").fill("TOTPBase32");174    await popup.locator('input[placeholder="domain.com"]').fill("https://totp.com");175    await popup.locator('input[placeholder="username or email"]').fill("[email protected]");176    const pwdField = popup.locator(".peach-new-field-input[placeholder='password']");177    await pwdField.fill("pass123");178    await popup.locator("input[placeholder*='totp' i]").fill("JBSWY3DPEHPK3PXP");179    await popup.getByRole("button", { name: /create/i }).click();180    await popup.waitForTimeout(1500);181    await ctx.close();182  });183184  test("TOTP02 - TOTP code displays in entry view", async () => {185    const ctx = await makeContext();186    const { popup } = await setupVault(ctx);187    await popup.locator('input[placeholder="search vault..."]').click();188    await popup.getByRole("button", { name: /new entry/i }).click();189    await popup.locator(".peach-new-name-input").fill("TOTPDisplay");190    await popup.locator('input[placeholder="domain.com"]').fill("https://display.com");191    await popup.locator('input[placeholder="username or email"]').fill("[email protected]");192    const pwdField = popup.locator(".peach-new-field-input[placeholder='password']");193    await pwdField.fill("pass123");194    await popup.locator("input[placeholder*='totp' i]").fill("JBSWY3DPEHPK3PXP");195    await popup.getByRole("button", { name: /create/i }).click();196    await popup.waitForTimeout(2000);197    await popup.locator(".peach-entry-name-button").first().click();198    await popup.waitForTimeout(1000);199    const totpCode = popup.locator(".peach-totp-code");200    await expect(totpCode).toBeVisible({ timeout: 5000 });201    await ctx.close();202  });203204  test("TOTP03 - TOTP countdown timer shows remaining seconds", async () => {205    const ctx = await makeContext();206    const { popup } = await setupVault(ctx);207    await popup.locator('input[placeholder="search vault..."]').click();208    await popup.getByRole("button", { name: /new entry/i }).click();209    await popup.locator(".peach-new-name-input").fill("TOTPTimer");210    await popup.locator('input[placeholder="domain.com"]').fill("https://timer.com");211    await popup.locator('input[placeholder="username or email"]').fill("[email protected]");212    const pwdField = popup.locator(".peach-new-field-input[placeholder='password']");213    await pwdField.fill("pass123");214    await popup.locator("input[placeholder*='totp' i]").fill("JBSWY3DPEHPK3PXP");215    await popup.getByRole("button", { name: /create/i }).click();216    await popup.waitForTimeout(1500);217    await popup.locator("text=TOTPTimer").first().click();218    await popup.waitForTimeout(500);219    const countdown = popup.locator(".peach-totp-countdown");220    await expect(countdown).toBeVisible({ timeout: 3000 });221    const remaining = await countdown.textContent();222    expect(parseInt(remaining ?? "0")).toBeGreaterThan(0);223    await ctx.close();224  });225226  test("TOTP04 - copy TOTP button works", async () => {227    const ctx = await makeContext();228    const { popup } = await setupVault(ctx);229    await popup.locator('input[placeholder="search vault..."]').click();230    await popup.getByRole("button", { name: /new entry/i }).click();231    await popup.locator(".peach-new-name-input").fill("TOTPCopy");232    await popup.locator('input[placeholder="domain.com"]').fill("https://copytotp.com");233    await popup.locator('input[placeholder="username or email"]').fill("[email protected]");234    const pwdField = popup.locator(".peach-new-field-input[placeholder='password']");235    await pwdField.fill("pass123");236    await popup.locator("input[placeholder*='totp' i]").fill("JBSWY3DPEHPK3PXP");237    await popup.getByRole("button", { name: /create/i }).click();238    await popup.waitForTimeout(1500);239    await popup.locator("text=TOTPCopy").first().click();240    await popup.waitForTimeout(500);241    const totpCopyBtn = popup.locator("[aria-label='Copy TOTP code']");242    if (await totpCopyBtn.isVisible({ timeout: 2000 }).catch(() => false)) {243      await totpCopyBtn.click();244      await popup.waitForTimeout(300);245    }246    await ctx.close();247  });248249  test("TOTP05 - TOTP ring progress animates", async () => {250    const ctx = await makeContext();251    const { popup } = await setupVault(ctx);252    await popup.locator('input[placeholder="search vault..."]').click();253    await popup.getByRole("button", { name: /new entry/i }).click();254    await popup.locator(".peach-new-name-input").fill("TOTPRing");255    await popup.locator('input[placeholder="domain.com"]').fill("https://ring.com");256    await popup.locator('input[placeholder="username or email"]').fill("[email protected]");257    const pwdField = popup.locator(".peach-new-field-input[placeholder='password']");258    await pwdField.fill("pass123");259    await popup.locator("input[placeholder*='totp' i]").fill("JBSWY3DPEHPK3PXP");260    await popup.getByRole("button", { name: /create/i }).click();261    await popup.waitForTimeout(1500);262    await popup.locator("text=TOTPRing").first().click();263    await popup.waitForTimeout(500);264    const ring = popup.locator(".peach-totp-ring");265    await expect(ring).toBeVisible({ timeout: 3000 });266    await ctx.close();267  });268269  test("TOTP06 - entry without TOTP shows no totp UI", async () => {270    const ctx = await makeContext();271    const { popup } = await setupVault(ctx);272    await popup.locator('input[placeholder="search vault..."]').click();273    await popup.getByRole("button", { name: /new entry/i }).click();274    await popup.locator(".peach-new-name-input").fill("NoTOTP");275    await popup.locator('input[placeholder="domain.com"]').fill("https://nototp.com");276    await popup.locator('input[placeholder="username or email"]').fill("[email protected]");277    const pwdField = popup.locator(".peach-new-field-input[placeholder='password']");278    await pwdField.fill("pass123");279    await popup.getByRole("button", { name: /create/i }).click();280    await popup.waitForTimeout(1500);281    await popup.locator("text=NoTOTP").first().click();282    await popup.waitForTimeout(500);283    const totpBand = popup.locator(".peach-totp-band");284    const isHidden = await totpBand.isHidden().catch(() => true);285    expect(isHidden).toBe(true);286    await ctx.close();287  });288});289290test.describe("Vault Management", () => {291292  test("VAULT01 - create vault with different passwords shows mismatch error", async () => {293    const ctx = await makeContext();294    const extId = await waitForSW(ctx);295    const popup = await ctx.newPage();296    await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);297    await popup.waitForTimeout(2000);298    await popup.locator("#master-password").fill("password123");299    await popup.locator("#confirm-password").fill("differentpassword");300    await popup.getByRole("button", { name: /create vault/i }).click();301    await popup.waitForTimeout(500);302    const error = popup.locator(".peach-hint-error");303    await expect(error).toBeVisible({ timeout: 3000 });304    await ctx.close();305  });306307  test("VAULT02 - clear vault option exists in settings", async () => {308    const ctx = await makeContext();309    const { popup } = await setupVault(ctx);310    await popup.locator(".peach-icon-btn[aria-label='Settings']").click();311    await popup.waitForTimeout(300);312    await ctx.close();313  });314315  test("VAULT04 - vault persists through navigation", async () => {316    const ctx = await makeContext();317    const { popup } = await setupVault(ctx);318    await popup.locator('input[placeholder="search vault..."]').click();319    await popup.getByRole("button", { name: /new entry/i }).click();320    await popup.locator(".peach-new-name-input").fill("PersistNav");321    await popup.locator('input[placeholder="domain.com"]').fill("https://persistnav.com");322    await popup.locator('input[placeholder="username or email"]').fill("[email protected]");323    const pwdField = popup.locator(".peach-new-field-input[placeholder='password']");324    await pwdField.fill("pass123");325    await popup.getByRole("button", { name: /create/i }).click();326    await popup.waitForTimeout(1500);327    await popup.locator(".peach-icon-btn[aria-label='Settings']").click();328    await popup.waitForTimeout(300);329    await popup.getByRole("button", { name: /password generator/i }).click();330    await popup.waitForTimeout(500);331    await popup.keyboard.press("Escape");332    await popup.waitForTimeout(300);333    const entry = popup.locator("text=PersistNav");334    await expect(entry).toBeVisible({ timeout: 3000 });335    await ctx.close();336  });337338  test("VAULT05 - re-lock and unlock vault", async () => {339    const ctx = await makeContext();340    const { popup } = await setupVault(ctx);341    const lockBtn = popup.locator(".peach-lock-btn, [aria-label='Lock vault']").first();342    if (await lockBtn.isVisible({ timeout: 2000 }).catch(() => false)) {343      await lockBtn.click();344      await popup.waitForTimeout(1000);345      await popup.locator("#master-password").fill("password123");346      await popup.getByRole("button", { name: /unlock/i }).click();347      await popup.waitForTimeout(2000);348      const entries = popup.locator("[class*=entry]");349      await expect(entries.first()).toBeVisible({ timeout: 5000 });350    }351    await ctx.close();352  });353354  test("VAULT06 - search works with Cyrillic text", async () => {355    const ctx = await makeContext();356    const { popup } = await setupVault(ctx);357    await popup.locator('input[placeholder="search vault..."]').click();358    await popup.getByRole("button", { name: /new entry/i }).click();359    await popup.locator(".peach-new-name-input").fill("ТестПароль");360    await popup.locator('input[placeholder="domain.com"]').fill("https://тест.com");361    await popup.locator('input[placeholder="username or email"]').fill("пользователь@тест.com");362    const pwdField = popup.locator(".peach-new-field-input[placeholder='password']");363    await pwdField.fill("пароль123");364    await popup.getByRole("button", { name: /create/i }).click();365    await popup.waitForTimeout(1500);366    const searchInput = popup.locator(".peach-search-input, .peach-search-input-list").first();367    await searchInput.click();368    await searchInput.fill("Тест");369    await popup.waitForTimeout(300);370    await expect(popup.locator("text=ТестПароль")).toBeVisible({ timeout: 3000 });371    await ctx.close();372  });373374  test("VAULT07 - entries can be sorted", async () => {375    const ctx = await makeContext();376    const { popup } = await setupVault(ctx);377    for (const name of ["Zebra", "Apple", "Mango"]) {378      await popup.locator('input[placeholder="search vault..."]').click();379      await popup.getByRole("button", { name: /new entry/i }).click();380      await popup.locator(".peach-new-name-input").fill(name);381      await popup.locator('input[placeholder="domain.com"]').fill(`https://${name.toLowerCase()}.com`);382      await popup.locator('input[placeholder="username or email"]').fill(`${name.toLowerCase()}@test.com`);383      const pwdField = popup.locator(".peach-new-field-input[placeholder='password']");384      await pwdField.fill("pass123");385      await popup.getByRole("button", { name: /create/i }).click();386      await popup.waitForTimeout(500);387    }388    await ctx.close();389  });390391  test("VAULT08 - delete entry removes it from list", async () => {392    const ctx = await makeContext();393    const { popup } = await setupVault(ctx);394    await popup.locator('input[placeholder="search vault..."]').click();395    await popup.getByRole("button", { name: /new entry/i }).click();396    await popup.locator(".peach-new-name-input").fill("DeleteMe");397    await popup.locator('input[placeholder="domain.com"]').fill("https://deleteme.com");398    await popup.locator('input[placeholder="username or email"]').fill("[email protected]");399    const pwdField = popup.locator(".peach-new-field-input[placeholder='password']");400    await pwdField.fill("pass123");401    await popup.getByRole("button", { name: /create/i }).click();402    await popup.waitForTimeout(1500);403    await popup.locator("text=DeleteMe").first().click();404    await popup.waitForTimeout(500);405    const deleteLink = popup.locator("text=delete entry");406    if (await deleteLink.isVisible({ timeout: 2000 }).catch(() => false)) {407      await deleteLink.click();408      await popup.waitForTimeout(1000);409    }410    await ctx.close();411  });412});