Merge pull request #125 from scub-france/feature/karate-ui-e2e-tests
feat: add Karate UI e2e tests with data-e2e selectors
This commit is contained in:
commit
6d3453dc7f
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
|
if [[ "${{ github.base_ref }}" == release/* ]] || [[ "${{ github.ref }}" == refs/heads/release/* ]]; then
|
||||||
KARATE_TAGS="@smoke,@regression,@e2e"
|
KARATE_TAGS="@smoke,@regression,@e2e"
|
||||||
fi
|
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
|
- name: Upload Karate reports
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: karate-reports
|
name: karate-api-reports
|
||||||
path: e2e/target/karate-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
|
- name: Tear down
|
||||||
if: always()
|
if: always()
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -43,3 +43,6 @@ hs_err_pid*
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker-compose.override.yml
|
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
|
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.
|
All tests must pass before submitting a PR.
|
||||||
|
|
||||||
## Pull Request Guidelines
|
## 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
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Generate test PDFs
|
# 1. Generate test PDFs (from repo root)
|
||||||
python e2e/generate-test-data.py
|
python e2e/generate-test-data.py
|
||||||
|
|
||||||
# 2. Start the stack
|
# 2. Start the stack
|
||||||
docker compose up -d --wait
|
docker compose up -d --wait
|
||||||
|
|
||||||
# 3. Run all tests
|
# 3. Run all API tests
|
||||||
mvn test -f e2e/pom.xml
|
mvn test -f e2e/api/pom.xml
|
||||||
|
|
||||||
# 4. Tear down
|
# 4. Tear down
|
||||||
docker compose down
|
docker compose down
|
||||||
|
|
@ -29,26 +29,25 @@ docker compose down
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Smoke only (~30s)
|
# 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)
|
# 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)
|
# 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
|
## Custom base URL
|
||||||
|
|
||||||
```bash
|
```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
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
e2e/
|
e2e/api/
|
||||||
├── generate-test-data.py # Generates test PDFs (no binaries in repo)
|
|
||||||
├── pom.xml # Maven + Karate dependency
|
├── pom.xml # Maven + Karate dependency
|
||||||
├── src/test/java/
|
├── src/test/java/
|
||||||
│ └── E2ERunner.java # JUnit5 Karate runner
|
│ └── E2ERunner.java # JUnit5 Karate runner
|
||||||
|
|
@ -68,4 +67,4 @@ e2e/
|
||||||
|
|
||||||
## Reports
|
## 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
|
from fpdf import FPDF
|
||||||
|
|
||||||
OUTPUT_DIR = os.path.join(
|
OUTPUT_DIRS = [
|
||||||
os.path.dirname(__file__), "src", "test", "resources", "common", "data", "generated"
|
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 = [
|
_PARAGRAPHS = [
|
||||||
"Document processing is a critical step in building retrieval-augmented generation systems.",
|
"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:
|
def main() -> None:
|
||||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
for output_dir in OUTPUT_DIRS:
|
||||||
print(f"Generating test data in {OUTPUT_DIR}")
|
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(1, os.path.join(output_dir, "small.pdf"))
|
||||||
_make_pdf(5, os.path.join(OUTPUT_DIR, "medium.pdf"))
|
_make_pdf(5, os.path.join(output_dir, "medium.pdf"))
|
||||||
_make_pdf(25, os.path.join(OUTPUT_DIR, "large.pdf"))
|
_make_pdf(25, os.path.join(output_dir, "large.pdf"))
|
||||||
_make_non_pdf(os.path.join(OUTPUT_DIR, "not-a-pdf.txt"))
|
_make_non_pdf(os.path.join(output_dir, "not-a-pdf.txt"))
|
||||||
|
|
||||||
print("Done.")
|
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>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
<groupId>com.scub.docling-studio</groupId>
|
<groupId>com.scub.docling-studio</groupId>
|
||||||
<artifactId>e2e-tests</artifactId>
|
<artifactId>e2e-ui-tests</artifactId>
|
||||||
<version>0.3.1</version>
|
<version>0.3.1</version>
|
||||||
<packaging>jar</packaging>
|
<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">
|
<header class="topbar">
|
||||||
<button
|
<button
|
||||||
class="burger-btn"
|
class="burger-btn"
|
||||||
|
data-e2e="burger-btn"
|
||||||
@click="sidebarOpen = !sidebarOpen"
|
@click="sidebarOpen = !sidebarOpen"
|
||||||
:title="sidebarOpen ? t('nav.collapse') : t('nav.expand')"
|
:title="sidebarOpen ? t('nav.collapse') : t('nav.expand')"
|
||||||
>
|
>
|
||||||
|
|
@ -14,7 +15,7 @@
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="topbar-logo">
|
<div class="topbar-logo" data-e2e="topbar-logo">
|
||||||
<img src="/logo.png" alt="Docling Studio" class="topbar-logo-icon" />
|
<img src="/logo.png" alt="Docling Studio" class="topbar-logo-icon" />
|
||||||
<span class="topbar-logo-text">Docling Studio</span>
|
<span class="topbar-logo-text">Docling Studio</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="result-tabs" v-if="store.currentAnalysis?.status === 'COMPLETED'">
|
<div
|
||||||
<div class="tabs-header">
|
class="result-tabs"
|
||||||
|
data-e2e="result-tabs"
|
||||||
|
v-if="store.currentAnalysis?.status === 'COMPLETED'"
|
||||||
|
>
|
||||||
|
<div class="tabs-header" data-e2e="tabs-header">
|
||||||
<button
|
<button
|
||||||
v-for="tab in tabs"
|
v-for="tab in tabs"
|
||||||
:key="tab.id"
|
:key="tab.id"
|
||||||
class="tab-btn"
|
class="tab-btn"
|
||||||
|
data-e2e="tab-btn"
|
||||||
:class="{ active: activeTab === tab.id }"
|
:class="{ active: activeTab === tab.id }"
|
||||||
@click="activeTab = tab.id"
|
@click="activeTab = tab.id"
|
||||||
>
|
>
|
||||||
|
|
@ -13,7 +18,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Page chip -->
|
<!-- 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">{{
|
<span class="page-chip">{{
|
||||||
t('results.pageOf', { current: currentPage, total: totalPages })
|
t('results.pageOf', { current: currentPage, total: totalPages })
|
||||||
}}</span>
|
}}</span>
|
||||||
|
|
@ -21,7 +26,7 @@
|
||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<!-- ELEMENTS VIEW — each bbox as a separate card -->
|
<!-- 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">
|
<div v-if="!currentElements.length" class="elements-empty">
|
||||||
{{ t('results.noElements') }}
|
{{ t('results.noElements') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -29,6 +34,7 @@
|
||||||
v-for="(el, idx) in currentElements"
|
v-for="(el, idx) in currentElements"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
class="element-card"
|
class="element-card"
|
||||||
|
data-e2e="element-card"
|
||||||
:class="{ highlighted: highlightedIndex === idx }"
|
:class="{ highlighted: highlightedIndex === idx }"
|
||||||
@mouseenter="$emit('highlight-element', idx)"
|
@mouseenter="$emit('highlight-element', idx)"
|
||||||
@mouseleave="$emit('highlight-element', -1)"
|
@mouseleave="$emit('highlight-element', -1)"
|
||||||
|
|
@ -79,7 +85,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RAW MARKDOWN -->
|
<!-- 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">
|
<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">
|
<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" />
|
<path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z" />
|
||||||
|
|
@ -95,7 +101,7 @@
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<pre class="raw-content">{{ pageMarkdown }}</pre>
|
<pre class="raw-content" data-e2e="raw-content">{{ pageMarkdown }}</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- IMAGES -->
|
<!-- IMAGES -->
|
||||||
|
|
@ -118,7 +124,11 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<svg viewBox="0 0 20 20" fill="currentColor" class="error-icon">
|
||||||
<path
|
<path
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="chunk-panel">
|
<div class="chunk-panel" data-e2e="chunk-panel">
|
||||||
<!-- Chunking config — collapsible -->
|
<!-- Chunking config — collapsible -->
|
||||||
<div class="chunk-config">
|
<div class="chunk-config">
|
||||||
<button class="config-toggle" @click="configOpen = !configOpen">
|
<button class="config-toggle" data-e2e="config-toggle" @click="configOpen = !configOpen">
|
||||||
<svg
|
<svg
|
||||||
class="config-chevron"
|
class="config-chevron"
|
||||||
|
data-e2e="config-chevron"
|
||||||
:class="{ open: configOpen }"
|
:class="{ open: configOpen }"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
|
|
@ -18,10 +19,10 @@
|
||||||
<span class="config-label">{{ t('chunking.settings') }}</span>
|
<span class="config-label">{{ t('chunking.settings') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-if="configOpen" class="config-body">
|
<div v-if="configOpen" class="config-body" data-e2e="config-body">
|
||||||
<div class="config-row">
|
<div class="config-row">
|
||||||
<label class="config-label-sm">{{ t('chunking.chunkerType') }}</label>
|
<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="hybrid">Hybrid</option>
|
||||||
<option value="hierarchical">Hierarchical</option>
|
<option value="hierarchical">Hierarchical</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -32,6 +33,7 @@
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
class="config-input"
|
class="config-input"
|
||||||
|
data-e2e="config-input"
|
||||||
v-model.number="options.max_tokens"
|
v-model.number="options.max_tokens"
|
||||||
min="64"
|
min="64"
|
||||||
max="8192"
|
max="8192"
|
||||||
|
|
@ -57,6 +59,7 @@
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="chunk-btn primary"
|
class="chunk-btn primary"
|
||||||
|
data-e2e="chunk-btn"
|
||||||
:disabled="!canRechunk || analysisStore.rechunking"
|
:disabled="!canRechunk || analysisStore.rechunking"
|
||||||
@click="doRechunk"
|
@click="doRechunk"
|
||||||
>
|
>
|
||||||
|
|
@ -67,11 +70,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chunks list -->
|
<!-- Chunks list -->
|
||||||
<div class="chunk-results" v-if="pageChunks.length">
|
<div class="chunk-results" data-e2e="chunk-results" v-if="pageChunks.length">
|
||||||
<div class="chunk-summary">{{ pagination.totalItems.value }} {{ t('chunking.chunks') }}</div>
|
<div class="chunk-summary" data-e2e="chunk-summary">
|
||||||
|
{{ pagination.totalItems.value }} {{ t('chunking.chunks') }}
|
||||||
|
</div>
|
||||||
<div class="chunk-list">
|
<div class="chunk-list">
|
||||||
<div
|
<div
|
||||||
class="chunk-card"
|
class="chunk-card"
|
||||||
|
data-e2e="chunk-card"
|
||||||
v-for="(chunk, localIdx) in pagination.paginatedItems.value"
|
v-for="(chunk, localIdx) in pagination.paginatedItems.value"
|
||||||
:key="globalIndex(localIdx)"
|
:key="globalIndex(localIdx)"
|
||||||
:class="{ highlighted: hoveredChunkIdx === globalIndex(localIdx) }"
|
:class="{ highlighted: hoveredChunkIdx === globalIndex(localIdx) }"
|
||||||
|
|
@ -79,8 +85,8 @@
|
||||||
@mouseleave="onChunkLeave"
|
@mouseleave="onChunkLeave"
|
||||||
>
|
>
|
||||||
<div class="chunk-header">
|
<div class="chunk-header">
|
||||||
<span class="chunk-index">#{{ globalIndex(localIdx) + 1 }}</span>
|
<span class="chunk-index" data-e2e="chunk-index">#{{ globalIndex(localIdx) + 1 }}</span>
|
||||||
<span class="chunk-tokens" v-if="chunk.tokenCount">
|
<span class="chunk-tokens" data-e2e="chunk-tokens" v-if="chunk.tokenCount">
|
||||||
{{ chunk.tokenCount }} tokens
|
{{ chunk.tokenCount }} tokens
|
||||||
</span>
|
</span>
|
||||||
<span class="chunk-page" v-if="chunk.sourcePage"> p.{{ chunk.sourcePage }} </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">
|
<div class="chunk-headings" v-if="chunk.headings.length">
|
||||||
<span class="chunk-heading" v-for="h in chunk.headings" :key="h">{{ h }}</span>
|
<span class="chunk-heading" v-for="h in chunk.headings" :key="h">{{ h }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chunk-text">{{ chunk.text }}</div>
|
<div class="chunk-text" data-e2e="chunk-text">{{ chunk.text }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
v-for="doc in store.documents"
|
v-for="doc in store.documents"
|
||||||
:key="doc.id"
|
:key="doc.id"
|
||||||
class="doc-item"
|
class="doc-item"
|
||||||
|
data-e2e="doc-item"
|
||||||
:class="{ selected: store.selectedId === doc.id }"
|
:class="{ selected: store.selectedId === doc.id }"
|
||||||
@click="store.select(doc.id)"
|
@click="store.select(doc.id)"
|
||||||
>
|
>
|
||||||
|
|
@ -16,14 +17,19 @@
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="doc-meta">
|
<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"
|
<span class="doc-size"
|
||||||
>{{ formatSize(doc.fileSize)
|
>{{ formatSize(doc.fileSize)
|
||||||
}}{{ doc.pageCount ? ` — ${doc.pageCount} pages` : '' }}</span
|
}}{{ doc.pageCount ? ` — ${doc.pageCount} pages` : '' }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path
|
<path
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="upload-zone"
|
class="upload-zone"
|
||||||
|
data-e2e="upload-zone"
|
||||||
:class="{ dragging, uploading: store.uploading }"
|
:class="{ dragging, uploading: store.uploading }"
|
||||||
@dragover.prevent="dragging = true"
|
@dragover.prevent="dragging = true"
|
||||||
@dragleave.prevent="dragging = false"
|
@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" />
|
<path d="M12 16V4m0 0L8 8m4-4l4 4M4 17v2a1 1 0 001 1h14a1 1 0 001-1v-2" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="upload-text">{{ t('upload.drop') }}</span>
|
<span class="upload-text">{{ t('upload.drop') }}</span>
|
||||||
<span class="upload-hint">{{ uploadHint }}</span>
|
<span class="upload-hint" data-e2e="upload-hint">{{ uploadHint }}</span>
|
||||||
<span v-if="store.error" class="upload-error">{{ store.error }}</span>
|
<span v-if="store.error" class="upload-error" data-e2e="upload-error">{{ store.error }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="settings-panel">
|
<div class="settings-panel" data-e2e="settings-panel">
|
||||||
<div class="setting-group">
|
<div class="setting-group">
|
||||||
<label class="setting-label">{{ t('settings.theme') }}</label>
|
<label class="setting-label">{{ t('settings.theme') }}</label>
|
||||||
<div class="setting-toggle">
|
<div class="setting-toggle">
|
||||||
|
|
@ -15,10 +15,18 @@
|
||||||
<div class="setting-group">
|
<div class="setting-group">
|
||||||
<label class="setting-label">{{ t('settings.language') }}</label>
|
<label class="setting-label">{{ t('settings.language') }}</label>
|
||||||
<div class="setting-toggle">
|
<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
|
FR
|
||||||
</button>
|
</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
|
EN
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
<div class="mode-toggle">
|
<div class="mode-toggle">
|
||||||
<button
|
<button
|
||||||
class="toggle-btn"
|
class="toggle-btn"
|
||||||
|
data-e2e="toggle-btn"
|
||||||
:class="{ active: mode === 'configurer' }"
|
:class="{ active: mode === 'configurer' }"
|
||||||
@click="mode = 'configurer'"
|
@click="mode = 'configurer'"
|
||||||
>
|
>
|
||||||
|
|
@ -36,6 +37,7 @@
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="toggle-btn"
|
class="toggle-btn"
|
||||||
|
data-e2e="toggle-btn"
|
||||||
:class="{ active: mode === 'verifier' }"
|
:class="{ active: mode === 'verifier' }"
|
||||||
@click="mode = 'verifier'"
|
@click="mode = 'verifier'"
|
||||||
:disabled="!analysisStore.currentAnalysis"
|
:disabled="!analysisStore.currentAnalysis"
|
||||||
|
|
@ -52,6 +54,7 @@
|
||||||
<button
|
<button
|
||||||
v-if="chunkingEnabled"
|
v-if="chunkingEnabled"
|
||||||
class="toggle-btn"
|
class="toggle-btn"
|
||||||
|
data-e2e="toggle-btn"
|
||||||
:class="{ active: mode === 'preparer' }"
|
:class="{ active: mode === 'preparer' }"
|
||||||
@click="mode = 'preparer'"
|
@click="mode = 'preparer'"
|
||||||
:disabled="!analysisStore.currentAnalysis"
|
:disabled="!analysisStore.currentAnalysis"
|
||||||
|
|
@ -76,6 +79,7 @@
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="topbar-btn primary"
|
class="topbar-btn primary"
|
||||||
|
data-e2e="run-btn"
|
||||||
:disabled="analysisStore.running"
|
:disabled="analysisStore.running"
|
||||||
@click="runAnalysis"
|
@click="runAnalysis"
|
||||||
v-if="mode === 'configurer'"
|
v-if="mode === 'configurer'"
|
||||||
|
|
@ -189,6 +193,7 @@
|
||||||
:src="previewUrl"
|
:src="previewUrl"
|
||||||
:alt="`Page ${currentPage}`"
|
:alt="`Page ${currentPage}`"
|
||||||
class="pdf-image"
|
class="pdf-image"
|
||||||
|
data-e2e="pdf-image"
|
||||||
@load="onPdfImageLoad"
|
@load="onPdfImageLoad"
|
||||||
/>
|
/>
|
||||||
<BboxOverlay
|
<BboxOverlay
|
||||||
|
|
@ -212,7 +217,7 @@
|
||||||
<!-- Right: Config or Results panel -->
|
<!-- Right: Config or Results panel -->
|
||||||
<div class="right-panel" :style="{ width: rightPanelWidth + 'px' }">
|
<div class="right-panel" :style="{ width: rightPanelWidth + 'px' }">
|
||||||
<!-- CONFIGURER MODE -->
|
<!-- 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">
|
<div class="config-section">
|
||||||
<label class="config-label">
|
<label class="config-label">
|
||||||
{{ t('config.model') }}
|
{{ t('config.model') }}
|
||||||
|
|
@ -228,9 +233,9 @@
|
||||||
<label class="config-label">{{ t('config.pipeline') }}</label>
|
<label class="config-label">{{ t('config.pipeline') }}</label>
|
||||||
|
|
||||||
<div class="config-toggle-row">
|
<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" />
|
<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>
|
<span class="toggle-text">{{ t('config.ocr') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="config-hint"
|
<span class="config-hint"
|
||||||
|
|
@ -240,13 +245,13 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="config-toggle-row">
|
<div class="config-toggle-row">
|
||||||
<label class="toggle-label">
|
<label class="toggle-label" data-e2e="toggle-label">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
v-model="pipelineOptions.do_table_structure"
|
v-model="pipelineOptions.do_table_structure"
|
||||||
class="toggle-input"
|
class="toggle-input"
|
||||||
/>
|
/>
|
||||||
<span class="toggle-switch" />
|
<span class="toggle-switch" data-e2e="toggle-switch" />
|
||||||
<span class="toggle-text">{{ t('config.tableStructure') }}</span>
|
<span class="toggle-text">{{ t('config.tableStructure') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="config-hint"
|
<span class="config-hint"
|
||||||
|
|
@ -257,7 +262,11 @@
|
||||||
|
|
||||||
<div class="config-sub-option" v-if="pipelineOptions.do_table_structure">
|
<div class="config-sub-option" v-if="pipelineOptions.do_table_structure">
|
||||||
<label class="config-label-sm">{{ t('config.tableMode') }}</label>
|
<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="accurate">{{ t('config.tableModeAccurate') }}</option>
|
||||||
<option value="fast">{{ t('config.tableModeFast') }}</option>
|
<option value="fast">{{ t('config.tableModeFast') }}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -269,13 +278,13 @@
|
||||||
<label class="config-label">{{ t('config.enrichment') }}</label>
|
<label class="config-label">{{ t('config.enrichment') }}</label>
|
||||||
|
|
||||||
<div class="config-toggle-row">
|
<div class="config-toggle-row">
|
||||||
<label class="toggle-label">
|
<label class="toggle-label" data-e2e="toggle-label">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
v-model="pipelineOptions.do_code_enrichment"
|
v-model="pipelineOptions.do_code_enrichment"
|
||||||
class="toggle-input"
|
class="toggle-input"
|
||||||
/>
|
/>
|
||||||
<span class="toggle-switch" />
|
<span class="toggle-switch" data-e2e="toggle-switch" />
|
||||||
<span class="toggle-text">{{ t('config.codeEnrichment') }}</span>
|
<span class="toggle-text">{{ t('config.codeEnrichment') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="config-hint"
|
<span class="config-hint"
|
||||||
|
|
@ -285,13 +294,13 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="config-toggle-row">
|
<div class="config-toggle-row">
|
||||||
<label class="toggle-label">
|
<label class="toggle-label" data-e2e="toggle-label">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
v-model="pipelineOptions.do_formula_enrichment"
|
v-model="pipelineOptions.do_formula_enrichment"
|
||||||
class="toggle-input"
|
class="toggle-input"
|
||||||
/>
|
/>
|
||||||
<span class="toggle-switch" />
|
<span class="toggle-switch" data-e2e="toggle-switch" />
|
||||||
<span class="toggle-text">{{ t('config.formulaEnrichment') }}</span>
|
<span class="toggle-text">{{ t('config.formulaEnrichment') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="config-hint"
|
<span class="config-hint"
|
||||||
|
|
@ -306,13 +315,13 @@
|
||||||
<label class="config-label">{{ t('config.pictures') }}</label>
|
<label class="config-label">{{ t('config.pictures') }}</label>
|
||||||
|
|
||||||
<div class="config-toggle-row">
|
<div class="config-toggle-row">
|
||||||
<label class="toggle-label">
|
<label class="toggle-label" data-e2e="toggle-label">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
v-model="pipelineOptions.do_picture_classification"
|
v-model="pipelineOptions.do_picture_classification"
|
||||||
class="toggle-input"
|
class="toggle-input"
|
||||||
/>
|
/>
|
||||||
<span class="toggle-switch" />
|
<span class="toggle-switch" data-e2e="toggle-switch" />
|
||||||
<span class="toggle-text">{{ t('config.pictureClassification') }}</span>
|
<span class="toggle-text">{{ t('config.pictureClassification') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="config-hint"
|
<span class="config-hint"
|
||||||
|
|
@ -322,13 +331,13 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="config-toggle-row">
|
<div class="config-toggle-row">
|
||||||
<label class="toggle-label">
|
<label class="toggle-label" data-e2e="toggle-label">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
v-model="pipelineOptions.do_picture_description"
|
v-model="pipelineOptions.do_picture_description"
|
||||||
class="toggle-input"
|
class="toggle-input"
|
||||||
/>
|
/>
|
||||||
<span class="toggle-switch" />
|
<span class="toggle-switch" data-e2e="toggle-switch" />
|
||||||
<span class="toggle-text">{{ t('config.pictureDescription') }}</span>
|
<span class="toggle-text">{{ t('config.pictureDescription') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="config-hint"
|
<span class="config-hint"
|
||||||
|
|
@ -338,13 +347,13 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="config-toggle-row">
|
<div class="config-toggle-row">
|
||||||
<label class="toggle-label">
|
<label class="toggle-label" data-e2e="toggle-label">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
v-model="pipelineOptions.generate_picture_images"
|
v-model="pipelineOptions.generate_picture_images"
|
||||||
class="toggle-input"
|
class="toggle-input"
|
||||||
/>
|
/>
|
||||||
<span class="toggle-switch" />
|
<span class="toggle-switch" data-e2e="toggle-switch" />
|
||||||
<span class="toggle-text">{{ t('config.generatePictureImages') }}</span>
|
<span class="toggle-text">{{ t('config.generatePictureImages') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="config-hint"
|
<span class="config-hint"
|
||||||
|
|
@ -354,13 +363,13 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="config-toggle-row">
|
<div class="config-toggle-row">
|
||||||
<label class="toggle-label">
|
<label class="toggle-label" data-e2e="toggle-label">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
v-model="pipelineOptions.generate_page_images"
|
v-model="pipelineOptions.generate_page_images"
|
||||||
class="toggle-input"
|
class="toggle-input"
|
||||||
/>
|
/>
|
||||||
<span class="toggle-switch" />
|
<span class="toggle-switch" data-e2e="toggle-switch" />
|
||||||
<span class="toggle-text">{{ t('config.generatePageImages') }}</span>
|
<span class="toggle-text">{{ t('config.generatePageImages') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="config-hint"
|
<span class="config-hint"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
<template>
|
<template>
|
||||||
<aside class="sidebar" :class="{ open }">
|
<aside class="sidebar" data-e2e="sidebar" :class="{ open }">
|
||||||
<nav class="sidebar-nav">
|
<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">
|
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path
|
<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"
|
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>
|
<span class="nav-label">{{ t('nav.home') }}</span>
|
||||||
</RouterLink>
|
</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">
|
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path
|
<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"
|
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>
|
<span class="nav-label">{{ t('nav.studio') }}</span>
|
||||||
</RouterLink>
|
</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">
|
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path
|
<path
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
|
|
@ -30,7 +45,12 @@
|
||||||
<span class="nav-label">{{ t('nav.documents') }}</span>
|
<span class="nav-label">{{ t('nav.documents') }}</span>
|
||||||
</RouterLink>
|
</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">
|
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path
|
<path
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
|
|
@ -41,7 +61,12 @@
|
||||||
<span class="nav-label">{{ t('nav.history') }}</span>
|
<span class="nav-label">{{ t('nav.history') }}</span>
|
||||||
</RouterLink>
|
</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">
|
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path
|
<path
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue