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 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/db-snapshot.ts b/client-v3/e2e/db-snapshot.ts new file mode 100644 index 000000000..be1e5adb2 --- /dev/null +++ b/client-v3/e2e/db-snapshot.ts @@ -0,0 +1,88 @@ +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(); + const db = path.join(tempDir, 'digiscript.sqlite'); + const config = path.join(tempDir, 'digiscript.json'); + return { + db, + dbSpecStart: `${db}.specstart`, + config, + configSpecStart: `${config}.specstart`, + serverDir: path.resolve(process.cwd(), '..', 'server'), + }; +} + +export function specStartExists(): boolean { + return fs.existsSync(getPaths().dbSpecStart); +} + +/** 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 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 restoreSpecStartAndRestartServer(): Promise { + const { db, dbSpecStart, config, configSpecStart, serverDir } = getPaths(); + + 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 + } + + fs.copyFileSync(dbSpecStart, db); + fs.copyFileSync(configSpecStart, 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 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 && specStartExists()) { + await restoreSpecStartAndRestartServer(); + } + }); + test.beforeAll(async () => { + if (test.info().retry === 0) { + snapshotSpecStart(); + } + }); +} diff --git a/client-v3/e2e/global-setup.ts b/client-v3/e2e/global-setup.ts new file mode 100644 index 000000000..b1cb644c2 --- /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'; + +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'); +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 + } +} + +export 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..d596884c6 --- /dev/null +++ b/client-v3/e2e/tests/01-first-run.spec.ts @@ -0,0 +1,72 @@ +/** + * 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'; +import { registerRetryHooks } from '../db-snapshot.js'; + +test.describe.configure({ mode: 'serial' }); + +registerRetryHooks(); + +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..f2d9c618a --- /dev/null +++ b/client-v3/e2e/tests/02-auth.spec.ts @@ -0,0 +1,83 @@ +/** + * 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'; +import { registerRetryHooks } from '../db-snapshot.js'; + +test.describe.configure({ mode: 'serial' }); + +registerRetryHooks(); + +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..3afc86ff0 --- /dev/null +++ b/client-v3/e2e/tests/03-system-config.spec.ts @@ -0,0 +1,225 @@ +/** + * 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, + loginAsAdmin, + waitForAppReady, + waitForModal, + waitForModalClosed, + confirmDialog, +} from '../helpers.js'; +import { registerRetryHooks } from '../db-snapshot.js'; + +test.describe.configure({ mode: 'serial' }); + +registerRetryHooks(); + +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 }); + + // 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; + + 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, + }); + + // 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); +}); + +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..0968a8f55 --- /dev/null +++ b/client-v3/e2e/tests/04-show-config-show.spec.ts @@ -0,0 +1,63 @@ +/** + * 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'; +import { registerRetryHooks } from '../db-snapshot.js'; + +test.describe.configure({ mode: 'serial' }); + +registerRetryHooks(); + +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..e39372cd2 --- /dev/null +++ b/client-v3/e2e/tests/05-show-config-acts-scenes.spec.ts @@ -0,0 +1,123 @@ +/** + * 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'; +import { registerRetryHooks } from '../db-snapshot.js'; + +test.describe.configure({ mode: 'serial' }); + +registerRetryHooks(); + +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..94978a157 --- /dev/null +++ b/client-v3/e2e/tests/06-show-config-characters.spec.ts @@ -0,0 +1,145 @@ +/** + * 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'; +import { registerRetryHooks } from '../db-snapshot.js'; + +test.describe.configure({ mode: 'serial' }); + +registerRetryHooks(); + +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..c85dd0b7a --- /dev/null +++ b/client-v3/e2e/tests/07-show-config-stage.spec.ts @@ -0,0 +1,181 @@ +/** + * 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'; +import { registerRetryHooks } from '../db-snapshot.js'; + +test.describe.configure({ mode: 'serial' }); + +registerRetryHooks(); + +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..102b0f6cc --- /dev/null +++ b/client-v3/e2e/tests/08-show-config-cues.spec.ts @@ -0,0 +1,123 @@ +/** + * 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'; +import { registerRetryHooks } from '../db-snapshot.js'; + +test.describe.configure({ mode: 'serial' }); + +registerRetryHooks(); + +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..f8c36b5d1 --- /dev/null +++ b/client-v3/e2e/tests/09-show-config-mics.spec.ts @@ -0,0 +1,163 @@ +/** + * 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'; +import { registerRetryHooks } from '../db-snapshot.js'; + +test.describe.configure({ mode: 'serial' }); + +registerRetryHooks(); + +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..b32cf7608 --- /dev/null +++ b/client-v3/e2e/tests/10-show-config-script.spec.ts @@ -0,0 +1,204 @@ +/** + * 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'; +import { registerRetryHooks } from '../db-snapshot.js'; + +test.describe.configure({ mode: 'serial' }); + +registerRetryHooks(); + +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..1d1925eee --- /dev/null +++ b/client-v3/e2e/tests/11-show-config-revisions.spec.ts @@ -0,0 +1,117 @@ +/** + * 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'; +import { registerRetryHooks } from '../db-snapshot.js'; + +test.describe.configure({ mode: 'serial' }); + +registerRetryHooks(); + +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..1b9a07dc6 --- /dev/null +++ b/client-v3/e2e/tests/12-show-config-sessions.spec.ts @@ -0,0 +1,75 @@ +/** + * 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'; +import { registerRetryHooks } from '../db-snapshot.js'; + +test.describe.configure({ mode: 'serial' }); + +registerRetryHooks(); + +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..3813d7a1a --- /dev/null +++ b/client-v3/e2e/tests/13-live-show.spec.ts @@ -0,0 +1,162 @@ +/** + * 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, + waitForAppReady, + loginAsAdmin, + confirmModal, + 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; +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..63d22388a --- /dev/null +++ b/client-v3/e2e/tests/14-user-settings.spec.ts @@ -0,0 +1,150 @@ +/** + * 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'; +import { registerRetryHooks } from '../db-snapshot.js'; + +test.describe.configure({ mode: 'serial' }); + +registerRetryHooks(); + +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..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", @@ -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..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, @@ -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..ae01964f8 --- /dev/null +++ b/client-v3/playwright.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, devices } from '@playwright/test'; + +const SERVER_PORT = 8888; + +export default defineConfig({ + testDir: './e2e/tests', + fullyParallel: false, + workers: 1, + retries: 3, + maxFailures: 1, + reporter: [ + ['html', { outputFolder: 'playwright-report', open: 'never' }], + ['junit', { outputFile: 'junit/playwright-results.xml' }], + ], + use: { + baseURL: `http://localhost:${SERVER_PORT}`, + trace: 'retain-on-failure', + 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/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-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: { 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/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; +} 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" 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 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 )