From f60a07544311617f6f3830c33fe91c2555c70030 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 11 May 2026 15:50:07 +0100 Subject: [PATCH] Add Playwright/bootRun/test.sh tasks (#6244) ## Description Consolidates Playwright running under cohesive Task namespaces, isolates Playwright state from the developer's local working tree, and swaps CI's frontend webserver from `vite` dev to `vite preview` against a pre-built `dist/`. ### `e2e:*` namespace Renames `.taskfiles/testing.yml` to `.taskfiles/e2e.yml` and consolidates everything Playwright-related under one `e2e:` namespace: - `e2e:stubbed` / `e2e:live` / `e2e:enterprise` / `e2e:cross-browser`: project-specific runners - `e2e:check` (no-Docker subset) and `e2e:check:all` (full) - `e2e:oauth:up` / `:down`, `e2e:saml:up` / `:down`: symmetric lifecycle for the keycloak compose stacks - `e2e:install`: Playwright browser install - `docker:test`: full Docker integration suite The redundant `frontend:test:e2e:*` project shortcuts are removed. CI workflows (`e2e-stubbed.yml`, `e2e-live.yml`, `build-enterprise.yml`, `nightly.yml`) are updated to call the new task names. ### Isolated Playwright state New `STIRLING_BASE_PATH` (and `-Dstirling.base-path=`) override in `InstallationPathConfig` redirects the entire state tree (configs, backups, customFiles, pipeline, logs) at startup. `task e2e:live` points it at `.test-state/playwright/` (purged on every invocation) so the suite never touches the developer's local DB, settings.yml or backups. `task e2e:live` auto-spawns gradle, waits for `/api/v1/info/status` to come up, runs Playwright, then tears down the whole backend process tree. ### CI runs Playwright against `vite preview` Builds the frontend up-front with `VITE_BUILD_FOR_PREVIEW=1` (forces absolute base so deep SPA routes resolve `/assets/...`) and the playwright `webServer` now uses `vite preview --port 5173 --strictPort` in CI. Avoids the per-page on-demand transform cost that was blowing the 30s navigation timeout under `--workers=3` on `all-tool-pages-load.spec.ts`. Local dev keeps `vite` dev for HMR. ### OAuth/SAML compose helpers `start-oauth-test.sh` and `start-saml-test.sh` gain a `--license-key ` (`-k`) flag so CI and scripted runs can skip the interactive license prompt. `start-oauth-test.sh` also moves from `for arg in "$@"` to a `while`-with-`shift` arg loop to support multi-arg flags consistently with the SAML script. ### Backend gradlew unification Drops the per-platform `cmd /c gradlew.bat` branches from `backend.yml` and routes every gradle invocation through `bash gradlew`. Works uniformly on Linux/macOS and Windows-with-Git-Bash. ### Compare.tsx flake fix (re-land of [#6316](https://github.com/Stirling-Tools/Stirling-PDF/pull/6316)) Piggybacks Anthony's never-merged fix from #6316. Without it, `e2e:stubbed` continues to flake under `--workers=3` on `compare.spec.ts`'s second-upload case via a React "Maximum update depth exceeded" infinite loop in the Compare auto-fill effect. CI traces from recent failed runs match exactly; 10 local runs of `compare.spec.ts` with `CI=1 --workers=3` pass cleanly with the fix applied. --------- Co-authored-by: James Brunton --- .github/workflows/build-enterprise.yml | 16 +- .github/workflows/e2e-live.yml | 46 +--- .github/workflows/e2e-stubbed.yml | 8 +- .github/workflows/nightly.yml | 4 +- .gitignore | 1 + .taskfiles/backend.yml | 69 ++---- .taskfiles/docker.yml | 6 + .taskfiles/e2e.yml | 204 ++++++++++++++++++ .taskfiles/frontend.yml | 12 -- Taskfile.yml | 3 + .../configuration/InstallationPathConfig.java | 12 ++ frontend/playwright.config.ts | 8 +- frontend/src/core/tools/Compare.tsx | 42 ++-- frontend/vite.config.ts | 47 ++-- testing/compose/start-oauth-test.sh | 39 +++- testing/compose/start-saml-test.sh | 34 ++- 16 files changed, 399 insertions(+), 152 deletions(-) create mode 100644 .taskfiles/e2e.yml diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml index 7300044d6..9ffcdb67a 100644 --- a/.github/workflows/build-enterprise.yml +++ b/.github/workflows/build-enterprise.yml @@ -63,7 +63,15 @@ jobs: - name: Install Task uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0 - name: Install Playwright (chromium only) - run: task frontend:test:e2e:install -- chromium + run: task e2e:install -- chromium + - name: Build frontend (needed for playwright's vite preview webServer) + # Enterprise tests target :8080 (Spring Boot's bundled frontend), but + # playwright's webServer config still launches `vite preview --port 5173` + # before any test run, which needs dist/ to exist. VITE_BUILD_FOR_PREVIEW + # forces absolute asset paths so vite preview can serve deep SPA routes. + env: + VITE_BUILD_FOR_PREVIEW: "1" + run: task frontend:build - name: Resolve kubernetes.docker.internal to localhost # The compose stacks set KC_HOSTNAME=kubernetes.docker.internal so @@ -140,7 +148,7 @@ jobs: wait_for_backend - name: Run enterprise OAuth Playwright tests id: oauth-tests - run: task frontend:test:e2e -- --project=enterprise --grep "OAuth" + run: task e2e:enterprise -- --grep "OAuth" - name: Stop backend + tear down OAuth Keycloak if: always() run: | @@ -213,7 +221,7 @@ jobs: wait_for_backend - name: Run enterprise SAML Playwright tests id: saml-tests - run: task frontend:test:e2e -- --project=enterprise --grep "SAML" + run: task e2e:enterprise -- --grep "SAML" - name: Stop backend + tear down SAML Keycloak if: always() run: | @@ -243,7 +251,7 @@ jobs: wait_for_backend - name: Run enterprise feature Playwright tests id: feature-tests - run: task frontend:test:e2e -- --project=enterprise --grep "Enterprise license" + run: task e2e:enterprise -- --grep "Enterprise license" - name: Print backend log on failure if: failure() run: | diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index 10d4ec347..47fd0a6c9 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -34,58 +34,26 @@ jobs: - name: Install Task uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0 - name: Install Playwright (chromium only) - run: task frontend:test:e2e:install -- chromium - - name: Start Spring Boot backend (background) + run: task e2e:install -- chromium + - name: Build frontend (production bundle for vite preview) env: - # Suppress the analytics opt-in modal that fires on first admin login when - # enableAnalytics is null (see Onboarding.tsx). The modal renders a Mantine - # overlay that intercepts pointer events on every tool page until dismissed, - # which causes every "click run button" assertion in the live suite to fail. - SYSTEM_ENABLEANALYTICS: "false" - # NOTE: SECURITY_INITIALLOGIN_USERNAME/PASSWORD are intentionally NOT set. - # The live-setup project's bootstrap spec performs the real first-login - # flow against the backend's default admin/stirling user, exercising the - # forced-password-change UI and leaving the DB at admin/adminadmin for - # the rest of the live suite. This is both real coverage of the first- - # login flow and a stronger seed than env-var-driven user creation. - run: | - nohup ./gradlew :stirling-pdf:bootRun > /tmp/backend.log 2>&1 & - echo $! > /tmp/backend.pid - - name: Wait for backend to become ready - run: | - start=$SECONDS - # 300 iterations × 2s = 10 minute ceiling - for i in $(seq 1 300); do - if curl -fsS http://localhost:8080/api/v1/info/status >/dev/null 2>&1; then - echo "Backend up after $((SECONDS - start))s" - exit 0 - fi - sleep 2 - done - echo "Backend did not become ready in $((SECONDS - start))s" - tail -200 /tmp/backend.log || true - exit 1 + VITE_BUILD_FOR_PREVIEW: "1" + run: task frontend:build - name: Run live E2E tests (chromium) id: live-tests - run: task frontend:test:e2e -- --project=live + run: task e2e:live - name: Print backend log on failure if: failure() && steps.live-tests.conclusion == 'failure' run: | echo "::group::Spring Boot backend log (last 500 lines)" - tail -500 /tmp/backend.log || echo "no backend log found" + tail -500 .test-state/playwright/backend.log || echo "no backend log found" echo "::endgroup::" - - name: Stop backend - if: always() - run: | - if [ -f /tmp/backend.pid ]; then - kill "$(cat /tmp/backend.pid)" 2>/dev/null || true - fi - name: Upload backend log if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: backend-log-live-${{ github.run_id }} - path: /tmp/backend.log + path: .test-state/playwright/backend.log retention-days: 7 - name: Upload Playwright report if: always() diff --git a/.github/workflows/e2e-stubbed.yml b/.github/workflows/e2e-stubbed.yml index 928f64e20..d2d41ea67 100644 --- a/.github/workflows/e2e-stubbed.yml +++ b/.github/workflows/e2e-stubbed.yml @@ -28,9 +28,13 @@ jobs: - name: Install Task uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0 - name: Install Playwright (chromium only) - run: task frontend:test:e2e:install -- chromium + run: task e2e:install -- chromium + - name: Build frontend (production bundle for vite preview) + env: + VITE_BUILD_FOR_PREVIEW: "1" + run: task frontend:build - name: Run stubbed E2E tests (chromium) - run: task frontend:test:e2e -- --project=stubbed --workers=3 + run: task e2e:stubbed -- --workers=3 - name: Upload Playwright report if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 56ece2f22..f43524670 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -35,10 +35,10 @@ jobs: - name: Install Task uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0 - name: Install all Playwright browsers - run: task frontend:test:e2e:install + run: task e2e:install - name: Run E2E tests (all browsers) - run: task frontend:test:e2e + run: task e2e:cross-browser - name: Upload Playwright report if: always() diff --git a/.gitignore b/.gitignore index b10c6cdc3..6abe13a31 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ exampleYmlFiles/stirling/ /testing/cucumber/junit/ /testing/cucumber/report.html /testing/.failed_tests +/.test-state/ SwaggerDoc.json # Runtime storage for uploaded files and user data (not Java source code) diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml index 66f2e376d..4fdc50a16 100644 --- a/.taskfiles/backend.yml +++ b/.taskfiles/backend.yml @@ -1,5 +1,8 @@ version: '3' +# All gradle invocations go through `bash gradlew` (the sh wrapper) so they work +# uniformly on Linux/macOS (native bash) and Windows-with-Git-Bash. + tasks: dev: desc: "Start backend dev server" @@ -10,58 +13,43 @@ tasks: env: SERVER_PORT: '{{.PORT}}' cmds: - - cmd: '{{if .AIENGINE_URL}}set "AIENGINE_URL={{.AIENGINE_URL}}" && set "AIENGINE_ENABLED=true" && {{end}}cmd /c gradlew.bat :stirling-pdf:bootRun' - platforms: [windows] - - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}./gradlew :stirling-pdf:bootRun' - platforms: [linux, darwin] + - '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}bash gradlew :stirling-pdf:bootRun' + + dev:bundled: + desc: "Clean + bootRun with frontend bundled into the backend (single :8080 server)" + ignore_error: true + cmds: + - bash gradlew clean bootRun -PbuildWithFrontend=true build: desc: "Full backend build" cmds: - - cmd: cmd /c gradlew.bat clean build - platforms: [windows] - - cmd: ./gradlew clean build - platforms: [linux, darwin] + - bash gradlew clean build build:fast: desc: "Build without tests" cmds: - - cmd: cmd /c gradlew.bat clean build -x test - platforms: [windows] - - cmd: ./gradlew clean build -x test - platforms: [linux, darwin] + - bash gradlew clean build -x test build:ci: desc: "Build for CI (formatting checked separately)" cmds: - - cmd: cmd /c gradlew.bat build -PnoSpotless - platforms: [windows] - - cmd: ./gradlew build -PnoSpotless - platforms: [linux, darwin] + - bash gradlew build -PnoSpotless test: desc: "Run backend tests" cmds: - - cmd: cmd /c gradlew.bat test - platforms: [windows] - - cmd: ./gradlew test - platforms: [linux, darwin] + - bash gradlew test format: desc: "Auto-fix code formatting" cmds: - - cmd: cmd /c gradlew.bat spotlessApply - platforms: [windows] - - cmd: ./gradlew spotlessApply - platforms: [linux, darwin] + - bash gradlew spotlessApply format:check: desc: "Check code formatting" cmds: - - cmd: cmd /c gradlew.bat spotlessCheck - platforms: [windows] - - cmd: ./gradlew spotlessCheck - platforms: [linux, darwin] + - bash gradlew spotlessCheck fix: desc: "Auto-fix backend" @@ -71,10 +59,7 @@ tasks: swagger: desc: "Generate OpenAPI docs" cmds: - - cmd: cmd /c gradlew.bat :stirling-pdf:copySwaggerDoc - platforms: [windows] - - cmd: ./gradlew :stirling-pdf:copySwaggerDoc - platforms: [linux, darwin] + - bash gradlew :stirling-pdf:copySwaggerDoc sources: - app/core/src/main/java/**/*.java - app/proprietary/src/main/java/**/*.java @@ -92,31 +77,19 @@ tasks: desc: "Print project version" silent: true cmds: - - cmd: cmd /c gradlew.bat printVersion --quiet | tail -1 - platforms: [windows] - - cmd: ./gradlew printVersion --quiet | tail -1 - platforms: [linux, darwin] + - bash gradlew printVersion --quiet | tail -1 licenses:check: desc: "Check dependency licenses" cmds: - - cmd: cmd /c gradlew.bat checkLicense --no-parallel - platforms: [windows] - - cmd: ./gradlew checkLicense --no-parallel - platforms: [linux, darwin] + - bash gradlew checkLicense --no-parallel licenses:generate: desc: "Check and generate dependency license report" cmds: - - cmd: cmd /c gradlew.bat checkLicense generateLicenseReport --no-parallel - platforms: [windows] - - cmd: ./gradlew checkLicense generateLicenseReport --no-parallel - platforms: [linux, darwin] + - bash gradlew checkLicense generateLicenseReport --no-parallel clean: desc: "Clean build artifacts" cmds: - - cmd: cmd /c gradlew.bat clean - platforms: [windows] - - cmd: ./gradlew clean - platforms: [linux, darwin] + - bash gradlew clean diff --git a/.taskfiles/docker.yml b/.taskfiles/docker.yml index eca54f4a8..abe885a13 100644 --- a/.taskfiles/docker.yml +++ b/.taskfiles/docker.yml @@ -55,3 +55,9 @@ tasks: desc: "Tail docker compose logs" cmds: - docker compose -f {{.COMPOSE_DIR}}/docker-compose.yml logs -f + + test: + desc: "Run full Docker integration test suite (builds all variants and tests them)" + ignore_error: true + cmds: + - bash testing/test.sh {{.CLI_ARGS}} diff --git a/.taskfiles/e2e.yml b/.taskfiles/e2e.yml new file mode 100644 index 000000000..23e2ad488 --- /dev/null +++ b/.taskfiles/e2e.yml @@ -0,0 +1,204 @@ +version: '3' + +tasks: + install: + desc: "Install Playwright browsers" + dir: frontend + deps: [ ':frontend:install' ] + cmds: + - npx playwright install {{.CLI_ARGS}} --with-deps + + stubbed: + desc: "Run stubbed E2E tests" + dir: frontend + deps: [ ':frontend:prepare' ] + cmds: + - npx playwright test --project=stubbed {{.CLI_ARGS}} + + live: + desc: "Run live E2E tests" + summary: | + Auto-spawns a Spring Boot backend with isolated state under + .test-state/playwright/ (purged on every run) so the suite doesn't + touch your local DB, settings.yml, backups or customFiles. The + backend is stopped automatically when the runner exits. + + Pass extra Playwright flags via -- : + task e2e:live -- --headed --grep merge + deps: + - live:backend + - live:runner + + live:backend: + internal: true + ignore_error: true + vars: + BASE_DIR: '{{.ROOT_DIR}}/.test-state/playwright' + env: + STIRLING_BASE_PATH: '{{.BASE_DIR}}' + # Suppress the analytics opt-in modal that fires on first admin login. + # It renders a Mantine overlay that intercepts pointer events and blocks + # FirstLoginSlide from rendering, so the bootstrap spec times out waiting + # for the password-change prompt. + SYSTEM_ENABLEANALYTICS: "false" + # NOTE: SECURITY_INITIALLOGIN_USERNAME/PASSWORD are intentionally NOT set. + # The live-setup project's bootstrap spec performs the real first-login + # flow against the backend's default admin/stirling user, exercising the + # forced-password-change UI and leaving the DB at admin/adminadmin for + # the rest of the live suite. This is both real coverage of the first- + # login flow and a stronger seed than env-var-driven user creation. + cmds: + # Wrapped in `bash -c` because Task's embedded shell (mvdan/sh) doesn't + # support `&` + `$!` + `wait` reliably (gradle was never actually + # backgrounded, so `$!` recorded a stale PID and the runner saw an + # immediate "backend died"). Real bash backgrounds gradle properly and + # gives us a stable PID to clean up. + - | + bash -c ' + set -e + rm -rf "{{.BASE_DIR}}" + mkdir -p "{{.BASE_DIR}}" + # Background gradle and record its PID so the runner can clean up + # the exact process tree (wrapper + forked Spring Boot JVM) without + # resorting to fuzzy `pkill -f` patterns. `wait` keeps this script + # alive for the lifetime of gradle so Task'"'"'s parallel deps stay + # synchronised. + bash gradlew :stirling-pdf:bootRun > "{{.BASE_DIR}}/backend.log" 2>&1 & + GRADLE_PID=$! + echo $GRADLE_PID > "{{.BASE_DIR}}/backend.pid" + wait $GRADLE_PID + ' + + live:runner: + internal: true + deps: [ ':frontend:prepare' ] + dir: frontend + vars: + BASE_DIR: '{{.ROOT_DIR}}/.test-state/playwright' + cmds: + # Wrapped in `bash -c` because Task's embedded shell (mvdan/sh) only + # supports `EXIT`/`ERR` in `trap`, not `INT`/`TERM`. CLI_ARGS are + # passed positionally so quoted args like --grep "foo bar" survive. + - | + bash -c ' + set +e + # bootRun forks a separate Spring Boot JVM as a child of the gradle + # wrapper; killing only the wrapper leaves that JVM orphaned holding + # :8080. Walk the recorded PID'"'"'s process tree via pgrep so we kill + # every descendant, then lsof as a last-resort backstop. + kill_tree() { + local pid=$1 + for child in $(pgrep -P "$pid" 2>/dev/null); do + kill_tree "$child" + done + kill "$pid" 2>/dev/null || true + } + cleanup() { + if [ -f "{{.BASE_DIR}}/backend.pid" ]; then + echo "Stopping backend..." + kill_tree "$(cat "{{.BASE_DIR}}/backend.pid")" + sleep 1 + lsof -ti:8080 2>/dev/null | xargs kill 2>/dev/null || true + echo "Backend stopped" + fi + } + # Trap so cleanup runs on every exit path: tests pass, tests fail, + # backend never starts, user Ctrl-Cs. Without this the gradle JVM + # is left orphaned holding :8080 whenever the wait loop times out. + trap cleanup EXIT INT TERM + + echo "Waiting for backend on :8080 (up to 10min; first compile can be slow)..." + WAITED=0 + while [ $WAITED -lt 600 ]; do + if curl -sf http://localhost:8080/api/v1/info/status 2>/dev/null | grep -q UP; then + echo "Backend ready, starting Playwright" + break + fi + # Bail early if the backend already died: no point waiting 10min. + if [ -f "{{.BASE_DIR}}/backend.pid" ]; then + BACKEND_PID=$(cat "{{.BASE_DIR}}/backend.pid") + if ! kill -0 "$BACKEND_PID" 2>/dev/null; then + echo "Backend process exited before becoming ready; last 100 log lines:" + tail -100 "{{.BASE_DIR}}/backend.log" 2>/dev/null || true + exit 1 + fi + fi + sleep 2 + WAITED=$((WAITED + 2)) + done + if [ $WAITED -ge 600 ]; then + echo "Backend did not become ready within 10min, aborting" + exit 1 + fi + + npx playwright test --project=live "$@" + ' bash {{.CLI_ARGS}} + + enterprise: + desc: "Run enterprise E2E tests" + summary: | + Requires an already-running keycloak compose stack on :8080. Bring one + up first with: + task e2e:oauth:up + task e2e:saml:up + dir: frontend + deps: [ ':frontend:prepare' ] + cmds: + - npx playwright test --project=enterprise {{.CLI_ARGS}} + + cross-browser: + desc: "Run stubbed E2E tests on Chromium, Firefox, and WebKit" + dir: frontend + deps: [ ':frontend:prepare' ] + cmds: + - npx playwright test --project=stubbed --project=stubbed-firefox --project=stubbed-webkit {{.CLI_ARGS}} + + check: + desc: "Run all E2E tests not requiring Docker" + cmds: + - task: stubbed + - task: live + + check:all: + desc: "Run all E2E tests" + summary: | + Includes the enterprise project, which requires a keycloak compose + stack on :8080. Bring one up first with: + task e2e:oauth:up + task e2e:saml:up + cmds: + - task: stubbed + - task: live + - task: enterprise + + oauth:up: + desc: "Start the OAuth keycloak test environment" + summary: | + Set LICENSE_KEY= to skip the interactive license prompt: + task e2e:oauth:up LICENSE_KEY=abc123 + Pass extra flags via -- : + task e2e:oauth:up -- --auto --nobuild + ignore_error: true + cmds: + - bash testing/compose/start-oauth-test.sh {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}} + + oauth:down: + desc: "Stop the OAuth keycloak test environment" + cmds: + - docker compose -f testing/compose/docker-compose-keycloak-oauth.yml down -v + + saml:up: + desc: "Start the SAML keycloak test environment" + summary: | + Set LICENSE_KEY= to skip the interactive license prompt: + task e2e:saml:up LICENSE_KEY=abc123 + Pass extra flags via -- : + task e2e:saml:up -- --auto --with-storage --nobuild --language sv-SE + ignore_error: true + cmds: + - bash testing/compose/start-saml-test.sh {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}} + + saml:down: + desc: "Stop the SAML keycloak test environment" + cmds: + - docker compose -f testing/compose/docker-compose-keycloak-saml.yml down -v diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 9d5d17a39..f17b827eb 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -284,18 +284,6 @@ tasks: cmds: - npx vitest --coverage - test:e2e: - desc: "Run E2E tests" - deps: [prepare] - cmds: - - npx playwright test {{.CLI_ARGS}} - - test:e2e:install: - desc: "Install E2E test browsers" - deps: [install] - cmds: - - npx playwright install {{.CLI_ARGS}} --with-deps - # ============================================================ # Code Generation # ============================================================ diff --git a/Taskfile.yml b/Taskfile.yml index ecdb97879..a4b71dce3 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -22,6 +22,9 @@ includes: desktop: taskfile: .taskfiles/desktop.yml dir: frontend + e2e: + taskfile: .taskfiles/e2e.yml + dir: . tasks: # ============================================================ diff --git a/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java b/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java index 8050a1659..c1cae3565 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java @@ -59,6 +59,18 @@ public class InstallationPathConfig { } private static String initializeBasePath() { + // Allow tests / harnesses to redirect the entire state tree (configs, + // backups, customFiles, pipeline, logs) to an isolated location via + // -Dstirling.base-path=... or STIRLING_BASE_PATH=... so a Playwright + // run never touches a developer's working state. + String override = System.getProperty("stirling.base-path"); + if (override == null || override.isBlank()) { + override = System.getenv("STIRLING_BASE_PATH"); + } + if (override != null && !override.isBlank()) { + boolean hasTrailingSeparator = override.endsWith("/") || override.endsWith("\\"); + return hasTrailingSeparator ? override : override + File.separator; + } return "." + File.separator; } diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index c45e145a1..1817a1361 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -96,7 +96,13 @@ export default defineConfig({ ], webServer: { - command: "npx vite", + // In CI, serve a pre-built `dist/` via `vite preview` so the heavy tool + // pages don't pay vite's on-demand transform cost on first hit (which + // blew the 30s navigationTimeout under --workers=3 — see + // all-tool-pages-load.spec.ts). Locally, keep `vite` dev for HMR. + command: process.env.CI + ? "npx vite preview --port 5173 --strictPort" + : "npx vite", url: "http://localhost:5173", reuseExistingServer: !process.env.CI, timeout: 120_000, diff --git a/frontend/src/core/tools/Compare.tsx b/frontend/src/core/tools/Compare.tsx index 7cc36b86b..01bf5e2e2 100644 --- a/frontend/src/core/tools/Compare.tsx +++ b/frontend/src/core/tools/Compare.tsx @@ -157,33 +157,39 @@ const Compare = (props: BaseToolProps) => { } }, []); - // Track previous file count to detect the transition to exactly 2 files. - const prevAllIdsLengthRef = useRef(null); - - // Auto-fill slots when the file count first reaches 2; respect manual picker changes after that. + // Keep slot params in sync with the current workbench/selection state. + // + // The effect must be idempotent: it can fire repeatedly during an upload + // burst (ADD_FILES, SET_SELECTED_FILES, and downstream dispatches all land + // in quick succession), and any "did the count transition?" tracking via + // a ref races with React's batched re-renders under load. With workers=3 + // in CI, that race surfaced as a "Maximum update depth exceeded" crash + // when the second slot was filled. So the implementation derives the + // target params purely from current state and bails when there's nothing + // to change. useEffect(() => { const selectedIds = fileState.ui.selectedFileIds as FileId[]; const allIds = fileState.files.ids as FileId[]; - const prevLength = prevAllIdsLengthRef.current; - prevAllIdsLengthRef.current = allIds.length; - - if (allIds.length === 2 && prevLength !== 2) { - // Transitioned to exactly 2 files — auto-fill both slots. - const [firstId, secondId] = allIds as [FileId, FileId]; - fileActions.setSelectedFiles([firstId, secondId]); - base.params.setParameters((prev) => { - if (prev.baseFileId === firstId && prev.comparisonFileId === secondId) - return prev; - return { ...prev, baseFileId: firstId, comparisonFileId: secondId }; - }); - return; - } if (selectedIds.length > 2) { fileActions.setSelectedFiles(selectedIds.slice(0, 2) as FileId[]); return; } + // When exactly 2 files are loaded but the selection doesn't cover both, + // promote the selection so Compare can run. Skips when the selection is + // already correct so we don't dispatch a fresh array reference and + // retrigger this effect. Covers the upload window where ADD_FILES has + // landed but SET_SELECTED_FILES is still in flight. + if (allIds.length === 2) { + const selectionMatchesAllIds = + selectedIds.length === 2 && + allIds.every((id) => selectedIds.includes(id)); + if (!selectionMatchesAllIds) { + fileActions.setSelectedFiles([...allIds] as FileId[]); + } + } + // Sticky slot mapping: preserve the current slot if its file is still selected, // otherwise fill empty slots from the selection in order. This lets users add/remove // files from the selection without the other slot being clobbered. diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 65d2c0ae4..0f1775dd3 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -49,6 +49,21 @@ export default defineConfig(async ({ mode }) => { xfwd: true, }; + // Shared between `vite` (dev) and `vite preview` (production-build serve, used + // in CI/E2E) so the live test suite still resolves /api → :8080. + const backendProxyConfig = + effectiveMode === "desktop" + ? undefined + : { + "/api": backendProxy, + "/oauth2": backendProxy, + "/saml2": backendProxy, + "/login/oauth2": backendProxy, + "/login/saml2": backendProxy, + "/swagger-ui": backendProxy, + "/v1/api-docs": backendProxy, + }; + return { plugins: [ react(), @@ -107,19 +122,25 @@ export default defineConfig(async ({ mode }) => { ignored: ["**/src-tauri/**"], }, // Only use proxy in web mode - Tauri handles backend connections directly - proxy: - effectiveMode === "desktop" - ? undefined - : { - "/api": backendProxy, - "/oauth2": backendProxy, - "/saml2": backendProxy, - "/login/oauth2": backendProxy, - "/login/saml2": backendProxy, - "/swagger-ui": backendProxy, - "/v1/api-docs": backendProxy, - }, + proxy: backendProxyConfig, }, - base: env.RUN_SUBPATH ? `/${env.RUN_SUBPATH}` : "./", + preview: { + host: true, + port: 5173, + strictPort: true, + proxy: backendProxyConfig, + }, + // base: "./" produces relative asset URLs which work when dist/ is served + // at any path (e.g. Spring Boot bundling the frontend at /). But under + // `vite preview` for deep SPA routes (e.g. /workflow/sign/), the + // browser resolves ./assets/X.js relative to the current path → 404, then + // SPA fallback returns index.html as text/html and React never mounts. + // VITE_BUILD_FOR_PREVIEW=1 (set by the CI playwright steps) overrides to + // an absolute base so deep-route asset paths resolve to /assets/... + base: env.RUN_SUBPATH + ? `/${env.RUN_SUBPATH}` + : process.env.VITE_BUILD_FOR_PREVIEW === "1" + ? "/" + : "./", }; }); diff --git a/testing/compose/start-oauth-test.sh b/testing/compose/start-oauth-test.sh index a4f35d957..f870ed72f 100644 --- a/testing/compose/start-oauth-test.sh +++ b/testing/compose/start-oauth-test.sh @@ -15,27 +15,52 @@ echo "" AUTO_LOGIN=false FORCE_ALL_LOGIN=false COMPOSE_UP_ARGS=(-d --build) -for arg in "$@"; do - case "$arg" in +while [[ $# -gt 0 ]]; do + case "$1" in --auto) AUTO_LOGIN=true + shift ;; --all) FORCE_ALL_LOGIN=true + shift ;; --nobuild) COMPOSE_UP_ARGS=(-d) + shift + ;; + --license-key) + if [[ -z "${2:-}" ]]; then + echo -e "${RED}Missing value for --license-key${NC}" + exit 1 + fi + export PREMIUM_KEY="$2" + shift 2 + ;; + --license-key=*) + export PREMIUM_KEY="${1#*=}" + shift + ;; + -k) + if [[ -z "${2:-}" ]]; then + echo -e "${RED}Missing value for -k${NC}" + exit 1 + fi + export PREMIUM_KEY="$2" + shift 2 ;; -h|--help) - echo "Usage: $0 [--auto] [--nobuild]" + echo "Usage: $0 [--auto] [--all] [--nobuild] [--license-key ]" echo "" - echo " --auto Enable SSO auto-login and force OAuth-only login method" - echo " --all Force login method to allow all providers (overrides --auto)" - echo " --nobuild Skip building images (use existing images)" + echo " --auto Enable SSO auto-login and force OAuth-only login method" + echo " --all Force login method to allow all providers (overrides --auto)" + echo " --nobuild Skip building images (use existing images)" + echo " --license-key Premium license key (skips the interactive prompt)" + echo " Equivalent to setting PREMIUM_KEY in the environment." exit 0 ;; *) - echo -e "${RED}Unknown option: $arg${NC}" + echo -e "${RED}Unknown option: $1${NC}" exit 1 ;; esac diff --git a/testing/compose/start-saml-test.sh b/testing/compose/start-saml-test.sh index 3a58606b2..1df63e0cc 100755 --- a/testing/compose/start-saml-test.sh +++ b/testing/compose/start-saml-test.sh @@ -55,14 +55,36 @@ while [[ $# -gt 0 ]]; do DEFAULT_LANGUAGE="$2" shift 2 ;; + --license-key) + if [[ -z "${2:-}" ]]; then + echo -e "${RED}Missing value for --license-key${NC}" + exit 1 + fi + export PREMIUM_KEY="$2" + shift 2 + ;; + --license-key=*) + export PREMIUM_KEY="${1#*=}" + shift + ;; + -k) + if [[ -z "${2:-}" ]]; then + echo -e "${RED}Missing value for -k${NC}" + exit 1 + fi + export PREMIUM_KEY="$2" + shift 2 + ;; -h|--help) - echo "Usage: $0 [--auto] [--with-storage] [--nobuild] [--language ]" + echo "Usage: $0 [--auto] [--with-storage] [--nobuild] [--language ] [--license-key ]" echo "" - echo " --auto Enable SSO auto-login and force SAML-only login method" - echo " --with-storage Enable the file storage + link-sharing feature" - echo " (required to test /share/ flows)" - echo " --nobuild Skip building images (use existing images)" - echo " --language Set system default locale (e.g. de-DE, sv-SE)" + echo " --auto Enable SSO auto-login and force SAML-only login method" + echo " --with-storage Enable the file storage + link-sharing feature" + echo " (required to test /share/ flows)" + echo " --nobuild Skip building images (use existing images)" + echo " --language Set system default locale (e.g. de-DE, sv-SE)" + echo " --license-key Premium license key (skips the interactive prompt)" + echo " Equivalent to setting PREMIUM_KEY in the environment." exit 0 ;; *)