diff --git a/.github/workflows/pr-check.yaml b/.github/workflows/pr-check.yaml index 885b700..c4639e8 100644 --- a/.github/workflows/pr-check.yaml +++ b/.github/workflows/pr-check.yaml @@ -45,3 +45,31 @@ jobs: run: pnpm install --frozen-lockfile - name: Build run: pnpm build + + e2e: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Install Playwright browsers + run: pnpm exec playwright install --with-deps chromium + - name: Run E2E tests + run: pnpm e2e + - name: Upload Playwright report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index b26920c..4479941 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,8 @@ docs/superpowers/screenshots/ # paraglide-js generated src/paraglide/ + +# Playwright +test-results/ +playwright-report/ +playwright/.cache/ diff --git a/docs/superpowers/plans/2026-05-04-e2e-tests-playwright.md b/docs/superpowers/plans/2026-05-04-e2e-tests-playwright.md new file mode 100644 index 0000000..90d4668 --- /dev/null +++ b/docs/superpowers/plans/2026-05-04-e2e-tests-playwright.md @@ -0,0 +1,1401 @@ +# E2E Tests (Playwright) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add Playwright Test as a separate E2E layer that exercises the real app via the dev server with actual button clicks and keyboard input, and remove the `pdfjs-dist` mock from `thumbnail.test.ts` in favor of a real fixture PDF. + +**Architecture:** Playwright Test runs in `e2e/`, separate from Vitest browser-mode unit tests in `src/`. Playwright's `webServer` boots `pnpm dev` (Vite) at `http://localhost:6123`. Tests reuse `demo/pdfpw-demo.pdf` and `demo/pdfpw-demo.pdfpc` as fixtures. Each test isolates state by clearing IndexedDB (`pdfpw` DB) and `localStorage` in `beforeEach`. Multi-window tests capture the presentation window via `BrowserContext.waitForEvent('page')`. + +**Tech Stack:** `@playwright/test` 1.59.1 (matching existing `playwright`), Vite dev server, GitHub Actions for CI. + +**Reference spec:** `docs/superpowers/specs/2026-05-04-e2e-tests-design.md` + +--- + +## File Structure + +| Path | Purpose | New / Modified | +|---|---|---| +| `playwright.config.ts` | Playwright Test config (testDir, webServer, projects) | New | +| `e2e/fixtures/pdfs.ts` | Absolute paths for `demo/*.pdf` / `*.pdfpc` | New | +| `e2e/helpers/reset-state.ts` | Clear IndexedDB / localStorage per test | New | +| `e2e/helpers/open-pdf.ts` | Upload demo fixture via `` and wait for presenter | New | +| `e2e/helpers/presentation-window.ts` | Capture and dispose the `window.open`-spawned presentation page | New | +| `e2e/tests/home-upload.spec.ts` | Scenario 1 | New | +| `e2e/tests/slide-navigation.spec.ts` | Scenario 4 | New | +| `e2e/tests/presenter-modes.spec.ts` | Scenario 5 | New | +| `e2e/tests/timer.spec.ts` | Scenario 6 | New | +| `e2e/tests/pdfpc-note.spec.ts` | Scenario 8 | New | +| `e2e/tests/locale-switch.spec.ts` | Scenario 9 | New | +| `e2e/tests/recent-files.spec.ts` | Scenario 3 | New | +| `e2e/tests/presenter-presentation-sync.spec.ts` | Scenario 7 | New | +| `package.json` | Add `@playwright/test`, `e2e` / `e2e:ui` scripts | Modify | +| `.gitignore` | Ignore Playwright outputs | Modify | +| `vite.config.ts` | Exclude `e2e/` from Vitest discovery | Modify | +| `src/routes/$locale/(main)/-presenter/NextPrevFooter.tsx` | Add `aria-label` to prev/next buttons (E2E selector) | Modify | +| `src/lib/thumbnail.test.ts` | Remove `pdfjs-dist` mock, use real fixture | Modify | +| `messages/en.json`, `messages/ja.json` | Add aria-label messages for prev/next | Modify | +| `.github/workflows/pr-check.yaml` | Add `e2e` job parallel to `test` / `build` | Modify | + +--- + +## Task 1: Install `@playwright/test` and project scaffolding + +**Files:** +- Modify: `package.json` +- Modify: `.gitignore` + +- [ ] **Step 1: Add `@playwright/test` to devDependencies and scripts** + +Run: + +```bash +pnpm add -D @playwright/test@1.59.1 +``` + +Then edit `package.json` `scripts` section to add `e2e` and `e2e:ui` (keep existing scripts intact): + +```json +"scripts": { + "dev": "vite --port 6123", + "build": "vite build && tsc -b", + "preview": "vite preview", + "test": "vitest run", + "e2e": "playwright test", + "e2e:ui": "playwright test --ui", + "format": "biome format", + "lint": "biome lint", + "check": "biome check", + "generate:icons": "node --experimental-strip-types scripts/generate-icons.mts" +} +``` + +- [ ] **Step 2: Install Playwright browsers (Chromium only)** + +Run: + +```bash +pnpm exec playwright install --with-deps chromium +``` + +Expected: download progress, then "Successfully installed". + +- [ ] **Step 3: Update `.gitignore`** + +Append: + +``` +# Playwright +test-results/ +playwright-report/ +playwright/.cache/ +``` + +- [ ] **Step 4: Verify install** + +Run: + +```bash +pnpm exec playwright --version +``` + +Expected: `Version 1.59.1`. + +- [ ] **Step 5: Commit** + +```bash +git add package.json pnpm-lock.yaml .gitignore +git commit -m "chore(e2e): add @playwright/test and scripts" +``` + +--- + +## Task 2: Create `playwright.config.ts` + +**Files:** +- Create: `playwright.config.ts` + +- [ ] **Step 1: Write config** + +```ts +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e/tests", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? "github" : "list", + use: { + baseURL: "http://localhost:6123", + trace: "on-first-retry", + }, + projects: [ + { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + ], + webServer: { + command: "pnpm dev", + url: "http://localhost:6123", + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, +}); +``` + +- [ ] **Step 2: Verify Playwright loads the config** + +Run (no tests yet, just discovery): + +```bash +pnpm exec playwright test --list +``` + +Expected: `Listing tests:` followed by `Total: 0 tests in 0 files` (no error about missing config). + +- [ ] **Step 3: Commit** + +```bash +git add playwright.config.ts +git commit -m "chore(e2e): add Playwright config" +``` + +--- + +## Task 3: Exclude `e2e/` from Vitest discovery + +**Files:** +- Modify: `vite.config.ts:221-229` (the `test:` block) + +- [ ] **Step 1: Add `exclude` entry** + +Locate the `test:` config block and update it: + +```ts +test: { + globals: true, + setupFiles: ["./src/test-setup.ts"], + exclude: ["**/node_modules/**", "**/dist/**", "e2e/**"], + browser: { + enabled: true, + provider: playwright(), + instances: [{ browser: "chromium" }, { browser: "firefox" }], + }, +}, +``` + +- [ ] **Step 2: Verify Vitest ignores e2e** + +Run: + +```bash +pnpm test --reporter=verbose 2>&1 | head -20 +``` + +Expected: existing tests collected; no error about `e2e/` (the dir is empty, but `exclude` should prevent any future regression). + +- [ ] **Step 3: Commit** + +```bash +git add vite.config.ts +git commit -m "chore(test): exclude e2e/ from Vitest" +``` + +--- + +## Task 4: Add fixtures helper + +**Files:** +- Create: `e2e/fixtures/pdfs.ts` + +- [ ] **Step 1: Write the helper** + +```ts +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, "..", ".."); + +export const fixtures = { + pdf: path.join(repoRoot, "demo", "pdfpw-demo.pdf"), + pdfpc: path.join(repoRoot, "demo", "pdfpw-demo.pdfpc"), +} as const; +``` + +- [ ] **Step 2: Sanity check the paths exist** + +Run: + +```bash +node --input-type=module -e "import('./e2e/fixtures/pdfs.ts').then(m=>console.log(m.fixtures))" 2>&1 | head -5 +``` + +(If TS-loading via Node fails, skip and rely on Playwright to import it later.) + +Alternative verification: + +```bash +test -f demo/pdfpw-demo.pdf && test -f demo/pdfpw-demo.pdfpc && echo OK +``` + +Expected: `OK`. + +- [ ] **Step 3: Commit** + +```bash +git add e2e/fixtures/pdfs.ts +git commit -m "test(e2e): add demo PDF fixture helper" +``` + +--- + +## Task 5: Add reset-state helper + +**Files:** +- Create: `e2e/helpers/reset-state.ts` + +- [ ] **Step 1: Write the helper** + +```ts +import type { Page } from "@playwright/test"; + +/** + * Clear IndexedDB ("pdfpw" DB defined in src/lib/recent-store.ts) and localStorage, + * then reload. Call once per test before navigating to feature pages. + */ +export async function resetAppState(page: Page): Promise { + await page.goto("/"); + await page.evaluate(async () => { + await new Promise((resolve) => { + const req = indexedDB.deleteDatabase("pdfpw"); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + req.onblocked = () => resolve(); + }); + localStorage.clear(); + }); + await page.reload(); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add e2e/helpers/reset-state.ts +git commit -m "test(e2e): add resetAppState helper" +``` + +--- + +## Task 6: Add open-pdf helper + +**Files:** +- Create: `e2e/helpers/open-pdf.ts` + +The home page renders a hidden `` (see `src/routes/$locale/(main)/-index/HeroSection.tsx:287-295`, `class="sr-only"`, `accept=".pdf,.pdfpc,.typ,application/pdf,application/json"`, `multiple`). Playwright can call `setInputFiles` on hidden inputs without dispatching a click. + +- [ ] **Step 1: Write the helper** + +```ts +import { expect, type Page } from "@playwright/test"; +import { fixtures } from "../fixtures/pdfs"; + +/** + * Upload the demo PDF + pdfpc via the hidden file input on the home page. + * Waits for navigation to //presenter and the slide counter to render. + * + * Returns the locale prefix that ended up being used (defaults to /en when LANG fallback hits English). + */ +export async function openDemoPdf( + page: Page, + options: { withPdfpc?: boolean } = {}, +): Promise<{ localePath: string }> { + const withPdfpc = options.withPdfpc ?? true; + const files = withPdfpc ? [fixtures.pdf, fixtures.pdfpc] : [fixtures.pdf]; + + const fileInput = page.locator('input[type="file"][accept*=".pdf"]'); + await fileInput.setInputFiles(files); + + await page.waitForURL(/\/(en|ja)\/presenter/, { timeout: 15_000 }); + // Wait for the slide counter rendered by NextPrevFooter ("{n} / {total}") + await expect(page.locator("text=/^\\s*\\d+\\s*\\/\\s*\\d+\\s*$/")).toBeVisible({ + timeout: 15_000, + }); + + const localePath = new URL(page.url()).pathname.startsWith("/ja") + ? "/ja" + : "/en"; + return { localePath }; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add e2e/helpers/open-pdf.ts +git commit -m "test(e2e): add openDemoPdf helper" +``` + +--- + +## Task 7: Add presentation-window helper + +**Files:** +- Create: `e2e/helpers/presentation-window.ts` + +`src/routes/$locale/(main)/index.tsx:256-261` calls `window.open(url, "_blank", ...)` immediately after navigating to presenter. The `BrowserContext` emits `'page'` when the new tab opens. + +- [ ] **Step 1: Write the helper** + +```ts +import type { BrowserContext, Page } from "@playwright/test"; + +/** + * Wait for the presentation window opened by `proceedWithPdf` (window.open). + * Pair with the action that triggers it (e.g. setInputFiles). + */ +export async function capturePresentationPage( + context: BrowserContext, + trigger: () => Promise, +): Promise { + const [presentation] = await Promise.all([ + context.waitForEvent("page"), + trigger(), + ]); + await presentation.waitForLoadState("domcontentloaded"); + return presentation; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add e2e/helpers/presentation-window.ts +git commit -m "test(e2e): add capturePresentationPage helper" +``` + +--- + +## Task 8: Scenario 1 — `home-upload.spec.ts` + +**Files:** +- Create: `e2e/tests/home-upload.spec.ts` + +This is the first end-to-end test and validates the entire setup. The presentation window is opened automatically; close it after to keep test isolation. + +- [ ] **Step 1: Write the failing spec** + +```ts +import { test, expect } from "@playwright/test"; +import { fixtures } from "../fixtures/pdfs"; +import { resetAppState } from "../helpers/reset-state"; + +test.describe("home upload → presenter", () => { + test.beforeEach(async ({ page }) => { + await resetAppState(page); + }); + + test("uploads PDF + pdfpc and navigates to presenter", async ({ page, context }) => { + const fileInput = page.locator('input[type="file"][accept*=".pdf"]'); + + const presentationPromise = context.waitForEvent("page"); + await fileInput.setInputFiles([fixtures.pdf, fixtures.pdfpc]); + + await page.waitForURL(/\/(en|ja)\/presenter/, { timeout: 15_000 }); + + // Slide counter from NextPrevFooter + await expect( + page.locator("text=/^\\s*\\d+\\s*\\/\\s*\\d+\\s*$/").first(), + ).toBeVisible({ timeout: 15_000 }); + + // Stage shows "SLIDE n / total" + await expect(page.locator("text=/SLIDE\\s+1\\s*\\/\\s*\\d+/i")).toBeVisible(); + + // Cleanup the auto-opened presentation window + const presentation = await presentationPromise; + await presentation.close(); + }); +}); +``` + +- [ ] **Step 2: Run and verify it passes (or surfaces a real bug)** + +Run: + +```bash +pnpm e2e --reporter=list +``` + +Expected: `1 passed`. If it fails, fix the helper / locator issue (do NOT add waits to mask flakiness — diagnose). + +- [ ] **Step 3: Commit** + +```bash +git add e2e/tests/home-upload.spec.ts +git commit -m "test(e2e): scenario 1 - home upload → presenter" +``` + +--- + +## Task 9: Add `aria-label` to prev/next buttons in `NextPrevFooter` + +The slide-navigation spec needs a stable accessible name for the Prev / Next icon buttons. They currently render only icons (`ChevronLeftCircleIcon`, `ChevronRightCircleIcon`) with no accessible name. + +**Files:** +- Modify: `messages/en.json`, `messages/ja.json` +- Modify: `src/routes/$locale/(main)/-presenter/NextPrevFooter.tsx:84-106` + +- [ ] **Step 1: Add message keys to `messages/en.json`** + +Add: + +```json +"presenter_prev_slide_aria": "Previous slide", +"presenter_next_slide_aria": "Next slide", +``` + +(Place them near other `presenter_*_aria` keys. The existing `kb_slide_next_label` / `kb_slide_prev_label` are for the keyboard-help dialog; we keep these distinct since they describe key labels rather than button purposes.) + +- [ ] **Step 2: Add the same keys to `messages/ja.json`** + +```json +"presenter_prev_slide_aria": "前のスライド", +"presenter_next_slide_aria": "次のスライド", +``` + +- [ ] **Step 3: Run paraglide compile (will be triggered by `pnpm dev` automatically; no separate command needed)** + +Run: + +```bash +pnpm tsc -b 2>&1 | head -20 +``` + +Expected: no errors related to missing message functions yet (they are referenced in next step). + +- [ ] **Step 4: Wire `aria-label` to the prev/next buttons in `NextPrevFooter.tsx`** + +Add `import * as m from "#src/paraglide/messages.js";` at the top if absent. + +Update the two ` +
+ {current + 1} / {pdfpcConfig.pages.length} +
+ +``` + +- [ ] **Step 5: Verify type-check** + +Run: + +```bash +pnpm tsc -b +``` + +Expected: no errors. + +- [ ] **Step 6: Verify existing tests still pass** + +Run: + +```bash +pnpm test +``` + +Expected: all green. + +- [ ] **Step 7: Commit** + +```bash +git add messages/en.json messages/ja.json src/routes/'$locale'/'(main)'/-presenter/NextPrevFooter.tsx +git commit -m "a11y(presenter): label prev/next slide buttons" +``` + +--- + +## Task 10: Scenario 4 — `slide-navigation.spec.ts` + +**Files:** +- Create: `e2e/tests/slide-navigation.spec.ts` + +Demo `pdfpw-demo.pdfpc` has 27 user slides (label `0` through `26`). The PDF itself has 74 raw pages. + +- [ ] **Step 1: Write the failing spec** + +```ts +import { test, expect } from "@playwright/test"; +import { fixtures } from "../fixtures/pdfs"; +import { resetAppState } from "../helpers/reset-state"; + +test.describe("slide navigation", () => { + let presentationCloser: (() => Promise) | null = null; + + test.beforeEach(async ({ page, context }) => { + await resetAppState(page); + const presentationPromise = context.waitForEvent("page"); + await page + .locator('input[type="file"][accept*=".pdf"]') + .setInputFiles([fixtures.pdf, fixtures.pdfpc]); + await page.waitForURL(/\/(en|ja)\/presenter/); + await expect( + page.locator("text=/^\\s*\\d+\\s*\\/\\s*\\d+\\s*$/").first(), + ).toBeVisible({ timeout: 15_000 }); + const presentation = await presentationPromise; + presentationCloser = async () => { + if (!presentation.isClosed()) await presentation.close(); + }; + }); + + test.afterEach(async () => { + await presentationCloser?.(); + presentationCloser = null; + }); + + const counter = (page: import("@playwright/test").Page) => + page.locator("text=/^\\s*\\d+\\s*\\/\\s*\\d+\\s*$/").first(); + + test("Next button advances one slide", async ({ page }) => { + await expect(counter(page)).toHaveText(/^\s*1\s*\/\s*\d+\s*$/); + await page.getByRole("button", { name: "Next slide" }).click(); + await expect(counter(page)).toHaveText(/^\s*2\s*\/\s*\d+\s*$/); + }); + + test("Prev button goes back one slide", async ({ page }) => { + await page.getByRole("button", { name: "Next slide" }).click(); + await expect(counter(page)).toHaveText(/^\s*2\s*\/\s*\d+\s*$/); + await page.getByRole("button", { name: "Previous slide" }).click(); + await expect(counter(page)).toHaveText(/^\s*1\s*\/\s*\d+\s*$/); + }); + + test("ArrowRight / ArrowLeft keys navigate", async ({ page }) => { + await page.locator("body").click(); // ensure focus + await page.keyboard.press("ArrowRight"); + await expect(counter(page)).toHaveText(/^\s*2\s*\/\s*\d+\s*$/); + await page.keyboard.press("ArrowLeft"); + await expect(counter(page)).toHaveText(/^\s*1\s*\/\s*\d+\s*$/); + }); + + test("Home / End jump to first / last user slide", async ({ page }) => { + await page.keyboard.press("End"); + await expect(counter(page)).toHaveText(/^\s*27\s*\/\s*27\s*$/); + await page.keyboard.press("Home"); + await expect(counter(page)).toHaveText(/^\s*1\s*\/\s*27\s*$/); + }); + + test("g 5 Enter jumps to slide 5", async ({ page }) => { + await page.locator("body").click(); + await page.keyboard.press("g"); + await page.keyboard.press("5"); + await page.keyboard.press("Enter"); + await expect(counter(page)).toHaveText(/^\s*5\s*\/\s*27\s*$/); + }); + + test("Backspace returns to previous jump position", async ({ page }) => { + // Establish history: slide 1 → jump to 10 + await page.keyboard.press("g"); + await page.keyboard.press("1"); + await page.keyboard.press("0"); + await page.keyboard.press("Enter"); + await expect(counter(page)).toHaveText(/^\s*10\s*\/\s*27\s*$/); + // Backspace should pop to where we came from (1) + await page.keyboard.press("Backspace"); + await expect(counter(page)).toHaveText(/^\s*1\s*\/\s*27\s*$/); + }); +}); +``` + +- [ ] **Step 2: Run and verify** + +Run: + +```bash +pnpm e2e e2e/tests/slide-navigation.spec.ts --reporter=list +``` + +Expected: all 6 tests pass. If "End" / "Home" land on a non-27 number, inspect the actual count rendered and adjust the regex (the demo `.pdfpc` has 27 user slides; this is the source of truth). + +- [ ] **Step 3: Commit** + +```bash +git add e2e/tests/slide-navigation.spec.ts +git commit -m "test(e2e): scenario 4 - slide navigation" +``` + +--- + +## Task 11: Scenario 5 — `presenter-modes.spec.ts` + +**Files:** +- Create: `e2e/tests/presenter-modes.spec.ts` + +`ModeForm.tsx` renders three buttons with `aria-label`s: "Freeze projection", "Blackout projection", "Show overview". Frozen/Blackout are toggle buttons; Overview opens a dialog. + +- [ ] **Step 1: Write the failing spec** + +```ts +import { test, expect } from "@playwright/test"; +import { fixtures } from "../fixtures/pdfs"; +import { resetAppState } from "../helpers/reset-state"; + +test.describe("presenter mode toggles", () => { + let presentationCloser: (() => Promise) | null = null; + + test.beforeEach(async ({ page, context }) => { + await resetAppState(page); + const presentationPromise = context.waitForEvent("page"); + await page + .locator('input[type="file"][accept*=".pdf"]') + .setInputFiles([fixtures.pdf, fixtures.pdfpc]); + await page.waitForURL(/\/(en|ja)\/presenter/); + await expect( + page.locator("text=/^\\s*\\d+\\s*\\/\\s*\\d+\\s*$/").first(), + ).toBeVisible({ timeout: 15_000 }); + const presentation = await presentationPromise; + presentationCloser = async () => { + if (!presentation.isClosed()) await presentation.close(); + }; + }); + + test.afterEach(async () => { + await presentationCloser?.(); + presentationCloser = null; + }); + + test("Freeze toggles aria-pressed", async ({ page }) => { + const btn = page.getByRole("button", { name: "Freeze projection" }); + await expect(btn).toHaveAttribute("aria-pressed", "false"); + await btn.click(); + await expect(btn).toHaveAttribute("aria-pressed", "true"); + await btn.click(); + await expect(btn).toHaveAttribute("aria-pressed", "false"); + }); + + test("Blackout toggles aria-pressed", async ({ page }) => { + const btn = page.getByRole("button", { name: "Blackout projection" }); + await expect(btn).toHaveAttribute("aria-pressed", "false"); + await btn.click(); + await expect(btn).toHaveAttribute("aria-pressed", "true"); + await btn.click(); + await expect(btn).toHaveAttribute("aria-pressed", "false"); + }); + + test("Overview button opens an overview dialog", async ({ page }) => { + await page.getByRole("button", { name: "Show overview" }).click(); + // OverviewDialog should appear; assert role=dialog + await expect(page.getByRole("dialog")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toBeHidden(); + }); +}); +``` + +- [ ] **Step 2: Run and verify** + +Run: + +```bash +pnpm e2e e2e/tests/presenter-modes.spec.ts --reporter=list +``` + +Expected: 3 passed. + +- [ ] **Step 3: Commit** + +```bash +git add e2e/tests/presenter-modes.spec.ts +git commit -m "test(e2e): scenario 5 - presenter mode toggles" +``` + +--- + +## Task 12: Scenario 6 — `timer.spec.ts` + +**Files:** +- Create: `e2e/tests/timer.spec.ts` + +Timer renders Play/Pause and Reset icon buttons (`Timer.tsx:407-431`). They have no `aria-label`; they have `lucide-react` SVGs only. We can locate them by relative position from the slide counter or by their SVG's `data-icon` attribute. The cleanest path is to add aria-labels (similar to Task 9). To avoid scope creep, we use the timer text format (digital `00:00:00`) and the order of buttons. + +- [ ] **Step 1: Add `aria-label` to timer buttons** + +Modify `src/routes/$locale/(main)/-presenter/Timer.tsx:407-431` to add aria-labels. + +First, add message keys to `messages/en.json`: + +```json +"presenter_timer_toggle_aria": "Start or pause timer", +"presenter_timer_reset_aria": "Reset timer", +``` + +And to `messages/ja.json`: + +```json +"presenter_timer_toggle_aria": "タイマーの開始/一時停止", +"presenter_timer_reset_aria": "タイマーをリセット", +``` + +Then in `Timer.tsx`, add `import * as m from "#src/paraglide/messages.js";` if not present (it already imports getLocale from runtime, so just import messages module). Update the two buttons: + +```tsx + + + +``` + +- [ ] **Step 2: Type-check** + +Run: + +```bash +pnpm tsc -b +``` + +Expected: no errors. + +- [ ] **Step 3: Run existing tests** + +Run: + +```bash +pnpm test +``` + +Expected: all green. + +- [ ] **Step 4: Write the failing spec** + +```ts +import { test, expect } from "@playwright/test"; +import { fixtures } from "../fixtures/pdfs"; +import { resetAppState } from "../helpers/reset-state"; + +test.describe("timer", () => { + let presentationCloser: (() => Promise) | null = null; + + test.beforeEach(async ({ page, context }) => { + await resetAppState(page); + const presentationPromise = context.waitForEvent("page"); + await page + .locator('input[type="file"][accept*=".pdf"]') + .setInputFiles([fixtures.pdf, fixtures.pdfpc]); + await page.waitForURL(/\/(en|ja)\/presenter/); + await expect( + page.locator("text=/^\\s*\\d+\\s*\\/\\s*\\d+\\s*$/").first(), + ).toBeVisible({ timeout: 15_000 }); + const presentation = await presentationPromise; + presentationCloser = async () => { + if (!presentation.isClosed()) await presentation.close(); + }; + }); + + test.afterEach(async () => { + await presentationCloser?.(); + presentationCloser = null; + }); + + test("Reset returns the timer to a zero-prefixed state", async ({ page }) => { + // Click toggle to start, wait > 1s so the timer ticks past 00:00. + await page + .getByRole("button", { name: "Start or pause timer" }) + .click(); + await page.waitForTimeout(1500); + + // Reset + await page.getByRole("button", { name: "Reset timer" }).click(); + + // Demo .pdfpc has duration: 25 (minutes). After reset the displayed time + // should match the duration format (e.g. "25:00:00" or "00:25:00"). + // Concretely: it should NOT be a strictly increasing arbitrary value; + // we assert it shows the duration with seconds == 00. + const timer = page.locator("span.font-mono.tabular-nums").first(); + await expect(timer).toHaveText(/00$/); + }); +}); +``` + +- [ ] **Step 5: Run and verify** + +Run: + +```bash +pnpm e2e e2e/tests/timer.spec.ts --reporter=list +``` + +Expected: 1 passed. If the `:00$` regex doesn't match, log the rendered text once via `console.log(await timer.textContent())` and adjust. + +- [ ] **Step 6: Commit** + +```bash +git add messages/en.json messages/ja.json src/routes/'$locale'/'(main)'/-presenter/Timer.tsx e2e/tests/timer.spec.ts +git commit -m "test(e2e): scenario 6 - timer + label timer buttons" +``` + +--- + +## Task 13: Scenario 8 — `pdfpc-note.spec.ts` + +**Files:** +- Create: `e2e/tests/pdfpc-note.spec.ts` + +The first slide of `pdfpw-demo.pdfpc` has the note `"Welcome! このデモはPDFPWの機能を紹介します。\n\nPDFPWはブラウザで動くpdfpc互換のプレゼンターコンソールです。"`. Slide 2 (PDF page 2) has no note → "No notes for this slide" should appear. + +- [ ] **Step 1: Write the failing spec** + +```ts +import { test, expect } from "@playwright/test"; +import { fixtures } from "../fixtures/pdfs"; +import { resetAppState } from "../helpers/reset-state"; + +test.describe("pdfpc note display", () => { + let presentationCloser: (() => Promise) | null = null; + + test.beforeEach(async ({ page, context }) => { + await resetAppState(page); + const presentationPromise = context.waitForEvent("page"); + await page + .locator('input[type="file"][accept*=".pdf"]') + .setInputFiles([fixtures.pdf, fixtures.pdfpc]); + await page.waitForURL(/\/(en|ja)\/presenter/); + await expect( + page.locator("text=/^\\s*\\d+\\s*\\/\\s*\\d+\\s*$/").first(), + ).toBeVisible({ timeout: 15_000 }); + const presentation = await presentationPromise; + presentationCloser = async () => { + if (!presentation.isClosed()) await presentation.close(); + }; + }); + + test.afterEach(async () => { + await presentationCloser?.(); + presentationCloser = null; + }); + + test("shows pdfpc note on first slide", async ({ page }) => { + await expect(page.getByText("Welcome!")).toBeVisible(); + await expect( + page.getByText("PDFPWはブラウザで動くpdfpc互換のプレゼンターコンソールです"), + ).toBeVisible(); + }); + + test("shows empty placeholder when slide has no note", async ({ page }) => { + // Slide 2 in the demo has no note (idx 1, label "1") + await page.getByRole("button", { name: "Next slide" }).click(); + await expect(page.getByText("No notes for this slide")).toBeVisible(); + }); +}); +``` + +- [ ] **Step 2: Run and verify** + +Run: + +```bash +pnpm e2e e2e/tests/pdfpc-note.spec.ts --reporter=list +``` + +Expected: 2 passed. + +- [ ] **Step 3: Commit** + +```bash +git add e2e/tests/pdfpc-note.spec.ts +git commit -m "test(e2e): scenario 8 - pdfpc note display" +``` + +--- + +## Task 14: Scenario 9 — `locale-switch.spec.ts` + +**Files:** +- Create: `e2e/tests/locale-switch.spec.ts` + +LocaleSwitcher renders two ` diff --git a/src/routes/$locale/(main)/-presenter/Timer.tsx b/src/routes/$locale/(main)/-presenter/Timer.tsx index 94b1331..f20be36 100644 --- a/src/routes/$locale/(main)/-presenter/Timer.tsx +++ b/src/routes/$locale/(main)/-presenter/Timer.tsx @@ -9,6 +9,7 @@ import { import { Button } from "#src/components/ui/button.tsx"; import type { ResolvedPdfpcConfigV2 } from "#src/lib/pdfpc-config"; import { cn } from "#src/lib/utils"; +import * as m from "#src/paraglide/messages.js"; import { getLocale } from "#src/paraglide/runtime.js"; export interface TimerHandle { @@ -409,6 +410,7 @@ export const Timer = forwardRef(function Timer( type="button" variant={view.isRunning ? "secondary" : "default"} size="icon-sm" + aria-label={m.presenter_timer_toggle_aria()} onClick={() => dispatch({ type: "TOGGLE", nowMs: Date.now() })} > {view.isRunning ? : } @@ -418,6 +420,7 @@ export const Timer = forwardRef(function Timer( type="button" variant="ghost" size="icon-sm" + aria-label={m.presenter_timer_reset_aria()} onClick={() => dispatch({ type: "RESET", diff --git a/src/routes/$locale/(main)/index.tsx b/src/routes/$locale/(main)/index.tsx index 78ecb72..5687ce0 100644 --- a/src/routes/$locale/(main)/index.tsx +++ b/src/routes/$locale/(main)/index.tsx @@ -10,6 +10,7 @@ import { useState, } from "react"; import * as typia from "typia"; +import { ensurePresenterPairId } from "#src/broadcast"; import { TypstDiagnosticList } from "#src/components/TypstDiagnosticList"; import { useLocalStorageSync } from "#src/hooks/use-local-storage-sync"; import { FetchPdfError, fetchPdfFromUrl } from "#src/lib/fetch-pdf"; @@ -249,6 +250,13 @@ function Home() { }, }).href; + // Seed the pair-id into sessionStorage BEFORE window.open so the popup + // inherits it at creation time. Without this, the presentation window + // has empty sessionStorage and falls back to lobby pairing, which can + // time out on slow environments before the presenter's broadcast hooks + // finish mounting (the presenter is suspended on PDF.js / pdfpc parsing). + ensurePresenterPairId(pdf.name); + if (presentationWindow && !presentationWindow.closed) { presentationWindow.location.href = url; presentationWindow.focus(); diff --git a/vite.config.ts b/vite.config.ts index 095176f..3ffe97e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,7 +9,7 @@ import { devtools } from "@tanstack/devtools-vite"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; import viteReact from "@vitejs/plugin-react"; import { regex } from "arkregex"; -import { defineConfig } from "vitest/config"; +import { configDefaults, defineConfig } from "vitest/config"; import { VitePWA } from "vite-plugin-pwa"; const GITHUB_REPO_URL_REGEX = regex( @@ -221,6 +221,7 @@ export default defineConfig({ test: { globals: true, setupFiles: ["./src/test-setup.ts"], + exclude: [...configDefaults.exclude, "e2e/**"], browser: { enabled: true, provider: playwright(),