From d56466a193be021e36ace0bce75a9940019038db Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Thu, 7 May 2026 17:40:50 +0200 Subject: [PATCH 01/21] Add Playwright E2E test suite for FW Lite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spins up a real `FwLiteWeb` binary against a kind-cluster lexbox in CI and drives it from a Playwright-controlled Chromium. Two scenarios so far: a smoke test (login + see server projects) and a full project-download test (triggers an initial CRDT sync via the lexbox API, then downloads sena-3 through the FwLite UI). Both run end-to-end against a fresh kind cluster. Test infrastructure - New `frontend/viewer/tests/e2e/` directory with playwright.config.ts, fixtures, helpers, and the integration test file. - Existing snapshot tests moved to `tests/snapshots/`; package.json scripts split into `test:snapshots` and `test:e2e`. - New vitest `integration` project for `tests/integration/fw-lite-launcher.test.ts` (node env, hard-fails when the binary is missing). `pnpm test` excludes it via `--project=!integration` so unrelated jobs don't need the binary. - Vitest `browser` project restored (was dropped accidentally during earlier cleanup) so existing `*.browser.test.ts` files keep running. Workflow (`.github/workflows/fw-lite.yaml`) - New `e2e-test` job depending only on `frontend` (fast-lane — parallel with `build-and-test`). Does its own `dotnet publish -r linux-x64` so it doesn't wait for `publish-linux`. - Spins up lexbox via `setup-k8s`, then port-forwards ingress-nginx :443 to localhost:6580 (MSAL.NET refuses non-HTTPS authorities; the snake-oil cert is trusted via `--environment Development` and playwright's `ignoreHTTPSErrors: true`). - On failure, uploads playwright traces/screenshots/videos AND a dump of k8s pod logs (`kubectl describe`/`logs` for lexbox/ui/hg/db/fw-headless/ ingress-nginx) — pattern lifted from `integration-test-gha.yaml`. - Caches `~/.cache/ms-playwright` keyed on pnpm-lock hash across the three jobs that run `playwright install --with-deps` (frontend, frontend-component-unit-tests, e2e-test). - Adds `permissions:` blocks to satisfy least-privilege guidelines. Backend (FwLite) - `FwLiteWeb`: `/health` endpoint (used by the launcher's readiness probe), `DELETE /api/crdt/{code}` for per-test cleanup, stdin-triggered shutdown (Windows can't SIGTERM child processes; launcher writes "shutdown\n"). - `AuthRoutes`: redirect-after-login fallback to `/` when the referer is the auth endpoint itself. - `HomeView.svelte` / `Server.svelte`: stable anchor IDs (`#local-projects`, `id={server?.id}`) for Playwright selectors. Deployment - `deployment/gha/lexbox.patch.yaml`: trust-all `KnownNetworks` override so lexbox honors `X-Forwarded-Proto: https` from the kind ingress controller (without this, lexbox emits HTTP URLs in `/.well-known/openid-configuration` even though the request came in over HTTPS, breaking the OAuth redirect). Iteration history is preserved in branch `e2e-test-fw-lite-backup-pre-squash`. --- .github/workflows/fw-lite.yaml | 149 +++++++++- .gitignore | 6 + backend/FwLite/FwLiteWeb/FwLiteWebServer.cs | 2 + backend/FwLite/FwLiteWeb/Program.cs | 8 + backend/FwLite/FwLiteWeb/Routes/AuthRoutes.cs | 3 + .../FwLite/FwLiteWeb/Routes/ProjectRoutes.cs | 6 + deployment/gha/lexbox.patch.yaml | 6 + frontend/viewer/.gitignore | 1 + frontend/viewer/Taskfile.yml | 26 +- frontend/viewer/package.json | 11 +- frontend/viewer/src/home/HomeView.svelte | 2 +- frontend/viewer/src/home/Server.svelte | 2 +- frontend/viewer/tests/e2e/README.md | 69 +++++ frontend/viewer/tests/e2e/config.ts | 110 ++++++++ .../tests/e2e/fixtures/test-projects.json | 39 +++ .../tests/e2e/fw-lite-integration.test.ts | 158 +++++++++++ frontend/viewer/tests/e2e/global-setup.ts | 59 ++++ frontend/viewer/tests/e2e/global-teardown.ts | 44 +++ .../tests/e2e/helpers/fw-lite-launcher.ts | 254 ++++++++++++++++++ .../viewer/tests/e2e/helpers/home-page.ts | 125 +++++++++ .../tests/e2e/helpers/project-operations.ts | 101 +++++++ .../viewer/tests/e2e/helpers/project-page.ts | 21 ++ .../viewer/tests/e2e/helpers/test-data.ts | 246 +++++++++++++++++ .../viewer/tests/e2e/playwright.config.ts | 90 +++++++ frontend/viewer/tests/e2e/types.ts | 64 +++++ .../integration/fw-lite-launcher.test.ts | 208 ++++++++++++++ .../snapshots}/playwright.config.ts | 5 +- .../project-view-snapshots.test.ts | 0 .../viewer/tests/{ => snapshots}/snapshot.ts | 0 frontend/viewer/tsconfig.node.json | 3 +- frontend/viewer/vitest.config.ts | 50 ++-- 31 files changed, 1825 insertions(+), 43 deletions(-) create mode 100644 frontend/viewer/tests/e2e/README.md create mode 100644 frontend/viewer/tests/e2e/config.ts create mode 100644 frontend/viewer/tests/e2e/fixtures/test-projects.json create mode 100644 frontend/viewer/tests/e2e/fw-lite-integration.test.ts create mode 100644 frontend/viewer/tests/e2e/global-setup.ts create mode 100644 frontend/viewer/tests/e2e/global-teardown.ts create mode 100644 frontend/viewer/tests/e2e/helpers/fw-lite-launcher.ts create mode 100644 frontend/viewer/tests/e2e/helpers/home-page.ts create mode 100644 frontend/viewer/tests/e2e/helpers/project-operations.ts create mode 100644 frontend/viewer/tests/e2e/helpers/project-page.ts create mode 100644 frontend/viewer/tests/e2e/helpers/test-data.ts create mode 100644 frontend/viewer/tests/e2e/playwright.config.ts create mode 100644 frontend/viewer/tests/e2e/types.ts create mode 100644 frontend/viewer/tests/integration/fw-lite-launcher.test.ts rename frontend/viewer/{ => tests/snapshots}/playwright.config.ts (96%) rename frontend/viewer/tests/{ => snapshots}/project-view-snapshots.test.ts (100%) rename frontend/viewer/tests/{ => snapshots}/snapshot.ts (100%) diff --git a/.github/workflows/fw-lite.yaml b/.github/workflows/fw-lite.yaml index 549a249ecb..10e5a54d80 100644 --- a/.github/workflows/fw-lite.yaml +++ b/.github/workflows/fw-lite.yaml @@ -22,6 +22,9 @@ on: - develop - main - feat/sync-morph-types #just for now ensure PRs to this branch have checks run + +permissions: + contents: read env: VIEWER_BUILD_OUTPUT_DIR: backend/FwLite/FwLiteShared/wwwroot/viewer jobs: @@ -107,12 +110,20 @@ jobs: exit 1 fi rm -f src/locales/en.po.old + - name: Cache Playwright browsers + uses: actions/cache@v4 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/pnpm-lock.yaml') }} - name: Set up Playwright dependencies + if: steps.playwright-cache.outputs.cache-hit != 'true' working-directory: frontend run: pnpm exec playwright install --with-deps - name: Run snapshot tests working-directory: frontend/viewer - run: task playwright-test-standalone + run: task test:snapshot-standalone + - name: Build viewer working-directory: frontend/viewer run: pnpm run build @@ -130,6 +141,9 @@ jobs: path: ${{ env.VIEWER_BUILD_OUTPUT_DIR }} frontend-component-unit-tests: + permissions: + contents: read + checks: write runs-on: ubuntu-latest steps: - name: Checkout @@ -147,7 +161,14 @@ jobs: run: | pnpm install # vitest is configured to use playwright for stories and other browser-based tests + - name: Cache Playwright browsers + uses: actions/cache@v4 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/pnpm-lock.yaml') }} - name: Set up Playwright dependencies + if: steps.playwright-cache.outputs.cache-hit != 'true' working-directory: frontend/viewer run: pnpm exec playwright install --with-deps - name: vitest @@ -371,6 +392,8 @@ jobs: path: backend/FwLite/artifacts/sign/*.msixbundle create-release: + permissions: + contents: write if: ${{ github.ref_name == 'main' }} environment: name: production @@ -424,3 +447,127 @@ jobs: run: | sleep 10 curl -X POST https://lexbox.org/api/fwlite-release/new-release + + + e2e-test: + name: E2E Tests + # Only depends on frontend (for viewer JS) — does its own linux publish to fast-lane + # CI feedback (no waiting on the long Windows build-and-test job). + needs: [frontend] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: true + + - uses: actions/download-artifact@v4 + with: + name: fw-lite-viewer-js + path: ${{ env.VIEWER_BUILD_OUTPUT_DIR }} + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.x' + + - name: Publish FwLiteWeb (linux-x64) + working-directory: backend/FwLite/FwLiteWeb + run: dotnet publish -r linux-x64 --artifacts-path ../artifacts -p:PublishSingleFile=true + + - name: set execute permissions + shell: bash + run: chmod +x backend/FwLite/artifacts/publish/FwLiteWeb/release_linux-x64/FwLiteWeb + + - name: Install Task + uses: arduino/setup-task@b91d5d2c96a56797b48ac1e0e89220bf64044611 #v2 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 # v4.0.0 + with: + package_json_file: 'frontend/package.json' + - uses: actions/setup-node@v4 + with: + node-version-file: './frontend/package.json' + cache: 'pnpm' + cache-dependency-path: './frontend/pnpm-lock.yaml' + - name: Prepare frontend + working-directory: frontend + run: | + pnpm install + - name: Test fw lite launcher + working-directory: frontend/viewer + env: + FW_LITE_BINARY_PATH: ${{ github.workspace }}/backend/FwLite/artifacts/publish/FwLiteWeb/release_linux-x64/FwLiteWeb + run: task e2e-test-helper-unit-tests + + - uses: ./.github/actions/setup-k8s + with: + lexbox-api-tag: develop + ingress-controller-port: '6579' # http; kept for the action's own curl-verify step + repo-token: ${{ secrets.GITHUB_TOKEN }} + + # MSAL refuses any non-https authority, so we also expose ingress-nginx's + # HTTPS port (snake-oil cert; FwLite trusts it via --environment Development, + # playwright via ignoreHTTPSErrors). + - name: Forward ingress HTTPS port + shell: bash + run: | + kubectl port-forward service/ingress-nginx-controller 6580:443 -n languagedepot >/tmp/https-port-forward.log 2>&1 & + # Wait for the port-forward to actually be listening + for i in $(seq 1 30); do + if curl -sk https://localhost:6580 >/dev/null 2>&1; then + echo "✅ HTTPS port-forward ready on :6580" + exit 0 + fi + sleep 1 + done + echo "❌ HTTPS port-forward did not become ready" + cat /tmp/https-port-forward.log + exit 1 + + - name: Cache Playwright browsers + uses: actions/cache@v4 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/pnpm-lock.yaml') }} + - name: Set up Playwright dependencies + if: steps.playwright-cache.outputs.cache-hit != 'true' + working-directory: frontend/viewer + run: pnpm exec playwright install --with-deps + + - name: Run E2E tests + working-directory: frontend/viewer + env: + FW_LITE_BINARY_PATH: ${{ github.workspace }}/backend/FwLite/artifacts/publish/FwLiteWeb/release_linux-x64/FwLiteWeb + TEST_SERVER_PROTOCOL: https + TEST_SERVER_PORT: 6580 + run: task test:e2e + + - name: Upload Playwright test results and traces (on failure) + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fw-lite-e2e-test-results + if-no-files-found: ignore + path: | + frontend/viewer/tests/e2e/test-results/ + + - name: Capture k8s pod logs (on failure) + if: failure() + shell: bash + run: | + mkdir -p k8s-logs + for app in lexbox ui hg db fw-headless; do + kubectl describe pods -l "app=${app}" -n languagedepot > k8s-logs/describe-${app}.txt 2>&1 || true + kubectl logs -l "app=${app}" -n languagedepot --prefix --all-containers --tail=-1 > k8s-logs/logs-${app}.txt 2>&1 || true + done + kubectl logs -l 'app.kubernetes.io/name=ingress-nginx' -n languagedepot --prefix --all-containers --tail=-1 > k8s-logs/logs-ingress.txt 2>&1 || true + + - name: Upload k8s logs (on failure) + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fw-lite-e2e-k8s-logs + if-no-files-found: ignore + path: k8s-logs/*.txt diff --git a/.gitignore b/.gitignore index 6d4c6eca15..80a85f546a 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ artifacts/ project-cache.json localResourcesCache/ +# Kiro AI-generated specs (not part of repo source) +.kiro/ + #Verify *.received.* backend/FwLite/FwLiteShared/wwwroot/viewer @@ -38,3 +41,6 @@ failedSyncs/ .idea *.lscache + +# AI session artifacts (ephemeral planning/review documents) +history/ diff --git a/backend/FwLite/FwLiteWeb/FwLiteWebServer.cs b/backend/FwLite/FwLiteWeb/FwLiteWebServer.cs index 29978b9dfa..627baac039 100644 --- a/backend/FwLite/FwLiteWeb/FwLiteWebServer.cs +++ b/backend/FwLite/FwLiteWeb/FwLiteWebServer.cs @@ -70,6 +70,7 @@ public static WebApplication SetupAppServer(WebApplicationOptions options, Actio options.AddFilter(new LockedProjectFilter()); options.EnableDetailedErrors = true; }).AddJsonProtocol(); + builder.Services.AddHealthChecks(); configure?.Invoke(builder); var app = builder.Build(); @@ -124,6 +125,7 @@ public static WebApplication SetupAppServer(WebApplicationOptions options, Actio app.MapImport(); app.MapAuthRoutes(); app.MapMiniLcmRoutes("/api/mini-lcm"); + app.MapHealthChecks("/health"); app.MapStaticAssets(); app.MapRazorComponents() diff --git a/backend/FwLite/FwLiteWeb/Program.cs b/backend/FwLite/FwLiteWeb/Program.cs index 23d6a827f3..881f9b440f 100644 --- a/backend/FwLite/FwLiteWeb/Program.cs +++ b/backend/FwLite/FwLiteWeb/Program.cs @@ -34,5 +34,13 @@ await app.StopAsync(); }); + _ = Task.Run(async () => + { + // Wait for the "shutdown" command from stdin + while (await Console.In.ReadLineAsync() is not "shutdown") { } + + await app.StopAsync(); + }); + await app.WaitForShutdownAsync(); } diff --git a/backend/FwLite/FwLiteWeb/Routes/AuthRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/AuthRoutes.cs index 55bc0711c4..a1cc1fad68 100644 --- a/backend/FwLite/FwLiteWeb/Routes/AuthRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/AuthRoutes.cs @@ -18,6 +18,9 @@ public static IEndpointConventionBuilder MapAuthRoutes(this WebApplication app) async (AuthService authService, string authority, IOptions options, [FromHeader] string referer) => { var returnUrl = new Uri(referer).PathAndQuery; + if (returnUrl.StartsWith("/api/auth/login")) { + returnUrl = "/"; + } if (options.Value.SystemWebViewLogin) { throw new NotSupportedException("System web view login is not supported for this endpoint"); diff --git a/backend/FwLite/FwLiteWeb/Routes/ProjectRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/ProjectRoutes.cs index 0795351782..87967906f8 100644 --- a/backend/FwLite/FwLiteWeb/Routes/ProjectRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/ProjectRoutes.cs @@ -70,6 +70,12 @@ [FromQuery] UserProjectRole? role _ => Results.InternalServerError("DownloadProjectByCodeResult enum value not handled, please inform FW Lite devs") }; }); + group.MapDelete("/crdt/{code}", + async (CrdtProjectsService projectService, string code) => + { + await projectService.DeleteProject(code); + return TypedResults.Ok(); + }); return group; } } diff --git a/deployment/gha/lexbox.patch.yaml b/deployment/gha/lexbox.patch.yaml index d1878e72ae..103d42031d 100644 --- a/deployment/gha/lexbox.patch.yaml +++ b/deployment/gha/lexbox.patch.yaml @@ -11,6 +11,12 @@ spec: env: - name: Email__BaseUrl value: "http://localhost:6579" + # Trust the kind cluster's pod network so X-Forwarded-Proto from the + # ingress-nginx controller is honored — otherwise lexbox sees the request + # as HTTP and emits HTTP URLs in /.well-known/openid-configuration, which + # breaks the FwLite OAuth flow over the HTTPS port-forward used in CI. + - name: ForwardedHeadersOptions__KnownNetworks__0 + value: "0.0.0.0/0" volumeMounts: - mountPath: /frontend name: gql-schema diff --git a/frontend/viewer/.gitignore b/frontend/viewer/.gitignore index 1fa96a2456..8cf71b5b30 100644 --- a/frontend/viewer/.gitignore +++ b/frontend/viewer/.gitignore @@ -27,3 +27,4 @@ html-test-results *storybook.log storybook-static screenshots/ +**/*-html-report diff --git a/frontend/viewer/Taskfile.yml b/frontend/viewer/Taskfile.yml index d9faef2d29..97dfc47dfe 100644 --- a/frontend/viewer/Taskfile.yml +++ b/frontend/viewer/Taskfile.yml @@ -42,20 +42,28 @@ tasks: test-unit: cmd: pnpm test --project unit - playwright-test: - desc: 'runs playwright tests against already running server' - cmd: pnpm run test:playwright {{.CLI_ARGS}} - playwright-test-standalone: + test:snapshot: + desc: 'runs snapshot tests against already running server' + cmd: pnpm run test:snapshots {{.CLI_ARGS}} + test:snapshot-standalone: aliases: [pts] - desc: 'runs playwright tests and runs dev automatically, run ui mode by calling with -- --ui or use --update-snapshots' + desc: 'runs snapshot tests and runs dev automatically, run ui mode by calling with -- --ui or use --update-snapshots' env: AUTO_START_SERVER: true - cmd: pnpm run test:playwright {{.CLI_ARGS}} + cmd: pnpm run test:snapshots {{.CLI_ARGS}} generate-marketing-screenshots: desc: 'they should be in the screenshots folder' env: AUTO_START_SERVER: true MARKETING_SCREENSHOTS: true - cmd: pnpm run test:playwright {{.CLI_ARGS}} - playwright-test-report: - cmd: pnpm run test:playwright-report + cmd: pnpm run test:snapshots {{.CLI_ARGS}} + + test:e2e-setup: + deps: [build] + cmds: + - dotnet publish ../../backend/FwLite/FwLiteWeb/FwLiteWeb.csproj --configuration Release --self-contained --output ./dist/fw-lite-server + test:e2e: + cmd: pnpm run test:e2e {{.CLI_ARGS}} + e2e-test-helper-unit-tests: + desc: 'tests the fw lite launcher; FW_LITE_BINARY_PATH must point to a built binary' + cmd: pnpm test:integration --run diff --git a/frontend/viewer/package.json b/frontend/viewer/package.json index 3b91d3f855..82aa07e7ba 100644 --- a/frontend/viewer/package.json +++ b/frontend/viewer/package.json @@ -13,16 +13,17 @@ "build": "vite build", "build-ffmpeg-worker": "vite build --config vite.config.ffmpeg-worker.ts", "preview": "vite preview", - "pretest:playwright": "playwright install", - "test:playwright": "playwright test", - "test:playwright-report": "playwright show-report html-test-results", - "test:playwright-record": "playwright codegen", - "test": "vitest run", + "pretest:snapshots": "playwright install chromium", + "pretest:e2e": "playwright install chromium", + "test:snapshots": "playwright test -c ./tests/snapshots/playwright.config.ts", + "test:e2e": "playwright test -c ./tests/e2e/playwright.config.ts", + "test": "vitest run --project=!integration", "test:ui": "vitest --ui", "test:watch": "vitest", "test:storybook": "vitest --project=storybook", "test:unit": "vitest --project=unit", "test:browser": "vitest --project=browser", + "test:integration": "vitest --project=integration", "check": "svelte-check", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/frontend/viewer/src/home/HomeView.svelte b/frontend/viewer/src/home/HomeView.svelte index 7c1cc824fa..778a3d1ec7 100644 --- a/frontend/viewer/src/home/HomeView.svelte +++ b/frontend/viewer/src/home/HomeView.svelte @@ -168,7 +168,7 @@

{$t`loading...`}

{:then projects}
-
+

{$t`Local`}

