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
This commit is contained in:
Pier-Jean Malandrino 2026-04-08 17:53:52 +02:00
parent 9696526961
commit fdbab2f49e
59 changed files with 1187 additions and 73 deletions

View file

@ -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()

3
.gitignore vendored
View file

@ -43,3 +43,6 @@ hs_err_pid*
# Docker
docker-compose.override.yml
# E2E tests — Maven build outputs & Chrome user data
e2e/**/target/

View file

@ -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

2
e2e/.gitignore vendored
View file

@ -1,2 +0,0 @@
e2e/target/
e2e/target/

212
e2e/CONVENTIONS.md Normal file
View file

@ -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 |

View file

@ -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/`.

58
e2e/api/pom.xml Normal file
View file

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.scub.docling-studio</groupId>
<artifactId>e2e-api-tests</artifactId>
<version>0.3.1</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<karate.version>1.5.0</karate.version>
</properties>
<dependencies>
<dependency>
<groupId>io.karatelabs</groupId>
<artifactId>karate-core</artifactId>
<version>${karate.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.karatelabs</groupId>
<artifactId>karate-junit5</artifactId>
<version>${karate.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<testResources>
<testResource>
<directory>src/test/resources</directory>
</testResource>
</testResources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<argLine>-Dfile.encoding=UTF-8</argLine>
<includes>
<include>**/*Runner.java</include>
</includes>
<systemPropertyVariables>
<karate.options>${karate.options}</karate.options>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
</build>
</project>

View file

@ -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.")

93
e2e/ui/README.md Normal file
View file

@ -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/`.

View file

@ -6,7 +6,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.scub.docling-studio</groupId>
<artifactId>e2e-tests</artifactId>
<artifactId>e2e-ui-tests</artifactId>
<version>0.3.1</version>
<packaging>jar</packaging>

View file

@ -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());
}
}

View file

@ -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)' }

View file

@ -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)' }

View file

@ -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)' }

View file

@ -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)' }

View file

@ -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

View file

@ -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 })

View file

@ -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

View file

@ -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)

View file

@ -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]')")

View file

@ -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

View file

@ -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)' }

View file

@ -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]') != ''

View file

@ -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' }

View file

@ -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;
}

View file

@ -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'

View file

@ -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'

View file

@ -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')")

View file

@ -3,6 +3,7 @@
<header class="topbar">
<button
class="burger-btn"
data-e2e="burger-btn"
@click="sidebarOpen = !sidebarOpen"
:title="sidebarOpen ? t('nav.collapse') : t('nav.expand')"
>
@ -14,7 +15,7 @@
/>
</svg>
</button>
<div class="topbar-logo">
<div class="topbar-logo" data-e2e="topbar-logo">
<img src="/logo.png" alt="Docling Studio" class="topbar-logo-icon" />
<span class="topbar-logo-text">Docling Studio</span>
</div>

View file

@ -1,10 +1,15 @@
<template>
<div class="result-tabs" v-if="store.currentAnalysis?.status === 'COMPLETED'">
<div class="tabs-header">
<div
class="result-tabs"
data-e2e="result-tabs"
v-if="store.currentAnalysis?.status === 'COMPLETED'"
>
<div class="tabs-header" data-e2e="tabs-header">
<button
v-for="tab in tabs"
:key="tab.id"
class="tab-btn"
data-e2e="tab-btn"
:class="{ active: activeTab === tab.id }"
@click="activeTab = tab.id"
>
@ -13,7 +18,7 @@
</div>
<!-- Page chip -->
<div class="page-indicator" v-if="totalPages > 0">
<div class="page-indicator" data-e2e="page-indicator" v-if="totalPages > 0">
<span class="page-chip">{{
t('results.pageOf', { current: currentPage, total: totalPages })
}}</span>
@ -21,7 +26,7 @@
<div class="tab-content">
<!-- ELEMENTS VIEW each bbox as a separate card -->
<div v-if="activeTab === 'elements'" class="elements-list">
<div v-if="activeTab === 'elements'" class="elements-list" data-e2e="elements-list">
<div v-if="!currentElements.length" class="elements-empty">
{{ t('results.noElements') }}
</div>
@ -29,6 +34,7 @@
v-for="(el, idx) in currentElements"
:key="idx"
class="element-card"
data-e2e="element-card"
:class="{ highlighted: highlightedIndex === idx }"
@mouseenter="$emit('highlight-element', idx)"
@mouseleave="$emit('highlight-element', -1)"
@ -79,7 +85,7 @@
</div>
<!-- RAW MARKDOWN -->
<div v-else-if="activeTab === 'markdown'" class="raw-markdown">
<div v-else-if="activeTab === 'markdown'" class="raw-markdown" data-e2e="raw-markdown">
<button class="copy-btn copy-btn-block" :title="t('results.copy')" @click="copyMarkdown">
<svg v-if="!copiedMarkdown" viewBox="0 0 20 20" fill="currentColor" class="copy-icon">
<path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z" />
@ -95,7 +101,7 @@
/>
</svg>
</button>
<pre class="raw-content">{{ pageMarkdown }}</pre>
<pre class="raw-content" data-e2e="raw-content">{{ pageMarkdown }}</pre>
</div>
<!-- IMAGES -->
@ -118,7 +124,11 @@
</span>
</div>
</div>
<div v-else-if="store.currentAnalysis?.status === 'FAILED'" class="result-placeholder error">
<div
v-else-if="store.currentAnalysis?.status === 'FAILED'"
class="result-placeholder error"
data-e2e="result-error"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="error-icon">
<path
fill-rule="evenodd"

View file

@ -1,10 +1,11 @@
<template>
<div class="chunk-panel">
<div class="chunk-panel" data-e2e="chunk-panel">
<!-- Chunking config collapsible -->
<div class="chunk-config">
<button class="config-toggle" @click="configOpen = !configOpen">
<button class="config-toggle" data-e2e="config-toggle" @click="configOpen = !configOpen">
<svg
class="config-chevron"
data-e2e="config-chevron"
:class="{ open: configOpen }"
viewBox="0 0 20 20"
fill="currentColor"
@ -18,10 +19,10 @@
<span class="config-label">{{ t('chunking.settings') }}</span>
</button>
<div v-if="configOpen" class="config-body">
<div v-if="configOpen" class="config-body" data-e2e="config-body">
<div class="config-row">
<label class="config-label-sm">{{ t('chunking.chunkerType') }}</label>
<select class="config-select" v-model="options.chunker_type">
<select class="config-select" data-e2e="config-select" v-model="options.chunker_type">
<option value="hybrid">Hybrid</option>
<option value="hierarchical">Hierarchical</option>
</select>
@ -32,6 +33,7 @@
<input
type="number"
class="config-input"
data-e2e="config-input"
v-model.number="options.max_tokens"
min="64"
max="8192"
@ -57,6 +59,7 @@
<button
class="chunk-btn primary"
data-e2e="chunk-btn"
:disabled="!canRechunk || analysisStore.rechunking"
@click="doRechunk"
>
@ -67,11 +70,14 @@
</div>
<!-- Chunks list -->
<div class="chunk-results" v-if="pageChunks.length">
<div class="chunk-summary">{{ pagination.totalItems.value }} {{ t('chunking.chunks') }}</div>
<div class="chunk-results" data-e2e="chunk-results" v-if="pageChunks.length">
<div class="chunk-summary" data-e2e="chunk-summary">
{{ pagination.totalItems.value }} {{ t('chunking.chunks') }}
</div>
<div class="chunk-list">
<div
class="chunk-card"
data-e2e="chunk-card"
v-for="(chunk, localIdx) in pagination.paginatedItems.value"
:key="globalIndex(localIdx)"
:class="{ highlighted: hoveredChunkIdx === globalIndex(localIdx) }"
@ -79,8 +85,8 @@
@mouseleave="onChunkLeave"
>
<div class="chunk-header">
<span class="chunk-index">#{{ globalIndex(localIdx) + 1 }}</span>
<span class="chunk-tokens" v-if="chunk.tokenCount">
<span class="chunk-index" data-e2e="chunk-index">#{{ globalIndex(localIdx) + 1 }}</span>
<span class="chunk-tokens" data-e2e="chunk-tokens" v-if="chunk.tokenCount">
{{ chunk.tokenCount }} tokens
</span>
<span class="chunk-page" v-if="chunk.sourcePage"> p.{{ chunk.sourcePage }} </span>
@ -88,7 +94,7 @@
<div class="chunk-headings" v-if="chunk.headings.length">
<span class="chunk-heading" v-for="h in chunk.headings" :key="h">{{ h }}</span>
</div>
<div class="chunk-text">{{ chunk.text }}</div>
<div class="chunk-text" data-e2e="chunk-text">{{ chunk.text }}</div>
</div>
</div>
</div>

