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:
parent
9696526961
commit
fdbab2f49e
59 changed files with 1187 additions and 73 deletions
73
.github/workflows/ci.yml
vendored
73
.github/workflows/ci.yml
vendored
|
|
@ -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
3
.gitignore
vendored
|
|
@ -43,3 +43,6 @@ hs_err_pid*
|
|||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
|
||||
# E2E tests — Maven build outputs & Chrome user data
|
||||
e2e/**/target/
|
||||
|
|
|
|||
|
|
@ -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
2
e2e/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
e2e/target/
|
||||
e2e/target/
|
||||
212
e2e/CONVENTIONS.md
Normal file
212
e2e/CONVENTIONS.md
Normal 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 |
|
||||
|
|
@ -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
58
e2e/api/pom.xml
Normal 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>
|
||||
|
|
@ -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
93
e2e/ui/README.md
Normal 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/`.
|
||||
|
|
@ -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>
|
||||
|
||||
24
e2e/ui/src/test/java/UIRunner.java
Normal file
24
e2e/ui/src/test/java/UIRunner.java
Normal 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());
|
||||
}
|
||||
}
|
||||
50
e2e/ui/src/test/resources/analyses/analysis.feature
Normal file
50
e2e/ui/src/test/resources/analyses/analysis.feature
Normal 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)' }
|
||||
32
e2e/ui/src/test/resources/analyses/batch-progress.feature
Normal file
32
e2e/ui/src/test/resources/analyses/batch-progress.feature
Normal 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)' }
|
||||
64
e2e/ui/src/test/resources/analyses/pipeline-options.feature
Normal file
64
e2e/ui/src/test/resources/analyses/pipeline-options.feature
Normal 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)' }
|
||||
62
e2e/ui/src/test/resources/analyses/rechunk.feature
Normal file
62
e2e/ui/src/test/resources/analyses/rechunk.feature
Normal 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)' }
|
||||
26
e2e/ui/src/test/resources/common/helpers/analyze.feature
Normal file
26
e2e/ui/src/test/resources/common/helpers/analyze.feature
Normal 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
|
||||
|
|
@ -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 })
|
||||
12
e2e/ui/src/test/resources/common/helpers/cleanup.feature
Normal file
12
e2e/ui/src/test/resources/common/helpers/cleanup.feature
Normal 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
|
||||
10
e2e/ui/src/test/resources/common/helpers/ui-upload.feature
Normal file
10
e2e/ui/src/test/resources/common/helpers/ui-upload.feature
Normal 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)
|
||||
|
|
@ -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]')")
|
||||
14
e2e/ui/src/test/resources/common/helpers/upload.feature
Normal file
14
e2e/ui/src/test/resources/common/helpers/upload.feature
Normal 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
|
||||
30
e2e/ui/src/test/resources/documents/delete.feature
Normal file
30
e2e/ui/src/test/resources/documents/delete.feature
Normal 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)' }
|
||||
22
e2e/ui/src/test/resources/documents/error-states.feature
Normal file
22
e2e/ui/src/test/resources/documents/error-states.feature
Normal 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]') != ''
|
||||
47
e2e/ui/src/test/resources/documents/upload.feature
Normal file
47
e2e/ui/src/test/resources/documents/upload.feature
Normal 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' }
|
||||
23
e2e/ui/src/test/resources/karate-config.js
Normal file
23
e2e/ui/src/test/resources/karate-config.js
Normal 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;
|
||||
}
|
||||
22
e2e/ui/src/test/resources/navigation/i18n.feature
Normal file
22
e2e/ui/src/test/resources/navigation/i18n.feature
Normal 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'
|
||||
48
e2e/ui/src/test/resources/navigation/sidebar.feature
Normal file
48
e2e/ui/src/test/resources/navigation/sidebar.feature
Normal 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'
|
||||
79
e2e/ui/src/test/resources/workflows/full-ui-path.feature
Normal file
79
e2e/ui/src/test/resources/workflows/full-ui-path.feature
Normal 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')")
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue