From fdbab2f49e7d4cd879e1e096e9055af47c1a8f8b Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Wed, 8 Apr 2026 17:53:52 +0200 Subject: [PATCH] feat: add Karate UI e2e tests with data-e2e selectors (#124) - Add e2e/ui/ as peer project to e2e/api/ (own pom.xml, runner, config) - 5 critical UI journeys: upload, delete, analysis, batch-progress, rechunk - 4 local-only tests: sidebar, i18n, error-states, pipeline-options - 1 full happy path workflow covering all modes - Add data-e2e attributes on all tested Vue components (decoupled from CSS) - Add CONVENTIONS.md with 7 golden rules for writing Karate UI tests - Update CI with dedicated e2e-ui job (Chrome headless, --no-sandbox) - Update docs/contributing.md with UI test instructions Closes #124 --- .github/workflows/ci.yml | 73 +++++- .gitignore | 3 + docs/contributing.md | 28 +++ e2e/.gitignore | 2 - e2e/CONVENTIONS.md | 212 ++++++++++++++++++ e2e/{ => api}/README.md | 19 +- e2e/api/pom.xml | 58 +++++ e2e/{ => api}/src/test/java/E2ERunner.java | 0 .../resources/analyses/batch-progress.feature | 0 .../test/resources/analyses/cancel.feature | 0 .../analyses/create-and-poll.feature | 0 .../analyses/pipeline-options.feature | 0 .../test/resources/analyses/rechunk.feature | 0 .../common/data/generated/.gitignore | 0 .../common/data/schemas/analysis.json | 0 .../common/data/schemas/document.json | 0 .../resources/common/data/schemas/health.json | 0 .../data/test-cases/invalid-uploads.json | 0 .../data/test-cases/pipeline-options.json | 0 .../resources/common/helpers/analyze.feature | 0 .../resources/common/helpers/cleanup.feature | 0 .../resources/common/helpers/upload.feature | 0 .../src/test/resources/documents/crud.feature | 0 .../test/resources/documents/preview.feature | 0 .../documents/size-validation.feature | 0 .../test/resources/documents/upload.feature | 0 .../src/test/resources/health/health.feature | 0 .../src/test/resources/karate-config.js | 0 .../workflows/concurrent-analyses.feature | 0 .../workflows/full-happy-path.feature | 0 e2e/generate-test-data.py | 20 +- e2e/ui/README.md | 93 ++++++++ e2e/{ => ui}/pom.xml | 2 +- e2e/ui/src/test/java/UIRunner.java | 24 ++ .../test/resources/analyses/analysis.feature | 50 +++++ .../resources/analyses/batch-progress.feature | 32 +++ .../analyses/pipeline-options.feature | 64 ++++++ .../test/resources/analyses/rechunk.feature | 62 +++++ .../resources/common/helpers/analyze.feature | 26 +++ .../common/helpers/cleanup-by-name.feature | 13 ++ .../resources/common/helpers/cleanup.feature | 12 + .../common/helpers/ui-upload.feature | 10 + .../common/helpers/ui-wait-analysis.feature | 10 + .../resources/common/helpers/upload.feature | 14 ++ .../test/resources/documents/delete.feature | 30 +++ .../resources/documents/error-states.feature | 22 ++ .../test/resources/documents/upload.feature | 47 ++++ e2e/ui/src/test/resources/karate-config.js | 23 ++ .../test/resources/navigation/i18n.feature | 22 ++ .../test/resources/navigation/sidebar.feature | 48 ++++ .../resources/workflows/full-ui-path.feature | 79 +++++++ frontend/src/app/App.vue | 3 +- .../src/features/analysis/ui/ResultTabs.vue | 24 +- .../src/features/chunking/ui/ChunkPanel.vue | 24 +- .../src/features/document/ui/DocumentList.vue | 10 +- .../features/document/ui/DocumentUpload.vue | 5 +- .../features/settings/ui/SettingsPanel.vue | 14 +- frontend/src/pages/StudioPage.vue | 45 ++-- frontend/src/shared/ui/AppSidebar.vue | 37 ++- 59 files changed, 1187 insertions(+), 73 deletions(-) delete mode 100644 e2e/.gitignore create mode 100644 e2e/CONVENTIONS.md rename e2e/{ => api}/README.md (76%) create mode 100644 e2e/api/pom.xml rename e2e/{ => api}/src/test/java/E2ERunner.java (100%) rename e2e/{ => api}/src/test/resources/analyses/batch-progress.feature (100%) rename e2e/{ => api}/src/test/resources/analyses/cancel.feature (100%) rename e2e/{ => api}/src/test/resources/analyses/create-and-poll.feature (100%) rename e2e/{ => api}/src/test/resources/analyses/pipeline-options.feature (100%) rename e2e/{ => api}/src/test/resources/analyses/rechunk.feature (100%) rename e2e/{ => api}/src/test/resources/common/data/generated/.gitignore (100%) rename e2e/{ => api}/src/test/resources/common/data/schemas/analysis.json (100%) rename e2e/{ => api}/src/test/resources/common/data/schemas/document.json (100%) rename e2e/{ => api}/src/test/resources/common/data/schemas/health.json (100%) rename e2e/{ => api}/src/test/resources/common/data/test-cases/invalid-uploads.json (100%) rename e2e/{ => api}/src/test/resources/common/data/test-cases/pipeline-options.json (100%) rename e2e/{ => api}/src/test/resources/common/helpers/analyze.feature (100%) rename e2e/{ => api}/src/test/resources/common/helpers/cleanup.feature (100%) rename e2e/{ => api}/src/test/resources/common/helpers/upload.feature (100%) rename e2e/{ => api}/src/test/resources/documents/crud.feature (100%) rename e2e/{ => api}/src/test/resources/documents/preview.feature (100%) rename e2e/{ => api}/src/test/resources/documents/size-validation.feature (100%) rename e2e/{ => api}/src/test/resources/documents/upload.feature (100%) rename e2e/{ => api}/src/test/resources/health/health.feature (100%) rename e2e/{ => api}/src/test/resources/karate-config.js (100%) rename e2e/{ => api}/src/test/resources/workflows/concurrent-analyses.feature (100%) rename e2e/{ => api}/src/test/resources/workflows/full-happy-path.feature (100%) create mode 100644 e2e/ui/README.md rename e2e/{ => ui}/pom.xml (97%) create mode 100644 e2e/ui/src/test/java/UIRunner.java create mode 100644 e2e/ui/src/test/resources/analyses/analysis.feature create mode 100644 e2e/ui/src/test/resources/analyses/batch-progress.feature create mode 100644 e2e/ui/src/test/resources/analyses/pipeline-options.feature create mode 100644 e2e/ui/src/test/resources/analyses/rechunk.feature create mode 100644 e2e/ui/src/test/resources/common/helpers/analyze.feature create mode 100644 e2e/ui/src/test/resources/common/helpers/cleanup-by-name.feature create mode 100644 e2e/ui/src/test/resources/common/helpers/cleanup.feature create mode 100644 e2e/ui/src/test/resources/common/helpers/ui-upload.feature create mode 100644 e2e/ui/src/test/resources/common/helpers/ui-wait-analysis.feature create mode 100644 e2e/ui/src/test/resources/common/helpers/upload.feature create mode 100644 e2e/ui/src/test/resources/documents/delete.feature create mode 100644 e2e/ui/src/test/resources/documents/error-states.feature create mode 100644 e2e/ui/src/test/resources/documents/upload.feature create mode 100644 e2e/ui/src/test/resources/karate-config.js create mode 100644 e2e/ui/src/test/resources/navigation/i18n.feature create mode 100644 e2e/ui/src/test/resources/navigation/sidebar.feature create mode 100644 e2e/ui/src/test/resources/workflows/full-ui-path.feature diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1591ce7..4361f4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,14 +126,81 @@ jobs: if [[ "${{ github.base_ref }}" == release/* ]] || [[ "${{ github.ref }}" == refs/heads/release/* ]]; then KARATE_TAGS="@smoke,@regression,@e2e" fi - mvn test -f e2e/pom.xml -DbaseUrl=http://localhost:3000 -Dkarate.options="--tags ${KARATE_TAGS}" + mvn test -f e2e/api/pom.xml -DbaseUrl=http://localhost:3000 -Dkarate.options="--tags ${KARATE_TAGS}" - name: Upload Karate reports if: always() uses: actions/upload-artifact@v4 with: - name: karate-reports - path: e2e/target/karate-reports/ + name: karate-api-reports + path: e2e/api/target/karate-reports/ + + - name: Tear down + if: always() + run: docker compose down + + e2e-ui: + name: E2E UI tests (Karate UI) + if: github.ref == 'refs/heads/main' + needs: [backend, frontend] + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: actions/setup-java@v4 + with: + java-version: "17" + distribution: temurin + cache: maven + + - name: Install Chrome + uses: browser-actions/setup-chrome@v1 + with: + chrome-version: stable + + - name: Generate test PDFs + run: | + pip install fpdf2 pypdfium2 + python e2e/generate-test-data.py + + - name: Start stack + run: docker compose up -d --wait --build + timeout-minutes: 10 + env: + RATE_LIMIT_RPM: "0" + + - name: Wait for health + run: | + for i in $(seq 1 30); do + if curl -sf http://localhost:3000/api/health > /dev/null 2>&1; then + echo "Backend healthy" + exit 0 + fi + echo "Waiting for backend... ($i/30)" + sleep 5 + done + echo "Backend failed to start" + docker compose logs + exit 1 + + - name: Run critical UI tests + run: > + mvn test -f e2e/ui/pom.xml + -DbaseUrl=http://localhost:3000 + -DuiBaseUrl=http://localhost:3000 + -Dkarate.options="--tags @critical" + + - name: Upload Karate UI reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: karate-ui-reports + path: e2e/ui/target/karate-reports/ - name: Tear down if: always() diff --git a/.gitignore b/.gitignore index 97313cf..eb53104 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ hs_err_pid* # Docker docker-compose.override.yml + +# E2E tests — Maven build outputs & Chrome user data +e2e/**/target/ diff --git a/docs/contributing.md b/docs/contributing.md index 44412e3..0f4c6a8 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -76,6 +76,34 @@ npx prettier --write src/ # auto-format npm run test:run ``` +=== "E2E API (Karate)" + + ```bash + # Generate test PDFs + start stack + python e2e/generate-test-data.py + docker compose up -d --wait + + # Run all API tests + mvn test -f e2e/api/pom.xml + + # Or by tag: @smoke, @regression, @e2e + mvn test -f e2e/api/pom.xml -Dkarate.options="--tags @smoke" + ``` + +=== "E2E UI (Karate UI)" + + ```bash + # Generate test PDFs + start stack (if not already running) + python e2e/generate-test-data.py + docker compose up -d --wait + + # Run critical UI tests (CI scope) + mvn test -f e2e/ui/pom.xml -Dkarate.options="--tags @critical" + + # Run all UI tests (local scope) + mvn test -f e2e/ui/pom.xml -Dkarate.options="--tags @ui" + ``` + All tests must pass before submitting a PR. ## Pull Request Guidelines diff --git a/e2e/.gitignore b/e2e/.gitignore deleted file mode 100644 index c8d04f8..0000000 --- a/e2e/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -e2e/target/ -e2e/target/ diff --git a/e2e/CONVENTIONS.md b/e2e/CONVENTIONS.md new file mode 100644 index 0000000..13d1262 --- /dev/null +++ b/e2e/CONVENTIONS.md @@ -0,0 +1,212 @@ +# E2E Test Conventions — Karate & Karate UI + +Rules and patterns for writing reliable, maintainable e2e tests in this project. + +## Architecture + +``` +e2e/ +├── generate-test-data.py # Shared — generates PDFs for both suites +├── api/ # Karate API tests (HTTP-only, no browser) +│ ├── pom.xml +│ └── src/test/... +└── ui/ # Karate UI tests (browser, Chrome headless) + ├── pom.xml + └── src/test/... +``` + +API and UI are **peer projects** — same level, each with its own `pom.xml`, runner, and `karate-config.js`. Never nest one inside the other. + +## Golden rules + +### 1. Never use `Thread.sleep()` — use `retry()` or `waitFor()` + +```gherkin +# BAD — blocks the thread, ignores Karate retry mechanism +* def wait = function(){ java.lang.Thread.sleep(2000) } +* wait() + +# GOOD — uses Karate's built-in retry with logging +* retry(30, 1000).waitFor('.chunk-card') +``` + +For "wait until one of several conditions": + +```gherkin +# GOOD — retry().script() returns truthy when the expression matches +* retry(120, 1000).script("document.querySelector('.result-tabs') || document.querySelector('.error')") +``` + +### 2. Never use `delay()` — use `waitFor()` on the expected outcome + +```gherkin +# BAD — arbitrary wait, flaky on slow CI +* driver.inputFile('input[type=file]', filePath) +* delay(2000) +* waitFor('.doc-item') + +# GOOD — wait for the actual result +* driver.inputFile('input[type=file]', filePath) +* waitFor('.doc-item') +``` + +The **only acceptable `delay()`** is for CSS animations that have no observable DOM change (e.g., a 250ms sidebar transition). Even then, prefer `retry().script()` on the final state. + +### 3. Use `karate.sizeOf()` for element counts — never raw `.length` + +```gherkin +# BAD — .length may return #notpresent depending on Karate context +* def tabs = locateAll('.tab-btn') +* match tabs.length == 3 + +# BAD — bypasses Karate's auto-wait and retry mechanisms +* def count = script("document.querySelectorAll('.tab-btn').length") + +# GOOD — idiomatic Karate +* match karate.sizeOf(locateAll('.tab-btn')) == 3 + +# GOOD — for assertions with > +* assert karate.sizeOf(locateAll('.element-card')) > 0 +``` + +### 4. Use `data-e2e` attributes — never depend on CSS classes + +```gherkin +# BAD — breaks when a dev renames a class for styling +* waitFor('.doc-item') +* click('.chunk-card') + +# BAD — breaks when locale is FR +* click('{a}History') + +# GOOD — decoupled from CSS and i18n +* waitFor('[data-e2e=doc-item]') +* click('[data-e2e=chunk-card]') +* click('[data-e2e=nav-history]') +``` + +CSS classes are for **styling**, `data-e2e` attributes are for **testing**. A frontend dev must be free to rename `.doc-item` to `.document-card` without breaking a single test. Add `data-e2e="xxx"` to every element the tests interact with. + +Convention for `data-e2e` values: +- **kebab-case**, descriptive: `doc-item`, `upload-zone`, `run-btn`, `result-tabs` +- **Unique per role**, not per instance: use `doc-item` for all doc items (plural via `locateAll`), `tab-btn` for all tabs +- **Never** use CSS class names as `data-e2e` values — if they happen to match today, rename the `data-e2e` to avoid confusion + +If you must match on text (e.g., i18n test), set the locale explicitly first: + +```gherkin +* click('{button}EN') +* waitFor('.sidebar') +# Now it's safe to assert on English text +``` + +### 5. Setup via API, verify via UI, cleanup via API + +```gherkin +# Setup — fast, deterministic +* def result = call read('classpath:common/helpers/upload.feature') { file: 'small.pdf' } +* def docId = result.docId + +# Verify — the actual UI test +* driver uiBaseUrl + '/studio' +* waitFor('.doc-item') +* click('.doc-item') +... + +# Cleanup — fast, doesn't depend on UI state +* call read('classpath:common/helpers/cleanup.feature') { docId: '#(docId)' } +``` + +Exception: when the test IS about the UI action (e.g., upload.feature tests the upload UI itself). + +### 6. Extract repeated patterns into callable helpers + +```gherkin +# BAD — 5 lines of cleanup duplicated in every scenario +Given path '/api/documents' +When method GET +Then status 200 +* def uploaded = karate.filter(response, function(d){ return d.filename == 'small.pdf' }) +* def cleanupId = uploaded[0].id +* call read('classpath:common/helpers/cleanup.feature') { docId: '#(cleanupId)' } + +# GOOD — one-liner via helper +* call read('classpath:common/helpers/cleanup-by-name.feature') { filename: 'small.pdf' } +``` + +Helpers go in `common/helpers/` with the `@ignore` tag and a doc comment showing usage. + +### 7. Use `optional()` for elements that may or may not appear + +```gherkin +# GOOD — doesn't fail if the chevron is already open +* def chevronOpen = optional('.config-chevron.open') +* if (!chevronOpen.present) click('.config-toggle') +``` + +Never `waitFor()` an element that might not exist (e.g., a spinner that flashes too fast). + +## Tag strategy + +| Tag | Scope | Run when | +|-----|-------|----------| +| `@critical` | 5 core user journeys | CI — merge to `main` | +| `@ui` | All UI features | Local dev | +| `@smoke` | API health checks | Every PR | +| `@regression` | API full coverage | PR to `release/*` | +| `@e2e` | API cross-domain workflows | PR to `release/*` | +| `@ignore` | Callable helpers | Never run directly | + +## Driver config (karate-config.js) + +```javascript +karate.configure('driver', { + type: 'chrome', + headless: true, + showDriverLog: false, + addOptions: ['--no-sandbox', '--disable-gpu'], // required for CI Linux + screenshotOnFailure: true // auto-screenshot on failure +}); +``` + +- `--no-sandbox` — mandatory in GitHub Actions / Docker containers +- `--disable-gpu` — avoids GPU-related headless issues +- `screenshotOnFailure` — auto-captures the browser state on failure for debugging + +## File naming + +| File | Naming convention | +|------|-------------------| +| Feature files | `kebab-case.feature` (e.g., `batch-progress.feature`) | +| Helpers | `kebab-case.feature` in `common/helpers/`, prefixed `ui-` for UI-specific | +| Runners | `PascalCase.java` (e.g., `UIRunner.java`, `E2ERunner.java`) | +| Config | `karate-config.js` (Karate convention) | + +## Running tests + +```bash +# Generate test PDFs (required once, or after clean) +python3 e2e/generate-test-data.py + +# API tests +mvn test -f e2e/api/pom.xml -Dkarate.options="--tags @smoke" + +# UI tests — critical (CI scope) +mvn test -f e2e/ui/pom.xml -Dkarate.options="--tags @critical" + +# UI tests — all (local scope) +mvn test -f e2e/ui/pom.xml -Dkarate.options="--tags @ui" +``` + +## Common pitfalls + +| Pitfall | Fix | +|---------|-----| +| `locateAll().length` returns `#notpresent` | Use `karate.sizeOf(locateAll(...))` | +| `match x > 0` fails with "no step-definition" | Use `assert x > 0` for numeric comparisons | +| `if (...) call read(...)` fails with JS eval error | Use `karate.call()` inside `if` expressions | +| `input()` on `input[type=file]` crashes | Use `driver.inputFile()` for file inputs | +| `waitFor('.spinner')` times out on fast ops | Use `optional()` or skip — wait for the result instead | +| Nav links break when locale changes | Use `data-e2e` attributes, not text matchers | +| Tests break when CSS class is renamed | Use `[data-e2e=xxx]` selectors, never `.class-name` | +| Tests fail on CI but pass locally | Add `--no-sandbox` to Chrome options | diff --git a/e2e/README.md b/e2e/api/README.md similarity index 76% rename from e2e/README.md rename to e2e/api/README.md index ee2b8a8..b4d5d34 100644 --- a/e2e/README.md +++ b/e2e/api/README.md @@ -12,14 +12,14 @@ End-to-end API tests for Docling Studio using [Karate](https://karatelabs.github ## Quick start ```bash -# 1. Generate test PDFs +# 1. Generate test PDFs (from repo root) python e2e/generate-test-data.py # 2. Start the stack docker compose up -d --wait -# 3. Run all tests -mvn test -f e2e/pom.xml +# 3. Run all API tests +mvn test -f e2e/api/pom.xml # 4. Tear down docker compose down @@ -29,26 +29,25 @@ docker compose down ```bash # Smoke only (~30s) -mvn test -f e2e/pom.xml -Dkarate.options="--tags @smoke" +mvn test -f e2e/api/pom.xml -Dkarate.options="--tags @smoke" # Regression (~2min) -mvn test -f e2e/pom.xml -Dkarate.options="--tags @regression" +mvn test -f e2e/api/pom.xml -Dkarate.options="--tags @regression" # Full E2E workflows (~5min) -mvn test -f e2e/pom.xml -Dkarate.options="--tags @e2e" +mvn test -f e2e/api/pom.xml -Dkarate.options="--tags @e2e" ``` ## Custom base URL ```bash -mvn test -f e2e/pom.xml -DbaseUrl=http://your-host:8000 +mvn test -f e2e/api/pom.xml -DbaseUrl=http://your-host:8000 ``` ## Structure ``` -e2e/ -├── generate-test-data.py # Generates test PDFs (no binaries in repo) +e2e/api/ ├── pom.xml # Maven + Karate dependency ├── src/test/java/ │ └── E2ERunner.java # JUnit5 Karate runner @@ -68,4 +67,4 @@ e2e/ ## Reports -After a run, Karate HTML reports are in `e2e/target/karate-reports/`. +After a run, Karate HTML reports are in `e2e/api/target/karate-reports/`. diff --git a/e2e/api/pom.xml b/e2e/api/pom.xml new file mode 100644 index 0000000..7f4c248 --- /dev/null +++ b/e2e/api/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + + com.scub.docling-studio + e2e-api-tests + 0.3.1 + jar + + + 17 + 17 + UTF-8 + 1.5.0 + + + + + io.karatelabs + karate-core + ${karate.version} + test + + + io.karatelabs + karate-junit5 + ${karate.version} + test + + + + + + + src/test/resources + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + -Dfile.encoding=UTF-8 + + **/*Runner.java + + + ${karate.options} + + + + + + diff --git a/e2e/src/test/java/E2ERunner.java b/e2e/api/src/test/java/E2ERunner.java similarity index 100% rename from e2e/src/test/java/E2ERunner.java rename to e2e/api/src/test/java/E2ERunner.java diff --git a/e2e/src/test/resources/analyses/batch-progress.feature b/e2e/api/src/test/resources/analyses/batch-progress.feature similarity index 100% rename from e2e/src/test/resources/analyses/batch-progress.feature rename to e2e/api/src/test/resources/analyses/batch-progress.feature diff --git a/e2e/src/test/resources/analyses/cancel.feature b/e2e/api/src/test/resources/analyses/cancel.feature similarity index 100% rename from e2e/src/test/resources/analyses/cancel.feature rename to e2e/api/src/test/resources/analyses/cancel.feature diff --git a/e2e/src/test/resources/analyses/create-and-poll.feature b/e2e/api/src/test/resources/analyses/create-and-poll.feature similarity index 100% rename from e2e/src/test/resources/analyses/create-and-poll.feature rename to e2e/api/src/test/resources/analyses/create-and-poll.feature diff --git a/e2e/src/test/resources/analyses/pipeline-options.feature b/e2e/api/src/test/resources/analyses/pipeline-options.feature similarity index 100% rename from e2e/src/test/resources/analyses/pipeline-options.feature rename to e2e/api/src/test/resources/analyses/pipeline-options.feature diff --git a/e2e/src/test/resources/analyses/rechunk.feature b/e2e/api/src/test/resources/analyses/rechunk.feature similarity index 100% rename from e2e/src/test/resources/analyses/rechunk.feature rename to e2e/api/src/test/resources/analyses/rechunk.feature diff --git a/e2e/src/test/resources/common/data/generated/.gitignore b/e2e/api/src/test/resources/common/data/generated/.gitignore similarity index 100% rename from e2e/src/test/resources/common/data/generated/.gitignore rename to e2e/api/src/test/resources/common/data/generated/.gitignore diff --git a/e2e/src/test/resources/common/data/schemas/analysis.json b/e2e/api/src/test/resources/common/data/schemas/analysis.json similarity index 100% rename from e2e/src/test/resources/common/data/schemas/analysis.json rename to e2e/api/src/test/resources/common/data/schemas/analysis.json diff --git a/e2e/src/test/resources/common/data/schemas/document.json b/e2e/api/src/test/resources/common/data/schemas/document.json similarity index 100% rename from e2e/src/test/resources/common/data/schemas/document.json rename to e2e/api/src/test/resources/common/data/schemas/document.json diff --git a/e2e/src/test/resources/common/data/schemas/health.json b/e2e/api/src/test/resources/common/data/schemas/health.json similarity index 100% rename from e2e/src/test/resources/common/data/schemas/health.json rename to e2e/api/src/test/resources/common/data/schemas/health.json diff --git a/e2e/src/test/resources/common/data/test-cases/invalid-uploads.json b/e2e/api/src/test/resources/common/data/test-cases/invalid-uploads.json similarity index 100% rename from e2e/src/test/resources/common/data/test-cases/invalid-uploads.json rename to e2e/api/src/test/resources/common/data/test-cases/invalid-uploads.json diff --git a/e2e/src/test/resources/common/data/test-cases/pipeline-options.json b/e2e/api/src/test/resources/common/data/test-cases/pipeline-options.json similarity index 100% rename from e2e/src/test/resources/common/data/test-cases/pipeline-options.json rename to e2e/api/src/test/resources/common/data/test-cases/pipeline-options.json diff --git a/e2e/src/test/resources/common/helpers/analyze.feature b/e2e/api/src/test/resources/common/helpers/analyze.feature similarity index 100% rename from e2e/src/test/resources/common/helpers/analyze.feature rename to e2e/api/src/test/resources/common/helpers/analyze.feature diff --git a/e2e/src/test/resources/common/helpers/cleanup.feature b/e2e/api/src/test/resources/common/helpers/cleanup.feature similarity index 100% rename from e2e/src/test/resources/common/helpers/cleanup.feature rename to e2e/api/src/test/resources/common/helpers/cleanup.feature diff --git a/e2e/src/test/resources/common/helpers/upload.feature b/e2e/api/src/test/resources/common/helpers/upload.feature similarity index 100% rename from e2e/src/test/resources/common/helpers/upload.feature rename to e2e/api/src/test/resources/common/helpers/upload.feature diff --git a/e2e/src/test/resources/documents/crud.feature b/e2e/api/src/test/resources/documents/crud.feature similarity index 100% rename from e2e/src/test/resources/documents/crud.feature rename to e2e/api/src/test/resources/documents/crud.feature diff --git a/e2e/src/test/resources/documents/preview.feature b/e2e/api/src/test/resources/documents/preview.feature similarity index 100% rename from e2e/src/test/resources/documents/preview.feature rename to e2e/api/src/test/resources/documents/preview.feature diff --git a/e2e/src/test/resources/documents/size-validation.feature b/e2e/api/src/test/resources/documents/size-validation.feature similarity index 100% rename from e2e/src/test/resources/documents/size-validation.feature rename to e2e/api/src/test/resources/documents/size-validation.feature diff --git a/e2e/src/test/resources/documents/upload.feature b/e2e/api/src/test/resources/documents/upload.feature similarity index 100% rename from e2e/src/test/resources/documents/upload.feature rename to e2e/api/src/test/resources/documents/upload.feature diff --git a/e2e/src/test/resources/health/health.feature b/e2e/api/src/test/resources/health/health.feature similarity index 100% rename from e2e/src/test/resources/health/health.feature rename to e2e/api/src/test/resources/health/health.feature diff --git a/e2e/src/test/resources/karate-config.js b/e2e/api/src/test/resources/karate-config.js similarity index 100% rename from e2e/src/test/resources/karate-config.js rename to e2e/api/src/test/resources/karate-config.js diff --git a/e2e/src/test/resources/workflows/concurrent-analyses.feature b/e2e/api/src/test/resources/workflows/concurrent-analyses.feature similarity index 100% rename from e2e/src/test/resources/workflows/concurrent-analyses.feature rename to e2e/api/src/test/resources/workflows/concurrent-analyses.feature diff --git a/e2e/src/test/resources/workflows/full-happy-path.feature b/e2e/api/src/test/resources/workflows/full-happy-path.feature similarity index 100% rename from e2e/src/test/resources/workflows/full-happy-path.feature rename to e2e/api/src/test/resources/workflows/full-happy-path.feature diff --git a/e2e/generate-test-data.py b/e2e/generate-test-data.py index fb03190..6af67cb 100644 --- a/e2e/generate-test-data.py +++ b/e2e/generate-test-data.py @@ -16,9 +16,10 @@ import os from fpdf import FPDF -OUTPUT_DIR = os.path.join( - os.path.dirname(__file__), "src", "test", "resources", "common", "data", "generated" -) +OUTPUT_DIRS = [ + os.path.join(os.path.dirname(__file__), "api", "src", "test", "resources", "common", "data", "generated"), + os.path.join(os.path.dirname(__file__), "ui", "src", "test", "resources", "common", "data", "generated"), +] _PARAGRAPHS = [ "Document processing is a critical step in building retrieval-augmented generation systems.", @@ -63,13 +64,14 @@ def _make_non_pdf(path: str) -> None: def main() -> None: - os.makedirs(OUTPUT_DIR, exist_ok=True) - print(f"Generating test data in {OUTPUT_DIR}") + for output_dir in OUTPUT_DIRS: + os.makedirs(output_dir, exist_ok=True) + print(f"Generating test data in {output_dir}") - _make_pdf(1, os.path.join(OUTPUT_DIR, "small.pdf")) - _make_pdf(5, os.path.join(OUTPUT_DIR, "medium.pdf")) - _make_pdf(25, os.path.join(OUTPUT_DIR, "large.pdf")) - _make_non_pdf(os.path.join(OUTPUT_DIR, "not-a-pdf.txt")) + _make_pdf(1, os.path.join(output_dir, "small.pdf")) + _make_pdf(5, os.path.join(output_dir, "medium.pdf")) + _make_pdf(25, os.path.join(output_dir, "large.pdf")) + _make_non_pdf(os.path.join(output_dir, "not-a-pdf.txt")) print("Done.") diff --git a/e2e/ui/README.md b/e2e/ui/README.md new file mode 100644 index 0000000..014038b --- /dev/null +++ b/e2e/ui/README.md @@ -0,0 +1,93 @@ +# E2E UI Tests — Karate UI + +Browser-based end-to-end tests for Docling Studio using [Karate UI](https://karatelabs.github.io/karate/karate-core/#ui-automation) (Chrome headless). + +## Prerequisites + +- **JDK 17+** (e.g. `brew install openjdk@17`) +- **Maven 3.9+** (e.g. `brew install maven`) +- **Google Chrome** (headless, auto-detected) +- **Python 3.12+** (for test data generation) +- **Docker** (for running the full stack) + +## Quick start + +```bash +# 1. Generate test PDFs (from repo root) +python e2e/generate-test-data.py + +# 2. Start the stack +docker compose up -d --wait + +# 3. Run all UI tests +mvn test -f e2e/ui/pom.xml + +# 4. Tear down +docker compose down +``` + +## Run by tag + +```bash +# Critical only — CI scope (~1min30) +mvn test -f e2e/ui/pom.xml -Dkarate.options="--tags @critical" + +# All UI tests — local scope (~3min) +mvn test -f e2e/ui/pom.xml -Dkarate.options="--tags @ui" +``` + +## Custom URLs + +```bash +mvn test -f e2e/ui/pom.xml -DbaseUrl=http://your-host:8000 -DuiBaseUrl=http://your-host:3000 +``` + +## Structure + +``` +e2e/ui/ +├── pom.xml # Maven + Karate dependency +├── src/test/java/ +│ └── UIRunner.java # JUnit5 Karate runner +└── src/test/resources/ + ├── karate-config.js # URLs, timeouts, Chrome driver config + ├── common/helpers/ + │ ├── upload.feature # API helper — upload (setup) + │ ├── analyze.feature # API helper — analyze (setup) + │ ├── cleanup.feature # API helper — delete (teardown) + │ ├── ui-upload.feature # UI helper — upload via file input + │ └── ui-wait-analysis.feature # UI helper — poll for completion + ├── documents/ # @critical @ui + │ ├── upload.feature # Upload + preview + │ ├── delete.feature # Delete via hover + click + │ └── error-states.feature # Non-PDF rejection, hints + ├── analyses/ # @critical @ui + │ ├── analysis.feature # Run analysis, verify tabs + │ ├── batch-progress.feature # Progress bar on multi-page + │ ├── rechunk.feature # Prepare mode, rechunk + │ └── pipeline-options.feature # OCR, table mode toggles + ├── navigation/ # @ui + │ ├── sidebar.feature # Sidebar navigation + │ └── i18n.feature # FR/EN language switch + └── workflows/ # @ui + └── full-ui-path.feature # Complete happy path via browser +``` + +## Tags + +| Tag | Scope | When | +|-----|-------|------| +| `@critical` | 5 features, 6 scenarios | CI on `main` branch | +| `@ui` | All UI features | Local development | + +## Design patterns + +- **Setup via API, verify via UI** — fast setup with API helpers, then browser assertions +- **Cleanup via API** — `cleanup.feature` deletes test data after each scenario +- **Polling with `optional()`** — graceful handling of fast completions (no `waitFor` on spinners that may flash) +- **`data-e2e` selectors** — `[data-e2e=doc-item]` instead of `.doc-item` — decoupled from CSS, never breaks on style refactors +- **`karate.sizeOf()` for counts** — `karate.sizeOf(locateAll('[data-e2e=xxx]'))` instead of raw `.length` or `script()` + +## Reports + +After a run, Karate HTML reports are in `e2e/ui/target/karate-reports/`. diff --git a/e2e/pom.xml b/e2e/ui/pom.xml similarity index 97% rename from e2e/pom.xml rename to e2e/ui/pom.xml index 35613d6..7c37770 100644 --- a/e2e/pom.xml +++ b/e2e/ui/pom.xml @@ -6,7 +6,7 @@ 4.0.0 com.scub.docling-studio - e2e-tests + e2e-ui-tests 0.3.1 jar diff --git a/e2e/ui/src/test/java/UIRunner.java b/e2e/ui/src/test/java/UIRunner.java new file mode 100644 index 0000000..9bf425d --- /dev/null +++ b/e2e/ui/src/test/java/UIRunner.java @@ -0,0 +1,24 @@ +import com.intuit.karate.junit5.Karate; + +class UIRunner { + + @Karate.Test + Karate testAll() { + return Karate.run("classpath:documents", "classpath:analyses", "classpath:navigation", "classpath:workflows") + .relativeTo(getClass()); + } + + @Karate.Test + Karate testCritical() { + return Karate.run("classpath:documents", "classpath:analyses") + .tags("@critical") + .relativeTo(getClass()); + } + + @Karate.Test + Karate testLocal() { + return Karate.run("classpath:documents", "classpath:analyses", "classpath:navigation", "classpath:workflows") + .tags("@ui") + .relativeTo(getClass()); + } +} diff --git a/e2e/ui/src/test/resources/analyses/analysis.feature b/e2e/ui/src/test/resources/analyses/analysis.feature new file mode 100644 index 0000000..42fc8dc --- /dev/null +++ b/e2e/ui/src/test/resources/analyses/analysis.feature @@ -0,0 +1,50 @@ +@critical @ui +Feature: UI — Launch an analysis and verify results + + Background: + * url baseUrl + + Scenario: Upload, analyze, and verify result tabs appear + # Setup via API — upload a document + * def result = call read('classpath:common/helpers/upload.feature') { file: 'small.pdf' } + * def docId = result.docId + + # Open Studio page + * driver uiBaseUrl + '/studio' + * waitFor('[data-e2e=doc-item]') + + # Select the uploaded document + * click('[data-e2e=doc-item]') + * waitFor('[data-e2e=doc-item].selected') + + # Verify we are in Configure mode (first toggle button is active) + * waitFor('[data-e2e=toggle-btn].active') + + # Click Run / Exécuter + * waitFor('[data-e2e=run-btn]') + * click('[data-e2e=run-btn]') + + # Wait for analysis to complete + * call read('classpath:common/helpers/ui-wait-analysis.feature') + + # Verify result tabs appear + * waitFor('[data-e2e=result-tabs]') + * waitFor('[data-e2e=tabs-header]') + * match karate.sizeOf(locateAll('[data-e2e=tab-btn]')) == 3 + + # Click on Markdown tab and verify content + * def tabs = locateAll('[data-e2e=tab-btn]') + * tabs[1].click() + * waitFor('[data-e2e=raw-markdown]') + * match text('[data-e2e=raw-content]') != '' + + # Switch to Elements tab + * tabs[0].click() + * waitFor('[data-e2e=elements-list]') + * assert karate.sizeOf(locateAll('[data-e2e=element-card]')) > 0 + + # Verify page indicator + * waitFor('[data-e2e=page-indicator]') + + # Cleanup + * call read('classpath:common/helpers/cleanup.feature') { docId: '#(docId)' } diff --git a/e2e/ui/src/test/resources/analyses/batch-progress.feature b/e2e/ui/src/test/resources/analyses/batch-progress.feature new file mode 100644 index 0000000..05b2af4 --- /dev/null +++ b/e2e/ui/src/test/resources/analyses/batch-progress.feature @@ -0,0 +1,32 @@ +@critical @ui +Feature: UI — Batch analysis progress bar + + Background: + * url baseUrl + + Scenario: Upload large PDF, analyze, and verify progress or completion + # Setup via API — upload a multi-page PDF for batch processing + * def result = call read('classpath:common/helpers/upload.feature') { file: 'medium.pdf' } + * def docId = result.docId + + # Open Studio page + * driver uiBaseUrl + '/studio' + * waitFor('[data-e2e=doc-item]') + + # Select the document + * click('[data-e2e=doc-item]') + * waitFor('[data-e2e=doc-item].selected') + + # Click Run / Exécuter + * waitFor('[data-e2e=run-btn]') + * click('[data-e2e=run-btn]') + + # Wait for analysis to complete (progress bar may or may not appear) + * call read('classpath:common/helpers/ui-wait-analysis.feature') + + # Verify results loaded + * waitFor('[data-e2e=result-tabs]') + * match karate.sizeOf(locateAll('[data-e2e=tab-btn]')) == 3 + + # Cleanup + * call read('classpath:common/helpers/cleanup.feature') { docId: '#(docId)' } diff --git a/e2e/ui/src/test/resources/analyses/pipeline-options.feature b/e2e/ui/src/test/resources/analyses/pipeline-options.feature new file mode 100644 index 0000000..1d33609 --- /dev/null +++ b/e2e/ui/src/test/resources/analyses/pipeline-options.feature @@ -0,0 +1,64 @@ +@ui +Feature: UI — Pipeline configuration options + + Background: + * url baseUrl + + Scenario: Toggle pipeline options in Configure mode + # Setup via API + * def result = call read('classpath:common/helpers/upload.feature') { file: 'small.pdf' } + * def docId = result.docId + + # Open Studio + * driver uiBaseUrl + '/studio' + * waitFor('[data-e2e=doc-item]') + * click('[data-e2e=doc-item]') + * waitFor('[data-e2e=doc-item].selected') + + # Verify config panel is visible in Configure mode + * waitFor('[data-e2e=config-panel]') + + # Verify toggle switches exist (OCR, table structure, etc.) + * assert karate.sizeOf(locateAll('[data-e2e=toggle-switch]')) > 0 + + # Toggle OCR off then on + * def firstToggle = locateAll('[data-e2e=toggle-label]')[0] + * firstToggle.click() + * firstToggle.click() + + # Verify select dropdown for table mode exists + * assert karate.sizeOf(locateAll('[data-e2e=config-select]')) > 0 + + # Change table mode + * select('[data-e2e=config-select]', 'fast') + + # Cleanup + * call read('classpath:common/helpers/cleanup.feature') { docId: '#(docId)' } + + Scenario: Verify pipeline options are preserved across mode switches + # Setup via API + * def result = call read('classpath:common/helpers/upload.feature') { file: 'small.pdf' } + * def docId = result.docId + + # Open Studio and select document + * driver uiBaseUrl + '/studio' + * waitFor('[data-e2e=doc-item]') + * click('[data-e2e=doc-item]') + * waitFor('[data-e2e=doc-item].selected') + + # Change table mode to fast + * waitFor('[data-e2e=config-select]') + * select('[data-e2e=config-select]', 'fast') + + # Switch to Verify mode and back + * def toggleBtns = locateAll('[data-e2e=toggle-btn]') + * toggleBtns[1].click() + * waitFor('[data-e2e=toggle-btn].active') + * toggleBtns[0].click() + * waitFor('[data-e2e=config-select]') + + # Verify table mode is still fast + * match value('[data-e2e=config-select]') == 'fast' + + # Cleanup + * call read('classpath:common/helpers/cleanup.feature') { docId: '#(docId)' } diff --git a/e2e/ui/src/test/resources/analyses/rechunk.feature b/e2e/ui/src/test/resources/analyses/rechunk.feature new file mode 100644 index 0000000..e409343 --- /dev/null +++ b/e2e/ui/src/test/resources/analyses/rechunk.feature @@ -0,0 +1,62 @@ +@critical @ui +Feature: UI — Rechunk an analysis with different parameters + + Background: + * url baseUrl + + Scenario: Analyze a document via UI then rechunk via Prepare tab + # Setup via API — upload only (analysis will be done via UI) + * def uploadResult = call read('classpath:common/helpers/upload.feature') { file: 'small.pdf' } + * def docId = uploadResult.docId + + # Open Studio page + * driver uiBaseUrl + '/studio' + * waitFor('[data-e2e=doc-item]') + + # Select the document + * click('[data-e2e=doc-item]') + * waitFor('[data-e2e=doc-item].selected') + + # Run analysis via UI (so the frontend store knows about it) + * waitFor('[data-e2e=run-btn]') + * click('[data-e2e=run-btn]') + + # Wait for analysis to complete + * call read('classpath:common/helpers/ui-wait-analysis.feature') + * waitFor('[data-e2e=result-tabs]') + + # Now Préparer toggle should be enabled — click the last one + * def toggleBtns = locateAll('[data-e2e=toggle-btn]') + * toggleBtns[karate.sizeOf(toggleBtns) - 1].click() + + # Wait for chunk panel to load + * waitFor('[data-e2e=chunk-panel]') + + # Expand config if collapsed + * def chevronOpen = optional('[data-e2e=config-chevron].open') + * if (!chevronOpen.present) click('[data-e2e=config-toggle]') + * waitFor('[data-e2e=config-body]') + + # Modify max tokens — clear and set new value + * clear('[data-e2e=config-input]') + * input('[data-e2e=config-input]', '256') + + # Click the Chunk / Run button + * waitFor('[data-e2e=chunk-btn]') + * click('[data-e2e=chunk-btn]') + + # Wait for chunks to appear + * retry(30, 1000).waitFor('[data-e2e=chunk-card]') + + # Verify chunk results + * waitFor('[data-e2e=chunk-results]') + * waitFor('[data-e2e=chunk-summary]') + * assert karate.sizeOf(locateAll('[data-e2e=chunk-card]')) > 0 + + # Verify each chunk has expected structure + * waitFor('[data-e2e=chunk-index]') + * waitFor('[data-e2e=chunk-tokens]') + * waitFor('[data-e2e=chunk-text]') + + # Cleanup + * call read('classpath:common/helpers/cleanup.feature') { docId: '#(docId)' } diff --git a/e2e/ui/src/test/resources/common/helpers/analyze.feature b/e2e/ui/src/test/resources/common/helpers/analyze.feature new file mode 100644 index 0000000..7cd4a7a --- /dev/null +++ b/e2e/ui/src/test/resources/common/helpers/analyze.feature @@ -0,0 +1,26 @@ +@ignore +Feature: Helper — Create analysis and poll until terminal state + + # Callable feature: returns { jobId, response } + # Usage: * def result = call read('classpath:common/helpers/analyze.feature') { docId: '#(docId)' } + # Optional: { docId: '...', pipelineOptions: { tableMode: 'fast' } } + + Scenario: + * def opts = karate.get('pipelineOptions', null) + * def body = { documentId: '#(docId)' } + * if (opts != null) body.pipelineOptions = opts + + # Create analysis + Given url baseUrl + And path '/api/analyses' + And request body + When method POST + Then status 200 + * def jobId = response.id + + # Poll until COMPLETED or FAILED + Given url baseUrl + And path '/api/analyses', jobId + And retry until response.status == 'COMPLETED' || response.status == 'FAILED' + When method GET + Then status 200 diff --git a/e2e/ui/src/test/resources/common/helpers/cleanup-by-name.feature b/e2e/ui/src/test/resources/common/helpers/cleanup-by-name.feature new file mode 100644 index 0000000..07116bd --- /dev/null +++ b/e2e/ui/src/test/resources/common/helpers/cleanup-by-name.feature @@ -0,0 +1,13 @@ +@ignore +Feature: Helper — Delete a document by filename + + # Callable feature: finds a document by name via API and deletes it + # Usage: * call read('classpath:common/helpers/cleanup-by-name.feature') { filename: 'small.pdf' } + + Scenario: + Given url baseUrl + And path '/api/documents' + When method GET + Then status 200 + * def matches = karate.filter(response, function(d){ return d.filename == filename }) + * if (karate.sizeOf(matches) > 0) karate.call('classpath:common/helpers/cleanup.feature', { docId: matches[0].id }) diff --git a/e2e/ui/src/test/resources/common/helpers/cleanup.feature b/e2e/ui/src/test/resources/common/helpers/cleanup.feature new file mode 100644 index 0000000..f6f3f53 --- /dev/null +++ b/e2e/ui/src/test/resources/common/helpers/cleanup.feature @@ -0,0 +1,12 @@ +@ignore +Feature: Helper — Delete a document and its analyses + + # Callable feature: cleans up test data + # Usage: * call read('classpath:common/helpers/cleanup.feature') { docId: '#(docId)' } + + Scenario: + Given url baseUrl + And path '/api/documents', docId + When method DELETE + # Accept both 204 (deleted) and 404 (already gone) + Then assert responseStatus == 204 || responseStatus == 404 diff --git a/e2e/ui/src/test/resources/common/helpers/ui-upload.feature b/e2e/ui/src/test/resources/common/helpers/ui-upload.feature new file mode 100644 index 0000000..d6627b8 --- /dev/null +++ b/e2e/ui/src/test/resources/common/helpers/ui-upload.feature @@ -0,0 +1,10 @@ +@ignore +Feature: Helper — Upload a PDF via the UI + + # Callable feature: uploads a file through the browser file input + # Usage: * call read('classpath:common/helpers/ui-upload.feature') { file: 'small.pdf' } + # Prerequisite: driver must already be on a page with .upload-zone (home or studio) + + Scenario: + * def filePath = karate.toAbsolutePath('classpath:common/data/generated/' + file) + * driver.inputFile('input[type=file]', filePath) diff --git a/e2e/ui/src/test/resources/common/helpers/ui-wait-analysis.feature b/e2e/ui/src/test/resources/common/helpers/ui-wait-analysis.feature new file mode 100644 index 0000000..fdc96eb --- /dev/null +++ b/e2e/ui/src/test/resources/common/helpers/ui-wait-analysis.feature @@ -0,0 +1,10 @@ +@ignore +Feature: Helper — Wait for analysis to complete via UI polling + + # Callable feature: waits for analysis to finish — checks for result tabs or error state + # Usage: * call read('classpath:common/helpers/ui-wait-analysis.feature') + # Prerequisite: analysis must have been triggered, driver on the studio page + + Scenario: + # Poll up to 2 minutes — result-tabs (COMPLETED) or error placeholder (FAILED) + * retry(120, 1000).script("document.querySelector('[data-e2e=result-tabs]') || document.querySelector('[data-e2e=result-error]')") diff --git a/e2e/ui/src/test/resources/common/helpers/upload.feature b/e2e/ui/src/test/resources/common/helpers/upload.feature new file mode 100644 index 0000000..63832e9 --- /dev/null +++ b/e2e/ui/src/test/resources/common/helpers/upload.feature @@ -0,0 +1,14 @@ +@ignore +Feature: Helper — Upload a PDF document + + # Callable feature: returns { docId, response } + # Usage: * def result = call read('classpath:common/helpers/upload.feature') { file: 'small.pdf' } + + Scenario: + * def filePath = 'classpath:common/data/generated/' + file + Given url baseUrl + And path '/api/documents/upload' + And multipart file file = { read: '#(filePath)', filename: '#(file)', contentType: 'application/pdf' } + When method POST + Then status 200 + * def docId = response.id diff --git a/e2e/ui/src/test/resources/documents/delete.feature b/e2e/ui/src/test/resources/documents/delete.feature new file mode 100644 index 0000000..2020908 --- /dev/null +++ b/e2e/ui/src/test/resources/documents/delete.feature @@ -0,0 +1,30 @@ +@critical @ui +Feature: UI — Delete a document + + Background: + * url baseUrl + + Scenario: Upload a document, then delete it via UI + # Setup via API — upload a doc + * def result = call read('classpath:common/helpers/upload.feature') { file: 'small.pdf' } + * def docId = result.docId + + # Open Studio page + * driver uiBaseUrl + '/studio' + * waitFor('[data-e2e=doc-item]') + + # Count docs before deletion + * def countBefore = karate.sizeOf(locateAll('[data-e2e=doc-item]')) + + # Select the first doc, hover to reveal delete, click it + * click('[data-e2e=doc-item]') + * waitFor('[data-e2e=doc-item].selected') + * mouse('[data-e2e=doc-item].selected').go() + * waitFor('[data-e2e=doc-delete]') + * click('[data-e2e=doc-delete]') + + # Wait for the doc count to decrease (UI confirmation) + * retry(10, 500).script("document.querySelectorAll('[data-e2e=doc-item]').length < " + countBefore) + + # Cleanup — ensure our uploaded doc is gone regardless + * call read('classpath:common/helpers/cleanup.feature') { docId: '#(docId)' } diff --git a/e2e/ui/src/test/resources/documents/error-states.feature b/e2e/ui/src/test/resources/documents/error-states.feature new file mode 100644 index 0000000..50e3df9 --- /dev/null +++ b/e2e/ui/src/test/resources/documents/error-states.feature @@ -0,0 +1,22 @@ +@ui +Feature: UI — Upload error states + + Scenario: Reject non-PDF file and show error message + * driver uiBaseUrl + '/studio' + * waitFor('[data-e2e=upload-zone]') + + # Try to upload a non-PDF file + * def filePath = karate.toAbsolutePath('classpath:common/data/generated/not-a-pdf.txt') + * driver.inputFile('input[type=file]', filePath) + + # Verify error message is displayed + * waitFor('[data-e2e=upload-error]') + * match text('[data-e2e=upload-error]') contains 'PDF' + + Scenario: Upload hint displays max size info + * driver uiBaseUrl + '/studio' + * waitFor('[data-e2e=upload-zone]') + + # Verify upload hint is visible and not empty + * waitFor('[data-e2e=upload-hint]') + * match text('[data-e2e=upload-hint]') != '' diff --git a/e2e/ui/src/test/resources/documents/upload.feature b/e2e/ui/src/test/resources/documents/upload.feature new file mode 100644 index 0000000..2363027 --- /dev/null +++ b/e2e/ui/src/test/resources/documents/upload.feature @@ -0,0 +1,47 @@ +@critical @ui +Feature: UI — Upload a PDF and verify preview + + Background: + * url baseUrl + + Scenario: Upload a single-page PDF via UI and verify it appears with preview + # Open Studio page + * driver uiBaseUrl + '/studio' + * waitFor('[data-e2e=upload-zone]') + + # Upload via hidden file input + * def filePath = karate.toAbsolutePath('classpath:common/data/generated/small.pdf') + * driver.inputFile('input[type=file]', filePath) + + # Verify document appears in the doc list sidebar + * waitFor('[data-e2e=doc-item]') + * match text('[data-e2e=doc-name]') contains 'small.pdf' + + # Click the document to select it + * click('[data-e2e=doc-item]') + * waitFor('[data-e2e=doc-item].selected') + + # Verify PDF preview loads + * waitFor('[data-e2e=pdf-image]') + + # Cleanup via API + * call read('classpath:common/helpers/cleanup-by-name.feature') { filename: 'small.pdf' } + + Scenario: Upload a multi-page PDF and verify page navigation + # Open Studio page + * driver uiBaseUrl + '/studio' + * waitFor('[data-e2e=upload-zone]') + + * def filePath = karate.toAbsolutePath('classpath:common/data/generated/medium.pdf') + * driver.inputFile('input[type=file]', filePath) + + # Verify document appears + * waitFor('[data-e2e=doc-item]') + + # Click to select and verify preview loads + * click('[data-e2e=doc-item]') + * waitFor('[data-e2e=doc-item].selected') + * waitFor('[data-e2e=pdf-image]') + + # Cleanup + * call read('classpath:common/helpers/cleanup-by-name.feature') { filename: 'medium.pdf' } diff --git a/e2e/ui/src/test/resources/karate-config.js b/e2e/ui/src/test/resources/karate-config.js new file mode 100644 index 0000000..68add37 --- /dev/null +++ b/e2e/ui/src/test/resources/karate-config.js @@ -0,0 +1,23 @@ +function fn() { + var config = { + baseUrl: karate.properties['baseUrl'] || 'http://localhost:8000', + uiBaseUrl: karate.properties['uiBaseUrl'] || 'http://localhost:3000', + pollInterval: 2000, + pollTimeout: 120000, + batchPollTimeout: 300000 + }; + karate.configure('connectTimeout', 10000); + karate.configure('readTimeout', 30000); + karate.configure('retry', { count: 60, interval: config.pollInterval }); + + // Karate UI — browser driver config (Chrome headless) + karate.configure('driver', { + type: 'chrome', + headless: true, + showDriverLog: false, + addOptions: ['--no-sandbox', '--disable-gpu'], + screenshotOnFailure: true + }); + + return config; +} diff --git a/e2e/ui/src/test/resources/navigation/i18n.feature b/e2e/ui/src/test/resources/navigation/i18n.feature new file mode 100644 index 0000000..6e3eadc --- /dev/null +++ b/e2e/ui/src/test/resources/navigation/i18n.feature @@ -0,0 +1,22 @@ +@ui +Feature: UI — Language switch FR/EN + + Scenario: Switch language to French and back to English + # Open settings page + * driver uiBaseUrl + '/settings' + * waitFor('[data-e2e=settings-panel]') + + # Ensure we start in EN (idempotent) + * click('[data-e2e=lang-en]') + * waitFor('[data-e2e=sidebar]') + * match text('[data-e2e=sidebar]') contains 'Home' + + # Switch to FR + * click('[data-e2e=lang-fr]') + * waitFor('[data-e2e=sidebar]') + * match text('[data-e2e=sidebar]') contains 'Accueil' + + # Switch back to EN (leave clean state) + * click('[data-e2e=lang-en]') + * waitFor('[data-e2e=sidebar]') + * match text('[data-e2e=sidebar]') contains 'Home' diff --git a/e2e/ui/src/test/resources/navigation/sidebar.feature b/e2e/ui/src/test/resources/navigation/sidebar.feature new file mode 100644 index 0000000..3275cfe --- /dev/null +++ b/e2e/ui/src/test/resources/navigation/sidebar.feature @@ -0,0 +1,48 @@ +@ui +Feature: UI — Sidebar navigation + + Scenario: Navigate through all sidebar links + * driver uiBaseUrl + + # Sidebar should be visible + * waitFor('[data-e2e=sidebar]') + + # Verify all navigation items exist (each has data-e2e starting with nav-) + * assert karate.sizeOf(locateAll('[data-e2e^=nav-]')) >= 4 + + # Navigate to Studio via data-e2e (i18n-safe) + * click('[data-e2e=nav-studio]') + * waitForUrl('/studio') + + # Navigate to Documents + * click('[data-e2e=nav-documents]') + * waitForUrl('/documents') + + # Navigate to History + * click('[data-e2e=nav-history]') + * waitForUrl('/history') + + # Navigate to Settings + * click('[data-e2e=nav-settings]') + * waitForUrl('/settings') + + # Navigate back to Home via logo + * click('[data-e2e=topbar-logo]') + * waitForUrl(uiBaseUrl + '/') + + Scenario: Toggle sidebar collapse and expand + * driver uiBaseUrl + * waitFor('[data-e2e=sidebar]') + + # Sidebar should start with .open class + * match attribute('[data-e2e=sidebar]', 'class') contains 'open' + + # Click burger menu to collapse + * click('[data-e2e=burger-btn]') + * retry(5, 300).script("!document.querySelector('[data-e2e=sidebar]').classList.contains('open')") + * match attribute('[data-e2e=sidebar]', 'class') !contains 'open' + + # Toggle back to expand + * click('[data-e2e=burger-btn]') + * retry(5, 300).script("document.querySelector('[data-e2e=sidebar]').classList.contains('open')") + * match attribute('[data-e2e=sidebar]', 'class') contains 'open' diff --git a/e2e/ui/src/test/resources/workflows/full-ui-path.feature b/e2e/ui/src/test/resources/workflows/full-ui-path.feature new file mode 100644 index 0000000..6790a53 --- /dev/null +++ b/e2e/ui/src/test/resources/workflows/full-ui-path.feature @@ -0,0 +1,79 @@ +@ui +Feature: UI — Full happy path via browser + + Background: + * url baseUrl + + Scenario: Complete workflow — upload, analyze, verify results, rechunk, delete — all via UI + # Step 1: Open Studio page + * driver uiBaseUrl + '/studio' + * waitFor('[data-e2e=upload-zone]') + + # Step 2: Upload a PDF via UI + * def filePath = karate.toAbsolutePath('classpath:common/data/generated/medium.pdf') + * driver.inputFile('input[type=file]', filePath) + + # Step 3: Verify document appears in doc list + * waitFor('[data-e2e=doc-item]') + * match text('[data-e2e=doc-name]') contains 'medium.pdf' + + # Step 4: Select the document + * click('[data-e2e=doc-item]') + * waitFor('[data-e2e=doc-item].selected') + + # Step 5: Verify Configure mode is active + * waitFor('[data-e2e=toggle-btn].active') + + # Step 6: Run the analysis + * click('[data-e2e=run-btn]') + + # Step 7: Wait for analysis to complete + * call read('classpath:common/helpers/ui-wait-analysis.feature') + + # Step 8: Verify results — tabs and content + * waitFor('[data-e2e=result-tabs]') + * match karate.sizeOf(locateAll('[data-e2e=tab-btn]')) == 3 + + # Check Elements tab has content + * def tabs = locateAll('[data-e2e=tab-btn]') + * tabs[0].click() + * waitFor('[data-e2e=elements-list]') + * assert karate.sizeOf(locateAll('[data-e2e=element-card]')) > 0 + + # Check Markdown tab has content + * tabs[1].click() + * waitFor('[data-e2e=raw-markdown]') + * match text('[data-e2e=raw-content]') != '' + + # Step 9: Switch to Préparer mode and rechunk + * def toggleBtns = locateAll('[data-e2e=toggle-btn]') + * toggleBtns[karate.sizeOf(toggleBtns) - 1].click() + * waitFor('[data-e2e=chunk-panel]') + + # Expand config if needed + * def chevronOpen = optional('[data-e2e=config-chevron].open') + * if (!chevronOpen.present) click('[data-e2e=config-toggle]') + * waitFor('[data-e2e=config-body]') + + # Set max tokens and run chunking + * clear('[data-e2e=config-input]') + * input('[data-e2e=config-input]', '512') + * click('[data-e2e=chunk-btn]') + + # Wait for chunks + * retry(30, 1000).waitFor('[data-e2e=chunk-card]') + * waitFor('[data-e2e=chunk-results]') + * assert karate.sizeOf(locateAll('[data-e2e=chunk-card]')) > 0 + + # Step 10: Delete the document via UI + * def toggleBtns2 = locateAll('[data-e2e=toggle-btn]') + * toggleBtns2[0].click() + * waitFor('[data-e2e=doc-item]') + + # Hover and delete + * mouse('[data-e2e=doc-item]').go() + * waitFor('[data-e2e=doc-delete]') + * click('[data-e2e=doc-delete]') + + # Verify document is gone from the list + * retry(10, 500).script("!document.querySelector('[data-e2e=doc-item]') || !document.querySelector('[data-e2e=doc-name]').textContent.includes('medium.pdf')") diff --git a/frontend/src/app/App.vue b/frontend/src/app/App.vue index 1733b98..db3837e 100644 --- a/frontend/src/app/App.vue +++ b/frontend/src/app/App.vue @@ -3,6 +3,7 @@
-