View file

@ -4,6 +4,7 @@
v-for="doc in store.documents"
:key="doc.id"
class="doc-item"
data-e2e="doc-item"
:class="{ selected: store.selectedId === doc.id }"
@click="store.select(doc.id)"
>
@ -16,14 +17,19 @@
/>
</svg>
<div class="doc-meta">
<span class="doc-name">{{ doc.filename }}</span>
<span class="doc-name" data-e2e="doc-name">{{ doc.filename }}</span>
<span class="doc-size"
>{{ formatSize(doc.fileSize)
}}{{ doc.pageCount ? `${doc.pageCount} pages` : '' }}</span
>
</div>
</div>
<button class="doc-delete" @click.stop="store.remove(doc.id)" title="Delete">
<button
class="doc-delete"
data-e2e="doc-delete"
@click.stop="store.remove(doc.id)"
title="Delete"
>
<svg viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"

View file

@ -1,6 +1,7 @@
<template>
<div
class="upload-zone"
data-e2e="upload-zone"
:class="{ dragging, uploading: store.uploading }"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@ -23,8 +24,8 @@
<path d="M12 16V4m0 0L8 8m4-4l4 4M4 17v2a1 1 0 001 1h14a1 1 0 001-1v-2" />
</svg>
<span class="upload-text">{{ t('upload.drop') }}</span>
<span class="upload-hint">{{ uploadHint }}</span>
<span v-if="store.error" class="upload-error">{{ store.error }}</span>
<span class="upload-hint" data-e2e="upload-hint">{{ uploadHint }}</span>
<span v-if="store.error" class="upload-error" data-e2e="upload-error">{{ store.error }}</span>
</div>
</div>
</template>

View file

@ -1,5 +1,5 @@
<template>
<div class="settings-panel">
<div class="settings-panel" data-e2e="settings-panel">
<div class="setting-group">
<label class="setting-label">{{ t('settings.theme') }}</label>
<div class="setting-toggle">
@ -15,10 +15,18 @@
<div class="setting-group">
<label class="setting-label">{{ t('settings.language') }}</label>
<div class="setting-toggle">
<button :class="{ active: store.locale === 'fr' }" @click="store.setLocale('fr')">
<button
:class="{ active: store.locale === 'fr' }"
data-e2e="lang-fr"
@click="store.setLocale('fr')"
>
FR
</button>
<button :class="{ active: store.locale === 'en' }" @click="store.setLocale('en')">
<button
:class="{ active: store.locale === 'en' }"
data-e2e="lang-en"
@click="store.setLocale('en')"
>
EN
</button>
</div>

View file

@ -22,6 +22,7 @@
<div class="mode-toggle">
<button
class="toggle-btn"
data-e2e="toggle-btn"
:class="{ active: mode === 'configurer' }"
@click="mode = 'configurer'"
>
@ -36,6 +37,7 @@
</button>
<button
class="toggle-btn"
data-e2e="toggle-btn"
:class="{ active: mode === 'verifier' }"
@click="mode = 'verifier'"
:disabled="!analysisStore.currentAnalysis"
@ -52,6 +54,7 @@
<button
v-if="chunkingEnabled"
class="toggle-btn"
data-e2e="toggle-btn"
:class="{ active: mode === 'preparer' }"
@click="mode = 'preparer'"
:disabled="!analysisStore.currentAnalysis"
@ -76,6 +79,7 @@
</button>
<button
class="topbar-btn primary"
data-e2e="run-btn"
:disabled="analysisStore.running"
@click="runAnalysis"
v-if="mode === 'configurer'"
@ -189,6 +193,7 @@
:src="previewUrl"
:alt="`Page ${currentPage}`"
class="pdf-image"
data-e2e="pdf-image"
@load="onPdfImageLoad"
/>
<BboxOverlay
@ -212,7 +217,7 @@
<!-- Right: Config or Results panel -->
<div class="right-panel" :style="{ width: rightPanelWidth + 'px' }">
<!-- CONFIGURER MODE -->
<div v-if="mode === 'configurer'" class="config-panel">
<div v-if="mode === 'configurer'" class="config-panel" data-e2e="config-panel">
<div class="config-section">
<label class="config-label">
{{ t('config.model') }}
@ -228,9 +233,9 @@
<label class="config-label">{{ t('config.pipeline') }}</label>
<div class="config-toggle-row">
<label class="toggle-label">
<label class="toggle-label" data-e2e="toggle-label">
<input type="checkbox" v-model="pipelineOptions.do_ocr" class="toggle-input" />
<span class="toggle-switch" />
<span class="toggle-switch" data-e2e="toggle-switch" />
<span class="toggle-text">{{ t('config.ocr') }}</span>
</label>
<span class="config-hint"
@ -240,13 +245,13 @@
</div>
<div class="config-toggle-row">
<label class="toggle-label">
<label class="toggle-label" data-e2e="toggle-label">
<input
type="checkbox"
v-model="pipelineOptions.do_table_structure"
class="toggle-input"
/>
<span class="toggle-switch" />
<span class="toggle-switch" data-e2e="toggle-switch" />
<span class="toggle-text">{{ t('config.tableStructure') }}</span>
</label>
<span class="config-hint"
@ -257,7 +262,11 @@
<div class="config-sub-option" v-if="pipelineOptions.do_table_structure">
<label class="config-label-sm">{{ t('config.tableMode') }}</label>
<select class="config-select" v-model="pipelineOptions.table_mode">
<select
class="config-select"
data-e2e="config-select"
v-model="pipelineOptions.table_mode"
>
<option value="accurate">{{ t('config.tableModeAccurate') }}</option>
<option value="fast">{{ t('config.tableModeFast') }}</option>
</select>
@ -269,13 +278,13 @@
<label class="config-label">{{ t('config.enrichment') }}</label>
<div class="config-toggle-row">
<label class="toggle-label">
<label class="toggle-label" data-e2e="toggle-label">
<input
type="checkbox"
v-model="pipelineOptions.do_code_enrichment"
class="toggle-input"
/>
<span class="toggle-switch" />
<span class="toggle-switch" data-e2e="toggle-switch" />
<span class="toggle-text">{{ t('config.codeEnrichment') }}</span>
</label>
<span class="config-hint"
@ -285,13 +294,13 @@
</div>
<div class="config-toggle-row">
<label class="toggle-label">
<label class="toggle-label" data-e2e="toggle-label">
<input
type="checkbox"
v-model="pipelineOptions.do_formula_enrichment"
class="toggle-input"
/>
<span class="toggle-switch" />
<span class="toggle-switch" data-e2e="toggle-switch" />
<span class="toggle-text">{{ t('config.formulaEnrichment') }}</span>
</label>
<span class="config-hint"
@ -306,13 +315,13 @@
<label class="config-label">{{ t('config.pictures') }}</label>
<div class="config-toggle-row">
<label class="toggle-label">
<label class="toggle-label" data-e2e="toggle-label">
<input
type="checkbox"
v-model="pipelineOptions.do_picture_classification"
class="toggle-input"
/>
<span class="toggle-switch" />
<span class="toggle-switch" data-e2e="toggle-switch" />
<span class="toggle-text">{{ t('config.pictureClassification') }}</span>
</label>
<span class="config-hint"
@ -322,13 +331,13 @@
</div>
<div class="config-toggle-row">
<label class="toggle-label">
<label class="toggle-label" data-e2e="toggle-label">
<input
type="checkbox"
v-model="pipelineOptions.do_picture_description"
class="toggle-input"
/>
<span class="toggle-switch" />
<span class="toggle-switch" data-e2e="toggle-switch" />
<span class="toggle-text">{{ t('config.pictureDescription') }}</span>
</label>
<span class="config-hint"
@ -338,13 +347,13 @@
</div>
<div class="config-toggle-row">
<label class="toggle-label">
<label class="toggle-label" data-e2e="toggle-label">
<input
type="checkbox"
v-model="pipelineOptions.generate_picture_images"
class="toggle-input"
/>
<span class="toggle-switch" />
<span class="toggle-switch" data-e2e="toggle-switch" />
<span class="toggle-text">{{ t('config.generatePictureImages') }}</span>
</label>
<span class="config-hint"
@ -354,13 +363,13 @@
</div>
<div class="config-toggle-row">
<label class="toggle-label">
<label class="toggle-label" data-e2e="toggle-label">
<input
type="checkbox"
v-model="pipelineOptions.generate_page_images"
class="toggle-input"
/>
<span class="toggle-switch" />
<span class="toggle-switch" data-e2e="toggle-switch" />
<span class="toggle-text">{{ t('config.generatePageImages') }}</span>
</label>
<span class="config-hint"

