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);
+ });
+});