From 064dd7a6cfaff68b171e15d42b0a6469253aec47 Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Mon, 4 May 2026 00:24:50 +0900 Subject: [PATCH 01/32] docs: add E2E tests design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan to introduce Playwright Test alongside existing Vitest browser mode, covering home → presenter flow, slide navigation, mode toggles, timer, multi-window broadcast sync, pdfpc note, and locale switch. Also removes PDF.js mocks from thumbnail.test.ts in favor of a real fixture PDF. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-05-04-e2e-tests-design.md | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-04-e2e-tests-design.md diff --git a/docs/superpowers/specs/2026-05-04-e2e-tests-design.md b/docs/superpowers/specs/2026-05-04-e2e-tests-design.md new file mode 100644 index 0000000..b222079 --- /dev/null +++ b/docs/superpowers/specs/2026-05-04-e2e-tests-design.md @@ -0,0 +1,256 @@ +# E2E テスト導入設計 (Playwright Test) + +## 概要 + +実ブラウザでアプリ全体を起動し、ボタンクリック・キーボード操作・マルチウィンドウ間の同期まで含めた挙動を検証する E2E テストを Playwright Test で導入する。あわせて `src/lib/thumbnail.test.ts` の `pdfjs-dist` モックを撤去し、実 PDF fixture を使った検証に置き換える。 + +既存の Vitest browser mode (Playwright provider, Chromium + Firefox) はコンポーネント / ロジックの単体テストとしてそのまま残す。E2E は別レイヤとして共存する。 + +## 採用方針 + +| 項目 | 採用 | 理由 | +|---|---|---| +| 追加するテストランナー | `@playwright/test` | アプリ全体起動・別ウィンドウ捕捉が可能 | +| サーバ | `pnpm dev` (Vite dev) | 起動が速い、E2E iteration が早い。本番ビルドの妥当性は別途 `build` ジョブで担保 | +| ブラウザ | Chromium 単一 | DOM 差は Vitest browser mode (Chromium + Firefox) で担保。E2E は通しの検証が目的 | +| ディレクトリ | `e2e/` をリポジトリ直下に新設 | Vitest と拡張子・配置で明確に分離 | +| CI | `.github/workflows/pr-check.yaml` に `e2e` ジョブ並列追加 | 既存 PR フローに統合、結果を即時得る | + +## カバーするシナリオ + +| # | スペックファイル | 検証内容 | +|---|---|---| +| 1 | `home-upload.spec.ts` | ホームの `` に PDF + pdfpc を `setInputFiles` し presenter に遷移、PDF が描画される | +| 3 | `recent-files.spec.ts` | アップロード後にホームへ戻ると Library に表示、クリックで再オープン。「履歴を保存」トグルで履歴が消えるか | +| 4 | `slide-navigation.spec.ts` | Next/Prev ボタン、矢印キー、Home/End、`g <数字> Enter`、Backspace の history | +| 5 | `presenter-modes.spec.ts` | Frozen / Blackout / Overview の 3 ボタンの toggle、`aria-pressed` の遷移 | +| 6 | `timer.spec.ts` | Timer の Start / Reset 操作と表示 | +| 7 | `presenter-presentation-sync.spec.ts` | `window.open` で開いた presentation を捕捉し、presenter 側のスライド変更・Blackout が presentation に反映 (Broadcast Channel) | +| 8 | `pdfpc-note.spec.ts` | `pdfpw-demo.pdfpc` に書かれた note が presenter の Note カードに表示 | +| 9 | `locale-switch.spec.ts` | LocaleSwitcher で en/ja 切替、URL prefix と localStorage 更新 | + +スコープ外 (今回見送り): + +- URL クエリ `?pdf=` 経由の自動読み込み (別 fixture サーバが必要) +- Typst 入力フロー (wasm ロードが重い) +- Drag & drop (DataTransfer エミュレーションが別案件) +- Visual regression / screenshot 比較 + +## ファイル構成 + +``` +playwright.config.ts # 新設 +e2e/ +├── fixtures/ +│ └── pdfs.ts # demo/pdfpw-demo.pdf 等の絶対パス helper +├── helpers/ +│ ├── open-pdf.ts # / にアクセスし setInputFiles で PDF を開く +│ ├── presentation-window.ts # context.waitForEvent('page') で別 window 捕捉 +│ └── reset-state.ts # IndexedDB / localStorage を毎テスト前にクリア +└── tests/ + ├── home-upload.spec.ts + ├── recent-files.spec.ts + ├── slide-navigation.spec.ts + ├── presenter-modes.spec.ts + ├── timer.spec.ts + ├── presenter-presentation-sync.spec.ts + ├── pdfpc-note.spec.ts + └── locale-switch.spec.ts +``` + +## Fixture + +- 既存 `demo/pdfpw-demo.pdf` (254KB, 74 ページ) と `demo/pdfpw-demo.pdfpc` をそのまま使用 (コピーしない) +- `setInputFiles` には絶対パスを渡す。helper でリポジトリルートからの相対解決 (`path.resolve(__dirname, '../../demo/...')`) をラップする +- 74 ページあるので Home/End/`g 50 Enter` のような飛び先のあるナビゲーションも検証可能 + +## `playwright.config.ts` + +```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, + }, +}); +``` + +## State isolation (`beforeEach`) + +各テストは独立して動くべきなので、`beforeEach` で IndexedDB と localStorage をクリアする。 + +```ts +test.beforeEach(async ({ page }) => { + await page.goto("/"); + await page.evaluate(() => { + indexedDB.deleteDatabase("pdfpw"); + localStorage.clear(); + }); + await page.reload(); +}); +``` + +`indexedDB.deleteDatabase` 名は `src/lib/recent-store.ts` の DB 名に合わせる (実装確認のうえ helper に固定値で持たせる)。 + +## マルチウィンドウ (シナリオ 7) + +`src/routes/$locale/(main)/index.tsx` の `proceedWithPdf` がファイル選択直後に `window.open(...)` で presentation を開く。Playwright では: + +```ts +const [presentationPage] = await Promise.all([ + context.waitForEvent("page"), + openPdf(page), // setInputFiles の中で window.open がトリガされる +]); +await presentationPage.waitForLoadState("domcontentloaded"); +``` + +注意点: `presentationWindow` モジュール変数 (`index.tsx`) が一度開いた window を再利用する。テスト間で残ると別テストの presentation が古い window に流れ込む可能性があるため、各テストで `presentationPage.close()` をクリーンアップに入れる。dev サーバ上では Vite HMR がモジュール変数を保持し続けるため、`page.reload()` での state クリアが効く。 + +## 同期検証ポイント (シナリオ 7) + +- presenter で Next ボタン → presenter のページ表示 (`{current+1} / {総数}` 形式、`NextPrevFooter.tsx`) が次に進む → presentation 側も同じスライドに同期 (実装の表示要素に対して assert)。総数は pdfpc の overlay グループ化に依存するため、`74` のような具体値は実 fixture をロード後に取得する +- Blackout ボタン押下 → presentation 側の表示が暗転 (実装の class / data attribute を assert) +- presentation 側の現スライド検証は Canvas ピクセルではなく、ページ番号テキストや `data-page` 等の DOM 属性で行う + +## `package.json` 追加 + +```json +{ + "scripts": { + "e2e": "playwright test", + "e2e:ui": "playwright test --ui" + }, + "devDependencies": { + "@playwright/test": "1.59.1" + } +} +``` + +既存 `playwright` (1.59.1) と版を揃える。 + +## `vite.config.ts` の Vitest 設定 + +E2E ディレクトリを Vitest が拾わないよう exclude を明示する: + +```diff + test: { + globals: true, + setupFiles: ["./src/test-setup.ts"], ++ exclude: ["**/node_modules/**", "**/dist/**", "e2e/**"], + browser: { ... }, + }, +``` + +## `thumbnail.test.ts` のモック撤去 + +### 変更前 (要約) + +```ts +vi.mock("pdfjs-dist/build/pdf.worker.min.mjs?url", () => ({ default: "mock-worker.js" })); +vi.mock("pdfjs-dist", () => ({ getDocument: vi.fn(), GlobalWorkerOptions: { workerSrc: "" } })); +// 各テストで mockGetDocument.mockReturnValue(...) を組み立て +``` + +### 変更後 + +- 両方の `vi.mock` を撤去 +- fixture PDF は `?url` インポートで取得 → `fetch` → `File`: + +```ts +import pdfUrl from "../../demo/pdfpw-demo.pdf?url"; + +async function loadDemoPdf(): Promise { + const res = await fetch(pdfUrl); + const blob = await res.blob(); + return new File([blob], "pdfpw-demo.pdf", { type: "application/pdf" }); +} +``` + +### テストケースの再定義 + +| ケース | 旧 | 新 | +|---|---|---| +| 成功 | mock の `getPage(1)` 呼び出しを assert | 結果が `data:image/jpeg;base64,` で始まり、長さが空 Canvas より十分大きい (例: > 1000) | +| ロードエラー | mock の `promise: Promise.reject(...)` | 不正バイト列 (`new File([new Uint8Array([0,0,0])], 'broken.pdf')`) を渡し `null` が返る | +| Canvas context 失敗 | `HTMLCanvasElement.prototype.getContext` を spy で `null` 返却 | 同じ (Canvas spy は実 PDF と独立) | + +実装詳細 (`getPage(1)` 呼び出し回数等) のアサーションは撤廃し、外部から観察可能な挙動 (戻り値) で検証する。 + +### 注意点 + +- `?url` インポートは Vite が dev/test サーバ越しに静的アセットとして配信する。Vitest browser mode は内部で Vite を使うため追加設定不要 +- worker (`pdf.worker.min.mjs`) も同様に Vite 経由で配信される +- 不正バイト列ケースで `getDocument(...).promise` が reject する。`thumbnail.ts` は `try/catch` で `null` 化する実装になっているか実装確認の上テスト + +## CI 統合 (`.github/workflows/pr-check.yaml`) + +既存 `test` / `build` と並列で `e2e` ジョブを追加: + +```yaml +e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm exec playwright install --with-deps chromium + - run: pnpm e2e + - if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 +``` + +ブラウザは Chromium のみインストール (Firefox は不要)。失敗時は `playwright-report/` を artifact に上げる。 + +## `.gitignore` 追加 + +``` +test-results/ +playwright-report/ +playwright/.cache/ +``` + +## 既存テストへの影響 + +- Vitest browser mode の既存テストは温存 +- 拡張子規約: `*.test.ts(x)` = Vitest、`*.spec.ts` = Playwright +- `thumbnail.test.ts` のみ内部のモック構造が変わる (上記) + +## ロールアウト順序 (実装計画で詰める観点) + +1. `@playwright/test` 追加 / `playwright.config.ts` / `e2e/` ひな形 / `.gitignore` / `vite.config.ts` exclude +2. helper (`open-pdf`, `reset-state`, `presentation-window`) +3. シナリオ 1 (`home-upload`) を最初に通す (基盤動作確認) +4. シナリオ 4, 5, 6, 8, 9 (単一ウィンドウで完結する系) を追加 +5. シナリオ 3 (recent files の状態管理) +6. シナリオ 7 (マルチウィンドウ — 最も複雑) +7. `thumbnail.test.ts` のモック撤去 +8. CI ジョブ追加 + +各ステップで `pnpm e2e` がローカル green、Chromium 1 ブラウザで CI 通過を確認しながら進める。 From 0c79fe0fb7872606d71e4b031dc1c1c5392f5036 Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Mon, 4 May 2026 00:32:27 +0900 Subject: [PATCH 02/32] docs: add E2E tests Playwright implementation plan 18-task plan for the spec at docs/superpowers/specs/2026-05-04-e2e-tests-design.md. Covers Playwright scaffolding, 8 E2E scenarios (home upload, slide nav, mode toggles, timer, pdfpc note, locale, recent files, presenter-presentation broadcast sync), thumbnail.test.ts mock removal, and CI job addition. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-04-e2e-tests-playwright.md | 1401 +++++++++++++++++ 1 file changed, 1401 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-04-e2e-tests-playwright.md 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 ` From 433b5662ff59e4190d40e09264ef96d78a5a7596 Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Mon, 4 May 2026 03:03:19 +0900 Subject: [PATCH 12/32] test(e2e): scenario 4 - slide navigation --- e2e/tests/slide-navigation.spec.ts | 116 +++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 e2e/tests/slide-navigation.spec.ts diff --git a/e2e/tests/slide-navigation.spec.ts b/e2e/tests/slide-navigation.spec.ts new file mode 100644 index 0000000..efb1aad --- /dev/null +++ b/e2e/tests/slide-navigation.spec.ts @@ -0,0 +1,116 @@ +import { test, expect, type Locator, type Page } from "@playwright/test"; +import { fixtures } from "../fixtures/pdfs"; +import { resetAppState } from "../helpers/reset-state"; + +/** + * Slide navigation E2E coverage (Plan Task 10 / Scenario 4). + * + * IMPORTANT — counter semantics: + * The footer counter in `NextPrevFooter.tsx` shows + * `{user-slide-index + 1} / {pdfpcConfig.pages.length}`, + * i.e. the *user slide* (overlay group) for the current PDF page. + * + * However, the underlying state (`pageNumber`) and the operations + * `nextSlide`, `prevSlide`, `jumpToSlide`, `jumpToFirstSlide`, + * `jumpToLastSlide` all operate on **PDF page numbers**, not user-slide + * indices (see `src/routes/$locale/(main)/presenter.tsx`). + * + * Demo `pdfpw-demo.pdfpc` has 74 PDF pages (overlays) grouped into 27 user + * slides (labels "0".."26"). The plan's task description assumed + * user-slide-indexed jumps; the actual implementation uses PDF page + * indices, so: + * - `g 7 Enter` → PDF page 7 = label "4" = user slide 5 ("5 / 27") + * - `g 1 0 Enter` → PDF page 10 = label "5" overlay 2 = user slide 6 + * + * Home / End / Next / Prev still produce expected counter values + * because the first PDF page (1) and the last PDF page (74) coincide + * with the first and last user slide, and Next/Prev only need to advance + * one PDF page to also advance one user slide from the initial 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(counter(page)).toBeVisible({ timeout: 15_000 }); + const presentation = await presentationPromise; + presentationCloser = async () => { + if (!presentation.isClosed()) await presentation.close(); + }; + }); + + test.afterEach(async () => { + await presentationCloser?.(); + presentationCloser = null; + }); + + // The footer counter is the
in NextPrevFooter + // rendering "{current_user_slide + 1} / {pdfpcConfig.pages.length}". + // Other counters exist on the page (e.g. NextSlide preview shows + // "{nextPdfPage} / {numPdfPages}"), so we scope by the text-2xl class. + const counter = (page: Page): Locator => + page.locator( + 'div.text-2xl:text-matches("^\\\\s*\\\\d+\\\\s*\\\\/\\\\s*\\\\d+\\\\s*$")', + ); + + 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 Enter jumps to the matching user slide", async ({ + page, + }) => { + // PDF page 7 (idx 6) has label "4" → user slide 5 of 27. + // `jumpToSlide` is implemented as a PDF-page jump, so the digits typed + // into jump-mode are PDF page numbers; the counter reports the + // resulting user slide. + await page.locator("body").click(); + await page.keyboard.press("g"); + await page.keyboard.press("7"); + 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 }) => { + // Jump to PDF page 10 (idx 9, label "5", overlay 2) → user slide 6. + await page.locator("body").click(); + 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*6\s*\/\s*27\s*$/); + // Backspace pops history → back to PDF page 1 → user slide 1. + await page.keyboard.press("Backspace"); + await expect(counter(page)).toHaveText(/^\s*1\s*\/\s*27\s*$/); + }); +}); From 0876ad7122d60d43d45790b53eb50c2cc5748534 Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Mon, 4 May 2026 03:05:03 +0900 Subject: [PATCH 13/32] test(e2e): scenario 5 - presenter mode toggles --- e2e/tests/presenter-modes.spec.ts | 54 +++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 e2e/tests/presenter-modes.spec.ts diff --git a/e2e/tests/presenter-modes.spec.ts b/e2e/tests/presenter-modes.spec.ts new file mode 100644 index 0000000..007910d --- /dev/null +++ b/e2e/tests/presenter-modes.spec.ts @@ -0,0 +1,54 @@ +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(); + }); +}); From aecc912d06e307dddd9a1b3cb6a15981cbbd90a2 Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Mon, 4 May 2026 03:13:37 +0900 Subject: [PATCH 14/32] test(e2e): scenario 6 - timer + label timer buttons Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/tests/timer.spec.ts | 46 +++++++++++++++++++ messages/en.json | 2 + messages/ja.json | 2 + .../$locale/(main)/-presenter/Timer.tsx | 3 ++ 4 files changed, 53 insertions(+) create mode 100644 e2e/tests/timer.spec.ts diff --git a/e2e/tests/timer.spec.ts b/e2e/tests/timer.spec.ts new file mode 100644 index 0000000..be96cc9 --- /dev/null +++ b/e2e/tests/timer.spec.ts @@ -0,0 +1,46 @@ +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$/); + }); +}); diff --git a/messages/en.json b/messages/en.json index e8f8175..7db796b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -88,6 +88,8 @@ "presenter_notes_empty": "No notes for this slide", "presenter_prev_slide_aria": "Previous slide", "presenter_next_slide_aria": "Next slide", + "presenter_timer_toggle_aria": "Start or pause timer", + "presenter_timer_reset_aria": "Reset timer", "presentation_missing_no_file_title": "No file specified", "presentation_missing_back_message": "Go back to the home page and select a file again.", diff --git a/messages/ja.json b/messages/ja.json index 289364e..58828a0 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -88,6 +88,8 @@ "presenter_notes_empty": "このスライドにノートはありません", "presenter_prev_slide_aria": "前のスライド", "presenter_next_slide_aria": "次のスライド", + "presenter_timer_toggle_aria": "タイマーの開始/一時停止", + "presenter_timer_reset_aria": "タイマーをリセット", "presentation_missing_no_file_title": "ファイルが指定されていません", "presentation_missing_back_message": "ホームに戻って再度ファイルを選択してください。", 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", From 5e0c8e21c729053a154cebd72a8a8c0cf589e7b4 Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Mon, 4 May 2026 03:14:51 +0900 Subject: [PATCH 15/32] test(e2e): scenario 8 - pdfpc note display --- e2e/tests/pdfpc-note.spec.ts | 41 ++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 e2e/tests/pdfpc-note.spec.ts diff --git a/e2e/tests/pdfpc-note.spec.ts b/e2e/tests/pdfpc-note.spec.ts new file mode 100644 index 0000000..cd580aa --- /dev/null +++ b/e2e/tests/pdfpc-note.spec.ts @@ -0,0 +1,41 @@ +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(); + }); +}); From b89656e5186ba43d15964faa321123ebfbd61fb5 Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Mon, 4 May 2026 03:15:52 +0900 Subject: [PATCH 16/32] test(e2e): scenario 9 - locale switcher --- e2e/tests/locale-switch.spec.ts | 34 +++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 e2e/tests/locale-switch.spec.ts diff --git a/e2e/tests/locale-switch.spec.ts b/e2e/tests/locale-switch.spec.ts new file mode 100644 index 0000000..f64b51d --- /dev/null +++ b/e2e/tests/locale-switch.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from "@playwright/test"; +import { resetAppState } from "../helpers/reset-state"; + +test.describe("locale switcher", () => { + test.beforeEach(async ({ page }) => { + await resetAppState(page); + }); + + test("switching to ja navigates to /ja and persists in localStorage", async ({ page }) => { + await page.goto("/en"); + await page.getByRole("button", { name: "日本語" }).click(); + await page.waitForURL(/\/ja(\/|$)/); + expect(await page.evaluate(() => localStorage.getItem("pdfpw:locale"))).toBe("ja"); + }); + + test("switching back to en navigates to /en and persists", async ({ page }) => { + await page.goto("/ja"); + await page.getByRole("button", { name: "English" }).click(); + await page.waitForURL(/\/en(\/|$)/); + expect(await page.evaluate(() => localStorage.getItem("pdfpw:locale"))).toBe("en"); + }); + + test("aria-pressed reflects the active locale", async ({ page }) => { + await page.goto("/en"); + await expect(page.getByRole("button", { name: "English" })).toHaveAttribute( + "aria-pressed", + "true", + ); + await expect(page.getByRole("button", { name: "日本語" })).toHaveAttribute( + "aria-pressed", + "false", + ); + }); +}); From 738bb2d4573825a14b9993175d90538c18716061 Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Mon, 4 May 2026 03:17:08 +0900 Subject: [PATCH 17/32] test(e2e): scenario 3 - recent files library --- e2e/tests/recent-files.spec.ts | 45 ++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 e2e/tests/recent-files.spec.ts diff --git a/e2e/tests/recent-files.spec.ts b/e2e/tests/recent-files.spec.ts new file mode 100644 index 0000000..fbdc441 --- /dev/null +++ b/e2e/tests/recent-files.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from "@playwright/test"; +import { fixtures } from "../fixtures/pdfs"; +import { resetAppState } from "../helpers/reset-state"; + +test.describe("recent files", () => { + 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("uploaded file appears in library after returning home", async ({ page }) => { + await page.goto("/en"); + // LibrarySection lists each recent by name; the file name from fixture is "pdfpw-demo.pdf" + await expect(page.getByText("pdfpw-demo.pdf").first()).toBeVisible({ + timeout: 10_000, + }); + }); + + test("deleting a recent file removes it from the library", async ({ page }) => { + await page.goto("/en"); + await expect(page.getByText("pdfpw-demo.pdf").first()).toBeVisible(); + + await page.getByRole("button", { name: "Delete from library" }).first().click(); + + await expect(page.getByText("pdfpw-demo.pdf")).toHaveCount(0); + }); +}); From f489436a0c288b5b0363569a930534020f3604af Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Mon, 4 May 2026 03:19:18 +0900 Subject: [PATCH 18/32] =?UTF-8?q?test(e2e):=20scenario=207=20-=20presenter?= =?UTF-8?q?=20=E2=86=94=20presentation=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/tests/presenter-presentation-sync.spec.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 e2e/tests/presenter-presentation-sync.spec.ts diff --git a/e2e/tests/presenter-presentation-sync.spec.ts b/e2e/tests/presenter-presentation-sync.spec.ts new file mode 100644 index 0000000..30a0c06 --- /dev/null +++ b/e2e/tests/presenter-presentation-sync.spec.ts @@ -0,0 +1,75 @@ +import { test, expect, type Page, type BrowserContext } from "@playwright/test"; +import { fixtures } from "../fixtures/pdfs"; +import { resetAppState } from "../helpers/reset-state"; + +async function nudgePresentation(presentation: Page): Promise { + // Keeps the auto-hiding menu visible (HIDE_DELAY_MS = 2500 in -Menu.tsx). + await presentation.mouse.move(10, 10); + await presentation.mouse.move(20, 20); +} + +async function uploadAndCapture( + page: Page, + context: BrowserContext, +): Promise { + 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/); + const presentation = await presentationPromise; + await presentation.waitForLoadState("domcontentloaded"); + await expect(presentation.getByText(/Connecting|接続/)).toBeHidden({ + timeout: 15_000, + }); + return presentation; +} + +test.describe("presenter ↔ presentation sync", () => { + test("next slide on presenter advances presentation", async ({ page, context }) => { + const presentation = await uploadAndCapture(page, context); + await nudgePresentation(presentation); + + const presentationCounter = presentation + .locator("span.font-mono.tabular-nums") + .filter({ hasText: /^\s*\d+\s*\/\s*\d+\s*$/ }) + .first(); + await expect(presentationCounter).toHaveText(/^\s*1\s*\/\s*27\s*$/); + + await page.getByRole("button", { name: "Next slide" }).click(); + + // Presenter footer counter is in NextPrevFooter (`div.text-2xl`), + // distinct from NextSlide preview which uses raw PDF page numbers. + const presenterCounter = page.locator( + 'div.text-2xl:text-matches("^\\\\s*\\\\d+\\\\s*\\\\/\\\\s*\\\\d+\\\\s*$")', + ); + await expect(presenterCounter).toHaveText(/^\s*2\s*\/\s*27\s*$/); + + await nudgePresentation(presentation); + await expect(presentationCounter).toHaveText(/^\s*2\s*\/\s*27\s*$/, { + timeout: 5_000, + }); + + await presentation.close(); + }); + + test("blackout on presenter blacks out presentation", async ({ page, context }) => { + const presentation = await uploadAndCapture(page, context); + + // presentation/index.tsx wraps the Menu in a div whose className gains + // `opacity-0` only when isBlackout is true. + const blackoutWrapper = presentation + .locator("div.absolute.bottom-24.w-full.flex.justify-center") + .first(); + await expect(blackoutWrapper).not.toHaveClass(/(?:^|\s)opacity-0(?:\s|$)/); + + await page.getByRole("button", { name: "Blackout projection" }).click(); + + await expect(blackoutWrapper).toHaveClass(/(?:^|\s)opacity-0(?:\s|$)/, { + timeout: 5_000, + }); + + await presentation.close(); + }); +}); From b7b2886b0a301af736809f186ee1b3ac30d7d59a Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Mon, 4 May 2026 03:23:46 +0900 Subject: [PATCH 19/32] test(thumbnail): remove pdfjs-dist mock, use real fixture --- src/lib/thumbnail.test.ts | 80 +++++++++++---------------------------- 1 file changed, 23 insertions(+), 57 deletions(-) diff --git a/src/lib/thumbnail.test.ts b/src/lib/thumbnail.test.ts index dbcbe68..2e0b4fd 100644 --- a/src/lib/thumbnail.test.ts +++ b/src/lib/thumbnail.test.ts @@ -1,74 +1,40 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("pdfjs-dist/build/pdf.worker.min.mjs?url", () => ({ - default: "mock-worker.js", -})); -vi.mock("pdfjs-dist", () => ({ - getDocument: vi.fn(), - GlobalWorkerOptions: { workerSrc: "" }, -})); - -import * as pdfjs from "pdfjs-dist"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import demoPdfUrl from "../../demo/pdfpw-demo.pdf?url"; import { generateThumbnail } from "./thumbnail.ts"; -describe("generateThumbnail", () => { - const mockGetDocument = vi.mocked(pdfjs.getDocument); - - beforeEach(() => { - const mockRender = vi.fn(() => ({ promise: Promise.resolve() })); - const mockGetViewport = vi.fn((opts?: { scale?: number }) => ({ - width: 800 * (opts?.scale ?? 1), - height: 600 * (opts?.scale ?? 1), - })); - const mockPage = { - getViewport: mockGetViewport, - render: mockRender, - }; - const mockPdfProxy = { - getPage: vi.fn(() => Promise.resolve(mockPage)), - destroy: vi.fn(() => Promise.resolve()), - }; - mockGetDocument.mockReturnValue({ - promise: Promise.resolve(mockPdfProxy), - // biome-ignore lint/suspicious/noExplicitAny: テスト用モック - } as any); - }); +async function loadDemoPdfFile(): Promise { + const res = await fetch(demoPdfUrl); + const blob = await res.blob(); + return new File([blob], "pdfpw-demo.pdf", { type: "application/pdf" }); +} +describe("generateThumbnail", () => { afterEach(() => { vi.restoreAllMocks(); }); - it("PDFの1ページ目のJPEG data URLを返す", async () => { - const file = new File(["pdf"], "test.pdf", { type: "application/pdf" }); + it("returns a JPEG data URL for a real PDF", async () => { + const file = await loadDemoPdfFile(); const result = await generateThumbnail(file); expect(result).toMatch(/^data:image\/jpeg;base64,/); - expect(mockGetDocument).toHaveBeenCalled(); - // biome-ignore lint/suspicious/noExplicitAny: テスト用モック - const proxy = await (mockGetDocument.mock.results[0].value as any).promise; - expect(proxy.getPage).toHaveBeenCalledWith(1); + // A rendered page produces a non-trivial JPEG (>1 KB encoded). + expect((result ?? "").length).toBeGreaterThan(1000); }); - it("PDF ロードエラー時は null を返す", async () => { - mockGetDocument.mockImplementation( - () => - ({ - // Promise.reject を即時生成すると unhandled rejection になるため、lazy に生成する - get promise() { - return Promise.reject(new Error("load error")); - }, - // biome-ignore lint/suspicious/noExplicitAny: テスト用モック - }) as any, - ); - const file = new File(["pdf"], "test.pdf", { type: "application/pdf" }); - const result = await generateThumbnail(file); + it("returns null for a corrupted PDF", async () => { + const broken = new File([new Uint8Array([0, 0, 0, 0])], "broken.pdf", { + type: "application/pdf", + }); + const result = await generateThumbnail(broken); expect(result).toBeNull(); }); - it("canvas の 2d context を取得できない場合は null を返す", async () => { - // biome-ignore lint/suspicious/noExplicitAny: テスト用モック - vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null as any); - - const file = new File(["pdf"], "test.pdf", { type: "application/pdf" }); + it("returns null when canvas getContext returns null", async () => { + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: forcing the failure branch + null as any, + ); + const file = await loadDemoPdfFile(); const result = await generateThumbnail(file); expect(result).toBeNull(); }); From 73c1dbcd2081469f860007eef3449751c439a8c7 Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Mon, 4 May 2026 03:25:01 +0900 Subject: [PATCH 20/32] ci: run E2E tests on PRs --- .github/workflows/pr-check.yaml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) 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 From bdfb8557e1c66fa9956df62159b6faa8846fa978 Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Mon, 4 May 2026 09:08:45 +0900 Subject: [PATCH 21/32] fix(e2e): make sync spec robust to slow CI init Wait for the presentation canvas (positive signal that broadcast init succeeded and SlideStage mounted) instead of "Connecting" disappearing, which falsely resolves when the presentation enters an error fallback. Also keep the auto-hiding menu visible during assertions via a background mouse-nudge loop, and emit Playwright HTML reports so CI artifact upload captures something on failure. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/tests/presenter-presentation-sync.spec.ts | 73 ++++++++++++++----- playwright.config.ts | 4 +- 2 files changed, 56 insertions(+), 21 deletions(-) diff --git a/e2e/tests/presenter-presentation-sync.spec.ts b/e2e/tests/presenter-presentation-sync.spec.ts index 30a0c06..0122d90 100644 --- a/e2e/tests/presenter-presentation-sync.spec.ts +++ b/e2e/tests/presenter-presentation-sync.spec.ts @@ -4,8 +4,25 @@ import { resetAppState } from "../helpers/reset-state"; async function nudgePresentation(presentation: Page): Promise { // Keeps the auto-hiding menu visible (HIDE_DELAY_MS = 2500 in -Menu.tsx). - await presentation.mouse.move(10, 10); - await presentation.mouse.move(20, 20); + // CI is slower than local — do several nudges across a wider area to make + // sure pointermove handlers see them and reset the hide timer. + await presentation.mouse.move(100, 100); + await presentation.mouse.move(120, 110); + await presentation.mouse.move(150, 130); +} + +/** + * Wait until the presentation page has fully initialized — i.e. the broadcast + * pairing succeeded, init data was received, and PresentationView is rendered. + * + * Asserting "Connecting" is hidden is a NEGATIVE signal that also resolves + * instantly when the presentation enters an error fallback (the loading text + * never renders in that case). We wait for a POSITIVE signal: the SlideStage + * canvas, which only mounts once `pdfProxy` is resolved. + */ +async function waitForPresentationReady(presentation: Page): Promise { + const canvas = presentation.locator("canvas").first(); + await canvas.waitFor({ state: "visible", timeout: 30_000 }); } async function uploadAndCapture( @@ -20,36 +37,50 @@ async function uploadAndCapture( await page.waitForURL(/\/(en|ja)\/presenter/); const presentation = await presentationPromise; await presentation.waitForLoadState("domcontentloaded"); - await expect(presentation.getByText(/Connecting|接続/)).toBeHidden({ - timeout: 15_000, - }); + await waitForPresentationReady(presentation); return presentation; } test.describe("presenter ↔ presentation sync", () => { test("next slide on presenter advances presentation", async ({ page, context }) => { const presentation = await uploadAndCapture(page, context); - await nudgePresentation(presentation); const presentationCounter = presentation .locator("span.font-mono.tabular-nums") .filter({ hasText: /^\s*\d+\s*\/\s*\d+\s*$/ }) .first(); - await expect(presentationCounter).toHaveText(/^\s*1\s*\/\s*27\s*$/); - await page.getByRole("button", { name: "Next slide" }).click(); + // Start a background loop that keeps the auto-hiding menu visible while + // we run assertions. Stop the loop at the end of the test. + let keepNudging = true; + const nudgeLoop = (async () => { + while (keepNudging) { + await nudgePresentation(presentation).catch(() => {}); + await presentation.waitForTimeout(500); + } + })(); - // Presenter footer counter is in NextPrevFooter (`div.text-2xl`), - // distinct from NextSlide preview which uses raw PDF page numbers. - const presenterCounter = page.locator( - 'div.text-2xl:text-matches("^\\\\s*\\\\d+\\\\s*\\\\/\\\\s*\\\\d+\\\\s*$")', - ); - await expect(presenterCounter).toHaveText(/^\s*2\s*\/\s*27\s*$/); + try { + await expect(presentationCounter).toHaveText(/^\s*1\s*\/\s*27\s*$/, { + timeout: 10_000, + }); - await nudgePresentation(presentation); - await expect(presentationCounter).toHaveText(/^\s*2\s*\/\s*27\s*$/, { - timeout: 5_000, - }); + await page.getByRole("button", { name: "Next slide" }).click(); + + // Presenter footer counter is in NextPrevFooter (`div.text-2xl`), + // distinct from NextSlide preview which uses raw PDF page numbers. + const presenterCounter = page.locator( + 'div.text-2xl:text-matches("^\\\\s*\\\\d+\\\\s*\\\\/\\\\s*\\\\d+\\\\s*$")', + ); + await expect(presenterCounter).toHaveText(/^\s*2\s*\/\s*27\s*$/); + + await expect(presentationCounter).toHaveText(/^\s*2\s*\/\s*27\s*$/, { + timeout: 10_000, + }); + } finally { + keepNudging = false; + await nudgeLoop; + } await presentation.close(); }); @@ -62,12 +93,14 @@ test.describe("presenter ↔ presentation sync", () => { const blackoutWrapper = presentation .locator("div.absolute.bottom-24.w-full.flex.justify-center") .first(); - await expect(blackoutWrapper).not.toHaveClass(/(?:^|\s)opacity-0(?:\s|$)/); + await expect(blackoutWrapper).not.toHaveClass(/(?:^|\s)opacity-0(?:\s|$)/, { + timeout: 10_000, + }); await page.getByRole("button", { name: "Blackout projection" }).click(); await expect(blackoutWrapper).toHaveClass(/(?:^|\s)opacity-0(?:\s|$)/, { - timeout: 5_000, + timeout: 10_000, }); await presentation.close(); diff --git a/playwright.config.ts b/playwright.config.ts index d5a294d..26aaa16 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -6,7 +6,9 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, - reporter: process.env.CI ? "github" : "list", + reporter: process.env.CI + ? [["github"], ["html", { open: "never" }]] + : [["list"], ["html", { open: "never" }]], use: { baseURL: "http://localhost:6123", trace: "on-first-retry", From 058607f1b7cabc7cd275fb373c0562465b9b988f Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Mon, 4 May 2026 09:19:29 +0900 Subject: [PATCH 22/32] fix(broadcast): seed presenter pair-id before window.open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The popup that hosts the presentation page snapshots the opener's sessionStorage at creation time. Previously the pair-id was only set during presenter render — which is gated on PDF.js + pdfpc Suspense — so on slow environments the popup opened with empty sessionStorage and fell back to lobby pairing. Lobby pairing has a 5s deadline, but the presenter's broadcast hook can take longer than that to mount when the PDF takes time to parse, surfacing TIMEOUT_PAIRING_PRESENTATION. Calling ensurePresenterPairId(pdf.name) immediately before window.open seeds the storage synchronously, so the popup inherits a usable pair-id and the BroadcastChannel pairs without going through the lobby. Caught by the new presenter-presentation-sync E2E spec running on the GitHub Actions Linux runner. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/routes/$locale/(main)/index.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) 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(); From 5c498481b27f793d4c7794a22907459fd1c3ae5f Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Mon, 4 May 2026 09:44:46 +0900 Subject: [PATCH 23/32] test(e2e): scenario 11 - pointer tools (laser + pen) cross-window sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 6 tests covering the laser pointer and pen drawing tools, which broadcast state via sendTool. Verifies: - laser activated on presenter syncs the dot to presentation - laser activated on presentation syncs the dot to presenter - pen stroke drawn on presenter appears on presentation - pen stroke drawn on presentation appears on presenter - erase (`e`) clears strokes on both windows - Escape removes the overlay portal on both windows Configured to run serially within the file (test.describe.configure mode: serial) — these tests share heavy multi-window interactions and race the dev server's module compilation when run in parallel. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/tests/pointer-tools.spec.ts | 223 ++++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 e2e/tests/pointer-tools.spec.ts diff --git a/e2e/tests/pointer-tools.spec.ts b/e2e/tests/pointer-tools.spec.ts new file mode 100644 index 0000000..314f7dc --- /dev/null +++ b/e2e/tests/pointer-tools.spec.ts @@ -0,0 +1,223 @@ +import { test, expect, type Page, type BrowserContext } from "@playwright/test"; +import { fixtures } from "../fixtures/pdfs"; +import { resetAppState } from "../helpers/reset-state"; + +/** + * Tool keys (from src/lib/keybindings.ts): + * - `l` → laser toggle + * - `d` → pen toggle + * - `e` → erase pen strokes + * - `Escape` → exit tool + * + * Both presenter and presentation register `useToolShortcut` with scope "both", + * so the same keys work from either window. State changes are broadcast via + * `sendTool` (src/broadcast/tools.ts), so a tool change on either side updates + * the other's PointerOverlay. + */ + +async function uploadAndCapture( + page: Page, + context: BrowserContext, +): Promise { + 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/); + const presentation = await presentationPromise; + await presentation.waitForLoadState("domcontentloaded"); + + // Wait for BOTH windows to render their slide canvas. The presenter's + // useToolShortcut / useToolBroadcast hooks are inside PresenterContent + // which is suspended on PDF.js + pdfpc parsing. Pressing tool keys before + // these hooks mount silently no-ops, so without this wait the test fails + // flakily under parallel load on a dev server (Vite serves modules slower + // when many specs request them concurrently). + await Promise.all([ + page.locator("canvas").first().waitFor({ state: "visible", timeout: 30_000 }), + presentation + .locator("canvas") + .first() + .waitFor({ state: "visible", timeout: 30_000 }), + ]); + return presentation; +} + +/** + * The PointerOverlay portal renders this wrapper into only when + * toolMode !== "none" or there are pen strokes. Its presence (or absence) + * is the most stable signal for "is a tool currently active". + */ +function pointerOverlayPortal(page: Page) { + return page.locator("body > div.fixed.pointer-events-none.z-50"); +} + +/** + * Pen strokes render as inside the overlay's . + * Counting these is the most reliable way to verify pen drawing arrived. + */ +function penPolylines(page: Page) { + return page.locator('body > div.fixed.pointer-events-none.z-50 svg polyline[stroke="#ef4444"]'); +} + +/** + * The laser dot wrapper has inline `style="display: none"` initially and is + * flipped to `display: ""` (computed: `block`) when laser mode is active and + * the laser position atom is non-null. + */ +function laserDotWrapper(page: Page) { + // The wrapper is the only descendant of the portal carrying width:0 inline + // style (see PointerOverlay.tsx LaserDot). + return page.locator('body > div.fixed.pointer-events-none.z-50 div[style*="width: 0"]').first(); +} + +async function getCenterOfCanvas(page: Page): Promise<{ x: number; y: number }> { + const box = await page.locator("canvas").first().boundingBox(); + if (!box) throw new Error("canvas not found"); + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; +} + +// These tests exercise heavy multi-window pointer/keyboard interactions and +// share a BroadcastChannel keyed on the demo file name. Running them in +// parallel against a single Vite dev server has surfaced flake (overlay +// portal not mounting in time when tool keys race ahead of PresenterContent +// suspense). CI runs workers=1 so this is a no-op there; locally it brings +// behavior in line with CI. +test.describe.configure({ mode: "serial" }); + +test.describe("pointer tools (laser + pen)", () => { + test("laser activated on presenter syncs dot to presentation", async ({ page, context }) => { + const presentation = await uploadAndCapture(page, context); + + // Presenter focuses its window then presses `l` to enable laser. + // focus body so window-level keydown listeners receive the key + await page.locator("body").press("l"); + + // Moving the pointer on presenter sends pointer-move broadcast. + const c = await getCenterOfCanvas(page); + await page.mouse.move(c.x, c.y); + // Slight extra movement so coalesced events flush a sample. + await page.mouse.move(c.x + 5, c.y + 5); + await page.mouse.move(c.x + 10, c.y + 10); + + // PointerOverlay portal must appear on BOTH sides (toolMode === "laser" + // is broadcast as `tool-mode`). + await expect(pointerOverlayPortal(page)).toBeAttached({ timeout: 5_000 }); + await expect(pointerOverlayPortal(presentation)).toBeAttached({ timeout: 5_000 }); + + // The laser dot on the presentation side gets `display: block` once a + // pointer-move broadcast arrives with a position. + await expect(laserDotWrapper(presentation)).toHaveCSS("display", "block", { + timeout: 10_000, + }); + + await presentation.close(); + }); + + test("laser activated on presentation syncs dot to presenter", async ({ page, context }) => { + const presentation = await uploadAndCapture(page, context); + + // Click presentation body to give it focus, then press `l`. + // focus body so window-level keydown listeners receive the key + await presentation.locator("body").press("l"); + + const c = await getCenterOfCanvas(presentation); + await presentation.mouse.move(c.x, c.y); + await presentation.mouse.move(c.x + 5, c.y + 5); + await presentation.mouse.move(c.x + 10, c.y + 10); + + await expect(pointerOverlayPortal(presentation)).toBeAttached({ timeout: 5_000 }); + await expect(pointerOverlayPortal(page)).toBeAttached({ timeout: 5_000 }); + await expect(laserDotWrapper(page)).toHaveCSS("display", "block", { + timeout: 10_000, + }); + + await presentation.close(); + }); + + test("pen drawing on presenter appears on presentation", async ({ page, context }) => { + const presentation = await uploadAndCapture(page, context); + + // focus body so window-level keydown listeners receive the key + await page.locator("body").press("d"); + + // Draw a short stroke across the presenter canvas. + const c = await getCenterOfCanvas(page); + await page.mouse.move(c.x - 40, c.y); + await page.mouse.down(); + await page.mouse.move(c.x - 20, c.y + 10, { steps: 5 }); + await page.mouse.move(c.x, c.y + 20, { steps: 5 }); + await page.mouse.move(c.x + 30, c.y + 10, { steps: 5 }); + await page.mouse.up(); + + // Both sides should now have at least one polyline. + await expect(penPolylines(page)).toHaveCount(1, { timeout: 5_000 }); + await expect(penPolylines(presentation)).toHaveCount(1, { timeout: 10_000 }); + + await presentation.close(); + }); + + test("pen drawing on presentation appears on presenter", async ({ page, context }) => { + const presentation = await uploadAndCapture(page, context); + + // focus body so window-level keydown listeners receive the key + await presentation.locator("body").press("d"); + + const c = await getCenterOfCanvas(presentation); + await presentation.mouse.move(c.x - 40, c.y); + await presentation.mouse.down(); + await presentation.mouse.move(c.x - 20, c.y + 10, { steps: 5 }); + await presentation.mouse.move(c.x, c.y + 20, { steps: 5 }); + await presentation.mouse.move(c.x + 30, c.y + 10, { steps: 5 }); + await presentation.mouse.up(); + + await expect(penPolylines(presentation)).toHaveCount(1, { timeout: 5_000 }); + await expect(penPolylines(page)).toHaveCount(1, { timeout: 10_000 }); + + await presentation.close(); + }); + + test("erase clears pen strokes on both windows", async ({ page, context }) => { + const presentation = await uploadAndCapture(page, context); + + // Draw on presenter + // focus body so window-level keydown listeners receive the key + await page.locator("body").press("d"); + const c = await getCenterOfCanvas(page); + await page.mouse.move(c.x - 30, c.y); + await page.mouse.down(); + await page.mouse.move(c.x + 30, c.y + 20, { steps: 6 }); + await page.mouse.up(); + + await expect(penPolylines(page)).toHaveCount(1, { timeout: 5_000 }); + await expect(penPolylines(presentation)).toHaveCount(1, { timeout: 10_000 }); + + // Erase + await page.locator("body").press("e"); + + await expect(penPolylines(page)).toHaveCount(0, { timeout: 5_000 }); + await expect(penPolylines(presentation)).toHaveCount(0, { timeout: 5_000 }); + + await presentation.close(); + }); + + test("Escape exits tool mode and removes the overlay", async ({ page, context }) => { + const presentation = await uploadAndCapture(page, context); + + // focus body so window-level keydown listeners receive the key + await page.locator("body").press("l"); + + // Overlay portal exists in both windows once tool mode is non-none. + await expect(pointerOverlayPortal(page)).toBeAttached({ timeout: 5_000 }); + await expect(pointerOverlayPortal(presentation)).toBeAttached({ timeout: 5_000 }); + + await page.locator("body").press("Escape"); + + // With no strokes and toolMode back to none, the portal unmounts. + await expect(pointerOverlayPortal(page)).toHaveCount(0, { timeout: 5_000 }); + await expect(pointerOverlayPortal(presentation)).toHaveCount(0, { timeout: 5_000 }); + + await presentation.close(); + }); +}); From 571aa3df3a83f0020c2397c8439385117085038c Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Mon, 4 May 2026 10:22:46 +0900 Subject: [PATCH 24/32] test(e2e): isolate fixtures per worker to stop BroadcastChannel cross-talk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `e2e/helpers/test-fixtures.ts` with a worker-scoped Playwright fixture that copies the demo PDF and pdfpc to a unique-named pair (e.g. `pdfpw-demo-w0.pdf`) per worker. The app keys its BroadcastChannel name and sessionStorage pair-id on `File.name`, so unique names prevent parallel test workers from cross-pairing or stomping on each other's tool state. All upload-spawning specs now consume `uniqueFixtures` instead of the static `fixtures` import. `locale-switch.spec.ts` is unchanged because it doesn't upload. Also: - pointer-tools.spec.ts gets `retries: 2` (in addition to `mode: "serial"`) to absorb a residual flake where useToolShortcut's window keydown listener occasionally registers after the spec presses its first key. - pressShortcut switched to `body.press` (single dispatch) — combining it with `window.dispatchEvent` was double-firing the toggle and observably flipping toolMode laser → none in one synthetic press. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/helpers/test-fixtures.ts | 64 ++++++++++++ e2e/tests/home-upload.spec.ts | 7 +- e2e/tests/pdfpc-note.spec.ts | 7 +- e2e/tests/pointer-tools.spec.ts | 97 ++++++++++++------- e2e/tests/presenter-modes.spec.ts | 7 +- e2e/tests/presenter-presentation-sync.spec.ts | 22 +++-- e2e/tests/recent-files.spec.ts | 19 ++-- e2e/tests/slide-navigation.spec.ts | 8 +- e2e/tests/timer.spec.ts | 7 +- 9 files changed, 168 insertions(+), 70 deletions(-) create mode 100644 e2e/helpers/test-fixtures.ts diff --git a/e2e/helpers/test-fixtures.ts b/e2e/helpers/test-fixtures.ts new file mode 100644 index 0000000..42728df --- /dev/null +++ b/e2e/helpers/test-fixtures.ts @@ -0,0 +1,64 @@ +import { test as base } from "@playwright/test"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, "..", ".."); +const sourcePdf = path.join(repoRoot, "demo", "pdfpw-demo.pdf"); +const sourcePdfpc = path.join(repoRoot, "demo", "pdfpw-demo.pdfpc"); + +interface UniqueFixtures { + /** Unique PDF basename (e.g. `pdfpw-demo-w0.pdf`) — what File.name resolves to. */ + pdfName: string; + /** Unique pdfpc basename. */ + pdfpcName: string; + /** Absolute path to the worker-local PDF copy. */ + pdf: string; + /** Absolute path to the worker-local pdfpc copy. */ + pdfpc: string; +} + +/** + * Each worker gets its own copy of the demo PDF + pdfpc with unique basenames + * (e.g. `pdfpw-demo-w0.pdf`). The app keys its BroadcastChannel name and + * sessionStorage pair-id on `File.name`, so unique names per worker prevent + * cross-talk between parallel test workers running against the same dev + * server. Without this isolation, a presenter in worker A could end up paired + * with a presentation in worker B because both opened the same fileName. + * + * The .pdfpc basename must share the .pdf's stem (e.g. `pdfpw-demo-w0`), since + * `proceedWithPdf` only treats them as a pair when their stems match. + */ +export const test = base.extend<{ uniqueFixtures: UniqueFixtures }, { _workerFixtures: UniqueFixtures }>( + { + _workerFixtures: [ + async ({}, use, workerInfo) => { + const id = `w${workerInfo.workerIndex}`; + const stem = `pdfpw-demo-${id}`; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), `pdfpw-e2e-${id}-`)); + const pdfPath = path.join(tempDir, `${stem}.pdf`); + const pdfpcPath = path.join(tempDir, `${stem}.pdfpc`); + await fs.copyFile(sourcePdf, pdfPath); + await fs.copyFile(sourcePdfpc, pdfpcPath); + + await use({ + pdfName: `${stem}.pdf`, + pdfpcName: `${stem}.pdfpc`, + pdf: pdfPath, + pdfpc: pdfpcPath, + }); + + await fs.rm(tempDir, { recursive: true, force: true }); + }, + { scope: "worker" }, + ], + + uniqueFixtures: async ({ _workerFixtures }, use) => { + await use(_workerFixtures); + }, + }, +); + +export { expect } from "@playwright/test"; diff --git a/e2e/tests/home-upload.spec.ts b/e2e/tests/home-upload.spec.ts index 634e0da..1c1e97f 100644 --- a/e2e/tests/home-upload.spec.ts +++ b/e2e/tests/home-upload.spec.ts @@ -1,5 +1,4 @@ -import { test, expect } from "@playwright/test"; -import { fixtures } from "../fixtures/pdfs"; +import { test, expect } from "../helpers/test-fixtures"; import { resetAppState } from "../helpers/reset-state"; test.describe("home upload → presenter", () => { @@ -7,11 +6,11 @@ test.describe("home upload → presenter", () => { await resetAppState(page); }); - test("uploads PDF + pdfpc and navigates to presenter", async ({ page, context }) => { + test("uploads PDF + pdfpc and navigates to presenter", async ({ page, context, uniqueFixtures }) => { const fileInput = page.locator('input[type="file"][accept*=".pdf"]'); const presentationPromise = context.waitForEvent("page"); - await fileInput.setInputFiles([fixtures.pdf, fixtures.pdfpc]); + await fileInput.setInputFiles([uniqueFixtures.pdf, uniqueFixtures.pdfpc]); await page.waitForURL(/\/(en|ja)\/presenter/, { timeout: 15_000 }); diff --git a/e2e/tests/pdfpc-note.spec.ts b/e2e/tests/pdfpc-note.spec.ts index cd580aa..662bc5d 100644 --- a/e2e/tests/pdfpc-note.spec.ts +++ b/e2e/tests/pdfpc-note.spec.ts @@ -1,16 +1,15 @@ -import { test, expect } from "@playwright/test"; -import { fixtures } from "../fixtures/pdfs"; +import { test, expect } from "../helpers/test-fixtures"; import { resetAppState } from "../helpers/reset-state"; test.describe("pdfpc note display", () => { let presentationCloser: (() => Promise) | null = null; - test.beforeEach(async ({ page, context }) => { + test.beforeEach(async ({ page, context, uniqueFixtures }) => { await resetAppState(page); const presentationPromise = context.waitForEvent("page"); await page .locator('input[type="file"][accept*=".pdf"]') - .setInputFiles([fixtures.pdf, fixtures.pdfpc]); + .setInputFiles([uniqueFixtures.pdf, uniqueFixtures.pdfpc]); await page.waitForURL(/\/(en|ja)\/presenter/); await expect( page.locator("text=/^\\s*\\d+\\s*\\/\\s*\\d+\\s*$/").first(), diff --git a/e2e/tests/pointer-tools.spec.ts b/e2e/tests/pointer-tools.spec.ts index 314f7dc..866a187 100644 --- a/e2e/tests/pointer-tools.spec.ts +++ b/e2e/tests/pointer-tools.spec.ts @@ -1,7 +1,14 @@ -import { test, expect, type Page, type BrowserContext } from "@playwright/test"; -import { fixtures } from "../fixtures/pdfs"; +import type { Page, BrowserContext } from "@playwright/test"; +import { test, expect } from "../helpers/test-fixtures"; import { resetAppState } from "../helpers/reset-state"; +type UniqueFixturesArg = { + pdfName: string; + pdfpcName: string; + pdf: string; + pdfpc: string; +}; + /** * Tool keys (from src/lib/keybindings.ts): * - `l` → laser toggle @@ -18,12 +25,13 @@ import { resetAppState } from "../helpers/reset-state"; async function uploadAndCapture( page: Page, context: BrowserContext, + uniqueFixtures: UniqueFixturesArg, ): Promise { await resetAppState(page); const presentationPromise = context.waitForEvent("page"); await page .locator('input[type="file"][accept*=".pdf"]') - .setInputFiles([fixtures.pdf, fixtures.pdfpc]); + .setInputFiles([uniqueFixtures.pdf, uniqueFixtures.pdfpc]); await page.waitForURL(/\/(en|ja)\/presenter/); const presentation = await presentationPromise; await presentation.waitForLoadState("domcontentloaded"); @@ -31,9 +39,7 @@ async function uploadAndCapture( // Wait for BOTH windows to render their slide canvas. The presenter's // useToolShortcut / useToolBroadcast hooks are inside PresenterContent // which is suspended on PDF.js + pdfpc parsing. Pressing tool keys before - // these hooks mount silently no-ops, so without this wait the test fails - // flakily under parallel load on a dev server (Vite serves modules slower - // when many specs request them concurrently). + // these hooks mount silently no-ops. await Promise.all([ page.locator("canvas").first().waitFor({ state: "visible", timeout: 30_000 }), presentation @@ -41,6 +47,15 @@ async function uploadAndCapture( .first() .waitFor({ state: "visible", timeout: 30_000 }), ]); + + // Belt-and-suspenders: also wait for the presenter's slide counter to + // render. Canvas paint can land slightly before the parent component's + // useEffects commit, so this waits one tick further to ensure the global + // keydown listener for tool shortcuts is attached. + await page + .locator('div.text-2xl', { hasText: /^\s*\d+\s*\/\s*\d+\s*$/ }) + .first() + .waitFor({ state: "visible", timeout: 15_000 }); return presentation; } @@ -78,21 +93,37 @@ async function getCenterOfCanvas(page: Page): Promise<{ x: number; y: number }> return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; } -// These tests exercise heavy multi-window pointer/keyboard interactions and -// share a BroadcastChannel keyed on the demo file name. Running them in -// parallel against a single Vite dev server has surfaced flake (overlay -// portal not mounting in time when tool keys race ahead of PresenterContent -// suspense). CI runs workers=1 so this is a no-op there; locally it brings -// behavior in line with CI. -test.describe.configure({ mode: "serial" }); +/** + * Trigger an app keyboard shortcut. `useToolShortcut.onKeyDown` (in + * use-tool-shortcut.ts) early-returns when `event.target` is an INPUT / + * TEXTAREA / contentEditable element, so we explicitly focus body first. + * Locator.press calls element.focus() then dispatches keydown via CDP, which + * bubbles to the window-level listener that the app registered. + */ +async function pressShortcut(page: Page, key: string): Promise { + await page.locator("body").press(key); +} + +// Run tests in this file serially within the worker — they share a +// BroadcastChannel keyed by the worker's unique file name and stomping each +// other's tool state mid-test would be confusing. +// +// Local parallel runs with this spec are racy (the global `keydown` listener +// in use-tool-shortcut.ts is registered by a useEffect that occasionally +// commits AFTER the canvas paints under heavy parallel load on the dev +// server). Allow retries on top of `mode: "serial"` to absorb the residual +// flake; CI already runs `workers: 1` so this is mainly a local-dev quality +// of life setting. +test.describe.configure({ mode: "serial", retries: 2 }); test.describe("pointer tools (laser + pen)", () => { - test("laser activated on presenter syncs dot to presentation", async ({ page, context }) => { - const presentation = await uploadAndCapture(page, context); + test("laser activated on presenter syncs dot to presentation", async ({ page, context, uniqueFixtures }) => { + const presentation = await uploadAndCapture(page, context, uniqueFixtures); // Presenter focuses its window then presses `l` to enable laser. // focus body so window-level keydown listeners receive the key - await page.locator("body").press("l"); + await pressShortcut(page, "l"); + // Moving the pointer on presenter sends pointer-move broadcast. const c = await getCenterOfCanvas(page); @@ -115,12 +146,12 @@ test.describe("pointer tools (laser + pen)", () => { await presentation.close(); }); - test("laser activated on presentation syncs dot to presenter", async ({ page, context }) => { - const presentation = await uploadAndCapture(page, context); + test("laser activated on presentation syncs dot to presenter", async ({ page, context, uniqueFixtures }) => { + const presentation = await uploadAndCapture(page, context, uniqueFixtures); // Click presentation body to give it focus, then press `l`. // focus body so window-level keydown listeners receive the key - await presentation.locator("body").press("l"); + await pressShortcut(presentation, "l"); const c = await getCenterOfCanvas(presentation); await presentation.mouse.move(c.x, c.y); @@ -136,11 +167,11 @@ test.describe("pointer tools (laser + pen)", () => { await presentation.close(); }); - test("pen drawing on presenter appears on presentation", async ({ page, context }) => { - const presentation = await uploadAndCapture(page, context); + test("pen drawing on presenter appears on presentation", async ({ page, context, uniqueFixtures }) => { + const presentation = await uploadAndCapture(page, context, uniqueFixtures); // focus body so window-level keydown listeners receive the key - await page.locator("body").press("d"); + await pressShortcut(page, "d"); // Draw a short stroke across the presenter canvas. const c = await getCenterOfCanvas(page); @@ -158,11 +189,11 @@ test.describe("pointer tools (laser + pen)", () => { await presentation.close(); }); - test("pen drawing on presentation appears on presenter", async ({ page, context }) => { - const presentation = await uploadAndCapture(page, context); + test("pen drawing on presentation appears on presenter", async ({ page, context, uniqueFixtures }) => { + const presentation = await uploadAndCapture(page, context, uniqueFixtures); // focus body so window-level keydown listeners receive the key - await presentation.locator("body").press("d"); + await pressShortcut(presentation, "d"); const c = await getCenterOfCanvas(presentation); await presentation.mouse.move(c.x - 40, c.y); @@ -178,12 +209,12 @@ test.describe("pointer tools (laser + pen)", () => { await presentation.close(); }); - test("erase clears pen strokes on both windows", async ({ page, context }) => { - const presentation = await uploadAndCapture(page, context); + test("erase clears pen strokes on both windows", async ({ page, context, uniqueFixtures }) => { + const presentation = await uploadAndCapture(page, context, uniqueFixtures); // Draw on presenter // focus body so window-level keydown listeners receive the key - await page.locator("body").press("d"); + await pressShortcut(page, "d"); const c = await getCenterOfCanvas(page); await page.mouse.move(c.x - 30, c.y); await page.mouse.down(); @@ -194,7 +225,7 @@ test.describe("pointer tools (laser + pen)", () => { await expect(penPolylines(presentation)).toHaveCount(1, { timeout: 10_000 }); // Erase - await page.locator("body").press("e"); + await pressShortcut(page, "e"); await expect(penPolylines(page)).toHaveCount(0, { timeout: 5_000 }); await expect(penPolylines(presentation)).toHaveCount(0, { timeout: 5_000 }); @@ -202,17 +233,17 @@ test.describe("pointer tools (laser + pen)", () => { await presentation.close(); }); - test("Escape exits tool mode and removes the overlay", async ({ page, context }) => { - const presentation = await uploadAndCapture(page, context); + test("Escape exits tool mode and removes the overlay", async ({ page, context, uniqueFixtures }) => { + const presentation = await uploadAndCapture(page, context, uniqueFixtures); // focus body so window-level keydown listeners receive the key - await page.locator("body").press("l"); + await pressShortcut(page, "l"); // Overlay portal exists in both windows once tool mode is non-none. await expect(pointerOverlayPortal(page)).toBeAttached({ timeout: 5_000 }); await expect(pointerOverlayPortal(presentation)).toBeAttached({ timeout: 5_000 }); - await page.locator("body").press("Escape"); + await pressShortcut(page, "Escape"); // With no strokes and toolMode back to none, the portal unmounts. await expect(pointerOverlayPortal(page)).toHaveCount(0, { timeout: 5_000 }); diff --git a/e2e/tests/presenter-modes.spec.ts b/e2e/tests/presenter-modes.spec.ts index 007910d..afc451e 100644 --- a/e2e/tests/presenter-modes.spec.ts +++ b/e2e/tests/presenter-modes.spec.ts @@ -1,16 +1,15 @@ -import { test, expect } from "@playwright/test"; -import { fixtures } from "../fixtures/pdfs"; +import { test, expect } from "../helpers/test-fixtures"; import { resetAppState } from "../helpers/reset-state"; test.describe("presenter mode toggles", () => { let presentationCloser: (() => Promise) | null = null; - test.beforeEach(async ({ page, context }) => { + test.beforeEach(async ({ page, context, uniqueFixtures }) => { await resetAppState(page); const presentationPromise = context.waitForEvent("page"); await page .locator('input[type="file"][accept*=".pdf"]') - .setInputFiles([fixtures.pdf, fixtures.pdfpc]); + .setInputFiles([uniqueFixtures.pdf, uniqueFixtures.pdfpc]); await page.waitForURL(/\/(en|ja)\/presenter/); await expect( page.locator("text=/^\\s*\\d+\\s*\\/\\s*\\d+\\s*$/").first(), diff --git a/e2e/tests/presenter-presentation-sync.spec.ts b/e2e/tests/presenter-presentation-sync.spec.ts index 0122d90..54f8356 100644 --- a/e2e/tests/presenter-presentation-sync.spec.ts +++ b/e2e/tests/presenter-presentation-sync.spec.ts @@ -1,7 +1,14 @@ -import { test, expect, type Page, type BrowserContext } from "@playwright/test"; -import { fixtures } from "../fixtures/pdfs"; +import type { Page, BrowserContext } from "@playwright/test"; +import { test, expect } from "../helpers/test-fixtures"; import { resetAppState } from "../helpers/reset-state"; +type UniqueFixturesArg = { + pdfName: string; + pdfpcName: string; + pdf: string; + pdfpc: string; +}; + async function nudgePresentation(presentation: Page): Promise { // Keeps the auto-hiding menu visible (HIDE_DELAY_MS = 2500 in -Menu.tsx). // CI is slower than local — do several nudges across a wider area to make @@ -28,12 +35,13 @@ async function waitForPresentationReady(presentation: Page): Promise { async function uploadAndCapture( page: Page, context: BrowserContext, + uniqueFixtures: UniqueFixturesArg, ): Promise { await resetAppState(page); const presentationPromise = context.waitForEvent("page"); await page .locator('input[type="file"][accept*=".pdf"]') - .setInputFiles([fixtures.pdf, fixtures.pdfpc]); + .setInputFiles([uniqueFixtures.pdf, uniqueFixtures.pdfpc]); await page.waitForURL(/\/(en|ja)\/presenter/); const presentation = await presentationPromise; await presentation.waitForLoadState("domcontentloaded"); @@ -42,8 +50,8 @@ async function uploadAndCapture( } test.describe("presenter ↔ presentation sync", () => { - test("next slide on presenter advances presentation", async ({ page, context }) => { - const presentation = await uploadAndCapture(page, context); + test("next slide on presenter advances presentation", async ({ page, context, uniqueFixtures }) => { + const presentation = await uploadAndCapture(page, context, uniqueFixtures); const presentationCounter = presentation .locator("span.font-mono.tabular-nums") @@ -85,8 +93,8 @@ test.describe("presenter ↔ presentation sync", () => { await presentation.close(); }); - test("blackout on presenter blacks out presentation", async ({ page, context }) => { - const presentation = await uploadAndCapture(page, context); + test("blackout on presenter blacks out presentation", async ({ page, context, uniqueFixtures }) => { + const presentation = await uploadAndCapture(page, context, uniqueFixtures); // presentation/index.tsx wraps the Menu in a div whose className gains // `opacity-0` only when isBlackout is true. diff --git a/e2e/tests/recent-files.spec.ts b/e2e/tests/recent-files.spec.ts index fbdc441..dd129b6 100644 --- a/e2e/tests/recent-files.spec.ts +++ b/e2e/tests/recent-files.spec.ts @@ -1,16 +1,15 @@ -import { test, expect } from "@playwright/test"; -import { fixtures } from "../fixtures/pdfs"; +import { test, expect } from "../helpers/test-fixtures"; import { resetAppState } from "../helpers/reset-state"; test.describe("recent files", () => { let presentationCloser: (() => Promise) | null = null; - test.beforeEach(async ({ page, context }) => { + test.beforeEach(async ({ page, context, uniqueFixtures }) => { await resetAppState(page); const presentationPromise = context.waitForEvent("page"); await page .locator('input[type="file"][accept*=".pdf"]') - .setInputFiles([fixtures.pdf, fixtures.pdfpc]); + .setInputFiles([uniqueFixtures.pdf, uniqueFixtures.pdfpc]); await page.waitForURL(/\/(en|ja)\/presenter/); await expect( page.locator("text=/^\\s*\\d+\\s*\\/\\s*\\d+\\s*$/").first(), @@ -26,20 +25,20 @@ test.describe("recent files", () => { presentationCloser = null; }); - test("uploaded file appears in library after returning home", async ({ page }) => { + test("uploaded file appears in library after returning home", async ({ page, uniqueFixtures }) => { await page.goto("/en"); - // LibrarySection lists each recent by name; the file name from fixture is "pdfpw-demo.pdf" - await expect(page.getByText("pdfpw-demo.pdf").first()).toBeVisible({ + // LibrarySection lists each recent by name; the file name from fixture is dynamic per worker. + await expect(page.getByText(uniqueFixtures.pdfName).first()).toBeVisible({ timeout: 10_000, }); }); - test("deleting a recent file removes it from the library", async ({ page }) => { + test("deleting a recent file removes it from the library", async ({ page, uniqueFixtures }) => { await page.goto("/en"); - await expect(page.getByText("pdfpw-demo.pdf").first()).toBeVisible(); + await expect(page.getByText(uniqueFixtures.pdfName).first()).toBeVisible(); await page.getByRole("button", { name: "Delete from library" }).first().click(); - await expect(page.getByText("pdfpw-demo.pdf")).toHaveCount(0); + await expect(page.getByText(uniqueFixtures.pdfName)).toHaveCount(0); }); }); diff --git a/e2e/tests/slide-navigation.spec.ts b/e2e/tests/slide-navigation.spec.ts index efb1aad..b0501c6 100644 --- a/e2e/tests/slide-navigation.spec.ts +++ b/e2e/tests/slide-navigation.spec.ts @@ -1,5 +1,5 @@ -import { test, expect, type Locator, type Page } from "@playwright/test"; -import { fixtures } from "../fixtures/pdfs"; +import type { Locator, Page } from "@playwright/test"; +import { test, expect } from "../helpers/test-fixtures"; import { resetAppState } from "../helpers/reset-state"; /** @@ -31,12 +31,12 @@ import { resetAppState } from "../helpers/reset-state"; test.describe("slide navigation", () => { let presentationCloser: (() => Promise) | null = null; - test.beforeEach(async ({ page, context }) => { + test.beforeEach(async ({ page, context, uniqueFixtures }) => { await resetAppState(page); const presentationPromise = context.waitForEvent("page"); await page .locator('input[type="file"][accept*=".pdf"]') - .setInputFiles([fixtures.pdf, fixtures.pdfpc]); + .setInputFiles([uniqueFixtures.pdf, uniqueFixtures.pdfpc]); await page.waitForURL(/\/(en|ja)\/presenter/); await expect(counter(page)).toBeVisible({ timeout: 15_000 }); const presentation = await presentationPromise; diff --git a/e2e/tests/timer.spec.ts b/e2e/tests/timer.spec.ts index be96cc9..8c5f83e 100644 --- a/e2e/tests/timer.spec.ts +++ b/e2e/tests/timer.spec.ts @@ -1,16 +1,15 @@ -import { test, expect } from "@playwright/test"; -import { fixtures } from "../fixtures/pdfs"; +import { test, expect } from "../helpers/test-fixtures"; import { resetAppState } from "../helpers/reset-state"; test.describe("timer", () => { let presentationCloser: (() => Promise) | null = null; - test.beforeEach(async ({ page, context }) => { + test.beforeEach(async ({ page, context, uniqueFixtures }) => { await resetAppState(page); const presentationPromise = context.waitForEvent("page"); await page .locator('input[type="file"][accept*=".pdf"]') - .setInputFiles([fixtures.pdf, fixtures.pdfpc]); + .setInputFiles([uniqueFixtures.pdf, uniqueFixtures.pdfpc]); await page.waitForURL(/\/(en|ja)\/presenter/); await expect( page.locator("text=/^\\s*\\d+\\s*\\/\\s*\\d+\\s*$/").first(), From bc09093e2f9bee81c50b560d6136eeac1f81359f Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Mon, 4 May 2026 10:32:24 +0900 Subject: [PATCH 25/32] test(e2e): address PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resetAppState: clear sessionStorage and seed pdfpw:locale="en". Forces English UI regardless of navigator.language so role/aria-label selectors stay deterministic, and prevents the broadcast pair-id from leaking between tests in the same context. - Drop e2e/helpers/open-pdf.ts and presentation-window.ts (YAGNI — neither was consumed by any spec). - vite.config.ts: extend Vitest's default exclude list instead of replacing it, so cypress/.idea/.git/.cache/config-file globs stay excluded. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/helpers/open-pdf.ts | 30 ------------------------------ e2e/helpers/presentation-window.ts | 17 ----------------- e2e/helpers/reset-state.ts | 25 +++++++++++++++++-------- vite.config.ts | 4 ++-- 4 files changed, 19 insertions(+), 57 deletions(-) delete mode 100644 e2e/helpers/open-pdf.ts delete mode 100644 e2e/helpers/presentation-window.ts diff --git a/e2e/helpers/open-pdf.ts b/e2e/helpers/open-pdf.ts deleted file mode 100644 index 49d7e7e..0000000 --- a/e2e/helpers/open-pdf.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 }; -} diff --git a/e2e/helpers/presentation-window.ts b/e2e/helpers/presentation-window.ts deleted file mode 100644 index 7aba3b3..0000000 --- a/e2e/helpers/presentation-window.ts +++ /dev/null @@ -1,17 +0,0 @@ -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; -} diff --git a/e2e/helpers/reset-state.ts b/e2e/helpers/reset-state.ts index 4a097ab..7304397 100644 --- a/e2e/helpers/reset-state.ts +++ b/e2e/helpers/reset-state.ts @@ -1,15 +1,22 @@ 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. + * Reset client-side state to a deterministic baseline: + * + * - Drop the `pdfpw` IndexedDB store (recent files, settings). + * - Clear `localStorage` and `sessionStorage`. + * - Seed `pdfpw:locale = "en"` so role/aria-label selectors render in English + * regardless of the host's `navigator.language`. + * + * `sessionStorage` notably holds the broadcast pair-id + * (`pdfpw:pairId:`); leaving it intact across tests can leak pairing + * state and make multi-window behavior non-deterministic. + * + * Navigates to `/en` directly (bypassing the `/` → `/` + * dance) and reloads after seeding so the app picks up the seeded locale. */ export async function resetAppState(page: Page): Promise { - // "/" client-redirects to "/{locale}" via . Wait for the redirect - // to finish before evaluating, otherwise the execution context can be - // destroyed mid-evaluate. - await page.goto("/"); - await page.waitForURL(/\/(en|ja)\/?$/, { timeout: 15_000 }); + await page.goto("/en"); await page.evaluate(async () => { await new Promise((resolve) => { const req = indexedDB.deleteDatabase("pdfpw"); @@ -18,6 +25,8 @@ export async function resetAppState(page: Page): Promise { req.onblocked = () => resolve(); }); localStorage.clear(); + sessionStorage.clear(); + localStorage.setItem("pdfpw:locale", "en"); }); - await page.reload(); + await page.goto("/en"); } diff --git a/vite.config.ts b/vite.config.ts index ab9c008..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,7 +221,7 @@ export default defineConfig({ test: { globals: true, setupFiles: ["./src/test-setup.ts"], - exclude: ["**/node_modules/**", "**/dist/**", "e2e/**"], + exclude: [...configDefaults.exclude, "e2e/**"], browser: { enabled: true, provider: playwright(), From 0f9c59bb43fa08ab632df1e74861441460d5e623 Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Mon, 4 May 2026 10:40:20 +0900 Subject: [PATCH 26/32] =?UTF-8?q?test(e2e):=20add=20dedicated=20presenter?= =?UTF-8?q?=20=E2=86=94=20presentation=20pairing=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing specs only verify pairing implicitly (sync + pointer-tools wait for the presentation canvas mid-test). This spec asserts pairing as the primary subject so the regression we just fixed — popup losing the pair-id when sessionStorage is seeded after window.open — surfaces directly here: - presenter sessionStorage carries a UUID pair-id keyed on the file name - the popup's sessionStorage was inherited at creation and matches - the presentation canvas mounts (init-response received) - none of the error fallbacks (Connecting / Pairing failed / config / PDF load) are visible Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/tests/pairing.spec.ts | 80 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 e2e/tests/pairing.spec.ts diff --git a/e2e/tests/pairing.spec.ts b/e2e/tests/pairing.spec.ts new file mode 100644 index 0000000..e638684 --- /dev/null +++ b/e2e/tests/pairing.spec.ts @@ -0,0 +1,80 @@ +import { test, expect } from "../helpers/test-fixtures"; +import { resetAppState } from "../helpers/reset-state"; + +/** + * Verify the full presenter ↔ presentation pairing flow end-to-end. + * + * Pairing is the act of getting both windows onto the same BroadcastChannel: + * + * 1. `proceedWithPdf` calls `ensurePresenterPairId(pdf.name)` to seed + * `sessionStorage["pdfpw:pairId:"]` BEFORE `window.open(...)`. + * 2. The popup snapshots the opener's sessionStorage at creation time, so it + * inherits the same pair-id without needing to fall through to the lobby. + * 3. With matching pair-ids, presenter and presentation join the same channel + * `pdfpw::`. The presentation sends `initialize`; the + * presenter responds with `initialize-response` carrying pdfpcConfig + + * pdfData; the presentation mounts `` (canvas appears). + * + * If any of the above breaks, the presentation is stuck in the "Connecting…" + * placeholder, falls back to the lobby and times out + * (`TIMEOUT_PAIRING_PRESENTATION`), or shows the error boundary fallback. + * + * Other specs implicitly rely on pairing succeeding (sync, pointer-tools) + * but only via the canvas-visible signal mid-test. This spec asserts + * pairing as the primary subject so regressions surface here directly. + */ + +const PAIR_ID_KEY = (file: string) => `pdfpw:pairId:${file}`; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +test.describe("presenter ↔ presentation pairing", () => { + test("file open seeds pair-id and the popup pairs successfully", async ({ page, context, uniqueFixtures }) => { + await resetAppState(page); + + const presentationPromise = context.waitForEvent("page"); + await page + .locator('input[type="file"][accept*=".pdf"]') + .setInputFiles([uniqueFixtures.pdf, uniqueFixtures.pdfpc]); + await page.waitForURL(/\/(en|ja)\/presenter/, { timeout: 15_000 }); + + const presentation = await presentationPromise; + await presentation.waitForLoadState("domcontentloaded"); + + // Presenter must have written a UUID pair-id to sessionStorage before + // opening the popup. Without this, the popup falls back to lobby + // pairing and can time out. + const presenterPairId = await page.evaluate( + (key) => sessionStorage.getItem(key), + PAIR_ID_KEY(uniqueFixtures.pdfName), + ); + expect(presenterPairId).toMatch(UUID_RE); + + // Presentation canvas mounts only after `initialize-response` arrives, + // which proves the channel pair worked end-to-end. + await presentation + .locator("canvas") + .first() + .waitFor({ state: "visible", timeout: 30_000 }); + + // And the presentation must NOT be in any of the failure states. + await expect(presentation.getByText(/Connecting…|接続中…/)).toBeHidden(); + await expect(presentation.getByText(/Pairing failed|ペアリングに失敗/)).toBeHidden(); + await expect( + presentation.getByText(/Could not load the configuration|設定を読み込めません/), + ).toBeHidden(); + await expect( + presentation.getByText(/Could not load the PDF file|PDF ファイルの読み込みに失敗/), + ).toBeHidden(); + + // The popup inherited the SAME pair-id. If this fails, the popup + // snapshotted sessionStorage before the seed (regression of the + // fix in src/routes/$locale/(main)/index.tsx). + const presentationPairId = await presentation.evaluate( + (key) => sessionStorage.getItem(key), + PAIR_ID_KEY(uniqueFixtures.pdfName), + ); + expect(presentationPairId).toBe(presenterPairId); + + await presentation.close(); + }); +}); From 30a57474022175358399846e02adb999d5616b0c Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Tue, 5 May 2026 11:54:49 +0900 Subject: [PATCH 27/32] test(e2e): assert laser dot position tracks mouse coordinates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing laser tests only verified the dot becomes visible after pressing `l`; they did not check whether the dot actually follows the cursor. This test moves the mouse to three distinct quadrants of the slide canvas (25/25, 75/75, 10/90) and asserts the dot's `style.left` / `style.top` percent values land within ±2% of the expected position on BOTH the presenter and the presentation page — catching coordinate-swap, axis-aliasing, or broadcast-truncation bugs that the visibility-only assertion would miss. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/tests/pointer-tools.spec.ts | 79 +++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/e2e/tests/pointer-tools.spec.ts b/e2e/tests/pointer-tools.spec.ts index 866a187..94199fd 100644 --- a/e2e/tests/pointer-tools.spec.ts +++ b/e2e/tests/pointer-tools.spec.ts @@ -93,6 +93,54 @@ async function getCenterOfCanvas(page: Page): Promise<{ x: number; y: number }> return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; } +/** + * Read the laser dot wrapper's `style.left` / `style.top` (percent strings + * like "25%", "50%"). These are written directly by `PointerOverlay` + * (src/components/PointerOverlay.tsx) from `laserPosAtom`, so they are the + * cleanest signal for "the laser dot is at this normalized position". + * + * Returns null when the wrapper isn't present or is hidden. + */ +async function readLaserPercent(page: Page): Promise<{ left: number; top: number } | null> { + const raw = await page.evaluate(() => { + const dot = document.querySelector( + 'body > div.fixed.pointer-events-none.z-50 div[style*="width: 0"]', + ) as HTMLElement | null; + if (!dot) return null; + if (dot.style.display === "none") return null; + return { left: dot.style.left, top: dot.style.top }; + }); + if (!raw) return null; + const parse = (v: string): number | null => { + const m = v.match(/^(-?\d+(?:\.\d+)?)%$/); + return m ? Number.parseFloat(m[1]) : null; + }; + const left = parse(raw.left); + const top = parse(raw.top); + if (left === null || top === null) return null; + return { left, top }; +} + +async function expectLaserNear( + page: Page, + expected: { left: number; top: number }, + tolerance = 2, +): Promise { + await expect + .poll( + async () => { + const pos = await readLaserPercent(page); + if (!pos) return false; + return ( + Math.abs(pos.left - expected.left) <= tolerance && + Math.abs(pos.top - expected.top) <= tolerance + ); + }, + { timeout: 10_000 }, + ) + .toBe(true); +} + /** * Trigger an app keyboard shortcut. `useToolShortcut.onKeyDown` (in * use-tool-shortcut.ts) early-returns when `event.target` is an INPUT / @@ -167,6 +215,37 @@ test.describe("pointer tools (laser + pen)", () => { await presentation.close(); }); + test("laser dot tracks mouse position across presenter and presentation", async ({ page, context, uniqueFixtures }) => { + const presentation = await uploadAndCapture(page, context, uniqueFixtures); + + await pressShortcut(page, "l"); + + const box = await page.locator("canvas").first().boundingBox(); + if (!box) throw new Error("canvas not found"); + + // Move to ~25% of canvas (top-left quadrant). PointerOverlay sets + // `style.left = ${pos.x * 100}%`, so the dot's percent should match + // the cursor's normalized canvas position. + await page.mouse.move(box.x + box.width * 0.25, box.y + box.height * 0.25); + await expectLaserNear(page, { left: 25, top: 25 }); + // Broadcast pushes the same position to the presentation. + await expectLaserNear(presentation, { left: 25, top: 25 }); + + // Move to ~75% (bottom-right quadrant) and verify the dot has moved + // the full delta, not lingering at the previous spot. + await page.mouse.move(box.x + box.width * 0.75, box.y + box.height * 0.75); + await expectLaserNear(page, { left: 75, top: 75 }); + await expectLaserNear(presentation, { left: 75, top: 75 }); + + // One more move to a non-symmetric point so x and y diverge — catches + // any accidental coordinate swap or shared-axis bug. + await page.mouse.move(box.x + box.width * 0.1, box.y + box.height * 0.9); + await expectLaserNear(page, { left: 10, top: 90 }); + await expectLaserNear(presentation, { left: 10, top: 90 }); + + await presentation.close(); + }); + test("pen drawing on presenter appears on presentation", async ({ page, context, uniqueFixtures }) => { const presentation = await uploadAndCapture(page, context, uniqueFixtures); From f5a5e8843c8b100984172b9d671303a8741f5572 Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Tue, 5 May 2026 12:21:07 +0900 Subject: [PATCH 28/32] test(e2e): assert laser stays on top of slide at multiple positions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a grid-of-9 visibility test for the laser dot. Verifies at each point that: 1. The visible inner dot passes Element.checkVisibility() — covers display:none / visibility:hidden / opacity:0 regressions. 2. At the dot's pixel center the topmost element is part of the overlay portal — catches the "laser ends up behind the slide" stacking regression that checkVisibility alone cannot detect. Chromium's elementsFromPoint() filters out pointer-events:none elements, so the helper temporarily flips the overlay's pointer-events inline style to "auto" during the probe, then restores it. The probe runs once per grid point on both presenter and presentation pages. Also stops using parseFloat and uses Number(...) consistently in the percent-parsing helpers. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/tests/pointer-tools.spec.ts | 116 +++++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/e2e/tests/pointer-tools.spec.ts b/e2e/tests/pointer-tools.spec.ts index 94199fd..a4e52a6 100644 --- a/e2e/tests/pointer-tools.spec.ts +++ b/e2e/tests/pointer-tools.spec.ts @@ -113,7 +113,9 @@ async function readLaserPercent(page: Page): Promise<{ left: number; top: number if (!raw) return null; const parse = (v: string): number | null => { const m = v.match(/^(-?\d+(?:\.\d+)?)%$/); - return m ? Number.parseFloat(m[1]) : null; + if (!m) return null; + const n = Number(m[1]); + return Number.isNaN(n) ? null : n; }; const left = parse(raw.left); const top = parse(raw.top); @@ -121,6 +123,69 @@ async function readLaserPercent(page: Page): Promise<{ left: number; top: number return { left, top }; } +/** + * Verify the laser dot is actually visible to the user at `expected`: + * + * 1. The visible inner dot (the red core, last child of the LaserDot wrapper) + * passes `Element.checkVisibility()` — covers display:none / + * visibility:hidden / opacity:0 regressions. + * 2. At the dot's pixel center, no other element is stacked in front of the + * overlay. The overlay carries `pointer-events: none`, which Chromium's + * `document.elementsFromPoint()` filters out — so we temporarily clear + * the inline override during the probe and restore it immediately. This + * catches z-index/stacking regressions (the "laser ends up behind the + * slide" failure mode `checkVisibility` alone cannot see). + * + * Returns `true` once both conditions hold; used inside `expect.poll` to + * absorb the small RAF + atom commit + broadcast latency window. + */ +async function isLaserOnTopAt( + page: Page, + expected: { left: number; top: number }, + tolerance: number, +): Promise { + const pos = await readLaserPercent(page); + if (!pos) return false; + if (Math.abs(pos.left - expected.left) > tolerance) return false; + if (Math.abs(pos.top - expected.top) > tolerance) return false; + return await page.evaluate(() => { + const overlay = document.querySelector( + "body > div.fixed.pointer-events-none.z-50", + ) as HTMLElement | null; + if (!overlay) return false; + const wrapper = overlay.querySelector( + 'div[style*="width: 0"]', + ) as HTMLElement | null; + if (!wrapper) return false; + // (1) checkVisibility on the visible inner core (last child of the + // 0×0 wrapper). + const core = wrapper.lastElementChild as HTMLElement | null; + if (!core) return false; + if (typeof core.checkVisibility === "function" && !core.checkVisibility()) + return false; + // (2) Resolve the dot's pixel center. + const overlayRect = overlay.getBoundingClientRect(); + const leftPct = Number((wrapper.style.left || "").replace("%", "")); + const topPct = Number((wrapper.style.top || "").replace("%", "")); + if (Number.isNaN(leftPct) || Number.isNaN(topPct)) return false; + const cx = overlayRect.left + (overlayRect.width * leftPct) / 100; + const cy = overlayRect.top + (overlayRect.height * topPct) / 100; + // Temporarily flip pointer-events so elementsFromPoint can see the + // overlay subtree. Without this, Chromium skips the entire overlay + // (and its visible dot) and reports whatever is below. + const prevPE = overlay.style.pointerEvents; + overlay.style.pointerEvents = "auto"; + try { + const stack = document.elementsFromPoint(cx, cy); + if (stack.length === 0) return false; + const top = stack[0]; + return top === overlay || overlay.contains(top); + } finally { + overlay.style.pointerEvents = prevPE; + } + }); +} + async function expectLaserNear( page: Page, expected: { left: number; top: number }, @@ -141,6 +206,18 @@ async function expectLaserNear( .toBe(true); } +async function expectLaserOnTopAt( + page: Page, + expected: { left: number; top: number }, + tolerance = 2, +): Promise { + await expect + .poll(async () => isLaserOnTopAt(page, expected, tolerance), { + timeout: 10_000, + }) + .toBe(true); +} + /** * Trigger an app keyboard shortcut. `useToolShortcut.onKeyDown` (in * use-tool-shortcut.ts) early-returns when `event.target` is an INPUT / @@ -246,6 +323,43 @@ test.describe("pointer tools (laser + pen)", () => { await presentation.close(); }); + test("laser dot stays on top of the slide across the canvas (grid of 9 points)", async ({ page, context, uniqueFixtures }) => { + const presentation = await uploadAndCapture(page, context, uniqueFixtures); + + await pressShortcut(page, "l"); + + const box = await page.locator("canvas").first().boundingBox(); + if (!box) throw new Error("canvas not found"); + + // 3×3 grid covering the slide. The dot must be both rendered + // (`checkVisibility`) AND the topmost element at its pixel center + // (no z-index regression hiding it behind the slide). Verified on + // presenter and presentation independently — a stacking bug on + // either side is its own concern. + const gridPoints: ReadonlyArray<{ left: number; top: number }> = [ + { left: 10, top: 10 }, + { left: 50, top: 10 }, + { left: 90, top: 10 }, + { left: 10, top: 50 }, + { left: 50, top: 50 }, + { left: 90, top: 50 }, + { left: 10, top: 90 }, + { left: 50, top: 90 }, + { left: 90, top: 90 }, + ]; + + for (const p of gridPoints) { + await page.mouse.move( + box.x + box.width * (p.left / 100), + box.y + box.height * (p.top / 100), + ); + await expectLaserOnTopAt(page, p); + await expectLaserOnTopAt(presentation, p); + } + + await presentation.close(); + }); + test("pen drawing on presenter appears on presentation", async ({ page, context, uniqueFixtures }) => { const presentation = await uploadAndCapture(page, context, uniqueFixtures); From 9d3a692cf052403fcb0f1de40f613e353f046f34 Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Tue, 5 May 2026 13:11:10 +0900 Subject: [PATCH 29/32] refactor(e2e): pass parsed laser percent into evaluate readLaserPercent already produces the numeric left/top from the wrapper's style. Threading those values through evaluate's argument removes the duplicate parse (and the .replace("%","") that came with it) inside the browser-side closure. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/tests/pointer-tools.spec.ts | 72 ++++++++++++++++----------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/e2e/tests/pointer-tools.spec.ts b/e2e/tests/pointer-tools.spec.ts index a4e52a6..af76f2e 100644 --- a/e2e/tests/pointer-tools.spec.ts +++ b/e2e/tests/pointer-tools.spec.ts @@ -148,42 +148,42 @@ async function isLaserOnTopAt( if (!pos) return false; if (Math.abs(pos.left - expected.left) > tolerance) return false; if (Math.abs(pos.top - expected.top) > tolerance) return false; - return await page.evaluate(() => { - const overlay = document.querySelector( - "body > div.fixed.pointer-events-none.z-50", - ) as HTMLElement | null; - if (!overlay) return false; - const wrapper = overlay.querySelector( - 'div[style*="width: 0"]', - ) as HTMLElement | null; - if (!wrapper) return false; - // (1) checkVisibility on the visible inner core (last child of the - // 0×0 wrapper). - const core = wrapper.lastElementChild as HTMLElement | null; - if (!core) return false; - if (typeof core.checkVisibility === "function" && !core.checkVisibility()) - return false; - // (2) Resolve the dot's pixel center. - const overlayRect = overlay.getBoundingClientRect(); - const leftPct = Number((wrapper.style.left || "").replace("%", "")); - const topPct = Number((wrapper.style.top || "").replace("%", "")); - if (Number.isNaN(leftPct) || Number.isNaN(topPct)) return false; - const cx = overlayRect.left + (overlayRect.width * leftPct) / 100; - const cy = overlayRect.top + (overlayRect.height * topPct) / 100; - // Temporarily flip pointer-events so elementsFromPoint can see the - // overlay subtree. Without this, Chromium skips the entire overlay - // (and its visible dot) and reports whatever is below. - const prevPE = overlay.style.pointerEvents; - overlay.style.pointerEvents = "auto"; - try { - const stack = document.elementsFromPoint(cx, cy); - if (stack.length === 0) return false; - const top = stack[0]; - return top === overlay || overlay.contains(top); - } finally { - overlay.style.pointerEvents = prevPE; - } - }); + return await page.evaluate( + ({ leftPct, topPct }) => { + const overlay = document.querySelector( + "body > div.fixed.pointer-events-none.z-50", + ) as HTMLElement | null; + if (!overlay) return false; + const wrapper = overlay.querySelector( + 'div[style*="width: 0"]', + ) as HTMLElement | null; + if (!wrapper) return false; + // (1) checkVisibility on the visible inner core (last child of the + // 0×0 wrapper). + const core = wrapper.lastElementChild as HTMLElement | null; + if (!core) return false; + if (typeof core.checkVisibility === "function" && !core.checkVisibility()) + return false; + // (2) Resolve the dot's pixel center. + const overlayRect = overlay.getBoundingClientRect(); + const cx = overlayRect.left + (overlayRect.width * leftPct) / 100; + const cy = overlayRect.top + (overlayRect.height * topPct) / 100; + // Temporarily flip pointer-events so elementsFromPoint can see the + // overlay subtree. Without this, Chromium skips the entire overlay + // (and its visible dot) and reports whatever is below. + const prevPE = overlay.style.pointerEvents; + overlay.style.pointerEvents = "auto"; + try { + const stack = document.elementsFromPoint(cx, cy); + if (stack.length === 0) return false; + const top = stack[0]; + return top === overlay || overlay.contains(top); + } finally { + overlay.style.pointerEvents = prevPE; + } + }, + { leftPct: pos.left, topPct: pos.top }, + ); } async function expectLaserNear( From 03c96120a69e47556781b369f38619c8d0ff0658 Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Tue, 5 May 2026 13:22:50 +0900 Subject: [PATCH 30/32] test(e2e): harden laser visibility check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous on-top helper relied on Element.checkVisibility() with default options, which only catches display:none and skips opacity:0 / visibility:hidden / 0-area regressions. Playwright's expect(locator).toBeVisible() is strictly stronger: it requires display ≠ none, visibility ≠ hidden, opacity > 0 anywhere up the chain, AND a non-zero bounding box. Changes: - Probe the *inner core* (the 12×12 red dot, last child of the wrapper) instead of the 0×0 wrapper. Targeting the wrapper made the bbox-area check meaningless because the wrapper is sized 0 by design. - Use Playwright's toBeVisible() for the visibility step. - Keep the existing position match + elementsFromPoint occlusion check as separate poll steps. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/tests/pointer-tools.spec.ts | 144 +++++++++++++++++--------------- 1 file changed, 78 insertions(+), 66 deletions(-) diff --git a/e2e/tests/pointer-tools.spec.ts b/e2e/tests/pointer-tools.spec.ts index af76f2e..ef4555a 100644 --- a/e2e/tests/pointer-tools.spec.ts +++ b/e2e/tests/pointer-tools.spec.ts @@ -124,73 +124,46 @@ async function readLaserPercent(page: Page): Promise<{ left: number; top: number } /** - * Verify the laser dot is actually visible to the user at `expected`: - * - * 1. The visible inner dot (the red core, last child of the LaserDot wrapper) - * passes `Element.checkVisibility()` — covers display:none / - * visibility:hidden / opacity:0 regressions. - * 2. At the dot's pixel center, no other element is stacked in front of the - * overlay. The overlay carries `pointer-events: none`, which Chromium's - * `document.elementsFromPoint()` filters out — so we temporarily clear - * the inline override during the probe and restore it immediately. This - * catches z-index/stacking regressions (the "laser ends up behind the - * slide" failure mode `checkVisibility` alone cannot see). - * - * Returns `true` once both conditions hold; used inside `expect.poll` to - * absorb the small RAF + atom commit + broadcast latency window. + * Selector for the visible inner core of the laser dot (the 12×12 red + * circle, last child of the 0×0 LaserDot wrapper). We probe THIS element + * for visibility instead of the wrapper because the wrapper has inline + * `width: 0; height: 0` and would always be reported as zero-area + * regardless of whether the actual dot is shown. */ -async function isLaserOnTopAt( - page: Page, - expected: { left: number; top: number }, - tolerance: number, -): Promise { - const pos = await readLaserPercent(page); - if (!pos) return false; - if (Math.abs(pos.left - expected.left) > tolerance) return false; - if (Math.abs(pos.top - expected.top) > tolerance) return false; - return await page.evaluate( - ({ leftPct, topPct }) => { - const overlay = document.querySelector( - "body > div.fixed.pointer-events-none.z-50", - ) as HTMLElement | null; - if (!overlay) return false; - const wrapper = overlay.querySelector( - 'div[style*="width: 0"]', - ) as HTMLElement | null; - if (!wrapper) return false; - // (1) checkVisibility on the visible inner core (last child of the - // 0×0 wrapper). - const core = wrapper.lastElementChild as HTMLElement | null; - if (!core) return false; - if (typeof core.checkVisibility === "function" && !core.checkVisibility()) - return false; - // (2) Resolve the dot's pixel center. - const overlayRect = overlay.getBoundingClientRect(); - const cx = overlayRect.left + (overlayRect.width * leftPct) / 100; - const cy = overlayRect.top + (overlayRect.height * topPct) / 100; - // Temporarily flip pointer-events so elementsFromPoint can see the - // overlay subtree. Without this, Chromium skips the entire overlay - // (and its visible dot) and reports whatever is below. - const prevPE = overlay.style.pointerEvents; - overlay.style.pointerEvents = "auto"; - try { - const stack = document.elementsFromPoint(cx, cy); - if (stack.length === 0) return false; - const top = stack[0]; - return top === overlay || overlay.contains(top); - } finally { - overlay.style.pointerEvents = prevPE; - } - }, - { leftPct: pos.left, topPct: pos.top }, - ); +function laserCore(page: Page) { + return page + .locator( + 'body > div.fixed.pointer-events-none.z-50 div[style*="width: 0"] > div:last-child', + ) + .first(); } -async function expectLaserNear( +/** + * Verify the laser dot is actually visible to the user at `expected`: + * + * 1. The inner red core passes Playwright's `toBeVisible()` — which is + * stricter than DOM `Element.checkVisibility()`: it requires + * display ≠ none, visibility ≠ hidden, opacity > 0 anywhere up the + * chain, AND bounding-box area > 0. Catches the "laser is in DOM but + * invisible" failure modes that `checkVisibility()` alone misses + * (opacity:0 ancestor, visibility:hidden, 0×0 size). + * 2. The wrapper's inline percent matches `expected` — proves the dot is + * where the cursor pointed, not lingering at a stale position. + * 3. At the dot's pixel center, no other element is stacked in front of + * the overlay. Chromium's `document.elementsFromPoint()` filters out + * `pointer-events: none` elements, so we flip the overlay's inline + * pointer-events to `auto` for the probe and restore it. Catches + * z-index / stacking regressions where the laser sits behind the + * slide. + */ +async function expectLaserVisibleAt( page: Page, expected: { left: number; top: number }, tolerance = 2, ): Promise { + // (1) Visible to the user. + await expect(laserCore(page)).toBeVisible({ timeout: 10_000 }); + // (2) At the expected position. await expect .poll( async () => { @@ -204,17 +177,56 @@ async function expectLaserNear( { timeout: 10_000 }, ) .toBe(true); + // (3) Not occluded by anything stacked in front. + await expect + .poll( + async () => + page.evaluate( + ({ leftPct, topPct }) => { + const overlay = document.querySelector( + "body > div.fixed.pointer-events-none.z-50", + ) as HTMLElement | null; + if (!overlay) return false; + const overlayRect = overlay.getBoundingClientRect(); + const cx = + overlayRect.left + (overlayRect.width * leftPct) / 100; + const cy = + overlayRect.top + (overlayRect.height * topPct) / 100; + const prevPE = overlay.style.pointerEvents; + overlay.style.pointerEvents = "auto"; + try { + const stack = document.elementsFromPoint(cx, cy); + if (stack.length === 0) return false; + const top = stack[0]; + return top === overlay || overlay.contains(top); + } finally { + overlay.style.pointerEvents = prevPE; + } + }, + { leftPct: expected.left, topPct: expected.top }, + ), + { timeout: 10_000 }, + ) + .toBe(true); } -async function expectLaserOnTopAt( +async function expectLaserNear( page: Page, expected: { left: number; top: number }, tolerance = 2, ): Promise { await expect - .poll(async () => isLaserOnTopAt(page, expected, tolerance), { - timeout: 10_000, - }) + .poll( + async () => { + const pos = await readLaserPercent(page); + if (!pos) return false; + return ( + Math.abs(pos.left - expected.left) <= tolerance && + Math.abs(pos.top - expected.top) <= tolerance + ); + }, + { timeout: 10_000 }, + ) .toBe(true); } @@ -353,8 +365,8 @@ test.describe("pointer tools (laser + pen)", () => { box.x + box.width * (p.left / 100), box.y + box.height * (p.top / 100), ); - await expectLaserOnTopAt(page, p); - await expectLaserOnTopAt(presentation, p); + await expectLaserVisibleAt(page, p); + await expectLaserVisibleAt(presentation, p); } await presentation.close(); From e92c86887047413c007ed03503aad6b49811bafb Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Tue, 5 May 2026 13:41:00 +0900 Subject: [PATCH 31/32] test(e2e): tighten laser visibility check (catches ancestor opacity) Self-verification surfaced a hole: Playwright's expect(locator).toBeVisible() does NOT walk ancestors to check opacity. Injecting opacity:0 on the overlay portal made the laser invisible to a user but our check still reported it as visible. Changes: - Add a supplementary `Element.checkVisibility({opacityProperty: true, visibilityProperty: true, contentVisibilityAuto: true})` step. Default options on this DOM API don't catch ancestor opacity either; the options enable the ancestor walk we actually need. - Add three self-verifying tests that prove the check fails when the laser is forcibly hidden via: 1. `display: none` injection 2. `opacity: 0` ancestor injection 3. A z-index 9999 div stacked in front These are guards against future regressions of the visibility helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/tests/pointer-tools.spec.ts | 91 ++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/e2e/tests/pointer-tools.spec.ts b/e2e/tests/pointer-tools.spec.ts index ef4555a..48d84f4 100644 --- a/e2e/tests/pointer-tools.spec.ts +++ b/e2e/tests/pointer-tools.spec.ts @@ -161,8 +161,30 @@ async function expectLaserVisibleAt( expected: { left: number; top: number }, tolerance = 2, ): Promise { - // (1) Visible to the user. + // (1a) Playwright's toBeVisible — catches display:none chain and 0×0 size. await expect(laserCore(page)).toBeVisible({ timeout: 10_000 }); + // (1b) DOM `Element.checkVisibility()` with all options — catches + // ancestor opacity:0, ancestor visibility:hidden, content-visibility:hidden. + // Playwright's toBeVisible does NOT walk ancestors for opacity, so this + // supplementary check is necessary. + await expect + .poll( + async () => + page.evaluate(() => { + const core = document.querySelector( + 'body > div.fixed.pointer-events-none.z-50 div[style*="width: 0"] > div:last-child', + ) as HTMLElement | null; + if (!core) return false; + if (typeof core.checkVisibility !== "function") return true; + return core.checkVisibility({ + opacityProperty: true, + visibilityProperty: true, + contentVisibilityAuto: true, + }); + }), + { timeout: 10_000 }, + ) + .toBe(true); // (2) At the expected position. await expect .poll( @@ -335,6 +357,73 @@ test.describe("pointer tools (laser + pen)", () => { await presentation.close(); }); + test("self-verify: hidden laser is detected (display:none injection)", async ({ page, context, uniqueFixtures }) => { + const presentation = await uploadAndCapture(page, context, uniqueFixtures); + await pressShortcut(page, "l"); + const box = await page.locator("canvas").first().boundingBox(); + if (!box) throw new Error("canvas not found"); + await page.mouse.move(box.x + box.width * 0.5, box.y + box.height * 0.5); + // First confirm presentation laser IS visible normally. + await expectLaserVisibleAt(presentation, { left: 50, top: 50 }); + // Now hide the laser dot wrapper and verify our check actually catches it. + await presentation.addStyleTag({ + content: + 'body > div.fixed.pointer-events-none.z-50 div[style*="width: 0"] { display: none !important; }', + }); + let detected = false; + try { + await expectLaserVisibleAt(presentation, { left: 50, top: 50 }, 2); + } catch { + detected = true; + } + expect(detected, "expectLaserVisibleAt should fail when laser is hidden").toBe(true); + }); + + test("self-verify: hidden laser via opacity is detected", async ({ page, context, uniqueFixtures }) => { + const presentation = await uploadAndCapture(page, context, uniqueFixtures); + await pressShortcut(page, "l"); + const box = await page.locator("canvas").first().boundingBox(); + if (!box) throw new Error("canvas not found"); + await page.mouse.move(box.x + box.width * 0.5, box.y + box.height * 0.5); + await expectLaserVisibleAt(presentation, { left: 50, top: 50 }); + // Hide via opacity — checkVisibility default options miss this. + await presentation.addStyleTag({ + content: + 'body > div.fixed.pointer-events-none.z-50 { opacity: 0 !important; }', + }); + let detected = false; + try { + await expectLaserVisibleAt(presentation, { left: 50, top: 50 }, 2); + } catch { + detected = true; + } + expect(detected, "expectLaserVisibleAt should fail when laser is opacity:0").toBe(true); + }); + + test("self-verify: laser occluded by overlay element is detected", async ({ page, context, uniqueFixtures }) => { + const presentation = await uploadAndCapture(page, context, uniqueFixtures); + await pressShortcut(page, "l"); + const box = await page.locator("canvas").first().boundingBox(); + if (!box) throw new Error("canvas not found"); + await page.mouse.move(box.x + box.width * 0.5, box.y + box.height * 0.5); + await expectLaserVisibleAt(presentation, { left: 50, top: 50 }); + // Inject a fixed div with higher z-index covering the entire viewport. + await presentation.evaluate(() => { + const blocker = document.createElement("div"); + blocker.id = "test-blocker"; + blocker.style.cssText = + "position: fixed; inset: 0; z-index: 9999; background: rgba(0,255,0,0.2);"; + document.body.appendChild(blocker); + }); + let detected = false; + try { + await expectLaserVisibleAt(presentation, { left: 50, top: 50 }, 2); + } catch { + detected = true; + } + expect(detected, "expectLaserVisibleAt should fail when laser is occluded").toBe(true); + }); + test("laser dot stays on top of the slide across the canvas (grid of 9 points)", async ({ page, context, uniqueFixtures }) => { const presentation = await uploadAndCapture(page, context, uniqueFixtures); From dda60d4616a799bf277d52aefa806362163ae268 Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Tue, 5 May 2026 14:16:12 +0900 Subject: [PATCH 32/32] fix(PointerOverlay): handle late ref attachment via MutationObserver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `useContainerRect` ran its useEffect once at mount with `[ref]` deps. When `pdfAreaRef.current` was null at that moment (the target div lives inside 's inner Suspense and PdfPageCanvas is suspended on `use(getPage(...))`), the effect early-returned and never recovered: `[ref]` is stable, so the effect is never re-fired. `rect` stayed null forever, `PointerOverlay` returned null forever, and the laser dot was permanently invisible on whichever side hit the race — most reliably the presentation page, which has more PdfPages preloaded and runs the race more often. Fix: when `ref.current` is null at first run, set up a `MutationObserver` on `document.body` and re-try once the target node attaches. After it attaches, install the original ResizeObserver + window listeners and disconnect the MutationObserver. Surfaced by user-reported "presentation laser doesn't display" repro (diagnostic: `body > div.fixed.pointer-events-none.z-50` was absent in presentation's DOM after activation, confirming the portal never rendered). The new `regression: presentation PointerOverlay portal mounts even when PdfPage Suspense is slow` E2E test in pointer-tools.spec.ts covers the contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/tests/pointer-tools.spec.ts | 46 ++++++++++++++++++++++++++++ src/components/PointerOverlay.tsx | 50 ++++++++++++++++++++++++------- 2 files changed, 85 insertions(+), 11 deletions(-) diff --git a/e2e/tests/pointer-tools.spec.ts b/e2e/tests/pointer-tools.spec.ts index 48d84f4..e8fc952 100644 --- a/e2e/tests/pointer-tools.spec.ts +++ b/e2e/tests/pointer-tools.spec.ts @@ -357,6 +357,52 @@ test.describe("pointer tools (laser + pen)", () => { await presentation.close(); }); + test("regression: presentation PointerOverlay portal mounts even when PdfPage Suspense is slow", async ({ page, context, uniqueFixtures }) => { + // Reproduces the user-reported bug: PointerOverlay's `useContainerRect` + // runs its useEffect once with `pdfAreaRef.current === null` (because + // PdfPageCanvas is still suspended inside the inner ), gives + // up early, and never re-runs because `[ref]` deps are stable. The + // portal then never mounts. Force the race by throttling presentation's + // JS thread so `pdfProxy.getPage()` takes longer to resolve, leaving + // `pdfAreaRef.current` null past the parent commit's effect phase. + await resetAppState(page); + const presentationPromise = context.waitForEvent("page"); + await page + .locator('input[type="file"][accept*=".pdf"]') + .setInputFiles([uniqueFixtures.pdf, uniqueFixtures.pdfpc]); + await page.waitForURL(/\/(en|ja)\/presenter/); + const presentation = await presentationPromise; + await presentation.waitForLoadState("domcontentloaded"); + + // Throttle the presentation's CPU 20× so the inner getPage() Suspense + // resolves slowly, deterministically reproducing the + // `pdfAreaRef.current === null` window. Without this, fast machines + // happen to have the ref attached in time and the bug stays silent. + const cdp = await context.newCDPSession(presentation); + await cdp.send("Emulation.setCPUThrottlingRate", { rate: 20 }); + + // Wait for presentation canvas — past PdfPageCanvas mount. + await presentation + .locator("canvas") + .first() + .waitFor({ state: "visible", timeout: 60_000 }); + // Restore CPU. + await cdp.send("Emulation.setCPUThrottlingRate", { rate: 1 }); + + // Activate laser on presenter — broadcasts `tool-mode laser`. + await page.locator("canvas").first().waitFor({ state: "visible", timeout: 30_000 }); + await pressShortcut(page, "l"); + const box = await page.locator("canvas").first().boundingBox(); + if (!box) throw new Error("canvas not found"); + await page.mouse.move(box.x + box.width * 0.5, box.y + box.height * 0.5); + + // The portal MUST mount on presentation. If `useContainerRect` lost + // its ref to the suspense race, this assertion times out. + await expect( + presentation.locator("body > div.fixed.pointer-events-none.z-50"), + ).toBeAttached({ timeout: 10_000 }); + }); + test("self-verify: hidden laser is detected (display:none injection)", async ({ page, context, uniqueFixtures }) => { const presentation = await uploadAndCapture(page, context, uniqueFixtures); await pressShortcut(page, "l"); diff --git a/src/components/PointerOverlay.tsx b/src/components/PointerOverlay.tsx index 6891d7f..d11a08e 100644 --- a/src/components/PointerOverlay.tsx +++ b/src/components/PointerOverlay.tsx @@ -118,18 +118,46 @@ export function PointerOverlay({ function useContainerRect(ref: RefObject) { const [rect, setRect] = useState(null); useEffect(() => { - const el = ref.current; - if (!el) return; - const update = () => setRect(el.getBoundingClientRect()); - update(); - const observer = new ResizeObserver(update); - observer.observe(el); - window.addEventListener("resize", update); - window.addEventListener("scroll", update, true); + // `ref.current` may be null at mount when the target lives behind a + // Suspense boundary that hasn't resolved yet (e.g. PdfPageCanvas's + // inner
is suspended while getPage() is + // pending). The previous implementation early-returned in that case + // and never recovered because `[ref]` deps are stable, leaving rect + // null forever and PointerOverlay perpetually rendering null. + // Watch the document for the ref's element to attach instead. + let cleanup: (() => void) | null = null; + + const setupOn = (el: HTMLElement) => { + const update = () => setRect(el.getBoundingClientRect()); + update(); + const observer = new ResizeObserver(update); + observer.observe(el); + window.addEventListener("resize", update); + window.addEventListener("scroll", update, true); + cleanup = () => { + observer.disconnect(); + window.removeEventListener("resize", update); + window.removeEventListener("scroll", update, true); + }; + }; + + const initial = ref.current; + if (initial) { + setupOn(initial); + return () => cleanup?.(); + } + + const mo = new MutationObserver(() => { + const el = ref.current; + if (el) { + mo.disconnect(); + setupOn(el); + } + }); + mo.observe(document.body, { childList: true, subtree: true }); return () => { - observer.disconnect(); - window.removeEventListener("resize", update); - window.removeEventListener("scroll", update, true); + mo.disconnect(); + cleanup?.(); }; }, [ref]); return rect;