View file

@ -1,7 +1,12 @@
<template>
<aside class="sidebar" :class="{ open }">
<aside class="sidebar" data-e2e="sidebar" :class="{ open }">
<nav class="sidebar-nav">
<RouterLink to="/" class="nav-item" :class="{ active: route.name === 'home' }">
<RouterLink
to="/"
class="nav-item"
data-e2e="nav-home"
:class="{ active: route.name === 'home' }"
>
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
<path
d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"
@ -10,7 +15,12 @@
<span class="nav-label">{{ t('nav.home') }}</span>
</RouterLink>
<RouterLink to="/studio" class="nav-item" :class="{ active: route.name === 'studio' }">
<RouterLink
to="/studio"
class="nav-item"
data-e2e="nav-studio"
:class="{ active: route.name === 'studio' }"
>
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
<path
d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838l-2.727 1.17 1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3zM3.31 9.397L5 10.12v4.102a8.969 8.969 0 00-1.05-.174 1 1 0 01-.89-.89 11.115 11.115 0 01.25-3.762zm5.99 7.176A9.026 9.026 0 007 15.96v-4.5l.61.26a2.5 2.5 0 001.98 0l.61-.26v4.5a9.026 9.026 0 00-1.7.613z"
@ -19,7 +29,12 @@
<span class="nav-label">{{ t('nav.studio') }}</span>
</RouterLink>
<RouterLink to="/documents" class="nav-item" :class="{ active: route.name === 'documents' }">
<RouterLink
to="/documents"
class="nav-item"
data-e2e="nav-documents"
:class="{ active: route.name === 'documents' }"
>
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
@ -30,7 +45,12 @@
<span class="nav-label">{{ t('nav.documents') }}</span>
</RouterLink>
<RouterLink to="/history" class="nav-item" :class="{ active: route.name === 'history' }">
<RouterLink
to="/history"
class="nav-item"
data-e2e="nav-history"
:class="{ active: route.name === 'history' }"
>
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
@ -41,7 +61,12 @@
<span class="nav-label">{{ t('nav.history') }}</span>
</RouterLink>
<RouterLink to="/settings" class="nav-item" :class="{ active: route.name === 'settings' }">
<RouterLink
to="/settings"
class="nav-item"
data-e2e="nav-settings"
:class="{ active: route.name === 'settings' }"
>
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"