From 47c07c7d2455b19abb2c1cc4efe5c00c984bc052 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sun, 24 May 2026 11:51:10 +0100 Subject: [PATCH 01/10] Highlight script row on add-cue button hover (#1081) (#1082) Add subtle row highlight when hovering the add-cue (+) button in the cue editor, clarifying which line the new cue will be attached to. Applied to both Vue 2 and Vue 3 clients for parity. Co-authored-by: Claude Sonnet 4.6 --- .../show/config/cues/ScriptLineCueEditor.vue | 9 ++++++++- .../show/config/cues/ScriptLineCueEditor.vue | 12 ++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/client-v3/src/components/show/config/cues/ScriptLineCueEditor.vue b/client-v3/src/components/show/config/cues/ScriptLineCueEditor.vue index 922a52ed8..d028af01e 100644 --- a/client-v3/src/components/show/config/cues/ScriptLineCueEditor.vue +++ b/client-v3/src/components/show/config/cues/ScriptLineCueEditor.vue @@ -13,6 +13,7 @@ @@ -483,4 +484,10 @@ async function deleteCue(): Promise { .cut-line-part { text-decoration: line-through; } + +.line-row:has(.add-cue-btn:hover) { + background-color: rgba(255, 255, 255, 0.06); + border-radius: 4px; + transition: background-color 0.15s ease; +} diff --git a/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue b/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue index 8226a7c91..cceed97d3 100644 --- a/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue +++ b/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue @@ -6,7 +6,10 @@

{{ actLabel }} - {{ sceneLabel }}

- + @@ -620,4 +623,9 @@ export default defineComponent({ .cut-line-part { text-decoration: line-through; } +.line-row:has(.add-cue-btn:hover) { + background-color: rgba(255, 255, 255, 0.06); + border-radius: 4px; + transition: background-color 0.15s ease; +} From 5f896e92b40f1f6afee7e0a38ed1289434bd24cd Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Mon, 25 May 2026 19:46:11 +0100 Subject: [PATCH 02/10] Add comprehensive Playwright E2E test suite for V3 UI (#1084) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add comprehensive Playwright E2E test suite for V3 UI 148 serial tests across 14 spec files covering every user-facing workflow in the Vue 3 frontend: authentication, system config (shows, users, RBAC, settings, backups), all show-config tabs (acts/scenes, characters, stage/props, cues, microphone allocations, script editing, revisions, sessions), and live show operation. Includes GitHub Actions workflow, global setup/teardown that starts a clean backend server, Playwright config with chromium/firefox projects, and targeted fixes for BVN v-show DOM persistence, modal stacking, and MicAllocations onMounted timing. Co-Authored-By: Claude Sonnet 4.6 * Fix CI failures: Prettier formatting, Vitest e2e exclusion, upload-artifact v4 - Run Prettier on 5 e2e spec files that had formatting violations - Exclude e2e/** from Vitest so Playwright spec files are not picked up by the unit test runner (test.describe.configure() is Playwright-only) - Upgrade actions/upload-artifact from v3 (deprecated) to v4 in the Playwright workflow Co-Authored-By: Claude Sonnet 4.6 * Fix CI: lazy-tab timeouts in spec 03, upgrade deprecated GHA actions - Add 10s timeouts to three toBeVisible() assertions in spec 03 that follow lazy BTabs switches (Users, Settings, Logs tabs in ConfigView) — default 5s wasn't enough on Ubuntu CI runners - Upgrade actions/checkout, setup-python, setup-node from v3/v4 to v4/v5/v4 to avoid Node.js 20 deprecation warnings and future breakage Co-Authored-By: Claude Sonnet 4.6 * Add README documentation for client-v3 and E2E test suite Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .github/workflows/playwright-test.yml | 65 ++++++ .gitignore | 4 + client-v3/README.md | 59 +++++ client-v3/e2e/README.md | 77 +++++++ client-v3/e2e/global-setup.ts | 100 ++++++++ client-v3/e2e/global-teardown.ts | 24 ++ client-v3/e2e/helpers.ts | 53 +++++ client-v3/e2e/tests/01-first-run.spec.ts | 69 ++++++ client-v3/e2e/tests/02-auth.spec.ts | 80 +++++++ client-v3/e2e/tests/03-system-config.spec.ts | 213 ++++++++++++++++++ .../e2e/tests/04-show-config-show.spec.ts | 60 +++++ .../tests/05-show-config-acts-scenes.spec.ts | 120 ++++++++++ .../tests/06-show-config-characters.spec.ts | 142 ++++++++++++ .../e2e/tests/07-show-config-stage.spec.ts | 178 +++++++++++++++ .../e2e/tests/08-show-config-cues.spec.ts | 120 ++++++++++ .../e2e/tests/09-show-config-mics.spec.ts | 160 +++++++++++++ .../e2e/tests/10-show-config-script.spec.ts | 201 +++++++++++++++++ .../tests/11-show-config-revisions.spec.ts | 114 ++++++++++ .../e2e/tests/12-show-config-sessions.spec.ts | 72 ++++++ client-v3/e2e/tests/13-live-show.spec.ts | 161 +++++++++++++ client-v3/e2e/tests/14-user-settings.spec.ts | 147 ++++++++++++ client-v3/package-lock.json | 64 ++++++ client-v3/package.json | 17 +- client-v3/playwright.config.ts | 32 +++ .../src/components/config/ConfigShows.vue | 32 +-- .../src/components/config/ConfigUsers.vue | 4 +- client-v3/tsconfig.e2e.json | 10 + client-v3/vitest.config.ts | 1 + 28 files changed, 2355 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/playwright-test.yml create mode 100644 client-v3/README.md create mode 100644 client-v3/e2e/README.md create mode 100644 client-v3/e2e/global-setup.ts create mode 100644 client-v3/e2e/global-teardown.ts create mode 100644 client-v3/e2e/helpers.ts create mode 100644 client-v3/e2e/tests/01-first-run.spec.ts create mode 100644 client-v3/e2e/tests/02-auth.spec.ts create mode 100644 client-v3/e2e/tests/03-system-config.spec.ts create mode 100644 client-v3/e2e/tests/04-show-config-show.spec.ts create mode 100644 client-v3/e2e/tests/05-show-config-acts-scenes.spec.ts create mode 100644 client-v3/e2e/tests/06-show-config-characters.spec.ts create mode 100644 client-v3/e2e/tests/07-show-config-stage.spec.ts create mode 100644 client-v3/e2e/tests/08-show-config-cues.spec.ts create mode 100644 client-v3/e2e/tests/09-show-config-mics.spec.ts create mode 100644 client-v3/e2e/tests/10-show-config-script.spec.ts create mode 100644 client-v3/e2e/tests/11-show-config-revisions.spec.ts create mode 100644 client-v3/e2e/tests/12-show-config-sessions.spec.ts create mode 100644 client-v3/e2e/tests/13-live-show.spec.ts create mode 100644 client-v3/e2e/tests/14-user-settings.spec.ts create mode 100644 client-v3/playwright.config.ts create mode 100644 client-v3/tsconfig.e2e.json diff --git a/.github/workflows/playwright-test.yml b/.github/workflows/playwright-test.yml new file mode 100644 index 000000000..282c8df9e --- /dev/null +++ b/.github/workflows/playwright-test.yml @@ -0,0 +1,65 @@ +name: Playwright E2E Tests + +on: [pull_request] + +permissions: + checks: write + pull-requests: write + +jobs: + playwright: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + browser: [chromium, firefox] + name: E2E Tests (${{ matrix.browser }}) + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install Python dependencies + run: pip install -r requirements.txt + working-directory: ./server + + - name: Set up Node 24 + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Install Node dependencies + run: npm ci + working-directory: ./client-v3 + + - name: Build Vue 3 frontend + run: npm run build + working-directory: ./client-v3 + + - name: Install Playwright browser + # matrix.browser is a static value from the matrix definition (chromium/firefox) + run: npx playwright install --with-deps ${{ matrix.browser }} + working-directory: ./client-v3 + + - name: Run Playwright tests + # matrix.browser is a static value from the matrix definition (chromium/firefox) + run: npx playwright test --project=${{ matrix.browser }} + working-directory: ./client-v3 + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report-${{ matrix.browser }} + path: client-v3/playwright-report/ + retention-days: 7 + + - name: Publish Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + check_name: "Playwright E2E Results (${{ matrix.browser }})" + junit_files: "**/client-v3/junit/playwright-results.xml" diff --git a/.gitignore b/.gitignore index bfc4a15d8..ff1f300fb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ CLAUDE.md .playwright-mcp/ plans/ +# Playwright +client-v3/playwright-report/ +client-v3/test-results/ + # Gradle and Maven with auto-import # When using Gradle or Maven with auto-import, you should exclude module files, # since they will be recreated, and may cause churn. Uncomment if using diff --git a/client-v3/README.md b/client-v3/README.md new file mode 100644 index 000000000..4a189de87 --- /dev/null +++ b/client-v3/README.md @@ -0,0 +1,59 @@ +# DigiScript Client (V3) + +**Requirements**: Node 24.x + +## Project setup +``` +npm ci +``` + +### Run the development server +``` +npm run dev +``` + +The dev server starts at `http://localhost:5173` and proxies API requests to `http://localhost:8080`. + +### Compiles and minifies for production +``` +npm run build +``` + +### Lints and formats files +``` +npm run lint +``` + +### Type checking +``` +npm run typecheck +``` + +### Unit tests +``` +npm run test:run +``` + +### E2E tests +``` +npm run test:e2e +``` + +See [e2e/README.md](./e2e/README.md) for full details on running and writing E2E tests. + +## Project Structure + +### Source Directory +The [src](./src) directory is where the main Vue 3 project lives. + +* [assets](./src/assets): static assets such as CSS and images. +* [components](./src/components): reusable Vue components, organised by feature area. +* [composables](./src/composables): Composition API composables (shared logic, replaces Vue 2 mixins). +* [constants](./src/constants): shared constants used across components. +* [js](./src/js): plain TypeScript utilities — HTTP interceptor, validators, platform abstraction. +* [router](./src/router): Vue Router configuration and navigation guards. +* [stores](./src/stores): Pinia stores for application state (user, show, script, websocket, etc.). +* [types](./src/types): TypeScript interfaces for API response shapes and shared types. +* [views](./src/views): page-level components mapped to router routes. +* [App.vue](./src/App.vue): root component — defines the main layout and houses the router view. +* [main.ts](./src/main.ts): application entry point, configures Vue, Pinia, and the router. diff --git a/client-v3/e2e/README.md b/client-v3/e2e/README.md new file mode 100644 index 000000000..c0a47df23 --- /dev/null +++ b/client-v3/e2e/README.md @@ -0,0 +1,77 @@ +# DigiScript E2E Tests + +End-to-end tests for the Vue 3 frontend, written with [Playwright](https://playwright.dev/). +Tests run against a real backend instance with a clean SQLite database, covering every major user-facing workflow. + +## Prerequisites + +* Node 24.x with dependencies installed (`npm ci` in `client-v3/`) +* Python 3.13.x with dependencies installed (`pip install -r requirements.txt` in `server/`) +* A production build of the frontend (`npm run build` in `client-v3/`) + +The global setup script starts and stops the backend automatically — no manual server management required. + +## Running the tests + +```bash +# Chromium only (default, fastest) +npm run test:e2e + +# Firefox only +npm run test:e2e:firefox + +# Both browsers +npm run test:e2e:all + +# Open the HTML report from the last run +npm run test:e2e:report +``` + +## How it works + +Before the suite runs, `e2e/global-setup.ts` launches the Python backend on port 8888 with a +temporary SQLite database. After the suite finishes, `e2e/global-teardown.ts` stops the server +and cleans up the database file. All tests share this single server instance and database. + +## Spec files + +Tests are numbered to enforce a specific run order. Each spec depends on the database state +left by earlier specs. + +| File | Area | +|------|------| +| `01-first-run.spec.ts` | Initial server setup wizard | +| `02-auth.spec.ts` | Login, logout, session handling | +| `03-system-config.spec.ts` | System Config: show creation, users, settings, system info, logs, backups | +| `04-show-config-show.spec.ts` | Show Config: show details | +| `05-show-config-acts-scenes.spec.ts` | Show Config: acts and scenes CRUD | +| `06-show-config-characters.spec.ts` | Show Config: characters CRUD | +| `07-show-config-stage.spec.ts` | Show Config: props, scenery, crew, stage manager allocations | +| `08-show-config-cues.spec.ts` | Show Config: cue types CRUD | +| `09-show-config-mics.spec.ts` | Show Config: microphones, allocations, timeline | +| `10-show-config-script.spec.ts` | Show Config: script editing, saving, stage direction styles, cue add/edit/delete | +| `11-show-config-revisions.spec.ts` | Show Config: script revisions | +| `12-show-config-sessions.spec.ts` | Show Config: live show sessions | +| `13-live-show.spec.ts` | Live show display and cue triggering | +| `14-user-settings.spec.ts` | User settings and profile | + +> **Important**: specs must always be run as a full suite. Running individual spec files in +> isolation will fail because each spec relies on database state created by earlier specs. +> Never use `--grep` or file-specific invocations in CI or local development. + +## Debugging + +Failed test runs produce an HTML report and screenshots/videos under `playwright-report/`. +Open the report with: + +```bash +npm run test:e2e:report +``` + +Trace files (recorded on failure) can be inspected with the Playwright Trace Viewer. + +## CI + +The GitHub Actions workflow (`.github/workflows/playwright-test.yml`) runs the full suite against +both Chromium and Firefox on every pull request. The workflow builds the frontend, installs +Playwright browsers, runs the suite, and uploads the HTML report as an artifact. diff --git a/client-v3/e2e/global-setup.ts b/client-v3/e2e/global-setup.ts new file mode 100644 index 000000000..c26e2108d --- /dev/null +++ b/client-v3/e2e/global-setup.ts @@ -0,0 +1,100 @@ +import { spawn, execFileSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +const SERVER_PORT = 8888; +const HEALTH_URL = `http://localhost:${SERVER_PORT}/api/v1/health`; + +export const PID_FILE = path.join(os.tmpdir(), 'digiscript-e2e-server.pid'); +export const TMPDIR_FILE = path.join(os.tmpdir(), 'digiscript-e2e-tmpdir.txt'); + +export default async function globalSetup(): Promise { + // Kill any stale server from a previous run that survived teardown + await killStaleServer(); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'digiscript-e2e-')); + const configPath = path.join(tempDir, 'digiscript.json'); + const dbPath = path.join(tempDir, 'digiscript.sqlite'); + + fs.writeFileSync( + configPath, + JSON.stringify({ + db_path: `sqlite:///${dbPath}`, + mdns_advertising: false, + log_path: path.join(tempDir, 'digiscript.log'), + }) + ); + fs.writeFileSync(TMPDIR_FILE, tempDir); + + // server/ is a sibling of client-v3/ — process.cwd() is client-v3/ when + // invoked via "npm run test:e2e" or with working-directory: ./client-v3 in CI + const serverDir = path.resolve(process.cwd(), '..', 'server'); + + const server = spawn( + 'python3', + ['main.py', `--port=${SERVER_PORT}`, `--settings_path=${configPath}`, '--debug=false'], + { + cwd: serverDir, + detached: true, + stdio: 'ignore', + } + ); + + if (server.pid === undefined) { + throw new Error('Failed to start DigiScript test server'); + } + + fs.writeFileSync(PID_FILE, String(server.pid)); + server.unref(); + + await waitForServer(); + console.log(`DigiScript test server ready on port ${SERVER_PORT}`); +} + +/** Kill any process listening on SERVER_PORT (handles failed teardowns). */ +async function killStaleServer(): Promise { + // Quick check — if port is free, nothing to do + try { + await fetch(HEALTH_URL, { signal: AbortSignal.timeout(1000) }); + } catch { + return; // connection refused → port is free + } + + // Port is in use — find and kill PIDs via lsof (macOS/Linux) + try { + const output = execFileSync('lsof', ['-ti', String(SERVER_PORT)], { encoding: 'utf-8' }); + const pids = output + .trim() + .split('\n') + .filter(Boolean) + .map((s) => parseInt(s, 10)) + .filter((n) => !isNaN(n)); + for (const pid of pids) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // process already gone + } + } + if (pids.length > 0) { + await new Promise((r) => setTimeout(r, 1500)); + } + } catch { + // lsof not available (e.g. Windows) or no PIDs found — best effort + } +} + +async function waitForServer(timeoutMs = 30_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(HEALTH_URL); + if (res.ok) return; + } catch { + // server not ready yet + } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`DigiScript test server did not become healthy within ${timeoutMs}ms`); +} diff --git a/client-v3/e2e/global-teardown.ts b/client-v3/e2e/global-teardown.ts new file mode 100644 index 000000000..8cd48d2d7 --- /dev/null +++ b/client-v3/e2e/global-teardown.ts @@ -0,0 +1,24 @@ +import fs from 'fs'; +import { PID_FILE, TMPDIR_FILE } from './global-setup.js'; + +export default async function globalTeardown(): Promise { + if (fs.existsSync(PID_FILE)) { + const pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8').trim(), 10); + try { + process.kill(pid, 'SIGKILL'); + } catch { + // process may have already exited + } + fs.unlinkSync(PID_FILE); + } + + if (fs.existsSync(TMPDIR_FILE)) { + const tempDir = fs.readFileSync(TMPDIR_FILE, 'utf-8').trim(); + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + fs.unlinkSync(TMPDIR_FILE); + } +} diff --git a/client-v3/e2e/helpers.ts b/client-v3/e2e/helpers.ts new file mode 100644 index 000000000..3a694ab90 --- /dev/null +++ b/client-v3/e2e/helpers.ts @@ -0,0 +1,53 @@ +import type { Page } from '@playwright/test'; + +export const SERVER_PORT = 8888; +export const UI_BASE = `http://localhost:${SERVER_PORT}/ui-new`; +export const ADMIN_USERNAME = 'admin'; +export const ADMIN_PASSWORD = 'testpassword'; + +/** Wait until the WebSocket is connected and the app has finished its startup sequence. */ +export async function waitForAppReady(page: Page): Promise { + await page.waitForSelector('#connection-status.healthy', { timeout: 15_000 }); +} + +/** Navigate to the login page, fill credentials, and wait for redirect to home. */ +export async function loginAsAdmin(page: Page): Promise { + await page.goto(`${UI_BASE}/login`); + await waitForAppReady(page); + await page.fill('#username-input', ADMIN_USERNAME); + await page.fill('#password-input', ADMIN_PASSWORD); + await page.click('button:has-text("Login")'); + await page.waitForURL(`${UI_BASE}/`); + await waitForAppReady(page); +} + +/** Wait for a Bootstrap modal with the given title to be visible. */ +export async function waitForModal(page: Page, title: string | RegExp): Promise { + await page + .locator('.modal.show .modal-title') + .filter({ hasText: title }) + .waitFor({ timeout: 5_000 }); +} + +/** Click the OK/confirm button in the currently open Bootstrap modal. */ +export async function confirmModal(page: Page): Promise { + await page.click('.modal.show .modal-footer button.btn-primary'); +} + +/** Click the Cancel button in the currently open Bootstrap modal. */ +export async function cancelModal(page: Page): Promise { + await page.click('.modal.show .modal-footer button:has-text("Cancel")'); +} + +/** Wait for the currently-open Bootstrap modal to fully close. */ +export async function waitForModalClosed(page: Page, timeout = 5_000): Promise { + await page.waitForSelector('.modal.show', { state: 'detached', timeout }); +} + +/** Click OK on a ConfirmDialog (the app-level confirm dialog, not a BModal). */ +export async function confirmDialog(page: Page): Promise { + await page.waitForSelector('.modal.show', { timeout: 5_000 }); + // Confirmation buttons may be btn-primary, btn-danger, or btn-warning depending on context + await page.locator('.modal.show .modal-footer button:not(.btn-secondary)').first().click(); + await page.waitForSelector('.modal.show', { state: 'detached', timeout: 5_000 }); +} diff --git a/client-v3/e2e/tests/01-first-run.spec.ts b/client-v3/e2e/tests/01-first-run.spec.ts new file mode 100644 index 000000000..272c74438 --- /dev/null +++ b/client-v3/e2e/tests/01-first-run.spec.ts @@ -0,0 +1,69 @@ +/** + * First-run: covers the admin-creation flow that appears when has_admin_user is false. + * This must be the first spec to run because it establishes the admin account used + * by all subsequent tests. + */ +import { test, expect, type BrowserContext, type Page } from '@playwright/test'; +import { UI_BASE, ADMIN_PASSWORD, waitForAppReady } from '../helpers.js'; + +test.describe.configure({ mode: 'serial' }); + +let ctx: BrowserContext; +let page: Page; + +test.beforeAll(async ({ browser }) => { + ctx = await browser.newContext(); + page = await ctx.newPage(); +}); + +test.afterAll(async () => { + await ctx.close(); +}); + +test('shows the first-run admin creation form', async () => { + await page.goto(`${UI_BASE}/`); + await waitForAppReady(page); + await expect(page.locator('h2')).toHaveText('Welcome to DigiScript'); + await expect(page.locator('text=To get started, please create an admin user!')).toBeVisible(); +}); + +test('username field is pre-filled with "admin" and disabled', async () => { + const usernameInput = page.locator('#username-input'); + await expect(usernameInput).toHaveValue('admin'); + await expect(usernameInput).toBeDisabled(); +}); + +test('Save button is disabled until form is valid', async () => { + await expect(page.locator('button:has-text("Save")')).toBeDisabled(); +}); + +test('creates the admin user and transitions to the main UI', async () => { + await page.fill('#password-input', ADMIN_PASSWORD); + await page.fill('#confirm-password-input', ADMIN_PASSWORD); + await page.click('button:has-text("Save")'); + + // After creation the settings update via WS and the router view replaces the form + await page.waitForSelector('h2:has-text("Welcome to DigiScript")', { + state: 'detached', + timeout: 10_000, + }); + await waitForAppReady(page); + + // The user is not yet logged in — Login link should appear in the navbar + await expect(page.locator('a:has-text("Login")')).toBeVisible(); +}); + +test('can log in with the newly created admin credentials', async () => { + await page.click('a:has-text("Login")'); + await page.waitForURL(`${UI_BASE}/login`); + + await page.fill('#username-input', 'admin'); + await page.fill('#password-input', ADMIN_PASSWORD); + await page.click('button:has-text("Login")'); + + await page.waitForURL(`${UI_BASE}/`); + await waitForAppReady(page); + + // Logged-in username appears in the navbar dropdown + await expect(page.locator('nav').getByText('admin')).toBeVisible(); +}); diff --git a/client-v3/e2e/tests/02-auth.spec.ts b/client-v3/e2e/tests/02-auth.spec.ts new file mode 100644 index 000000000..489a88d3a --- /dev/null +++ b/client-v3/e2e/tests/02-auth.spec.ts @@ -0,0 +1,80 @@ +/** + * Authentication: login, wrong credentials error, logout, force-password-change guard. + * Assumes the admin account was created in 01-first-run.spec.ts. + */ +import { test, expect, type BrowserContext, type Page } from '@playwright/test'; +import { + UI_BASE, + ADMIN_USERNAME, + ADMIN_PASSWORD, + waitForAppReady, + loginAsAdmin, +} from '../helpers.js'; + +test.describe.configure({ mode: 'serial' }); + +let ctx: BrowserContext; +let page: Page; + +test.beforeAll(async ({ browser }) => { + ctx = await browser.newContext(); + page = await ctx.newPage(); +}); + +test.afterAll(async () => { + await ctx.close(); +}); + +test('login page renders correctly', async () => { + await page.goto(`${UI_BASE}/login`); + await waitForAppReady(page); + await expect(page.locator('h3')).toHaveText('Login to DigiScript'); + await expect(page.locator('#username-input')).toBeVisible(); + await expect(page.locator('#password-input')).toBeVisible(); + await expect(page.locator('button:has-text("Login")')).toBeDisabled(); +}); + +test('Login button is enabled once both fields are filled', async () => { + await page.fill('#username-input', ADMIN_USERNAME); + await page.fill('#password-input', ADMIN_PASSWORD); + await expect(page.locator('button:has-text("Login")')).toBeEnabled(); +}); + +test('shows error message on wrong credentials', async () => { + await page.fill('#password-input', 'wrongpassword'); + await page.click('button:has-text("Login")'); + await expect(page.locator('b:has-text("Login unsuccessful.")')).toBeVisible(); +}); + +test('successful login redirects to home page', async () => { + await page.fill('#password-input', ADMIN_PASSWORD); + await page.click('button:has-text("Login")'); + await page.waitForURL(`${UI_BASE}/`); + await waitForAppReady(page); + await expect(page.locator('nav').getByText(ADMIN_USERNAME)).toBeVisible(); +}); + +test('redirects to login when already logged in trying to visit /login', async () => { + await page.goto(`${UI_BASE}/login`); + // Router guard redirects authenticated users away from /login + await page.waitForURL(`${UI_BASE}/`); +}); + +test('can sign out via the navbar dropdown', async () => { + // Open the username dropdown + await page.locator('nav').getByText(ADMIN_USERNAME).click(); + await page.click('button:has-text("Sign Out")'); + // After logout, Login link reappears + await expect(page.locator('a:has-text("Login")')).toBeVisible({ timeout: 5_000 }); +}); + +test('protected routes redirect to login when not authenticated', async () => { + await page.goto(`${UI_BASE}/config`); + await page.waitForURL(`${UI_BASE}/login`); +}); + +test('can log back in after logout', async () => { + await loginAsAdmin(page); + // Allow extra time for user data to populate the nav after WS reconnect + await expect(page.locator('nav').getByText(ADMIN_USERNAME)).toBeVisible({ timeout: 15_000 }); +}); diff --git a/client-v3/e2e/tests/03-system-config.spec.ts b/client-v3/e2e/tests/03-system-config.spec.ts new file mode 100644 index 000000000..a4483b2b3 --- /dev/null +++ b/client-v3/e2e/tests/03-system-config.spec.ts @@ -0,0 +1,213 @@ +/** + * System config (/config): create + load show, user management, settings. + * This spec creates and loads a show — all subsequent specs depend on that show being loaded. + */ +import { test, expect, type BrowserContext, type Page } from '@playwright/test'; +import { + UI_BASE, + ADMIN_USERNAME, + loginAsAdmin, + waitForAppReady, + waitForModal, + waitForModalClosed, + confirmDialog, +} from '../helpers.js'; + +test.describe.configure({ mode: 'serial' }); + +let ctx: BrowserContext; +let page: Page; + +test.beforeAll(async ({ browser }) => { + ctx = await browser.newContext(); + page = await ctx.newPage(); + await loginAsAdmin(page); +}); + +test.afterAll(async () => { + await ctx.close(); +}); + +// ── Shows tab ────────────────────────────────────────────────────────────── + +test('navigates to System Config', async () => { + await page.click('a:has-text("System Config")'); + await page.waitForURL(`${UI_BASE}/config`); + await expect(page.locator('h1, h2, h3').first()).toBeVisible(); +}); + +test('shows the Shows tab by default', async () => { + await expect(page.locator('button:has-text("Setup New Show")')).toBeVisible(); +}); + +test('opens the Setup New Show modal', async () => { + await page.click('button:has-text("Setup New Show")'); + await waitForModal(page, 'Setup New Show'); +}); + +test('modal shows Save and Save and Load buttons', async () => { + await expect( + page.locator('.modal.show').getByRole('button', { name: 'Save', exact: true }) + ).toBeVisible(); + await expect( + page.locator('.modal.show').getByRole('button', { name: 'Save and Load' }) + ).toBeVisible(); +}); + +test('creates and loads a show via Save and Load', async () => { + await page.fill('#show-name-input', 'E2E Test Show'); + await page.fill('#show-start-input', '2025-01-01'); + await page.fill('#show-end-input', '2025-03-31'); + // Wait for script modes to load then explicitly select to trigger v-model update + await page + .waitForSelector('#show-script-mode-input option:not([value=""])', { timeout: 5_000 }) + .catch(() => {}); + await page.selectOption('#show-script-mode-input', { index: 0 }); + await page.click('.modal.show button:has-text("Save and Load")'); + + // Modal closes, toast appears + await waitForModalClosed(page, 10_000); + await waitForAppReady(page); + + // Show Config nav link appears once a show is loaded + await expect(page.locator('a:has-text("Show Config")')).toBeVisible({ timeout: 10_000 }); +}); + +// ── Users tab ────────────────────────────────────────────────────────────── + +test('switches to the Users tab', async () => { + await page.click( + 'button[role="tab"]:has-text("Users"), a[role="tab"]:has-text("Users"), .nav-link:has-text("Users")' + ); + await expect(page.locator('button:has-text("New User")')).toBeVisible({ timeout: 10_000 }); +}); + +test('opens New User modal', async () => { + await page.click('button:has-text("New User")'); + await waitForModal(page, 'Add New User'); + await expect(page.locator('.modal.show #username-input')).toBeVisible(); +}); + +test('creates a non-admin user', async () => { + await page.fill('.modal.show #username-input', 'testuser'); + await page.fill('.modal.show #password-input', 'testpassword'); + await page.fill('.modal.show #confirm-password-input', 'testpassword'); + await page.click('.modal.show button:has-text("Save")'); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("testuser")')).toBeVisible(); +}); + +test('can configure RBAC permissions for testuser', async () => { + const userRow = page.locator('tr', { has: page.locator('td:has-text("testuser")') }); + await userRow.locator('button:has-text("RBAC")').click(); + await waitForModal(page, 'User RBAC Config'); + + const grantBtn = page.locator('.modal.show button:has-text("Grant Role")').first(); + await expect(grantBtn).toBeVisible({ timeout: 5_000 }); + await grantBtn.click(); + await expect(page.locator('.modal.show button:has-text("Revoke Role")').first()).toBeVisible({ + timeout: 5_000, + }); + + await page.locator('.modal.show button:has-text("Revoke Role")').first().click(); + await expect(page.locator('.modal.show button:has-text("Grant Role")').first()).toBeVisible({ + timeout: 5_000, + }); + + await page.locator('.modal.show .modal-header .btn-close').click(); + await waitForModalClosed(page); +}); + +test('resets the non-admin user password', async () => { + // Ensure testuser row is stable before interacting + await expect(page.locator('td:has-text("testuser")')).toBeVisible({ timeout: 5_000 }); + const userRow = page.locator('tr', { has: page.locator('td:has-text("testuser")') }); + await userRow.locator('button:has-text("Reset Password")').click(); + // Wait for the modal to appear — match visible modal content rather than title class + await page.waitForSelector('.modal.show', { timeout: 10_000 }); + await expect(page.locator('.modal.show')).toContainText('Reset User Password'); + // Click the Reset Password button inside the modal body + await page.locator('.modal.show button:has-text("Reset Password")').click(); + // Temporary password shown — Done button appears + await expect(page.locator('.modal.show button:has-text("Done")')).toBeVisible({ timeout: 5_000 }); + await page.locator('.modal.show button:has-text("Done")').click(); + await waitForModalClosed(page); +}); + +test('deletes the non-admin user', async () => { + const userRow = page.locator('tr', { has: page.locator('td:has-text("testuser")') }); + await userRow.locator('button:has-text("Delete")').click(); + await confirmDialog(page); + await expect(page.locator('td:has-text("testuser")')).not.toBeVisible({ timeout: 5_000 }); +}); + +// ── Settings tab ────────────────────────────────────────────────────────── + +test('switches to the Settings tab', async () => { + await page.click('.nav-link:has-text("Settings")'); + await expect(page.locator('button:has-text("Submit")')).toBeVisible({ timeout: 10_000 }); +}); + +test('Submit button is disabled until a setting is changed', async () => { + await expect(page.locator('button:has-text("Submit")')).toBeDisabled(); +}); + +test('changing a setting enables the Submit and Reset buttons', async () => { + // Toggle the Debug Mode setting (a boolean switch) + const debugSwitch = page + .locator('label:has-text("Debug Mode")') + .locator('..') + .locator('input[type="checkbox"], .form-check-input') + .first(); + if ((await debugSwitch.count()) > 0) { + await debugSwitch.click(); + await expect(page.locator('button:has-text("Submit")')).toBeEnabled(); + await expect(page.getByRole('button', { name: 'Reset', exact: true })).toBeEnabled(); + // Reset it back + await page.getByRole('button', { name: 'Reset', exact: true }).click(); + } +}); + +// ── System tab ──────────────────────────────────────────────────────────── + +test('switches to the System tab', async () => { + await page + .locator('.nav-link') + .filter({ hasText: /^System$/ }) + .click(); + // Use strong selector — the modal "Connected Clients" h5 is also in the DOM (BVN teleport) + await expect(page.locator('strong:has-text("Connected Clients")')).toBeVisible({ + timeout: 10_000, + }); +}); + +test('can open the View Clients modal', async () => { + await page.click('button:has-text("View Clients")'); + await waitForModal(page, 'Connected Clients'); + await page.click('.modal.show .btn-secondary, .modal.show button:has-text("Close")'); +}); + +// ── Logs tab ────────────────────────────────────────────────────────────── + +test('switches to the Logs tab and can change source', async () => { + await page.click('.nav-link:has-text("Logs")'); + await expect(page.locator('text=Server').first()).toBeVisible({ timeout: 10_000 }); + // Switch to Client logs — Bootstrap btn-check inputs have pointer-events:none; click the label + await page + .locator('.tab-pane.active label') + .filter({ hasText: /^Client$/ }) + .click(); +}); + +// ── Backups tab ────────────────────────────────────────────────────────── + +test('switches to the Backups tab', async () => { + await page.click('.nav-link:has-text("Backups")'); + // Wait for the loading spinner to resolve to either the backups table or the empty-state alert. + // Scope to elements unique to ConfigBackups to avoid matching the RBAC modal's table. + await expect( + page + .locator('.alert:has-text("No backup files found")') + .or(page.locator('th:has-text("Filename")')) + ).toBeVisible({ timeout: 10_000 }); +}); diff --git a/client-v3/e2e/tests/04-show-config-show.spec.ts b/client-v3/e2e/tests/04-show-config-show.spec.ts new file mode 100644 index 000000000..8bac56a6d --- /dev/null +++ b/client-v3/e2e/tests/04-show-config-show.spec.ts @@ -0,0 +1,60 @@ +/** + * Show Config — Show details tab: view show info, edit show name/dates/first act. + */ +import { test, expect, type BrowserContext, type Page } from '@playwright/test'; +import { + UI_BASE, + loginAsAdmin, + waitForAppReady, + waitForModal, + confirmModal, + waitForModalClosed, +} from '../helpers.js'; + +test.describe.configure({ mode: 'serial' }); + +let ctx: BrowserContext; +let page: Page; + +test.beforeAll(async ({ browser }) => { + ctx = await browser.newContext(); + page = await ctx.newPage(); + await loginAsAdmin(page); + await page.goto(`${UI_BASE}/show-config`); + await waitForAppReady(page); +}); + +test.afterAll(async () => { + await ctx.close(); +}); + +test('show config landing page displays show details table', async () => { + await expect(page.locator('table')).toBeVisible(); + await expect(page.locator('td:has-text("E2E Test Show")')).toBeVisible(); +}); + +test('Edit Show button is visible for admin', async () => { + await expect(page.locator('button:has-text("Edit Show")')).toBeVisible(); +}); + +test('opens the Edit Show modal with current values', async () => { + await page.click('button:has-text("Edit Show")'); + await waitForModal(page, 'Edit Show'); + await expect(page.locator('#name-input')).toHaveValue('E2E Test Show'); +}); + +test('can update the show name', async () => { + await page.fill('#name-input', 'E2E Test Show (Updated)'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("E2E Test Show (Updated)")')).toBeVisible(); +}); + +test('restores original show name', async () => { + await page.click('button:has-text("Edit Show")'); + await waitForModal(page, 'Edit Show'); + await page.fill('#name-input', 'E2E Test Show'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("E2E Test Show")')).toBeVisible(); +}); diff --git a/client-v3/e2e/tests/05-show-config-acts-scenes.spec.ts b/client-v3/e2e/tests/05-show-config-acts-scenes.spec.ts new file mode 100644 index 000000000..ba9adfa78 --- /dev/null +++ b/client-v3/e2e/tests/05-show-config-acts-scenes.spec.ts @@ -0,0 +1,120 @@ +/** + * Show Config — Acts & Scenes tab: full CRUD for acts and scenes. + * Creates Act 1 + Scene 1 that later specs (characters, script) depend on. + */ +import { test, expect, type BrowserContext, type Page } from '@playwright/test'; +import { + UI_BASE, + loginAsAdmin, + waitForAppReady, + waitForModal, + confirmModal, + waitForModalClosed, + confirmDialog, +} from '../helpers.js'; + +test.describe.configure({ mode: 'serial' }); + +let ctx: BrowserContext; +let page: Page; + +test.beforeAll(async ({ browser }) => { + ctx = await browser.newContext(); + page = await ctx.newPage(); + await loginAsAdmin(page); + await page.goto(`${UI_BASE}/show-config/acts`); + await waitForAppReady(page); +}); + +test.afterAll(async () => { + await ctx.close(); +}); + +// ── Acts ─────────────────────────────────────────────────────────────────── + +test('Acts tab is visible', async () => { + await expect(page.locator('button:has-text("New Act")')).toBeVisible(); +}); + +test('creates Act 1', async () => { + await page.click('button:has-text("New Act")'); + await waitForModal(page, 'New Act'); + await page.fill('.modal.show input[type="text"]', 'Act 1'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("Act 1")').first()).toBeVisible(); +}); + +test('creates Act 2', async () => { + await page.click('button:has-text("New Act")'); + await waitForModal(page, 'New Act'); + await page.fill('.modal.show input[type="text"]', 'Act 2'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("Act 2")').first()).toBeVisible(); +}); + +test('edits Act 2 name', async () => { + const actRow = page.locator('tr', { has: page.locator('td:has-text("Act 2")') }); + await actRow.locator('button:has-text("Edit")').click(); + await waitForModal(page, 'Edit Act'); + await page.fill('.modal.show input[type="text"]', 'Act 2 (Edited)'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("Act 2 (Edited)")').first()).toBeVisible(); +}); + +test('deletes Act 2', async () => { + const actRow = page.locator('tr', { has: page.locator('td:has-text("Act 2 (Edited)")') }); + await actRow.locator('button:has-text("Delete")').click(); + await confirmDialog(page); + await expect(page.locator('td:has-text("Act 2 (Edited)")').first()).not.toBeVisible({ + timeout: 5_000, + }); +}); + +// ── Scenes ──────────────────────────────────────────────────────────────── + +test('switches to the Scenes sub-tab', async () => { + await page.click('.nav-link:has-text("Scenes"), button[role="tab"]:has-text("Scenes")'); + await expect(page.locator('button:has-text("New Scene")')).toBeVisible(); +}); + +test('creates Scene 1 in Act 1', async () => { + await page.click('button:has-text("New Scene")'); + await waitForModal(page, 'New Scene'); + await page.selectOption('#new-act-input', { index: 1 }); + await page.fill('.modal.show input[type="text"]', 'Scene 1'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("Scene 1")').first()).toBeVisible(); +}); + +test('creates Scene 2 in Act 1', async () => { + await page.click('button:has-text("New Scene")'); + await waitForModal(page, 'New Scene'); + await page.selectOption('#new-act-input', { index: 1 }); + await page.fill('.modal.show input[type="text"]', 'Scene 2'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("Scene 2")').first()).toBeVisible(); +}); + +test('edits Scene 2 name', async () => { + const sceneRow = page.locator('tr', { has: page.locator('td:has-text("Scene 2")') }); + await sceneRow.locator('button:has-text("Edit")').click(); + await waitForModal(page, 'Edit Scene'); + await page.fill('.modal.show input[type="text"]', 'Scene 2 (Edited)'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("Scene 2 (Edited)")').first()).toBeVisible(); +}); + +test('deletes Scene 2', async () => { + const sceneRow = page.locator('tr', { has: page.locator('td:has-text("Scene 2 (Edited)")') }); + await sceneRow.locator('button:has-text("Delete")').click(); + await confirmDialog(page); + await expect(page.locator('td:has-text("Scene 2 (Edited)")').first()).not.toBeVisible({ + timeout: 5_000, + }); +}); diff --git a/client-v3/e2e/tests/06-show-config-characters.spec.ts b/client-v3/e2e/tests/06-show-config-characters.spec.ts new file mode 100644 index 000000000..a68aacbc2 --- /dev/null +++ b/client-v3/e2e/tests/06-show-config-characters.spec.ts @@ -0,0 +1,142 @@ +/** + * Show Config — Characters tab: character CRUD, character groups, cast CRUD. + */ +import { test, expect, type BrowserContext, type Page } from '@playwright/test'; +import { + UI_BASE, + loginAsAdmin, + waitForAppReady, + waitForModal, + confirmModal, + waitForModalClosed, + confirmDialog, +} from '../helpers.js'; + +test.describe.configure({ mode: 'serial' }); + +let ctx: BrowserContext; +let page: Page; + +test.beforeAll(async ({ browser }) => { + ctx = await browser.newContext(); + page = await ctx.newPage(); + await loginAsAdmin(page); + await page.goto(`${UI_BASE}/show-config/characters`); + await waitForAppReady(page); +}); + +test.afterAll(async () => { + await ctx.close(); +}); + +// ── Characters ──────────────────────────────────────────────────────────── + +test('Characters tab shows New Character button', async () => { + await expect(page.getByRole('button', { name: 'New Character', exact: true })).toBeVisible(); +}); + +test('creates a character', async () => { + await page.getByRole('button', { name: 'New Character', exact: true }).click(); + await waitForModal(page, 'New Character'); + await page.fill('.modal.show #character-name-input, .modal.show input[type="text"]', 'Hamlet'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("Hamlet")').first()).toBeVisible(); +}); + +test('creates a second character with description', async () => { + await page.getByRole('button', { name: 'New Character', exact: true }).click(); + await waitForModal(page, 'New Character'); + const inputs = page.locator('.modal.show input[type="text"]'); + await inputs.first().fill('Ophelia'); + // Description may be a second text input or textarea + const descInput = page.locator('.modal.show textarea, .modal.show input[type="text"]').nth(1); + if ((await descInput.count()) > 0) { + await descInput.fill("Hamlet's love interest"); + } + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("Ophelia")').first()).toBeVisible(); +}); + +test('edits a character', async () => { + const row = page.locator('tr', { has: page.locator('td:has-text("Ophelia")') }); + await row.locator('button:has-text("Edit")').click(); + await waitForModal(page, 'Edit Character'); + const nameInput = page.locator('.modal.show input[type="text"]').first(); + await nameInput.fill('Ophelia (Edited)'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("Ophelia (Edited)")').first()).toBeVisible(); +}); + +test('deletes a character', async () => { + const row = page.locator('tr', { has: page.locator('td:has-text("Ophelia (Edited)")') }); + await row.locator('button:has-text("Delete")').click(); + await confirmDialog(page); + await expect(page.locator('td:has-text("Ophelia (Edited)")').first()).not.toBeVisible({ + timeout: 5_000, + }); +}); + +// ── Character Groups ────────────────────────────────────────────────────── + +test('switches to Character Groups sub-tab', async () => { + await page.click('.nav-link:has-text("Character Groups"), button[role="tab"]:has-text("Groups")'); + await expect( + page.locator('button:has-text("New Character Group"), button:has-text("New Group")') + ).toBeVisible(); +}); + +test('creates a character group', async () => { + await page.click('button:has-text("New Character Group"), button:has-text("New Group")'); + await waitForModal(page, /Character Group/); + await page.fill('.modal.show input[type="text"]', 'Royalty'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("Royalty")')).toBeVisible(); +}); + +test('deletes the character group', async () => { + const row = page.locator('tr', { has: page.locator('td:has-text("Royalty")') }); + await row.locator('button:has-text("Delete")').click(); + await confirmDialog(page); + await expect(page.locator('td:has-text("Royalty")')).not.toBeVisible({ timeout: 5_000 }); +}); + +// ── Cast ───────────────────────────────────────────────────────────────── + +test('navigates to Cast page', async () => { + await page.goto(`${UI_BASE}/show-config/cast`); + await waitForAppReady(page); + await expect(page.locator('button:has-text("New Cast Member")')).toBeVisible(); +}); + +test('creates a cast member', async () => { + await page.click('button:has-text("New Cast Member")'); + await waitForModal(page, /Cast Member/); + const textInputs = page.locator('.modal.show input[type="text"]'); + await textInputs.nth(0).fill('Jane'); + await textInputs.nth(1).fill('Doe'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("Jane")').first()).toBeVisible(); +}); + +test('edits the cast member', async () => { + const row = page.locator('tr', { has: page.locator('td:has-text("Jane")') }); + await row.locator('button:has-text("Edit")').click(); + await waitForModal(page, /Cast Member/); + const textInputs = page.locator('.modal.show input[type="text"]'); + await textInputs.nth(0).fill('Janet'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("Janet")').first()).toBeVisible(); +}); + +test('deletes the cast member', async () => { + const row = page.locator('tr', { has: page.locator('td:has-text("Janet")') }); + await row.locator('button:has-text("Delete")').click(); + await confirmDialog(page); + await expect(page.locator('td:has-text("Janet")').first()).not.toBeVisible({ timeout: 5_000 }); +}); diff --git a/client-v3/e2e/tests/07-show-config-stage.spec.ts b/client-v3/e2e/tests/07-show-config-stage.spec.ts new file mode 100644 index 000000000..00f67d72f --- /dev/null +++ b/client-v3/e2e/tests/07-show-config-stage.spec.ts @@ -0,0 +1,178 @@ +/** + * Show Config — Stage tab: prop types, props, scenery types, scenery, crew types, crew. + * Also covers the Stage Manager scene-navigation and allocation panel. + */ +import { test, expect, type BrowserContext, type Page } from '@playwright/test'; +import { + UI_BASE, + loginAsAdmin, + waitForAppReady, + waitForModal, + confirmModal, + waitForModalClosed, + confirmDialog, +} from '../helpers.js'; + +test.describe.configure({ mode: 'serial' }); + +let ctx: BrowserContext; +let page: Page; + +test.beforeAll(async ({ browser }) => { + ctx = await browser.newContext(); + page = await ctx.newPage(); + await loginAsAdmin(page); + await page.goto(`${UI_BASE}/show-config/stage`); + await waitForAppReady(page); +}); + +test.afterAll(async () => { + await ctx.close(); +}); + +// ── Props ────────────────────────────────────────────────────────────────── + +test('Props tab is visible', async () => { + await page.click('.nav-link:has-text("Props"), button[role="tab"]:has-text("Props")'); + await expect( + page.locator('button:has-text("New Prop Type"), button:has-text("New Type")') + ).toBeVisible(); +}); + +test('creates a prop type', async () => { + await page.click('button:has-text("New Prop Type")'); + await waitForModal(page, 'Add New Prop Type'); + await page.fill('#new-ptype-name', 'Furniture'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("Furniture")')).toBeVisible(); +}); + +test('creates a prop', async () => { + await page.click('button:has-text("New Props Item")'); + await waitForModal(page, 'Add New Prop'); + // Select the prop type we just created + await page.selectOption('.modal.show #new-prop-type-sel', { label: 'Furniture' }); + await page.fill('.modal.show #new-prop-name', 'Chair'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("Chair")')).toBeVisible(); +}); + +// ── Scenery ─────────────────────────────────────────────────────────────── + +test('switches to Scenery tab', async () => { + await page.click('.nav-link:has-text("Scenery"), button[role="tab"]:has-text("Scenery")'); + await expect( + page.locator('button:has-text("New Scenery Type"), button:has-text("New Type")') + ).toBeVisible(); +}); + +test('creates a scenery type', async () => { + await page.click('button:has-text("New Scenery Type")'); + await waitForModal(page, 'Add New Scenery Type'); + await page.fill('#new-stype-name', 'Backdrop'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("Backdrop")')).toBeVisible(); +}); + +test('creates a scenery item', async () => { + await page.click('button:has-text("New Scenery Item")'); + await waitForModal(page, 'Add New Scenery'); + // Select the scenery type we just created + await page.selectOption('#new-scenery-type-sel', { label: 'Backdrop' }); + await page.fill('#new-scenery-name', 'Castle Wall'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("Castle Wall")')).toBeVisible(); +}); + +test('deletes the scenery item', async () => { + const row = page.locator('tr', { has: page.locator('td:has-text("Castle Wall")') }); + await row.locator('button:has-text("Delete")').click(); + await confirmDialog(page); + await expect(page.locator('td:has-text("Castle Wall")')).not.toBeVisible({ timeout: 5_000 }); +}); + +test('deletes the scenery type', async () => { + const row = page.locator('tr', { has: page.locator('td:has-text("Backdrop")') }); + await row.locator('button:has-text("Delete")').click(); + await confirmDialog(page); + await expect(page.locator('td:has-text("Backdrop")')).not.toBeVisible({ timeout: 5_000 }); +}); + +// ── Crew ────────────────────────────────────────────────────────────────── + +test('switches to Crew tab', async () => { + await page.click('.nav-link:has-text("Crew"), button[role="tab"]:has-text("Crew")'); + await expect(page.locator('button:has-text("New Crew Member")')).toBeVisible(); +}); + +test('creates a crew member', async () => { + await page.click('button:has-text("New Crew Member")'); + await waitForModal(page, 'Add Crew Member'); + await page.fill('#new-first-name', 'John'); + await page.fill('#new-last-name', 'Smith'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("John")')).toBeVisible(); +}); + +test('deletes the crew member', async () => { + const row = page.locator('tr', { has: page.locator('td:has-text("John")') }); + await row.locator('button:has-text("Delete")').click(); + await confirmDialog(page); + await expect(page.locator('td:has-text("John")')).not.toBeVisible({ timeout: 5_000 }); +}); + +// ── Stage Manager ───────────────────────────────────────────────────────── + +test('switches to Stage Manager tab and shows scene navigation', async () => { + await page.click( + '.nav-link:has-text("Stage Manager"), button[role="tab"]:has-text("Stage Manager")' + ); + // Shows Prev/Next scene buttons + await expect(page.locator('button:has-text("Next")').first()).toBeVisible(); +}); + +test('adds a prop allocation to the current scene', async () => { + await page.locator('button.btn-success:has-text("Add")').click(); + await page.click('.dropdown-item:has-text("Prop")'); + await waitForModal(page, 'Add Prop to Scene'); + await page.selectOption('#add-prop-select', { label: 'Chair' }); + await confirmModal(page); + await waitForModalClosed(page); + // Scope to active tab — Props tab panel is also mounted in DOM and contains a Chair cell + await expect(page.locator('.tab-pane.active td:has-text("Chair")')).toBeVisible({ + timeout: 5_000, + }); +}); + +test('deletes the prop allocation', async () => { + const propRow = page.locator('.tab-pane.active tr', { + has: page.locator('td:has-text("Chair")'), + }); + await propRow.locator('button:has-text("Delete")').click(); + await confirmDialog(page); + await expect(page.locator('.tab-pane.active td:has-text("Chair")')).not.toBeVisible({ + timeout: 5_000, + }); +}); + +// ── Prop cleanup ────────────────────────────────────────────────────────── + +test('deletes the prop', async () => { + await page.click('.nav-link:has-text("Props"), button[role="tab"]:has-text("Props")'); + const row = page.locator('tr', { has: page.locator('td:has-text("Chair")') }); + await row.locator('button:has-text("Delete")').click(); + await confirmDialog(page); + await expect(page.locator('td:has-text("Chair")')).not.toBeVisible({ timeout: 5_000 }); +}); + +test('deletes the prop type', async () => { + const row = page.locator('tr', { has: page.locator('td:has-text("Furniture")') }); + await row.locator('button:has-text("Delete")').click(); + await confirmDialog(page); + await expect(page.locator('td:has-text("Furniture")')).not.toBeVisible({ timeout: 5_000 }); +}); diff --git a/client-v3/e2e/tests/08-show-config-cues.spec.ts b/client-v3/e2e/tests/08-show-config-cues.spec.ts new file mode 100644 index 000000000..39bc0e896 --- /dev/null +++ b/client-v3/e2e/tests/08-show-config-cues.spec.ts @@ -0,0 +1,120 @@ +/** + * Show Config — Cues tab: cue type CRUD, cue editor navigation, import cue types. + */ +import { test, expect, type BrowserContext, type Page } from '@playwright/test'; +import { + UI_BASE, + loginAsAdmin, + waitForAppReady, + waitForModal, + confirmModal, + waitForModalClosed, + confirmDialog, +} from '../helpers.js'; + +test.describe.configure({ mode: 'serial' }); + +let ctx: BrowserContext; +let page: Page; + +test.beforeAll(async ({ browser }) => { + ctx = await browser.newContext(); + page = await ctx.newPage(); + await loginAsAdmin(page); + await page.goto(`${UI_BASE}/show-config/cues`); + await waitForAppReady(page); +}); + +test.afterAll(async () => { + await ctx.close(); +}); + +// ── Cue Types ────────────────────────────────────────────────────────────── + +test('Cue Types tab shows New Cue Type button', async () => { + await expect(page.locator('button:has-text("New Cue Type")')).toBeVisible(); +}); + +test('opens New Cue Type modal', async () => { + await page.click('button:has-text("New Cue Type")'); + await waitForModal(page, /Cue Type/); +}); + +test('Save is disabled when prefix is empty', async () => { + await expect(page.locator('.modal.show .modal-footer button.btn-primary')).toBeDisabled(); +}); + +test('creates a cue type LX', async () => { + await page.fill('.modal.show input[type="text"]', 'LX'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("LX")').first()).toBeVisible(); +}); + +test('creates a cue type SQ', async () => { + await page.click('button:has-text("New Cue Type")'); + await waitForModal(page, /Cue Type/); + await page.fill('.modal.show input[type="text"]', 'SQ'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("SQ")').first()).toBeVisible(); +}); + +test('edits a cue type', async () => { + const row = page.locator('tr', { has: page.locator('td:has-text("SQ")') }); + await row.locator('button:has-text("Edit")').click(); + await waitForModal(page, /Edit.*Cue Type|Cue Type/); + const prefixInput = page.locator('.modal.show input[type="text"]').first(); + await prefixInput.fill('SFX'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("SFX")').first()).toBeVisible(); +}); + +test('deletes a cue type', async () => { + const row = page.locator('tr', { has: page.locator('td:has-text("SFX")') }); + await row.locator('button:has-text("Delete")').click(); + await confirmDialog(page); + await expect(page.locator('td:has-text("SFX")').first()).not.toBeVisible({ timeout: 5_000 }); +}); + +// ── Cue Editor ──────────────────────────────────────────────────────────── + +test('switches to Cue Configuration sub-tab', async () => { + await page.click( + '.nav-link:has-text("Cue Configuration"), button[role="tab"]:has-text("Cue Config")' + ); + // Cue editor loads the script view — shows page navigation + await expect(page.locator('button:has-text("Go to Page")')).toBeVisible({ timeout: 10_000 }); +}); + +test('cue editor navigation buttons are rendered', async () => { + // With no script content there is only one page; verify nav controls are present + await expect(page.locator('button:has-text("Next")').first()).toBeVisible(); + await expect( + page.locator('button:has-text("Prev"), button:has-text("Previous")').first() + ).toBeVisible(); +}); + +test('can open the Go to Page dialog in cue editor', async () => { + await page.click('button:has-text("Go to Page")'); + await waitForModal(page, 'Go to Page'); + // Cancel the dialog — no script pages exist yet at this point in the test suite + await page.click( + '.modal.show .modal-footer button.btn-secondary, .modal.show button:has-text("Cancel")' + ); + await waitForModalClosed(page); +}); + +// ── Cue Counts ──────────────────────────────────────────────────────────── + +test('switches to Cue Counts sub-tab', async () => { + await page.click('.nav-link:has-text("Cue Counts"), button[role="tab"]:has-text("Counts")'); + // Stats view loads — scope to active tab panel to avoid matching hidden panels + await expect( + page + .locator('.tab-pane.active .center-spinner') + .or(page.locator('.tab-pane.active table')) + .or(page.locator('.tab-pane.active b')) + ).toBeVisible({ timeout: 5_000 }); +}); diff --git a/client-v3/e2e/tests/09-show-config-mics.spec.ts b/client-v3/e2e/tests/09-show-config-mics.spec.ts new file mode 100644 index 000000000..b737e0017 --- /dev/null +++ b/client-v3/e2e/tests/09-show-config-mics.spec.ts @@ -0,0 +1,160 @@ +/** + * Show Config — Mics tab: microphone CRUD, allocations view, timeline view. + */ +import { test, expect, type BrowserContext, type Page } from '@playwright/test'; +import { + UI_BASE, + loginAsAdmin, + waitForAppReady, + waitForModal, + confirmModal, + waitForModalClosed, + confirmDialog, +} from '../helpers.js'; + +test.describe.configure({ mode: 'serial' }); + +let ctx: BrowserContext; +let page: Page; + +test.beforeAll(async ({ browser }) => { + ctx = await browser.newContext(); + page = await ctx.newPage(); + await loginAsAdmin(page); + await page.goto(`${UI_BASE}/show-config/mics`); + await waitForAppReady(page); +}); + +test.afterAll(async () => { + await ctx.close(); +}); + +// ── Mics ────────────────────────────────────────────────────────────────── + +test('Mics tab shows New Microphone button', async () => { + await expect(page.locator('button:has-text("New Microphone")')).toBeVisible(); +}); + +test('creates a microphone', async () => { + await page.click('button:has-text("New Microphone")'); + await waitForModal(page, /Microphone/); + await page.fill('.modal.show input[type="text"]', 'Mic 1'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("Mic 1")')).toBeVisible(); +}); + +test('creates a second microphone', async () => { + await page.click('button:has-text("New Microphone")'); + await waitForModal(page, /Microphone/); + await page.fill('.modal.show input[type="text"]', 'Mic 2'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("Mic 2")')).toBeVisible(); +}); + +test('edits a microphone', async () => { + const row = page.locator('tr', { has: page.locator('td:has-text("Mic 2")') }); + await row.locator('button:has-text("Edit")').click(); + await waitForModal(page, /Edit.*Mic|Microphone/); + const input = page.locator('.modal.show input[type="text"]').first(); + await input.fill('Mic 2 (Edited)'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("Mic 2 (Edited)")')).toBeVisible(); +}); + +test('deletes a microphone', async () => { + const row = page.locator('tr', { has: page.locator('td:has-text("Mic 2 (Edited)")') }); + await row.locator('button:has-text("Delete")').click(); + await confirmDialog(page); + await expect(page.locator('td:has-text("Mic 2 (Edited)")')).not.toBeVisible({ timeout: 5_000 }); +}); + +// ── Allocations ─────────────────────────────────────────────────────────── + +test('switches to Allocations sub-tab', async () => { + await page.click('.nav-link:has-text("Allocations"), button[role="tab"]:has-text("Allocations")'); + // Allocations table container is always visible on this sub-tab + await expect(page.locator('#allocations-table')).toBeVisible({ timeout: 10_000 }); +}); + +test('switches to edit mode and back', async () => { + // Component may initialise in edit mode — ensure we start in view mode. + // Use :visible to avoid strict-mode violation from Cancel buttons inside hidden BVN modals. + if ((await page.locator('button:has-text("View"):visible').count()) > 0) { + await page.locator('button:has-text("View"):visible').click(); + } + // Scope to active panel — the Mics tab (not active) also has row-level Edit buttons in the DOM + const editBtn = page.locator('.tab-pane.active button:has-text("Edit")'); + await expect(editBtn).toBeVisible({ timeout: 5_000 }); + await editBtn.click(); + await expect(page.locator('.tab-pane.active button:has-text("Save")')).toBeVisible(); + await page + .locator('.tab-pane.active') + .locator('button:has-text("View"), button:has-text("Cancel")') + .click(); +}); + +test('saves a mic allocation', async () => { + // Re-navigate so MicAllocations re-mounts with Mic 1 already in the store. + // internalState is initialised in onMounted; the first navigation (beforeAll) ran before any + // mics existed, so the component would have set up an empty internalState and toggleAllocation + // would silently no-op. A fresh navigation after mic creation fixes this. + await page.goto(`${UI_BASE}/show-config/mics`); + await waitForAppReady(page); + await page.click('.nav-link:has-text("Allocations"), button[role="tab"]:has-text("Allocations")'); + await expect(page.locator('#allocations-table')).toBeVisible({ timeout: 10_000 }); + + // MicAllocations starts with editMode = true; #mic-input is immediately visible + await expect(page.locator('#mic-input')).toBeVisible({ timeout: 5_000 }); + await page.selectOption('#mic-input', { label: 'Mic 1' }); + + await page + .locator('#allocations-table tbody tr') + .first() + .locator('td') + .nth(1) + .locator('button') + .click(); + await page.locator('.tab-pane.active button:has-text("Save")').click(); + + await page + .locator('.tab-pane.active') + .locator('button:has-text("View"), button:has-text("Cancel")') + .click(); + await expect(page.locator('.tab-pane.active button:has-text("Edit")')).toBeVisible({ + timeout: 5_000, + }); + + await expect(page.locator('#allocations-table .allocation-cell').first()).toBeVisible({ + timeout: 5_000, + }); +}); + +// ── Timeline ───────────────────────────────────────────────────────────── + +test('switches to Timeline sub-tab', async () => { + await page.click('.nav-link:has-text("Timeline"), button[role="tab"]:has-text("Timeline")'); + await expect(page.locator('button:has-text("By Microphone")')).toBeVisible({ timeout: 10_000 }); +}); + +// ── Density ────────────────────────────────────────────────────────────── + +test('switches to Density sub-tab', async () => { + await page.click('.nav-link:has-text("Density"), button[role="tab"]:has-text("Density")'); + await expect(page.locator('h5:has-text("Scene Microphone Density")')).toBeVisible({ + timeout: 10_000, + }); +}); + +// ── Availability ────────────────────────────────────────────────────────── + +test('switches to Availability sub-tab', async () => { + await page.click( + '.nav-link:has-text("Availability"), button[role="tab"]:has-text("Availability")' + ); + await expect(page.locator('h5:has-text("Microphone Resource Availability")')).toBeVisible({ + timeout: 10_000, + }); +}); diff --git a/client-v3/e2e/tests/10-show-config-script.spec.ts b/client-v3/e2e/tests/10-show-config-script.spec.ts new file mode 100644 index 000000000..4ade7b50e --- /dev/null +++ b/client-v3/e2e/tests/10-show-config-script.spec.ts @@ -0,0 +1,201 @@ +/** + * Show Config — Script tab: page navigation, edit mode, adding lines. + * Also covers Stage Direction Styles sub-tab: create and delete a style. + */ +import { test, expect, type BrowserContext, type Page } from '@playwright/test'; +import { + UI_BASE, + loginAsAdmin, + waitForAppReady, + waitForModal, + confirmModal, + waitForModalClosed, + confirmDialog, +} from '../helpers.js'; + +test.describe.configure({ mode: 'serial' }); + +let ctx: BrowserContext; +let page: Page; + +test.beforeAll(async ({ browser }) => { + ctx = await browser.newContext(); + page = await ctx.newPage(); + await loginAsAdmin(page); + await page.goto(`${UI_BASE}/show-config/script`); + await waitForAppReady(page); +}); + +test.afterAll(async () => { + await ctx.close(); +}); + +// ── Script Editor ───────────────────────────────────────────────────────── + +test('script editor shows page navigation controls', async () => { + await expect(page.locator('button:has-text("Go to Page")')).toBeVisible({ timeout: 15_000 }); +}); + +test('Edit button is visible for admin', async () => { + await expect(page.locator('button:has-text("Edit")')).toBeVisible(); +}); + +test('requests edit mode', async () => { + await page.click('button:has-text("Edit")'); + // After WS roundtrip, "Stop Editing" appears instead of "Edit" + await expect(page.locator('button:has-text("Stop Editing")')).toBeVisible({ timeout: 10_000 }); +}); + +test('adds a dialogue line', async () => { + await page.click('button:has-text("Add Dialogue")'); + // A ScriptLineEditor row appears with Done/Delete buttons + await expect(page.locator('button:has-text("Done")').first()).toBeVisible({ timeout: 5_000 }); +}); + +test('adds a stage direction via dropdown', async () => { + // Split dropdown: click the arrow toggle to open the menu + await page.click('button.dropdown-toggle-split'); + await page.click('.dropdown-item:has-text("Add Stage Direction")'); + // At least two Done buttons are now visible (one per open editor) + await expect(page.locator('button:has-text("Done")').first()).toBeVisible({ timeout: 5_000 }); +}); + +test('stops editing without saving', async () => { + await page.click('button:has-text("Stop Editing")'); + // Script has unsaved changes — confirm dialog appears + await confirmDialog(page); + // Edit button reappears — use exact match to avoid matching "Stop Editing" / "Bulk Edit" + await expect(page.getByRole('button', { name: 'Edit', exact: true })).toBeVisible({ + timeout: 10_000, + }); +}); + +test('can navigate to page via Go to Page dialog', async () => { + await page.click('button:has-text("Go to Page")'); + await waitForModal(page, 'Go to Page'); + await page.fill('.modal.show input[type="number"]', '1'); + await confirmModal(page); + await waitForModalClosed(page); +}); + +// ── Save script ──────────────────────────────────────────────────────────── + +test('saves a dialogue line to the script', async () => { + await page.click('button:has-text("Edit")'); + await expect(page.locator('button:has-text("Stop Editing")')).toBeVisible({ timeout: 10_000 }); + + await page.click('button:has-text("Add Dialogue")'); + await expect(page.locator('button:has-text("Done")').first()).toBeVisible({ timeout: 5_000 }); + + const lineEditor = page + .locator('div.row') + .filter({ has: page.locator('button:has-text("Done")') }) + .first(); + await lineEditor.locator('select').nth(0).selectOption({ label: 'Act 1' }); + await lineEditor.locator('select').nth(1).selectOption({ label: 'Scene 1' }); + + await page.locator('select').filter({ hasText: 'Hamlet' }).selectOption({ label: 'Hamlet' }); + await page.locator('input[type="text"]:visible').last().fill('To be or not to be'); + + await page.locator('button:has-text("Done")').first().click(); + // ScriptEditor auto-adds a blank dialogue line after Done — delete it before asserting + await page.locator('button.btn-danger:has-text("Delete")').first().click(); + await expect(page.locator('button:has-text("Done")')).not.toBeVisible({ timeout: 5_000 }); + + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await waitForModal(page, 'Saving Script'); + // The "Saving Script" modal is ok-only and stays open until the user clicks OK. + // Wait for save to finish (footer OK button appears when savingInProgress = false). + await expect(page.locator('.modal.show').getByText('Finished saving script.')).toBeVisible({ + timeout: 15_000, + }); + await confirmModal(page); + await waitForModalClosed(page); + + await expect( + page.locator('.viewable-line').filter({ hasText: 'To be or not to be' }) + ).toBeVisible({ timeout: 10_000 }); +}); + +// ── Stage Direction Styles ──────────────────────────────────────────────── + +test('switches to Stage Direction Styles sub-tab', async () => { + await page.click('.nav-link:has-text("Stage Direction Styles")'); + await expect(page.locator('button:has-text("New Style")')).toBeVisible({ timeout: 5_000 }); +}); + +test('creates a stage direction style', async () => { + await page.click('button:has-text("New Style")'); + await waitForModal(page, 'Add New Style'); + await page.fill('.modal.show input[type="text"]', 'Action'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("Action")')).toBeVisible(); +}); + +test('deletes the stage direction style', async () => { + const row = page.locator('tr', { has: page.locator('td:has-text("Action")') }); + await row.locator('button:has-text("Delete")').click(); + await confirmDialog(page); + await expect(page.locator('td:has-text("Action")')).not.toBeVisible({ timeout: 5_000 }); +}); + +// ── Cue editor — add/edit/delete ────────────────────────────────────────── + +test('navigates to cue editor with saved script content', async () => { + await page.goto(`${UI_BASE}/show-config/cues`); + await waitForAppReady(page); + await page.click( + '.nav-link:has-text("Cue Configuration"), button[role="tab"]:has-text("Cue Config")' + ); + await expect(page.locator('button:has-text("Go to Page")')).toBeVisible({ timeout: 10_000 }); + // .first() avoids strict-mode violation: BVN keeps the Add New Cue modal in the DOM + // (v-show), and its preview also contains a .viewable-line with this text. + await expect( + page.locator('.viewable-line').filter({ hasText: 'To be or not to be' }).first() + ).toBeVisible({ timeout: 10_000 }); +}); + +test('adds a cue to the script line', async () => { + await page.locator('.add-cue-btn').first().click(); + await waitForModal(page, 'Add New Cue'); + // Scope to the visible modal's select to avoid matching the hidden "Add Cue Type" modal + // dialog which BVN assigns id="new-cue-type" via its auto-ID scheme. + await page.locator('.modal.show select#new-cue-type').selectOption({ index: 1 }); + await page.fill('#new-cue-ident', '001'); + await confirmModal(page); + await waitForModalClosed(page); + // Wait for the actual cue button (not the add-cue-btn which shares the cue-button class) + // to confirm the WS update delivered the new cue to the store before proceeding. + await expect(page.locator('.cue-button:not(.add-cue-btn)').first()).toBeVisible({ + timeout: 5_000, + }); +}); + +test('edits the cue identifier', async () => { + // Use :not(.add-cue-btn) to target the real cue button, not the add button + await page.locator('.cue-button:not(.add-cue-btn)').first().click(); + await waitForModal(page, 'Edit Cue'); + await page.fill('#edit-cue-ident', '002'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('.cue-button:not(.add-cue-btn)').first()).toBeVisible({ + timeout: 5_000, + }); +}); + +test('deletes the cue', async () => { + await page.locator('.cue-button:not(.add-cue-btn)').first().click(); + await waitForModal(page, 'Edit Cue'); + // Clicking Delete in Edit Cue opens a stacked ConfirmDialog (BVN modal via useConfirm), + // not a browser dialog — scope to it by title to avoid clicking the wrong modal's button. + await page.locator('.modal.show button:has-text("Delete")').click(); + await waitForModal(page, 'Delete Cue'); + await page + .locator('.modal.show') + .filter({ has: page.locator('.modal-title:has-text("Delete Cue")') }) + .locator('.modal-footer button.btn-danger') + .click(); + // After deletion only the add-cue-btn remains; no actual cue buttons should exist + await expect(page.locator('.cue-button:not(.add-cue-btn)')).not.toBeVisible({ timeout: 5_000 }); +}); diff --git a/client-v3/e2e/tests/11-show-config-revisions.spec.ts b/client-v3/e2e/tests/11-show-config-revisions.spec.ts new file mode 100644 index 000000000..9c9cd8c9c --- /dev/null +++ b/client-v3/e2e/tests/11-show-config-revisions.spec.ts @@ -0,0 +1,114 @@ +/** + * Show Config — Script Revisions tab: create revision, view revision graph, compiled scripts. + */ +import { test, expect, type BrowserContext, type Page } from '@playwright/test'; +import { + UI_BASE, + loginAsAdmin, + waitForAppReady, + waitForModal, + confirmModal, + waitForModalClosed, + confirmDialog, +} from '../helpers.js'; + +test.describe.configure({ mode: 'serial' }); + +let ctx: BrowserContext; +let page: Page; + +test.beforeAll(async ({ browser }) => { + ctx = await browser.newContext(); + page = await ctx.newPage(); + await loginAsAdmin(page); + await page.goto(`${UI_BASE}/show-config/script-revisions`); + await waitForAppReady(page); +}); + +test.afterAll(async () => { + await ctx.close(); +}); + +// ── Revisions ───────────────────────────────────────────────────────────── + +test('Revisions tab shows the revision table', async () => { + await expect(page.locator('button:has-text("New Revision")')).toBeVisible({ timeout: 10_000 }); + // Revision 1 exists by default + await expect(page.locator('td:has-text("1")').first()).toBeVisible(); +}); + +test('creates a new revision', async () => { + await page.click('button:has-text("New Revision")'); + await waitForModal(page, 'Add New Revision'); + await page.fill('.modal.show input[type="text"]', 'Revision for e2e test'); + await confirmModal(page); + await waitForModalClosed(page, 10_000); + await expect(page.locator('td:has-text("Revision for e2e test")')).toBeVisible(); +}); + +test('revision graph renders', async () => { + // The graph card starts collapsed by default — expand it if needed + const revisionGraph = page.locator('.revision-graph'); + if (!(await revisionGraph.isVisible())) { + await page + .locator('.card-header') + .filter({ hasText: 'Revision Branch Graph' }) + .locator('button') + .click(); + } + await expect(revisionGraph).toBeVisible({ timeout: 5_000 }); +}); + +test('loads revision 1', async () => { + const rev1Row = page + .locator('tr') + .filter({ has: page.locator('td:has-text("1")') }) + .first(); + await expect(rev1Row).toBeVisible({ timeout: 5_000 }); + const loadBtn = rev1Row.locator('button:has-text("Load")'); + if (await loadBtn.isEnabled()) { + await loadBtn.click(); + await confirmDialog(page); + // Confirm the UI has settled after the load + await expect(page.locator('button:has-text("New Revision")')).toBeVisible({ timeout: 10_000 }); + } + // If disabled, revision 1 is already the loaded revision — no action needed +}); + +test('deletes the test revision', async () => { + const testRevRow = page.locator('tr', { + has: page.locator('td:has-text("Revision for e2e test")'), + }); + await testRevRow.locator('button:has-text("Delete")').click(); + await confirmDialog(page); + await expect(page.locator('td:has-text("Revision for e2e test")')).not.toBeVisible({ + timeout: 5_000, + }); +}); + +// ── Compiled Scripts ────────────────────────────────────────────────────── + +test('switches to Compiled Scripts sub-tab', async () => { + await page.click( + '.nav-link:has-text("Compiled Scripts"), button[role="tab"]:has-text("Compiled")' + ); + // Two tables rendered simultaneously (Revisions + Compiled Scripts tabs) — scope to active panel + await expect(page.locator('.tab-pane.active table')).toBeVisible({ timeout: 10_000 }); +}); + +test('can trigger script compilation', async () => { + // "Generate" only appears when no compiled script exists yet (auto-compile on save may have already run). + // If it exists and is enabled, click it and wait for it to finish; otherwise the row already has a + // compiled script (showing "Delete" instead) — both states are valid. + const generateBtn = page.locator('button:has-text("Generate")').first(); + if ((await generateBtn.count()) > 0 && (await generateBtn.isEnabled())) { + await generateBtn.click(); + await expect(generateBtn.or(page.locator('button:has-text("Delete")').first())).toBeVisible({ + timeout: 15_000, + }); + } + // Either way, at least one compiled-scripts table row must exist + await expect(page.locator('.tab-pane.active table tbody tr').first()).toBeVisible({ + timeout: 10_000, + }); +}); diff --git a/client-v3/e2e/tests/12-show-config-sessions.spec.ts b/client-v3/e2e/tests/12-show-config-sessions.spec.ts new file mode 100644 index 000000000..3008bb69c --- /dev/null +++ b/client-v3/e2e/tests/12-show-config-sessions.spec.ts @@ -0,0 +1,72 @@ +/** + * Show Config — Sessions tab: session tags CRUD. + * Note: actual session start/stop is tested in 13-live-show.spec.ts. + */ +import { test, expect, type BrowserContext, type Page } from '@playwright/test'; +import { + UI_BASE, + loginAsAdmin, + waitForAppReady, + waitForModal, + confirmModal, + waitForModalClosed, + confirmDialog, +} from '../helpers.js'; + +test.describe.configure({ mode: 'serial' }); + +let ctx: BrowserContext; +let page: Page; + +test.beforeAll(async ({ browser }) => { + ctx = await browser.newContext(); + page = await ctx.newPage(); + await loginAsAdmin(page); + await page.goto(`${UI_BASE}/show-config/sessions`); + await waitForAppReady(page); +}); + +test.afterAll(async () => { + await ctx.close(); +}); + +// ── Sessions list ───────────────────────────────────────────────────────── + +test('Sessions tab renders', async () => { + await expect(page.locator('table').first()).toBeVisible({ timeout: 10_000 }); +}); + +// ── Tags ───────────────────────────────────────────────────────────────── + +test('switches to Tags sub-tab', async () => { + await page.click('.nav-link:has-text("Tags"), button[role="tab"]:has-text("Tags")'); + await expect(page.locator('button:has-text("New Tag")')).toBeVisible(); +}); + +test('creates a session tag', async () => { + await page.click('button:has-text("New Tag")'); + await waitForModal(page, 'Add Session Tag'); + await page.fill('#new-tag-name', 'Tech Run'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("Tech Run")')).toBeVisible(); +}); + +test('edits a session tag', async () => { + const row = page.locator('tr', { has: page.locator('td:has-text("Tech Run")') }); + await row.locator('button:has-text("Edit")').click(); + await waitForModal(page, 'Edit Session Tag'); + await page.fill('#edit-tag-name', 'Tech Run (Edited)'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("Tech Run (Edited)")')).toBeVisible(); +}); + +test('deletes a session tag', async () => { + const row = page.locator('tr', { has: page.locator('td:has-text("Tech Run (Edited)")') }); + await row.locator('button:has-text("Delete")').click(); + await confirmDialog(page); + await expect(page.locator('td:has-text("Tech Run (Edited)")')).not.toBeVisible({ + timeout: 5_000, + }); +}); diff --git a/client-v3/e2e/tests/13-live-show.spec.ts b/client-v3/e2e/tests/13-live-show.spec.ts new file mode 100644 index 000000000..bdd1299fa --- /dev/null +++ b/client-v3/e2e/tests/13-live-show.spec.ts @@ -0,0 +1,161 @@ +/** + * Live show: start session, leader navigates, follower auto-scrolls to match. + * + * Uses two browser contexts to simulate two concurrent clients: + * leaderPage — the first client to connect (elected leader) + * followerPage — the second client (becomes follower) + * + * The WS leader status is determined server-side by which client connects first. + */ +import { test, expect, type BrowserContext, type Page } from '@playwright/test'; +import { + UI_BASE, + ADMIN_USERNAME, + ADMIN_PASSWORD, + waitForAppReady, + loginAsAdmin, + confirmModal, + waitForModalClosed, + confirmDialog, +} from '../helpers.js'; + +test.describe.configure({ mode: 'serial' }); + +let leaderCtx: BrowserContext; +let followerCtx: BrowserContext; +let leaderPage: Page; +let followerPage: Page; + +test.beforeAll(async ({ browser }) => { + leaderCtx = await browser.newContext(); + leaderPage = await leaderCtx.newPage(); + await loginAsAdmin(leaderPage); + + followerCtx = await browser.newContext(); + followerPage = await followerCtx.newPage(); + await loginAsAdmin(followerPage); +}); + +test.afterAll(async () => { + await leaderCtx.close(); + await followerCtx.close(); +}); + +// ── Session lifecycle ────────────────────────────────────────────────────── + +test('Live Config dropdown is visible for admin when a show is loaded', async () => { + await expect(leaderPage.locator('text=Live Config')).toBeVisible(); +}); + +test('Start Session button is enabled before a session exists', async () => { + await leaderPage.locator('text=Live Config').click(); + await expect(leaderPage.locator('button:has-text("Start Session")')).toBeEnabled(); +}); + +test('starts a show session via the navbar', async () => { + await leaderPage.click('button:has-text("Start Session")'); + await confirmDialog(leaderPage); + // Session started — Live nav link becomes enabled + await expect(leaderPage.locator('a:has-text("Live")')).not.toHaveClass(/disabled/, { + timeout: 10_000, + }); +}); + +// ── Leader navigates to /live ───────────────────────────────────────────── + +test('leader can navigate to /live', async () => { + await leaderPage.click('a:has-text("Live")'); + await leaderPage.waitForURL(`${UI_BASE}/live`, { timeout: 10_000 }); + await waitForAppReady(leaderPage); + // Script container rendered + await expect(leaderPage.locator('#script-container')).toBeVisible({ timeout: 15_000 }); +}); + +test('leader is NOT in following mode', async () => { + // data-following is false on the leader's script container + const container = leaderPage.locator('#script-container'); + await expect(container).not.toHaveAttribute('data-following', 'true'); +}); + +// ── Follower connects ───────────────────────────────────────────────────── + +test('follower navigates to /live after leader', async () => { + // Leader is confirmed elected (not-following) before the follower connects + await expect(leaderPage.locator('#script-container')).not.toHaveAttribute( + 'data-following', + 'true', + { timeout: 10_000 } + ); + await followerPage.goto(`${UI_BASE}/live`); + await waitForAppReady(followerPage); + await expect(followerPage.locator('#script-container')).toBeVisible({ timeout: 15_000 }); +}); + +test('follower is in following mode', async () => { + // Follower's script container should have data-following="true" + const container = followerPage.locator('#script-container'); + await expect(container).toHaveAttribute('data-following', 'true', { timeout: 10_000 }); +}); + +// ── Leader navigates pages, follower follows ─────────────────────────────── + +test('session header shows current page number', async () => { + await expect(leaderPage.locator('b:has-text("Page")')).toBeVisible(); +}); + +test('follower receives leader page navigation via WebSocket scroll sync', async () => { + // Leader navigates using the navbar Jump To Page feature + await leaderPage.locator('text=Live Config').click(); + const jumpBtn = leaderPage.locator('a:has-text("Jump To Page"), button:has-text("Jump To Page")'); + await jumpBtn.click(); + await leaderPage.waitForSelector('.modal.show', { timeout: 5_000 }); + await leaderPage.fill('#page-input', '1'); + await confirmModal(leaderPage); + await waitForModalClosed(leaderPage); + + // Follower should still be in following mode (scroll sync keeps it following) + await expect(followerPage.locator('#script-container')).toHaveAttribute( + 'data-following', + 'true', + { timeout: 10_000 } + ); +}); + +// ── Enable/disable Stage Manager mode ───────────────────────────────────── + +test('can toggle Stage Manager mode', async () => { + await leaderPage.locator('text=Live Config').click(); + await leaderPage.click('button:has-text("Enable Stage Manager")'); + // Stage manager pane appears + await expect(leaderPage.locator('[class*="stage-manager"], [class*="stageManager"]')) + .toBeVisible({ timeout: 5_000 }) + .catch(() => { + // Not all themes render a distinct pane element; presence in UI is enough + }); + // Disable it again + await leaderPage.locator('text=Live Config').click(); + await leaderPage.click('button:has-text("Disable Stage Manager")'); +}); + +// ── Reload Clients ───────────────────────────────────────────────────────── + +test('Reload Clients triggers and all clients reload', async () => { + await leaderPage.locator('text=Live Config').click(); + await leaderPage.click('button:has-text("Reload Clients")'); + await confirmDialog(leaderPage); + // After reload, leader ends up back on the live page + await leaderPage.waitForURL(`${UI_BASE}/live`, { timeout: 15_000 }); + await waitForAppReady(leaderPage); +}); + +// ── Stop session ─────────────────────────────────────────────────────────── + +test('can stop the show session', async () => { + await leaderPage.locator('text=Live Config').click(); + await leaderPage.click('button:has-text("Stop Session")'); + await confirmDialog(leaderPage); + // After stop, Live nav link becomes disabled + await expect(leaderPage.locator('a:has-text("Live")')).toHaveClass(/disabled/, { + timeout: 10_000, + }); +}); diff --git a/client-v3/e2e/tests/14-user-settings.spec.ts b/client-v3/e2e/tests/14-user-settings.spec.ts new file mode 100644 index 000000000..6b51f2a45 --- /dev/null +++ b/client-v3/e2e/tests/14-user-settings.spec.ts @@ -0,0 +1,147 @@ +/** + * User Settings (/me): personal settings, stage direction style overrides, + * password change, API token management. + */ +import { test, expect, type BrowserContext, type Page } from '@playwright/test'; +import { + UI_BASE, + ADMIN_PASSWORD, + loginAsAdmin, + waitForAppReady, + confirmDialog, +} from '../helpers.js'; + +test.describe.configure({ mode: 'serial' }); + +let ctx: BrowserContext; +let page: Page; + +const NEW_PASSWORD = 'newpassword123'; + +test.beforeAll(async ({ browser }) => { + ctx = await browser.newContext(); + page = await ctx.newPage(); + await loginAsAdmin(page); + await page.goto(`${UI_BASE}/me`); + await waitForAppReady(page); +}); + +test.afterAll(async () => { + await ctx.close(); +}); + +// ── About ───────────────────────────────────────────────────────────────── + +test('Settings page renders the About tab', async () => { + await expect( + page.locator('.nav-link:has-text("About"), button[role="tab"]:has-text("About")') + ).toBeVisible(); +}); + +// ── Settings ────────────────────────────────────────────────────────────── + +test('switches to Settings tab', async () => { + await page.click('.nav-link:has-text("Settings"), button[role="tab"]:has-text("Settings")'); + await expect(page.locator('button:has-text("Submit")')).toBeVisible({ timeout: 5_000 }); +}); + +test('Submit button is disabled until a setting is changed', async () => { + await expect(page.locator('button:has-text("Submit")')).toBeDisabled(); +}); + +test('changing a toggle enables the Submit button', async () => { + const toggle = page.locator('input[type="checkbox"]').first(); + if ((await toggle.count()) > 0) { + await toggle.click(); + await expect(page.locator('button:has-text("Submit")')).toBeEnabled(); + // Reset back + await page.click('button:has-text("Reset")'); + } +}); + +// ── Stage Direction Styles ──────────────────────────────────────────────── + +test('switches to Stage Direction Styles tab', async () => { + await page.click('.nav-link:has-text("Stage Direction")'); + // Use the component-specific table ID to avoid matching hidden tab-panel elements + await expect(page.locator('#stage-directions-table')).toBeVisible({ timeout: 5_000 }); +}); + +// ── Change Password ─────────────────────────────────────────────────────── + +test('switches to Change Password tab', async () => { + await page.click( + '.nav-link:has-text("Change Password"), button[role="tab"]:has-text("Password")' + ); + // Use the form input as the visibility anchor (avoids matching the nav tab button) + await expect(page.locator('#current-password-input')).toBeVisible(); +}); + +test('Change Password button is disabled when fields are empty', async () => { + await expect(page.locator('button[type="submit"]:has-text("Change Password")')).toBeDisabled(); +}); + +test('changes the admin password', async () => { + await page.fill('#current-password-input', ADMIN_PASSWORD); + await page.fill('#new-password-input', NEW_PASSWORD); + await page.fill('#confirm-password-input', NEW_PASSWORD); + await page.click('button[type="submit"]:has-text("Change Password")'); + // Form resets on success (current-password-input cleared) — toBeDisabled alone catches loading=true + await expect(page.locator('#current-password-input')).toHaveValue('', { timeout: 10_000 }); +}); + +test('can log in with the new password', async () => { + // Logout first + await page.locator('nav').getByText('admin').click(); + await page.click('button:has-text("Sign Out")'); + await expect(page.locator('a:has-text("Login")')).toBeVisible({ timeout: 5_000 }); + + // Login with new password + await page.goto(`${UI_BASE}/login`); + await page.fill('#username-input', 'admin'); + await page.fill('#password-input', NEW_PASSWORD); + await page.click('button:has-text("Login")'); + await page.waitForURL(`${UI_BASE}/`); + await waitForAppReady(page); +}); + +test('restores the original admin password', async () => { + await page.goto(`${UI_BASE}/me`); + await waitForAppReady(page); + await page.click( + '.nav-link:has-text("Change Password"), button[role="tab"]:has-text("Password")' + ); + await page.fill('#current-password-input', NEW_PASSWORD); + await page.fill('#new-password-input', ADMIN_PASSWORD); + await page.fill('#confirm-password-input', ADMIN_PASSWORD); + await page.click('button[type="submit"]:has-text("Change Password")'); + await expect(page.locator('#current-password-input')).toHaveValue('', { timeout: 10_000 }); +}); + +// ── API Token ───────────────────────────────────────────────────────────── + +test('switches to API Token tab', async () => { + await page.click('.nav-link:has-text("API Token"), button[role="tab"]:has-text("API")'); + await expect(page.locator('h3:has-text("API Token Management")')).toBeVisible({ timeout: 5_000 }); +}); + +test('generates an API token', async () => { + const generateBtn = page.locator('button:has-text("Generate API Token")'); + await expect(generateBtn).toBeVisible({ timeout: 5_000 }); + await generateBtn.click(); + // Scope to card to avoid matching the hidden modal footer "Revoke Token" button + await expect(page.locator('.card button:has-text("Revoke Token")')).toBeVisible({ + timeout: 10_000, + }); +}); + +test('revokes the API token', async () => { + // Scope to card button (distinct from modal footer "Revoke Token" ok button) + const revokeBtn = page.locator('.card button:has-text("Revoke Token")'); + await expect(revokeBtn).toBeVisible({ timeout: 5_000 }); + await revokeBtn.click(); + await confirmDialog(page); + await expect(page.locator('button:has-text("Generate API Token")')).toBeVisible({ + timeout: 10_000, + }); +}); diff --git a/client-v3/package-lock.json b/client-v3/package-lock.json index 66b2a725b..9b0783c57 100644 --- a/client-v3/package-lock.json +++ b/client-v3/package-lock.json @@ -37,6 +37,7 @@ "@emnapi/runtime": "1.10.0", "@eslint/js": "^10.0.1", "@iconify-json/mdi": "^1.2.3", + "@playwright/test": "^1.60.0", "@types/lodash": "~4.17.24", "@types/node": ">=22.12.0", "@typescript-eslint/eslint-plugin": "^8.59.4", @@ -1169,6 +1170,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -4566,6 +4583,53 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", diff --git a/client-v3/package.json b/client-v3/package.json index e554d6d4f..01ce312f4 100644 --- a/client-v3/package.json +++ b/client-v3/package.json @@ -9,17 +9,21 @@ "build": "vite build", "build:analyze": "vite build --mode analyze", "lint": "npm run format && npm run lint:eslint", - "lint:eslint": "eslint 'src/**/*.{ts,vue}' --fix", + "lint:eslint": "eslint 'src/**/*.{ts,vue}' 'e2e/**/*.ts' 'playwright.config.ts' --fix", "ci-lint": "npm run format:check && npm run lint:eslint-check", - "lint:eslint-check": "eslint 'src/**/*.{ts,vue}'", - "format": "prettier --write 'src/**/*.{ts,vue,json,css,scss}'", - "format:check": "prettier --check 'src/**/*.{ts,vue,json,css,scss}'", + "lint:eslint-check": "eslint 'src/**/*.{ts,vue}' 'e2e/**/*.ts' 'playwright.config.ts'", + "format": "prettier --write 'src/**/*.{ts,vue,json,css,scss}' 'e2e/**/*.ts' 'playwright.config.ts'", + "format:check": "prettier --check 'src/**/*.{ts,vue,json,css,scss}' 'e2e/**/*.ts' 'playwright.config.ts'", "typecheck": "tsc --noEmit -p tsconfig.json", "test": "vitest", "test:ui": "vitest --ui", "test:run": "vitest run", "test:coverage": "vitest run --coverage", - "dev": "vite" + "dev": "vite", + "test:e2e": "playwright test --project=chromium", + "test:e2e:firefox": "playwright test --project=firefox", + "test:e2e:all": "playwright test", + "test:e2e:report": "playwright show-report" }, "engines": { "npm": ">=11.0.0 <12", @@ -77,7 +81,8 @@ "unplugin-vue-components": "^32.1.0", "vite": "^8.0.14", "vitest": "^4.1.7", - "vue-eslint-parser": "^10.4.0" + "vue-eslint-parser": "^10.4.0", + "@playwright/test": "^1.60.0" }, "browserslist": [ "> 1%", diff --git a/client-v3/playwright.config.ts b/client-v3/playwright.config.ts new file mode 100644 index 000000000..a12529647 --- /dev/null +++ b/client-v3/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from '@playwright/test'; + +const SERVER_PORT = 8888; + +export default defineConfig({ + testDir: './e2e/tests', + fullyParallel: false, + workers: 1, + retries: 0, + reporter: [ + ['html', { outputFolder: 'playwright-report', open: 'never' }], + ['junit', { outputFile: 'junit/playwright-results.xml' }], + ], + use: { + baseURL: `http://localhost:${SERVER_PORT}`, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + globalSetup: './e2e/global-setup.ts', + globalTeardown: './e2e/global-teardown.ts', + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + ], +}); diff --git a/client-v3/src/components/config/ConfigShows.vue b/client-v3/src/components/config/ConfigShows.vue index b51d2bce6..7be4359f3 100644 --- a/client-v3/src/components/config/ConfigShows.vue +++ b/client-v3/src/components/config/ConfigShows.vue @@ -59,10 +59,10 @@ Required, must be before or same as end date. @@ -70,10 +70,10 @@ Required, must be after or same as start date. ({ name: '', - start_date: '', - end_date: '', + start: '', + end: '', script_mode: scriptModes.value[0]?.value ?? null, }); @@ -161,23 +161,23 @@ const formState = ref(defaultForm()); const beforeEnd = helpers.withMessage( 'Start date must be before end date', () => - !formState.value.start_date || - !formState.value.end_date || - new Date(formState.value.start_date) <= new Date(formState.value.end_date) + !formState.value.start || + !formState.value.end || + new Date(formState.value.start) <= new Date(formState.value.end) ); const afterStart = helpers.withMessage( 'End date must be after start date', () => - !formState.value.start_date || - !formState.value.end_date || - new Date(formState.value.end_date) >= new Date(formState.value.start_date) + !formState.value.start || + !formState.value.end || + new Date(formState.value.end) >= new Date(formState.value.start) ); const rules = { name: { required, maxLength: maxLength(100) }, - start_date: { required, beforeEnd }, - end_date: { required, afterStart }, + start: { required, beforeEnd }, + end: { required, afterStart }, script_mode: { required }, }; diff --git a/client-v3/src/components/config/ConfigUsers.vue b/client-v3/src/components/config/ConfigUsers.vue index be7bbf271..1082a688f 100644 --- a/client-v3/src/components/config/ConfigUsers.vue +++ b/client-v3/src/components/config/ConfigUsers.vue @@ -113,8 +113,8 @@ const userFields = [ const adminUsers = computed(() => users.value.filter((u) => u.is_admin)); -function handleUserCreated(modal: typeof newUserModal): void { - modal.value?.hide(); +function handleUserCreated(modal: InstanceType | undefined): void { + modal?.hide(); userStore.getUsers(); } diff --git a/client-v3/tsconfig.e2e.json b/client-v3/tsconfig.e2e.json new file mode 100644 index 000000000..5a294f189 --- /dev/null +++ b/client-v3/tsconfig.e2e.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "moduleResolution": "node", + "module": "CommonJS", + "types": ["node", "@playwright/test"], + "noEmit": true + }, + "include": ["e2e/**/*.ts", "playwright.config.ts"] +} diff --git a/client-v3/vitest.config.ts b/client-v3/vitest.config.ts index 0a9d396ea..a1cc8cc81 100644 --- a/client-v3/vitest.config.ts +++ b/client-v3/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ test: { globals: true, environment: 'jsdom', + exclude: ['**/node_modules/**', '**/dist/**', 'e2e/**'], passWithNoTests: true, reporters: ['default', 'junit'], outputFile: { From a21d4aeb6ec1dc4b1c2819748fca59acc401c66b Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Mon, 25 May 2026 21:28:13 +0100 Subject: [PATCH 03/10] Fix E2E CI failure: wait for SHOW_CHANGED page reload in spec 03 The SHOW_CHANGED WS handler calls window.location.reload() after a show is loaded. On slow CI runners this reload arrives after the test 'creates and loads a show via Save and Load' has already completed, causing the subsequent 'opens New User modal' test to fail with 'Target page, context or browser has been closed' when the reload fires mid-click. Fix by listening for the load event before clicking 'Save and Load' so the test waits for the reload regardless of when SHOW_CHANGED arrives. Co-Authored-By: Claude Sonnet 4.6 --- client-v3/e2e/tests/03-system-config.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/client-v3/e2e/tests/03-system-config.spec.ts b/client-v3/e2e/tests/03-system-config.spec.ts index a4483b2b3..4e1917fa0 100644 --- a/client-v3/e2e/tests/03-system-config.spec.ts +++ b/client-v3/e2e/tests/03-system-config.spec.ts @@ -63,10 +63,14 @@ test('creates and loads a show via Save and Load', async () => { .waitForSelector('#show-script-mode-input option:not([value=""])', { timeout: 5_000 }) .catch(() => {}); await page.selectOption('#show-script-mode-input', { index: 0 }); + + // The frontend's SHOW_CHANGED WS handler calls window.location.reload() after the show is + // loaded. On fast runners this fires before waitForModalClosed returns; on slow CI runners it + // can arrive seconds after the API response. Listen before clicking to catch it either way. + const afterLoad = page.waitForEvent('load', { timeout: 30_000 }); await page.click('.modal.show button:has-text("Save and Load")'); + await afterLoad; - // Modal closes, toast appears - await waitForModalClosed(page, 10_000); await waitForAppReady(page); // Show Config nav link appears once a show is loaded From 7aa6a13eab7add95c888ec629e9ce00875f3e511 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Mon, 25 May 2026 22:22:09 +0100 Subject: [PATCH 04/10] Fix JWT token uniqueness to prevent revoked-token collision on fast re-login (#1086) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix JWT token uniqueness to prevent revoked token collision PyJWT tokens created within the same UTC second for the same user produce identical byte strings (same user_id, token_version, iat, exp). When a logout revoked token T1 and a subsequent re-login created T2 within the same second, T2 === T1 causing is_token_revoked() to return true and trigger WS_AUTH_ERROR → logout on the newly authenticated session. Fix: add a unique jti (UUID) claim to every created token so tokens are always distinct regardless of timing. Also fix revoke_token() which incorrectly read exp from the JWT *header* (where it doesn't exist → always -1), preventing expired tokens from ever being purged from the in-memory revocation dict. Now reads exp from the payload via an unverified decode. Co-Authored-By: Claude Sonnet 4.6 * Fix python formatting --------- Co-authored-by: Claude Sonnet 4.6 --- client-v3/playwright.config.ts | 2 +- server/utils/web/jwt_service.py | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/client-v3/playwright.config.ts b/client-v3/playwright.config.ts index a12529647..41d1f37a4 100644 --- a/client-v3/playwright.config.ts +++ b/client-v3/playwright.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ ], use: { baseURL: `http://localhost:${SERVER_PORT}`, - trace: 'on-first-retry', + trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure', }, diff --git a/server/utils/web/jwt_service.py b/server/utils/web/jwt_service.py index db83b779e..639439531 100644 --- a/server/utils/web/jwt_service.py +++ b/server/utils/web/jwt_service.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta, timezone from typing import Any, Dict, Optional +from uuid import uuid4 import tornado.locks from jwt import PyJWS, PyJWT, PyJWTError @@ -34,8 +35,15 @@ def __init__( self._revoked_token_lock = tornado.locks.Lock() async def revoke_token(self, token: str): - headers = self._jws.get_unverified_header(token) - expiry = headers.get("exp", -1) + try: + payload = self._jwt.decode( + token, + options={"verify_signature": False, "verify_exp": False}, + algorithms=[self._jwt_algorithm], + ) + expiry = payload.get("exp", -1) + except Exception: + expiry = -1 async with self._revoked_token_lock: self._revoked_tokens[token] = expiry @@ -91,7 +99,9 @@ def create_access_token( if user: to_encode["token_version"] = user.token_version - to_encode.update({"exp": expire, "iat": datetime.now(tz=timezone.utc)}) + to_encode.update( + {"exp": expire, "iat": datetime.now(tz=timezone.utc), "jti": str(uuid4())} + ) encoded_jwt = self._jwt.encode( to_encode, self.get_secret(), algorithm=self._jwt_algorithm ) From da8d8dc959304b392d5611cdf9d45109d28cef88 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Mon, 25 May 2026 22:34:03 +0100 Subject: [PATCH 05/10] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- client-v3/e2e/tests/03-system-config.spec.ts | 1 - client-v3/e2e/tests/13-live-show.spec.ts | 2 -- 2 files changed, 3 deletions(-) diff --git a/client-v3/e2e/tests/03-system-config.spec.ts b/client-v3/e2e/tests/03-system-config.spec.ts index 4e1917fa0..b0742aee0 100644 --- a/client-v3/e2e/tests/03-system-config.spec.ts +++ b/client-v3/e2e/tests/03-system-config.spec.ts @@ -5,7 +5,6 @@ import { test, expect, type BrowserContext, type Page } from '@playwright/test'; import { UI_BASE, - ADMIN_USERNAME, loginAsAdmin, waitForAppReady, waitForModal, diff --git a/client-v3/e2e/tests/13-live-show.spec.ts b/client-v3/e2e/tests/13-live-show.spec.ts index bdd1299fa..1e3b58864 100644 --- a/client-v3/e2e/tests/13-live-show.spec.ts +++ b/client-v3/e2e/tests/13-live-show.spec.ts @@ -10,8 +10,6 @@ import { test, expect, type BrowserContext, type Page } from '@playwright/test'; import { UI_BASE, - ADMIN_USERNAME, - ADMIN_PASSWORD, waitForAppReady, loginAsAdmin, confirmModal, From aaa84beb80315b66c38f1116b33ea04dcfeae82b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 22:22:11 +0000 Subject: [PATCH 06/10] Update sqlalchemy requirement in /server (#1083) Updates the requirements on [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) to permit the latest version. - [Release notes](https://github.com/sqlalchemy/sqlalchemy/releases) - [Changelog](https://github.com/sqlalchemy/sqlalchemy/blob/main/CHANGES.rst) - [Commits](https://github.com/sqlalchemy/sqlalchemy/commits) --- updated-dependencies: - dependency-name: sqlalchemy dependency-version: 2.0.50 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- server/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/requirements.txt b/server/requirements.txt index 589721fc2..d64318b56 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -1,5 +1,5 @@ tornado==6.5.5 -sqlalchemy>=2.0.49,<2.1.0 +sqlalchemy>=2.0.50,<2.1.0 datetime==4.9 python-dateutil==2.9.0.post0 marshmallow-sqlalchemy>=1.5.0 From c6eeedc3d4f0a967f874e35c1b10c65c65197381 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Mon, 25 May 2026 23:48:48 +0100 Subject: [PATCH 07/10] Add E2E retry support with SQLite snapshot/restore (#1087) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add E2E retry support with SQLite snapshot/restore and bail-on-failure Enables Playwright retries (3 attempts) for the E2E suite while preserving the serial, state-dependent nature of the 14 specs. On retry, the backend server is killed, both the SQLite DB and settings JSON are restored from the last known-good snapshot, and the server is restarted before the spec group re-runs. maxFailures: 1 ensures downstream specs are skipped immediately once a spec exhausts all retries, preventing meaningless results from running against incomplete state. - Add e2e/db-snapshot.ts: snapshotState(), snapshotExists(), restoreStateAndRestartServer() - Export SERVER_PORT and waitForServer from global-setup.ts for reuse - Set retries: 3 and maxFailures: 1 in playwright.config.ts - Add beforeAll (restore on retry) and afterEach (snapshot on pass) hooks to all 14 spec files Co-Authored-By: Claude Sonnet 4.6 * Fix no-empty-pattern lint errors in E2E retry hooks Replace ({}, testInfo) with (_fixtures, testInfo) in the beforeAll/afterEach hooks added to all 14 spec files. Empty destructuring patterns are disallowed by the no-empty-pattern ESLint rule. Co-Authored-By: Claude Sonnet 4.6 * Use test.info() instead of testInfo parameter in retry hooks Playwright requires the first argument of beforeAll/afterEach to use object destructuring syntax — a plain identifier causes a fixture parser error. Using test.info() as a static accessor avoids the parameter entirely, satisfying both Playwright and the no-empty-pattern ESLint rule. Co-Authored-By: Claude Sonnet 4.6 * Refactor retry hooks into registerRetryHooks() to eliminate duplication Extract the beforeAll/afterEach retry logic from all 14 spec files into a single registerRetryHooks() function in db-snapshot.ts. Each spec now calls registerRetryHooks() instead of repeating the 10-line hook block, reducing ~140 duplicated lines to one call site per spec. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- client-v3/e2e/db-snapshot.ts | 83 +++++++++++++++++++ client-v3/e2e/global-setup.ts | 4 +- client-v3/e2e/tests/01-first-run.spec.ts | 3 + client-v3/e2e/tests/02-auth.spec.ts | 3 + client-v3/e2e/tests/03-system-config.spec.ts | 3 + .../e2e/tests/04-show-config-show.spec.ts | 3 + .../tests/05-show-config-acts-scenes.spec.ts | 3 + .../tests/06-show-config-characters.spec.ts | 3 + .../e2e/tests/07-show-config-stage.spec.ts | 3 + .../e2e/tests/08-show-config-cues.spec.ts | 3 + .../e2e/tests/09-show-config-mics.spec.ts | 3 + .../e2e/tests/10-show-config-script.spec.ts | 3 + .../tests/11-show-config-revisions.spec.ts | 3 + .../e2e/tests/12-show-config-sessions.spec.ts | 3 + client-v3/e2e/tests/13-live-show.spec.ts | 3 + client-v3/e2e/tests/14-user-settings.spec.ts | 3 + client-v3/playwright.config.ts | 3 +- 17 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 client-v3/e2e/db-snapshot.ts diff --git a/client-v3/e2e/db-snapshot.ts b/client-v3/e2e/db-snapshot.ts new file mode 100644 index 000000000..e4dd528ee --- /dev/null +++ b/client-v3/e2e/db-snapshot.ts @@ -0,0 +1,83 @@ +import fs from 'fs'; +import path from 'path'; +import { spawn } from 'child_process'; +import { test } from '@playwright/test'; +import { PID_FILE, TMPDIR_FILE, SERVER_PORT, waitForServer } from './global-setup.js'; + +function getPaths() { + const tempDir = fs.readFileSync(TMPDIR_FILE, 'utf-8').trim(); + return { + db: path.join(tempDir, 'digiscript.sqlite'), + dbSnapshot: path.join(tempDir, 'digiscript.sqlite.snapshot'), + config: path.join(tempDir, 'digiscript.json'), + configSnapshot: path.join(tempDir, 'digiscript.json.snapshot'), + serverDir: path.resolve(process.cwd(), '..', 'server'), + }; +} + +export function snapshotExists(): boolean { + const { dbSnapshot } = getPaths(); + return fs.existsSync(dbSnapshot); +} + +/** Copy the DB and config to snapshot files. Call after each passing test. */ +export function snapshotState(): void { + const { db, dbSnapshot, config, configSnapshot } = getPaths(); + fs.copyFileSync(db, dbSnapshot); + fs.copyFileSync(config, configSnapshot); +} + +/** + * Restore DB and config from the last snapshot, then restart the server. + * Call at the start of a retry to bring the backend back to last known-good state. + */ +export async function restoreStateAndRestartServer(): Promise { + const { db, dbSnapshot, config, configSnapshot, serverDir } = getPaths(); + + // Kill existing server + if (fs.existsSync(PID_FILE)) { + const pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8').trim(), 10); + try { + process.kill(pid, 'SIGKILL'); + } catch { + // process already gone + } + await new Promise((r) => setTimeout(r, 500)); + } + + // Remove any stale journal file (server uses DELETE journal mode, not WAL) + try { + fs.rmSync(`${db}-journal`); + } catch { + // no journal file present + } + + // Restore DB and config from snapshot + fs.copyFileSync(dbSnapshot, db); + fs.copyFileSync(configSnapshot, config); + + // Respawn server with the restored config + const server = spawn( + 'python3', + ['main.py', `--port=${SERVER_PORT}`, `--settings_path=${config}`, '--debug=false'], + { cwd: serverDir, detached: true, stdio: 'ignore' } + ); + fs.writeFileSync(PID_FILE, String(server.pid!)); + server.unref(); + + await waitForServer(); +} + +/** Register beforeAll/afterEach hooks for retry support. Call once at the top of each spec file. */ +export function registerRetryHooks(): void { + test.beforeAll(async () => { + if (test.info().retry > 0 && snapshotExists()) { + await restoreStateAndRestartServer(); + } + }); + test.afterEach(async () => { + if (test.info().status === 'passed') { + snapshotState(); + } + }); +} diff --git a/client-v3/e2e/global-setup.ts b/client-v3/e2e/global-setup.ts index c26e2108d..b1cb644c2 100644 --- a/client-v3/e2e/global-setup.ts +++ b/client-v3/e2e/global-setup.ts @@ -3,7 +3,7 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; -const SERVER_PORT = 8888; +export const SERVER_PORT = 8888; const HEALTH_URL = `http://localhost:${SERVER_PORT}/api/v1/health`; export const PID_FILE = path.join(os.tmpdir(), 'digiscript-e2e-server.pid'); @@ -85,7 +85,7 @@ async function killStaleServer(): Promise { } } -async function waitForServer(timeoutMs = 30_000): Promise { +export async function waitForServer(timeoutMs = 30_000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { diff --git a/client-v3/e2e/tests/01-first-run.spec.ts b/client-v3/e2e/tests/01-first-run.spec.ts index 272c74438..d596884c6 100644 --- a/client-v3/e2e/tests/01-first-run.spec.ts +++ b/client-v3/e2e/tests/01-first-run.spec.ts @@ -5,9 +5,12 @@ */ import { test, expect, type BrowserContext, type Page } from '@playwright/test'; import { UI_BASE, ADMIN_PASSWORD, waitForAppReady } from '../helpers.js'; +import { registerRetryHooks } from '../db-snapshot.js'; test.describe.configure({ mode: 'serial' }); +registerRetryHooks(); + let ctx: BrowserContext; let page: Page; diff --git a/client-v3/e2e/tests/02-auth.spec.ts b/client-v3/e2e/tests/02-auth.spec.ts index 489a88d3a..f2d9c618a 100644 --- a/client-v3/e2e/tests/02-auth.spec.ts +++ b/client-v3/e2e/tests/02-auth.spec.ts @@ -10,9 +10,12 @@ import { waitForAppReady, loginAsAdmin, } from '../helpers.js'; +import { registerRetryHooks } from '../db-snapshot.js'; test.describe.configure({ mode: 'serial' }); +registerRetryHooks(); + let ctx: BrowserContext; let page: Page; diff --git a/client-v3/e2e/tests/03-system-config.spec.ts b/client-v3/e2e/tests/03-system-config.spec.ts index b0742aee0..7d85e2149 100644 --- a/client-v3/e2e/tests/03-system-config.spec.ts +++ b/client-v3/e2e/tests/03-system-config.spec.ts @@ -11,9 +11,12 @@ import { waitForModalClosed, confirmDialog, } from '../helpers.js'; +import { registerRetryHooks } from '../db-snapshot.js'; test.describe.configure({ mode: 'serial' }); +registerRetryHooks(); + let ctx: BrowserContext; let page: Page; diff --git a/client-v3/e2e/tests/04-show-config-show.spec.ts b/client-v3/e2e/tests/04-show-config-show.spec.ts index 8bac56a6d..0968a8f55 100644 --- a/client-v3/e2e/tests/04-show-config-show.spec.ts +++ b/client-v3/e2e/tests/04-show-config-show.spec.ts @@ -10,9 +10,12 @@ import { confirmModal, waitForModalClosed, } from '../helpers.js'; +import { registerRetryHooks } from '../db-snapshot.js'; test.describe.configure({ mode: 'serial' }); +registerRetryHooks(); + let ctx: BrowserContext; let page: Page; diff --git a/client-v3/e2e/tests/05-show-config-acts-scenes.spec.ts b/client-v3/e2e/tests/05-show-config-acts-scenes.spec.ts index ba9adfa78..e39372cd2 100644 --- a/client-v3/e2e/tests/05-show-config-acts-scenes.spec.ts +++ b/client-v3/e2e/tests/05-show-config-acts-scenes.spec.ts @@ -12,9 +12,12 @@ import { waitForModalClosed, confirmDialog, } from '../helpers.js'; +import { registerRetryHooks } from '../db-snapshot.js'; test.describe.configure({ mode: 'serial' }); +registerRetryHooks(); + let ctx: BrowserContext; let page: Page; diff --git a/client-v3/e2e/tests/06-show-config-characters.spec.ts b/client-v3/e2e/tests/06-show-config-characters.spec.ts index a68aacbc2..94978a157 100644 --- a/client-v3/e2e/tests/06-show-config-characters.spec.ts +++ b/client-v3/e2e/tests/06-show-config-characters.spec.ts @@ -11,9 +11,12 @@ import { waitForModalClosed, confirmDialog, } from '../helpers.js'; +import { registerRetryHooks } from '../db-snapshot.js'; test.describe.configure({ mode: 'serial' }); +registerRetryHooks(); + let ctx: BrowserContext; let page: Page; diff --git a/client-v3/e2e/tests/07-show-config-stage.spec.ts b/client-v3/e2e/tests/07-show-config-stage.spec.ts index 00f67d72f..c85dd0b7a 100644 --- a/client-v3/e2e/tests/07-show-config-stage.spec.ts +++ b/client-v3/e2e/tests/07-show-config-stage.spec.ts @@ -12,9 +12,12 @@ import { waitForModalClosed, confirmDialog, } from '../helpers.js'; +import { registerRetryHooks } from '../db-snapshot.js'; test.describe.configure({ mode: 'serial' }); +registerRetryHooks(); + let ctx: BrowserContext; let page: Page; diff --git a/client-v3/e2e/tests/08-show-config-cues.spec.ts b/client-v3/e2e/tests/08-show-config-cues.spec.ts index 39bc0e896..102b0f6cc 100644 --- a/client-v3/e2e/tests/08-show-config-cues.spec.ts +++ b/client-v3/e2e/tests/08-show-config-cues.spec.ts @@ -11,9 +11,12 @@ import { waitForModalClosed, confirmDialog, } from '../helpers.js'; +import { registerRetryHooks } from '../db-snapshot.js'; test.describe.configure({ mode: 'serial' }); +registerRetryHooks(); + let ctx: BrowserContext; let page: Page; diff --git a/client-v3/e2e/tests/09-show-config-mics.spec.ts b/client-v3/e2e/tests/09-show-config-mics.spec.ts index b737e0017..f8c36b5d1 100644 --- a/client-v3/e2e/tests/09-show-config-mics.spec.ts +++ b/client-v3/e2e/tests/09-show-config-mics.spec.ts @@ -11,9 +11,12 @@ import { waitForModalClosed, confirmDialog, } from '../helpers.js'; +import { registerRetryHooks } from '../db-snapshot.js'; test.describe.configure({ mode: 'serial' }); +registerRetryHooks(); + let ctx: BrowserContext; let page: Page; diff --git a/client-v3/e2e/tests/10-show-config-script.spec.ts b/client-v3/e2e/tests/10-show-config-script.spec.ts index 4ade7b50e..b32cf7608 100644 --- a/client-v3/e2e/tests/10-show-config-script.spec.ts +++ b/client-v3/e2e/tests/10-show-config-script.spec.ts @@ -12,9 +12,12 @@ import { waitForModalClosed, confirmDialog, } from '../helpers.js'; +import { registerRetryHooks } from '../db-snapshot.js'; test.describe.configure({ mode: 'serial' }); +registerRetryHooks(); + let ctx: BrowserContext; let page: Page; diff --git a/client-v3/e2e/tests/11-show-config-revisions.spec.ts b/client-v3/e2e/tests/11-show-config-revisions.spec.ts index 9c9cd8c9c..1d1925eee 100644 --- a/client-v3/e2e/tests/11-show-config-revisions.spec.ts +++ b/client-v3/e2e/tests/11-show-config-revisions.spec.ts @@ -11,9 +11,12 @@ import { waitForModalClosed, confirmDialog, } from '../helpers.js'; +import { registerRetryHooks } from '../db-snapshot.js'; test.describe.configure({ mode: 'serial' }); +registerRetryHooks(); + let ctx: BrowserContext; let page: Page; diff --git a/client-v3/e2e/tests/12-show-config-sessions.spec.ts b/client-v3/e2e/tests/12-show-config-sessions.spec.ts index 3008bb69c..1b9a07dc6 100644 --- a/client-v3/e2e/tests/12-show-config-sessions.spec.ts +++ b/client-v3/e2e/tests/12-show-config-sessions.spec.ts @@ -12,9 +12,12 @@ import { waitForModalClosed, confirmDialog, } from '../helpers.js'; +import { registerRetryHooks } from '../db-snapshot.js'; test.describe.configure({ mode: 'serial' }); +registerRetryHooks(); + let ctx: BrowserContext; let page: Page; diff --git a/client-v3/e2e/tests/13-live-show.spec.ts b/client-v3/e2e/tests/13-live-show.spec.ts index 1e3b58864..3813d7a1a 100644 --- a/client-v3/e2e/tests/13-live-show.spec.ts +++ b/client-v3/e2e/tests/13-live-show.spec.ts @@ -16,9 +16,12 @@ import { waitForModalClosed, confirmDialog, } from '../helpers.js'; +import { registerRetryHooks } from '../db-snapshot.js'; test.describe.configure({ mode: 'serial' }); +registerRetryHooks(); + let leaderCtx: BrowserContext; let followerCtx: BrowserContext; let leaderPage: Page; diff --git a/client-v3/e2e/tests/14-user-settings.spec.ts b/client-v3/e2e/tests/14-user-settings.spec.ts index 6b51f2a45..63d22388a 100644 --- a/client-v3/e2e/tests/14-user-settings.spec.ts +++ b/client-v3/e2e/tests/14-user-settings.spec.ts @@ -10,9 +10,12 @@ import { waitForAppReady, confirmDialog, } from '../helpers.js'; +import { registerRetryHooks } from '../db-snapshot.js'; test.describe.configure({ mode: 'serial' }); +registerRetryHooks(); + let ctx: BrowserContext; let page: Page; diff --git a/client-v3/playwright.config.ts b/client-v3/playwright.config.ts index 41d1f37a4..ae01964f8 100644 --- a/client-v3/playwright.config.ts +++ b/client-v3/playwright.config.ts @@ -6,7 +6,8 @@ export default defineConfig({ testDir: './e2e/tests', fullyParallel: false, workers: 1, - retries: 0, + retries: 3, + maxFailures: 1, reporter: [ ['html', { outputFolder: 'playwright-report', open: 'never' }], ['junit', { outputFile: 'junit/playwright-results.xml' }], From b624b76bd59160bd9fb53b3fa63dd399485a0b9d Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Mon, 25 May 2026 23:53:36 +0100 Subject: [PATCH 08/10] Bump version to 0.30.2 --- client-v3/package-lock.json | 4 ++-- client-v3/package.json | 2 +- client/package-lock.json | 4 ++-- client/package.json | 2 +- electron/package-lock.json | 4 ++-- electron/package.json | 2 +- server/pyproject.toml | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/client-v3/package-lock.json b/client-v3/package-lock.json index 9b0783c57..3ee11df07 100644 --- a/client-v3/package-lock.json +++ b/client-v3/package-lock.json @@ -1,12 +1,12 @@ { "name": "client-v3", - "version": "0.30.1", + "version": "0.30.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "client-v3", - "version": "0.30.1", + "version": "0.30.2", "dependencies": { "@vuelidate/core": "^2.0.3", "@vuelidate/validators": "^2.0.4", diff --git a/client-v3/package.json b/client-v3/package.json index 01ce312f4..8c0fcdaf3 100644 --- a/client-v3/package.json +++ b/client-v3/package.json @@ -1,6 +1,6 @@ { "name": "client-v3", - "version": "0.30.1", + "version": "0.30.2", "description": "DigiScript front end (Vue 3)", "author": "DreamTeamProd", "private": true, diff --git a/client/package-lock.json b/client/package-lock.json index aaa5349b0..d5cba4ac3 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "client", - "version": "0.30.1", + "version": "0.30.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "client", - "version": "0.30.1", + "version": "0.30.2", "dependencies": { "bootstrap": "4.6.2", "bootstrap-vue": "2.23.1", diff --git a/client/package.json b/client/package.json index c9f046846..8f5e36404 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "client", - "version": "0.30.1", + "version": "0.30.2", "description": "DigiScript front end", "author": "DreamTeamProd", "private": true, diff --git a/electron/package-lock.json b/electron/package-lock.json index 2bc2a2aca..09a94e5bf 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "digiscript-electron", - "version": "0.30.1", + "version": "0.30.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "digiscript-electron", - "version": "0.30.1", + "version": "0.30.2", "license": "GPL-3.0", "dependencies": { "bonjour-service": "^1.4.0", diff --git a/electron/package.json b/electron/package.json index 7107e58c7..a56f9f3e1 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "digiscript-electron", - "version": "0.30.1", + "version": "0.30.2", "description": "DigiScript Electron Desktop Application", "author": "DreamTeamProd", "license": "GPL-3.0", diff --git a/server/pyproject.toml b/server/pyproject.toml index c9cc12964..48e9a021d 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -11,7 +11,7 @@ build-backend = "setuptools.build_meta" [project] name = "digiscript-server" -version = "0.30.1" +version = "0.30.2" description = "DigiScript server - Digital script management for theatrical shows" readme = "../README.md" requires-python = ">=3.13" From fb92e8851ca51e8f70fafb278c4224290d891bfe Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Tue, 26 May 2026 00:16:01 +0100 Subject: [PATCH 09/10] Fix Firefox E2E failure in spec 03: toast blocks modal close, and retry cascade (#1088) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related issues: 1. In "can configure RBAC permissions for testuser", Firefox kept the "Revoked role from user" v-toast visible long enough to intercept pointer events to the modal's .btn-close, causing a 30s timeout. Fix: wait for .v-toast to detach before clicking the close button. 2. The original rolling-checkpoint snapshot strategy had a cascade bug: when test N passes (e.g. creates testuser) then test N+1 fails, the retry restores to the post-N snapshot and re-runs all tests — but test N hits its own prior side effect (duplicate username) and fails. Redesign: switch from rolling-checkpoint to spec-start snapshots. Each spec captures the DB state at its beginning (beforeAll, retry=0). On retry, restore from that spec-start so every test in the spec runs against a clean baseline with none of its own previous side effects. Co-authored-by: Claude Sonnet 4.6 --- client-v3/e2e/db-snapshot.ts | 59 +++++++++++--------- client-v3/e2e/tests/03-system-config.spec.ts | 6 ++ 2 files changed, 38 insertions(+), 27 deletions(-) diff --git a/client-v3/e2e/db-snapshot.ts b/client-v3/e2e/db-snapshot.ts index e4dd528ee..be1e5adb2 100644 --- a/client-v3/e2e/db-snapshot.ts +++ b/client-v3/e2e/db-snapshot.ts @@ -6,35 +6,36 @@ import { PID_FILE, TMPDIR_FILE, SERVER_PORT, waitForServer } from './global-setu function getPaths() { const tempDir = fs.readFileSync(TMPDIR_FILE, 'utf-8').trim(); + const db = path.join(tempDir, 'digiscript.sqlite'); + const config = path.join(tempDir, 'digiscript.json'); return { - db: path.join(tempDir, 'digiscript.sqlite'), - dbSnapshot: path.join(tempDir, 'digiscript.sqlite.snapshot'), - config: path.join(tempDir, 'digiscript.json'), - configSnapshot: path.join(tempDir, 'digiscript.json.snapshot'), + db, + dbSpecStart: `${db}.specstart`, + config, + configSpecStart: `${config}.specstart`, serverDir: path.resolve(process.cwd(), '..', 'server'), }; } -export function snapshotExists(): boolean { - const { dbSnapshot } = getPaths(); - return fs.existsSync(dbSnapshot); +export function specStartExists(): boolean { + return fs.existsSync(getPaths().dbSpecStart); } -/** Copy the DB and config to snapshot files. Call after each passing test. */ -export function snapshotState(): void { - const { db, dbSnapshot, config, configSnapshot } = getPaths(); - fs.copyFileSync(db, dbSnapshot); - fs.copyFileSync(config, configSnapshot); +/** Copy the current DB and config to the spec-start snapshot files. */ +export function snapshotSpecStart(): void { + const { db, dbSpecStart, config, configSpecStart } = getPaths(); + fs.copyFileSync(db, dbSpecStart); + fs.copyFileSync(config, configSpecStart); } /** - * Restore DB and config from the last snapshot, then restart the server. - * Call at the start of a retry to bring the backend back to last known-good state. + * Restore DB and config from the spec-start snapshot, then restart the server. + * This returns the backend to the state it was in before this spec's first test ran, + * so every test in the spec can re-run cleanly without hitting its own prior side effects. */ -export async function restoreStateAndRestartServer(): Promise { - const { db, dbSnapshot, config, configSnapshot, serverDir } = getPaths(); +export async function restoreSpecStartAndRestartServer(): Promise { + const { db, dbSpecStart, config, configSpecStart, serverDir } = getPaths(); - // Kill existing server if (fs.existsSync(PID_FILE)) { const pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8').trim(), 10); try { @@ -52,11 +53,9 @@ export async function restoreStateAndRestartServer(): Promise { // no journal file present } - // Restore DB and config from snapshot - fs.copyFileSync(dbSnapshot, db); - fs.copyFileSync(configSnapshot, config); + fs.copyFileSync(dbSpecStart, db); + fs.copyFileSync(configSpecStart, config); - // Respawn server with the restored config const server = spawn( 'python3', ['main.py', `--port=${SERVER_PORT}`, `--settings_path=${config}`, '--debug=false'], @@ -68,16 +67,22 @@ export async function restoreStateAndRestartServer(): Promise { await waitForServer(); } -/** Register beforeAll/afterEach hooks for retry support. Call once at the top of each spec file. */ +/** + * Register beforeAll hooks for retry support. Call once at the top of each spec file. + * + * On first run (retry=0): captures the DB state at spec start so retries have a clean baseline. + * On retry: restores from that spec-start snapshot before tests run again, ensuring every test + * in the spec sees the same starting conditions regardless of side effects from prior attempts. + */ export function registerRetryHooks(): void { test.beforeAll(async () => { - if (test.info().retry > 0 && snapshotExists()) { - await restoreStateAndRestartServer(); + if (test.info().retry > 0 && specStartExists()) { + await restoreSpecStartAndRestartServer(); } }); - test.afterEach(async () => { - if (test.info().status === 'passed') { - snapshotState(); + test.beforeAll(async () => { + if (test.info().retry === 0) { + snapshotSpecStart(); } }); } diff --git a/client-v3/e2e/tests/03-system-config.spec.ts b/client-v3/e2e/tests/03-system-config.spec.ts index 7d85e2149..3afc86ff0 100644 --- a/client-v3/e2e/tests/03-system-config.spec.ts +++ b/client-v3/e2e/tests/03-system-config.spec.ts @@ -120,6 +120,12 @@ test('can configure RBAC permissions for testuser', async () => { timeout: 5_000, }); + // Wait for the "Revoked role from user" toast to clear before clicking the modal close button. + // In Firefox the toast persists long enough to intercept pointer events to .btn-close. + await page + .locator('.v-toast') + .waitFor({ state: 'detached', timeout: 10_000 }) + .catch(() => {}); await page.locator('.modal.show .modal-header .btn-close').click(); await waitForModalClosed(page); }); From c4a3aaf076f4c13820630241127f008acee0f12b Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Tue, 26 May 2026 00:24:16 +0100 Subject: [PATCH 10/10] Update dockerignore to clean build context (#1089) --- .dockerignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index 1d3fa65fb..c8548acf3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,11 +5,15 @@ scripts documentation dist hooks +electron # Client client/node_modules +client-v3/node_modules +client-v3/e2e # Server server/conf server/public -server/static \ No newline at end of file +server/static +server/test \ No newline at end of file