From 79e7d4df9c7cf6480f09854f000e063667571db9 Mon Sep 17 00:00:00 2001 From: CC Worker Date: Sun, 31 May 2026 23:49:41 +0000 Subject: [PATCH] test: add Playwright E2E tests for canvas mount, node navigation, transcription Three spec files with beforeAll skip guards: - Skip if VITE_TEST_TEACHER_EMAIL/PASSWORD not set - Skip if dev server at 192.168.0.251:13000 unreachable Tests exit 0 (3 skipped) when env is unconfigured. Requires: live dev server + real teacher credentials to run live. --- playwright.config.ts | 18 +++++++ src/__tests__/e2e/canvas-mount.spec.js | 47 ++++++++++++++++ src/__tests__/e2e/canvas-mount.spec.ts | 39 ++++++++++++++ src/__tests__/e2e/node-navigation.spec.js | 54 +++++++++++++++++++ src/__tests__/e2e/node-navigation.spec.ts | 47 ++++++++++++++++ .../e2e/transcription-toggle.spec.js | 54 +++++++++++++++++++ .../e2e/transcription-toggle.spec.ts | 41 ++++++++++++++ 7 files changed, 300 insertions(+) create mode 100644 playwright.config.ts create mode 100644 src/__tests__/e2e/canvas-mount.spec.js create mode 100644 src/__tests__/e2e/canvas-mount.spec.ts create mode 100644 src/__tests__/e2e/node-navigation.spec.js create mode 100644 src/__tests__/e2e/node-navigation.spec.ts create mode 100644 src/__tests__/e2e/transcription-toggle.spec.js create mode 100644 src/__tests__/e2e/transcription-toggle.spec.ts diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..58d6e4e --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './src/__tests__/e2e', + testMatch: '**/*.spec.js', + timeout: 30000, + expect: { timeout: 5000 }, + fullyParallel: false, + retries: 1, + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000', + trace: 'on-first-retry', + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], + webServer: undefined, +}); diff --git a/src/__tests__/e2e/canvas-mount.spec.js b/src/__tests__/e2e/canvas-mount.spec.js new file mode 100644 index 0000000..055dc41 --- /dev/null +++ b/src/__tests__/e2e/canvas-mount.spec.js @@ -0,0 +1,47 @@ +import { test, expect, request } from '@playwright/test'; + +const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000'; +const TEST_EMAIL = process.env.VITE_TEST_TEACHER_EMAIL; +const TEST_PASSWORD = process.env.VITE_TEST_TEACHER_PASSWORD; + +async function serverReachable() { + try { + const ctx = await request.newContext(); + const res = await ctx.get(BASE, { timeout: 3000 }).catch(() => null); + await ctx.dispose(); + return Boolean(res); + } catch { + return false; + } +} + +async function login(page) { + await page.goto(`${BASE}/login`); + await page.getByLabel('Email').fill(TEST_EMAIL); + await page.getByLabel('Password').fill(TEST_PASSWORD); + await page.getByRole('button', { name: 'Login' }).click(); + await page.waitForURL((url) => /\/dashboard|\/node\//.test(url.pathname), { timeout: 15000 }); +} + +function skipIfUnconfigured() { + if (!TEST_EMAIL || !TEST_PASSWORD) test.skip(true, 'VITE_TEST_TEACHER_EMAIL / VITE_TEST_TEACHER_PASSWORD not set'); +} + +test.describe('Canvas mount', () => { + test.beforeAll(async () => { + skipIfUnconfigured(); + const reachable = await serverReachable(); + test.skip(!reachable, `Dev server not reachable at ${BASE}`); + }); + + test('login and show lesson canvas within 5s', async ({ page }) => { + await login(page); + const canvasVisible = await page + .locator('[data-testid="tldraw-canvas"], .tl-container') + .waitFor({ timeout: 10000 }) + .then(() => true) + .catch(() => false); + expect(canvasVisible).toBe(true); + await expect(page.locator('[class*="error-state"], [data-testid="error"]').first()).toBeHidden({ timeout: 2000 }).catch(() => {}); + }); +}); diff --git a/src/__tests__/e2e/canvas-mount.spec.ts b/src/__tests__/e2e/canvas-mount.spec.ts new file mode 100644 index 0000000..31d5117 --- /dev/null +++ b/src/__tests__/e2e/canvas-mount.spec.ts @@ -0,0 +1,39 @@ +/// +import { test, expect } from '@playwright/test'; + +const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000'; + +test.describe('Canvas mount', () => { + test('login and show lesson canvas', async ({ page }) => { + test.setTimeout(60000); + await page.goto(`${BASE}/login`); + const email = process.env.VITE_TEST_TEACHER_EMAIL || 'teacher@test'; + const password = process.env.VITE_TEST_TEACHER_PASSWORD || 'password'; + + await page.getByLabel('Email').fill(email); + await page.getByLabel('Password').fill(password); + await page.getByRole('button', { name: /sign in|log in/i }).click(); + await page.waitForURL((url: URL) => /\/dashboard|\/node\//.test(url.pathname), { timeout: 12000 }); + + async function pingOk() { + try { + const r = await page.request.get(new URL('/api/health', BASE).toString(), { timeout: 4000 }).catch( + () => null as any + ); + return Boolean(r && (r as any).ok()); + } catch { + return false; + } + } + if (!(await pingOk())) test.skip(true, 'Dev server not reachable at ' + BASE); + + const canvasVisible = page + .locator('[data-testid="tldraw-canvas"], .tl-container') + .waitFor({ timeout: 8000 }) + .then(() => true) + .catch(() => false); + + expect((await canvasVisible)).toBe(true); + await expect(page.getByText(/error|failed to load/i).first()).toBeHidden(); + }); +}); diff --git a/src/__tests__/e2e/node-navigation.spec.js b/src/__tests__/e2e/node-navigation.spec.js new file mode 100644 index 0000000..57240ad --- /dev/null +++ b/src/__tests__/e2e/node-navigation.spec.js @@ -0,0 +1,54 @@ +import { test, expect, request } from '@playwright/test'; + +const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000'; +const TEST_EMAIL = process.env.VITE_TEST_TEACHER_EMAIL; +const TEST_PASSWORD = process.env.VITE_TEST_TEACHER_PASSWORD; + +async function serverReachable() { + try { + const ctx = await request.newContext(); + const res = await ctx.get(BASE, { timeout: 3000 }).catch(() => null); + await ctx.dispose(); + return Boolean(res); + } catch { + return false; + } +} + +async function login(page) { + await page.goto(`${BASE}/login`); + await page.getByLabel('Email').fill(TEST_EMAIL); + await page.getByLabel('Password').fill(TEST_PASSWORD); + await page.getByRole('button', { name: 'Login' }).click(); + await page.waitForURL((url) => /\/dashboard|\/node\//.test(url.pathname), { timeout: 15000 }); +} + +function skipIfUnconfigured() { + if (!TEST_EMAIL || !TEST_PASSWORD) test.skip(true, 'VITE_TEST_TEACHER_EMAIL / VITE_TEST_TEACHER_PASSWORD not set'); +} + +test.describe('Node navigation', () => { + test.beforeAll(async () => { + skipIfUnconfigured(); + const reachable = await serverReachable(); + test.skip(!reachable, `Dev server not reachable at ${BASE}`); + }); + + test('canvas remounts cleanly on node navigation', async ({ page }) => { + const errors = []; + page.on('pageerror', err => errors.push(err.message)); + await login(page); + await page.locator('[data-testid="tldraw-canvas"], .tl-container').waitFor({ timeout: 10000 }); + const navLinks = page.locator('nav a, [data-testid="nav-node"]'); + const count = await navLinks.count(); + if (count > 1) { + await navLinks.nth(1).click(); + await page.waitForTimeout(2000); + const hasCanvas = await page.locator('[data-testid="tldraw-canvas"], .tl-container') + .waitFor({ timeout: 8000 }).then(() => true).catch(() => false); + expect(hasCanvas).toBe(true); + } + const criticalErrors = errors.filter(e => /uncaught|undefined is not/i.test(e)); + expect(criticalErrors).toHaveLength(0); + }); +}); diff --git a/src/__tests__/e2e/node-navigation.spec.ts b/src/__tests__/e2e/node-navigation.spec.ts new file mode 100644 index 0000000..eeff92a --- /dev/null +++ b/src/__tests__/e2e/node-navigation.spec.ts @@ -0,0 +1,47 @@ +/// +import { test, expect } from '@playwright/test'; + +const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000'; + +test.describe('Node navigation', () => { + test('canvas remounts on node change', async ({ page }) => { + test.setTimeout(60000); + + async function serverReachable() { + try { + const res = await page.request.get(new URL('/api/health', BASE).toString(), { timeout: 4000 }).catch( + () => null as any + ); + return Boolean(res && (res as any).ok()); + } catch { + return false; + } + } + if (!(await serverReachable())) test.skip(true, 'Dev server not reachable at ' + BASE); + + const consoleMessages: string[] = []; + page.on('console', (msg: any) => consoleMessages.push((msg.text() as string) || '')); + page.on('pageerror', (err: any) => consoleMessages.push('pageerror: ' + ((err.message as string) || 'unknown'))); + + await page.goto(`${BASE}/login`); + const email = process.env.VITE_TEST_TEACHER_EMAIL || 'teacher@test'; + const password = process.env.VITE_TEST_TEACHER_PASSWORD || 'password'; + await page.getByLabel('Email').fill(email); + await page.getByLabel('Password').fill(password); + await page.getByRole('button', { name: /sign in|log in/i }).click(); + await page.waitForURL((url: URL) => /\/dashboard|\/node\//.test(url.pathname), { timeout: 12000 }); + + const canvas = page.locator('[data-testid="tldraw-canvas"], .tl-container'); + if ((await canvas.count()) === 0) await canvas.first().waitFor({ timeout: 8000 }); + await canvas.first().waitFor({ state: 'visible', timeout: 8000 }); + + const nodes = page.locator('a[href*="/node/"]').first(); + if ((await nodes.count()) === 0) test.skip(true, 'No node navigation links found'); + await nodes.click(); + await page.waitForURL((url: URL) => /\/node\/\d+/.test(url.pathname), { timeout: 12000 }); + + await expect(canvas.first()).toBeVisible(); + const errorTexts = consoleMessages.some((m) => /error|failed/i.test(m)); + expect(errorTexts).toBe(false); + }); +}); diff --git a/src/__tests__/e2e/transcription-toggle.spec.js b/src/__tests__/e2e/transcription-toggle.spec.js new file mode 100644 index 0000000..3f6a9a5 --- /dev/null +++ b/src/__tests__/e2e/transcription-toggle.spec.js @@ -0,0 +1,54 @@ +import { test, expect, request } from '@playwright/test'; + +const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000'; +const TEST_EMAIL = process.env.VITE_TEST_TEACHER_EMAIL; +const TEST_PASSWORD = process.env.VITE_TEST_TEACHER_PASSWORD; + +async function serverReachable() { + try { + const ctx = await request.newContext(); + const res = await ctx.get(BASE, { timeout: 3000 }).catch(() => null); + await ctx.dispose(); + return Boolean(res); + } catch { + return false; + } +} + +async function login(page) { + await page.goto(`${BASE}/login`); + await page.getByLabel('Email').fill(TEST_EMAIL); + await page.getByLabel('Password').fill(TEST_PASSWORD); + await page.getByRole('button', { name: 'Login' }).click(); + await page.waitForURL((url) => /\/dashboard|\/node\//.test(url.pathname), { timeout: 15000 }); +} + +function skipIfUnconfigured() { + if (!TEST_EMAIL || !TEST_PASSWORD) test.skip(true, 'VITE_TEST_TEACHER_EMAIL / VITE_TEST_TEACHER_PASSWORD not set'); +} + +test.describe('Transcription toggle', () => { + test.beforeAll(async () => { + skipIfUnconfigured(); + const reachable = await serverReachable(); + test.skip(!reachable, `Dev server not reachable at ${BASE}`); + }); + + test('record button toggles isRecording state', async ({ page, context }) => { + await context.grantPermissions(['microphone']); + await login(page); + await page.locator('[data-testid="tldraw-canvas"], .tl-container').waitFor({ timeout: 10000 }); + const recordBtn = page.locator('[data-testid="record-button"], button[aria-label*="ecord" i]').first(); + const btnVisible = await recordBtn.waitFor({ timeout: 5000 }).then(() => true).catch(() => false); + if (btnVisible) { + const labelBefore = await recordBtn.getAttribute('aria-label').catch(() => ''); + await recordBtn.click(); + await page.waitForTimeout(1000); + const labelAfter = await recordBtn.getAttribute('aria-label').catch(() => ''); + expect(labelBefore).not.toEqual(labelAfter); + await recordBtn.click(); + } else { + test.skip(true, 'Record button not visible on current node type'); + } + }); +}); diff --git a/src/__tests__/e2e/transcription-toggle.spec.ts b/src/__tests__/e2e/transcription-toggle.spec.ts new file mode 100644 index 0000000..0b68e07 --- /dev/null +++ b/src/__tests__/e2e/transcription-toggle.spec.ts @@ -0,0 +1,41 @@ +/// +import { test, expect } from '@playwright/test'; + +const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000'; + +test.describe('Transcription toggle', () => { + test('record and stop preserve segments', async ({ page }) => { + test.setTimeout(60000); + + async function serverReachable() { + try { + const res = await page.request.get(new URL('/api/health', BASE).toString(), { timeout: 4000 }).catch( + () => null as any + ); + return Boolean(res && (res as any).ok()); + } catch { + return false; + } + } + if (!(await serverReachable())) test.skip(true, 'Dev server not reachable at ' + BASE); + + await page.goto(`${BASE}/login`); + const email = process.env.VITE_TEST_TEACHER_EMAIL || 'teacher@test'; + const password = process.env.VITE_TEST_TEACHER_PASSWORD || 'password'; + await page.getByLabel('Email').fill(email); + await page.getByLabel('Password').fill(password); + await page.getByRole('button', { name: /sign in|log in/i }).click(); + await page.waitForURL((url: URL) => /\/dashboard|\/node\//.test(url.pathname), { timeout: 12000 }); + + const recordButton = page.locator('[data-testid="record-button"], button[aria-label*="record" i], button[aria-label*="Record" i]').first(); + if ((await recordButton.count()) === 0) test.skip(true, 'No transcription record control found'); + const beforeCount = await page.locator('[data-testid="segment"], .segment, .transcription-segment').count(); + + await recordButton.click(); + await expect(recordButton).toHaveAttribute('aria-pressed', 'true'); + await recordButton.click(); + + const afterCount = await page.locator('[data-testid="segment"], .segment, .transcription-segment').count(); + expect(afterCount).toBeGreaterThanOrEqual(beforeCount); + }); +});