diff --git a/frontend/viewer/src/home/Server.svelte b/frontend/viewer/src/home/Server.svelte index 336f58b7b7..4de48e5e95 100644 --- a/frontend/viewer/src/home/Server.svelte +++ b/frontend/viewer/src/home/Server.svelte @@ -114,7 +114,7 @@ onDownloadProject={downloadCrdtProjectByCode} validateCode={validateCodeForDownload} /> -
+
{#if server} diff --git a/frontend/viewer/tests/e2e/README.md b/frontend/viewer/tests/e2e/README.md new file mode 100644 index 0000000000..89f1c08df4 --- /dev/null +++ b/frontend/viewer/tests/e2e/README.md @@ -0,0 +1,69 @@ +# FW Lite E2E Tests + +End-to-end tests for FieldWorks Lite that drive the actual published binary against a real (kind-hosted) LexBox server. + +> **Status: in progress / WIP.** Most of this code was AI-generated; expect rough edges. The first goal is to get a smoke test through CI; the rich integration scenarios will follow. + +## What's tested (so far) + +The suite has two layers: + +### 1. Launcher integration tests — `tests/integration/fw-lite-launcher.test.ts` (Vitest) + +These exercise [`FwLiteLauncher`](./helpers/fw-lite-launcher.ts), the helper that spawns and shuts down the published `FwLiteWeb` binary. They run via vitest (no browser): + +- Synthetic checks: `isRunning()` defaults, error on unknown binary path, error if launched twice, port-finding sanity. +- **Real binary smoke test**: spawn the actual `FwLiteWeb` binary, wait for it to become healthy on `/health`, hit it over HTTP, then shut it down. Repeated with a second port to confirm "already running" behaviour. + +In CI these run *before* the Playwright suite as a fast pre-flight: if the binary won't start at all, fail fast. + +Task entry point: `task e2e-test-helper-unit-tests`. + +### 2. Playwright E2E — `tests/e2e/fw-lite-integration.test.ts` + +These drive a real browser against a real `FwLiteWeb` binary, with a real (kind-cluster-hosted) LexBox server. Two tests so far: + +- **Smoke test: Application launch and server connectivity.** Boots FwLiteWeb, opens it in Chromium, logs in to LexBox, asserts at least one server project is visible. +- **Project download: Download and verify project structure.** Logs in, downloads `sena-3` from the configured server, then opens the downloaded local copy and asserts the project page renders. + +Each test launches its own `FwLiteWeb` process (and shuts it down afterwards via stdin `shutdown` on Windows / SIGTERM elsewhere) and uses an isolated user-data directory so logins don't bleed between tests. After-each cleanup logs out from the server and deletes the local CRDT project copy via `DELETE /api/crdt/{code}` (a helper endpoint added on this branch). + +Task entry point: `task test:e2e`. + +## How CI runs this + +The `e2e-test` job in [`.github/workflows/fw-lite.yaml`](../../../../.github/workflows/fw-lite.yaml): + +1. Depends on `frontend` only — does *not* wait for the Windows `build-and-test` job, so feedback comes ~20+ minutes sooner. +2. Downloads the viewer JS artifact built by `frontend`. +3. Runs `dotnet publish -r linux-x64 -p:PublishSingleFile=true` to produce `FwLiteWeb`. +4. Spins up an in-cluster LexBox via the local `setup-k8s` action (port 6579, HTTP). +5. Runs the launcher unit tests, then the Playwright suite. +6. Uploads `tests/e2e/test-results/` as the `fw-lite-e2e-test-results` artifact on failure (traces, screenshots, videos, FW Lite server log). + +## Backend changes that landed for this + +- `GET /health` (`AddHealthChecks` + `MapHealthChecks`) — used by the launcher for its readiness probe. +- `DELETE /api/crdt/{code}` — used by per-test cleanup so re-running the suite doesn't leave stale local CRDT projects. +- Stdin-triggered shutdown in `FwLiteWeb/Program.cs` — Windows can't send SIGTERM to a child process, so the launcher writes `shutdown\n` to stdin instead. +- `--Auth:LexboxServers:0:Authority` overrides — the test launches `FwLiteWeb` pointed at the kind-hosted LexBox, with `--environment Development` so OAuth accepts the self-signed cert. +- Anchor IDs on `HomeView.svelte` (`#local-projects`) and `Server.svelte` (`#{server.id}`) for stable Playwright selectors. + +## Running locally + +```bash +# Build the FwLiteWeb binary for the host platform +task -d frontend/viewer test:e2e-setup + +# Then either: +task -d frontend/viewer e2e-test-helper-unit-tests # launcher checks only +task -d frontend/viewer test:e2e # full Playwright suite (needs a LexBox server) +``` + +`config.ts` reads from env: `FW_LITE_BINARY_PATH`, `TEST_SERVER_HOSTNAME`, `TEST_SERVER_PORT`, `TEST_USER`, `TEST_DEFAULT_PASSWORD`, `TEST_PROJECT_CODE`. The default binary path (`./dist/fw-lite-server/FwLiteWeb.exe`) assumes Windows; override on Linux/Mac. + +## Known rough edges + +- `helpers/test-data.ts` `cleanupTestData` is a stub — it logs but doesn't actually call any API. +- The default `binaryPath` in `config.ts` is Windows-only (`.exe`); use `FW_LITE_BINARY_PATH` to override. +- Playwright config runs serially (`workers: 1`) — each test owns its own `FwLiteWeb` process, so parallelism would need port coordination. diff --git a/frontend/viewer/tests/e2e/config.ts b/frontend/viewer/tests/e2e/config.ts new file mode 100644 index 0000000000..d749e77994 --- /dev/null +++ b/frontend/viewer/tests/e2e/config.ts @@ -0,0 +1,110 @@ +/** + * E2E Test Configuration and Constants + */ + +import type { E2ETestConfig, TestProject } from './types'; + +/** + * Default test configuration + */ +export const DEFAULT_E2E_CONFIG: E2ETestConfig = { + lexboxServer: { + hostname: process.env.TEST_SERVER_HOSTNAME || 'localhost', + // The CI kind cluster serves HTTP on `ingress-controller-port` (6579). Override + // when pointing at a real https deployment. + protocol: (process.env.TEST_SERVER_PROTOCOL as 'http' | 'https') || 'http', + port: process.env.TEST_SERVER_PORT ? parseInt(process.env.TEST_SERVER_PORT) : 6579, + }, + fwLite: { + binaryPath: process.env.FW_LITE_BINARY_PATH || './dist/fw-lite-server/FwLiteWeb.exe', + launchTimeout: 30000, // 30 seconds + shutdownTimeout: 10000, // 10 seconds + }, + testData: { + projectCode: process.env.TEST_PROJECT_CODE || 'sena-3', + testUser: process.env.TEST_USER || 'manager', + testPassword: process.env.TEST_DEFAULT_PASSWORD || 'pass', + }, + timeouts: { + projectDownload: 60000, // 60 seconds + entryCreation: 30000, // 30 seconds + dataSync: 45000, // 45 seconds + }, +}; + +/** + * Test project configurations + */ +export const TEST_PROJECTS: Record = { + 'sena-3': { + code: 'sena-3', + name: 'Sena 3', + expectedEntries: 0, // Will be updated based on actual project state + testUser: 'admin', + }, +}; + +/** + * Test data constants + */ +export const TEST_CONSTANTS = { + // Unique identifier prefix for test entries to avoid conflicts + TEST_ENTRY_PREFIX: 'e2e-test', + + // Default test entry data + DEFAULT_TEST_ENTRY: { + lexeme: 'test-word', + definition: 'A word created during E2E testing', + partOfSpeech: 'noun', + }, + + // Retry configuration for flaky operations + RETRY_CONFIG: { + projectDownload: { attempts: 3, delay: 5000 }, + entryCreation: { attempts: 2, delay: 2000 }, + dataSync: { attempts: 3, delay: 3000 }, + }, + + // UI selectors (to be updated based on actual FW Lite UI) + SELECTORS: { + projectList: '[data-testid="project-list"]', + downloadButton: '[data-testid="download-project"]', + newEntryButton: '[data-testid="new-entry"]', + entryForm: '[data-testid="entry-form"]', + saveButton: '[data-testid="save-entry"]', + searchInput: '[data-testid="search-entries"]', + deleteProjectButton: '[data-testid="delete-project"]', + }, +} as const; + +/** + * Generate unique test identifier + */ +export function generateTestId(): string { + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 8); + return `${TEST_CONSTANTS.TEST_ENTRY_PREFIX}-${timestamp}-${random}`; +} + +/** + * Get test configuration with environment variable overrides + */ +export function getTestConfig(): E2ETestConfig { + return { + ...DEFAULT_E2E_CONFIG, + lexboxServer: { + ...DEFAULT_E2E_CONFIG.lexboxServer, + hostname: process.env.TEST_SERVER_HOSTNAME || DEFAULT_E2E_CONFIG.lexboxServer.hostname, + }, + fwLite: { + ...DEFAULT_E2E_CONFIG.fwLite, + binaryPath: process.env.FW_LITE_BINARY_PATH || DEFAULT_E2E_CONFIG.fwLite.binaryPath, + }, + testData: { + ...DEFAULT_E2E_CONFIG.testData, + projectCode: process.env.TEST_PROJECT_CODE || DEFAULT_E2E_CONFIG.testData.projectCode, + testUser: process.env.TEST_USER || DEFAULT_E2E_CONFIG.testData.testUser, + testPassword: process.env.TEST_DEFAULT_PASSWORD || DEFAULT_E2E_CONFIG.testData.testPassword, + }, + }; +} diff --git a/frontend/viewer/tests/e2e/fixtures/test-projects.json b/frontend/viewer/tests/e2e/fixtures/test-projects.json new file mode 100644 index 0000000000..07f261e103 --- /dev/null +++ b/frontend/viewer/tests/e2e/fixtures/test-projects.json @@ -0,0 +1,39 @@ +{ + "projects": { + "sena-3": { + "code": "sena-3", + "name": "Sena 3", + "description": "Test project for E2E testing", + "expectedEntries": 0, + "testUser": "admin", + "permissions": ["read", "write", "delete"], + "mediaFiles": [], + "expectedStructure": { + "hasLexicon": true, + "hasGrammar": false, + "hasTexts": false + } + } + }, + "testUsers": { + "admin": { + "username": "admin", + "role": "admin", + "permissions": ["read", "write", "delete", "admin"], + "projects": ["sena-3"] + } + }, + "testEntries": { + "sample": { + "lexeme": "sample-word", + "definition": "A sample word for testing", + "partOfSpeech": "noun", + "examples": [ + { + "sentence": "This is a sample sentence.", + "translation": "This is a sample translation." + } + ] + } + } +} diff --git a/frontend/viewer/tests/e2e/fw-lite-integration.test.ts b/frontend/viewer/tests/e2e/fw-lite-integration.test.ts new file mode 100644 index 0000000000..635988ff7d --- /dev/null +++ b/frontend/viewer/tests/e2e/fw-lite-integration.test.ts @@ -0,0 +1,158 @@ +/** + * FW Lite Integration E2E Tests + * + * This test suite implements the core integration scenarios for FW Lite and LexBox. + * It tests the complete workflow: download project, create entry, delete local copy, + * re-download, and verify entry persistence. + */ + +import {expect, test} from '@playwright/test'; +import {FwLiteLauncher} from './helpers/fw-lite-launcher'; +import { + deleteProject, + ensureProjectCrdtReady, + logoutFromServer, +} from './helpers/project-operations'; +import { + cleanupTestData, + generateTestEntry, + generateUniqueIdentifier, + getTestProject, + validateTestDataConfiguration +} from './helpers/test-data'; +import {getTestConfig} from './config'; +import type {TestEntry, TestProject} from './types'; +import {HomePage} from './helpers/home-page'; +import { ProjectPage } from './helpers/project-page'; + +// Test configuration +const config = getTestConfig(); +let fwLiteLauncher: FwLiteLauncher; +let testProject: TestProject; +let testEntry: TestEntry; +let testId: string; + +/** + * Test suite setup and teardown + */ +test.describe('FW Lite Integration Tests', () => { + test.beforeAll(async () => { + console.log('Setting up FW Lite Integration Test Suite'); + + // Validate test configuration + validateTestDataConfiguration(config.testData.projectCode); + + // Get test project configuration + testProject = getTestProject(config.testData.projectCode); + + // Generate unique test identifier + testId = generateUniqueIdentifier('integration'); + + // Generate test entry data + testEntry = generateTestEntry(testId, 'basic'); + + console.log('Test configuration:', { + project: testProject.code, + testId, + entry: testEntry.lexeme + }); + }); + + test.beforeEach(async ({ page }, testInfo) => { + console.log('Setting up individual test'); + + // Initialize FW Lite launcher + fwLiteLauncher = new FwLiteLauncher(); + + // Launch FW Lite application + await fwLiteLauncher.launch({ + binaryPath: config.fwLite.binaryPath, + serverUrl: `${config.lexboxServer.protocol}://${config.lexboxServer.hostname}:${config.lexboxServer.port}`, + timeout: config.fwLite.launchTimeout, + logFile: testInfo.outputPath('fw-lite-server.log'), + }); + + console.log(`FW Lite launched at: ${fwLiteLauncher.getBaseUrl()}`); + + await page.goto(fwLiteLauncher.getBaseUrl()); + await page.waitForLoadState('networkidle'); + + console.log('FW Lite application is ready for testing'); + }); + + test.afterEach(async ({ page }) => { + console.log('Cleaning up individual test'); + + try { + + // Logout from server + await logoutFromServer(page, config.lexboxServer); + } catch (error) { + console.warn('Cleanup warning:', error); + } + + await deleteProject(page, 'sena-3'); + + // Shutdown FW Lite application + if (fwLiteLauncher) { + await fwLiteLauncher.shutdown(); + console.log('FW Lite application shut down'); + } + }); + + test.afterAll(async () => { + console.log('Cleaning up test suite'); + + // Clean up test data + try { + cleanupTestData(testProject.code, [testId]); + console.log('Test data cleanup completed'); + } catch (error) { + console.warn('Test data cleanup warning:', error); + } + }); + /** + * Smoke test: Basic application launch and connectivity + */ + test('Smoke test: Application launch and server connectivity', async ({ page }) => { + const homePage = new HomePage(page); + await test.step('Verify application is accessible', async () => { + await homePage.waitFor(); + }); + + await test.step('Verify server connectivity', async () => { + // Attempt login to verify server connection + await homePage.ensureLoggedIn(config.lexboxServer, config.testData.testUser, config.testData.testPassword); + + expect(await homePage.serverProjects(config.lexboxServer).count()).toBeGreaterThan(0); + }); + }); + + /** + * Project download test: Isolated project download verification + */ + test('Project download: Download and verify project structure', async ({ page }) => { + test.setTimeout(3 * 60 * 1000); + const homePage = new HomePage(page); + + await homePage.waitFor(); + await homePage.ensureLoggedIn(config.lexboxServer, config.testData.testUser, config.testData.testPassword); + + // sena-3 in the freshly-seeded kind cluster has no ServerCommits, so the FwLite + // home page filters it out of the listed projects. Trigger an initial sync via + // the lexbox API so it shows up as a downloadable CRDT project. + await ensureProjectCrdtReady(page, config.lexboxServer, 'sena-3'); + + // After triggering sync server-side, refresh FwLite's view of remote projects + // and wait for sena-3 to appear in the list. + await page.reload(); + await homePage.waitFor(); + + await homePage.downloadProject(config.lexboxServer, 'sena-3'); + + await homePage.openLocalProject('sena-3'); + + const projectPage = new ProjectPage(page, 'sena-3'); + await projectPage.waitFor(); + }); +}); diff --git a/frontend/viewer/tests/e2e/global-setup.ts b/frontend/viewer/tests/e2e/global-setup.ts new file mode 100644 index 0000000000..090aae4f97 --- /dev/null +++ b/frontend/viewer/tests/e2e/global-setup.ts @@ -0,0 +1,59 @@ +/** + * Global Setup for FW Lite E2E Tests + * + * This file handles global test setup operations that need to run once + * before all tests in the suite. + */ + +import { getTestConfig } from './config'; +import { validateTestDataConfiguration } from './helpers/test-data'; + +async function globalSetup() { + console.log('🚀 Starting FW Lite E2E Test Suite Global Setup'); + + const config = getTestConfig(); + + try { + // Validate test configuration + console.log('📋 Validating test configuration...'); + console.log('Test Config:', { + server: config.lexboxServer.hostname, + project: config.testData.projectCode, + user: config.testData.testUser, + binaryPath: config.fwLite.binaryPath + }); + + // Validate test data configuration + console.log('🔍 Validating test data configuration...'); + validateTestDataConfiguration(config.testData.projectCode); + + // Check if FW Lite binary exists + console.log('🔧 Checking FW Lite binary availability...'); + const fs = await import('node:fs/promises'); + try { + await fs.access(config.fwLite.binaryPath); + console.log('✅ FW Lite binary found at:', config.fwLite.binaryPath); + } catch (error) { + console.warn('⚠️ FW Lite binary not found at:', config.fwLite.binaryPath); + console.warn(' Tests will fail if binary is not available during execution'); + console.warn(' Error:', error); + throw error; + } + + // Log test environment information + console.log('🌍 Test Environment Information:'); + console.log(' - Lexbox Server:', `${config.lexboxServer.protocol}://${config.lexboxServer.hostname}`); + console.log(' - Project:', config.testData.projectCode); + console.log(' - Test User:', config.testData.testUser); + console.log(' - Binary Path:', config.fwLite.binaryPath); + console.log(' - CI Mode:', !!process.env.CI); + + console.log('✅ Global setup completed successfully'); + + } catch (error) { + console.error('❌ Global setup failed:', error); + throw error; + } +} + +export default globalSetup; diff --git a/frontend/viewer/tests/e2e/global-teardown.ts b/frontend/viewer/tests/e2e/global-teardown.ts new file mode 100644 index 0000000000..b05381291e --- /dev/null +++ b/frontend/viewer/tests/e2e/global-teardown.ts @@ -0,0 +1,44 @@ +/** + * Global Teardown for FW Lite E2E Tests + * + * This file handles global test teardown operations that need to run once + * after all tests in the suite have completed. + */ + +import { getTestConfig } from './config'; +import { cleanupAllTestData, getActiveTestIds } from './helpers/test-data'; + +async function globalTeardown() { + console.log('🧹 Starting FW Lite E2E Test Suite Global Teardown'); + + const config = getTestConfig(); + + try { + // Clean up any remaining test data + console.log('🗑️ Cleaning up test data...'); + const activeIds = getActiveTestIds(); + + if (activeIds.length > 0) { + console.log(` Found ${activeIds.length} active test entries to clean up`); + await cleanupAllTestData(config.testData.projectCode); + console.log('✅ Test data cleanup completed'); + } else { + console.log(' No active test data found to clean up'); + } + + // Log test completion summary + console.log('📊 Test Suite Summary:'); + console.log(' - Project:', config.testData.projectCode); + console.log(' - Cleaned up entries:', activeIds.length); + console.log(' - CI Mode:', !!process.env.CI); + + console.log('✅ Global teardown completed successfully'); + + } catch (error) { + console.error('❌ Global teardown failed:', error); + // Don't throw error in teardown to avoid masking test failures + console.warn(' Continuing despite teardown errors...'); + } +} + +export default globalTeardown; diff --git a/frontend/viewer/tests/e2e/helpers/fw-lite-launcher.ts b/frontend/viewer/tests/e2e/helpers/fw-lite-launcher.ts new file mode 100644 index 0000000000..d4fd05530b --- /dev/null +++ b/frontend/viewer/tests/e2e/helpers/fw-lite-launcher.ts @@ -0,0 +1,254 @@ +/** + * FW Lite Application Launcher + * + * Manages the FW Lite application lifecycle during tests. + * Handles launching, health checking, and shutting down the FW Lite application. + */ + +import { spawn, type ChildProcess } from 'node:child_process'; +import { access, constants } from 'node:fs/promises'; +import { platform } from 'node:os'; +import type { FwLiteManager, LaunchConfig } from '../types'; + +export class FwLiteLauncher implements FwLiteManager { + private process: ChildProcess | null = null; + private baseUrl = ''; + private port = 0; + private isHealthy = false; + + /** + * Launch the FW Lite application + */ + async launch(config: LaunchConfig): Promise { + if (this.process) { + throw new Error('FW Lite is already running. Call shutdown() first.'); + } + + // Validate binary exists and is executable + await this.validateBinary(config.binaryPath); + + // Find available port + this.port = config.port || await this.findAvailablePort(5000); + this.baseUrl = `http://localhost:${this.port}`; + + // Launch the application + await this.launchProcess(config); + + // Wait for application to be ready + await this.waitForHealthy(config.timeout || 30000); + } + + /** + * Shutdown the FW Lite application + */ + async shutdown(): Promise { + if (!this.process) { + return; + } + + this.isHealthy = false; + + // Try graceful shutdown first + if (platform() === 'win32') { + //windows sucks https://stackoverflow.com/a/41976985/1620542 + this.process.stdin?.write('shutdown\n'); + this.process.stdin?.end(); + } else { + this.process.kill('SIGTERM'); + } + + // Wait for graceful shutdown + const shutdownPromise = new Promise((resolve) => { + if (!this.process) { + resolve(); + return; + } + + this.process.on('exit', () => { + resolve(); + }); + }); + + // Force kill after timeout + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => { + if (this.process && !this.process.killed) { + this.process.kill('SIGKILL'); + } + resolve(); + }, 10000); // 10 second timeout + }); + + await Promise.race([shutdownPromise, timeoutPromise]); + + this.process = null; + this.baseUrl = ''; + this.port = 0; + } + + /** + * Check if the application is running + */ + isRunning(): boolean { + return this.process !== null && !this.process.killed && this.isHealthy; + } + + /** + * Get the base URL of the running application + */ + getBaseUrl(): string { + if (!this.isRunning()) { + throw new Error('FW Lite is not running'); + } + return this.baseUrl; + } + + /** + * Validate that the binary exists and is executable + */ + private async validateBinary(binaryPath: string): Promise { + try { + await access(binaryPath, constants.F_OK | constants.X_OK); + } catch (error) { + throw new Error(`FW Lite binary not found or not executable: ${binaryPath}. Error: ${error}`); + } + } + + /** + * Find an available port starting from the given port + */ + private async findAvailablePort(startPort: number): Promise { + const net = await import('node:net'); + + return new Promise((resolve, reject) => { + const server = net.createServer(); + + server.listen(startPort, () => { + const port = (server.address() as any)?.port; + server.close(() => { + resolve(port); + }); + }); + + server.on('error', (err: any) => { + if (err.code === 'EADDRINUSE') { + // Port is in use, try next one + this.findAvailablePort(startPort + 1).then(resolve).catch(reject); + } else { + reject(err); + } + }); + }); + } + + /** + * Launch the FW Lite process + */ + private async launchProcess(config: LaunchConfig): Promise { + return new Promise((resolve, reject) => { + const args = [ + '--urls', this.baseUrl, + '--Auth:LexboxServers:0:Authority', config.serverUrl, + '--Auth:LexboxServers:0:DisplayName', 'e2e test server', + '--FwLiteWeb:OpenBrowser', 'false', + '--environment', 'Development',//required to allow oauth to accept self signed certs + '--FwLite:UseDevAssets', 'false',//in dev env we'd use dev assets normally + ]; + if (config.logFile) { + args.push('--FwLiteWeb:LogFileName', config.logFile); + } + + this.process = spawn(config.binaryPath, args, { + stdio: ['pipe', 'pipe', 'pipe'], + detached: false, + }); + + // Handle process events + this.process.on('error', (error) => { + reject(new Error(`Failed to start FW Lite: ${error.message}`)); + }); + + this.process.on('exit', (code, signal) => { + if (code !== 0 && code !== null) { + reject(new Error(`FW Lite exited with code ${code}`)); + } else if (signal) { + reject(new Error(`FW Lite was killed with signal ${signal}`)); + } + }); + + // Capture stdout/stderr for debugging + if (this.process.stdout) { + this.process.stdout.on('data', (data) => { + const output = data.toString(); + // Look for startup indicators + if (output.includes('Now listening on:') || output.includes('Application started')) { + resolve(); + } + }); + } + + if (this.process.stderr) { + this.process.stderr.on('data', (data) => { + console.error('FW Lite stderr:', data.toString()); + }); + } + + // Fallback timeout for process startup + setTimeout(() => { + resolve(); + }, 5000); + }); + } + + /** + * Wait for the application to be healthy and responsive + */ + private async waitForHealthy(timeout: number): Promise { + const startTime = Date.now(); + const checkInterval = 1000; // Check every second + + while (Date.now() - startTime < timeout) { + try { + const isHealthy = await this.performHealthCheck(); + if (isHealthy) { + this.isHealthy = true; + return; + } + } catch (error) { + // Health check failed, continue waiting + } + + await new Promise(resolve => setTimeout(resolve, checkInterval)); + } + + throw new Error(`FW Lite failed to become healthy within ${timeout}ms`); + } + + /** + * Perform a health check on the running application + */ + private async performHealthCheck(): Promise { + try { + // Try to fetch a basic endpoint to verify the app is responding + const response = await fetch(`${this.baseUrl}/health`, { + method: 'GET', + signal: AbortSignal.timeout(5000), // 5 second timeout + }); + + return response.ok; + } catch (error) { + // If /health doesn't exist, try the root endpoint + try { + const response = await fetch(this.baseUrl, { + method: 'GET', + signal: AbortSignal.timeout(5000), + }); + + // Accept any response that isn't a connection error + return response.status < 500; + } catch (rootError) { + return false; + } + } + } +} diff --git a/frontend/viewer/tests/e2e/helpers/home-page.ts b/frontend/viewer/tests/e2e/helpers/home-page.ts new file mode 100644 index 0000000000..581d3536b0 --- /dev/null +++ b/frontend/viewer/tests/e2e/helpers/home-page.ts @@ -0,0 +1,125 @@ +import {expect, type Page} from '@playwright/test'; +import type {E2ETestConfig} from '../types'; +import {LoginPage} from '../../../../tests/pages/loginPage'; + +type Server = E2ETestConfig['lexboxServer']; + +// FW Lite uses Uri.Authority (host[:port]) as the server.id, so the rendered +// element id always includes the port. Use an attribute selector — `#host:port` +// would parse the `:port` as a CSS pseudo-class. +function serverElementSelector(server: Server) { + return `[id="${server.hostname}:${server.port}"]`; +} + +function serverLoginUrlPrefix(server: Server) { + return `${server.protocol}://${server.hostname}:${server.port}/login`; +} + +export class HomePage { + + + constructor(private page: Page) { + } + + public async waitFor() { + await this.page.waitForLoadState('load'); + await expect(this.page.getByRole('heading', {name: 'Dictionaries'})).toBeVisible(); + } + + public serverSection(server: Server) { + return this.page.locator(serverElementSelector(server)); + } + + public userIndicator(server: Server) { + return this.serverSection(server).locator(`.i-mdi-account-circle`); + } + + public loginButton(server: Server) { + return this.serverSection(server).locator(`a:has-text("Login")`); + } + + public serverProjects(server: Server) { + return this.serverSection(server).getByRole('row'); + } + + public localProjects() { + return this.page.locator('#local-projects'); + } + + public async ensureLoggedIn(server: Server, username: string, password: string) { + await this.serverSection(server).waitFor({state: 'visible'}); + const isLoggedIn = await this.userIndicator(server).isVisible(); + + if (isLoggedIn) { + console.log('User already logged in, skipping login process'); + return; + } // Look for login button or link + const loginButton = this.loginButton(server); + + await loginButton.waitFor({state: 'visible'}); + await loginButton.click(); + + await expect(this.page).toHaveURL(url => url.href.startsWith(serverLoginUrlPrefix(server))); + + const loginPage = new LoginPage(this.page); + await loginPage.waitFor(); + await loginPage.fillForm(username, password); + await loginPage.submit(); + + // First time a user authorizes a client, lexbox's OpenIddict shows a consent + // prompt. Fresh kind cluster in CI = always first time. Auto-approved auth + // skips this step (e.g. local dev with prior consent). + const authorizeButton = this.page.getByRole('button', {name: /^Authorize\s/i}); + try { + await authorizeButton.click({timeout: 15000}); + } catch { + // No consent prompt — already authorized + } + + await this.userIndicator(server).waitFor({state: 'visible'}); + } + + public async ensureLoggedOut(server: Server) { + await this.serverSection(server).waitFor({state: 'visible'}); + const isLoggedIn = await this.userIndicator(server).isVisible(); + + if (!isLoggedIn) { + console.log('User already logged out, skipping logout process'); + return; + } + + await this.userIndicator(server).click(); + + const logoutButton = this.page.getByRole('menuitem', {name: 'Logout'}); + await logoutButton.click(); + + await this.loginButton(server).waitFor({state: 'visible'}); + } + + async downloadProject(server: Server, projectCode: string) { + // FwLite caches the per-server projects list (LexboxProjectService uses + // IMemoryCache). Page reload doesn't clear it — only the per-server + // "Refresh Projects" button does (it passes force=true). Click it so the + // list reflects any server-side changes (e.g. a CRDT sync that just ran). + const refreshBtn = this.serverSection(server).getByRole('button', {name: 'Refresh Projects'}); + if (await refreshBtn.isVisible().catch(() => false)) { + await refreshBtn.click(); + } + + const projectRow = this.serverProjects(server).filter({hasText: projectCode}).first(); + await projectRow.waitFor({state: 'visible', timeout: 30_000}); + await projectRow.click(); + + // Loading indicator appears while download/sync is in progress + const progressIndicator = this.page.locator('.i-mdi-loading'); + await progressIndicator.waitFor({state: 'visible', timeout: 30_000}); + await progressIndicator.waitFor({state: 'detached', timeout: 60_000}); + + // Verify the project landed in the local section + await expect(this.localProjects().getByText(projectCode)).toBeVisible(); + } + + async openLocalProject(projectCode: string) { + await this.localProjects().getByText(`${projectCode}`).click(); + } +} diff --git a/frontend/viewer/tests/e2e/helpers/project-operations.ts b/frontend/viewer/tests/e2e/helpers/project-operations.ts new file mode 100644 index 0000000000..c46aea1272 --- /dev/null +++ b/frontend/viewer/tests/e2e/helpers/project-operations.ts @@ -0,0 +1,101 @@ +/** + * Project Operations Helper + * + * This module provides functions for project download automation and management. + * It handles UI interactions for downloading projects, creating entries, and verifying data. + */ + +import {type Page} from '@playwright/test'; +import type {E2ETestConfig} from '../types'; +import {HomePage} from './home-page'; + + +/** + * Login to the LexBox server + * Handles authentication before accessing server resources + * + * @param page - Playwright page object + * @param username - Username for authentication + * @param password - Password for authentication + * @throws Error if login fails + */ +export async function loginToServer(page: Page, username: string, password: string, server: E2ETestConfig['lexboxServer']): Promise { + console.log(`Attempting to login as user: ${username}`); + const homePage = new HomePage(page); + await homePage.ensureLoggedIn(server, username, password); +} + +/** + * Logout from the LexBox server + * Clears authentication state + * + * @param page - Playwright page object + */ +export async function logoutFromServer(page: Page, server: E2ETestConfig['lexboxServer']): Promise { + console.log('Attempting to logout'); + + const homePage = new HomePage(page); + await homePage.ensureLoggedOut(server); +} + +/** + * Delete a local project copy + * + * @param page - Playwright page object + * @param projectCode - Code of the project to delete + * @throws Error if deletion fails + */ +export async function deleteProject(page: Page, projectCode: string): Promise { + const origin = new URL(page.url()).origin; + await page.request.delete(`${origin}/api/crdt/${projectCode}`).catch(() => { + }); +} + +/** + * Project IDs of seed-data projects in the lexbox kind cluster. + * These match the constants in `backend/LexData/SeedingData.cs`. We hardcode + * here because `/api/crdt/lookupProjectId` returns 406 when the project has + * no CRDT commits yet — i.e. exactly the state we need to resolve. + */ +export const SEEDED_PROJECT_IDS: Record = { + 'sena-3': '0ebc5976-058d-4447-aaa7-297f8569f968', + 'elawa': '9e972940-8a8e-4b29-a609-bdc2f93b3507', + 'empty': '762b50e8-2e09-4ed4-a48d-775e1ada78e8', +}; + +/** + * Make sure the given project has a server-side CRDT representation. + * + * In a freshly-seeded lexbox (e.g. a kind cluster spun up for CI) projects + * exist in the database with FwData metadata only — no `ServerCommit` rows. + * The FwLite home page filters listed projects to ones with `IsCrdtProject=true` + * (i.e. ones with at least one CRDT commit), so the project is invisible until + * an initial sync runs. + * + * This calls lexbox's `/api/fw-lite/sync/trigger/{id}` endpoint (browser cookie + * auth — `LexboxApi` scope satisfies the `RequireScope(SendAndReceive, + * exclusive: false)` check; the test user must have at least Editor role on + * the project) and waits for the sync to finish. + */ +export async function ensureProjectCrdtReady( + page: Page, + server: E2ETestConfig['lexboxServer'], + projectCode: string, +): Promise { + const lexboxBase = `${server.protocol}://${server.hostname}:${server.port}`; + const projectId = SEEDED_PROJECT_IDS[projectCode]; + if (!projectId) { + throw new Error(`Unknown project code "${projectCode}". Add to SEEDED_PROJECT_IDS or use a project that's already a CRDT project.`); + } + + const trigger = await page.request.post(`${lexboxBase}/api/fw-lite/sync/trigger/${projectId}`); + if (!trigger.ok()) { + throw new Error(`Trigger sync for "${projectCode}" failed: ${trigger.status()} ${await trigger.text()}`); + } + + const finish = await page.request.get(`${lexboxBase}/api/fw-lite/sync/await-sync-finished/${projectId}`, {timeout: 120_000}); + if (!finish.ok()) { + throw new Error(`Await sync finished for "${projectCode}" failed: ${finish.status()} ${await finish.text()}`); + } + console.log(`Initial sync for ${projectCode} finished:`, await finish.text()); +} diff --git a/frontend/viewer/tests/e2e/helpers/project-page.ts b/frontend/viewer/tests/e2e/helpers/project-page.ts new file mode 100644 index 0000000000..13664a9e6e --- /dev/null +++ b/frontend/viewer/tests/e2e/helpers/project-page.ts @@ -0,0 +1,21 @@ +import {expect, type Page} from '@playwright/test'; + +export class ProjectPage { + constructor(private page: Page, private projectCode: string) { + + } + + public async waitFor() { + await this.page.waitForLoadState('load'); + await this.page.locator('.i-mdi-loading').waitFor({state: 'detached'}); + await expect(this.page.locator('.animate-pulse')).toHaveCount(0); + await expect(this.page.getByRole('textbox', {name: 'Filter'})).toBeVisible(); + await expect(this.page.getByRole('button', {name: 'Headword'})).toBeVisible(); + const count = await this.entryRows().count(); + expect(count).toBeGreaterThan(5); + } + + public entryRows() { + return this.page.getByRole('table').getByRole('row'); + } +} diff --git a/frontend/viewer/tests/e2e/helpers/test-data.ts b/frontend/viewer/tests/e2e/helpers/test-data.ts new file mode 100644 index 0000000000..7ca6e86ca6 --- /dev/null +++ b/frontend/viewer/tests/e2e/helpers/test-data.ts @@ -0,0 +1,246 @@ +/** + * Test Data Management + * + * This module provides test data configurations and utilities for E2E tests. + * It manages test projects, entries, and cleanup operations to ensure test isolation. + */ + +import type {TestProject, TestEntry} from '../types'; +import testProjectsData from '../fixtures/test-projects.json' assert {type: 'json'}; + +// Test session identifier for unique test data +const TEST_SESSION_ID = `test-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`; + +/** + * Available test projects with their configurations + */ +export const TEST_PROJECTS: Record = { + 'sena-3': { + code: 'sena-3', + name: 'Sena 3', + expectedEntries: 0, + testUser: 'admin' + } +}; + +/** + * Test entry templates for different types of entries + */ +export const TEST_ENTRY_TEMPLATES = { + basic: { + lexeme: 'test-word', + definition: 'A test word created during E2E testing', + partOfSpeech: 'noun' + }, + verb: { + lexeme: 'test-action', + definition: 'A test action verb created during E2E testing', + partOfSpeech: 'verb' + }, + adjective: { + lexeme: 'test-quality', + definition: 'A test adjective created during E2E testing', + partOfSpeech: 'adjective' + } +}; + +/** + * Active test identifiers for cleanup tracking + */ +const activeTestIds = new Set(); + +/** + * Get test project configuration by project code + * @param projectCode - The project code to retrieve + * @returns TestProject configuration + * @throws Error if project code is not found + */ +export function getTestProject(projectCode: string): TestProject { + const project = TEST_PROJECTS[projectCode]; + if (!project) { + throw new Error(`Test project '${projectCode}' not found. Available projects: ${Object.keys(TEST_PROJECTS).join(', ')}`); + } + return project; +} + +/** + * Generate a test entry with unique identifier + * @param uniqueId - Unique identifier for the entry + * @param template - Template type to use ('basic', 'verb', 'adjective') + * @returns TestEntry with unique data + */ +export function generateTestEntry(uniqueId: string, template: keyof typeof TEST_ENTRY_TEMPLATES = 'basic'): TestEntry { + const baseTemplate = TEST_ENTRY_TEMPLATES[template]; + if (!baseTemplate) { + throw new Error(`Test entry template '${template}' not found. Available templates: ${Object.keys(TEST_ENTRY_TEMPLATES).join(', ')}`); + } + + const entry: TestEntry = { + lexeme: `${baseTemplate.lexeme}-${uniqueId}`, + definition: `${baseTemplate.definition} (ID: ${uniqueId})`, + partOfSpeech: baseTemplate.partOfSpeech, + uniqueIdentifier: uniqueId + }; + + // Track this test ID for cleanup + activeTestIds.add(uniqueId); + + return entry; +} + +/** + * Generate a unique identifier for test data + * Uses session ID and timestamp to ensure uniqueness across test runs + * @param prefix - Optional prefix for the identifier + * @returns Unique identifier string + */ +export function generateUniqueIdentifier(prefix = 'e2e'): string { + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 8); + const uniqueId = `${prefix}-${TEST_SESSION_ID}-${timestamp}-${random}`; + + // Track this ID for cleanup + activeTestIds.add(uniqueId); + + return uniqueId; +} + +/** + * Generate multiple unique identifiers + * @param count - Number of identifiers to generate + * @param prefix - Optional prefix for the identifiers + * @returns Array of unique identifier strings + */ +export function generateUniqueIdentifiers(count: number, prefix = 'e2e'): string[] { + return Array.from({length: count}, () => generateUniqueIdentifier(prefix)); +} + +/** + * Get all test projects from fixtures + * @returns Record of all available test projects + */ +export function getAllTestProjects(): Record { + return TEST_PROJECTS; +} + +/** + * Get test user configuration for a project + * @param projectCode - Project code to get user for + * @returns Test user information + */ +export function getTestUser(projectCode: string): {username: string; role: string} { + const project = getTestProject(projectCode); + const userData = testProjectsData.testUsers[project.testUser as keyof typeof testProjectsData.testUsers]; + + if (!userData) { + throw new Error(`Test user '${project.testUser}' not found for project '${projectCode}'`); + } + + return { + username: userData.username, + role: userData.role + }; +} + +/** + * Get expected project structure for validation + * @param projectCode - Project code to get structure for + * @returns Expected project structure + */ +export function getExpectedProjectStructure(projectCode: string): { + hasLexicon: boolean; + hasGrammar: boolean; + hasTexts: boolean; +} { + const projectData = testProjectsData.projects[projectCode as keyof typeof testProjectsData.projects]; + + if (!projectData) { + throw new Error(`Project structure data not found for '${projectCode}'`); + } + + return projectData.expectedStructure; +} + +/** + * Clean up test data created during test execution + * This function should be called after each test to remove temporary test entries + * @param projectCode - Project code to clean up data from + * @param testIds - Array of test identifiers to clean up + * @returns Promise that resolves when cleanup is complete + */ +export function cleanupTestData(projectCode: string, testIds: string[]): void { + console.log(`Cleaning up test data for project '${projectCode}' with IDs:`, testIds); + + + // In a real implementation, this would make API calls to delete test entries + // For now, we'll simulate the cleanup process + try { + // Simulate API cleanup calls + for (const testId of testIds) { + console.log(`Cleaning up test entry with ID: ${testId}`); + // TODO: Implement actual API calls to delete entries when API is available + // await deleteTestEntry(projectCode, testId); + activeTestIds.delete(testId); + } + + console.log(`Successfully cleaned up ${testIds.length} test entries from project '${projectCode}'`); + } catch (error) { + console.error(`Failed to clean up test data for project '${projectCode}':`, error); + throw new Error(`Test data cleanup failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * Clean up all active test data for the current session + * Should be called at the end of test suite execution + * @param projectCode - Project code to clean up data from + * @returns Promise that resolves when cleanup is complete + */ +export async function cleanupAllTestData(projectCode: string): Promise { + const allActiveIds = Array.from(activeTestIds); + if (allActiveIds.length > 0) { + console.log(`Cleaning up all active test data (${allActiveIds.length} entries) for session: ${TEST_SESSION_ID}`); + await cleanupTestData(projectCode, allActiveIds); + } else { + console.log('No active test data to clean up'); + } +} + +/** + * Get the current test session ID + * @returns Current test session identifier + */ +export function getTestSessionId(): string { + return TEST_SESSION_ID; +} + +/** + * Get all active test IDs for the current session + * @returns Array of active test identifiers + */ +export function getActiveTestIds(): string[] { + return Array.from(activeTestIds); +} + +/** + * Validate test data configuration + * Ensures all required test data is available before running tests + * @param projectCode - Project code to validate + * @throws Error if validation fails + */ +export function validateTestDataConfiguration(projectCode: string): void { + // Check if project exists + const project = getTestProject(projectCode); + + // Check if test user exists + const user = getTestUser(projectCode); + + // Check if project structure data exists + const structure = getExpectedProjectStructure(projectCode); + + console.log(`Test data validation passed for project '${projectCode}':`, { + project: project.name, + user: user.username, + structure: structure + }); +} diff --git a/frontend/viewer/tests/e2e/playwright.config.ts b/frontend/viewer/tests/e2e/playwright.config.ts new file mode 100644 index 0000000000..287861c1e2 --- /dev/null +++ b/frontend/viewer/tests/e2e/playwright.config.ts @@ -0,0 +1,90 @@ +/** + * Playwright Configuration for FW Lite E2E Tests + * + * This configuration is specifically for E2E integration tests that require + * FW Lite application management and extended timeouts. + */ + +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: '.', + testMatch: '**/*.test.ts', + + // E2E tests need more time due to application startup and complex workflows + timeout: 300000, // 5 minutes per test + + expect: { + timeout: 30000, // 30 seconds for assertions + }, + + // Sequential execution to avoid resource conflicts + fullyParallel: false, + workers: 1, + + // Retry failed tests once in CI + retries: process.env.CI ? 1 : 0, + + // Fail fast on CI if test.only is left in code + forbidOnly: !!process.env.CI, + + // Output configuration + outputDir: 'test-results', + + // Reporter configuration + // (HTML output folder must NOT be inside outputDir — playwright errors otherwise.) + reporter: process.env.CI + ? [ + ['github'], + ['list'], + ['junit', { outputFile: 'test-results/e2e-results.xml' }], + ['html', { outputFolder: 'e2e-html-report', open: 'never' }] + ] + : [ + ['list'], + ['html', { outputFolder: 'e2e-html-report', open: 'never' }] + ], + + use: { + // No base URL since we'll be connecting to dynamically launched FW Lite + baseURL: undefined, + + // Extended timeouts for E2E operations + actionTimeout: 30000, // 30 seconds for actions + navigationTimeout: 60000, // 60 seconds for navigation + + // Always capture traces and screenshots for debugging + trace: 'on', + screenshot: 'on', + video: 'retain-on-failure', + + // Browser context settings + viewport: { width: 1280, height: 720 }, + ignoreHTTPSErrors: true, // For self-signed certificates in test environments + + // Storage state for test isolation + storageState: { + cookies: [], + origins: [] + } + }, + + // Browser projects + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + // Use a specific user agent to identify E2E tests + userAgent: 'Playwright E2E Tests - Chrome' + }, + }, + + // Only run on Chrome for E2E tests to reduce complexity and execution time + // Additional browsers can be added later if needed + ], + + // Global setup and teardown + globalSetup: './global-setup', + globalTeardown: './global-teardown', +}); diff --git a/frontend/viewer/tests/e2e/types.ts b/frontend/viewer/tests/e2e/types.ts new file mode 100644 index 0000000000..b47832a83c --- /dev/null +++ b/frontend/viewer/tests/e2e/types.ts @@ -0,0 +1,64 @@ +/** + * TypeScript type definitions for E2E tests + */ + +export interface E2ETestConfig { + lexboxServer: { + hostname: string; + protocol: 'http' | 'https'; + port?: number; + }; + fwLite: { + binaryPath: string; + launchTimeout: number; + shutdownTimeout: number; + }; + testData: { + projectCode: string; + testUser: string; + testPassword: string; + }; + timeouts: { + projectDownload: number; + entryCreation: number; + dataSync: number; + }; +} + +export interface TestProject { + code: string; + name: string; + expectedEntries: number; + testUser: string; +} + +export interface TestEntry { + lexeme: string; + definition: string; + partOfSpeech: string; + uniqueIdentifier: string; +} + +export interface LaunchConfig { + binaryPath: string; + serverUrl: string; + port?: number; + timeout?: number; + logFile?: string; +} + +export interface TestResult { + testName: string; + status: 'passed' | 'failed' | 'skipped'; + duration: number; + error?: string; + screenshots: string[]; + logs: string[]; +} + +export interface FwLiteManager { + launch(config: LaunchConfig): Promise; + shutdown(): Promise; + isRunning(): boolean; + getBaseUrl(): string; +} diff --git a/frontend/viewer/tests/integration/fw-lite-launcher.test.ts b/frontend/viewer/tests/integration/fw-lite-launcher.test.ts new file mode 100644 index 0000000000..349688e298 --- /dev/null +++ b/frontend/viewer/tests/integration/fw-lite-launcher.test.ts @@ -0,0 +1,208 @@ +/** + * Integration tests for FW Lite Application Launcher + * + * These tests run against the real implementation without mocking + * to ensure the launcher works correctly in practice. + */ + +import {existsSync} from 'node:fs'; +import {describe, it, expect, beforeEach, afterEach} from 'vitest'; +import {FwLiteLauncher} from '../e2e/helpers/fw-lite-launcher'; +import type {LaunchConfig} from '../e2e/types'; +import {getTestConfig} from '../e2e/config'; + +// Hard-fail if the binary is missing. Running this suite at all means you've opted in +// (via `pnpm test:integration` or the e2e CI job). Default `pnpm test` filters out the +// integration project, so unrelated workflows aren't burdened with building the binary. +const fwLiteBinaryPath = getTestConfig().fwLite.binaryPath; +if (!existsSync(fwLiteBinaryPath)) { + throw new Error( + `FW Lite binary not found at ${fwLiteBinaryPath}. ` + + `Build it (e.g. \`task -d frontend/viewer test:e2e-setup\`) ` + + `or set FW_LITE_BINARY_PATH to a built binary.` + ); +} + +describe('FwLiteLauncher', () => { + let launcher: FwLiteLauncher; + + beforeEach(() => { + launcher = new FwLiteLauncher(); + }); + + afterEach(async () => { + // Ensure cleanup after each test + if (launcher.isRunning()) { + await launcher.shutdown(); + } + }); + + describe('basic functionality', () => { + it('should return false when not launched', () => { + expect(launcher.isRunning()).toBe(false); + }); + + it('should throw error when getting base URL while not running', () => { + expect(() => launcher.getBaseUrl()).toThrow('FW Lite is not running'); + }); + + it('should handle shutdown when not running', async () => { + await expect(launcher.shutdown()).resolves.not.toThrow(); + expect(launcher.isRunning()).toBe(false); + }); + + it('should throw error if binary does not exist', async () => { + const config: LaunchConfig = { + binaryPath: '/nonexistent/path/to/fw-lite', + serverUrl: 'http://localhost:5137', + port: 5000, + timeout: 1000, + }; + + await expect(launcher.launch(config)).rejects.toThrow( + 'FW Lite binary not found or not executable' + ); + }); + + it('should throw error if already running', async () => { + // Create a fake binary file for testing + const testBinaryPath = './test-fake-binary.js'; + + const fs = await import('node:fs/promises'); + await fs.writeFile(testBinaryPath, '#!/usr/bin/env node\nconsole.log("fake binary");', {mode: 0o755}); + + const config: LaunchConfig = { + binaryPath: testBinaryPath, + serverUrl: 'http://localhost:5137', + port: 5000, + timeout: 1000, + }; + + // First launch should fail because it's not a real FW Lite binary + await expect(launcher.launch(config)).rejects.toThrow(); + + // Clean up + await fs.unlink(testBinaryPath).catch(() => { }); + }, 10000); + }); + + describe('port finding functionality', () => { + it('should be able to find available ports', async () => { + const net = await import('node:net'); + + // Test the port finding logic by creating a server on a port + const server = net.createServer(); + + return new Promise((resolve, reject) => { + server.listen(0, () => { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + + expect(port).toBeGreaterThan(0); + + server.close(() => { + resolve(); + }); + }); + + server.on('error', reject); + }); + }); + }); + + describe('configuration validation', () => { + it('should validate launch configuration parameters', () => { + const validConfig: LaunchConfig = { + binaryPath: '/path/to/fw-lite', + serverUrl: 'http://localhost:5137', + port: 5000, + timeout: 10000, + }; + + // Test that config properties are accessible + expect(validConfig.binaryPath).toBe('/path/to/fw-lite'); + expect(validConfig.serverUrl).toBe('http://localhost:5137'); + expect(validConfig.port).toBe(5000); + expect(validConfig.timeout).toBe(10000); + }); + + it('should handle optional configuration parameters', () => { + const minimalConfig: LaunchConfig = { + binaryPath: '/path/to/fw-lite', + serverUrl: 'http://localhost:5137', + }; + + // Test that optional parameters can be undefined + expect(minimalConfig.port).toBeUndefined(); + expect(minimalConfig.timeout).toBeUndefined(); + }); + }); + + describe('launcher state management', () => { + it('should maintain proper state transitions', () => { + // Initial state + expect(launcher.isRunning()).toBe(false); + + // State should remain consistent + expect(launcher.isRunning()).toBe(false); + expect(launcher.isRunning()).toBe(false); + }); + }); + + describe('real FW Lite server integration', () => { + it('should successfully launch and shutdown real FW Lite server', async () => { + const config: LaunchConfig = { + binaryPath: fwLiteBinaryPath, + serverUrl: 'http://localhost:5137', + port: 5555, // Use a specific port for testing + timeout: 30000, // 30 seconds timeout + }; + + // Launch the server + await launcher.launch(config); + + // Verify it's running + expect(launcher.isRunning()).toBe(true); + expect(launcher.getBaseUrl()).toBe('http://localhost:5555'); + + // Test that we can make a request to the server + try { + const response = await fetch(`${launcher.getBaseUrl()}/health`); + // Accept any response that indicates the server is running + expect(response.status).toBeLessThan(500); + } catch { + // If /health doesn't exist, try the root endpoint + const response = await fetch(launcher.getBaseUrl()); + expect(response.status).toBeLessThan(500); + } + + // Shutdown the server + await launcher.shutdown(); + + // Verify it's stopped + expect(launcher.isRunning()).toBe(false); + }, 60000); // 60 second timeout for this test + + it('should handle multiple launch attempts gracefully', async () => { + const config: LaunchConfig = { + binaryPath: fwLiteBinaryPath, + serverUrl: 'http://localhost:5137', + port: 5556, // Use a different port + timeout: 30000, + }; + + // First launch should succeed + await launcher.launch(config); + expect(launcher.isRunning()).toBe(true); + + // Second launch should fail + await expect(launcher.launch(config)).rejects.toThrow( + 'FW Lite is already running. Call shutdown() first.' + ); + + // Cleanup + await launcher.shutdown(); + expect(launcher.isRunning()).toBe(false); + }, 60000); + }); +}); diff --git a/frontend/viewer/playwright.config.ts b/frontend/viewer/tests/snapshots/playwright.config.ts similarity index 96% rename from frontend/viewer/playwright.config.ts rename to frontend/viewer/tests/snapshots/playwright.config.ts index 99f0384271..0e290793d8 100644 --- a/frontend/viewer/playwright.config.ts +++ b/frontend/viewer/tests/snapshots/playwright.config.ts @@ -1,5 +1,5 @@ import {defineConfig, devices, type ReporterDescription} from '@playwright/test'; -import * as testEnv from '../tests/envVars'; +import * as testEnv from '../../../tests/envVars'; const vitePort = '5173'; const dotnetPort = '5137'; @@ -19,7 +19,7 @@ const ciReporters: ReporterDescription[] = [['github'], ['junit', {outputFile: ' } ]]; export default defineConfig({ - testDir: './tests', + testDir: '.', fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, @@ -42,6 +42,7 @@ export default defineConfig({ use: { baseURL: 'http://localhost:' + serverPort, + ignoreHTTPSErrors: true, /* Local storage to be populated for every test */ storageState: { cookies: [], diff --git a/frontend/viewer/tests/project-view-snapshots.test.ts b/frontend/viewer/tests/snapshots/project-view-snapshots.test.ts similarity index 100% rename from frontend/viewer/tests/project-view-snapshots.test.ts rename to frontend/viewer/tests/snapshots/project-view-snapshots.test.ts diff --git a/frontend/viewer/tests/snapshot.ts b/frontend/viewer/tests/snapshots/snapshot.ts similarity index 100% rename from frontend/viewer/tests/snapshot.ts rename to frontend/viewer/tests/snapshots/snapshot.ts diff --git a/frontend/viewer/tsconfig.node.json b/frontend/viewer/tsconfig.node.json index 494bfe0835..ae3723ec14 100644 --- a/frontend/viewer/tsconfig.node.json +++ b/frontend/viewer/tsconfig.node.json @@ -3,7 +3,8 @@ "composite": true, "skipLibCheck": true, "module": "ESNext", - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "resolveJsonModule": true, }, "include": ["vite.config.ts"] } diff --git a/frontend/viewer/vitest.config.ts b/frontend/viewer/vitest.config.ts index 89e4088cd6..b280800e24 100644 --- a/frontend/viewer/vitest.config.ts +++ b/frontend/viewer/vitest.config.ts @@ -1,4 +1,5 @@ -import {defineConfig} from 'vitest/config'; +import {configDefaults, defineConfig} from 'vitest/config'; + import {fileURLToPath} from 'node:url'; import path from 'node:path'; import {playwright} from '@vitest/browser-playwright'; @@ -10,8 +11,13 @@ const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); const browserTestPattern = '**/*.browser.{test,spec}.?(c|m)[jt]s?(x)'; -const e2eTestPattern = './tests/**'; -const defaultExcludeList = ['**/node_modules/**', '**/dist/**', '**/cypress/**', '**/.{idea,git,cache,output,temp}/**', '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*']; +const integrationTestPattern = './tests/integration/**/*.{test,spec}.?(c|m)[jt]s?(x)'; +const e2eTestPatterns = ['./tests/**']; + +const sharedAlias = [ + {find: '$lib', replacement: '/src/lib'}, + {find: '$project', replacement: '/src/project'}, +]; export default defineConfig({ test: { @@ -24,15 +30,14 @@ export default defineConfig({ name: 'unit', // $effect.root requires a dom. // We can add a node environment test project later if needed. - environment:'jsdom', - exclude: [browserTestPattern, e2eTestPattern, ...defaultExcludeList], - }, - resolve: { - alias: [ - {find: '$lib', replacement: '/src/lib'}, - {find: '$project', replacement: '/src/project'}, - ] + environment: 'jsdom', + exclude: [ + browserTestPattern, + ...e2eTestPatterns, + ...configDefaults.exclude, + ], }, + resolve: {alias: sharedAlias}, }, { plugins: [ @@ -50,13 +55,17 @@ export default defineConfig({ ], }, include: [browserTestPattern], + exclude: [...e2eTestPatterns, ...configDefaults.exclude], }, - resolve: { - alias: [ - {find: '$lib', replacement: '/src/lib'}, - {find: '$project', replacement: '/src/project'}, - ] + resolve: {alias: sharedAlias}, + }, + { + test: { + name: 'integration', + environment: 'node', + include: [integrationTestPattern], }, + resolve: {alias: sharedAlias}, }, { plugins: [ @@ -80,13 +89,8 @@ export default defineConfig({ }, setupFiles: ['./.storybook/vitest.setup.ts'], }, - resolve: { - alias: [ - {find: '$lib', replacement: '/src/lib'}, - {find: '$project', replacement: '/src/project'}, - ] - }, - } + resolve: {alias: sharedAlias}, + }, ], }, }); From 87fe0ec764761c179e53e8a334b5b435d4da0f51 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 8 May 2026 14:44:20 +0200 Subject: [PATCH 02/21] Fix lint --- frontend/viewer/tests/e2e/config.ts | 42 ----------------- .../tests/e2e/fw-lite-integration.test.ts | 4 +- frontend/viewer/tests/e2e/global-teardown.ts | 4 +- .../tests/e2e/helpers/fw-lite-launcher.ts | 46 +++++++++---------- .../viewer/tests/e2e/helpers/test-data.ts | 4 +- 5 files changed, 27 insertions(+), 73 deletions(-) diff --git a/frontend/viewer/tests/e2e/config.ts b/frontend/viewer/tests/e2e/config.ts index d749e77994..911585fafd 100644 --- a/frontend/viewer/tests/e2e/config.ts +++ b/frontend/viewer/tests/e2e/config.ts @@ -44,48 +44,6 @@ export const TEST_PROJECTS: Record = { }, }; -/** - * Test data constants - */ -export const TEST_CONSTANTS = { - // Unique identifier prefix for test entries to avoid conflicts - TEST_ENTRY_PREFIX: 'e2e-test', - - // Default test entry data - DEFAULT_TEST_ENTRY: { - lexeme: 'test-word', - definition: 'A word created during E2E testing', - partOfSpeech: 'noun', - }, - - // Retry configuration for flaky operations - RETRY_CONFIG: { - projectDownload: { attempts: 3, delay: 5000 }, - entryCreation: { attempts: 2, delay: 2000 }, - dataSync: { attempts: 3, delay: 3000 }, - }, - - // UI selectors (to be updated based on actual FW Lite UI) - SELECTORS: { - projectList: '[data-testid="project-list"]', - downloadButton: '[data-testid="download-project"]', - newEntryButton: '[data-testid="new-entry"]', - entryForm: '[data-testid="entry-form"]', - saveButton: '[data-testid="save-entry"]', - searchInput: '[data-testid="search-entries"]', - deleteProjectButton: '[data-testid="delete-project"]', - }, -} as const; - -/** - * Generate unique test identifier - */ -export function generateTestId(): string { - const timestamp = Date.now(); - const random = Math.random().toString(36).substring(2, 8); - return `${TEST_CONSTANTS.TEST_ENTRY_PREFIX}-${timestamp}-${random}`; -} - /** * Get test configuration with environment variable overrides */ diff --git a/frontend/viewer/tests/e2e/fw-lite-integration.test.ts b/frontend/viewer/tests/e2e/fw-lite-integration.test.ts index 635988ff7d..355e453328 100644 --- a/frontend/viewer/tests/e2e/fw-lite-integration.test.ts +++ b/frontend/viewer/tests/e2e/fw-lite-integration.test.ts @@ -36,7 +36,7 @@ let testId: string; * Test suite setup and teardown */ test.describe('FW Lite Integration Tests', () => { - test.beforeAll(async () => { + test.beforeAll(() => { console.log('Setting up FW Lite Integration Test Suite'); // Validate test configuration @@ -100,7 +100,7 @@ test.describe('FW Lite Integration Tests', () => { } }); - test.afterAll(async () => { + test.afterAll(() => { console.log('Cleaning up test suite'); // Clean up test data diff --git a/frontend/viewer/tests/e2e/global-teardown.ts b/frontend/viewer/tests/e2e/global-teardown.ts index b05381291e..4969c8d045 100644 --- a/frontend/viewer/tests/e2e/global-teardown.ts +++ b/frontend/viewer/tests/e2e/global-teardown.ts @@ -8,7 +8,7 @@ import { getTestConfig } from './config'; import { cleanupAllTestData, getActiveTestIds } from './helpers/test-data'; -async function globalTeardown() { +function globalTeardown() { console.log('🧹 Starting FW Lite E2E Test Suite Global Teardown'); const config = getTestConfig(); @@ -20,7 +20,7 @@ async function globalTeardown() { if (activeIds.length > 0) { console.log(` Found ${activeIds.length} active test entries to clean up`); - await cleanupAllTestData(config.testData.projectCode); + cleanupAllTestData(config.testData.projectCode); console.log('✅ Test data cleanup completed'); } else { console.log(' No active test data found to clean up'); diff --git a/frontend/viewer/tests/e2e/helpers/fw-lite-launcher.ts b/frontend/viewer/tests/e2e/helpers/fw-lite-launcher.ts index d4fd05530b..a1c7078000 100644 --- a/frontend/viewer/tests/e2e/helpers/fw-lite-launcher.ts +++ b/frontend/viewer/tests/e2e/helpers/fw-lite-launcher.ts @@ -7,6 +7,7 @@ import { spawn, type ChildProcess } from 'node:child_process'; import { access, constants } from 'node:fs/promises'; +import { createServer, type AddressInfo } from 'node:net'; import { platform } from 'node:os'; import type { FwLiteManager, LaunchConfig } from '../types'; @@ -110,30 +111,29 @@ export class FwLiteLauncher implements FwLiteManager { try { await access(binaryPath, constants.F_OK | constants.X_OK); } catch (error) { - throw new Error(`FW Lite binary not found or not executable: ${binaryPath}. Error: ${error}`); + const message = error instanceof Error ? error.message : String(error); + throw new Error(`FW Lite binary not found or not executable: ${binaryPath}. Error: ${message}`); } } /** * Find an available port starting from the given port */ - private async findAvailablePort(startPort: number): Promise { - const net = await import('node:net'); - + private findAvailablePort(startPort: number): Promise { return new Promise((resolve, reject) => { - const server = net.createServer(); + const server = createServer(); server.listen(startPort, () => { - const port = (server.address() as any)?.port; + const port = (server.address() as AddressInfo).port; server.close(() => { resolve(port); }); }); - server.on('error', (err: any) => { + server.on('error', (err: NodeJS.ErrnoException) => { if (err.code === 'EADDRINUSE') { // Port is in use, try next one - this.findAvailablePort(startPort + 1).then(resolve).catch(reject); + this.findAvailablePort(startPort + 1).then(resolve).catch((reason: unknown) => reject(reason instanceof Error ? reason : new Error(String(reason)))); } else { reject(err); } @@ -177,21 +177,17 @@ export class FwLiteLauncher implements FwLiteManager { }); // Capture stdout/stderr for debugging - if (this.process.stdout) { - this.process.stdout.on('data', (data) => { - const output = data.toString(); - // Look for startup indicators - if (output.includes('Now listening on:') || output.includes('Application started')) { - resolve(); - } - }); - } + this.process.stdout?.on('data', (data: Buffer) => { + const output = data.toString(); + // Look for startup indicators + if (output.includes('Now listening on:') || output.includes('Application started')) { + resolve(); + } + }); - if (this.process.stderr) { - this.process.stderr.on('data', (data) => { - console.error('FW Lite stderr:', data.toString()); - }); - } + this.process.stderr?.on('data', (data: Buffer) => { + console.error('FW Lite stderr:', data.toString()); + }); // Fallback timeout for process startup setTimeout(() => { @@ -214,7 +210,7 @@ export class FwLiteLauncher implements FwLiteManager { this.isHealthy = true; return; } - } catch (error) { + } catch { // Health check failed, continue waiting } @@ -236,7 +232,7 @@ export class FwLiteLauncher implements FwLiteManager { }); return response.ok; - } catch (error) { + } catch { // If /health doesn't exist, try the root endpoint try { const response = await fetch(this.baseUrl, { @@ -246,7 +242,7 @@ export class FwLiteLauncher implements FwLiteManager { // Accept any response that isn't a connection error return response.status < 500; - } catch (rootError) { + } catch { return false; } } diff --git a/frontend/viewer/tests/e2e/helpers/test-data.ts b/frontend/viewer/tests/e2e/helpers/test-data.ts index 7ca6e86ca6..7b9c08bb9c 100644 --- a/frontend/viewer/tests/e2e/helpers/test-data.ts +++ b/frontend/viewer/tests/e2e/helpers/test-data.ts @@ -196,11 +196,11 @@ export function cleanupTestData(projectCode: string, testIds: string[]): void { * @param projectCode - Project code to clean up data from * @returns Promise that resolves when cleanup is complete */ -export async function cleanupAllTestData(projectCode: string): Promise { +export function cleanupAllTestData(projectCode: string): void { const allActiveIds = Array.from(activeTestIds); if (allActiveIds.length > 0) { console.log(`Cleaning up all active test data (${allActiveIds.length} entries) for session: ${TEST_SESSION_ID}`); - await cleanupTestData(projectCode, allActiveIds); + cleanupTestData(projectCode, allActiveIds); } else { console.log('No active test data to clean up'); } From cdda9a816ab0a9dd27ec893f7818654c5dd5b23c Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 8 May 2026 16:12:08 +0200 Subject: [PATCH 03/21] Cleanup --- .github/workflows/fw-lite.yaml | 34 +++++++++++------------------ backend/FwLite/FwLiteWeb/Program.cs | 8 ------- 2 files changed, 13 insertions(+), 29 deletions(-) diff --git a/.github/workflows/fw-lite.yaml b/.github/workflows/fw-lite.yaml index 10e5a54d80..c35e604abd 100644 --- a/.github/workflows/fw-lite.yaml +++ b/.github/workflows/fw-lite.yaml @@ -451,32 +451,29 @@ jobs: e2e-test: name: E2E Tests - # Only depends on frontend (for viewer JS) — does its own linux publish to fast-lane - # CI feedback (no waiting on the long Windows build-and-test job). needs: [frontend] runs-on: ubuntu-latest + env: + FW_LITE_BINARY_PATH: ${{ github.workspace }}/backend/FwLite/artifacts/publish/FwLiteWeb/release_linux-x64/FwLiteWeb steps: - name: Checkout uses: actions/checkout@v4 with: submodules: true - - uses: actions/download-artifact@v4 with: name: fw-lite-viewer-js path: ${{ env.VIEWER_BUILD_OUTPUT_DIR }} - - uses: actions/setup-dotnet@v4 with: dotnet-version: '9.x' - - name: Publish FwLiteWeb (linux-x64) working-directory: backend/FwLite/FwLiteWeb run: dotnet publish -r linux-x64 --artifacts-path ../artifacts -p:PublishSingleFile=true - name: set execute permissions shell: bash - run: chmod +x backend/FwLite/artifacts/publish/FwLiteWeb/release_linux-x64/FwLiteWeb + run: chmod +x ${{ env.FW_LITE_BINARY_PATH }} - name: Install Task uses: arduino/setup-task@b91d5d2c96a56797b48ac1e0e89220bf64044611 #v2 @@ -490,25 +487,22 @@ jobs: node-version-file: './frontend/package.json' cache: 'pnpm' cache-dependency-path: './frontend/pnpm-lock.yaml' - - name: Prepare frontend + - name: Install frontend deps working-directory: frontend - run: | - pnpm install - - name: Test fw lite launcher + run: pnpm install + - name: Test FwLite launcher working-directory: frontend/viewer - env: - FW_LITE_BINARY_PATH: ${{ github.workspace }}/backend/FwLite/artifacts/publish/FwLiteWeb/release_linux-x64/FwLiteWeb run: task e2e-test-helper-unit-tests - uses: ./.github/actions/setup-k8s with: lexbox-api-tag: develop - ingress-controller-port: '6579' # http; kept for the action's own curl-verify step + ingress-controller-port: '6579' # http; only used by the action's own curl-verify step repo-token: ${{ secrets.GITHUB_TOKEN }} - # MSAL refuses any non-https authority, so we also expose ingress-nginx's - # HTTPS port (snake-oil cert; FwLite trusts it via --environment Development, - # playwright via ignoreHTTPSErrors). + # MSAL refuses any non-https authority. ingress-nginx serves a snake-oil + # cert that FwLite trusts via --environment Development and playwright via + # ignoreHTTPSErrors. - name: Forward ingress HTTPS port shell: bash run: | @@ -531,7 +525,7 @@ jobs: with: path: ~/.cache/ms-playwright key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/pnpm-lock.yaml') }} - - name: Set up Playwright dependencies + - name: Install Playwright browsers if: steps.playwright-cache.outputs.cache-hit != 'true' working-directory: frontend/viewer run: pnpm exec playwright install --with-deps @@ -539,19 +533,17 @@ jobs: - name: Run E2E tests working-directory: frontend/viewer env: - FW_LITE_BINARY_PATH: ${{ github.workspace }}/backend/FwLite/artifacts/publish/FwLiteWeb/release_linux-x64/FwLiteWeb TEST_SERVER_PROTOCOL: https TEST_SERVER_PORT: 6580 run: task test:e2e - - name: Upload Playwright test results and traces (on failure) + - name: Upload Playwright results/traces (on failure) if: failure() uses: actions/upload-artifact@v4 with: name: fw-lite-e2e-test-results if-no-files-found: ignore - path: | - frontend/viewer/tests/e2e/test-results/ + path: frontend/viewer/tests/e2e/test-results/ - name: Capture k8s pod logs (on failure) if: failure() diff --git a/backend/FwLite/FwLiteWeb/Program.cs b/backend/FwLite/FwLiteWeb/Program.cs index 881f9b440f..23d6a827f3 100644 --- a/backend/FwLite/FwLiteWeb/Program.cs +++ b/backend/FwLite/FwLiteWeb/Program.cs @@ -34,13 +34,5 @@ await app.StopAsync(); }); - _ = Task.Run(async () => - { - // Wait for the "shutdown" command from stdin - while (await Console.In.ReadLineAsync() is not "shutdown") { } - - await app.StopAsync(); - }); - await app.WaitForShutdownAsync(); } From e2e7290a4b7cb45b2bcaaa6f5fc7b840ff0711e4 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 8 May 2026 16:12:33 +0200 Subject: [PATCH 04/21] Maybe cleanup? --- frontend/viewer/tests/e2e/README.md | 69 ----- frontend/viewer/tests/e2e/config.ts | 58 +--- .../tests/e2e/fixtures/test-projects.json | 39 --- .../tests/e2e/fw-lite-integration.test.ts | 155 ++-------- frontend/viewer/tests/e2e/global-setup.ts | 59 ---- frontend/viewer/tests/e2e/global-teardown.ts | 44 --- .../tests/e2e/helpers/fw-lite-launcher.ts | 270 +++++------------- .../viewer/tests/e2e/helpers/home-page.ts | 75 ++--- .../tests/e2e/helpers/project-operations.ts | 85 ++---- .../viewer/tests/e2e/helpers/project-page.ts | 11 +- .../viewer/tests/e2e/helpers/test-data.ts | 246 ---------------- .../viewer/tests/e2e/playwright.config.ts | 85 +----- frontend/viewer/tests/e2e/types.ts | 42 +-- .../integration/fw-lite-launcher.test.ts | 230 +++------------ 14 files changed, 214 insertions(+), 1254 deletions(-) delete mode 100644 frontend/viewer/tests/e2e/README.md delete mode 100644 frontend/viewer/tests/e2e/fixtures/test-projects.json delete mode 100644 frontend/viewer/tests/e2e/global-setup.ts delete mode 100644 frontend/viewer/tests/e2e/global-teardown.ts delete mode 100644 frontend/viewer/tests/e2e/helpers/test-data.ts diff --git a/frontend/viewer/tests/e2e/README.md b/frontend/viewer/tests/e2e/README.md deleted file mode 100644 index 89f1c08df4..0000000000 --- a/frontend/viewer/tests/e2e/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# FW Lite E2E Tests - -End-to-end tests for FieldWorks Lite that drive the actual published binary against a real (kind-hosted) LexBox server. - -> **Status: in progress / WIP.** Most of this code was AI-generated; expect rough edges. The first goal is to get a smoke test through CI; the rich integration scenarios will follow. - -## What's tested (so far) - -The suite has two layers: - -### 1. Launcher integration tests — `tests/integration/fw-lite-launcher.test.ts` (Vitest) - -These exercise [`FwLiteLauncher`](./helpers/fw-lite-launcher.ts), the helper that spawns and shuts down the published `FwLiteWeb` binary. They run via vitest (no browser): - -- Synthetic checks: `isRunning()` defaults, error on unknown binary path, error if launched twice, port-finding sanity. -- **Real binary smoke test**: spawn the actual `FwLiteWeb` binary, wait for it to become healthy on `/health`, hit it over HTTP, then shut it down. Repeated with a second port to confirm "already running" behaviour. - -In CI these run *before* the Playwright suite as a fast pre-flight: if the binary won't start at all, fail fast. - -Task entry point: `task e2e-test-helper-unit-tests`. - -### 2. Playwright E2E — `tests/e2e/fw-lite-integration.test.ts` - -These drive a real browser against a real `FwLiteWeb` binary, with a real (kind-cluster-hosted) LexBox server. Two tests so far: - -- **Smoke test: Application launch and server connectivity.** Boots FwLiteWeb, opens it in Chromium, logs in to LexBox, asserts at least one server project is visible. -- **Project download: Download and verify project structure.** Logs in, downloads `sena-3` from the configured server, then opens the downloaded local copy and asserts the project page renders. - -Each test launches its own `FwLiteWeb` process (and shuts it down afterwards via stdin `shutdown` on Windows / SIGTERM elsewhere) and uses an isolated user-data directory so logins don't bleed between tests. After-each cleanup logs out from the server and deletes the local CRDT project copy via `DELETE /api/crdt/{code}` (a helper endpoint added on this branch). - -Task entry point: `task test:e2e`. - -## How CI runs this - -The `e2e-test` job in [`.github/workflows/fw-lite.yaml`](../../../../.github/workflows/fw-lite.yaml): - -1. Depends on `frontend` only — does *not* wait for the Windows `build-and-test` job, so feedback comes ~20+ minutes sooner. -2. Downloads the viewer JS artifact built by `frontend`. -3. Runs `dotnet publish -r linux-x64 -p:PublishSingleFile=true` to produce `FwLiteWeb`. -4. Spins up an in-cluster LexBox via the local `setup-k8s` action (port 6579, HTTP). -5. Runs the launcher unit tests, then the Playwright suite. -6. Uploads `tests/e2e/test-results/` as the `fw-lite-e2e-test-results` artifact on failure (traces, screenshots, videos, FW Lite server log). - -## Backend changes that landed for this - -- `GET /health` (`AddHealthChecks` + `MapHealthChecks`) — used by the launcher for its readiness probe. -- `DELETE /api/crdt/{code}` — used by per-test cleanup so re-running the suite doesn't leave stale local CRDT projects. -- Stdin-triggered shutdown in `FwLiteWeb/Program.cs` — Windows can't send SIGTERM to a child process, so the launcher writes `shutdown\n` to stdin instead. -- `--Auth:LexboxServers:0:Authority` overrides — the test launches `FwLiteWeb` pointed at the kind-hosted LexBox, with `--environment Development` so OAuth accepts the self-signed cert. -- Anchor IDs on `HomeView.svelte` (`#local-projects`) and `Server.svelte` (`#{server.id}`) for stable Playwright selectors. - -## Running locally - -```bash -# Build the FwLiteWeb binary for the host platform -task -d frontend/viewer test:e2e-setup - -# Then either: -task -d frontend/viewer e2e-test-helper-unit-tests # launcher checks only -task -d frontend/viewer test:e2e # full Playwright suite (needs a LexBox server) -``` - -`config.ts` reads from env: `FW_LITE_BINARY_PATH`, `TEST_SERVER_HOSTNAME`, `TEST_SERVER_PORT`, `TEST_USER`, `TEST_DEFAULT_PASSWORD`, `TEST_PROJECT_CODE`. The default binary path (`./dist/fw-lite-server/FwLiteWeb.exe`) assumes Windows; override on Linux/Mac. - -## Known rough edges - -- `helpers/test-data.ts` `cleanupTestData` is a stub — it logs but doesn't actually call any API. -- The default `binaryPath` in `config.ts` is Windows-only (`.exe`); use `FW_LITE_BINARY_PATH` to override. -- Playwright config runs serially (`workers: 1`) — each test owns its own `FwLiteWeb` process, so parallelism would need port coordination. diff --git a/frontend/viewer/tests/e2e/config.ts b/frontend/viewer/tests/e2e/config.ts index 911585fafd..d01fbdec9f 100644 --- a/frontend/viewer/tests/e2e/config.ts +++ b/frontend/viewer/tests/e2e/config.ts @@ -1,68 +1,20 @@ -/** - * E2E Test Configuration and Constants - */ +import type {E2ETestConfig} from './types'; -import type { E2ETestConfig, TestProject } from './types'; - -/** - * Default test configuration - */ -export const DEFAULT_E2E_CONFIG: E2ETestConfig = { +export const testConfig: E2ETestConfig = { lexboxServer: { hostname: process.env.TEST_SERVER_HOSTNAME || 'localhost', - // The CI kind cluster serves HTTP on `ingress-controller-port` (6579). Override - // when pointing at a real https deployment. + // Kind cluster in CI serves HTTP on 6579; override to https/6580 for the + // ingress port-forward (MSAL refuses non-https authorities). protocol: (process.env.TEST_SERVER_PROTOCOL as 'http' | 'https') || 'http', port: process.env.TEST_SERVER_PORT ? parseInt(process.env.TEST_SERVER_PORT) : 6579, }, fwLite: { binaryPath: process.env.FW_LITE_BINARY_PATH || './dist/fw-lite-server/FwLiteWeb.exe', - launchTimeout: 30000, // 30 seconds - shutdownTimeout: 10000, // 10 seconds + launchTimeout: 30_000, }, testData: { projectCode: process.env.TEST_PROJECT_CODE || 'sena-3', testUser: process.env.TEST_USER || 'manager', testPassword: process.env.TEST_DEFAULT_PASSWORD || 'pass', }, - timeouts: { - projectDownload: 60000, // 60 seconds - entryCreation: 30000, // 30 seconds - dataSync: 45000, // 45 seconds - }, -}; - -/** - * Test project configurations - */ -export const TEST_PROJECTS: Record = { - 'sena-3': { - code: 'sena-3', - name: 'Sena 3', - expectedEntries: 0, // Will be updated based on actual project state - testUser: 'admin', - }, }; - -/** - * Get test configuration with environment variable overrides - */ -export function getTestConfig(): E2ETestConfig { - return { - ...DEFAULT_E2E_CONFIG, - lexboxServer: { - ...DEFAULT_E2E_CONFIG.lexboxServer, - hostname: process.env.TEST_SERVER_HOSTNAME || DEFAULT_E2E_CONFIG.lexboxServer.hostname, - }, - fwLite: { - ...DEFAULT_E2E_CONFIG.fwLite, - binaryPath: process.env.FW_LITE_BINARY_PATH || DEFAULT_E2E_CONFIG.fwLite.binaryPath, - }, - testData: { - ...DEFAULT_E2E_CONFIG.testData, - projectCode: process.env.TEST_PROJECT_CODE || DEFAULT_E2E_CONFIG.testData.projectCode, - testUser: process.env.TEST_USER || DEFAULT_E2E_CONFIG.testData.testUser, - testPassword: process.env.TEST_DEFAULT_PASSWORD || DEFAULT_E2E_CONFIG.testData.testPassword, - }, - }; -} diff --git a/frontend/viewer/tests/e2e/fixtures/test-projects.json b/frontend/viewer/tests/e2e/fixtures/test-projects.json deleted file mode 100644 index 07f261e103..0000000000 --- a/frontend/viewer/tests/e2e/fixtures/test-projects.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "projects": { - "sena-3": { - "code": "sena-3", - "name": "Sena 3", - "description": "Test project for E2E testing", - "expectedEntries": 0, - "testUser": "admin", - "permissions": ["read", "write", "delete"], - "mediaFiles": [], - "expectedStructure": { - "hasLexicon": true, - "hasGrammar": false, - "hasTexts": false - } - } - }, - "testUsers": { - "admin": { - "username": "admin", - "role": "admin", - "permissions": ["read", "write", "delete", "admin"], - "projects": ["sena-3"] - } - }, - "testEntries": { - "sample": { - "lexeme": "sample-word", - "definition": "A sample word for testing", - "partOfSpeech": "noun", - "examples": [ - { - "sentence": "This is a sample sentence.", - "translation": "This is a sample translation." - } - ] - } - } -} diff --git a/frontend/viewer/tests/e2e/fw-lite-integration.test.ts b/frontend/viewer/tests/e2e/fw-lite-integration.test.ts index 355e453328..dcacc2ea80 100644 --- a/frontend/viewer/tests/e2e/fw-lite-integration.test.ts +++ b/frontend/viewer/tests/e2e/fw-lite-integration.test.ts @@ -1,158 +1,57 @@ -/** - * FW Lite Integration E2E Tests - * - * This test suite implements the core integration scenarios for FW Lite and LexBox. - * It tests the complete workflow: download project, create entry, delete local copy, - * re-download, and verify entry persistence. - */ - import {expect, test} from '@playwright/test'; import {FwLiteLauncher} from './helpers/fw-lite-launcher'; -import { - deleteProject, - ensureProjectCrdtReady, - logoutFromServer, -} from './helpers/project-operations'; -import { - cleanupTestData, - generateTestEntry, - generateUniqueIdentifier, - getTestProject, - validateTestDataConfiguration -} from './helpers/test-data'; -import {getTestConfig} from './config'; -import type {TestEntry, TestProject} from './types'; +import {deleteProject, ensureProjectCrdtReady, logoutFromServer} from './helpers/project-operations'; +import {testConfig} from './config'; import {HomePage} from './helpers/home-page'; -import { ProjectPage } from './helpers/project-page'; +import {ProjectPage} from './helpers/project-page'; -// Test configuration -const config = getTestConfig(); +const {lexboxServer, fwLite, testData} = testConfig; +const lexboxServerUrl = `${lexboxServer.protocol}://${lexboxServer.hostname}:${lexboxServer.port}`; let fwLiteLauncher: FwLiteLauncher; -let testProject: TestProject; -let testEntry: TestEntry; -let testId: string; -/** - * Test suite setup and teardown - */ test.describe('FW Lite Integration Tests', () => { - test.beforeAll(() => { - console.log('Setting up FW Lite Integration Test Suite'); - - // Validate test configuration - validateTestDataConfiguration(config.testData.projectCode); - - // Get test project configuration - testProject = getTestProject(config.testData.projectCode); - - // Generate unique test identifier - testId = generateUniqueIdentifier('integration'); - - // Generate test entry data - testEntry = generateTestEntry(testId, 'basic'); - - console.log('Test configuration:', { - project: testProject.code, - testId, - entry: testEntry.lexeme - }); - }); - - test.beforeEach(async ({ page }, testInfo) => { - console.log('Setting up individual test'); - - // Initialize FW Lite launcher + test.beforeEach(async ({page}, testInfo) => { fwLiteLauncher = new FwLiteLauncher(); - - // Launch FW Lite application await fwLiteLauncher.launch({ - binaryPath: config.fwLite.binaryPath, - serverUrl: `${config.lexboxServer.protocol}://${config.lexboxServer.hostname}:${config.lexboxServer.port}`, - timeout: config.fwLite.launchTimeout, + binaryPath: fwLite.binaryPath, + serverUrl: lexboxServerUrl, + timeout: fwLite.launchTimeout, logFile: testInfo.outputPath('fw-lite-server.log'), }); - - console.log(`FW Lite launched at: ${fwLiteLauncher.getBaseUrl()}`); - await page.goto(fwLiteLauncher.getBaseUrl()); await page.waitForLoadState('networkidle'); - - console.log('FW Lite application is ready for testing'); }); - test.afterEach(async ({ page }) => { - console.log('Cleaning up individual test'); - + test.afterEach(async ({page}) => { try { - - // Logout from server - await logoutFromServer(page, config.lexboxServer); - } catch (error) { - console.warn('Cleanup warning:', error); - } - - await deleteProject(page, 'sena-3'); - - // Shutdown FW Lite application - if (fwLiteLauncher) { + await logoutFromServer(page, lexboxServer); + await deleteProject(page, testData.projectCode); + } finally { await fwLiteLauncher.shutdown(); - console.log('FW Lite application shut down'); } }); - test.afterAll(() => { - console.log('Cleaning up test suite'); - - // Clean up test data - try { - cleanupTestData(testProject.code, [testId]); - console.log('Test data cleanup completed'); - } catch (error) { - console.warn('Test data cleanup warning:', error); - } - }); - /** - * Smoke test: Basic application launch and connectivity - */ - test('Smoke test: Application launch and server connectivity', async ({ page }) => { + test('Smoke test: application launch and server connectivity', async ({page}) => { const homePage = new HomePage(page); - await test.step('Verify application is accessible', async () => { - await homePage.waitFor(); - }); - - await test.step('Verify server connectivity', async () => { - // Attempt login to verify server connection - await homePage.ensureLoggedIn(config.lexboxServer, config.testData.testUser, config.testData.testPassword); + await homePage.waitFor(); - expect(await homePage.serverProjects(config.lexboxServer).count()).toBeGreaterThan(0); - }); + await homePage.ensureLoggedIn(lexboxServer, testData.testUser, testData.testPassword); + expect(await homePage.serverProjects(lexboxServer).count()).toBeGreaterThan(0); }); - /** - * Project download test: Isolated project download verification - */ - test('Project download: Download and verify project structure', async ({ page }) => { - test.setTimeout(3 * 60 * 1000); + test('Project download: download and verify project structure', async ({page}) => { + test.setTimeout(3 * 60_000); const homePage = new HomePage(page); - await homePage.waitFor(); - await homePage.ensureLoggedIn(config.lexboxServer, config.testData.testUser, config.testData.testPassword); - - // sena-3 in the freshly-seeded kind cluster has no ServerCommits, so the FwLite - // home page filters it out of the listed projects. Trigger an initial sync via - // the lexbox API so it shows up as a downloadable CRDT project. - await ensureProjectCrdtReady(page, config.lexboxServer, 'sena-3'); - - // After triggering sync server-side, refresh FwLite's view of remote projects - // and wait for sena-3 to appear in the list. - await page.reload(); - await homePage.waitFor(); - - await homePage.downloadProject(config.lexboxServer, 'sena-3'); + await homePage.ensureLoggedIn(lexboxServer, testData.testUser, testData.testPassword); - await homePage.openLocalProject('sena-3'); + // sena-3 in the freshly-seeded kind cluster has no ServerCommits, so FwLite + // filters it out of the listed projects. Trigger an initial sync server-side + // so it shows up as a downloadable CRDT project. + await ensureProjectCrdtReady(page, lexboxServer, testData.projectCode); - const projectPage = new ProjectPage(page, 'sena-3'); - await projectPage.waitFor(); + await homePage.downloadProject(lexboxServer, testData.projectCode); + await homePage.openLocalProject(testData.projectCode); + await new ProjectPage(page, testData.projectCode).waitFor(); }); }); diff --git a/frontend/viewer/tests/e2e/global-setup.ts b/frontend/viewer/tests/e2e/global-setup.ts deleted file mode 100644 index 090aae4f97..0000000000 --- a/frontend/viewer/tests/e2e/global-setup.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Global Setup for FW Lite E2E Tests - * - * This file handles global test setup operations that need to run once - * before all tests in the suite. - */ - -import { getTestConfig } from './config'; -import { validateTestDataConfiguration } from './helpers/test-data'; - -async function globalSetup() { - console.log('🚀 Starting FW Lite E2E Test Suite Global Setup'); - - const config = getTestConfig(); - - try { - // Validate test configuration - console.log('📋 Validating test configuration...'); - console.log('Test Config:', { - server: config.lexboxServer.hostname, - project: config.testData.projectCode, - user: config.testData.testUser, - binaryPath: config.fwLite.binaryPath - }); - - // Validate test data configuration - console.log('🔍 Validating test data configuration...'); - validateTestDataConfiguration(config.testData.projectCode); - - // Check if FW Lite binary exists - console.log('🔧 Checking FW Lite binary availability...'); - const fs = await import('node:fs/promises'); - try { - await fs.access(config.fwLite.binaryPath); - console.log('✅ FW Lite binary found at:', config.fwLite.binaryPath); - } catch (error) { - console.warn('⚠️ FW Lite binary not found at:', config.fwLite.binaryPath); - console.warn(' Tests will fail if binary is not available during execution'); - console.warn(' Error:', error); - throw error; - } - - // Log test environment information - console.log('🌍 Test Environment Information:'); - console.log(' - Lexbox Server:', `${config.lexboxServer.protocol}://${config.lexboxServer.hostname}`); - console.log(' - Project:', config.testData.projectCode); - console.log(' - Test User:', config.testData.testUser); - console.log(' - Binary Path:', config.fwLite.binaryPath); - console.log(' - CI Mode:', !!process.env.CI); - - console.log('✅ Global setup completed successfully'); - - } catch (error) { - console.error('❌ Global setup failed:', error); - throw error; - } -} - -export default globalSetup; diff --git a/frontend/viewer/tests/e2e/global-teardown.ts b/frontend/viewer/tests/e2e/global-teardown.ts deleted file mode 100644 index 4969c8d045..0000000000 --- a/frontend/viewer/tests/e2e/global-teardown.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Global Teardown for FW Lite E2E Tests - * - * This file handles global test teardown operations that need to run once - * after all tests in the suite have completed. - */ - -import { getTestConfig } from './config'; -import { cleanupAllTestData, getActiveTestIds } from './helpers/test-data'; - -function globalTeardown() { - console.log('🧹 Starting FW Lite E2E Test Suite Global Teardown'); - - const config = getTestConfig(); - - try { - // Clean up any remaining test data - console.log('🗑️ Cleaning up test data...'); - const activeIds = getActiveTestIds(); - - if (activeIds.length > 0) { - console.log(` Found ${activeIds.length} active test entries to clean up`); - cleanupAllTestData(config.testData.projectCode); - console.log('✅ Test data cleanup completed'); - } else { - console.log(' No active test data found to clean up'); - } - - // Log test completion summary - console.log('📊 Test Suite Summary:'); - console.log(' - Project:', config.testData.projectCode); - console.log(' - Cleaned up entries:', activeIds.length); - console.log(' - CI Mode:', !!process.env.CI); - - console.log('✅ Global teardown completed successfully'); - - } catch (error) { - console.error('❌ Global teardown failed:', error); - // Don't throw error in teardown to avoid masking test failures - console.warn(' Continuing despite teardown errors...'); - } -} - -export default globalTeardown; diff --git a/frontend/viewer/tests/e2e/helpers/fw-lite-launcher.ts b/frontend/viewer/tests/e2e/helpers/fw-lite-launcher.ts index a1c7078000..0f9ebf32e8 100644 --- a/frontend/viewer/tests/e2e/helpers/fw-lite-launcher.ts +++ b/frontend/viewer/tests/e2e/helpers/fw-lite-launcher.ts @@ -1,250 +1,136 @@ -/** - * FW Lite Application Launcher - * - * Manages the FW Lite application lifecycle during tests. - * Handles launching, health checking, and shutting down the FW Lite application. - */ +import {spawn, type ChildProcess} from 'node:child_process'; +import {access, constants} from 'node:fs/promises'; +import {createServer, type AddressInfo} from 'node:net'; +import {platform} from 'node:os'; +import type {LaunchConfig} from '../types'; -import { spawn, type ChildProcess } from 'node:child_process'; -import { access, constants } from 'node:fs/promises'; -import { createServer, type AddressInfo } from 'node:net'; -import { platform } from 'node:os'; -import type { FwLiteManager, LaunchConfig } from '../types'; +const SHUTDOWN_TIMEOUT_MS = 10_000; +const HEALTH_CHECK_INTERVAL_MS = 1_000; +const HEALTH_CHECK_REQUEST_TIMEOUT_MS = 5_000; -export class FwLiteLauncher implements FwLiteManager { +/** + * Spawns and manages the FwLiteWeb binary for an e2e test. Each test owns its + * own launcher (and process) so user-data directories don't bleed between tests. + */ +export class FwLiteLauncher { private process: ChildProcess | null = null; private baseUrl = ''; - private port = 0; private isHealthy = false; - /** - * Launch the FW Lite application - */ async launch(config: LaunchConfig): Promise { - if (this.process) { - throw new Error('FW Lite is already running. Call shutdown() first.'); - } - - // Validate binary exists and is executable - await this.validateBinary(config.binaryPath); + if (this.process) throw new Error('FW Lite is already running. Call shutdown() first.'); - // Find available port - this.port = config.port || await this.findAvailablePort(5000); - this.baseUrl = `http://localhost:${this.port}`; + await assertExecutable(config.binaryPath); - // Launch the application - await this.launchProcess(config); + const port = config.port ?? await findAvailablePort(5000); + this.baseUrl = `http://localhost:${port}`; - // Wait for application to be ready - await this.waitForHealthy(config.timeout || 30000); + await this.spawnProcess(config); + await this.waitForHealthy(config.timeout ?? 30_000); } - /** - * Shutdown the FW Lite application - */ async shutdown(): Promise { - if (!this.process) { - return; - } - + if (!this.process) return; this.isHealthy = false; - // Try graceful shutdown first if (platform() === 'win32') { - //windows sucks https://stackoverflow.com/a/41976985/1620542 + // Windows can't SIGTERM a child process; FwLiteWeb listens for "shutdown" on stdin. this.process.stdin?.write('shutdown\n'); this.process.stdin?.end(); } else { this.process.kill('SIGTERM'); } - // Wait for graceful shutdown - const shutdownPromise = new Promise((resolve) => { - if (!this.process) { - resolve(); - return; - } - - this.process.on('exit', () => { - resolve(); - }); - }); - - // Force kill after timeout - const timeoutPromise = new Promise((resolve) => { - setTimeout(() => { - if (this.process && !this.process.killed) { - this.process.kill('SIGKILL'); - } - resolve(); - }, 10000); // 10 second timeout - }); - - await Promise.race([shutdownPromise, timeoutPromise]); + const exited = new Promise(resolve => this.process!.on('exit', () => resolve())); + const timeout = new Promise(resolve => setTimeout(() => { + if (this.process && !this.process.killed) this.process.kill('SIGKILL'); + resolve(); + }, SHUTDOWN_TIMEOUT_MS)); + await Promise.race([exited, timeout]); this.process = null; this.baseUrl = ''; - this.port = 0; } - /** - * Check if the application is running - */ isRunning(): boolean { return this.process !== null && !this.process.killed && this.isHealthy; } - /** - * Get the base URL of the running application - */ getBaseUrl(): string { - if (!this.isRunning()) { - throw new Error('FW Lite is not running'); - } + if (!this.isRunning()) throw new Error('FW Lite is not running'); return this.baseUrl; } - /** - * Validate that the binary exists and is executable - */ - private async validateBinary(binaryPath: string): Promise { - try { - await access(binaryPath, constants.F_OK | constants.X_OK); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`FW Lite binary not found or not executable: ${binaryPath}. Error: ${message}`); - } - } - - /** - * Find an available port starting from the given port - */ - private findAvailablePort(startPort: number): Promise { - return new Promise((resolve, reject) => { - const server = createServer(); - - server.listen(startPort, () => { - const port = (server.address() as AddressInfo).port; - server.close(() => { - resolve(port); - }); - }); - - server.on('error', (err: NodeJS.ErrnoException) => { - if (err.code === 'EADDRINUSE') { - // Port is in use, try next one - this.findAvailablePort(startPort + 1).then(resolve).catch((reason: unknown) => reject(reason instanceof Error ? reason : new Error(String(reason)))); - } else { - reject(err); - } - }); - }); - } + private spawnProcess(config: LaunchConfig): Promise { + const args = [ + '--urls', this.baseUrl, + '--Auth:LexboxServers:0:Authority', config.serverUrl, + '--Auth:LexboxServers:0:DisplayName', 'e2e test server', + '--FwLiteWeb:OpenBrowser', 'false', + // Required so OAuth accepts the kind cluster's self-signed cert. + '--environment', 'Development', + // Override the dev default so we serve the published viewer assets, not vite-served ones. + '--FwLite:UseDevAssets', 'false', + ]; + if (config.logFile) args.push('--FwLiteWeb:LogFileName', config.logFile); - /** - * Launch the FW Lite process - */ - private async launchProcess(config: LaunchConfig): Promise { return new Promise((resolve, reject) => { - const args = [ - '--urls', this.baseUrl, - '--Auth:LexboxServers:0:Authority', config.serverUrl, - '--Auth:LexboxServers:0:DisplayName', 'e2e test server', - '--FwLiteWeb:OpenBrowser', 'false', - '--environment', 'Development',//required to allow oauth to accept self signed certs - '--FwLite:UseDevAssets', 'false',//in dev env we'd use dev assets normally - ]; - if (config.logFile) { - args.push('--FwLiteWeb:LogFileName', config.logFile); - } - - this.process = spawn(config.binaryPath, args, { - stdio: ['pipe', 'pipe', 'pipe'], - detached: false, - }); - - // Handle process events - this.process.on('error', (error) => { - reject(new Error(`Failed to start FW Lite: ${error.message}`)); - }); + this.process = spawn(config.binaryPath, args, {stdio: ['pipe', 'pipe', 'pipe']}); + this.process.on('error', error => reject(new Error(`Failed to start FW Lite: ${error.message}`))); this.process.on('exit', (code, signal) => { - if (code !== 0 && code !== null) { - reject(new Error(`FW Lite exited with code ${code}`)); - } else if (signal) { - reject(new Error(`FW Lite was killed with signal ${signal}`)); - } + if (code !== 0 && code !== null) reject(new Error(`FW Lite exited with code ${code}`)); + else if (signal) reject(new Error(`FW Lite was killed with signal ${signal}`)); }); - - // Capture stdout/stderr for debugging this.process.stdout?.on('data', (data: Buffer) => { const output = data.toString(); - // Look for startup indicators - if (output.includes('Now listening on:') || output.includes('Application started')) { - resolve(); - } - }); - - this.process.stderr?.on('data', (data: Buffer) => { - console.error('FW Lite stderr:', data.toString()); + if (output.includes('Now listening on:') || output.includes('Application started')) resolve(); }); - - // Fallback timeout for process startup - setTimeout(() => { - resolve(); - }, 5000); + this.process.stderr?.on('data', (data: Buffer) => console.error('FW Lite stderr:', data.toString())); }); } - /** - * Wait for the application to be healthy and responsive - */ private async waitForHealthy(timeout: number): Promise { - const startTime = Date.now(); - const checkInterval = 1000; // Check every second - - while (Date.now() - startTime < timeout) { - try { - const isHealthy = await this.performHealthCheck(); - if (isHealthy) { - this.isHealthy = true; - return; - } - } catch { - // Health check failed, continue waiting + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if (await this.healthCheck()) { + this.isHealthy = true; + return; } - - await new Promise(resolve => setTimeout(resolve, checkInterval)); + await new Promise(r => setTimeout(r, HEALTH_CHECK_INTERVAL_MS)); } - throw new Error(`FW Lite failed to become healthy within ${timeout}ms`); } - /** - * Perform a health check on the running application - */ - private async performHealthCheck(): Promise { + private async healthCheck(): Promise { try { - // Try to fetch a basic endpoint to verify the app is responding - const response = await fetch(`${this.baseUrl}/health`, { - method: 'GET', - signal: AbortSignal.timeout(5000), // 5 second timeout - }); - + const response = await fetch(`${this.baseUrl}/health`, {signal: AbortSignal.timeout(HEALTH_CHECK_REQUEST_TIMEOUT_MS)}); return response.ok; } catch { - // If /health doesn't exist, try the root endpoint - try { - const response = await fetch(this.baseUrl, { - method: 'GET', - signal: AbortSignal.timeout(5000), - }); - - // Accept any response that isn't a connection error - return response.status < 500; - } catch { - return false; - } + return false; } } } + +async function assertExecutable(binaryPath: string): Promise { + try { + await access(binaryPath, constants.F_OK | constants.X_OK); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`FW Lite binary not found or not executable: ${binaryPath}. Error: ${message}`); + } +} + +function findAvailablePort(startPort: number): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.listen(startPort, () => { + const {port} = server.address() as AddressInfo; + server.close(() => resolve(port)); + }); + server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') findAvailablePort(startPort + 1).then(resolve, reject); + else reject(err); + }); + }); +} diff --git a/frontend/viewer/tests/e2e/helpers/home-page.ts b/frontend/viewer/tests/e2e/helpers/home-page.ts index 581d3536b0..baac6f5f82 100644 --- a/frontend/viewer/tests/e2e/helpers/home-page.ts +++ b/frontend/viewer/tests/e2e/helpers/home-page.ts @@ -4,9 +4,9 @@ import {LoginPage} from '../../../../tests/pages/loginPage'; type Server = E2ETestConfig['lexboxServer']; -// FW Lite uses Uri.Authority (host[:port]) as the server.id, so the rendered +// FwLite uses Uri.Authority (host[:port]) as the server.id, so the rendered // element id always includes the port. Use an attribute selector — `#host:port` -// would parse the `:port` as a CSS pseudo-class. +// would parse `:port` as a CSS pseudo-class. function serverElementSelector(server: Server) { return `[id="${server.hostname}:${server.port}"]`; } @@ -16,49 +16,38 @@ function serverLoginUrlPrefix(server: Server) { } export class HomePage { + constructor(private page: Page) {} - - constructor(private page: Page) { - } - - public async waitFor() { + async waitFor() { await this.page.waitForLoadState('load'); await expect(this.page.getByRole('heading', {name: 'Dictionaries'})).toBeVisible(); } - public serverSection(server: Server) { + serverSection(server: Server) { return this.page.locator(serverElementSelector(server)); } - public userIndicator(server: Server) { - return this.serverSection(server).locator(`.i-mdi-account-circle`); + userIndicator(server: Server) { + return this.serverSection(server).locator('.i-mdi-account-circle'); } - public loginButton(server: Server) { - return this.serverSection(server).locator(`a:has-text("Login")`); + loginButton(server: Server) { + return this.serverSection(server).locator('a:has-text("Login")'); } - public serverProjects(server: Server) { + serverProjects(server: Server) { return this.serverSection(server).getByRole('row'); } - public localProjects() { + localProjects() { return this.page.locator('#local-projects'); } - public async ensureLoggedIn(server: Server, username: string, password: string) { + async ensureLoggedIn(server: Server, username: string, password: string) { await this.serverSection(server).waitFor({state: 'visible'}); - const isLoggedIn = await this.userIndicator(server).isVisible(); - - if (isLoggedIn) { - console.log('User already logged in, skipping login process'); - return; - } // Look for login button or link - const loginButton = this.loginButton(server); - - await loginButton.waitFor({state: 'visible'}); - await loginButton.click(); + if (await this.userIndicator(server).isVisible()) return; + await this.loginButton(server).click(); await expect(this.page).toHaveURL(url => url.href.startsWith(serverLoginUrlPrefix(server))); const loginPage = new LoginPage(this.page); @@ -68,58 +57,42 @@ export class HomePage { // First time a user authorizes a client, lexbox's OpenIddict shows a consent // prompt. Fresh kind cluster in CI = always first time. Auto-approved auth - // skips this step (e.g. local dev with prior consent). - const authorizeButton = this.page.getByRole('button', {name: /^Authorize\s/i}); - try { - await authorizeButton.click({timeout: 15000}); - } catch { - // No consent prompt — already authorized - } + // (e.g. local dev with prior consent) skips this step. + await this.page.getByRole('button', {name: /^Authorize\s/i}) + .click({timeout: 15_000}) + .catch(() => { /* no consent prompt — already authorized */ }); await this.userIndicator(server).waitFor({state: 'visible'}); } - public async ensureLoggedOut(server: Server) { + async ensureLoggedOut(server: Server) { await this.serverSection(server).waitFor({state: 'visible'}); - const isLoggedIn = await this.userIndicator(server).isVisible(); - - if (!isLoggedIn) { - console.log('User already logged out, skipping logout process'); - return; - } + if (!(await this.userIndicator(server).isVisible())) return; await this.userIndicator(server).click(); - - const logoutButton = this.page.getByRole('menuitem', {name: 'Logout'}); - await logoutButton.click(); - + await this.page.getByRole('menuitem', {name: 'Logout'}).click(); await this.loginButton(server).waitFor({state: 'visible'}); } async downloadProject(server: Server, projectCode: string) { // FwLite caches the per-server projects list (LexboxProjectService uses // IMemoryCache). Page reload doesn't clear it — only the per-server - // "Refresh Projects" button does (it passes force=true). Click it so the - // list reflects any server-side changes (e.g. a CRDT sync that just ran). + // "Refresh Projects" button does (it passes force=true). const refreshBtn = this.serverSection(server).getByRole('button', {name: 'Refresh Projects'}); - if (await refreshBtn.isVisible().catch(() => false)) { - await refreshBtn.click(); - } + if (await refreshBtn.isVisible().catch(() => false)) await refreshBtn.click(); const projectRow = this.serverProjects(server).filter({hasText: projectCode}).first(); await projectRow.waitFor({state: 'visible', timeout: 30_000}); await projectRow.click(); - // Loading indicator appears while download/sync is in progress const progressIndicator = this.page.locator('.i-mdi-loading'); await progressIndicator.waitFor({state: 'visible', timeout: 30_000}); await progressIndicator.waitFor({state: 'detached', timeout: 60_000}); - // Verify the project landed in the local section await expect(this.localProjects().getByText(projectCode)).toBeVisible(); } async openLocalProject(projectCode: string) { - await this.localProjects().getByText(`${projectCode}`).click(); + await this.localProjects().getByText(projectCode).click(); } } diff --git a/frontend/viewer/tests/e2e/helpers/project-operations.ts b/frontend/viewer/tests/e2e/helpers/project-operations.ts index c46aea1272..31c8cadbe1 100644 --- a/frontend/viewer/tests/e2e/helpers/project-operations.ts +++ b/frontend/viewer/tests/e2e/helpers/project-operations.ts @@ -1,101 +1,48 @@ -/** - * Project Operations Helper - * - * This module provides functions for project download automation and management. - * It handles UI interactions for downloading projects, creating entries, and verifying data. - */ - import {type Page} from '@playwright/test'; import type {E2ETestConfig} from '../types'; import {HomePage} from './home-page'; - -/** - * Login to the LexBox server - * Handles authentication before accessing server resources - * - * @param page - Playwright page object - * @param username - Username for authentication - * @param password - Password for authentication - * @throws Error if login fails - */ -export async function loginToServer(page: Page, username: string, password: string, server: E2ETestConfig['lexboxServer']): Promise { - console.log(`Attempting to login as user: ${username}`); - const homePage = new HomePage(page); - await homePage.ensureLoggedIn(server, username, password); -} - -/** - * Logout from the LexBox server - * Clears authentication state - * - * @param page - Playwright page object - */ export async function logoutFromServer(page: Page, server: E2ETestConfig['lexboxServer']): Promise { - console.log('Attempting to logout'); - - const homePage = new HomePage(page); - await homePage.ensureLoggedOut(server); + await new HomePage(page).ensureLoggedOut(server); } -/** - * Delete a local project copy - * - * @param page - Playwright page object - * @param projectCode - Code of the project to delete - * @throws Error if deletion fails - */ export async function deleteProject(page: Page, projectCode: string): Promise { const origin = new URL(page.url()).origin; - await page.request.delete(`${origin}/api/crdt/${projectCode}`).catch(() => { - }); + // Best-effort cleanup; the project may not exist if a test failed before downloading it. + await page.request.delete(`${origin}/api/crdt/${projectCode}`).catch(() => undefined); } -/** - * Project IDs of seed-data projects in the lexbox kind cluster. - * These match the constants in `backend/LexData/SeedingData.cs`. We hardcode - * here because `/api/crdt/lookupProjectId` returns 406 when the project has - * no CRDT commits yet — i.e. exactly the state we need to resolve. - */ -export const SEEDED_PROJECT_IDS: Record = { +// Project IDs match the constants in `backend/LexData/SeedingData.cs`. Hardcoded +// because `/api/crdt/lookupProjectId` returns 406 when a project has no CRDT +// commits yet — exactly the state ensureProjectCrdtReady is here to resolve. +const SEEDED_PROJECT_IDS: Record = { 'sena-3': '0ebc5976-058d-4447-aaa7-297f8569f968', 'elawa': '9e972940-8a8e-4b29-a609-bdc2f93b3507', 'empty': '762b50e8-2e09-4ed4-a48d-775e1ada78e8', }; /** - * Make sure the given project has a server-side CRDT representation. - * - * In a freshly-seeded lexbox (e.g. a kind cluster spun up for CI) projects - * exist in the database with FwData metadata only — no `ServerCommit` rows. - * The FwLite home page filters listed projects to ones with `IsCrdtProject=true` - * (i.e. ones with at least one CRDT commit), so the project is invisible until - * an initial sync runs. - * - * This calls lexbox's `/api/fw-lite/sync/trigger/{id}` endpoint (browser cookie - * auth — `LexboxApi` scope satisfies the `RequireScope(SendAndReceive, - * exclusive: false)` check; the test user must have at least Editor role on - * the project) and waits for the sync to finish. + * In a freshly-seeded lexbox, projects exist with FwData metadata only — no + * `ServerCommit` rows. The FwLite home page filters listed projects to ones + * with `IsCrdtProject=true`, so the project is invisible until an initial sync + * runs. This calls lexbox's `/api/fw-lite/sync/trigger/{id}` endpoint (browser + * cookie auth — `LexboxApi` scope satisfies the `RequireScope(SendAndReceive)` + * check; the test user must have at least Editor role on the project). */ export async function ensureProjectCrdtReady( page: Page, server: E2ETestConfig['lexboxServer'], projectCode: string, ): Promise { - const lexboxBase = `${server.protocol}://${server.hostname}:${server.port}`; const projectId = SEEDED_PROJECT_IDS[projectCode]; if (!projectId) { throw new Error(`Unknown project code "${projectCode}". Add to SEEDED_PROJECT_IDS or use a project that's already a CRDT project.`); } + const lexboxBase = `${server.protocol}://${server.hostname}:${server.port}`; const trigger = await page.request.post(`${lexboxBase}/api/fw-lite/sync/trigger/${projectId}`); - if (!trigger.ok()) { - throw new Error(`Trigger sync for "${projectCode}" failed: ${trigger.status()} ${await trigger.text()}`); - } + if (!trigger.ok()) throw new Error(`Trigger sync for "${projectCode}" failed: ${trigger.status()} ${await trigger.text()}`); const finish = await page.request.get(`${lexboxBase}/api/fw-lite/sync/await-sync-finished/${projectId}`, {timeout: 120_000}); - if (!finish.ok()) { - throw new Error(`Await sync finished for "${projectCode}" failed: ${finish.status()} ${await finish.text()}`); - } - console.log(`Initial sync for ${projectCode} finished:`, await finish.text()); + if (!finish.ok()) throw new Error(`Await sync finished for "${projectCode}" failed: ${finish.status()} ${await finish.text()}`); } diff --git a/frontend/viewer/tests/e2e/helpers/project-page.ts b/frontend/viewer/tests/e2e/helpers/project-page.ts index 13664a9e6e..a238811dd8 100644 --- a/frontend/viewer/tests/e2e/helpers/project-page.ts +++ b/frontend/viewer/tests/e2e/helpers/project-page.ts @@ -1,21 +1,18 @@ import {expect, type Page} from '@playwright/test'; export class ProjectPage { - constructor(private page: Page, private projectCode: string) { + constructor(private page: Page, private projectCode: string) {} - } - - public async waitFor() { + async waitFor() { await this.page.waitForLoadState('load'); await this.page.locator('.i-mdi-loading').waitFor({state: 'detached'}); await expect(this.page.locator('.animate-pulse')).toHaveCount(0); await expect(this.page.getByRole('textbox', {name: 'Filter'})).toBeVisible(); await expect(this.page.getByRole('button', {name: 'Headword'})).toBeVisible(); - const count = await this.entryRows().count(); - expect(count).toBeGreaterThan(5); + expect(await this.entryRows().count()).toBeGreaterThan(5); } - public entryRows() { + entryRows() { return this.page.getByRole('table').getByRole('row'); } } diff --git a/frontend/viewer/tests/e2e/helpers/test-data.ts b/frontend/viewer/tests/e2e/helpers/test-data.ts deleted file mode 100644 index 7b9c08bb9c..0000000000 --- a/frontend/viewer/tests/e2e/helpers/test-data.ts +++ /dev/null @@ -1,246 +0,0 @@ -/** - * Test Data Management - * - * This module provides test data configurations and utilities for E2E tests. - * It manages test projects, entries, and cleanup operations to ensure test isolation. - */ - -import type {TestProject, TestEntry} from '../types'; -import testProjectsData from '../fixtures/test-projects.json' assert {type: 'json'}; - -// Test session identifier for unique test data -const TEST_SESSION_ID = `test-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`; - -/** - * Available test projects with their configurations - */ -export const TEST_PROJECTS: Record = { - 'sena-3': { - code: 'sena-3', - name: 'Sena 3', - expectedEntries: 0, - testUser: 'admin' - } -}; - -/** - * Test entry templates for different types of entries - */ -export const TEST_ENTRY_TEMPLATES = { - basic: { - lexeme: 'test-word', - definition: 'A test word created during E2E testing', - partOfSpeech: 'noun' - }, - verb: { - lexeme: 'test-action', - definition: 'A test action verb created during E2E testing', - partOfSpeech: 'verb' - }, - adjective: { - lexeme: 'test-quality', - definition: 'A test adjective created during E2E testing', - partOfSpeech: 'adjective' - } -}; - -/** - * Active test identifiers for cleanup tracking - */ -const activeTestIds = new Set(); - -/** - * Get test project configuration by project code - * @param projectCode - The project code to retrieve - * @returns TestProject configuration - * @throws Error if project code is not found - */ -export function getTestProject(projectCode: string): TestProject { - const project = TEST_PROJECTS[projectCode]; - if (!project) { - throw new Error(`Test project '${projectCode}' not found. Available projects: ${Object.keys(TEST_PROJECTS).join(', ')}`); - } - return project; -} - -/** - * Generate a test entry with unique identifier - * @param uniqueId - Unique identifier for the entry - * @param template - Template type to use ('basic', 'verb', 'adjective') - * @returns TestEntry with unique data - */ -export function generateTestEntry(uniqueId: string, template: keyof typeof TEST_ENTRY_TEMPLATES = 'basic'): TestEntry { - const baseTemplate = TEST_ENTRY_TEMPLATES[template]; - if (!baseTemplate) { - throw new Error(`Test entry template '${template}' not found. Available templates: ${Object.keys(TEST_ENTRY_TEMPLATES).join(', ')}`); - } - - const entry: TestEntry = { - lexeme: `${baseTemplate.lexeme}-${uniqueId}`, - definition: `${baseTemplate.definition} (ID: ${uniqueId})`, - partOfSpeech: baseTemplate.partOfSpeech, - uniqueIdentifier: uniqueId - }; - - // Track this test ID for cleanup - activeTestIds.add(uniqueId); - - return entry; -} - -/** - * Generate a unique identifier for test data - * Uses session ID and timestamp to ensure uniqueness across test runs - * @param prefix - Optional prefix for the identifier - * @returns Unique identifier string - */ -export function generateUniqueIdentifier(prefix = 'e2e'): string { - const timestamp = Date.now(); - const random = Math.random().toString(36).substring(2, 8); - const uniqueId = `${prefix}-${TEST_SESSION_ID}-${timestamp}-${random}`; - - // Track this ID for cleanup - activeTestIds.add(uniqueId); - - return uniqueId; -} - -/** - * Generate multiple unique identifiers - * @param count - Number of identifiers to generate - * @param prefix - Optional prefix for the identifiers - * @returns Array of unique identifier strings - */ -export function generateUniqueIdentifiers(count: number, prefix = 'e2e'): string[] { - return Array.from({length: count}, () => generateUniqueIdentifier(prefix)); -} - -/** - * Get all test projects from fixtures - * @returns Record of all available test projects - */ -export function getAllTestProjects(): Record { - return TEST_PROJECTS; -} - -/** - * Get test user configuration for a project - * @param projectCode - Project code to get user for - * @returns Test user information - */ -export function getTestUser(projectCode: string): {username: string; role: string} { - const project = getTestProject(projectCode); - const userData = testProjectsData.testUsers[project.testUser as keyof typeof testProjectsData.testUsers]; - - if (!userData) { - throw new Error(`Test user '${project.testUser}' not found for project '${projectCode}'`); - } - - return { - username: userData.username, - role: userData.role - }; -} - -/** - * Get expected project structure for validation - * @param projectCode - Project code to get structure for - * @returns Expected project structure - */ -export function getExpectedProjectStructure(projectCode: string): { - hasLexicon: boolean; - hasGrammar: boolean; - hasTexts: boolean; -} { - const projectData = testProjectsData.projects[projectCode as keyof typeof testProjectsData.projects]; - - if (!projectData) { - throw new Error(`Project structure data not found for '${projectCode}'`); - } - - return projectData.expectedStructure; -} - -/** - * Clean up test data created during test execution - * This function should be called after each test to remove temporary test entries - * @param projectCode - Project code to clean up data from - * @param testIds - Array of test identifiers to clean up - * @returns Promise that resolves when cleanup is complete - */ -export function cleanupTestData(projectCode: string, testIds: string[]): void { - console.log(`Cleaning up test data for project '${projectCode}' with IDs:`, testIds); - - - // In a real implementation, this would make API calls to delete test entries - // For now, we'll simulate the cleanup process - try { - // Simulate API cleanup calls - for (const testId of testIds) { - console.log(`Cleaning up test entry with ID: ${testId}`); - // TODO: Implement actual API calls to delete entries when API is available - // await deleteTestEntry(projectCode, testId); - activeTestIds.delete(testId); - } - - console.log(`Successfully cleaned up ${testIds.length} test entries from project '${projectCode}'`); - } catch (error) { - console.error(`Failed to clean up test data for project '${projectCode}':`, error); - throw new Error(`Test data cleanup failed: ${error instanceof Error ? error.message : 'Unknown error'}`); - } -} - -/** - * Clean up all active test data for the current session - * Should be called at the end of test suite execution - * @param projectCode - Project code to clean up data from - * @returns Promise that resolves when cleanup is complete - */ -export function cleanupAllTestData(projectCode: string): void { - const allActiveIds = Array.from(activeTestIds); - if (allActiveIds.length > 0) { - console.log(`Cleaning up all active test data (${allActiveIds.length} entries) for session: ${TEST_SESSION_ID}`); - cleanupTestData(projectCode, allActiveIds); - } else { - console.log('No active test data to clean up'); - } -} - -/** - * Get the current test session ID - * @returns Current test session identifier - */ -export function getTestSessionId(): string { - return TEST_SESSION_ID; -} - -/** - * Get all active test IDs for the current session - * @returns Array of active test identifiers - */ -export function getActiveTestIds(): string[] { - return Array.from(activeTestIds); -} - -/** - * Validate test data configuration - * Ensures all required test data is available before running tests - * @param projectCode - Project code to validate - * @throws Error if validation fails - */ -export function validateTestDataConfiguration(projectCode: string): void { - // Check if project exists - const project = getTestProject(projectCode); - - // Check if test user exists - const user = getTestUser(projectCode); - - // Check if project structure data exists - const structure = getExpectedProjectStructure(projectCode); - - console.log(`Test data validation passed for project '${projectCode}':`, { - project: project.name, - user: user.username, - structure: structure - }); -} diff --git a/frontend/viewer/tests/e2e/playwright.config.ts b/frontend/viewer/tests/e2e/playwright.config.ts index 287861c1e2..e087dea2eb 100644 --- a/frontend/viewer/tests/e2e/playwright.config.ts +++ b/frontend/viewer/tests/e2e/playwright.config.ts @@ -1,90 +1,31 @@ -/** - * Playwright Configuration for FW Lite E2E Tests - * - * This configuration is specifically for E2E integration tests that require - * FW Lite application management and extended timeouts. - */ - -import { defineConfig, devices } from '@playwright/test'; +import {defineConfig, devices} from '@playwright/test'; export default defineConfig({ testDir: '.', testMatch: '**/*.test.ts', - - // E2E tests need more time due to application startup and complex workflows - timeout: 300000, // 5 minutes per test - - expect: { - timeout: 30000, // 30 seconds for assertions - }, - - // Sequential execution to avoid resource conflicts + // E2E tests boot a real FwLiteWeb process and walk a multi-step flow per test. + timeout: 5 * 60_000, + expect: {timeout: 30_000}, fullyParallel: false, workers: 1, - - // Retry failed tests once in CI retries: process.env.CI ? 1 : 0, - - // Fail fast on CI if test.only is left in code forbidOnly: !!process.env.CI, - - // Output configuration outputDir: 'test-results', - - // Reporter configuration - // (HTML output folder must NOT be inside outputDir — playwright errors otherwise.) + // outputFolder must NOT be inside outputDir; playwright errors otherwise. reporter: process.env.CI - ? [ - ['github'], - ['list'], - ['junit', { outputFile: 'test-results/e2e-results.xml' }], - ['html', { outputFolder: 'e2e-html-report', open: 'never' }] - ] - : [ - ['list'], - ['html', { outputFolder: 'e2e-html-report', open: 'never' }] - ], - + ? [['github'], ['list'], ['junit', {outputFile: 'test-results/e2e-results.xml'}], ['html', {outputFolder: 'e2e-html-report', open: 'never'}]] + : [['list'], ['html', {outputFolder: 'e2e-html-report', open: 'never'}]], use: { - // No base URL since we'll be connecting to dynamically launched FW Lite - baseURL: undefined, - - // Extended timeouts for E2E operations - actionTimeout: 30000, // 30 seconds for actions - navigationTimeout: 60000, // 60 seconds for navigation - - // Always capture traces and screenshots for debugging + actionTimeout: 30_000, + navigationTimeout: 60_000, trace: 'on', screenshot: 'on', video: 'retain-on-failure', - - // Browser context settings - viewport: { width: 1280, height: 720 }, - ignoreHTTPSErrors: true, // For self-signed certificates in test environments - - // Storage state for test isolation - storageState: { - cookies: [], - origins: [] - } + viewport: {width: 1280, height: 720}, + // Kind cluster ingress uses a snake-oil cert. + ignoreHTTPSErrors: true, }, - - // Browser projects projects: [ - { - name: 'chromium', - use: { - ...devices['Desktop Chrome'], - // Use a specific user agent to identify E2E tests - userAgent: 'Playwright E2E Tests - Chrome' - }, - }, - - // Only run on Chrome for E2E tests to reduce complexity and execution time - // Additional browsers can be added later if needed + {name: 'chromium', use: devices['Desktop Chrome']}, ], - - // Global setup and teardown - globalSetup: './global-setup', - globalTeardown: './global-teardown', }); diff --git a/frontend/viewer/tests/e2e/types.ts b/frontend/viewer/tests/e2e/types.ts index b47832a83c..bde189ce9f 100644 --- a/frontend/viewer/tests/e2e/types.ts +++ b/frontend/viewer/tests/e2e/types.ts @@ -1,42 +1,18 @@ -/** - * TypeScript type definitions for E2E tests - */ - export interface E2ETestConfig { lexboxServer: { hostname: string; protocol: 'http' | 'https'; - port?: number; + port: number; }; fwLite: { binaryPath: string; launchTimeout: number; - shutdownTimeout: number; }; testData: { projectCode: string; testUser: string; testPassword: string; }; - timeouts: { - projectDownload: number; - entryCreation: number; - dataSync: number; - }; -} - -export interface TestProject { - code: string; - name: string; - expectedEntries: number; - testUser: string; -} - -export interface TestEntry { - lexeme: string; - definition: string; - partOfSpeech: string; - uniqueIdentifier: string; } export interface LaunchConfig { @@ -46,19 +22,3 @@ export interface LaunchConfig { timeout?: number; logFile?: string; } - -export interface TestResult { - testName: string; - status: 'passed' | 'failed' | 'skipped'; - duration: number; - error?: string; - screenshots: string[]; - logs: string[]; -} - -export interface FwLiteManager { - launch(config: LaunchConfig): Promise; - shutdown(): Promise; - isRunning(): boolean; - getBaseUrl(): string; -} diff --git a/frontend/viewer/tests/integration/fw-lite-launcher.test.ts b/frontend/viewer/tests/integration/fw-lite-launcher.test.ts index 349688e298..08136136d0 100644 --- a/frontend/viewer/tests/integration/fw-lite-launcher.test.ts +++ b/frontend/viewer/tests/integration/fw-lite-launcher.test.ts @@ -1,20 +1,12 @@ -/** - * Integration tests for FW Lite Application Launcher - * - * These tests run against the real implementation without mocking - * to ensure the launcher works correctly in practice. - */ - import {existsSync} from 'node:fs'; -import {describe, it, expect, beforeEach, afterEach} from 'vitest'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; import {FwLiteLauncher} from '../e2e/helpers/fw-lite-launcher'; -import type {LaunchConfig} from '../e2e/types'; -import {getTestConfig} from '../e2e/config'; +import {testConfig} from '../e2e/config'; // Hard-fail if the binary is missing. Running this suite at all means you've opted in -// (via `pnpm test:integration` or the e2e CI job). Default `pnpm test` filters out the -// integration project, so unrelated workflows aren't burdened with building the binary. -const fwLiteBinaryPath = getTestConfig().fwLite.binaryPath; +// (via `pnpm test:integration` or the e2e CI job). The default `pnpm test` excludes +// the integration project so unrelated workflows aren't burdened with building the binary. +const fwLiteBinaryPath = testConfig.fwLite.binaryPath; if (!existsSync(fwLiteBinaryPath)) { throw new Error( `FW Lite binary not found at ${fwLiteBinaryPath}. ` + @@ -25,184 +17,54 @@ if (!existsSync(fwLiteBinaryPath)) { describe('FwLiteLauncher', () => { let launcher: FwLiteLauncher; + beforeEach(() => { launcher = new FwLiteLauncher(); }); + afterEach(async () => { if (launcher.isRunning()) await launcher.shutdown(); }); - beforeEach(() => { - launcher = new FwLiteLauncher(); + it('reports not running before launch', () => { + expect(launcher.isRunning()).toBe(false); + expect(() => launcher.getBaseUrl()).toThrow('FW Lite is not running'); }); - afterEach(async () => { - // Ensure cleanup after each test - if (launcher.isRunning()) { - await launcher.shutdown(); - } + it('shutdown is a no-op when not running', async () => { + await expect(launcher.shutdown()).resolves.not.toThrow(); }); - describe('basic functionality', () => { - it('should return false when not launched', () => { - expect(launcher.isRunning()).toBe(false); - }); - - it('should throw error when getting base URL while not running', () => { - expect(() => launcher.getBaseUrl()).toThrow('FW Lite is not running'); - }); - - it('should handle shutdown when not running', async () => { - await expect(launcher.shutdown()).resolves.not.toThrow(); - expect(launcher.isRunning()).toBe(false); - }); - - it('should throw error if binary does not exist', async () => { - const config: LaunchConfig = { - binaryPath: '/nonexistent/path/to/fw-lite', - serverUrl: 'http://localhost:5137', - port: 5000, - timeout: 1000, - }; - - await expect(launcher.launch(config)).rejects.toThrow( - 'FW Lite binary not found or not executable' - ); - }); - - it('should throw error if already running', async () => { - // Create a fake binary file for testing - const testBinaryPath = './test-fake-binary.js'; - - const fs = await import('node:fs/promises'); - await fs.writeFile(testBinaryPath, '#!/usr/bin/env node\nconsole.log("fake binary");', {mode: 0o755}); - - const config: LaunchConfig = { - binaryPath: testBinaryPath, - serverUrl: 'http://localhost:5137', - port: 5000, - timeout: 1000, - }; - - // First launch should fail because it's not a real FW Lite binary - await expect(launcher.launch(config)).rejects.toThrow(); - - // Clean up - await fs.unlink(testBinaryPath).catch(() => { }); - }, 10000); + it('rejects when the binary does not exist', async () => { + await expect(launcher.launch({ + binaryPath: '/nonexistent/path/to/fw-lite', + serverUrl: 'http://localhost:5137', + timeout: 1000, + })).rejects.toThrow('FW Lite binary not found or not executable'); }); - describe('port finding functionality', () => { - it('should be able to find available ports', async () => { - const net = await import('node:net'); - - // Test the port finding logic by creating a server on a port - const server = net.createServer(); - - return new Promise((resolve, reject) => { - server.listen(0, () => { - const address = server.address(); - const port = typeof address === 'object' && address ? address.port : 0; - - expect(port).toBeGreaterThan(0); - - server.close(() => { - resolve(); - }); - }); - - server.on('error', reject); - }); + it('launches and shuts down the real binary', async () => { + await launcher.launch({ + binaryPath: fwLiteBinaryPath, + serverUrl: 'http://localhost:5137', + port: 5555, + timeout: 30_000, }); - }); - - describe('configuration validation', () => { - it('should validate launch configuration parameters', () => { - const validConfig: LaunchConfig = { - binaryPath: '/path/to/fw-lite', - serverUrl: 'http://localhost:5137', - port: 5000, - timeout: 10000, - }; - - // Test that config properties are accessible - expect(validConfig.binaryPath).toBe('/path/to/fw-lite'); - expect(validConfig.serverUrl).toBe('http://localhost:5137'); - expect(validConfig.port).toBe(5000); - expect(validConfig.timeout).toBe(10000); - }); - - it('should handle optional configuration parameters', () => { - const minimalConfig: LaunchConfig = { - binaryPath: '/path/to/fw-lite', - serverUrl: 'http://localhost:5137', - }; - - // Test that optional parameters can be undefined - expect(minimalConfig.port).toBeUndefined(); - expect(minimalConfig.timeout).toBeUndefined(); - }); - }); - - describe('launcher state management', () => { - it('should maintain proper state transitions', () => { - // Initial state - expect(launcher.isRunning()).toBe(false); - - // State should remain consistent - expect(launcher.isRunning()).toBe(false); - expect(launcher.isRunning()).toBe(false); + expect(launcher.isRunning()).toBe(true); + expect(launcher.getBaseUrl()).toBe('http://localhost:5555'); + + const response = await fetch(`${launcher.getBaseUrl()}/health`); + expect(response.ok).toBe(true); + + await launcher.shutdown(); + expect(launcher.isRunning()).toBe(false); + }, 60_000); + + it('rejects a second launch while already running', async () => { + await launcher.launch({ + binaryPath: fwLiteBinaryPath, + serverUrl: 'http://localhost:5137', + port: 5556, + timeout: 30_000, }); - }); - - describe('real FW Lite server integration', () => { - it('should successfully launch and shutdown real FW Lite server', async () => { - const config: LaunchConfig = { - binaryPath: fwLiteBinaryPath, - serverUrl: 'http://localhost:5137', - port: 5555, // Use a specific port for testing - timeout: 30000, // 30 seconds timeout - }; - - // Launch the server - await launcher.launch(config); - - // Verify it's running - expect(launcher.isRunning()).toBe(true); - expect(launcher.getBaseUrl()).toBe('http://localhost:5555'); - - // Test that we can make a request to the server - try { - const response = await fetch(`${launcher.getBaseUrl()}/health`); - // Accept any response that indicates the server is running - expect(response.status).toBeLessThan(500); - } catch { - // If /health doesn't exist, try the root endpoint - const response = await fetch(launcher.getBaseUrl()); - expect(response.status).toBeLessThan(500); - } - - // Shutdown the server - await launcher.shutdown(); - - // Verify it's stopped - expect(launcher.isRunning()).toBe(false); - }, 60000); // 60 second timeout for this test - - it('should handle multiple launch attempts gracefully', async () => { - const config: LaunchConfig = { - binaryPath: fwLiteBinaryPath, - serverUrl: 'http://localhost:5137', - port: 5556, // Use a different port - timeout: 30000, - }; - - // First launch should succeed - await launcher.launch(config); - expect(launcher.isRunning()).toBe(true); - - // Second launch should fail - await expect(launcher.launch(config)).rejects.toThrow( - 'FW Lite is already running. Call shutdown() first.' - ); - - // Cleanup - await launcher.shutdown(); - expect(launcher.isRunning()).toBe(false); - }, 60000); - }); + await expect(launcher.launch({ + binaryPath: fwLiteBinaryPath, + serverUrl: 'http://localhost:5137', + port: 5557, + })).rejects.toThrow('FW Lite is already running'); + }, 60_000); }); From 1c49043fcd2e571e2a1fcd164c3c45ae88be3e6e Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 8 May 2026 16:31:25 +0200 Subject: [PATCH 05/21] Stabilize FW Lite e2e tests + add CI concurrency cancellation - Poll for entry rows in ProjectPage.waitFor: the table shell renders before all entries hydrate, so a one-shot count was racing. - afterEach: navigate back to home before logout. The download test ends on the project page, where serverSection isn't visible. - fw-lite workflow: cancel-in-progress concurrency group so iterating on a PR doesn't stack up Windows + e2e lanes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/fw-lite.yaml | 7 +++++++ frontend/viewer/tests/e2e/fw-lite-integration.test.ts | 2 ++ frontend/viewer/tests/e2e/helpers/project-page.ts | 3 ++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/fw-lite.yaml b/.github/workflows/fw-lite.yaml index c35e604abd..23b65095d2 100644 --- a/.github/workflows/fw-lite.yaml +++ b/.github/workflows/fw-lite.yaml @@ -25,6 +25,13 @@ on: permissions: contents: read + +# Cancel earlier in-progress runs for the same ref when a new commit lands — +# keeps the e2e/Windows lanes from piling up while iterating on a PR. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: VIEWER_BUILD_OUTPUT_DIR: backend/FwLite/FwLiteShared/wwwroot/viewer jobs: diff --git a/frontend/viewer/tests/e2e/fw-lite-integration.test.ts b/frontend/viewer/tests/e2e/fw-lite-integration.test.ts index dcacc2ea80..df3b6cee5b 100644 --- a/frontend/viewer/tests/e2e/fw-lite-integration.test.ts +++ b/frontend/viewer/tests/e2e/fw-lite-integration.test.ts @@ -24,6 +24,8 @@ test.describe('FW Lite Integration Tests', () => { test.afterEach(async ({page}) => { try { + // Tests may end on a project page; logout/delete operate from the home page. + await page.goto(fwLiteLauncher.getBaseUrl()); await logoutFromServer(page, lexboxServer); await deleteProject(page, testData.projectCode); } finally { diff --git a/frontend/viewer/tests/e2e/helpers/project-page.ts b/frontend/viewer/tests/e2e/helpers/project-page.ts index a238811dd8..94e27d8863 100644 --- a/frontend/viewer/tests/e2e/helpers/project-page.ts +++ b/frontend/viewer/tests/e2e/helpers/project-page.ts @@ -9,7 +9,8 @@ export class ProjectPage { await expect(this.page.locator('.animate-pulse')).toHaveCount(0); await expect(this.page.getByRole('textbox', {name: 'Filter'})).toBeVisible(); await expect(this.page.getByRole('button', {name: 'Headword'})).toBeVisible(); - expect(await this.entryRows().count()).toBeGreaterThan(5); + // Entries hydrate after the table shell renders, so poll instead of one-shotting. + await expect.poll(() => this.entryRows().count()).toBeGreaterThan(5); } entryRows() { From 5747ecbde7f3a26ae370c9bb30531528ba20fc04 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 11 May 2026 11:33:24 +0200 Subject: [PATCH 06/21] Refactor --- .github/workflows/fw-lite.yaml | 18 +- .gitignore | 6 - frontend/viewer/Taskfile.yml | 14 +- frontend/viewer/package.json | 6 +- frontend/viewer/src/home/HomeView.svelte | 2 +- frontend/viewer/tests/browse-page.ts | 33 -- frontend/viewer/tests/e2e/config.ts | 35 +- .../tests/e2e/fw-lite-integration.test.ts | 59 ---- frontend/viewer/tests/e2e/helpers/fixtures.ts | 39 +++ .../tests/e2e/helpers/fw-lite-launcher.ts | 9 +- .../tests/e2e/helpers/project-operations.ts | 8 +- .../viewer/tests/e2e/helpers/project-page.ts | 19 -- .../viewer/tests/e2e/project-download.test.ts | 22 ++ frontend/viewer/tests/e2e/smoke.test.ts | 11 + frontend/viewer/tests/e2e/types.ts | 24 -- frontend/viewer/tests/entries-list.test.ts | 323 ------------------ .../integration/fw-lite-launcher.test.ts | 3 +- .../entries-list.component.ts} | 26 +- .../entry-view.component.ts} | 0 .../home-page.ts => pages/home.page.ts} | 16 +- frontend/viewer/tests/pages/project.page.ts | 35 ++ frontend/viewer/tests/test-utils.ts | 11 - .../back-navigation-persistence.test.ts | 41 +-- frontend/viewer/tests/ui/demo-project.page.ts | 46 +++ frontend/viewer/tests/ui/entries-list.test.ts | 319 +++++++++++++++++ .../viewer/tests/{ => ui}/entry-api-helper.ts | 0 .../{ => ui}/entry-edit-persists.test.ts | 51 +-- .../{snapshots => ui}/playwright.config.ts | 1 - .../snapshots/project-view-snapshots.test.ts | 0 .../tests/{ => ui}/snapshots/snapshot.ts | 0 frontend/viewer/tests/{ => ui}/test.d.ts | 0 31 files changed, 571 insertions(+), 606 deletions(-) delete mode 100644 frontend/viewer/tests/browse-page.ts delete mode 100644 frontend/viewer/tests/e2e/fw-lite-integration.test.ts create mode 100644 frontend/viewer/tests/e2e/helpers/fixtures.ts delete mode 100644 frontend/viewer/tests/e2e/helpers/project-page.ts create mode 100644 frontend/viewer/tests/e2e/project-download.test.ts create mode 100644 frontend/viewer/tests/e2e/smoke.test.ts delete mode 100644 frontend/viewer/tests/e2e/types.ts delete mode 100644 frontend/viewer/tests/entries-list.test.ts rename frontend/viewer/tests/{entries-list-component.ts => pages/entries-list.component.ts} (75%) rename frontend/viewer/tests/{entry-view-component.ts => pages/entry-view.component.ts} (100%) rename frontend/viewer/tests/{e2e/helpers/home-page.ts => pages/home.page.ts} (87%) create mode 100644 frontend/viewer/tests/pages/project.page.ts delete mode 100644 frontend/viewer/tests/test-utils.ts rename frontend/viewer/tests/{ => ui}/back-navigation-persistence.test.ts (53%) create mode 100644 frontend/viewer/tests/ui/demo-project.page.ts create mode 100644 frontend/viewer/tests/ui/entries-list.test.ts rename frontend/viewer/tests/{ => ui}/entry-api-helper.ts (100%) rename frontend/viewer/tests/{ => ui}/entry-edit-persists.test.ts (64%) rename frontend/viewer/tests/{snapshots => ui}/playwright.config.ts (98%) rename frontend/viewer/tests/{ => ui}/snapshots/project-view-snapshots.test.ts (100%) rename frontend/viewer/tests/{ => ui}/snapshots/snapshot.ts (100%) rename frontend/viewer/tests/{ => ui}/test.d.ts (100%) diff --git a/.github/workflows/fw-lite.yaml b/.github/workflows/fw-lite.yaml index 23b65095d2..c83cda9545 100644 --- a/.github/workflows/fw-lite.yaml +++ b/.github/workflows/fw-lite.yaml @@ -23,15 +23,6 @@ on: - main - feat/sync-morph-types #just for now ensure PRs to this branch have checks run -permissions: - contents: read - -# Cancel earlier in-progress runs for the same ref when a new commit lands — -# keeps the e2e/Windows lanes from piling up while iterating on a PR. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - env: VIEWER_BUILD_OUTPUT_DIR: backend/FwLite/FwLiteShared/wwwroot/viewer jobs: @@ -127,9 +118,9 @@ jobs: if: steps.playwright-cache.outputs.cache-hit != 'true' working-directory: frontend run: pnpm exec playwright install --with-deps - - name: Run snapshot tests + - name: Run UI tests working-directory: frontend/viewer - run: task test:snapshot-standalone + run: task test:ui-standalone - name: Build viewer working-directory: frontend/viewer @@ -148,9 +139,6 @@ jobs: path: ${{ env.VIEWER_BUILD_OUTPUT_DIR }} frontend-component-unit-tests: - permissions: - contents: read - checks: write runs-on: ubuntu-latest steps: - name: Checkout @@ -399,8 +387,6 @@ jobs: path: backend/FwLite/artifacts/sign/*.msixbundle create-release: - permissions: - contents: write if: ${{ github.ref_name == 'main' }} environment: name: production diff --git a/.gitignore b/.gitignore index 80a85f546a..6d4c6eca15 100644 --- a/.gitignore +++ b/.gitignore @@ -26,9 +26,6 @@ artifacts/ project-cache.json localResourcesCache/ -# Kiro AI-generated specs (not part of repo source) -.kiro/ - #Verify *.received.* backend/FwLite/FwLiteShared/wwwroot/viewer @@ -41,6 +38,3 @@ failedSyncs/ .idea *.lscache - -# AI session artifacts (ephemeral planning/review documents) -history/ diff --git a/frontend/viewer/Taskfile.yml b/frontend/viewer/Taskfile.yml index 97dfc47dfe..42dad7d978 100644 --- a/frontend/viewer/Taskfile.yml +++ b/frontend/viewer/Taskfile.yml @@ -42,21 +42,21 @@ tasks: test-unit: cmd: pnpm test --project unit - test:snapshot: - desc: 'runs snapshot tests against already running server' - cmd: pnpm run test:snapshots {{.CLI_ARGS}} - test:snapshot-standalone: + test:ui: + desc: 'runs UI tests against already running server' + cmd: pnpm run test:ui {{.CLI_ARGS}} + test:ui-standalone: aliases: [pts] - desc: 'runs snapshot tests and runs dev automatically, run ui mode by calling with -- --ui or use --update-snapshots' + desc: 'runs UI tests and runs dev automatically; --ui for playwright UI mode, --update-snapshots to refresh visual baselines' env: AUTO_START_SERVER: true - cmd: pnpm run test:snapshots {{.CLI_ARGS}} + cmd: pnpm run test:ui {{.CLI_ARGS}} generate-marketing-screenshots: desc: 'they should be in the screenshots folder' env: AUTO_START_SERVER: true MARKETING_SCREENSHOTS: true - cmd: pnpm run test:snapshots {{.CLI_ARGS}} + cmd: pnpm run test:ui snapshots {{.CLI_ARGS}} test:e2e-setup: deps: [build] diff --git a/frontend/viewer/package.json b/frontend/viewer/package.json index 82aa07e7ba..a00a6da5f6 100644 --- a/frontend/viewer/package.json +++ b/frontend/viewer/package.json @@ -13,12 +13,12 @@ "build": "vite build", "build-ffmpeg-worker": "vite build --config vite.config.ffmpeg-worker.ts", "preview": "vite preview", - "pretest:snapshots": "playwright install chromium", + "pretest:ui": "playwright install chromium", "pretest:e2e": "playwright install chromium", - "test:snapshots": "playwright test -c ./tests/snapshots/playwright.config.ts", + "test:ui": "playwright test -c ./tests/ui/playwright.config.ts", "test:e2e": "playwright test -c ./tests/e2e/playwright.config.ts", "test": "vitest run --project=!integration", - "test:ui": "vitest --ui", + "test:vitest-ui": "vitest --ui", "test:watch": "vitest", "test:storybook": "vitest --project=storybook", "test:unit": "vitest --project=unit", diff --git a/frontend/viewer/src/home/HomeView.svelte b/frontend/viewer/src/home/HomeView.svelte index 778a3d1ec7..7c1cc824fa 100644 --- a/frontend/viewer/src/home/HomeView.svelte +++ b/frontend/viewer/src/home/HomeView.svelte @@ -168,7 +168,7 @@

{$t`loading...`}

{:then projects}
-
+

{$t`Local`}

diff --git a/frontend/viewer/tests/browse-page.ts b/frontend/viewer/tests/browse-page.ts deleted file mode 100644 index 285a8f3565..0000000000 --- a/frontend/viewer/tests/browse-page.ts +++ /dev/null @@ -1,33 +0,0 @@ -import {type Page} from '@playwright/test'; -import {waitForProjectViewReady} from './test-utils'; -import {EntriesListComponent} from './entries-list-component'; -import {EntryViewComponent} from './entry-view-component'; -import {EntryApiHelper} from './entry-api-helper'; - -/** - * Page object for the browse page which contains: - * - EntriesListComponent (left panel - entry list with virtual scrolling) - * - EntryViewComponent (right panel - entry detail/edit view) - */ -export class BrowsePage { - readonly entriesList: EntriesListComponent; - readonly entryView: EntryViewComponent; - readonly api: EntryApiHelper; - - constructor(readonly page: Page) { - this.api = new EntryApiHelper(page); - this.entriesList = new EntriesListComponent(page, this.api); - this.entryView = new EntryViewComponent(page); - } - - async goto(): Promise { - await this.page.goto('/testing/project-view'); - await waitForProjectViewReady(this.page, true); - } - - async selectEntryByFilter(filter: string): Promise { - await this.entriesList.filterByText(filter); - await this.entriesList.entryWithText(filter).click(); - await this.entryView.waitForEntryLoaded(); - } -} diff --git a/frontend/viewer/tests/e2e/config.ts b/frontend/viewer/tests/e2e/config.ts index d01fbdec9f..621d7b0103 100644 --- a/frontend/viewer/tests/e2e/config.ts +++ b/frontend/viewer/tests/e2e/config.ts @@ -1,20 +1,19 @@ -import type {E2ETestConfig} from './types'; +export interface Server { + hostname: string; + protocol: 'http' | 'https'; + port: number; +} -export const testConfig: E2ETestConfig = { - lexboxServer: { - hostname: process.env.TEST_SERVER_HOSTNAME || 'localhost', - // Kind cluster in CI serves HTTP on 6579; override to https/6580 for the - // ingress port-forward (MSAL refuses non-https authorities). - protocol: (process.env.TEST_SERVER_PROTOCOL as 'http' | 'https') || 'http', - port: process.env.TEST_SERVER_PORT ? parseInt(process.env.TEST_SERVER_PORT) : 6579, - }, - fwLite: { - binaryPath: process.env.FW_LITE_BINARY_PATH || './dist/fw-lite-server/FwLiteWeb.exe', - launchTimeout: 30_000, - }, - testData: { - projectCode: process.env.TEST_PROJECT_CODE || 'sena-3', - testUser: process.env.TEST_USER || 'manager', - testPassword: process.env.TEST_DEFAULT_PASSWORD || 'pass', - }, +// Kind cluster in CI serves HTTP on 6579; in CI we override to https/6580 +// (the ingress port-forward). MSAL refuses non-https authorities. +export const lexboxServer: Server = { + hostname: process.env.TEST_SERVER_HOSTNAME || 'localhost', + protocol: (process.env.TEST_SERVER_PROTOCOL as 'http' | 'https') || 'http', + port: process.env.TEST_SERVER_PORT ? parseInt(process.env.TEST_SERVER_PORT) : 6579, }; + +export const fwLiteBinaryPath = process.env.FW_LITE_BINARY_PATH || './dist/fw-lite-server/FwLiteWeb.exe'; + +export const projectCode = process.env.TEST_PROJECT_CODE || 'sena-3'; +export const testUser = process.env.TEST_USER || 'manager'; +export const testPassword = process.env.TEST_DEFAULT_PASSWORD || 'pass'; diff --git a/frontend/viewer/tests/e2e/fw-lite-integration.test.ts b/frontend/viewer/tests/e2e/fw-lite-integration.test.ts deleted file mode 100644 index df3b6cee5b..0000000000 --- a/frontend/viewer/tests/e2e/fw-lite-integration.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import {expect, test} from '@playwright/test'; -import {FwLiteLauncher} from './helpers/fw-lite-launcher'; -import {deleteProject, ensureProjectCrdtReady, logoutFromServer} from './helpers/project-operations'; -import {testConfig} from './config'; -import {HomePage} from './helpers/home-page'; -import {ProjectPage} from './helpers/project-page'; - -const {lexboxServer, fwLite, testData} = testConfig; -const lexboxServerUrl = `${lexboxServer.protocol}://${lexboxServer.hostname}:${lexboxServer.port}`; -let fwLiteLauncher: FwLiteLauncher; - -test.describe('FW Lite Integration Tests', () => { - test.beforeEach(async ({page}, testInfo) => { - fwLiteLauncher = new FwLiteLauncher(); - await fwLiteLauncher.launch({ - binaryPath: fwLite.binaryPath, - serverUrl: lexboxServerUrl, - timeout: fwLite.launchTimeout, - logFile: testInfo.outputPath('fw-lite-server.log'), - }); - await page.goto(fwLiteLauncher.getBaseUrl()); - await page.waitForLoadState('networkidle'); - }); - - test.afterEach(async ({page}) => { - try { - // Tests may end on a project page; logout/delete operate from the home page. - await page.goto(fwLiteLauncher.getBaseUrl()); - await logoutFromServer(page, lexboxServer); - await deleteProject(page, testData.projectCode); - } finally { - await fwLiteLauncher.shutdown(); - } - }); - - test('Smoke test: application launch and server connectivity', async ({page}) => { - const homePage = new HomePage(page); - await homePage.waitFor(); - - await homePage.ensureLoggedIn(lexboxServer, testData.testUser, testData.testPassword); - expect(await homePage.serverProjects(lexboxServer).count()).toBeGreaterThan(0); - }); - - test('Project download: download and verify project structure', async ({page}) => { - test.setTimeout(3 * 60_000); - const homePage = new HomePage(page); - await homePage.waitFor(); - await homePage.ensureLoggedIn(lexboxServer, testData.testUser, testData.testPassword); - - // sena-3 in the freshly-seeded kind cluster has no ServerCommits, so FwLite - // filters it out of the listed projects. Trigger an initial sync server-side - // so it shows up as a downloadable CRDT project. - await ensureProjectCrdtReady(page, lexboxServer, testData.projectCode); - - await homePage.downloadProject(lexboxServer, testData.projectCode); - await homePage.openLocalProject(testData.projectCode); - await new ProjectPage(page, testData.projectCode).waitFor(); - }); -}); diff --git a/frontend/viewer/tests/e2e/helpers/fixtures.ts b/frontend/viewer/tests/e2e/helpers/fixtures.ts new file mode 100644 index 0000000000..be52907e2d --- /dev/null +++ b/frontend/viewer/tests/e2e/helpers/fixtures.ts @@ -0,0 +1,39 @@ +import {test as base, expect} from '@playwright/test'; +import {fwLiteBinaryPath, lexboxServer, projectCode} from '../config'; +import {FwLiteLauncher} from './fw-lite-launcher'; +import {deleteProject, logoutFromServer} from './project-operations'; + +const lexboxServerUrl = `${lexboxServer.protocol}://${lexboxServer.hostname}:${lexboxServer.port}`; + +/** + * `fwLite` fixture: launches FwLiteWeb pointed at the kind-cluster Lexbox, + * navigates the page to its base URL, and tears everything down (best-effort + * logout + project delete + process shutdown) after the test. + * + * Use via `test('...', async ({page, fwLite}) => { ... })`. + */ +export const test = base.extend<{fwLite: FwLiteLauncher}>({ + fwLite: async ({page}, use, testInfo) => { + const launcher = new FwLiteLauncher(); + await launcher.launch({ + binaryPath: fwLiteBinaryPath, + serverUrl: lexboxServerUrl, + logFile: testInfo.outputPath('fw-lite-server.log'), + }); + await page.goto(launcher.getBaseUrl()); + await page.waitForLoadState('networkidle'); + + await use(launcher); + + try { + // Tests may end on a project page; logout/delete operate from the home page. + await page.goto(launcher.getBaseUrl()); + await logoutFromServer(page, lexboxServer); + await deleteProject(page, projectCode); + } finally { + await launcher.shutdown(); + } + }, +}); + +export {expect}; diff --git a/frontend/viewer/tests/e2e/helpers/fw-lite-launcher.ts b/frontend/viewer/tests/e2e/helpers/fw-lite-launcher.ts index 0f9ebf32e8..b61559a961 100644 --- a/frontend/viewer/tests/e2e/helpers/fw-lite-launcher.ts +++ b/frontend/viewer/tests/e2e/helpers/fw-lite-launcher.ts @@ -2,7 +2,14 @@ import {spawn, type ChildProcess} from 'node:child_process'; import {access, constants} from 'node:fs/promises'; import {createServer, type AddressInfo} from 'node:net'; import {platform} from 'node:os'; -import type {LaunchConfig} from '../types'; + +interface LaunchConfig { + binaryPath: string; + serverUrl: string; + port?: number; + timeout?: number; + logFile?: string; +} const SHUTDOWN_TIMEOUT_MS = 10_000; const HEALTH_CHECK_INTERVAL_MS = 1_000; diff --git a/frontend/viewer/tests/e2e/helpers/project-operations.ts b/frontend/viewer/tests/e2e/helpers/project-operations.ts index 31c8cadbe1..55f9fe1d56 100644 --- a/frontend/viewer/tests/e2e/helpers/project-operations.ts +++ b/frontend/viewer/tests/e2e/helpers/project-operations.ts @@ -1,8 +1,8 @@ import {type Page} from '@playwright/test'; -import type {E2ETestConfig} from '../types'; -import {HomePage} from './home-page'; +import type {Server} from '../config'; +import {HomePage} from '../../pages/home.page'; -export async function logoutFromServer(page: Page, server: E2ETestConfig['lexboxServer']): Promise { +export async function logoutFromServer(page: Page, server: Server): Promise { await new HomePage(page).ensureLoggedOut(server); } @@ -31,7 +31,7 @@ const SEEDED_PROJECT_IDS: Record = { */ export async function ensureProjectCrdtReady( page: Page, - server: E2ETestConfig['lexboxServer'], + server: Server, projectCode: string, ): Promise { const projectId = SEEDED_PROJECT_IDS[projectCode]; diff --git a/frontend/viewer/tests/e2e/helpers/project-page.ts b/frontend/viewer/tests/e2e/helpers/project-page.ts deleted file mode 100644 index 94e27d8863..0000000000 --- a/frontend/viewer/tests/e2e/helpers/project-page.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {expect, type Page} from '@playwright/test'; - -export class ProjectPage { - constructor(private page: Page, private projectCode: string) {} - - async waitFor() { - await this.page.waitForLoadState('load'); - await this.page.locator('.i-mdi-loading').waitFor({state: 'detached'}); - await expect(this.page.locator('.animate-pulse')).toHaveCount(0); - await expect(this.page.getByRole('textbox', {name: 'Filter'})).toBeVisible(); - await expect(this.page.getByRole('button', {name: 'Headword'})).toBeVisible(); - // Entries hydrate after the table shell renders, so poll instead of one-shotting. - await expect.poll(() => this.entryRows().count()).toBeGreaterThan(5); - } - - entryRows() { - return this.page.getByRole('table').getByRole('row'); - } -} diff --git a/frontend/viewer/tests/e2e/project-download.test.ts b/frontend/viewer/tests/e2e/project-download.test.ts new file mode 100644 index 0000000000..3cbf2152e3 --- /dev/null +++ b/frontend/viewer/tests/e2e/project-download.test.ts @@ -0,0 +1,22 @@ +import {test} from './helpers/fixtures'; +import {ensureProjectCrdtReady} from './helpers/project-operations'; +import {lexboxServer, projectCode, testPassword, testUser} from './config'; +import {HomePage} from '../pages/home.page'; +import {ProjectPage} from '../pages/project.page'; + +test('Project download: log in, download, and open the project', async ({page, fwLite: _fwLite}) => { + test.setTimeout(3 * 60_000); + + const homePage = new HomePage(page); + await homePage.waitFor(); + await homePage.ensureLoggedIn(lexboxServer, testUser, testPassword); + + // sena-3 in the freshly-seeded kind cluster has no ServerCommits, so FwLite + // filters it out of the listed projects. Trigger an initial sync server-side + // so it shows up as a downloadable CRDT project. + await ensureProjectCrdtReady(page, lexboxServer, projectCode); + + await homePage.downloadProject(lexboxServer, projectCode); + await homePage.openLocalProject(projectCode); + await new ProjectPage(page).waitFor(); +}); diff --git a/frontend/viewer/tests/e2e/smoke.test.ts b/frontend/viewer/tests/e2e/smoke.test.ts new file mode 100644 index 0000000000..de5614c0fd --- /dev/null +++ b/frontend/viewer/tests/e2e/smoke.test.ts @@ -0,0 +1,11 @@ +import {expect, test} from './helpers/fixtures'; +import {lexboxServer, testPassword, testUser} from './config'; +import {HomePage} from '../pages/home.page'; + +test('FW Lite launches and connects to the server', async ({page, fwLite: _fwLite}) => { + const homePage = new HomePage(page); + await homePage.waitFor(); + + await homePage.ensureLoggedIn(lexboxServer, testUser, testPassword); + expect(await homePage.serverProjects(lexboxServer).count()).toBeGreaterThan(0); +}); diff --git a/frontend/viewer/tests/e2e/types.ts b/frontend/viewer/tests/e2e/types.ts deleted file mode 100644 index bde189ce9f..0000000000 --- a/frontend/viewer/tests/e2e/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -export interface E2ETestConfig { - lexboxServer: { - hostname: string; - protocol: 'http' | 'https'; - port: number; - }; - fwLite: { - binaryPath: string; - launchTimeout: number; - }; - testData: { - projectCode: string; - testUser: string; - testPassword: string; - }; -} - -export interface LaunchConfig { - binaryPath: string; - serverUrl: string; - port?: number; - timeout?: number; - logFile?: string; -} diff --git a/frontend/viewer/tests/entries-list.test.ts b/frontend/viewer/tests/entries-list.test.ts deleted file mode 100644 index ebb0208930..0000000000 --- a/frontend/viewer/tests/entries-list.test.ts +++ /dev/null @@ -1,323 +0,0 @@ -import {expect, test} from '@playwright/test'; -import {EntriesListComponent} from './entries-list-component'; -import {EntryApiHelper} from './entry-api-helper'; - -const ESTIMATED_ITEM_HEIGHT = 60; -const BATCH_SIZE = 50; - -test.describe('EntriesList', () => { - let entriesList: EntriesListComponent; - let api: EntryApiHelper; - - test.describe('Lazy loading', () => { - test.beforeEach(async ({page}) => { - api = new EntryApiHelper(page); - entriesList = new EntriesListComponent(page, api); - await entriesList.goto(); - }); - - test('entries are loaded initially', async () => { - await expect(entriesList.entryRows.first()).toBeVisible(); - - const visibleCount = await entriesList.entryRows.count(); - expect(visibleCount).toBeGreaterThan(5); - - await expect(entriesList.entryRows.first()).not.toHaveAttribute('data-skeleton'); - await expect(entriesList.entryRows.first()).toContainText(/.+/); - }); - - test('can scroll through entries incrementally', async () => { - const initialTexts = await entriesList.getVisibleEntryTexts(5); - expect(initialTexts.length).toBeGreaterThan(0); - - await entriesList.scrollToPixels(1000); - - await expect(async () => { - const scrollTop = await entriesList.getScrollTop(); - expect(scrollTop).toBeGreaterThan(850); - }).toPass({timeout: 2000}); - - const newTexts = await entriesList.getVisibleEntryTexts(5); - expect(newTexts).not.toEqual(initialTexts); - - await expect(entriesList.skeletons).toHaveCount(0); - }); - - test('large scroll jump loads new entries and preserves unloaded entries', async () => { - await expect(entriesList.entryRows.first()).toBeVisible(); - - const scrollHeight = await entriesList.getScrollHeight(); - expect(scrollHeight).toBeGreaterThan(500); - - // Jump to 90% of list - const targetScroll = scrollHeight * 0.9; - await entriesList.scrollToPixels(targetScroll); - - await expect(async () => { - const scrollTop = await entriesList.getScrollTop(); - expect(scrollTop).toBeGreaterThan(targetScroll - 200); - }).toPass({timeout: 2000}); - - // Should resolve from skeletons to entries - await expect(async () => { - await expect(entriesList.entryRows.first()).toBeVisible(); - const skeletonCount = await entriesList.skeletons.count(); - expect(skeletonCount).toBeLessThan(3); - }).toPass({timeout: 5000}); - - // Scroll back to middle (should show skeletons then load) - await entriesList.scrollToPercent(0.5); - - // Middle was not loaded yet - verify skeletons appear briefly - await entriesList.page.waitForTimeout(100); - await expect(entriesList.skeletons.first()).toBeVisible(); - - // Eventually loads content - await expect(async () => { - await expect(entriesList.entryRows.first()).toBeVisible(); - await expect(entriesList.skeletons).toHaveCount(0); - }).toPass({timeout: 5000}); - - await expect(entriesList.entryRows.first()).not.toHaveAttribute('data-skeleton'); - }); - }); - - test.describe('Jump to entry', () => { - test.beforeEach(async ({page}) => { - api = new EntryApiHelper(page); - entriesList = new EntriesListComponent(page, api); - await entriesList.goto(); - }); - - test('reloading with entry selected scrolls to that entry', async ({page}) => { - // Scroll far down (~100 items) - await entriesList.scrollToPixels(100 * ESTIMATED_ITEM_HEIGHT); - await entriesList.page.waitForTimeout(200); - await entriesList.waitForSkeletonsToResolve(); - - // Select an entry that's visible (get text before click to avoid DOM recycling issues) - const selectedText = await entriesList.selectEntryByIndex(6); - expect(selectedText).toBeTruthy(); - - expect(page.url()).toContain('entryId='); - - // Reload and verify selected entry is still visible - await page.reload(); - await entriesList.waitForSkeletonsToResolve(); - - await expect(entriesList.selectedEntry).toBeVisible({timeout: 5000}); - const expectedSnippet = selectedText.slice(0, 20); - await expect(entriesList.selectedEntry).toContainText(expectedSnippet); - }); - - test('clearing search filter with entry selected keeps entry visible', async () => { - // Scroll far into the list (~500 items down) - await entriesList.scrollToPixels(500 * ESTIMATED_ITEM_HEIGHT); - await entriesList.page.waitForTimeout(200); - await entriesList.waitForSkeletonsToResolve(); - - // Get headword from visible entry - const entryText = await entriesList.entryRows.first().textContent(); - const headword = entryText?.split(/\s+/).filter(Boolean)[0] ?? ''; - expect(headword.length).toBeGreaterThan(0); - - // Filter and select - await entriesList.filterByText(headword); - await entriesList.entryRows.first().click(); - await expect(entriesList.entryRows.first()).toContainText(headword); - - // Clear filter - selected entry should still be visible - await entriesList.clearFilter(); - - await expect(entriesList.selectedEntry).toBeVisible({timeout: 10000}); - await expect(entriesList.selectedEntry).toContainText(headword); - }); - }); - - test.describe('Entry event handling', () => { - test.beforeEach(async ({page}) => { - api = new EntryApiHelper(page); - entriesList = new EntriesListComponent(page, api); - await entriesList.goto(true); - }); - - test('entry delete event removes entry from list without full reset', async () => { - const initialTexts = await entriesList.getVisibleEntryTexts(); - expect(initialTexts.length).toBeGreaterThan(2); - - const firstEntryId = await api.getEntryIdAtIndex(0); - await api.deleteEntry(firstEntryId); - - await expect(async () => { - const newTexts = await entriesList.getVisibleEntryTexts(); - expect(newTexts[0]).toBe(initialTexts[1]); - expect(newTexts).not.toContain(initialTexts[0]); - }).toPass({timeout: 5000}); - }); - - test('entry update event updates entry in list without full reset', async () => { - const firstEntryText = await entriesList.entryRows.first().textContent(); - expect(firstEntryText).toBeTruthy(); - - // Update first entry by prepending to its headword (so it stays at index 0) - const {updatedHeadword} = await api.updateEntryHeadwordPrepend(0, '-UPDATED-'); - - // The first entry in UI should now show the updated text - await expect(async () => { - const newFirstEntryText = await entriesList.entryRows.first().textContent(); - expect(newFirstEntryText).toContain('-UPDATED-'); - }).toPass({timeout: 5000}); - - await expect(entriesList.entryWithText(updatedHeadword)).toBeVisible(); - }); - - test('deleting selected entry clears selection', async () => { - await entriesList.entryRows.first().click(); - await expect(entriesList.selectedEntry).toBeVisible(); - - const entryId = await api.getEntryIdAtIndex(0); - await api.deleteEntry(entryId); - - await entriesList.page.waitForTimeout(300); - await expect(entriesList.selectedEntry).not.toBeAttached(); - }); - - test('deleting entry not in cache triggers reset but maintains position', async () => { - // Scroll to middle - await entriesList.scrollToPercent(0.5); - await entriesList.waitForSkeletonsToResolve(); - - const visibleTexts = await entriesList.getVisibleEntryTexts(); - expect(visibleTexts.length).toBeGreaterThan(0); - - // Delete entry from top (not in cache) - const topEntryId = await api.getEntryIdAtIndex(0); - await api.deleteEntry(topEntryId); - - await entriesList.page.waitForTimeout(500); - await entriesList.waitForSkeletonsToResolve(); - - // Should still have visible entries after reset - const newVisibleTexts = await entriesList.getVisibleEntryTexts(); - expect(newVisibleTexts.length).toBeGreaterThan(0); - }); - - test('adding entry in loaded batch updates UI in place', async () => { - const midBatchIndex = Math.floor(BATCH_SIZE / 2); - const lastIndexInBatch0 = BATCH_SIZE - 1; - - const oldMidText = await api.getHeadwordAtIndex(midBatchIndex); - const oldLastText = await api.getHeadwordAtIndex(lastIndexInBatch0); - - const {headword} = await api.createEntryAtIndex(midBatchIndex); - await entriesList.page.waitForTimeout(300); - - // Verify API reflects the insertion - const newMidText = await api.getHeadwordAtIndex(midBatchIndex); - expect(newMidText).toBe(headword); - - const shiftedMidText = await api.getHeadwordAtIndex(midBatchIndex + 1); - expect(shiftedMidText).toBe(oldMidText); - - const shiftedLastText = await api.getHeadwordAtIndex(lastIndexInBatch0 + 1); - expect(shiftedLastText).toBe(oldLastText); - - // Verify UI shows the new entry - await entriesList.scrollToIndex(midBatchIndex); - await expect(entriesList.entryWithText(headword)).toBeVisible({timeout: 5000}); - - await entriesList.scrollToIndex(lastIndexInBatch0 + 1); - await expect(entriesList.entryWithText(oldLastText)).toBeVisible({timeout: 5000}); - }); - - test('adding entry at end increases scroll height without affecting visible entries', async () => { - const initialVisibleTexts = await entriesList.getVisibleEntryTexts(); - const oldScrollHeight = await entriesList.getScrollHeight(); - - const {headword: lastHeadword, index: lastIndex} = await api.getLastEntry(); - const newHeadword = lastHeadword + 'z-inserted'; - await api.createEntryWithHeadword(newHeadword); - - // Wait for scroll height to increase - await expect(async () => { - const newHeight = await entriesList.getScrollHeight(); - expect(newHeight).toBeGreaterThan(oldScrollHeight); - }).toPass({timeout: 5000}); - - // Visible entries unchanged (batch 0 not affected) - const newVisibleTexts = await entriesList.getVisibleEntryTexts(); - expect(newVisibleTexts).toEqual(initialVisibleTexts); - - // Verify via API - const entryAtEnd = await api.getHeadwordAtIndex(lastIndex + 1); - expect(entryAtEnd).toBe(newHeadword); - - // Scroll to end and verify visible - await entriesList.scrollToEnd(); - await expect(entriesList.entryWithText(newHeadword)).toBeVisible({timeout: 10000}); - }); - - test('entry added before loaded batch triggers quiet reset', async () => { - const midBatchIndex = Math.floor(BATCH_SIZE / 2); - const indexInBatch2 = BATCH_SIZE * 2 + 20; - const entriesToAdd = 10; - - // Scroll to batch 2 - await entriesList.scrollToIndex(indexInBatch2); - const oldCount = await api.countEntries(); - - const visibleTextsBefore = await entriesList.getVisibleEntryTexts(); - expect(visibleTextsBefore.length).toBeGreaterThan(0); - - // Create multiple entries in batch 0 to ensure count changes - const {headword} = await api.createEntryAtIndex(midBatchIndex); - for (let i = 0; i < entriesToAdd - 1; i++) { - await api.createEntryAtIndex(midBatchIndex); - } - - await entriesList.page.waitForTimeout(500); - await entriesList.waitForSkeletonsToResolve(); - - // Verify entry count increased - const newCount = await api.countEntries(); - expect(newCount).toBe(oldCount + entriesToAdd); - - // View still shows valid entries (not skeletons or empty) - const visibleTextsAfter = await entriesList.getVisibleEntryTexts(); - expect(visibleTextsAfter.length).toBeGreaterThan(0); - for (const text of visibleTextsAfter) { - expect(text.trim().length).toBeGreaterThan(0); - } - - // New entry is at mid-batch index - await entriesList.scrollToIndex(midBatchIndex); - await expect(entriesList.entryWithText(headword)).toBeVisible({timeout: 5000}); - }); - - test('updating uncached entry leaves visible entries unchanged', async () => { - const indexBeyondLoadedBatches = BATCH_SIZE * 2; - - await entriesList.page.waitForTimeout(500); - await entriesList.waitForSkeletonsToResolve(); - - const initialVisibleTexts = await entriesList.getVisibleEntryTexts(); - expect(initialVisibleTexts.filter(t => t.length > 0).length).toBeGreaterThan(0); - - // Update entry beyond loaded batches - const {id: entryId, updatedHeadword} = await api.updateEntryHeadword(indexBeyondLoadedBatches, '-UPDATED'); - await entriesList.page.waitForTimeout(300); - - // Batch 0 unchanged - const newVisibleTexts = await entriesList.getVisibleEntryTexts(); - expect(newVisibleTexts).toEqual(initialVisibleTexts); - - // Find and verify updated entry - const newIndex = await api.getEntryIndex(entryId); - expect(newIndex).toBeGreaterThanOrEqual(0); - - await entriesList.scrollToIndex(newIndex); - await expect(entriesList.entryWithText('-UPDATED')).toBeVisible({timeout: 5000}); - await expect(entriesList.entryWithText(updatedHeadword)).toBeVisible({timeout: 5000}); - }); - }); -}); diff --git a/frontend/viewer/tests/integration/fw-lite-launcher.test.ts b/frontend/viewer/tests/integration/fw-lite-launcher.test.ts index 08136136d0..ab62342a85 100644 --- a/frontend/viewer/tests/integration/fw-lite-launcher.test.ts +++ b/frontend/viewer/tests/integration/fw-lite-launcher.test.ts @@ -1,12 +1,11 @@ import {existsSync} from 'node:fs'; import {afterEach, beforeEach, describe, expect, it} from 'vitest'; import {FwLiteLauncher} from '../e2e/helpers/fw-lite-launcher'; -import {testConfig} from '../e2e/config'; +import {fwLiteBinaryPath} from '../e2e/config'; // Hard-fail if the binary is missing. Running this suite at all means you've opted in // (via `pnpm test:integration` or the e2e CI job). The default `pnpm test` excludes // the integration project so unrelated workflows aren't burdened with building the binary. -const fwLiteBinaryPath = testConfig.fwLite.binaryPath; if (!existsSync(fwLiteBinaryPath)) { throw new Error( `FW Lite binary not found at ${fwLiteBinaryPath}. ` + diff --git a/frontend/viewer/tests/entries-list-component.ts b/frontend/viewer/tests/pages/entries-list.component.ts similarity index 75% rename from frontend/viewer/tests/entries-list-component.ts rename to frontend/viewer/tests/pages/entries-list.component.ts index 74e9bd1a96..fc34acb358 100644 --- a/frontend/viewer/tests/entries-list-component.ts +++ b/frontend/viewer/tests/pages/entries-list.component.ts @@ -1,6 +1,4 @@ import {type Locator, type Page, expect} from '@playwright/test'; -import {waitForProjectViewReady} from './test-utils'; -import type {EntryApiHelper} from './entry-api-helper'; /** * Component object for EntriesList. @@ -13,7 +11,7 @@ export class EntriesListComponent { readonly selectedEntry: Locator; readonly searchInput: Locator; - constructor(readonly page: Page, readonly api: EntryApiHelper) { + constructor(readonly page: Page) { const table = page.locator('[role="table"]'); this.vlist = table.locator('> div > div'); this.entryRows = table.locator('[role="row"]'); @@ -22,11 +20,6 @@ export class EntriesListComponent { this.searchInput = page.locator('input.real-input').first(); } - async goto(waitForTestUtils = false): Promise { - await this.page.goto('/testing/project-view'); - await waitForProjectViewReady(this.page, waitForTestUtils); - } - async waitForSkeletonsToResolve(): Promise { await expect(this.skeletons).toHaveCount(0, {timeout: 5000}); } @@ -61,23 +54,6 @@ export class EntriesListComponent { await this.scrollToPixels(scrollHeight * percent); } - async scrollToIndex(targetIndex: number): Promise { - const totalCount = await this.api.countEntries(); - if (targetIndex >= totalCount) throw new Error(`Target index ${targetIndex} exceeds total count ${totalCount}`); - - const targetScroll = await this.vlist.evaluate((el, params) => { - const {idx, total} = params; - return (idx / total) * el.scrollHeight; - }, {idx: targetIndex, total: totalCount}); - - await this.vlist.evaluate((el, target) => { - el.scrollTop = Math.min(target, el.scrollHeight - el.clientHeight); - }, targetScroll); - - await this.page.waitForTimeout(300); - await this.waitForSkeletonsToResolve(); - } - async scrollToEnd(): Promise { await this.vlist.evaluate((el) => { el.scrollTop = el.scrollHeight; diff --git a/frontend/viewer/tests/entry-view-component.ts b/frontend/viewer/tests/pages/entry-view.component.ts similarity index 100% rename from frontend/viewer/tests/entry-view-component.ts rename to frontend/viewer/tests/pages/entry-view.component.ts diff --git a/frontend/viewer/tests/e2e/helpers/home-page.ts b/frontend/viewer/tests/pages/home.page.ts similarity index 87% rename from frontend/viewer/tests/e2e/helpers/home-page.ts rename to frontend/viewer/tests/pages/home.page.ts index baac6f5f82..84854a43dc 100644 --- a/frontend/viewer/tests/e2e/helpers/home-page.ts +++ b/frontend/viewer/tests/pages/home.page.ts @@ -1,8 +1,6 @@ import {expect, type Page} from '@playwright/test'; -import type {E2ETestConfig} from '../types'; -import {LoginPage} from '../../../../tests/pages/loginPage'; - -type Server = E2ETestConfig['lexboxServer']; +import type {Server} from '../e2e/config'; +import {LoginPage} from '../../../tests/pages/loginPage'; // FwLite uses Uri.Authority (host[:port]) as the server.id, so the rendered // element id always includes the port. Use an attribute selector — `#host:port` @@ -39,8 +37,10 @@ export class HomePage { return this.serverSection(server).getByRole('row'); } - localProjects() { - return this.page.locator('#local-projects'); + // Only downloaded (local) projects render as anchors to `/project/{code}`; + // server-side projects appear as rows inside their server section. + localProjectLink(projectCode: string) { + return this.page.locator(`a[href="/project/${projectCode}"]`); } async ensureLoggedIn(server: Server, username: string, password: string) { @@ -89,10 +89,10 @@ export class HomePage { await progressIndicator.waitFor({state: 'visible', timeout: 30_000}); await progressIndicator.waitFor({state: 'detached', timeout: 60_000}); - await expect(this.localProjects().getByText(projectCode)).toBeVisible(); + await expect(this.localProjectLink(projectCode)).toBeVisible(); } async openLocalProject(projectCode: string) { - await this.localProjects().getByText(projectCode).click(); + await this.localProjectLink(projectCode).click(); } } diff --git a/frontend/viewer/tests/pages/project.page.ts b/frontend/viewer/tests/pages/project.page.ts new file mode 100644 index 0000000000..1723ef9d3e --- /dev/null +++ b/frontend/viewer/tests/pages/project.page.ts @@ -0,0 +1,35 @@ +import {expect, type Page} from '@playwright/test'; +import {EntriesListComponent} from './entries-list.component'; +import {EntryViewComponent} from './entry-view.component'; + +/** + * The project's browse view: entries list on the left, entry detail/edit on the right. + * Used by both the in-memory-demo UI tests and the real-backend e2e tests. + */ +export class ProjectPage { + readonly entriesList: EntriesListComponent; + readonly entryView: EntryViewComponent; + + constructor(readonly page: Page) { + this.entriesList = new EntriesListComponent(page); + this.entryView = new EntryViewComponent(page); + } + + async waitFor() { + await this.page.waitForLoadState('load'); + await this.page.locator('.i-mdi-loading').waitFor({state: 'detached'}); + await this.page.waitForFunction(() => document.fonts.ready); + await expect(this.page.locator('.animate-pulse')).toHaveCount(0); + await expect(this.entriesList.skeletons).toHaveCount(0); + await expect(this.page.getByRole('textbox', {name: 'Filter'})).toBeVisible(); + await expect(this.page.getByRole('button', {name: 'Headword'})).toBeVisible(); + // Entries hydrate after the table shell renders, so poll instead of one-shotting. + await expect.poll(() => this.entriesList.entryRows.count()).toBeGreaterThan(5); + } + + async selectEntryByFilter(filter: string) { + await this.entriesList.filterByText(filter); + await this.entriesList.entryWithText(filter).click(); + await this.entryView.waitForEntryLoaded(); + } +} diff --git a/frontend/viewer/tests/test-utils.ts b/frontend/viewer/tests/test-utils.ts deleted file mode 100644 index 61262d23b1..0000000000 --- a/frontend/viewer/tests/test-utils.ts +++ /dev/null @@ -1,11 +0,0 @@ -import {type Page, expect} from '@playwright/test'; - -export async function waitForProjectViewReady(page: Page, waitForTestUtils = false) { - await expect(page.locator('.i-mdi-loading')).toHaveCount(0, {timeout: 10000}); - await page.waitForFunction(() => document.fonts.ready); - await expect(page.locator('[data-skeleton]')).toHaveCount(0, {timeout: 10000}); - // Wait for test utilities to be available if requested - if (waitForTestUtils) { - await page.waitForFunction(() => window.__PLAYWRIGHT_UTILS__?.demoApi, {timeout: 5000}); - } -} diff --git a/frontend/viewer/tests/back-navigation-persistence.test.ts b/frontend/viewer/tests/ui/back-navigation-persistence.test.ts similarity index 53% rename from frontend/viewer/tests/back-navigation-persistence.test.ts rename to frontend/viewer/tests/ui/back-navigation-persistence.test.ts index df556196c8..5035c892ec 100644 --- a/frontend/viewer/tests/back-navigation-persistence.test.ts +++ b/frontend/viewer/tests/ui/back-navigation-persistence.test.ts @@ -1,34 +1,35 @@ import {expect, test} from '@playwright/test'; -import {BrowsePage} from './browse-page'; + +import {DemoProjectPage} from './demo-project.page'; test.describe('Back navigation persistence', () => { test('edit is applied to correct entry when navigating back without blurring', async ({page}) => { - const browsePage = new BrowsePage(page); - await browsePage.goto(); + const projectPage = new DemoProjectPage(page); + await projectPage.goto(); // Get three entries from demo data to ensure a solid history stack - const entryAId = await browsePage.api.getEntryIdAtIndex(0); - const entryBId = await browsePage.api.getEntryIdAtIndex(1); - const entryCId = await browsePage.api.getEntryIdAtIndex(2); + const entryAId = await projectPage.api.getEntryIdAtIndex(0); + const entryBId = await projectPage.api.getEntryIdAtIndex(1); + const entryCId = await projectPage.api.getEntryIdAtIndex(2); - const originalLexemeA = await browsePage.api.getEntryLexeme(entryAId); - const originalLexemeB = await browsePage.api.getEntryLexeme(entryBId); + const originalLexemeA = await projectPage.api.getEntryLexeme(entryAId); + const originalLexemeB = await projectPage.api.getEntryLexeme(entryBId); // 1) Open entry A - await browsePage.entriesList.selectEntryByIndex(0); - await browsePage.entryView.waitForEntryLoaded(); + await projectPage.entriesList.selectEntryByIndex(0); + await projectPage.entryView.waitForEntryLoaded(); // 2) Open entry B - await browsePage.entriesList.selectEntryByIndex(1); - await browsePage.entryView.waitForEntryLoaded(); + await projectPage.entriesList.selectEntryByIndex(1); + await projectPage.entryView.waitForEntryLoaded(); // 3) Open entry C - await browsePage.entriesList.selectEntryByIndex(2); - await browsePage.entryView.waitForEntryLoaded(); + await projectPage.entriesList.selectEntryByIndex(2); + await projectPage.entryView.waitForEntryLoaded(); // 4) Edit a field on entry C (lexeme form) - const lexemeInput = await browsePage.entryView.getLexemeInput(); + const lexemeInput = await projectPage.entryView.getLexemeInput(); const timestamp = Date.now().toString().slice(-6); const newValueC = `edited-C-${timestamp}`; @@ -42,21 +43,21 @@ test.describe('Back navigation persistence', () => { // Wait for entry B to be loaded again in the UI await expect(page.locator('.i-mdi-loading')).toHaveCount(0, {timeout: 10000}); - await browsePage.entryView.waitForEntryLoaded(); + await projectPage.entryView.waitForEntryLoaded(); // Ensure the input value for Entry B is NOT newValueC - const lexemeInputAfterNav = await browsePage.entryView.getLexemeInput(); + const lexemeInputAfterNav = await projectPage.entryView.getLexemeInput(); await expect(lexemeInputAfterNav).not.toHaveValue(newValueC); await expect(lexemeInputAfterNav).toHaveValue(originalLexemeB); // 6) Verify via API that entry B and A were NOT changed - const savedLexemeB = await browsePage.api.getEntryLexeme(entryBId); + const savedLexemeB = await projectPage.api.getEntryLexeme(entryBId); expect(savedLexemeB, 'Entry B should not have the edit meant for Entry C').toBe(originalLexemeB); - const savedLexemeA = await browsePage.api.getEntryLexeme(entryAId); + const savedLexemeA = await projectPage.api.getEntryLexeme(entryAId); expect(savedLexemeA, 'Entry A should not have the edit meant for Entry C').toBe(originalLexemeA); // 7) Verify via API that entry C WAS changed - const savedLexemeC = await browsePage.api.getEntryLexeme(entryCId); + const savedLexemeC = await projectPage.api.getEntryLexeme(entryCId); expect(savedLexemeC).toBe(newValueC); }); }); diff --git a/frontend/viewer/tests/ui/demo-project.page.ts b/frontend/viewer/tests/ui/demo-project.page.ts new file mode 100644 index 0000000000..e78d57fc8a --- /dev/null +++ b/frontend/viewer/tests/ui/demo-project.page.ts @@ -0,0 +1,46 @@ +import {type Page} from '@playwright/test'; +import {ProjectPage} from '../pages/project.page'; +import {EntryApiHelper} from './entry-api-helper'; + +/** + * ProjectPage variant for the in-memory demo served by `vite dev` at + * `/testing/project-view`. Exposes the demo's `__PLAYWRIGHT_UTILS__.demoApi` + * bridge via `api`, and adds api-aware helpers (e.g. `scrollToIndex`) that + * can't be expressed against UI alone. + */ +export class DemoProjectPage extends ProjectPage { + readonly api: EntryApiHelper; + + constructor(page: Page) { + super(page); + this.api = new EntryApiHelper(page); + } + + async goto() { + await this.page.goto('/testing/project-view'); + await this.waitFor(); + // The demo exposes its in-memory API on `window` for tests; api calls would race without this. + await this.page.waitForFunction(() => window.__PLAYWRIGHT_UTILS__?.demoApi, {timeout: 5000}); + } + + /** + * Scroll the virtual list to a target entry index. Requires the demo api to + * report the total entry count so we can convert index → scroll position. + */ + async scrollToIndex(targetIndex: number): Promise { + const totalCount = await this.api.countEntries(); + if (targetIndex >= totalCount) throw new Error(`Target index ${targetIndex} exceeds total count ${totalCount}`); + + const targetScroll = await this.entriesList.vlist.evaluate((el, params) => { + const {idx, total} = params; + return (idx / total) * el.scrollHeight; + }, {idx: targetIndex, total: totalCount}); + + await this.entriesList.vlist.evaluate((el, target) => { + el.scrollTop = Math.min(target, el.scrollHeight - el.clientHeight); + }, targetScroll); + + await this.page.waitForTimeout(300); + await this.entriesList.waitForSkeletonsToResolve(); + } +} diff --git a/frontend/viewer/tests/ui/entries-list.test.ts b/frontend/viewer/tests/ui/entries-list.test.ts new file mode 100644 index 0000000000..692bbda5a8 --- /dev/null +++ b/frontend/viewer/tests/ui/entries-list.test.ts @@ -0,0 +1,319 @@ +import {expect, test} from '@playwright/test'; + +import {DemoProjectPage} from './demo-project.page'; + +const ESTIMATED_ITEM_HEIGHT = 60; +const BATCH_SIZE = 50; + +test.describe('EntriesList', () => { + let projectPage: DemoProjectPage; + + test.describe('Lazy loading', () => { + test.beforeEach(async ({page}) => { + projectPage = new DemoProjectPage(page); + await projectPage.goto(); + }); + + test('entries are loaded initially', async () => { + await expect(projectPage.entriesList.entryRows.first()).toBeVisible(); + + const visibleCount = await projectPage.entriesList.entryRows.count(); + expect(visibleCount).toBeGreaterThan(5); + + await expect(projectPage.entriesList.entryRows.first()).not.toHaveAttribute('data-skeleton'); + await expect(projectPage.entriesList.entryRows.first()).toContainText(/.+/); + }); + + test('can scroll through entries incrementally', async () => { + const initialTexts = await projectPage.entriesList.getVisibleEntryTexts(5); + expect(initialTexts.length).toBeGreaterThan(0); + + await projectPage.entriesList.scrollToPixels(1000); + + await expect(async () => { + const scrollTop = await projectPage.entriesList.getScrollTop(); + expect(scrollTop).toBeGreaterThan(850); + }).toPass({timeout: 2000}); + + const newTexts = await projectPage.entriesList.getVisibleEntryTexts(5); + expect(newTexts).not.toEqual(initialTexts); + + await expect(projectPage.entriesList.skeletons).toHaveCount(0); + }); + + test('large scroll jump loads new entries and preserves unloaded entries', async () => { + await expect(projectPage.entriesList.entryRows.first()).toBeVisible(); + + const scrollHeight = await projectPage.entriesList.getScrollHeight(); + expect(scrollHeight).toBeGreaterThan(500); + + // Jump to 90% of list + const targetScroll = scrollHeight * 0.9; + await projectPage.entriesList.scrollToPixels(targetScroll); + + await expect(async () => { + const scrollTop = await projectPage.entriesList.getScrollTop(); + expect(scrollTop).toBeGreaterThan(targetScroll - 200); + }).toPass({timeout: 2000}); + + // Should resolve from skeletons to entries + await expect(async () => { + await expect(projectPage.entriesList.entryRows.first()).toBeVisible(); + const skeletonCount = await projectPage.entriesList.skeletons.count(); + expect(skeletonCount).toBeLessThan(3); + }).toPass({timeout: 5000}); + + // Scroll back to middle (should show skeletons then load) + await projectPage.entriesList.scrollToPercent(0.5); + + // Middle was not loaded yet - verify skeletons appear briefly + await projectPage.page.waitForTimeout(100); + await expect(projectPage.entriesList.skeletons.first()).toBeVisible(); + + // Eventually loads content + await expect(async () => { + await expect(projectPage.entriesList.entryRows.first()).toBeVisible(); + await expect(projectPage.entriesList.skeletons).toHaveCount(0); + }).toPass({timeout: 5000}); + + await expect(projectPage.entriesList.entryRows.first()).not.toHaveAttribute('data-skeleton'); + }); + }); + + test.describe('Jump to entry', () => { + test.beforeEach(async ({page}) => { + projectPage = new DemoProjectPage(page); + await projectPage.goto(); + }); + + test('reloading with entry selected scrolls to that entry', async ({page}) => { + // Scroll far down (~100 items) + await projectPage.entriesList.scrollToPixels(100 * ESTIMATED_ITEM_HEIGHT); + await projectPage.page.waitForTimeout(200); + await projectPage.entriesList.waitForSkeletonsToResolve(); + + // Select an entry that's visible (get text before click to avoid DOM recycling issues) + const selectedText = await projectPage.entriesList.selectEntryByIndex(6); + expect(selectedText).toBeTruthy(); + + expect(page.url()).toContain('entryId='); + + // Reload and verify selected entry is still visible + await page.reload(); + await projectPage.entriesList.waitForSkeletonsToResolve(); + + await expect(projectPage.entriesList.selectedEntry).toBeVisible({timeout: 5000}); + const expectedSnippet = selectedText.slice(0, 20); + await expect(projectPage.entriesList.selectedEntry).toContainText(expectedSnippet); + }); + + test('clearing search filter with entry selected keeps entry visible', async () => { + // Scroll far into the list (~500 items down) + await projectPage.entriesList.scrollToPixels(500 * ESTIMATED_ITEM_HEIGHT); + await projectPage.page.waitForTimeout(200); + await projectPage.entriesList.waitForSkeletonsToResolve(); + + // Get headword from visible entry + const entryText = await projectPage.entriesList.entryRows.first().textContent(); + const headword = entryText?.split(/\s+/).filter(Boolean)[0] ?? ''; + expect(headword.length).toBeGreaterThan(0); + + // Filter and select + await projectPage.entriesList.filterByText(headword); + await projectPage.entriesList.entryRows.first().click(); + await expect(projectPage.entriesList.entryRows.first()).toContainText(headword); + + // Clear filter - selected entry should still be visible + await projectPage.entriesList.clearFilter(); + + await expect(projectPage.entriesList.selectedEntry).toBeVisible({timeout: 10000}); + await expect(projectPage.entriesList.selectedEntry).toContainText(headword); + }); + }); + + test.describe('Entry event handling', () => { + test.beforeEach(async ({page}) => { + projectPage = new DemoProjectPage(page); + await projectPage.goto(); + }); + + test('entry delete event removes entry from list without full reset', async () => { + const initialTexts = await projectPage.entriesList.getVisibleEntryTexts(); + expect(initialTexts.length).toBeGreaterThan(2); + + const firstEntryId = await projectPage.api.getEntryIdAtIndex(0); + await projectPage.api.deleteEntry(firstEntryId); + + await expect(async () => { + const newTexts = await projectPage.entriesList.getVisibleEntryTexts(); + expect(newTexts[0]).toBe(initialTexts[1]); + expect(newTexts).not.toContain(initialTexts[0]); + }).toPass({timeout: 5000}); + }); + + test('entry update event updates entry in list without full reset', async () => { + const firstEntryText = await projectPage.entriesList.entryRows.first().textContent(); + expect(firstEntryText).toBeTruthy(); + + // Update first entry by prepending to its headword (so it stays at index 0) + const {updatedHeadword} = await projectPage.api.updateEntryHeadwordPrepend(0, '-UPDATED-'); + + // The first entry in UI should now show the updated text + await expect(async () => { + const newFirstEntryText = await projectPage.entriesList.entryRows.first().textContent(); + expect(newFirstEntryText).toContain('-UPDATED-'); + }).toPass({timeout: 5000}); + + await expect(projectPage.entriesList.entryWithText(updatedHeadword)).toBeVisible(); + }); + + test('deleting selected entry clears selection', async () => { + await projectPage.entriesList.entryRows.first().click(); + await expect(projectPage.entriesList.selectedEntry).toBeVisible(); + + const entryId = await projectPage.api.getEntryIdAtIndex(0); + await projectPage.api.deleteEntry(entryId); + + await projectPage.page.waitForTimeout(300); + await expect(projectPage.entriesList.selectedEntry).not.toBeAttached(); + }); + + test('deleting entry not in cache triggers reset but maintains position', async () => { + // Scroll to middle + await projectPage.entriesList.scrollToPercent(0.5); + await projectPage.entriesList.waitForSkeletonsToResolve(); + + const visibleTexts = await projectPage.entriesList.getVisibleEntryTexts(); + expect(visibleTexts.length).toBeGreaterThan(0); + + // Delete entry from top (not in cache) + const topEntryId = await projectPage.api.getEntryIdAtIndex(0); + await projectPage.api.deleteEntry(topEntryId); + + await projectPage.page.waitForTimeout(500); + await projectPage.entriesList.waitForSkeletonsToResolve(); + + // Should still have visible entries after reset + const newVisibleTexts = await projectPage.entriesList.getVisibleEntryTexts(); + expect(newVisibleTexts.length).toBeGreaterThan(0); + }); + + test('adding entry in loaded batch updates UI in place', async () => { + const midBatchIndex = Math.floor(BATCH_SIZE / 2); + const lastIndexInBatch0 = BATCH_SIZE - 1; + + const oldMidText = await projectPage.api.getHeadwordAtIndex(midBatchIndex); + const oldLastText = await projectPage.api.getHeadwordAtIndex(lastIndexInBatch0); + + const {headword} = await projectPage.api.createEntryAtIndex(midBatchIndex); + await projectPage.page.waitForTimeout(300); + + // Verify API reflects the insertion + const newMidText = await projectPage.api.getHeadwordAtIndex(midBatchIndex); + expect(newMidText).toBe(headword); + + const shiftedMidText = await projectPage.api.getHeadwordAtIndex(midBatchIndex + 1); + expect(shiftedMidText).toBe(oldMidText); + + const shiftedLastText = await projectPage.api.getHeadwordAtIndex(lastIndexInBatch0 + 1); + expect(shiftedLastText).toBe(oldLastText); + + // Verify UI shows the new entry + await projectPage.scrollToIndex(midBatchIndex); + await expect(projectPage.entriesList.entryWithText(headword)).toBeVisible({timeout: 5000}); + + await projectPage.scrollToIndex(lastIndexInBatch0 + 1); + await expect(projectPage.entriesList.entryWithText(oldLastText)).toBeVisible({timeout: 5000}); + }); + + test('adding entry at end increases scroll height without affecting visible entries', async () => { + const initialVisibleTexts = await projectPage.entriesList.getVisibleEntryTexts(); + const oldScrollHeight = await projectPage.entriesList.getScrollHeight(); + + const {headword: lastHeadword, index: lastIndex} = await projectPage.api.getLastEntry(); + const newHeadword = lastHeadword + 'z-inserted'; + await projectPage.api.createEntryWithHeadword(newHeadword); + + // Wait for scroll height to increase + await expect(async () => { + const newHeight = await projectPage.entriesList.getScrollHeight(); + expect(newHeight).toBeGreaterThan(oldScrollHeight); + }).toPass({timeout: 5000}); + + // Visible entries unchanged (batch 0 not affected) + const newVisibleTexts = await projectPage.entriesList.getVisibleEntryTexts(); + expect(newVisibleTexts).toEqual(initialVisibleTexts); + + // Verify via API + const entryAtEnd = await projectPage.api.getHeadwordAtIndex(lastIndex + 1); + expect(entryAtEnd).toBe(newHeadword); + + // Scroll to end and verify visible + await projectPage.entriesList.scrollToEnd(); + await expect(projectPage.entriesList.entryWithText(newHeadword)).toBeVisible({timeout: 10000}); + }); + + test('entry added before loaded batch triggers quiet reset', async () => { + const midBatchIndex = Math.floor(BATCH_SIZE / 2); + const indexInBatch2 = BATCH_SIZE * 2 + 20; + const entriesToAdd = 10; + + // Scroll to batch 2 + await projectPage.scrollToIndex(indexInBatch2); + const oldCount = await projectPage.api.countEntries(); + + const visibleTextsBefore = await projectPage.entriesList.getVisibleEntryTexts(); + expect(visibleTextsBefore.length).toBeGreaterThan(0); + + // Create multiple entries in batch 0 to ensure count changes + const {headword} = await projectPage.api.createEntryAtIndex(midBatchIndex); + for (let i = 0; i < entriesToAdd - 1; i++) { + await projectPage.api.createEntryAtIndex(midBatchIndex); + } + + await projectPage.page.waitForTimeout(500); + await projectPage.entriesList.waitForSkeletonsToResolve(); + + // Verify entry count increased + const newCount = await projectPage.api.countEntries(); + expect(newCount).toBe(oldCount + entriesToAdd); + + // View still shows valid entries (not skeletons or empty) + const visibleTextsAfter = await projectPage.entriesList.getVisibleEntryTexts(); + expect(visibleTextsAfter.length).toBeGreaterThan(0); + for (const text of visibleTextsAfter) { + expect(text.trim().length).toBeGreaterThan(0); + } + + // New entry is at mid-batch index + await projectPage.scrollToIndex(midBatchIndex); + await expect(projectPage.entriesList.entryWithText(headword)).toBeVisible({timeout: 5000}); + }); + + test('updating uncached entry leaves visible entries unchanged', async () => { + const indexBeyondLoadedBatches = BATCH_SIZE * 2; + + await projectPage.page.waitForTimeout(500); + await projectPage.entriesList.waitForSkeletonsToResolve(); + + const initialVisibleTexts = await projectPage.entriesList.getVisibleEntryTexts(); + expect(initialVisibleTexts.filter(t => t.length > 0).length).toBeGreaterThan(0); + + // Update entry beyond loaded batches + const {id: entryId, updatedHeadword} = await projectPage.api.updateEntryHeadword(indexBeyondLoadedBatches, '-UPDATED'); + await projectPage.page.waitForTimeout(300); + + // Batch 0 unchanged + const newVisibleTexts = await projectPage.entriesList.getVisibleEntryTexts(); + expect(newVisibleTexts).toEqual(initialVisibleTexts); + + // Find and verify updated entry + const newIndex = await projectPage.api.getEntryIndex(entryId); + expect(newIndex).toBeGreaterThanOrEqual(0); + + await projectPage.scrollToIndex(newIndex); + await expect(projectPage.entriesList.entryWithText('-UPDATED')).toBeVisible({timeout: 5000}); + await expect(projectPage.entriesList.entryWithText(updatedHeadword)).toBeVisible({timeout: 5000}); + }); + }); +}); diff --git a/frontend/viewer/tests/entry-api-helper.ts b/frontend/viewer/tests/ui/entry-api-helper.ts similarity index 100% rename from frontend/viewer/tests/entry-api-helper.ts rename to frontend/viewer/tests/ui/entry-api-helper.ts diff --git a/frontend/viewer/tests/entry-edit-persists.test.ts b/frontend/viewer/tests/ui/entry-edit-persists.test.ts similarity index 64% rename from frontend/viewer/tests/entry-edit-persists.test.ts rename to frontend/viewer/tests/ui/entry-edit-persists.test.ts index 9ffe21d1c6..359efa6d7a 100644 --- a/frontend/viewer/tests/entry-edit-persists.test.ts +++ b/frontend/viewer/tests/ui/entry-edit-persists.test.ts @@ -1,5 +1,6 @@ import {expect, test} from '@playwright/test'; -import {BrowsePage} from './browse-page'; + +import {DemoProjectPage} from './demo-project.page'; /** * Critical tests: Verify that entry edits are saved to the backend. @@ -11,30 +12,30 @@ import {BrowsePage} from './browse-page'; test.describe('Entry edit persistence', () => { test('UI edit of gloss field is saved to backend', async ({page}) => { - const browsePage = new BrowsePage(page); - await browsePage.goto(); + const projectPage = new DemoProjectPage(page); + await projectPage.goto(); // Get an existing entry with senses from demo data that has an English gloss - const {entryId, headword, originalGloss} = await browsePage.api.getEntryWithEnglishGloss(); + const {entryId, headword, originalGloss} = await projectPage.api.getEntryWithEnglishGloss(); expect(entryId).toBeTruthy(); expect(headword).toBeTruthy(); expect(originalGloss).toBeTruthy(); // Select the entry - await browsePage.selectEntryByFilter(headword); + await projectPage.selectEntryByFilter(headword); // Verify we have the expected original value - const glossInput = await browsePage.entryView.getGlossInput(0, 'Eng'); + const glossInput = await projectPage.entryView.getGlossInput(0, 'Eng'); await expect(glossInput).toHaveValue(originalGloss); // Edit the gloss const timestamp = Date.now().toString().slice(-6); const newGloss = `edited-${timestamp}`; - await browsePage.entryView.editGloss(newGloss, 0, 'Eng'); + await projectPage.entryView.editGloss(newGloss, 0, 'Eng'); // Verify via API that the change was saved await expect(async () => { - const savedGloss = await browsePage.api.getEntryGloss(entryId, 'en'); + const savedGloss = await projectPage.api.getEntryGloss(entryId, 'en'); expect(savedGloss).toBe(newGloss); }).toPass({timeout: 5000}); @@ -43,69 +44,69 @@ test.describe('Entry edit persistence', () => { }); test('adding sense via UI is saved to backend', async ({page}) => { - const browsePage = new BrowsePage(page); - await browsePage.goto(); + const projectPage = new DemoProjectPage(page); + await projectPage.goto(); // Get an existing entry and count its senses - const {entryId, headword, senseCount: initialSenseCount} = await browsePage.api.getEntryAtIndex(15); + const {entryId, headword, senseCount: initialSenseCount} = await projectPage.api.getEntryAtIndex(15); expect(entryId).toBeTruthy(); expect(headword).toBeTruthy(); // Select the entry - await browsePage.selectEntryByFilter(headword); + await projectPage.selectEntryByFilter(headword); // Add a new sense - await browsePage.entryView.addSense(); + await projectPage.entryView.addSense(); // Verify UI shows new sense - const senseCount = await browsePage.entryView.getSenseCount(); + const senseCount = await projectPage.entryView.getSenseCount(); expect(senseCount).toBeGreaterThan(initialSenseCount); // Fill in the new sense's gloss const timestamp = Date.now().toString().slice(-6); const senseGloss = `new-sense-${timestamp}`; - await browsePage.entryView.editGloss(senseGloss, senseCount - 1); + await projectPage.entryView.editGloss(senseGloss, senseCount - 1); // Verify via API that the sense was added await expect(async () => { - const savedSenseCount = await browsePage.api.getEntrySenseCount(entryId); + const savedSenseCount = await projectPage.api.getEntrySenseCount(entryId); expect(savedSenseCount).toBe(initialSenseCount + 1); - const hasGloss = await browsePage.api.entryHasGlossValue(entryId, senseGloss); + const hasGloss = await projectPage.api.entryHasGlossValue(entryId, senseGloss); expect(hasGloss).toBe(true); }).toPass({timeout: 5000}); // Also verify the UI shows the new sense with the gloss we entered - const newGlossInput = await browsePage.entryView.getGlossInput(senseCount - 1); + const newGlossInput = await projectPage.entryView.getGlossInput(senseCount - 1); await expect(newGlossInput).toHaveValue(senseGloss); }); test('editing lexeme form is saved to backend', async ({page}) => { - const browsePage = new BrowsePage(page); - await browsePage.goto(); + const projectPage = new DemoProjectPage(page); + await projectPage.goto(); // Get an existing entry from the demo data - const {entryId, headword: originalLexeme} = await browsePage.api.getEntryAtIndex(10); + const {entryId, headword: originalLexeme} = await projectPage.api.getEntryAtIndex(10); expect(entryId).toBeTruthy(); expect(originalLexeme).toBeTruthy(); // Search and select the entry - await browsePage.selectEntryByFilter(originalLexeme); + await projectPage.selectEntryByFilter(originalLexeme); // Edit the lexeme const timestamp = Date.now().toString().slice(-6); const editMarker = `-E${timestamp}`; const newLexeme = originalLexeme + editMarker; - await browsePage.entryView.editLexemeForm(newLexeme); + await projectPage.entryView.editLexemeForm(newLexeme); // Verify via API that the change was saved await expect(async () => { - const savedLexeme = await browsePage.api.getEntryLexeme(entryId); + const savedLexeme = await projectPage.api.getEntryLexeme(entryId); expect(savedLexeme).toBe(newLexeme); }).toPass({timeout: 5000}); // Also verify the UI shows the new value - const lexemeInput = await browsePage.entryView.getLexemeInput(); + const lexemeInput = await projectPage.entryView.getLexemeInput(); await expect(lexemeInput).toHaveValue(newLexeme); }); }); diff --git a/frontend/viewer/tests/snapshots/playwright.config.ts b/frontend/viewer/tests/ui/playwright.config.ts similarity index 98% rename from frontend/viewer/tests/snapshots/playwright.config.ts rename to frontend/viewer/tests/ui/playwright.config.ts index 0e290793d8..52390997b9 100644 --- a/frontend/viewer/tests/snapshots/playwright.config.ts +++ b/frontend/viewer/tests/ui/playwright.config.ts @@ -42,7 +42,6 @@ export default defineConfig({ use: { baseURL: 'http://localhost:' + serverPort, - ignoreHTTPSErrors: true, /* Local storage to be populated for every test */ storageState: { cookies: [], diff --git a/frontend/viewer/tests/snapshots/project-view-snapshots.test.ts b/frontend/viewer/tests/ui/snapshots/project-view-snapshots.test.ts similarity index 100% rename from frontend/viewer/tests/snapshots/project-view-snapshots.test.ts rename to frontend/viewer/tests/ui/snapshots/project-view-snapshots.test.ts diff --git a/frontend/viewer/tests/snapshots/snapshot.ts b/frontend/viewer/tests/ui/snapshots/snapshot.ts similarity index 100% rename from frontend/viewer/tests/snapshots/snapshot.ts rename to frontend/viewer/tests/ui/snapshots/snapshot.ts diff --git a/frontend/viewer/tests/test.d.ts b/frontend/viewer/tests/ui/test.d.ts similarity index 100% rename from frontend/viewer/tests/test.d.ts rename to frontend/viewer/tests/ui/test.d.ts From d02c2277ad9c0be0b7d33bb11ff4e633e157d97c Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 11 May 2026 13:27:03 +0200 Subject: [PATCH 07/21] Rename "integration" to "launcher" --- .github/workflows/fw-lite.yaml | 2 +- frontend/viewer/AGENTS.md | 31 ++++++++++++------- frontend/viewer/Taskfile.yml | 13 ++++---- frontend/viewer/package.json | 8 ++--- .../fw-lite-launcher.test.ts | 9 +++--- frontend/viewer/vitest.config.ts | 6 ++-- 6 files changed, 39 insertions(+), 30 deletions(-) rename frontend/viewer/tests/{integration => launcher}/fw-lite-launcher.test.ts (90%) diff --git a/.github/workflows/fw-lite.yaml b/.github/workflows/fw-lite.yaml index c83cda9545..feb1d68b87 100644 --- a/.github/workflows/fw-lite.yaml +++ b/.github/workflows/fw-lite.yaml @@ -485,7 +485,7 @@ jobs: run: pnpm install - name: Test FwLite launcher working-directory: frontend/viewer - run: task e2e-test-helper-unit-tests + run: task test:launcher - uses: ./.github/actions/setup-k8s with: diff --git a/frontend/viewer/AGENTS.md b/frontend/viewer/AGENTS.md index bfc6d8e968..12e99ce0ac 100644 --- a/frontend/viewer/AGENTS.md +++ b/frontend/viewer/AGENTS.md @@ -26,22 +26,29 @@ dotnet build backend/FwLite/FwLiteShared/FwLiteShared.csproj The generated files are located in `src/lib/dotnet-types/generated-types/`. -### E2E Testing (Playwright) +### Testing + +| Suite | Location | Runnable here? | +|---|---|---| +| UI | `tests/ui/` | ✅ Yes — auto-starts a dev server with in-memory demo; no infra needed | +| E2E | `tests/e2e/` | ❌ Needs a Lexbox kind cluster + published FwLiteWeb binary | +| Launcher | `tests/launcher/` | ❌ Needs a published FwLiteWeb binary | + +**Don't run E2E or Launcher tests unless you've explicitly set up that infrastructure** — they fail loudly without it and the setup isn't part of normal dev. + +UI tests (the runnable ones) — from `frontend/viewer/`: -To run E2E tests for the viewer: ```bash -# From frontend/viewer/ directory -# Automatically starts dev server if needed -# For debugging e.g. with Chrome MCP: dev server/demo project will be available at port 5173 & path /testing/project-view/browse +# Dev server / demo project: http://localhost:5173/testing/project-view (useful for Chrome MCP debugging). -# Filter by test name (the ONLY RIGHT choice if testing specific features or changes) e.g. -task playwright-test-standalone -- entries-list +# Filter by test name (the ONLY RIGHT choice when testing specific features or changes), e.g. +task test:ui-standalone -- entries-list -# All tests -task playwright-test-standalone +# All UI tests +task test:ui-standalone -# In UI mode -task playwright-test-standalone -- entries-list --ui +# Playwright UI mode +task test:ui-standalone -- entries-list --ui ``` ## Tech Stack @@ -60,7 +67,7 @@ task playwright-test-standalone -- entries-list --ui | `src/lib/` | Shared components, entry editor | | `src/locales/` | i18n translation files (JSON) | | `.storybook/` | Component storybook | -| `tests/` | Playwright E2E tests | +| `tests/` | Playwright (UI + e2e) and Vitest (launcher) tests | ## i18n (Lingui) diff --git a/frontend/viewer/Taskfile.yml b/frontend/viewer/Taskfile.yml index 42dad7d978..6ecd37c244 100644 --- a/frontend/viewer/Taskfile.yml +++ b/frontend/viewer/Taskfile.yml @@ -58,12 +58,13 @@ tasks: MARKETING_SCREENSHOTS: true cmd: pnpm run test:ui snapshots {{.CLI_ARGS}} - test:e2e-setup: + test:e2e: + cmd: pnpm run test:e2e {{.CLI_ARGS}} + test:launcher: + desc: 'tests the fw lite launcher class against a real subprocess; FW_LITE_BINARY_PATH must point to a built binary' + cmd: pnpm test:launcher --run + test:build-launcher: + desc: 'publishes a self-contained FwLiteWeb binary into ./dist/fw-lite-server; prerequisite for test:e2e and test:launcher' deps: [build] cmds: - dotnet publish ../../backend/FwLite/FwLiteWeb/FwLiteWeb.csproj --configuration Release --self-contained --output ./dist/fw-lite-server - test:e2e: - cmd: pnpm run test:e2e {{.CLI_ARGS}} - e2e-test-helper-unit-tests: - desc: 'tests the fw lite launcher; FW_LITE_BINARY_PATH must point to a built binary' - cmd: pnpm test:integration --run diff --git a/frontend/viewer/package.json b/frontend/viewer/package.json index a00a6da5f6..e0a13ba7ac 100644 --- a/frontend/viewer/package.json +++ b/frontend/viewer/package.json @@ -14,16 +14,16 @@ "build-ffmpeg-worker": "vite build --config vite.config.ffmpeg-worker.ts", "preview": "vite preview", "pretest:ui": "playwright install chromium", - "pretest:e2e": "playwright install chromium", "test:ui": "playwright test -c ./tests/ui/playwright.config.ts", + "pretest:e2e": "playwright install chromium", "test:e2e": "playwright test -c ./tests/e2e/playwright.config.ts", - "test": "vitest run --project=!integration", - "test:vitest-ui": "vitest --ui", + "test": "vitest run --project=!launcher", "test:watch": "vitest", "test:storybook": "vitest --project=storybook", "test:unit": "vitest --project=unit", "test:browser": "vitest --project=browser", - "test:integration": "vitest --project=integration", + "test:launcher": "vitest --project=launcher", + "test:vitest-ui": "vitest --ui", "check": "svelte-check", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/frontend/viewer/tests/integration/fw-lite-launcher.test.ts b/frontend/viewer/tests/launcher/fw-lite-launcher.test.ts similarity index 90% rename from frontend/viewer/tests/integration/fw-lite-launcher.test.ts rename to frontend/viewer/tests/launcher/fw-lite-launcher.test.ts index ab62342a85..82456b6d3b 100644 --- a/frontend/viewer/tests/integration/fw-lite-launcher.test.ts +++ b/frontend/viewer/tests/launcher/fw-lite-launcher.test.ts @@ -1,15 +1,16 @@ -import {existsSync} from 'node:fs'; import {afterEach, beforeEach, describe, expect, it} from 'vitest'; + import {FwLiteLauncher} from '../e2e/helpers/fw-lite-launcher'; +import {existsSync} from 'node:fs'; import {fwLiteBinaryPath} from '../e2e/config'; // Hard-fail if the binary is missing. Running this suite at all means you've opted in -// (via `pnpm test:integration` or the e2e CI job). The default `pnpm test` excludes -// the integration project so unrelated workflows aren't burdened with building the binary. +// (via `pnpm test:launcher` or the e2e CI job). The default `pnpm test` excludes +// the launcher project so unrelated workflows aren't burdened with building the binary. if (!existsSync(fwLiteBinaryPath)) { throw new Error( `FW Lite binary not found at ${fwLiteBinaryPath}. ` + - `Build it (e.g. \`task -d frontend/viewer test:e2e-setup\`) ` + + `Build it (e.g. \`task -d frontend/viewer test:build-launcher\`) ` + `or set FW_LITE_BINARY_PATH to a built binary.` ); } diff --git a/frontend/viewer/vitest.config.ts b/frontend/viewer/vitest.config.ts index b280800e24..7bda42cf85 100644 --- a/frontend/viewer/vitest.config.ts +++ b/frontend/viewer/vitest.config.ts @@ -11,7 +11,7 @@ const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); const browserTestPattern = '**/*.browser.{test,spec}.?(c|m)[jt]s?(x)'; -const integrationTestPattern = './tests/integration/**/*.{test,spec}.?(c|m)[jt]s?(x)'; +const launcherTestPattern = './tests/launcher/**/*.{test,spec}.?(c|m)[jt]s?(x)'; const e2eTestPatterns = ['./tests/**']; const sharedAlias = [ @@ -61,9 +61,9 @@ export default defineConfig({ }, { test: { - name: 'integration', + name: 'launcher', environment: 'node', - include: [integrationTestPattern], + include: [launcherTestPattern], }, resolve: {alias: sharedAlias}, }, From 3c307692088f2e010bb7afe1d613dc7303876bea Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 11 May 2026 13:27:37 +0200 Subject: [PATCH 08/21] Remove unused shadcnMode --- frontend/viewer/src/ShadcnProjectView.svelte | 14 -------------- frontend/viewer/tests/ui/playwright.config.ts | 14 +++----------- 2 files changed, 3 insertions(+), 25 deletions(-) diff --git a/frontend/viewer/src/ShadcnProjectView.svelte b/frontend/viewer/src/ShadcnProjectView.svelte index 761fbb8e35..62110cca7c 100644 --- a/frontend/viewer/src/ShadcnProjectView.svelte +++ b/frontend/viewer/src/ShadcnProjectView.svelte @@ -1,17 +1,3 @@ - -