From e4ec700e88ec06354134bece992d9a65d38f44d9 Mon Sep 17 00:00:00 2001 From: Many Kasiriha Date: Fri, 12 Jun 2026 15:08:38 +0200 Subject: [PATCH 1/6] feat: visual review dashboard, result sidecars, and unified uv packaging Dashboard (new, ships as the [dashboard] extra): - FastAPI backend: output.xml ingestion (sidecar-first with HTML-scraping fallback), SQLite store, accept/reject with SHA-256 audit trail and bug-data export, root-confined asset serving, masks API with library- parity normalization, embedded comparison engine for live mask preview and recompare of stored runs, file browser, local file and results-folder upload, feature-advertising health endpoint - React/Vite frontend: run/test grids, diff viewer (side-by-side, overlay, blink, swipe, region navigation), react-konva mask editor with live pattern preview, create-mask-from-diff-region, version-skew banner - Tests: backend suites against real robot-generated runs, Playwright end-to-end journeys (ingest, review, accept, reject, mask editing, uploads, version skew), wheel parity gate Core library: - result_json sidecar (schema v1) with per-page scores, diff regions, resolved masks, and lossless renderings; DOCTEST_RESULT log line - implement previously non-functional reference_run promotion - fix _convert_to_pixels truncation (25.4mm @200dpi is now 200px) - OCR pattern fixes: original-case matching, line-level matching for line_pattern, phrase span matching for patterns containing whitespace Packaging (uv-unified-packaging): - single PEP 621 distribution with ai/dashboard/all extras, hatchling build, universal uv.lock; poetry removed - poetry multi-constraints translated to environment markers, validated per interpreter (3.9-3.13) by scripts/audit_resolved_versions.py - wheel/sdist parity with the poetry baseline gated by scripts/compare_wheel_contents.py - uv-based invoke tasks (incl. multipython), CI, publish workflow with frontend build step, docs Co-Authored-By: claude-flow --- .github/workflows/ci.yml | 80 +- .github/workflows/python-publish.yml | 36 +- .gitignore | 7 + .gitpod.yml | 5 +- DocTest/DocumentRepresentation.py | 151 +- DocTest/PdfTest.py | 181 +- DocTest/ReferencePromotion.py | 29 + DocTest/ResultWriter.py | 129 + DocTest/VisualTest.py | 155 + README.md | 64 +- atest/ReferenceRun.robot | 48 + atest/ResultJson.robot | 63 + docs/Ai-0.32.0.html | 2 +- docs/Ai-0.33.0.html | 2 +- docs/Ai-0.34.0.html | 387 ++ docs/Ai.html | 2 +- docs/PdfTest-0.32.0.html | 2 +- docs/PdfTest-0.33.0.html | 2 +- docs/PdfTest-0.34.0.html | 387 ++ docs/PdfTest.html | 2 +- docs/PrintJobTest-0.32.0.html | 2 +- docs/PrintJobTest-0.33.0.html | 2 +- docs/PrintJobTest-0.34.0.html | 387 ++ docs/PrintJobTest.html | 2 +- docs/VisualTest-0.32.0.html | 2 +- docs/VisualTest-0.33.0.html | 2 +- docs/VisualTest-0.34.0.html | 387 ++ docs/VisualTest.html | 2 +- docs/dashboard.md | 164 + docs/doctest-dashboard-proposal.md | 275 ++ doctest_dashboard/__init__.py | 12 + doctest_dashboard/cli.py | 109 + doctest_dashboard/config.py | 48 + doctest_dashboard/db.py | 212 + doctest_dashboard/engine.py | 208 + doctest_dashboard/ingest.py | 231 ++ doctest_dashboard/masks.py | 105 + doctest_dashboard/models/__init__.py | 0 doctest_dashboard/models/sidecar.py | 80 + doctest_dashboard/review.py | 189 + doctest_dashboard/server/__init__.py | 0 doctest_dashboard/server/app.py | 547 +++ e2e/conftest.py | 59 + e2e/e2e_helpers.py | 33 + e2e/test_journeys.py | 300 ++ e2e/test_version_skew.py | 56 + frontend/index.html | 12 + frontend/package-lock.json | 1826 +++++++++ frontend/package.json | 24 + frontend/src/App.tsx | 267 ++ frontend/src/ComparisonView.tsx | 332 ++ frontend/src/FileBrowser.tsx | 182 + frontend/src/MaskEditor.tsx | 747 ++++ frontend/src/api.ts | 112 + frontend/src/main.tsx | 10 + frontend/src/styles.css | 115 + frontend/tsconfig.json | 20 + frontend/vite.config.ts | 15 + .../.openspec.yaml | 2 + .../2026-06-12-doctest-dashboard/design.md | 94 + .../2026-06-12-doctest-dashboard/proposal.md | 41 + .../specs/dashboard-ingest/spec.md | 43 + .../specs/dashboard-review/spec.md | 57 + .../specs/live-recompare/spec.md | 39 + .../specs/mask-editor/spec.md | 56 + .../specs/result-sidecar/spec.md | 58 + .../2026-06-12-doctest-dashboard/tasks.md | 61 + .../.openspec.yaml | 2 + .../2026-06-12-uv-unified-packaging/design.md | 110 + .../proposal.md | 32 + .../specs/unified-packaging/spec.md | 43 + .../specs/uv-tooling/spec.md | 50 + .../2026-06-12-uv-unified-packaging/tasks.md | 51 + openspec/config.yaml | 20 + openspec/specs/dashboard-ingest/spec.md | 43 + openspec/specs/dashboard-review/spec.md | 57 + openspec/specs/live-recompare/spec.md | 39 + openspec/specs/mask-editor/spec.md | 56 + openspec/specs/result-sidecar/spec.md | 58 + openspec/specs/unified-packaging/spec.md | 43 + openspec/specs/uv-tooling/spec.md | 50 + pyproject.toml | 143 +- scripts/audit_resolved_versions.py | 73 + scripts/compare_wheel_contents.py | 164 + scripts/wheel_baseline.json | 105 + tasks.py | 77 +- utest/dashboard/conftest.py | 81 + utest/dashboard/helpers.py | 34 + utest/dashboard/test_api.py | 114 + utest/dashboard/test_browse.py | 54 + utest/dashboard/test_cli_guard.py | 21 + utest/dashboard/test_engine.py | 190 + utest/dashboard/test_feature_contract.py | 32 + utest/dashboard/test_ingest.py | 62 + utest/dashboard/test_masks.py | 148 + utest/dashboard/test_review.py | 200 + utest/dashboard/test_sidecar_contract.py | 51 + utest/dashboard/test_upload.py | 121 + utest/test_masks.py | 145 + utest/test_package_parity.py | 22 + utest/test_reference_run.py | 96 + utest/test_result_json.py | 167 + uv.lock | 3610 +++++++++++++++++ 103 files changed, 15467 insertions(+), 158 deletions(-) create mode 100644 DocTest/ReferencePromotion.py create mode 100644 DocTest/ResultWriter.py create mode 100644 atest/ReferenceRun.robot create mode 100644 atest/ResultJson.robot create mode 100644 docs/Ai-0.34.0.html create mode 100644 docs/PdfTest-0.34.0.html create mode 100644 docs/PrintJobTest-0.34.0.html create mode 100644 docs/VisualTest-0.34.0.html create mode 100644 docs/dashboard.md create mode 100644 docs/doctest-dashboard-proposal.md create mode 100644 doctest_dashboard/__init__.py create mode 100644 doctest_dashboard/cli.py create mode 100644 doctest_dashboard/config.py create mode 100644 doctest_dashboard/db.py create mode 100644 doctest_dashboard/engine.py create mode 100644 doctest_dashboard/ingest.py create mode 100644 doctest_dashboard/masks.py create mode 100644 doctest_dashboard/models/__init__.py create mode 100644 doctest_dashboard/models/sidecar.py create mode 100644 doctest_dashboard/review.py create mode 100644 doctest_dashboard/server/__init__.py create mode 100644 doctest_dashboard/server/app.py create mode 100644 e2e/conftest.py create mode 100644 e2e/e2e_helpers.py create mode 100644 e2e/test_journeys.py create mode 100644 e2e/test_version_skew.py create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/ComparisonView.tsx create mode 100644 frontend/src/FileBrowser.tsx create mode 100644 frontend/src/MaskEditor.tsx create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/styles.css create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 openspec/changes/archive/2026-06-12-doctest-dashboard/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-12-doctest-dashboard/design.md create mode 100644 openspec/changes/archive/2026-06-12-doctest-dashboard/proposal.md create mode 100644 openspec/changes/archive/2026-06-12-doctest-dashboard/specs/dashboard-ingest/spec.md create mode 100644 openspec/changes/archive/2026-06-12-doctest-dashboard/specs/dashboard-review/spec.md create mode 100644 openspec/changes/archive/2026-06-12-doctest-dashboard/specs/live-recompare/spec.md create mode 100644 openspec/changes/archive/2026-06-12-doctest-dashboard/specs/mask-editor/spec.md create mode 100644 openspec/changes/archive/2026-06-12-doctest-dashboard/specs/result-sidecar/spec.md create mode 100644 openspec/changes/archive/2026-06-12-doctest-dashboard/tasks.md create mode 100644 openspec/changes/archive/2026-06-12-uv-unified-packaging/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-12-uv-unified-packaging/design.md create mode 100644 openspec/changes/archive/2026-06-12-uv-unified-packaging/proposal.md create mode 100644 openspec/changes/archive/2026-06-12-uv-unified-packaging/specs/unified-packaging/spec.md create mode 100644 openspec/changes/archive/2026-06-12-uv-unified-packaging/specs/uv-tooling/spec.md create mode 100644 openspec/changes/archive/2026-06-12-uv-unified-packaging/tasks.md create mode 100644 openspec/config.yaml create mode 100644 openspec/specs/dashboard-ingest/spec.md create mode 100644 openspec/specs/dashboard-review/spec.md create mode 100644 openspec/specs/live-recompare/spec.md create mode 100644 openspec/specs/mask-editor/spec.md create mode 100644 openspec/specs/result-sidecar/spec.md create mode 100644 openspec/specs/unified-packaging/spec.md create mode 100644 openspec/specs/uv-tooling/spec.md create mode 100644 scripts/audit_resolved_versions.py create mode 100644 scripts/compare_wheel_contents.py create mode 100644 scripts/wheel_baseline.json create mode 100644 utest/dashboard/conftest.py create mode 100644 utest/dashboard/helpers.py create mode 100644 utest/dashboard/test_api.py create mode 100644 utest/dashboard/test_browse.py create mode 100644 utest/dashboard/test_cli_guard.py create mode 100644 utest/dashboard/test_engine.py create mode 100644 utest/dashboard/test_feature_contract.py create mode 100644 utest/dashboard/test_ingest.py create mode 100644 utest/dashboard/test_masks.py create mode 100644 utest/dashboard/test_review.py create mode 100644 utest/dashboard/test_sidecar_contract.py create mode 100644 utest/dashboard/test_upload.py create mode 100644 utest/test_package_parity.py create mode 100644 utest/test_reference_run.py create mode 100644 utest/test_result_json.py create mode 100644 uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ce160d..9e4ae3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,10 +9,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.11" - name: Install Packages and Binaries run: | sudo apt-get update @@ -27,25 +23,23 @@ jobs: cp ${archive_name}/gpcl6* ${archive_name}/pcl6 sudo cp ${archive_name}/* /usr/bin sudo cp policy.xml /etc/ImageMagick-6/ - - name: Install and configure Poetry - uses: snok/install-poetry@v1 + - name: Set up uv + uses: astral-sh/setup-uv@v5 with: - version: 1.8.5 - virtualenvs-create: true - virtualenvs-in-project: false - virtualenvs-path: ~/.virtualenvs - installer-parallel: true + python-version: "3.11" - name: Install base dependencies (without AI extras) - run: poetry install + run: uv sync - name: Run smoke end-to-end tests without AI extras run: | - poetry run robot \ + uv run robot \ --loglevel=TRACE:INFO \ --listener RobotStackTracer \ --xunit xunit.xml \ -d results-smoke \ atest/Compare.robot \ - atest/PdfContent.robot + atest/PdfContent.robot \ + atest/ReferenceRun.robot \ + atest/ResultJson.robot - name: Store Smoke Artifacts uses: actions/upload-artifact@v4 if: success() || failure() @@ -81,10 +75,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - name: Install Packages and Binaries run: | sudo apt-get update @@ -99,20 +89,17 @@ jobs: cp ${archive_name}/gpcl6* ${archive_name}/pcl6 sudo cp ${archive_name}/* /usr/bin sudo cp policy.xml /etc/ImageMagick-6/ - - name: Install and configure Poetry - uses: snok/install-poetry@v1 + - name: Set up uv + uses: astral-sh/setup-uv@v5 with: - version: 1.8.5 - virtualenvs-create: true - virtualenvs-in-project: false - virtualenvs-path: ~/.virtualenvs - installer-parallel: true - - name: Install dependencies - run: poetry install --extras ai - if: steps.cache.outputs.cache-hit != 'true' + python-version: ${{ matrix.python-version }} + - name: Install dependencies (all extras) + run: uv sync --all-extras --python ${{ matrix.python-version }} + - name: Audit resolved dependency versions + run: uv run python scripts/audit_resolved_versions.py - name: Run tests run: | - poetry run invoke tests + uv run invoke tests - name: Store Artifact uses: actions/upload-artifact@v4 if: success() || failure() @@ -133,3 +120,38 @@ jobs: path: results/pytest.xml,results/xunit.xml # Path to test results reporter: java-junit # Format of test results + dashboard: + needs: smoke-no-ai + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Packages and Binaries + run: | + sudo apt-get update + sudo apt-get install -y imagemagick tesseract-ocr ghostscript libdmtx0b libzbar0 + sudo cp policy.xml /etc/ImageMagick-6/ + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.13" + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Build frontend + working-directory: frontend + run: | + npm ci || npm install + npm run build + - name: Sync environment (all extras) + run: uv sync --all-extras + - name: Backend tests + run: uv run pytest utest/dashboard -v + - name: Wheel parity gate + run: | + uv build + uv run python scripts/compare_wheel_contents.py dist/*.whl --sdist dist/*.tar.gz + - name: Install Playwright browser + run: uv run playwright install chromium --with-deps + - name: End-to-end journeys + run: uv run pytest e2e -v --browser chromium diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 8f5e456..ac8eb39 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,10 +1,7 @@ -# This workflow will upload a Python Package using Twine when a release is created -# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries - -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. +# Builds and uploads the package to PyPI. +# The wheel bundles the dashboard web UI, so the frontend must be built +# before the Python package; a parity gate compares the artifacts against +# the committed baseline manifest before anything is uploaded. name: Upload Python Package @@ -16,17 +13,26 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.12" + - name: Set up Node + uses: actions/setup-node@v4 with: - python-version: '3.x' - - name: Install dependencies + node-version: "22" + - name: Build frontend (bundled into the wheel) + working-directory: frontend run: | - python -m pip install --upgrade pip - pip install build + npm ci || npm install + npm run build - name: Build package - run: python -m build + run: uv build + - name: Parity gate against poetry baseline + run: | + uv sync + uv run python scripts/compare_wheel_contents.py dist/*.whl --sdist dist/*.tar.gz - name: Publish package uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 with: diff --git a/.gitignore b/.gitignore index 5dbc7fe..5805243 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,10 @@ RELEASE*.md .claude/ .*cache/ research/ + +# Frontend build artifacts (dashboard web UI) +frontend/node_modules/ +frontend/dist/ + +# Dashboard runtime data +.doctest_dashboard/ diff --git a/.gitpod.yml b/.gitpod.yml index d8bbea6..eb9e028 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -5,9 +5,10 @@ tasks: sudo apt-get update sudo apt-get install -y imagemagick tesseract-ocr ghostscript libdmtx0b libzbar0 allure sudo cp policy.xml /etc/ImageMagick-6/ - poetry install + curl -LsSf https://astral.sh/uv/install.sh | sh + uv sync --all-extras command: | - poetry run invoke tests + uv run invoke tests image: gitpod/workspace-full-vnc vscode: extensions: diff --git a/DocTest/DocumentRepresentation.py b/DocTest/DocumentRepresentation.py index afd7b19..5dcc257 100644 --- a/DocTest/DocumentRepresentation.py +++ b/DocTest/DocumentRepresentation.py @@ -630,36 +630,126 @@ def _process_ignore_area(self, ignore_area: Dict): self._process_area_ignore_area(ignore_area) def _process_pattern_ignore_area_from_ocr(self, ignore_area: Dict): - """Handle pattern-based ignore areas by searching the OCR text for text patterns.""" + """Handle pattern-based ignore areas by searching the OCR text for text patterns. + + Matching levels mirror the PDF text path: + + - ``word_pattern``: individual OCR tokens + - ``line_pattern``: whole OCR lines (anchored match on the joined line) + - ``pattern``: individual tokens; when the pattern contains + whitespace (phrases like ``Robot Framework`` cannot match a single + token) it is searched anywhere inside each line and only the words + covered by the match span are masked — wrap the phrase in ``.*`` + to mask the entire line instead. + """ import re pattern = ignore_area.get('pattern') + pattern_type = ignore_area.get('type') or 'pattern' xoffset = int(ignore_area.get('xoffset', 0)) yoffset = int(ignore_area.get('yoffset', 0)) - # Iterate through text data to identify matching patterns and mark as ignore areas - n_boxes = len(self.ocr_text_data['text']) - for j in range(n_boxes): - raw_text = self.ocr_text_data['text'][j] - normalized_text = self._normalize_token(raw_text) - if not normalized_text: - continue - match_target = normalized_text.upper() - if not re.match(pattern, match_target): - continue + def matches(text: str) -> bool: + # Match the original-case text first (consistent with the PDF text + # path); keep the legacy uppercased match as fallback so patterns + # written against uppercase targets continue to work. + return bool(re.match(pattern, text) or re.match(pattern, text.upper())) - x, y, w, h = ( - self.ocr_text_data['left'][j], - self.ocr_text_data['top'][j], - self.ocr_text_data['width'][j], - self.ocr_text_data['height'][j], - ) - text_mask = { + def add_area(x, y, w, h): + self.pixel_ignore_areas.append({ "x": int(x) - xoffset, "y": int(y) - yoffset, "width": int(w) + 2 * xoffset, "height": int(h) + 2 * yoffset, - } - self.pixel_ignore_areas.append(text_mask) + }) + + has_line_info = all( + key in self.ocr_text_data for key in ('block_num', 'par_num', 'line_num') + ) + word_level = pattern_type in ('pattern', 'word_pattern') + line_level = has_line_info and ( + pattern_type == 'line_pattern' + or (pattern_type == 'pattern' and re.search(r'\s|\\s', pattern or '')) + ) + if pattern_type == 'line_pattern' and not has_line_info: + # e.g. EAST engine output has no line structure — fall back to words + word_level = True + + n_boxes = len(self.ocr_text_data['text']) + + if word_level: + for j in range(n_boxes): + normalized_text = self._normalize_token(self.ocr_text_data['text'][j]) + if not normalized_text or not matches(normalized_text): + continue + add_area( + self.ocr_text_data['left'][j], + self.ocr_text_data['top'][j], + self.ocr_text_data['width'][j], + self.ocr_text_data['height'][j], + ) + + if line_level: + def add_union_area(token_indices): + x1 = min(self.ocr_text_data['left'][j] for j in token_indices) + y1 = min(self.ocr_text_data['top'][j] for j in token_indices) + x2 = max( + self.ocr_text_data['left'][j] + self.ocr_text_data['width'][j] + for j in token_indices + ) + y2 = max( + self.ocr_text_data['top'][j] + self.ocr_text_data['height'][j] + for j in token_indices + ) + add_area(x1, y1, x2 - x1, y2 - y1) + + lines: Dict[tuple, list] = {} + for j in range(n_boxes): + normalized_text = self._normalize_token(self.ocr_text_data['text'][j]) + if not normalized_text: + continue + key = ( + self.ocr_text_data['block_num'][j], + self.ocr_text_data['par_num'][j], + self.ocr_text_data['line_num'][j], + ) + lines.setdefault(key, []).append(j) + for indices in lines.values(): + tokens = [ + self._normalize_token(self.ocr_text_data['text'][j]) for j in indices + ] + line_text = " ".join(tokens) + + if pattern_type == 'line_pattern': + if matches(line_text): + add_union_area(indices) + continue + + # type 'pattern' with whitespace: search the phrase anywhere + # in the line and mask only the words the match span covers + spans = [ + m.span() for m in re.finditer(pattern, line_text) if m.end() > m.start() + ] + if not spans: + # legacy fallback: patterns written against uppercase text + spans = [ + m.span() + for m in re.finditer(pattern, line_text, re.IGNORECASE) + if m.end() > m.start() + ] + if not spans: + continue + offsets = [] + position = 0 + for token in tokens: + offsets.append((position, position + len(token))) + position += len(token) + 1 + for start, end in spans: + covered = [ + j for j, (token_start, token_end) in zip(indices, offsets) + if token_start < end and token_end > start + ] + if covered: + add_union_area(covered) def _process_pattern_ignore_area_from_pdf(self, ignore_area: Dict): import re @@ -709,18 +799,27 @@ def _process_coordinates_ignore_area(self, ignore_area: Dict): self.pixel_ignore_areas.append({"x": x, "y": y, "height": h, "width": w}) def _convert_to_pixels(self, area: Dict, unit: str): - """Convert dimensions from cm, mm, or px to pixel units.""" - x, y, w, h = int(area['x']), int(area['y']), int(area['width']), int(area['height']) + """Convert dimensions from cm, mm, pt, or px to pixel units. + + Conversion is applied to the original (possibly fractional) values + and rounded only once at the end, so e.g. 25.4 mm at 200 DPI + resolves to exactly 200 px. + """ + x, y, w, h = float(area['x']), float(area['y']), float(area['width']), float(area['height']) if unit == 'mm': constant = self.dpi / 25.4 - x, y, w, h = int(x * constant), int(y * constant), int(w * constant), int(h * constant) elif unit == 'cm': constant = self.dpi / 2.54 - x, y, w, h = int(x * constant), int(y * constant), int(w * constant), int(h * constant) elif unit == 'pt': constant = self.dpi / 72.0 - x, y, w, h = int(x * constant), int(y * constant), int(w * constant), int(h * constant) - return x, y, w, h + else: + constant = 1.0 + return ( + int(round(x * constant)), + int(round(y * constant)), + int(round(w * constant)), + int(round(h * constant)), + ) def _process_area_ignore_area(self, ignore_area: Dict): """Handle area-based ignore areas (e.g., 'top', 'bottom', 'left', 'right') as percentages.""" diff --git a/DocTest/PdfTest.py b/DocTest/PdfTest.py index 25c15a4..f0a88d1 100644 --- a/DocTest/PdfTest.py +++ b/DocTest/PdfTest.py @@ -6,12 +6,15 @@ from robot.api.deco import keyword, library import fitz import json +import os import re from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Pattern, Tuple from DocTest.config import DEFAULT_DPI from DocTest.DocumentRepresentation import DocumentRepresentation from DocTest.Downloader import is_url, download_file_from_url +from DocTest.ReferencePromotion import promote_candidate_to_reference +from DocTest.ResultWriter import RESULT_LOG_PREFIX, ComparisonResultWriter from DocTest.PdfStructureComparator import ( StructureTolerance, compare_document_structures, @@ -112,9 +115,59 @@ class PdfTest(object): | ``character_replacements`` | Dict mapping characters to replacements, applied to all text extraction/comparison. Example: ``{'\u00A0': ' '}`` to normalize non-breaking spaces. | """ - def __init__(self, character_replacements: Optional[Dict[str, str]] = None, **kwargs): + def __init__( + self, + character_replacements: Optional[Dict[str, str]] = None, + result_json: bool = False, + **kwargs, + ): fitz.TOOLS.set_aa_level(0) self.character_replacements = self._parse_character_replacements(character_replacements) + self.reference_run = self._read_reference_run_variable() + self.result_json = bool(result_json) + self.output_directory, self.pabot_index = self._read_output_variables() + + @staticmethod + def _read_reference_run_variable() -> bool: + try: + from robot.libraries.BuiltIn import BuiltIn + + return bool(BuiltIn().get_variable_value("${REFERENCE_RUN}", False)) + except Exception: + return False + + @staticmethod + def _read_output_variables(): + try: + from robot.libraries.BuiltIn import BuiltIn + + built_in = BuiltIn() + output_dir = built_in.get_variable_value("${OUTPUT DIR}") + pabot_index = built_in.get_variable_value("${PABOTQUEUEINDEX}") + except Exception: + return Path.cwd(), None + return Path(output_dir) if output_dir else Path.cwd(), pabot_index + + @keyword + def set_result_json(self, result_json: bool): + """Set whether comparisons write machine-readable JSON sidecar results. + + | =Arguments= | =Description= | + | ``result_json`` | When ``True``, every comparison writes a JSON sidecar to ``{OUTPUT_DIR}/doctest_results/`` and logs a ``DOCTEST_RESULT:`` message. | + """ + self.result_json = bool(result_json) + + @keyword + def set_reference_run(self, reference_run: bool): + """Set whether comparisons run as reference runs. + + | =Arguments= | =Description= | + | ``reference_run`` | Whether the run is a reference run. | + + In a reference run, a missing or differing reference document is + replaced by the candidate document and the comparison passes. + """ + self.reference_run = reference_run def _parse_character_replacements( self, value: Optional[Any] @@ -322,9 +375,45 @@ def compare_pdf_documents(self, reference_document, candidate_document, **kwargs llm_general_notes.append(f"mask_applied={'yes' if mask_applied else 'no'}") llm_general_notes.append(f"text_mask_patterns={'yes' if text_pattern_values else 'no'}") + # Reference promotion only targets local reference paths, never URLs + reference_target = None if is_url(reference_document) else reference_document + reference_document = self._ensure_local_document(reference_document) candidate_document = self._ensure_local_document(candidate_document) + result_writer = ( + ComparisonResultWriter( + self.output_directory, + keyword="Compare Pdf Documents", + library="DocTest.PdfTest", + pabot_index=self.pabot_index, + ) + if self.result_json + else None + ) + + if ( + self.reference_run + and reference_target is not None + and not os.path.isfile(reference_target) + ): + promote_candidate_to_reference(reference_target, candidate_document) + robot_logger.info( + f"Reference run: reference '{reference_target}' did not exist " + "and was created from the candidate." + ) + self._write_pdf_result( + result_writer, + status="PASS", + reference_document=reference_target, + candidate_document=candidate_document, + compare_set=compare_set, + dpi=structure_dpi, + differences=[], + notes=["Reference run: reference was created from the candidate."], + ) + return + reference_repr = DocumentRepresentation( reference_document, dpi=structure_dpi, @@ -352,6 +441,23 @@ def compare_pdf_documents(self, reference_document, candidate_document, **kwargs ) if reference_snapshot['page_count'] != candidate_snapshot['page_count']: + promoted = self._promote_on_reference_run(reference_target, candidate_document) + self._write_pdf_result( + result_writer, + status="PASS" if promoted else "FAIL", + reference_document=reference_document, + candidate_document=candidate_document, + compare_set=compare_set, + dpi=structure_dpi, + reference_pages=reference_snapshot['page_count'], + candidate_pages=candidate_snapshot['page_count'], + differences=[{ + "facet": "pages", + "description": "Documents have different number of pages.", + }], + ) + if promoted: + return raise AssertionError("Documents have different number of pages.") metadata_requested = compare_all or 'metadata' in compare_set @@ -520,7 +626,24 @@ def _record_diff(facet: str, description: str, diff_payload: Any): elif not llm_decision.is_positive and llm_override_result: robot_logger.info("LLM rejected PDF differences. Baseline failure will stand.") + promoted = False if differences_detected: + promoted = self._promote_on_reference_run(reference_target, candidate_document) + self._write_pdf_result( + result_writer, + status="PASS" if (promoted or not differences_detected) else "FAIL", + reference_document=reference_document, + candidate_document=candidate_document, + compare_set=compare_set, + dpi=structure_dpi, + reference_pages=reference_snapshot['page_count'], + candidate_pages=candidate_snapshot['page_count'], + differences=llm_differences, + notes=( + ["Reference run: candidate saved as new reference."] if promoted else None + ), + ) + if differences_detected and not promoted: raise AssertionError('The compared PDF Document Data is different.') finally: reference_repr.close() @@ -1118,6 +1241,62 @@ def _log_structure_result(self, result, ignore_page_boundaries: bool = False): def _ensure_local_document(self, document): return download_file_from_url(document) if is_url(document) else document + def _write_pdf_result( + self, + result_writer: Optional[ComparisonResultWriter], + status: str, + reference_document, + candidate_document, + compare_set, + dpi: int, + differences: List[Dict[str, Any]], + reference_pages: Optional[int] = None, + candidate_pages: Optional[int] = None, + notes: Optional[List[str]] = None, + ) -> None: + """Write the comparison sidecar for a PDF comparison, if enabled. + + PdfTest compares text, metadata, and structure, so sidecars carry + document-level results (per-page granularity is a visual-comparison + concept; see ``Compare Images``). + """ + if result_writer is None: + return + all_notes = ["Per-page granularity is not available for PdfTest comparisons."] + if notes: + all_notes.extend(notes) + if differences: + for diff in differences: + all_notes.append(f"[{diff.get('facet')}] {diff.get('description')}") + rel_path = result_writer.write( + status=status, + reference={"path": str(reference_document), "pages": reference_pages, "dpi": dpi}, + candidate={"path": str(candidate_document), "pages": candidate_pages, "dpi": dpi}, + settings={"compare": sorted(compare_set)}, + notes=all_notes, + ) + robot_logger.info(f"{RESULT_LOG_PREFIX} {rel_path}") + + def _promote_on_reference_run(self, reference_target, candidate_document) -> bool: + """Save the candidate as new reference if a reference run is active. + + Returns True when promotion happened and the comparison shall pass. + """ + if not self.reference_run: + return False + if reference_target is None: + robot_logger.warn( + "Reference run requested but the reference is a URL; " + "cannot save the candidate as reference." + ) + return False + promote_candidate_to_reference(reference_target, candidate_document) + robot_logger.info( + f"Reference run: candidate saved as new reference " + f"'{reference_target}'. Detected differences were accepted." + ) + return True + def _handle_llm_for_pdf_differences( self, reference_document: str, diff --git a/DocTest/ReferencePromotion.py b/DocTest/ReferencePromotion.py new file mode 100644 index 0000000..cfb61bd --- /dev/null +++ b/DocTest/ReferencePromotion.py @@ -0,0 +1,29 @@ +"""Promotion of candidate documents to references during reference runs. + +Used by ``VisualTest`` and ``PdfTest`` when ``reference_run`` is active: +instead of failing on a missing or differing reference, the candidate file +is saved as the new reference. +""" + +import os +import shutil + + +def promote_candidate_to_reference(reference_path: str, candidate_path: str) -> str: + """Copy ``candidate_path`` over ``reference_path`` and return the target path. + + Creates missing parent directories of the reference. The candidate must + exist as a local file. + """ + reference_path = os.fspath(reference_path) + candidate_path = os.fspath(candidate_path) + if not os.path.isfile(candidate_path): + raise FileNotFoundError( + f"Cannot save candidate as reference: '{candidate_path}' does not exist" + ) + if os.path.abspath(reference_path) == os.path.abspath(candidate_path): + return reference_path + parent = os.path.dirname(os.path.abspath(reference_path)) + os.makedirs(parent, exist_ok=True) + shutil.copyfile(candidate_path, reference_path) + return reference_path diff --git a/DocTest/ResultWriter.py b/DocTest/ResultWriter.py new file mode 100644 index 0000000..01b14c1 --- /dev/null +++ b/DocTest/ResultWriter.py @@ -0,0 +1,129 @@ +"""Machine-readable comparison result sidecars (schema v1). + +When ``result_json`` is enabled on ``VisualTest`` or ``PdfTest``, every +comparison writes a JSON sidecar to ``{OUTPUT_DIR}/doctest_results/`` and +logs a single ``DOCTEST_RESULT: `` message. The sidecar is +the machine-readable counterpart of the human-oriented log and is consumed +by external tooling such as the doctest-dashboard. +""" + +import json +import time +import uuid +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +import cv2 + +SCHEMA_VERSION = 1 +RESULTS_DIR_NAME = "doctest_results" +RESULT_LOG_PREFIX = "DOCTEST_RESULT:" + + +def _json_safe(value: Any) -> Any: + """Fallback serializer for values json cannot encode natively.""" + if isinstance(value, Path): + return str(value) + if hasattr(value, "item"): # numpy scalars + try: + return value.item() + except Exception: + pass + return str(value) + + +class ComparisonResultWriter: + """Collects per-page data during one comparison and writes the sidecar.""" + + def __init__( + self, + output_directory, + keyword: str, + library: str, + pabot_index: Optional[str] = None, + ): + self.output_directory = Path(output_directory) + self.keyword = keyword + self.library = library + uid = str(uuid.uuid1()) + if pabot_index is not None: + uid = f"{pabot_index}-{uid}" + self.uid = uid + self.results_dir = self.output_directory / RESULTS_DIR_NAME + self.image_dir = self.results_dir / self.uid + self.started = datetime.now().isoformat(timespec="seconds") + self._t0 = time.monotonic() + self.pages: List[Dict[str, Any]] = [] + + def save_page_image(self, image, page_number, kind: str) -> Optional[str]: + """Save a page rendering as lossless PNG. + + Returns the path relative to the output directory, or None if the + image could not be written. + """ + if image is None: + return None + self.image_dir.mkdir(parents=True, exist_ok=True) + rel = Path(RESULTS_DIR_NAME) / self.uid / f"page_{page_number}_{kind}.png" + if cv2.imwrite(str(self.output_directory / rel), image): + return rel.as_posix() + return None + + def add_page( + self, + page_number, + status: str, + score: Optional[float] = None, + threshold: Optional[float] = None, + diff_regions: Optional[List[Dict[str, int]]] = None, + images: Optional[Dict[str, str]] = None, + notes: Optional[List[str]] = None, + resolved_masks: Optional[List[Dict[str, int]]] = None, + ) -> None: + self.pages.append( + { + "page": int(page_number) if str(page_number).isdigit() else page_number, + "status": status, + "score": score, + "threshold": threshold, + "diff_regions": diff_regions or [], + "images": images or {}, + "notes": notes or [], + "resolved_masks": resolved_masks or [], + } + ) + + def write( + self, + status: str, + reference: Dict[str, Any], + candidate: Dict[str, Any], + settings: Dict[str, Any], + masks: Optional[Dict[str, Any]] = None, + llm: Optional[Dict[str, Any]] = None, + notes: Optional[List[str]] = None, + ) -> str: + """Write the sidecar JSON and return its path relative to OUTPUT_DIR.""" + result = { + "schema_version": SCHEMA_VERSION, + "keyword": self.keyword, + "library": self.library, + "status": status, + "reference": reference, + "candidate": candidate, + "settings": settings, + "masks": masks or {}, + "pages": self.pages, + "llm": llm, + "notes": notes or [], + "timing": { + "started": self.started, + "elapsed_ms": int((time.monotonic() - self._t0) * 1000), + }, + } + self.results_dir.mkdir(parents=True, exist_ok=True) + path = self.results_dir / f"{self.uid}.json" + with open(path, "w", encoding="utf-8") as file: + json.dump(result, file, indent=2, default=_json_safe) + return (Path(RESULTS_DIR_NAME) / f"{self.uid}.json").as_posix() diff --git a/DocTest/VisualTest.py b/DocTest/VisualTest.py index 75712cc..eca8f24 100644 --- a/DocTest/VisualTest.py +++ b/DocTest/VisualTest.py @@ -15,6 +15,8 @@ from DocTest.DocumentRepresentation import DocumentRepresentation from DocTest.Downloader import download_file_from_url, is_url +from DocTest.ReferencePromotion import promote_candidate_to_reference +from DocTest.ResultWriter import RESULT_LOG_PREFIX, ComparisonResultWriter from DocTest.config import DEFAULT_CONFIDENCE from DocTest.llm import LLMDependencyError, load_llm_settings from DocTest.TextNormalization import apply_character_replacements @@ -109,6 +111,7 @@ def __init__( document_page_cache_size: int = 2, verbose_movement_logging: bool = False, character_replacements: Optional[Dict[str, str]] = None, + result_json: bool = False, **kwargs, ): """ @@ -135,6 +138,7 @@ def __init__( | ``document_page_cache_size`` | Maximum number of rendered pages to keep in memory per document when streaming. Default is ``2``. | | ``verbose_movement_logging`` | When ``True``, emit detailed warnings from movement detection helpers (template, ORB, SIFT). Disabled by default to reduce noise. | | ``character_replacements`` | Dict mapping characters to replacements, applied to text extraction results. Example: ``{'\u00A0': ' '}`` to normalize non-breaking spaces. | + | ``result_json`` | When ``True``, every comparison writes a machine-readable JSON sidecar to ``{OUTPUT_DIR}/doctest_results/`` and logs a ``DOCTEST_RESULT:`` message. Default is ``False``. | | ``**kwargs`` | Everything else. | @@ -169,6 +173,7 @@ def __init__( self.document_page_cache_size = max(1, int(document_page_cache_size)) self.verbose_movement_logging = bool(verbose_movement_logging) self.character_replacements = self._parse_character_replacements(character_replacements) + self.result_json = bool(result_json) output_dir, reference_run, pabot_index = self._read_robot_variables() self.output_directory = output_dir @@ -346,12 +351,48 @@ def compare_images( else bool(stream_documents_override) ) + # Reference promotion only targets local reference paths, never URLs + reference_target = None if is_url(reference_image) else reference_image + # Download files if URLs are provided if is_url(reference_image): reference_image = download_file_from_url(reference_image) if is_url(candidate_image): candidate_image = download_file_from_url(candidate_image) + result_writer = ( + ComparisonResultWriter( + self.output_directory, + keyword="Compare Images", + library="DocTest.VisualTest", + pabot_index=self.PABOTQUEUEINDEX, + ) + if self.result_json + else None + ) + + if ( + self.reference_run + and reference_target is not None + and not os.path.isfile(reference_target) + ): + promote_candidate_to_reference(reference_target, candidate_image) + robot_logger.info( + f"Reference run: reference '{reference_target}' did not exist " + "and was created from the candidate." + ) + if result_writer is not None: + promotion_dpi = DPI if DPI else self.dpi + rel_path = result_writer.write( + status="PASS", + reference={"path": str(reference_target), "dpi": promotion_dpi}, + candidate={"path": str(candidate_image), "dpi": promotion_dpi}, + settings={}, + notes=["Reference run: reference was created from the candidate."], + ) + robot_logger.info(f"{RESULT_LOG_PREFIX} {rel_path}") + return + # Set DPI and threshold if provided dpi = DPI if DPI else self.dpi threshold = threshold if threshold is not None else self.threshold @@ -410,6 +451,17 @@ def compare_images( page_notes: List[str] = [] diff_rectangles_cache: Optional[List[Dict[str, int]]] = None combined_image_with_differences = None + page_images: Dict[str, str] = {} + if result_writer is not None: + # Save clean renderings before any labelling mutates them + page_images = { + "reference": result_writer.save_page_image( + ref_page.image, ref_page.page_number, "reference" + ), + "candidate": result_writer.save_page_image( + cand_page.image, cand_page.page_number, "candidate" + ), + } # Resize the candidate page if needed if resize_candidate and ref_page.image.shape != cand_page.image.shape: cand_page.image = cv2.resize( @@ -437,6 +489,15 @@ def compare_images( self.add_screenshot_to_log( combined_image, suffix="_combined", original_size=False ) + if result_writer is not None: + result_writer.add_page( + page_number=ref_page.page_number, + status="FAIL", + threshold=threshold, + images=page_images, + notes=["Image dimensions are different."], + resolved_masks=ref_page.pixel_ignore_areas, + ) continue similar, diff, thresh, absolute_diff, score = ref_page.compare_with( @@ -843,6 +904,32 @@ def compare_images( if entry[0] == "print": robot_logger.info(entry[1]) + if result_writer is not None: + images = dict(page_images) + if not similar: + diff_image_path = result_writer.save_page_image( + absolute_diff, ref_page.page_number, "diff" + ) + if diff_image_path: + images["diff"] = diff_image_path + combined_path = result_writer.save_page_image( + combined_image_with_differences, + ref_page.page_number, + "combined_with_diff", + ) + if combined_path: + images["combined_with_diff"] = combined_path + result_writer.add_page( + page_number=ref_page.page_number, + status="PASS" if similar else "FAIL", + score=score, + threshold=threshold, + diff_regions=[] if similar else (diff_rectangles_cache or []), + images=images, + notes=page_notes[:], + resolved_masks=ref_page.pixel_ignore_areas, + ) + llm_decision = None llm_label_enum = None if detected_differences and llm_requested: @@ -885,6 +972,61 @@ def compare_images( elif not llm_decision.is_positive and llm_override_result: robot_logger.info("LLM rejected differences. Baseline failure will be raised.") + if detected_differences and self.reference_run: + if reference_target is not None: + promote_candidate_to_reference(reference_target, candidate_image) + robot_logger.info( + f"Reference run: candidate saved as new reference " + f"'{reference_target}'. Detected differences were accepted." + ) + detected_differences = [] + else: + robot_logger.warn( + "Reference run requested but the reference is a URL; " + "cannot save the candidate as reference." + ) + + if result_writer is not None: + llm_info = None + if llm_decision: + llm_info = { + "decision": str(_coerce_label_value(llm_decision.decision)), + "reason": llm_decision.reason, + } + rel_path = result_writer.write( + status="FAIL" if detected_differences else "PASS", + reference={ + "path": str(reference_image), + "pages": reference_doc.page_count, + "dpi": dpi, + }, + candidate={ + "path": str(candidate_image), + "pages": candidate_doc.page_count, + "dpi": dpi, + }, + settings={ + "threshold": threshold, + "move_tolerance": move_tolerance, + "check_text_content": check_text_content, + "watermark_file": watermark_file, + "ignore_watermarks": bool(ignore_watermarks), + "screenshot_format": self.screenshot_format, + "ocr_engine": ocr_engine, + "force_ocr": force_ocr, + "blur": blur, + "block_based_ssim": block_based_ssim, + "resize_candidate": resize_candidate, + }, + masks={ + "placeholder_file": placeholder_file, + "mask": mask, + "abstract": reference_doc.abstract_ignore_areas, + }, + llm=llm_info, + ) + robot_logger.info(f"{RESULT_LOG_PREFIX} {rel_path}") + for diff in detected_differences: robot_logger.info(diff["message"]) self._raise_comparison_failure() @@ -1691,6 +1833,19 @@ def set_character_replacements( """ self.character_replacements = self._parse_character_replacements(character_replacements) + @keyword + def set_result_json(self, result_json: bool): + """Set whether comparisons write machine-readable JSON sidecar results. + + | =Arguments= | =Description= | + | ``result_json`` | When ``True``, every comparison writes a JSON sidecar to ``{OUTPUT_DIR}/doctest_results/`` and logs a ``DOCTEST_RESULT:`` message. | + + Examples: + | `Set Result Json` ${True} + | `Compare Images` reference.png candidate.png + """ + self.result_json = bool(result_json) + @keyword def get_barcodes( self, diff --git a/README.md b/README.md index a19cb32..6f19809 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,24 @@ Count Items With LLM # Installation instructions -`pip install --upgrade robotframework-doctestlibrary` +```bash +pip install --upgrade robotframework-doctestlibrary # core library +pip install --upgrade robotframework-doctestlibrary[ai] # + LLM-assisted comparisons +pip install --upgrade robotframework-doctestlibrary[dashboard] # + visual review dashboard & mask editor +pip install --upgrade robotframework-doctestlibrary[all] # everything +``` + +## Development setup (contributors) + +The project is managed with [uv](https://docs.astral.sh/uv/) — one `pyproject.toml`, one lockfile: + +```bash +uv sync --all-extras # full dev environment (library, dashboard, test tooling) +uv run invoke tests # unit + acceptance suites +uv run invoke multipython # validate dependency resolution on Python 3.9–3.13 +uv run pytest e2e --browser chromium # dashboard end-to-end journeys +cd frontend && npm install && npm run build # build the dashboard web UI once +``` ## Optional LLM-Assisted Comparisons @@ -186,6 +203,33 @@ Afterwards you can, e.g., start the container and run the povided examples like [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/manykarim/robotframework-doctestlibrary) Try out the library using [Gitpod](https://gitpod.io/#https://github.com/manykarim/robotframework-doctestlibrary) +# Visual Review Dashboard & Mask Editor + +The **doctest-dashboard** (shipped with the `[dashboard]` extra) is a locally runnable web app to review comparison results outside of `log.html`: + +- browse runs and failing comparisons with diff thumbnails +- diff viewer with side-by-side / overlay / blink / swipe modes and next/previous-difference navigation +- **accept** a difference → the candidate is promoted to the new reference (plain file copy, git-diffable, SHA-256 audit trail) +- **reject** a difference → export a bug-data ZIP (reference + candidate + diffs + metadata) +- **visual mask editor** for coordinate, area, and text-pattern masks with live preview — including one-click *create mask from diff region* and instant re-comparison of past runs with the adjusted masks + +Quick start: + +```bash +pip install robotframework-doctestlibrary[dashboard] # wheels include the web UI +doctest-dashboard serve # open http://127.0.0.1:8008 +doctest-dashboard ingest results/output.xml +``` + +Run your suites with the machine-readable sidecar enabled so the dashboard gets per-page data: + +```RobotFramework +*** Settings *** +Library DocTest.VisualTest result_json=true take_screenshots=true screenshot_format=png +``` + +See the full guide in [docs/dashboard.md](./docs/dashboard.md) for the review workflow, mask editor usage, team mode, and the API. + # Examples Have a look at @@ -235,6 +279,8 @@ Compare two Farm images with area mask as string ``` #### Different Mask Types to Ignore Parts When Comparing ##### Areas, Coordinates, Text Patterns + +Text pattern matching levels: `word_pattern` matches single words; `line_pattern` matches whole text lines (anchored, use `.*…​.*` to match anywhere); `pattern` matches single words — and when the regex contains whitespace it is searched anywhere within each line, masking **exactly the words the match span covers**. So `Robot Framework` masks just that phrase wherever it occurs, while `.*Robot Framework.*` extends the match over the whole line and masks all of it. Matching is case-sensitive; add `(?i)` for case-insensitive. ```python [ { @@ -269,6 +315,22 @@ Compare two Farm images with area mask as string } ] ``` + +##### Creating and editing masks visually + +The companion **doctest-dashboard** ships a visual mask editor with live preview of coordinate, area, and pattern masks — including one-click mask creation from detected diff regions and instant re-comparison of past runs with the adjusted masks. It reads and writes the exact JSON schema shown above. See [docs/dashboard.md](./docs/dashboard.md) for the full guide. + +> **Deprecation note:** the tkinter tool `utilities/mask_editor.py` is superseded by the dashboard's mask editor and will be removed in a future release. It only supports pixel-based coordinate masks. + +##### Machine-readable results for review tooling + +Enable `result_json=true` on `DocTest.VisualTest` / `DocTest.PdfTest` to write a JSON sidecar per comparison into `{OUTPUT_DIR}/doctest_results/` (statuses, SSIM scores, diff regions, resolved masks, per-page renderings). The dashboard — and any other tooling — ingests these for review, accept/reject baseline management, and mask maintenance: + +```RobotFramework +*** Settings *** +Library DocTest.VisualTest result_json=true take_screenshots=true screenshot_format=png +``` + ### Accept visual different by checking move distance or text content ```RobotFramework diff --git a/atest/ReferenceRun.robot b/atest/ReferenceRun.robot new file mode 100644 index 0000000..677c224 --- /dev/null +++ b/atest/ReferenceRun.robot @@ -0,0 +1,48 @@ +*** Settings *** +Library DocTest.VisualTest take_screenshots=false +Library DocTest.PdfTest +Library OperatingSystem + +*** Variables *** +${TESTDATA_DIR} testdata +${WORK_DIR} ${TEMPDIR}${/}doctest_reference_run + +*** Test Cases *** +Missing Image Reference Is Created In Reference Run + [Setup] Prepare Work Dir + DocTest.VisualTest.Set Reference Run ${True} + Compare Images ${WORK_DIR}${/}missing_reference.png ${TESTDATA_DIR}${/}Beach_left.jpg + File Should Exist ${WORK_DIR}${/}missing_reference.png + DocTest.VisualTest.Set Reference Run ${False} + Compare Images ${WORK_DIR}${/}missing_reference.png ${TESTDATA_DIR}${/}Beach_left.jpg + [Teardown] Remove Directory ${WORK_DIR} recursive=${True} + +Differing Image Candidate Replaces Reference In Reference Run + [Setup] Prepare Work Dir + Copy File ${TESTDATA_DIR}${/}Beach_left.jpg ${WORK_DIR}${/}reference.jpg + DocTest.VisualTest.Set Reference Run ${True} + Compare Images ${WORK_DIR}${/}reference.jpg ${TESTDATA_DIR}${/}Beach_right.jpg + DocTest.VisualTest.Set Reference Run ${False} + Compare Images ${WORK_DIR}${/}reference.jpg ${TESTDATA_DIR}${/}Beach_right.jpg + [Teardown] Remove Directory ${WORK_DIR} recursive=${True} + +Reference Run Disabled Still Fails On Differences + [Setup] Prepare Work Dir + Copy File ${TESTDATA_DIR}${/}Beach_left.jpg ${WORK_DIR}${/}reference.jpg + DocTest.VisualTest.Set Reference Run ${False} + Run Keyword And Expect Error The compared images are different. + ... Compare Images ${WORK_DIR}${/}reference.jpg ${TESTDATA_DIR}${/}Beach_right.jpg + [Teardown] Remove Directory ${WORK_DIR} recursive=${True} + +Differing Pdf Candidate Replaces Reference In Reference Run + [Setup] Prepare Work Dir + Copy File ${TESTDATA_DIR}${/}sample_1_page.pdf ${WORK_DIR}${/}reference.pdf + DocTest.PdfTest.Set Reference Run ${True} + Compare Pdf Documents ${WORK_DIR}${/}reference.pdf ${TESTDATA_DIR}${/}sample_1_page_moved.pdf + DocTest.PdfTest.Set Reference Run ${False} + Compare Pdf Documents ${WORK_DIR}${/}reference.pdf ${TESTDATA_DIR}${/}sample_1_page_moved.pdf + [Teardown] Remove Directory ${WORK_DIR} recursive=${True} + +*** Keywords *** +Prepare Work Dir + Create Directory ${WORK_DIR} diff --git a/atest/ResultJson.robot b/atest/ResultJson.robot new file mode 100644 index 0000000..f1dbbec --- /dev/null +++ b/atest/ResultJson.robot @@ -0,0 +1,63 @@ +*** Settings *** +Library DocTest.VisualTest result_json=true take_screenshots=false +Library DocTest.PdfTest result_json=true +Library OperatingSystem +Library Collections +Library String + +*** Variables *** +${TESTDATA_DIR} testdata + +*** Test Cases *** +Failing Image Comparison Writes Sidecar Into Output Dir + ${before}= Count Sidecars + Run Keyword And Expect Error The compared images are different. + ... Compare Images ${TESTDATA_DIR}${/}Beach_left.jpg ${TESTDATA_DIR}${/}Beach_right.jpg + ${after}= Count Sidecars + Should Be Equal As Integers ${after} ${before + 1} + ${sidecar}= Get Latest Sidecar + ${content}= Evaluate json.load(open(r'''${sidecar}''')) modules=json + Should Be Equal ${content}[status] FAIL + Should Be Equal As Integers ${content}[schema_version] 1 + ${pages}= Get From Dictionary ${content} pages + Should Not Be Empty ${pages} + +Passing Image Comparison Writes Sidecar + ${before}= Count Sidecars + Compare Images ${TESTDATA_DIR}${/}Beach_left.jpg ${TESTDATA_DIR}${/}Beach_left.jpg + ${after}= Count Sidecars + Should Be Equal As Integers ${after} ${before + 1} + ${sidecar}= Get Latest Sidecar + ${content}= Evaluate json.load(open(r'''${sidecar}''')) modules=json + Should Be Equal ${content}[status] PASS + +Pdf Comparison Writes Document Level Sidecar + ${before}= Count Sidecars + Compare Pdf Documents ${TESTDATA_DIR}${/}sample_1_page.pdf ${TESTDATA_DIR}${/}sample_1_page.pdf + ${after}= Count Sidecars + Should Be Equal As Integers ${after} ${before + 1} + ${sidecar}= Get Latest Sidecar + ${content}= Evaluate json.load(open(r'''${sidecar}''')) modules=json + Should Be Equal ${content}[status] PASS + Should Be Equal ${content}[library] DocTest.PdfTest + +Sidecar Can Be Disabled With Keyword + ${before}= Count Sidecars + DocTest.VisualTest.Set Result Json ${False} + Compare Images ${TESTDATA_DIR}${/}Beach_left.jpg ${TESTDATA_DIR}${/}Beach_left.jpg + ${after}= Count Sidecars + Should Be Equal As Integers ${after} ${before} + [Teardown] DocTest.VisualTest.Set Result Json ${True} + +*** Keywords *** +Count Sidecars + ${exists}= Run Keyword And Return Status Directory Should Exist ${OUTPUT_DIR}${/}doctest_results + IF not ${exists} RETURN ${0} + ${files}= List Files In Directory ${OUTPUT_DIR}${/}doctest_results *.json + ${count}= Get Length ${files} + RETURN ${count} + +Get Latest Sidecar + ${files}= List Files In Directory ${OUTPUT_DIR}${/}doctest_results *.json absolute=${True} + ${sorted}= Evaluate sorted(${files}, key=lambda p: __import__('os').path.getmtime(p)) + RETURN ${sorted}[-1] diff --git a/docs/Ai-0.32.0.html b/docs/Ai-0.32.0.html index 82a41ba..43bd2a8 100644 --- a/docs/Ai-0.32.0.html +++ b/docs/Ai-0.32.0.html @@ -6,7 +6,7 @@ diff --git a/docs/Ai-0.33.0.html b/docs/Ai-0.33.0.html index f6f2de7..9d4382b 100644 --- a/docs/Ai-0.33.0.html +++ b/docs/Ai-0.33.0.html @@ -6,7 +6,7 @@ diff --git a/docs/Ai-0.34.0.html b/docs/Ai-0.34.0.html new file mode 100644 index 0000000..66dc952 --- /dev/null +++ b/docs/Ai-0.34.0.html @@ -0,0 +1,387 @@ + + + + + + + + + + + + + +
+

Opening library documentation failed

+
    +
  • Verify that you have JavaScript enabled in your browser.
  • +
  • +Make sure you are using a modern enough browser. If using +Internet Explorer, version 11 is required. +
  • +
  • +Check are there messages in your browser's +JavaScript error log. Please report the problem if you suspect +you have encountered a bug. +
  • +
+
+ + + + + + + + +
+ + + + + + + + + + + + + + + + diff --git a/docs/Ai.html b/docs/Ai.html index 4f0a59d..1a4f2b0 100644 --- a/docs/Ai.html +++ b/docs/Ai.html @@ -6,7 +6,7 @@ diff --git a/docs/PdfTest-0.32.0.html b/docs/PdfTest-0.32.0.html index 207277b..742b7b8 100644 --- a/docs/PdfTest-0.32.0.html +++ b/docs/PdfTest-0.32.0.html @@ -6,7 +6,7 @@ diff --git a/docs/PdfTest-0.33.0.html b/docs/PdfTest-0.33.0.html index 5637dc7..de560a2 100644 --- a/docs/PdfTest-0.33.0.html +++ b/docs/PdfTest-0.33.0.html @@ -6,7 +6,7 @@ diff --git a/docs/PdfTest-0.34.0.html b/docs/PdfTest-0.34.0.html new file mode 100644 index 0000000..9ab0d85 --- /dev/null +++ b/docs/PdfTest-0.34.0.html @@ -0,0 +1,387 @@ + + + + + + + + + + + + + +
+

Opening library documentation failed

+
    +
  • Verify that you have JavaScript enabled in your browser.
  • +
  • +Make sure you are using a modern enough browser. If using +Internet Explorer, version 11 is required. +
  • +
  • +Check are there messages in your browser's +JavaScript error log. Please report the problem if you suspect +you have encountered a bug. +
  • +
+
+ + + + + + + + +
+ + + + + + + + + + + + + + + + diff --git a/docs/PdfTest.html b/docs/PdfTest.html index 87f11dc..5aff4a4 100644 --- a/docs/PdfTest.html +++ b/docs/PdfTest.html @@ -6,7 +6,7 @@ diff --git a/docs/PrintJobTest-0.32.0.html b/docs/PrintJobTest-0.32.0.html index 431f47e..4ba59ec 100644 --- a/docs/PrintJobTest-0.32.0.html +++ b/docs/PrintJobTest-0.32.0.html @@ -6,7 +6,7 @@ diff --git a/docs/PrintJobTest-0.33.0.html b/docs/PrintJobTest-0.33.0.html index 2af4716..f4c48af 100644 --- a/docs/PrintJobTest-0.33.0.html +++ b/docs/PrintJobTest-0.33.0.html @@ -6,7 +6,7 @@ diff --git a/docs/PrintJobTest-0.34.0.html b/docs/PrintJobTest-0.34.0.html new file mode 100644 index 0000000..e82210e --- /dev/null +++ b/docs/PrintJobTest-0.34.0.html @@ -0,0 +1,387 @@ + + + + + + + + + + + + + +
+

Opening library documentation failed

+
    +
  • Verify that you have JavaScript enabled in your browser.
  • +
  • +Make sure you are using a modern enough browser. If using +Internet Explorer, version 11 is required. +
  • +
  • +Check are there messages in your browser's +JavaScript error log. Please report the problem if you suspect +you have encountered a bug. +
  • +
+
+ + + + + + + + +
+ + + + + + + + + + + + + + + + diff --git a/docs/PrintJobTest.html b/docs/PrintJobTest.html index 2dd6319..0ae02f3 100644 --- a/docs/PrintJobTest.html +++ b/docs/PrintJobTest.html @@ -6,7 +6,7 @@ diff --git a/docs/VisualTest-0.32.0.html b/docs/VisualTest-0.32.0.html index fe66ade..91f0074 100644 --- a/docs/VisualTest-0.32.0.html +++ b/docs/VisualTest-0.32.0.html @@ -6,7 +6,7 @@ diff --git a/docs/VisualTest-0.33.0.html b/docs/VisualTest-0.33.0.html index 2c43998..3ced79b 100644 --- a/docs/VisualTest-0.33.0.html +++ b/docs/VisualTest-0.33.0.html @@ -6,7 +6,7 @@ diff --git a/docs/VisualTest-0.34.0.html b/docs/VisualTest-0.34.0.html new file mode 100644 index 0000000..e30d6a6 --- /dev/null +++ b/docs/VisualTest-0.34.0.html @@ -0,0 +1,387 @@ + + + + + + + + + + + + + +
+

Opening library documentation failed

+
    +
  • Verify that you have JavaScript enabled in your browser.
  • +
  • +Make sure you are using a modern enough browser. If using +Internet Explorer, version 11 is required. +
  • +
  • +Check are there messages in your browser's +JavaScript error log. Please report the problem if you suspect +you have encountered a bug. +
  • +
+
+ + + + + + + + +
+ + + + + + + + + + + + + + + + diff --git a/docs/VisualTest.html b/docs/VisualTest.html index 2c43998..55aaf79 100644 --- a/docs/VisualTest.html +++ b/docs/VisualTest.html @@ -6,7 +6,7 @@ diff --git a/docs/dashboard.md b/docs/dashboard.md new file mode 100644 index 0000000..ebd2583 --- /dev/null +++ b/docs/dashboard.md @@ -0,0 +1,164 @@ +# doctest-dashboard — Visual Review Dashboard & Mask Editor + +The **doctest-dashboard** is a companion web application for robotframework-doctestlibrary. It lets you: + +- review visual comparison failures in a purpose-built UI (instead of `log.html`) +- **accept** differences by promoting the candidate to the new reference — with a full audit trail +- **reject** differences and export a ready-to-attach bug-data bundle +- create and edit **masks/ignore areas visually**, with live preview and instant re-comparison of past runs against the adjusted masks + +It runs locally with zero infrastructure: one process, one SQLite file, your baselines stay plain files in your repository (accepting a change produces a normal git diff). + +--- + +## 1. Installation + +The dashboard ships as the `[dashboard]` extra of `robotframework-doctestlibrary` — release wheels include the prebuilt web UI, so no Node.js is needed: + +```bash +pip install robotframework-doctestlibrary[dashboard] +# or everything (LLM keywords + dashboard): +pip install robotframework-doctestlibrary[all] +``` + +### Development setup + +The repository is managed with [uv](https://docs.astral.sh/uv/); the dashboard backend lives in `doctest_dashboard/`, its tests in `utest/dashboard/`, browser journeys in `e2e/`, and the web UI in `frontend/`: + +```bash +uv sync --all-extras # backend + library + test tooling +cd frontend && npm install && npm run build # build the web UI once +uv run doctest-dashboard serve # serves the built UI +uv run pytest utest/dashboard # backend tests +uv run playwright install chromium +uv run pytest e2e --browser chromium # end-to-end journeys (real robot runs, no mocks) +``` + +During frontend development, `npm run dev` starts a Vite dev server that proxies `/api` to `127.0.0.1:8008`. + +## 2. Prerequisites in your test suites + +The dashboard works best when the library emits its machine-readable **result sidecar**. Enable it (plus file-mode PNG screenshots) in suites you want to review: + +```robotframework +*** Settings *** +Library DocTest.VisualTest result_json=true take_screenshots=true screenshot_format=png +Library DocTest.PdfTest result_json=true +``` + +This writes one JSON file per comparison into `{OUTPUT_DIR}/doctest_results/` containing statuses, SSIM scores, diff regions, resolved masks, and separate per-page reference/candidate/diff renderings. + +Runs **without** `result_json` can still be ingested: the dashboard falls back to scraping screenshot references from the log and shows those comparisons as **degraded** — combined images only, no per-page review, no accept, no diff-region navigation. The UI tells you exactly what is missing and how to enable it. + +> `embed_screenshots=true` runs cannot be reviewed — use file mode (the default). + +## 3. Starting the dashboard + +```bash +doctest-dashboard serve +``` + +- binds **127.0.0.1:8008** by default — open +- `--port 9000` to change the port +- `--root /path/to/testdata` — allowlist a directory the server may read images from and write baselines/masks into (repeatable). Directories of ingested `output.xml` files are allowed automatically. +- `--data-dir` — where the SQLite database lives (default `./.doctest_dashboard/`) + +**Team mode** (shared instance): + +```bash +doctest-dashboard serve --host 0.0.0.0 --token +``` + +All API calls then require `Authorization: Bearer `. Put the instance behind your existing tunnel/SSO (e.g. Cloudflare Access) for anything beyond a trusted network. Note the server runs with *your* file permissions and writes accepted baselines into your test-data tree — every write is confined to the configured roots. + +## 4. Ingesting results + +After a Robot Framework run, three equivalent ways: + +```bash +doctest-dashboard ingest results/output.xml +``` + +- **UI, by path**: paste the `output.xml` path into the field on the start page and click **Ingest output.xml** (the file must be readable by the server). +- **UI, from disk**: click **Upload results folder…** and pick your Robot Framework output directory in the browser's folder dialog. The whole tree (`output.xml`, `screenshots/`, `doctest_results/` sidecars and renderings) uploads with its structure intact into the dashboard workspace and is ingested immediately — no path or `--root` configuration needed. Irrelevant file types are filtered out before upload; 500 MB limit per upload. + +Re-ingesting the same `output.xml` path updates the run instead of duplicating it. Pabot-merged outputs work — each comparison carries its own sidecar. + +> Note on uploaded runs: reference/candidate *source documents* usually live outside the output folder, so for uploads from another machine those paths may not resolve — reviewing diffs works fully (the images travel with the upload), while accept/recompare need the source files to exist on the server. + +The ingester reads comparison status at **keyword level**, so failures wrapped in `Run Keyword And Expect Error` are still recorded as failing comparisons. + +## 5. Reviewing comparisons + +1. **Runs** page → click a run → test grid with diff thumbnails. Filter by status (`FAIL`/`PASS`) and review state (`unresolved`, `accepted`, `rejected`). +2. Click a comparison to open the **diff viewer**: + + | Key | Mode | + |-----|------| + | `1` | side-by-side | + | `2` | overlay (default, with opacity slider) | + | `3` | blink | + | `4` | swipe | + | `n` / `p` | next / previous diff region | + +3. Decide: + - **Accept page / Accept document** — copies the candidate file over the reference (identical layout to a `REFERENCE_RUN`), records actor, reason, and SHA-256 before/after in the audit table, and marks the comparison `accepted`. For **multi-page PDFs**, page-level accept is impossible at file level — the UI offers document-level accept or mask creation instead, and never silently writes partial files. + - **Reject** — stores the reason, marks the comparison `rejected`, and **Download bug data** gives you a ZIP with reference, candidate, all failing diff images, the sidecar JSON, and decision metadata — ready to attach to a ticket. + +When a newer run of the same test is ingested, pages whose images changed return to `unresolved`; unchanged pages keep their accepted/rejected state, and all past decisions stay queryable. + +## 6. Mask editor + +Open it via **Mask Editor** in the top bar, or — the fastest path — step to a detected diff region in the viewer (`n`/`p` or the *next diff* button) and click **Add ignore mask**: the editor opens pre-seeded with a coordinate mask covering that region (plus padding). + +The editor reads and writes the **exact mask schema** the library consumes (`IgnoreAreaManager`) — nothing proprietary: + +- **Coordinates masks** — draw by dragging on the page, move/resize with handles, or type exact values. The unit selector (`px`/`mm`/`cm`/`pt`) converts for display only using the **rendering DPI shown in the banner** (sourced from the comparison sidecar); the file stores whatever unit you chose. +- **Area masks** — pick location (top/bottom/left/right) and percentage in the side panel; a translucent band previews the covered area live. +- **Pattern masks** (`pattern`, `line_pattern`, `word_pattern`) — type a regex and watch the matched text regions highlight on the page within half a second. The preview runs the library's *own* text-extraction path (PDF text or OCR), so what you see is exactly what a test run will mask. If Tesseract is not installed, pattern preview is disabled with an explanation — other mask types keep working. + + Matching levels: `word_pattern` matches single words; `line_pattern` matches whole text lines (anchored — wrap with `.*…​.*` to match anywhere); `pattern` matches single words, and regexes containing whitespace are searched anywhere within each line, masking **exactly the words the match span covers** — `Robot Framework` masks just the phrase, `.*Robot Framework.*` masks the whole containing line. Matching is case-sensitive; add `(?i)` for case-insensitive. + +**Workflow:** + +1. Get a document onto the canvas: + - **Upload…** — pick any image or PDF from your machine; it is stored in the dashboard's workspace folder (`{data-dir}/uploads/`, always browsable) and opened immediately. A `masks.json` target next to the upload is suggested automatically. This works with zero configuration. + - **Browse…** — a file picker that navigates the server's configured roots (ingested run folders are browsable automatically). Selecting an existing `masks.json` loads it immediately; for a new file, navigate to the folder and type a name. + - Paths can still be typed or pasted directly. +2. **Load** an existing file, or **Import** masks as JSON or shorthand (`top:10;bottom:5`). +3. Edit. If you arrived from a comparison, click **Recompare with these masks** — the server re-runs the *actual* comparison engine on the stored reference/candidate with your draft masks and shows the would-be result (PASS/FAIL) without touching the original run or your baselines. Tune until it passes. +4. **Save** — pretty-printed JSON with stable key order (clean git diffs), written atomically with a `.bak` of the previous version. + +> The old tkinter tool `utilities/mask_editor.py` is deprecated in favor of this editor. + +## 7. Command and API reference (abridged) + +```bash +doctest-dashboard serve [--host] [--port] [--token] [--root DIR ...] [--data-dir DIR] +doctest-dashboard ingest +``` + +| Endpoint | Purpose | +|---|---| +| `POST /api/ingest` | ingest an output.xml | +| `GET /api/runs`, `GET /api/runs/{id}/tests` | browse | +| `GET /api/comparisons/{id}` | full detail incl. pages and sidecar | +| `POST /api/pages/{id}/accept`, `POST /api/comparisons/{id}/accept` | baseline promotion | +| `POST /api/comparisons/{id}/reject`, `GET .../bugdata` | reject + bug bundle | +| `GET/PUT /api/masks` | masks.json round-trip | +| `POST /api/mask-preview` | resolve masks to pixel boxes (live preview) | +| `POST /api/recompare`, `POST /api/recompare-batch` | re-run comparisons with adjusted masks | +| `GET /api/capabilities` | OCR/engine availability | +| `GET /api/browse` | root-confined directory listing (file picker) | +| `POST /api/upload` | store a local file in the dashboard workspace (images/PDF/JSON, 100 MB limit) | +| `POST /api/upload-results` | upload a whole results folder (relative paths preserved) and ingest its output.xml | + +## 8. Troubleshooting + +- **Yellow banner "server is older than this user interface" / 405 or "Not Found" on new buttons** — the UI files are re-read from disk on every request, but the Python server process keeps running the code it started with. After updating the dashboard, restart `doctest-dashboard serve`. The UI detects this skew at load time and tells you. + +- **Comparison shows "degraded"** — the run was executed without `result_json=true`. Re-run with the sidecar enabled. +- **403 on accept/masks/preview** — the file lies outside the configured roots. Start the server with `--root` covering your test-data directory. +- **File picker shows no useful locations** — use **Upload…** to bring a file from your machine (no configuration needed), start the server with `--root /your/testdata`, or ingest a run first (run folders become browsable automatically). +- **Pattern preview disabled** — Tesseract is not installed in the dashboard's environment. Install it (and keep the OCR engine consistent with your test environment) — check `GET /api/capabilities`. +- **Recompare is slow on OCR-heavy pages** — first run is computed (process pool, 120 s timeout); identical requests are served from cache. diff --git a/docs/doctest-dashboard-proposal.md b/docs/doctest-dashboard-proposal.md new file mode 100644 index 0000000..a329c4d --- /dev/null +++ b/docs/doctest-dashboard-proposal.md @@ -0,0 +1,275 @@ +# Visual Review Dashboard for robotframework-doctestlibrary + +## Solution Proposal & Research Report + +**Date:** 2026-06-10 +**Scope:** A companion web dashboard for [robotframework-doctestlibrary](https://github.com/manykarim/robotframework-doctestlibrary) that lets users review visual differences, accept or deny them (Applitools-style baseline management), and create/edit masks in a visual editor — plus the research needed to decide between a post-execution adapter and a Robot Framework Listener. + +--- + +## 1. Executive Summary + +robotframework-doctestlibrary already produces everything a review workflow needs — diff screenshots, reference/candidate renderings, OCR text, and a `reference_run` mode — but the review experience today lives inside `log.html`, and the only triage tooling is a PyQt5 desktop prototype (`TestResultEvaluator.py` from the RoboCon 2022 DE demo) and a minimal tkinter `utilities/mask_editor.py`. Both prove the concept; neither scales to team use, CI integration, or a pleasant reviewing experience. + +The recommendation in one paragraph: build **`doctest-dashboard`**, a self-contained, locally-runnable web application (FastAPI backend + React frontend, SQLite metadata store, file-system baseline store) shipped as an optional extra of the library or as a sibling package. It ingests results through **two complementary channels**: a **post-execution ingester** that parses `output.xml` with `robot.api.ExecutionResult` (zero changes to test execution, works retroactively, works with pabot-merged outputs) and an **optional Listener v3** for live streaming during long runs. The critical enabler is a small change inside the library itself: emit a **machine-readable `comparison_result.json` sidecar per comparison**, so the dashboard never has to scrape HTML log messages or guess screenshot semantics from filename suffixes. The dashboard provides an Applitools-class diff viewer (side-by-side, overlay, blink, swipe, diff-region navigation), one-click accept (promote candidate → baseline) and reject (collect bug data, annotate), full audit history, and an integrated **mask editor** that reads and writes the exact JSON schema `IgnoreAreaManager` consumes today — including live preview of coordinate, area, and text-pattern masks, and a "create mask from this diff region" shortcut that closes the loop between reviewing and maintaining tests. + +Estimated effort for a usable v1 (ingest + review + accept/reject + coordinate-mask editor): roughly 6–9 person-weeks. The pattern-mask live preview and listener live mode are v1.5/v2 items. + +--- + +## 2. Goals and Non-Goals + +**Goals.** Review visual comparison failures outside `log.html` with a purpose-built UI; accept differences by promoting candidates to new baselines with an audit trail; deny differences by packaging reproducible bug data; create and maintain masks visually with immediate preview; support both single-run review and run-over-run history; work locally with zero infrastructure, and optionally self-hosted for a team (Hetzner/Coolify-friendly: single container, single volume). + +**Non-Goals (v1).** No cloud service, no SaaS billing, no AI auto-triage (though the library's existing LLM keywords are a natural v2 integration), no replacement of `log.html` (the dashboard complements it and deep-links into it), no attempt to manage the test code itself. + +--- + +## 3. Prior Art: What "Applitools-like" Actually Means + +To define the feature bar, it is worth being precise about what established tools do well, because the differentiating value is rarely the diff algorithm — doctestlibrary already has SSIM, move tolerance, OCR-based text checks, and watermark subtraction. The value is in the *review workflow*. + +**Applitools Eyes** popularized the batch/test/step hierarchy, a baseline per (test, environment) tuple, accept/reject at step granularity with "accept all similar" batching, annotation regions that become ignore regions for future runs, and a full audit trail of who accepted what and when. Its single most-copied UX idea is that *creating an ignore region is a one-click action from the diff view*, not a separate tool. + +**Percy (BrowserStack)** contributed the "build" review model borrowed from code review: a run is reviewed as a unit, reviewers approve or request changes, and approval state gates CI. Its diff UI defaults to a red-highlight overlay with a click-to-toggle original/candidate, which users consistently find faster than side-by-side for spotting small diffs. + +**Open-source tools** (BackstopJS, reg-suit, Lost Pixel, Argos, Vizregress-style viewers) converge on the same minimum set: a grid of thumbnails filtered by status, a detail view with side-by-side / overlay / onion-skin / swipe modes, approve buttons that copy candidate files over reference files, and a JSON report format the viewer consumes. reg-suit's insight worth stealing: baselines live in plain directories so they remain git-versionable — exactly how doctestlibrary users already manage reference PDFs/images. + +The synthesis for this project: **per-page (not just per-test) accept granularity** — because document tests are multi-page and page 3 may be an intended change while page 7 is a bug — plus **mask creation from the diff view**, plus **plain-file baselines** that stay compatible with existing repos. + +--- + +## 4. Lessons from `TestResultEvaluator.py` (RoboCon 2022 DE) + +The PyQt5 prototype ([source](https://github.com/manykarim/rbcn22de-visual-document-testing/blob/main/utilities/TestResultEvaluator.py)) is the conceptual seed of this proposal and validates the core workflow loop. What it gets right, and should be preserved: + +**Parsing `output.xml` with `robot.api.ExecutionResult` and a `ResultVisitor`.** The evaluator opens an `output.xml`, runs a `TestResultParser` visitor over it, and extracts per-test records of suite, test name, error message, reference path, candidate path, and screenshot list. This proves the post-execution channel works and requires no test changes. The dashboard's ingester is a direct, hardened descendant of this approach. + +**Accept and decline as first-class row actions.** The context menu offers "Accept Changes" / "Decline Changes" per selected rows, with a checkbox column tracking selection state. The dashboard generalizes this to per-page decisions with persisted state. + +**Reference promotion via `candidate_` filename convention.** `generate_reference_data()` copies each accepted candidate into a reference folder, deriving the target filename by stripping the `candidate_` prefix. This convention is fragile (it warns when filenames don't contain `candidate_`) and is one of the things the structured sidecar JSON (Section 6) eliminates: the comparison result should *state* the reference path explicitly rather than have tooling reverse-engineer it. + +**Bug data collection.** `generate_bug_data()` copies the entire directory of a declined test into a per-test-case bug folder — a genuinely useful "package this failure for a ticket" feature that few commercial tools offer. Keep it, and add the diff images and decision metadata to the package. + +What the prototype lacks, and why a web app: no diff overlay modes (it stacks raw screenshots in a `QGraphicsView`); no persistence of decisions (state dies with the window); no multi-user/remote access; no run history; no mask awareness at all; desktop-only distribution with PyQt5 as a heavy dependency. Every one of these is structural, not incremental — which is the argument for a rebuild rather than an extension. + +--- + +## 5. Current State of the Library: What the Dashboard Can Rely On Today + +Findings from the `main` branch source (v0.33.x line), because the dashboard's data contract must be grounded in what the library actually emits. + +**Screenshots.** `VisualTest.add_screenshot_to_log()` has two modes. With `embed_screenshots=True` it base64-inlines images into the log via `robot.api.logger` — invisible to any external tool except by parsing HTML out of `output.xml` messages. In file mode (default) it writes `{uuid1}{suffix}.{jpg|png}` into `{OUTPUT_DIR}/screenshots/` (configurable via `set_screenshot_dir`), prefixing `{PABOTQUEUEINDEX}-` under pabot, and logs an `` HTML message with the relative path. Consequences: (1) the dashboard must require or strongly recommend file mode; (2) today, the *only* machine link between a test and its images is the HTML `` inside `output.xml` messages — parseable, but brittle; (3) the `suffix` (e.g. `_diff`, reference/candidate page markers) is the only semantic tag on an image. JPEG quality is hardcoded to 70, which visibly degrades diff inspection — the dashboard should recommend `screenshot_format=png` for review runs, or the sidecar should store lossless copies of diff images. + +**Reference-run mode.** The library reads `${REFERENCE_RUN}` (and exposes `set_reference_run`); when true, candidates are saved as new references instead of compared. This is the existing "approve everything" mechanism — the dashboard's accept action is its surgical, per-page counterpart, and the two must write identical file layouts. + +**Masks.** `IgnoreAreaManager` accepts a JSON file (`placeholder_file`), an inline dict/list/JSON string, or the shorthand `top:10;bottom:10` string. `DocumentRepresentation` implements five mask types — `coordinates` (x/y/width/height with `unit` of `px`, `mm`, `cm`, or `pt`, converted using the rendering DPI), `area` (top/bottom/left/right + percent), and the text-driven `pattern`, `line_pattern`, `word_pattern` (regex matched against OCR or PDF text, masking matched line/word bounding boxes). Each entry carries `page` (`"all"`, int, or numeric string) and an optional `name`. This is the schema the mask editor must read and write verbatim — no new format. + +**Result semantics in the log.** Failures raise with a summary (a single top-level warning like "Comparison failed: N difference(s) found") while individual differences, SSIM scores, move-tolerance results, OCR text comparisons, and LLM verdicts are logged as INFO messages inside the keyword. Rich, but free-text — reinforcing the case for a structured sidecar. + +**Existing mask editor.** `utilities/mask_editor.py` is a ~170-line tkinter tool: load an image, drag rectangles, right-click delete, export/load JSON. Pixel-unit coordinate masks only, no page concept, no area/pattern types, no preview of mask effect. It should be deprecated in favor of the dashboard's editor once that ships, with a pointer in the README. + +--- + +## 6. The Key Architectural Decision: How Results Reach the Dashboard + +Three channels were investigated. The recommendation is to implement the structured sidecar plus post-execution ingestion first, and the listener as an optional live mode — but the sidecar is the linchpin. + +### 6.1 Channel A — Post-execution ingestion of `output.xml` (must have) + +A CLI (`doctest-dashboard ingest output.xml` or auto-watch of a results directory) parses the result model: + +```python +from robot.api import ExecutionResult, ResultVisitor + +class ComparisonCollector(ResultVisitor): + def visit_test(self, test): + # walk test.body for keywords from DocTest.VisualTest / DocTest.PdfTest, + # collect status, messages, paths, and (preferred) the + # comparison_result.json sidecar reference logged by the library + ... + +result = ExecutionResult("output.xml") +result.visit(ComparisonCollector(db)) +``` + +Strengths: zero impact on execution, works on historical outputs, works on `rebot`-merged pabot results, works in CI where the dashboard isn't running. Weakness: without help from the library, semantics must be scraped from HTML messages (which the evaluator prototype already had to do). This mirrors the conclusion from the Allure-adapter analysis: post-execution adapters are robust exactly to the degree the producer emits structured data. + +### 6.2 Channel B — Robot Framework Listener (optional, for live review) + +Research summary of the listener interface (RF User Guide §4.3, current RF 7.x): + +A listener is a class or module exposing hook methods; the API version is declared with `ROBOT_LISTENER_API_VERSION = 2` or `3`. **Listener v3 is the right choice** for this project: since RF 7.0 it is feature-complete (including keyword-level `start_keyword`/`end_keyword` and `start_library_keyword` etc.), and unlike v2's string/dict arguments it receives the *actual running and result model objects*, so `result.status`, `result.message`, timing, and tags are typed objects, and listeners may even modify results. Relevant hooks for the dashboard: + +```python +from robot import result, running + +class DashboardListener: + ROBOT_LISTENER_API_VERSION = 3 + + def __init__(self, url="http://127.0.0.1:8008", run_id=None): + ... # listeners accept init args: --listener Dashboard.py:arg1:arg2 + # or with named syntax --listener Dashboard.py;url=...;run_id=... + + def start_suite(self, data: running.TestSuite, result_: result.TestSuite): ... + def end_test(self, data: running.TestCase, result_: result.TestCase): + # POST test status + collected comparison sidecars to the dashboard API + ... + def log_message(self, message: result.Message): + # capture INFO/WARN messages, including the html messages, + # and sidecar-path announcements, as they happen + ... + def output_file(self, path): # final output.xml path → trigger full ingest + ... + def close(self): ... +``` + +Listeners are activated per run (`robot --listener DashboardListener.py tests/`) or registered *by the library itself* as a **library listener** (`self.ROBOT_LIBRARY_LISTENER = self` with `ROBOT_LIBRARY_SCOPE`), which is the elegant deployment: `Library DocTest.VisualTest dashboard_url=...` could self-report without any CLI flags. Listener calling order can be controlled with `ROBOT_LISTENER_PRIORITY` if interaction with other listeners ever matters. Two operational cautions: listener exceptions are reported but must never break test execution, so all network I/O in the listener must be fire-and-forget with short timeouts and local spooling when the dashboard is unreachable; and under pabot, N parallel processes each run their own listener instance, so events must carry the pabot index (the library already exposes `PABOTQUEUEINDEX`) and the dashboard must merge. + +Verdict: the listener is *not required* for the core review workflow — `end_test` granularity adds "watch failures arrive live during a 2-hour regression run," which is valuable but secondary. Build it after ingestion works, as ~150 lines reusing the same API. + +### 6.3 Channel C — Structured sidecar emitted by the library (small library change, biggest payoff) + +Add to `VisualTest`/`PdfTest` an opt-in `result_json=${True}` (or always-on when cheap): for every comparison, write `{OUTPUT_DIR}/doctest_results/{uuid}.json` and log one parseable message `DOCTEST_RESULT: `. Proposed schema: + +```json +{ + "schema_version": 1, + "keyword": "Compare Images", + "library": "DocTest.VisualTest", + "status": "FAIL", + "reference": {"path": "testdata/Reference.pdf", "pages": 3, "dpi": 200}, + "candidate": {"path": "out/Candidate.pdf", "pages": 3, "dpi": 200}, + "settings": {"move_tolerance": 20, "check_text_content": false, + "watermark_file": null, "screenshot_format": "png"}, + "masks": {"source": "masks.json", "resolved": [ /* IgnoreAreaManager output */ ]}, + "pages": [ + {"page": 1, "status": "PASS", "ssim": 0.9991, + "images": {"reference": "screenshots/ab12_ref_p1.png", + "candidate": "screenshots/ab12_cand_p1.png"}}, + {"page": 2, "status": "FAIL", "ssim": 0.9412, + "diff_regions": [{"x": 410, "y": 36, "width": 150, "height": 42}], + "images": {"reference": "...", "candidate": "...", + "diff": "screenshots/ab12_diff_p2.png", + "thresholded": "screenshots/ab12_thresh_p2.png"}} + ], + "llm": {"verdict": null}, + "timing": {"started": "2026-06-10T09:14:02", "elapsed_ms": 1840} +} +``` + +Two fields do most of the heavy lifting downstream. Explicit `reference.path` makes accept-promotion exact (no `candidate_` filename parsing). `diff_regions` — the bounding boxes the library already computes internally for contour/diff detection — enable diff-region navigation ("next difference" button), accept-region statistics, and crucially the **one-click "mask this region"** feature, since a diff region converts directly into a `coordinates` mask entry. This change is small (the data already exists in memory at comparison time), fully backward compatible, and benefits *any* future tooling, not just this dashboard — the same argument that favored the post-execution Allure adapter. + +--- + +## 7. Recommended Architecture + +``` +┌────────────────────────────── doctest-dashboard ──────────────────────────────┐ +│ │ +│ React SPA (Vite, TypeScript) │ +│ ├─ Run & test grid (status filters, thumbnails) │ +│ ├─ Diff viewer (side-by-side / overlay / blink / swipe / diff-only) │ +│ ├─ Review actions (accept / reject / annotate, per page & batch) │ +│ └─ Mask editor (Konva canvas, schema-exact masks.json I/O) │ +│ │ REST + WebSocket │ +│ FastAPI backend │ +│ ├─ Ingest service: output.xml (ResultVisitor) + sidecar JSON │ +│ ├─ Review service: decisions, baseline promotion, bug-data export │ +│ ├─ Mask service: load/save masks.json, server-side pattern preview │ +│ ├─ Asset service: serves screenshots & rendered pages (range/cache headers) │ +│ └─ Live API: receives Listener v3 events (optional) │ +│ │ +│ Storage │ +│ ├─ SQLite (runs, tests, pages, decisions, audit) — single file │ +│ └─ Filesystem: baselines stay where they are (git-versionable); │ +│ run artifacts referenced in place under each OUTPUT_DIR │ +└────────────────────────────────────────────────────────────────────────────────┘ +``` + +Rationale for the stack: FastAPI + SQLite + React matches the existing ecosystem conventions in your other projects (rf-mcp tooling, the Polarion MCP recommendation) and keeps deployment to `pipx install doctest-dashboard && doctest-dashboard serve` locally, or one small container behind a Cloudflare tunnel for a team. SQLite is sufficient because the write rate is tiny (decisions and ingests) and concurrency is low; Postgres would be premature. Baselines deliberately remain **plain files in the repository** so accepting a change produces a reviewable git diff — the reg-suit lesson, and consistent with how doctestlibrary users already work. + +Packaging options, in order of preference: (1) a separate package `robotframework-doctestlibrary-dashboard` that depends on the core library (keeps the core dependency-light — it already fights heavy native deps); (2) an extra `pip install robotframework-doctestlibrary[dashboard]`. The frontend is built at release time and shipped as static files inside the wheel, so end users never need Node. + +## 8. Feature Set and Applitools-Parity Matrix + +| Capability | Applitools/Percy | Dashboard v1 | Notes | +|---|---|---|---| +| Run (batch) overview with pass/fail/unresolved counts | ✓ | ✓ | per `output.xml` ingest; pabot runs merge by run id | +| Test grid with diff thumbnails & status filters | ✓ | ✓ | thumbnail = first failing page's diff | +| Side-by-side viewer with synced zoom/pan | ✓ | ✓ | | +| Overlay / blink / onion-skin / swipe modes | ✓ | ✓ | overlay default; keyboard `1–5` to switch | +| Diff-region navigation (next/prev difference) | ✓ | ✓ (needs sidecar) | from `diff_regions` | +| Accept → baseline promotion | ✓ | ✓ | per page, per test, per run ("accept all in suite") | +| Reject with reason / bug-data export | partial | ✓ | zip of ref+cand+diff+metadata, à la `generate_bug_data` | +| Audit trail (who/when/what, before/after hashes) | ✓ | ✓ | SQLite `decisions` table; baseline file hash recorded | +| Ignore-region creation from diff view | ✓ | ✓ | writes `coordinates` mask into chosen masks.json | +| Mask editor (coordinate, area, pattern) with preview | region-only | ✓ superset | pattern masks are a doctest-unique strength | +| Baseline history & rollback | ✓ | v1.5 | store promoted-over files in `.doctest_baseline_history/` or rely on git | +| Live results during a run | ✓ | v2 (listener) | WebSocket push to the run view | +| AI auto-triage of diffs | ✓ | v2 | reuse `DocTest.Ai` LLM verdicts already in the library | +| CI status gating (fail until reviewed) | ✓ | v2 | exit-code tool: `doctest-dashboard gate ` | + +**Review semantics.** A failed comparison enters state `unresolved`. *Accept (page)* copies the candidate rendering or source file over the reference. For single-image comparisons this is a file copy; for PDFs, page-level accept is not possible at the file level, so the UI offers accept-at-document granularity when the artifact is a PDF and explains why — with the documented alternative of masking the intended change. *Accept (test)* promotes the whole candidate document, exactly equivalent to a targeted `REFERENCE_RUN`. Every promotion records previous and new SHA-256 in the audit table. *Reject* requires an optional reason, marks the test `rejected`, and offers the bug-data bundle. A re-ingested newer run resets pages whose images changed to `unresolved` while keeping history. + +## 9. Mask Editor — Design Investigation + +The editor's contract is simple and strict: read and write the exact `IgnoreAreaManager` / `DocumentRepresentation` schema, nothing proprietary. Everything else is UX. + +**Canvas technology.** Three candidates were compared. Plain `` + custom hit-testing: maximal control, highest effort. **Fabric.js**: object model with built-in selection/resize handles, but heavyweight and its serialization model fights a schema-exact JSON target. **Konva.js (react-konva)**: declarative React bindings, `Transformer` gives move/resize/rotate handles for free, layers map naturally to "document page image below, mask shapes above, diff overlay optional," and it is the de-facto choice for annotation UIs. **Recommendation: react-konva.** The page image sits in a locked layer; each mask is a `Rect` (or full-width/height band for area masks) in an editable layer with a `Transformer`; a third toggleable layer can show the latest diff regions to guide mask placement. + +**Editing model per mask type.** + +*Coordinates masks:* draw by drag; move/resize with handles; numeric fields stay in sync for precision; a unit selector (`px`/`mm`/`cm`/`pt`) converts using the run's DPI — this is the subtle part. The library converts physical units to pixels with the rendering DPI (`_convert_to_pixels`), so the editor must know the DPI of the displayed rendering (from the sidecar, default 200 today) or the conversion silently drifts. The editor therefore displays the active DPI prominently and stores whichever unit the user selected, converting only for display. + +*Area masks:* not drawn freehand — selected from a side panel (location + percent slider) and previewed as a translucent band across the page; `page: all` vs specific page is a toggle. Live preview makes the percent slider self-explanatory. + +*Pattern / line_pattern / word_pattern masks:* a regex input with live match preview. The backend exposes `POST /api/mask-preview` which runs the same text-extraction path the library uses (PDF text via MuPDF or OCR via the configured engine) for the displayed page and returns the bounding boxes the pattern would mask; the editor highlights them. This gives users, for the first time, *visual confirmation that a date-pattern mask actually catches the date* before committing it — arguably the editor's strongest feature, and only possible because the backend can import the library directly. Implementation note: extraction can be slow on OCR paths, so preview results are cached per (file, page, engine) and the request is debounced. + +**Create-mask-from-diff.** In the diff viewer, hovering a detected diff region (from sidecar `diff_regions`) shows "Add ignore mask"; clicking opens the editor pre-seeded with a `coordinates` mask of that box (plus configurable padding), targeting a masks.json chosen via a file picker rooted at the test's data directory. This single interaction is what makes mask maintenance routine instead of a chore. + +**Multi-page & files.** A page strip on the left; masks listed with name, type, page, and visibility toggles; `page: "all"` masks render on every page. Import merges or replaces; export writes pretty-printed JSON with stable key order to keep git diffs clean. The editor also accepts the shorthand string format on import (`top:10;bottom:10`) by normalizing it through the same logic as `_parse_mask_string`, but always exports the JSON form. + +## 10. Data Model and API Sketch + +SQLite tables (abridged): `runs(id, name, output_xml_path, started, imported_at, rf_version, pabot)`, `tests(id, run_id, suite, name, status, keyword, message)`, `pages(id, test_id, page_no, status, ssim, ref_img, cand_img, diff_img, regions_json)`, `decisions(id, page_id|test_id, action accept|reject, actor, reason, prev_hash, new_hash, created_at)`, `mask_files(path, last_seen_hash)`. + +REST surface (abridged): + +``` +POST /api/ingest {output_xml: path} → run summary +GET /api/runs ?status=unresolved +GET /api/runs/{id}/tests ?status=fail +GET /api/tests/{id} full detail incl. pages, sidecar +POST /api/pages/{id}/accept {reason?} → promotes baseline +POST /api/tests/{id}/accept {scope: test|document} +POST /api/tests/{id}/reject {reason} → optional bug bundle +GET /api/tests/{id}/bugdata → zip download +GET /api/assets/{token} image serving (paths never exposed raw) +GET /api/masks?file=... normalized mask list +PUT /api/masks {file, masks[]} atomic write + backup +POST /api/mask-preview {file, page, pattern, type} → bounding boxes +WS /api/live/{run_id} listener event stream (v2) +``` + +Security posture: local-first, binds to 127.0.0.1 by default, no auth in local mode. Self-hosted mode adds a simple token or sits behind existing SSO/tunnel auth (Cloudflare Access fits your current infra). The accept endpoint writes into the test-data tree, so the server runs with the same user permissions as the test author and refuses paths outside configured roots — path traversal is the main thing to defend. + +## 11. Roadmap + +**M1 — Ingest & browse (≈2 wks).** CLI ingest of `output.xml` (HTML-message scraping fallback, evaluator-style), SQLite store, run/test grid, asset serving, basic side-by-side viewer. + +**M2 — Sidecar in the library (≈1 wk, separate PR to doctestlibrary).** `result_json` option, `DOCTEST_RESULT:` log line, schema v1, docs. Ingester prefers sidecars when present. + +**M3 — Review workflow (≈2 wks).** Overlay/blink/swipe modes, diff-region navigation, accept/reject with promotion + audit, bug-data export, keyboard-driven triage. + +**M4 — Mask editor (≈2–3 wks).** react-konva editor, coordinate + area masks with unit/DPI handling, masks.json round-trip, create-mask-from-diff. Pattern preview endpoint lands at the end of M4 or as M4.5. + +**M5 — Listener live mode & polish (≈1–2 wks).** Listener v3 with spooling, WebSocket run view, pabot merge handling, `gate` command for CI, deprecation note on the tkinter editor. + +## 12. Risks and Open Questions + +The DPI/unit conversion mismatch between editor preview and execution is the most likely source of subtle bugs; mitigated by always sourcing DPI from the sidecar and showing it in the UI. `embed_screenshots=True` runs cannot be reviewed without extracting base64 from messages — feasible but ugly; document file mode as a prerequisite instead. PDF page-level accept is semantically impossible at file level (Section 8) and the UI must communicate that honestly rather than pretend. Pattern-preview parity requires the backend environment to mirror the test environment's OCR setup (Tesseract availability, engine choice) — acceptable for a companion tool, but worth a capability check on startup (the library's `CapabilityCheck` can be reused). Finally, whether the sidecar lands in the core library or the dashboard ships a wrapper keyword is a governance call; the core PR is strongly preferred since the data already exists in memory and other tools (Allure adapter, rf-mcp reporting) would benefit from the same schema. + +## 13. Sources + +Repository and keyword docs: github.com/manykarim/robotframework-doctestlibrary (README, `DocTest/VisualTest.py`, `DocTest/IgnoreAreaManager.py`, `DocTest/DocumentRepresentation.py`, `utilities/mask_editor.py`, main branch, June 2026). PyQt5 prototype: github.com/manykarim/rbcn22de-visual-document-testing, `utilities/TestResultEvaluator.py`. Robot Framework User Guide 7.4.2, §3.4 Post-processing outputs and §4.3 Listener interface (robotframework.org). Feature-bar references: Applitools Eyes review workflow, Percy build review model, reg-suit/BackstopJS/Lost Pixel open-source viewers (vendor documentation, general knowledge). diff --git a/doctest_dashboard/__init__.py b/doctest_dashboard/__init__.py new file mode 100644 index 0000000..cace535 --- /dev/null +++ b/doctest_dashboard/__init__.py @@ -0,0 +1,12 @@ +"""doctest_dashboard: visual review dashboard for robotframework-doctestlibrary. + +Ships as part of the robotframework-doctestlibrary distribution (install the +``[dashboard]`` extra) and therefore carries the same version. +""" + +from importlib import metadata + +try: + __version__ = metadata.version("robotframework-doctestlibrary") +except metadata.PackageNotFoundError: # running from a plain checkout + __version__ = "0.0.0.dev0" diff --git a/doctest_dashboard/cli.py b/doctest_dashboard/cli.py new file mode 100644 index 0000000..edf7960 --- /dev/null +++ b/doctest_dashboard/cli.py @@ -0,0 +1,109 @@ +"""Command line interface: ``doctest-dashboard serve|ingest``.""" + +import argparse +import sys +from pathlib import Path + +from doctest_dashboard import __version__ +from doctest_dashboard.config import DEFAULT_HOST, DEFAULT_PORT, AppConfig + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="doctest-dashboard", + description="Visual review dashboard for robotframework-doctestlibrary", + ) + parser.add_argument("--version", action="version", version=__version__) + parser.add_argument( + "--data-dir", + type=Path, + default=None, + help="Directory for the dashboard database (default: ./.doctest_dashboard)", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + serve = subparsers.add_parser("serve", help="Run the dashboard server") + serve.add_argument("--host", default=DEFAULT_HOST, help="Bind address (default 127.0.0.1)") + serve.add_argument("--port", type=int, default=DEFAULT_PORT) + serve.add_argument("--token", default=None, help="Require this bearer token on all API calls") + serve.add_argument( + "--root", + action="append", + type=Path, + default=[], + dest="roots", + help="Allow asset/baseline/mask access under this directory (repeatable)", + ) + serve.add_argument("--reload", action="store_true", help="Auto-reload (development)") + + ingest = subparsers.add_parser("ingest", help="Ingest a Robot Framework output.xml") + ingest.add_argument("output_xml", type=Path) + + return parser + + +def _require_dashboard_dependencies() -> bool: + """The console script is installed with every variant of the package; + the server/ingest dependencies only come with the [dashboard] extra.""" + try: + import fastapi # noqa: F401 + import pydantic # noqa: F401 + import uvicorn # noqa: F401 + except ImportError: + print( + "The dashboard dependencies are not installed.\n" + "Install them with:\n\n" + " pip install robotframework-doctestlibrary[dashboard]\n", + file=sys.stderr, + ) + return False + return True + + +def main(argv=None) -> int: + args = build_parser().parse_args(argv) + data_dir = args.data_dir or Path.cwd() / ".doctest_dashboard" + + if not _require_dashboard_dependencies(): + return 3 + + if args.command == "serve": + import uvicorn + + from doctest_dashboard.server.app import create_app + + config = AppConfig( + data_dir=data_dir, + roots=args.roots, + host=args.host, + port=args.port, + token=args.token, + ) + app = create_app(config) + uvicorn.run(app, host=config.host, port=config.port) + return 0 + + if args.command == "ingest": + from doctest_dashboard.db import Database + from doctest_dashboard.ingest import ingest_output_xml + + config = AppConfig(data_dir=data_dir) + config.data_dir.mkdir(parents=True, exist_ok=True) + database = Database(config.db_path) + try: + summary = ingest_output_xml(database, config, args.output_xml) + except FileNotFoundError as error: + print(f"error: {error}", file=sys.stderr) + return 2 + print( + f"Ingested run {summary.run_id}: {summary.tests} tests, " + f"{summary.comparisons} comparisons " + f"({summary.sidecar_comparisons} with sidecar, {summary.degraded_comparisons} degraded)" + ) + return 0 + + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/doctest_dashboard/config.py b/doctest_dashboard/config.py new file mode 100644 index 0000000..67bf202 --- /dev/null +++ b/doctest_dashboard/config.py @@ -0,0 +1,48 @@ +"""Application configuration.""" + +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional + +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 8008 +DATA_DIR_NAME = ".doctest_dashboard" + + +@dataclass +class AppConfig: + """Runtime configuration for the dashboard server. + + ``roots`` is the allowlist of directories the server may read assets + from and write baselines/masks into. Every filesystem access is + validated against it; ingesting an output.xml adds its directory + automatically. + """ + + data_dir: Path = field(default_factory=lambda: Path.cwd() / DATA_DIR_NAME) + roots: List[Path] = field(default_factory=list) + host: str = DEFAULT_HOST + port: int = DEFAULT_PORT + token: Optional[str] = None + + def __post_init__(self) -> None: + self.data_dir = Path(self.data_dir).resolve() + self.roots = [Path(root).resolve() for root in self.roots] + + @property + def db_path(self) -> Path: + return self.data_dir / "dashboard.db" + + def add_root(self, root: Path) -> Path: + resolved = Path(root).resolve() + if resolved not in self.roots: + self.roots.append(resolved) + return resolved + + def is_within_roots(self, path: Path) -> bool: + """True if ``path`` (fully resolved, symlinks included) is under a root.""" + try: + resolved = Path(path).resolve(strict=True) + except OSError: + return False + return any(resolved.is_relative_to(root) for root in self.roots) diff --git a/doctest_dashboard/db.py b/doctest_dashboard/db.py new file mode 100644 index 0000000..1f3fb5a --- /dev/null +++ b/doctest_dashboard/db.py @@ -0,0 +1,212 @@ +"""SQLite persistence: thin DAL over WAL-mode sqlite3. + +Write rates are tiny (ingests and review decisions), so plain sqlite3 with +a small helper class is deliberate — no ORM. +""" + +import json +import sqlite3 +import threading +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS runs ( + id INTEGER PRIMARY KEY, + output_xml_path TEXT NOT NULL UNIQUE, + name TEXT, + started TEXT, + imported_at TEXT NOT NULL, + rf_version TEXT +); +CREATE TABLE IF NOT EXISTS tests ( + id INTEGER PRIMARY KEY, + run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE, + suite TEXT NOT NULL, + name TEXT NOT NULL, + status TEXT NOT NULL, + message TEXT +); +CREATE TABLE IF NOT EXISTS comparisons ( + id INTEGER PRIMARY KEY, + test_id INTEGER NOT NULL REFERENCES tests(id) ON DELETE CASCADE, + keyword TEXT NOT NULL, + library TEXT, + status TEXT NOT NULL, + degraded INTEGER NOT NULL DEFAULT 0, + review_state TEXT NOT NULL DEFAULT 'unresolved', + sidecar_path TEXT, + sidecar_json TEXT, + reference_path TEXT, + candidate_path TEXT, + identity TEXT NOT NULL, + images_json TEXT +); +CREATE TABLE IF NOT EXISTS pages ( + id INTEGER PRIMARY KEY, + comparison_id INTEGER NOT NULL REFERENCES comparisons(id) ON DELETE CASCADE, + page_no INTEGER NOT NULL, + status TEXT NOT NULL, + score REAL, + threshold REAL, + regions_json TEXT, + images_json TEXT, + review_state TEXT NOT NULL DEFAULT 'unresolved', + content_key TEXT +); +CREATE TABLE IF NOT EXISTS decisions ( + id INTEGER PRIMARY KEY, + comparison_id INTEGER REFERENCES comparisons(id) ON DELETE SET NULL, + page_id INTEGER REFERENCES pages(id) ON DELETE SET NULL, + action TEXT NOT NULL, + actor TEXT, + reason TEXT, + prev_sha256 TEXT, + new_sha256 TEXT, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS mask_files ( + path TEXT PRIMARY KEY, + last_seen_hash TEXT, + updated_at TEXT +); +CREATE TABLE IF NOT EXISTS assets ( + token TEXT PRIMARY KEY, + path TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_tests_run ON tests(run_id); +CREATE INDEX IF NOT EXISTS idx_comparisons_test ON comparisons(test_id); +CREATE INDEX IF NOT EXISTS idx_comparisons_identity ON comparisons(identity); +CREATE INDEX IF NOT EXISTS idx_pages_comparison ON pages(comparison_id); +""" + + +def _now() -> str: + return datetime.now().isoformat(timespec="seconds") + + +class Database: + """Thread-safe-enough sqlite wrapper (one connection, one lock).""" + + def __init__(self, path: Path): + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + self._conn = sqlite3.connect(str(path), check_same_thread=False) + self._conn.row_factory = sqlite3.Row + self._lock = threading.Lock() + with self._lock: + self._conn.execute("PRAGMA journal_mode=WAL") + self._conn.execute("PRAGMA foreign_keys=ON") + self._conn.executescript(SCHEMA) + self._conn.commit() + + def close(self) -> None: + with self._lock: + self._conn.close() + + def execute(self, sql: str, params: tuple = ()) -> sqlite3.Cursor: + with self._lock: + cursor = self._conn.execute(sql, params) + self._conn.commit() + return cursor + + def query(self, sql: str, params: tuple = ()) -> List[Dict[str, Any]]: + with self._lock: + rows = self._conn.execute(sql, params).fetchall() + return [dict(row) for row in rows] + + def query_one(self, sql: str, params: tuple = ()) -> Optional[Dict[str, Any]]: + rows = self.query(sql, params) + return rows[0] if rows else None + + # -- runs --------------------------------------------------------------- + + def upsert_run(self, output_xml_path: str, name: str, started: Optional[str], + rf_version: Optional[str]) -> int: + """Insert the run or, when re-ingesting the same output.xml, wipe and + re-create its children so ingestion stays idempotent.""" + existing = self.query_one( + "SELECT id FROM runs WHERE output_xml_path = ?", (output_xml_path,)) + if existing: + run_id = existing["id"] + self.execute("DELETE FROM tests WHERE run_id = ?", (run_id,)) + self.execute( + "UPDATE runs SET name = ?, started = ?, imported_at = ?, rf_version = ? WHERE id = ?", + (name, started, _now(), rf_version, run_id)) + return run_id + cursor = self.execute( + "INSERT INTO runs (output_xml_path, name, started, imported_at, rf_version) " + "VALUES (?, ?, ?, ?, ?)", + (output_xml_path, name, started, _now(), rf_version)) + return cursor.lastrowid + + def insert_test(self, run_id: int, suite: str, name: str, status: str, + message: str) -> int: + cursor = self.execute( + "INSERT INTO tests (run_id, suite, name, status, message) VALUES (?, ?, ?, ?, ?)", + (run_id, suite, name, status, message)) + return cursor.lastrowid + + def insert_comparison(self, test_id: int, keyword: str, library: Optional[str], + status: str, degraded: bool, identity: str, + sidecar_path: Optional[str] = None, + sidecar_json: Optional[Dict[str, Any]] = None, + reference_path: Optional[str] = None, + candidate_path: Optional[str] = None, + images: Optional[List[str]] = None) -> int: + cursor = self.execute( + "INSERT INTO comparisons (test_id, keyword, library, status, degraded, " + "review_state, sidecar_path, sidecar_json, reference_path, candidate_path, " + "identity, images_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (test_id, keyword, library, status, int(degraded), + "unresolved" if status == "FAIL" else "passed", + sidecar_path, + json.dumps(sidecar_json) if sidecar_json is not None else None, + reference_path, candidate_path, identity, + json.dumps(images) if images is not None else None)) + return cursor.lastrowid + + def insert_page(self, comparison_id: int, page_no: int, status: str, + score: Optional[float], threshold: Optional[float], + regions: List[Dict[str, int]], images: Dict[str, str], + content_key: Optional[str]) -> int: + cursor = self.execute( + "INSERT INTO pages (comparison_id, page_no, status, score, threshold, " + "regions_json, images_json, review_state, content_key) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (comparison_id, page_no, status, score, threshold, + json.dumps(regions), json.dumps(images), + "unresolved" if status == "FAIL" else "passed", content_key)) + return cursor.lastrowid + + # -- decisions ---------------------------------------------------------- + + def insert_decision(self, action: str, actor: Optional[str], reason: Optional[str], + comparison_id: Optional[int] = None, page_id: Optional[int] = None, + prev_sha256: Optional[str] = None, + new_sha256: Optional[str] = None) -> int: + cursor = self.execute( + "INSERT INTO decisions (comparison_id, page_id, action, actor, reason, " + "prev_sha256, new_sha256, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (comparison_id, page_id, action, actor, reason, prev_sha256, new_sha256, _now())) + return cursor.lastrowid + + def set_comparison_state(self, comparison_id: int, state: str) -> None: + self.execute( + "UPDATE comparisons SET review_state = ? WHERE id = ?", (state, comparison_id)) + + def set_page_state(self, page_id: int, state: str) -> None: + self.execute("UPDATE pages SET review_state = ? WHERE id = ?", (state, page_id)) + + # -- assets --------------------------------------------------------------- + + def register_asset(self, token: str, path: str) -> None: + self.execute( + "INSERT INTO assets (token, path) VALUES (?, ?) " + "ON CONFLICT(token) DO UPDATE SET path = excluded.path", + (token, path)) + + def resolve_asset(self, token: str) -> Optional[str]: + row = self.query_one("SELECT path FROM assets WHERE token = ?", (token,)) + return row["path"] if row else None diff --git a/doctest_dashboard/engine.py b/doctest_dashboard/engine.py new file mode 100644 index 0000000..81e4a97 --- /dev/null +++ b/doctest_dashboard/engine.py @@ -0,0 +1,208 @@ +"""Embedded comparison engine. + +The dashboard imports the DocTest library directly (verified to work +outside a Robot Framework run) for two features: + +- mask preview: resolve mask definitions to pixel boxes for a page +- recompare: re-run a stored comparison with adjusted masks/settings into + a scratch directory, never touching the original run artifacts + +Comparisons are CPU-bound (OpenCV/OCR), so jobs run in a +``ProcessPoolExecutor`` with per-job timeouts; identical requests are +served from an in-memory cache. +""" + +import hashlib +import json +import logging +import multiprocessing +import os +import tempfile +from concurrent.futures import ProcessPoolExecutor +from concurrent.futures import TimeoutError as FutureTimeoutError +from pathlib import Path +from typing import Any, Dict, List, Optional + +LOG = logging.getLogger(__name__) + +JOB_TIMEOUT_SECONDS = 120 +MAX_WORKERS = 2 + + +# --- worker functions (run in subprocesses, must stay module-level) ---------- + +def _mask_preview_job(file_path: str, page: int, masks: Any, dpi: Optional[int], + ocr_engine: Optional[str], force_ocr: bool) -> Dict[str, Any]: + from DocTest.DocumentRepresentation import DocumentRepresentation + + kwargs: Dict[str, Any] = {"ignore_area": masks} + if dpi: + kwargs["dpi"] = dpi + if ocr_engine: + kwargs["ocr_engine"] = ocr_engine + if force_ocr: + kwargs["force_ocr"] = True + document = DocumentRepresentation(file_path, **kwargs) + try: + pages = document.pages + if page < 1 or page > len(pages): + raise ValueError(f"Page {page} out of range (document has {len(pages)} pages)") + target = pages[page - 1] + return { + "page": page, + "dpi": target.dpi, + "page_count": len(pages), + "resolved_areas": list(target.pixel_ignore_areas), + "image_size": {"height": target.image.shape[0], "width": target.image.shape[1]}, + } + finally: + document.close() + + +def _page_image_job(file_path: str, page: int, dpi: Optional[int], + target_png: str) -> Dict[str, Any]: + import cv2 + + from DocTest.DocumentRepresentation import DocumentRepresentation + + kwargs: Dict[str, Any] = {} + if dpi: + kwargs["dpi"] = dpi + document = DocumentRepresentation(file_path, **kwargs) + try: + pages = document.pages + if page < 1 or page > len(pages): + raise ValueError(f"Page {page} out of range (document has {len(pages)} pages)") + target = pages[page - 1] + cv2.imwrite(target_png, target.image) + return { + "page": page, + "page_count": len(pages), + "dpi": target.dpi, + "image_size": {"height": target.image.shape[0], "width": target.image.shape[1]}, + } + finally: + document.close() + + +def _recompare_job(reference: str, candidate: str, masks: Any, + settings: Dict[str, Any], scratch_dir: str) -> Dict[str, Any]: + os.chdir(scratch_dir) + from DocTest.VisualTest import VisualTest + + visual_tester = VisualTest(result_json=True) + kwargs: Dict[str, Any] = {} + if masks is not None: + kwargs["mask"] = masks + for key in ("threshold", "move_tolerance", "DPI", "force_ocr", "blur", + "block_based_ssim", "check_text_content"): + if settings.get(key) is not None: + kwargs[key] = settings[key] + status = "PASS" + try: + visual_tester.compare_images(reference, candidate, **kwargs) + except AssertionError: + status = "FAIL" + sidecars = sorted(Path(scratch_dir).glob("doctest_results/*.json")) + if not sidecars: + raise RuntimeError("Comparison produced no sidecar") + with open(sidecars[-1], encoding="utf-8") as file: + sidecar = json.load(file) + sidecar["status"] = status + return sidecar + + +# --- service ----------------------------------------------------------------- + +class EngineService: + def __init__(self, scratch_root: Path): + self.scratch_root = Path(scratch_root) + self.scratch_root.mkdir(parents=True, exist_ok=True) + self._pool: Optional[ProcessPoolExecutor] = None + self._cache: Dict[str, Dict[str, Any]] = {} + self.capabilities = self._check_capabilities() + + @staticmethod + def _check_capabilities() -> Dict[str, Any]: + try: + from DocTest.CapabilityCheck import check_all_capabilities + + return check_all_capabilities() + except Exception as error: # pragma: no cover - defensive + LOG.warning("Capability check failed: %s", error) + return {} + + @property + def ocr_available(self) -> bool: + tesseract = self.capabilities.get("tesseract", {}) + return bool(tesseract.get("available", False)) + + def _ensure_pool(self) -> ProcessPoolExecutor: + if self._pool is None: + # spawn: the server process is multi-threaded, fork is unsafe + self._pool = ProcessPoolExecutor( + max_workers=MAX_WORKERS, + mp_context=multiprocessing.get_context("spawn")) + return self._pool + + def shutdown(self) -> None: + if self._pool is not None: + self._pool.shutdown(wait=False, cancel_futures=True) + self._pool = None + + @staticmethod + def _cache_key(*parts: Any) -> str: + digest = hashlib.sha256() + for part in parts: + digest.update(json.dumps(part, sort_keys=True, default=str).encode()) + return digest.hexdigest() + + @staticmethod + def _file_fingerprint(path: str) -> Dict[str, Any]: + stat = os.stat(path) + return {"path": path, "size": stat.st_size, "mtime": stat.st_mtime_ns} + + def mask_preview(self, file_path: str, page: int, masks: Any, + dpi: Optional[int] = None, ocr_engine: Optional[str] = None, + force_ocr: bool = False) -> Dict[str, Any]: + key = self._cache_key("preview", self._file_fingerprint(file_path), + page, masks, dpi, ocr_engine, force_ocr) + if key in self._cache: + return {**self._cache[key], "cached": True} + future = self._ensure_pool().submit( + _mask_preview_job, file_path, page, masks, dpi, ocr_engine, force_ocr) + result = future.result(timeout=JOB_TIMEOUT_SECONDS) + self._cache[key] = result + return {**result, "cached": False} + + def page_image(self, file_path: str, page: int, dpi: Optional[int] = None) -> Dict[str, Any]: + """Render a document/image page to a PNG in the scratch area.""" + key = self._cache_key("page-image", self._file_fingerprint(file_path), page, dpi) + if key in self._cache: + return {**self._cache[key], "cached": True} + scratch_dir = tempfile.mkdtemp(prefix="page_", dir=self.scratch_root) + target_png = str(Path(scratch_dir) / f"page_{page}.png") + future = self._ensure_pool().submit( + _page_image_job, file_path, page, dpi, target_png) + info = future.result(timeout=JOB_TIMEOUT_SECONDS) + result = {**info, "png_path": target_png} + self._cache[key] = result + return {**result, "cached": False} + + def recompare(self, reference: str, candidate: str, masks: Any, + settings: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + settings = settings or {} + key = self._cache_key("recompare", self._file_fingerprint(reference), + self._file_fingerprint(candidate), masks, settings) + if key in self._cache: + return {**self._cache[key], "cached": True} + scratch_dir = tempfile.mkdtemp(prefix="recompare_", dir=self.scratch_root) + future = self._ensure_pool().submit( + _recompare_job, reference, candidate, masks, settings, scratch_dir) + try: + sidecar = future.result(timeout=JOB_TIMEOUT_SECONDS) + except FutureTimeoutError: + raise TimeoutError(f"Recompare timed out after {JOB_TIMEOUT_SECONDS}s") + result = {"scratch_dir": scratch_dir, "result": sidecar} + self._cache[key] = result + return {**result, "cached": False} diff --git a/doctest_dashboard/ingest.py b/doctest_dashboard/ingest.py new file mode 100644 index 0000000..5818817 --- /dev/null +++ b/doctest_dashboard/ingest.py @@ -0,0 +1,231 @@ +"""Post-execution ingestion of Robot Framework ``output.xml`` files. + +Walks the result model with a ``ResultVisitor``, extracting DocTest +comparison keywords at *keyword level* (a failing comparison wrapped in +``Run Keyword And Expect Error`` passes at test level — keyword status is +the truth). Sidecar JSON referenced by a ``DOCTEST_RESULT:`` message is the +preferred source; without it, ```` references are scraped from HTML +log messages and the comparison is stored as *degraded*. +""" + +import hashlib +import json +import logging +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional + +from robot.api import ExecutionResult, ResultVisitor + +from doctest_dashboard.config import AppConfig +from doctest_dashboard.db import Database +from doctest_dashboard.models.sidecar import parse_sidecar + +LOG = logging.getLogger(__name__) + +DOCTEST_LIBRARIES = {"DocTest.VisualTest", "DocTest.PdfTest"} +RESULT_MESSAGE_RE = re.compile(r"DOCTEST_RESULT:\s*(\S+)") +IMG_SRC_RE = re.compile(r']+src="([^"]+)"') + + +@dataclass +class IngestSummary: + run_id: int + tests: int + comparisons: int + sidecar_comparisons: int + degraded_comparisons: int + + +def asset_token(path: str) -> str: + return hashlib.sha256(path.encode("utf-8")).hexdigest()[:32] + + +def _file_sha256(path: Path) -> Optional[str]: + try: + digest = hashlib.sha256() + with open(path, "rb") as file: + for chunk in iter(lambda: file.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + except OSError: + return None + + +def _iter_messages(item): + for child in getattr(item, "body", []): + if child.__class__.__name__ == "Message": + yield child + else: + yield from _iter_messages(child) + + +def _keyword_owner(keyword) -> Optional[str]: + owner = getattr(keyword, "owner", None) or getattr(keyword, "libname", None) + return str(owner) if owner else None + + +class _ComparisonCollector(ResultVisitor): + def __init__(self, database: Database, config: AppConfig, run_id: int, base_dir: Path): + self.database = database + self.config = config + self.run_id = run_id + self.base_dir = base_dir + self.tests = 0 + self.comparisons = 0 + self.sidecar_comparisons = 0 + self.degraded_comparisons = 0 + + def visit_test(self, test): + self.tests += 1 + test_id = self.database.insert_test( + run_id=self.run_id, + suite=test.parent.longname if test.parent else "", + name=test.name, + status=test.status, + message=test.message or "", + ) + index = 0 + for keyword in self._find_comparison_keywords(test): + index += 1 + identity = f"{test.longname}::{keyword.name}::{index}" + self._store_comparison(test_id, keyword, identity) + + def _find_comparison_keywords(self, item) -> List[Any]: + found = [] + for child in getattr(item, "body", []): + if child.__class__.__name__ == "Message": + continue + is_keyword = child.__class__.__name__ == "Keyword" + owner = _keyword_owner(child) if is_keyword else None + if owner in DOCTEST_LIBRARIES and str( + getattr(child, "name", "") or getattr(child, "kwname", "") + ).lower().startswith("compare"): + found.append(child) + else: + found.extend(self._find_comparison_keywords(child)) + return found + + def _store_comparison(self, test_id: int, keyword, identity: str) -> None: + self.comparisons += 1 + name = str(getattr(keyword, "name", "") or getattr(keyword, "kwname", "")) + owner = _keyword_owner(keyword) + status = keyword.status + + sidecar_rel = None + images: List[str] = [] + for message in _iter_messages(keyword): + match = RESULT_MESSAGE_RE.search(message.message or "") + if match: + sidecar_rel = match.group(1) + for src in IMG_SRC_RE.findall(message.message or ""): + images.append(src) + + sidecar_data = None + if sidecar_rel: + sidecar_path = (self.base_dir / sidecar_rel).resolve() + try: + with open(sidecar_path, encoding="utf-8") as file: + sidecar_data = parse_sidecar(json.load(file)) + except (OSError, ValueError, json.JSONDecodeError) as error: + LOG.warning("Cannot load sidecar %s: %s", sidecar_path, error) + sidecar_data = None + + if sidecar_data is not None: + comparison_id = self.database.insert_comparison( + test_id=test_id, + keyword=name, + library=owner, + status=status, + degraded=False, + identity=identity, + sidecar_path=str((self.base_dir / sidecar_rel).resolve()), + sidecar_json=sidecar_data.model_dump(), + reference_path=sidecar_data.reference.path, + candidate_path=sidecar_data.candidate.path, + ) + self.sidecar_comparisons += 1 + for page in sidecar_data.pages: + page_images: Dict[str, str] = {} + content_parts = [] + for kind, rel in page.images.items(): + absolute = (self.base_dir / rel).resolve() + token = asset_token(str(absolute)) + self.database.register_asset(token, str(absolute)) + page_images[kind] = token + if kind == "candidate": + content_parts.append(_file_sha256(absolute) or rel) + content_key = content_parts[0] if content_parts else None + page_no = page.page if isinstance(page.page, int) else 0 + page_id = self.database.insert_page( + comparison_id=comparison_id, + page_no=page_no, + status=page.status, + score=page.score, + threshold=page.threshold, + regions=[region.model_dump() for region in page.diff_regions], + images=page_images, + content_key=content_key, + ) + self._inherit_page_state(page_id, identity, page_no, page.status, content_key) + else: + tokens = [] + for rel in images: + absolute = (self.base_dir / rel).resolve() + token = asset_token(str(absolute)) + self.database.register_asset(token, str(absolute)) + tokens.append(token) + self.database.insert_comparison( + test_id=test_id, + keyword=name, + library=owner, + status=status, + degraded=True, + identity=identity, + images=tokens, + ) + self.degraded_comparisons += 1 + + def _inherit_page_state(self, page_id: int, identity: str, page_no: int, + status: str, content_key: Optional[str]) -> None: + """Keep an accepted/rejected state only while the page content is unchanged.""" + if status != "FAIL" or not content_key: + return + previous = self.database.query_one( + "SELECT p.review_state, p.content_key FROM pages p " + "JOIN comparisons c ON p.comparison_id = c.id " + "WHERE c.identity = ? AND p.page_no = ? AND p.id != ? " + "ORDER BY p.id DESC LIMIT 1", + (identity, page_no, page_id)) + if ( + previous + and previous["review_state"] in ("accepted", "rejected") + and previous["content_key"] == content_key + ): + self.database.set_page_state(page_id, previous["review_state"]) + + +def ingest_output_xml(database: Database, config: AppConfig, output_xml) -> IngestSummary: + output_xml = Path(output_xml).resolve() + if not output_xml.is_file(): + raise FileNotFoundError(f"output.xml not found: {output_xml}") + base_dir = output_xml.parent + config.add_root(base_dir) + + result = ExecutionResult(str(output_xml)) + run_id = database.upsert_run( + output_xml_path=str(output_xml), + name=result.suite.name, + started=getattr(result.suite, "starttime", None) or None, + rf_version=getattr(result, "generator", None), + ) + collector = _ComparisonCollector(database, config, run_id, base_dir) + result.visit(collector) + return IngestSummary( + run_id=run_id, + tests=collector.tests, + comparisons=collector.comparisons, + sidecar_comparisons=collector.sidecar_comparisons, + degraded_comparisons=collector.degraded_comparisons, + ) diff --git a/doctest_dashboard/masks.py b/doctest_dashboard/masks.py new file mode 100644 index 0000000..31135f6 --- /dev/null +++ b/doctest_dashboard/masks.py @@ -0,0 +1,105 @@ +"""masks.json I/O: schema-exact load/save with the library's own parser. + +Normalization goes through ``DocTest.IgnoreAreaManager`` so the dashboard +accepts exactly what the library accepts (JSON file, inline list/dict, +JSON string, or the ``top:10;bottom:10`` shorthand) — and never invents a +schema of its own. Exports are pretty-printed JSON with stable key order +to keep git diffs clean. +""" + +import hashlib +import json +import os +import shutil +from pathlib import Path +from typing import Any, List + +from DocTest.IgnoreAreaManager import IgnoreAreaManager + +# Canonical key order for exported mask entries (extras keep insertion order) +KEY_ORDER = [ + "page", "name", "type", + "x", "y", "width", "height", "unit", + "location", "percent", + "pattern", "xoffset", "yoffset", +] + + +class MaskError(Exception): + pass + + +PATTERN_TYPES = {"pattern", "line_pattern", "word_pattern"} + + +def validate_pattern_masks(masks: List[dict]) -> None: + """Reject pattern masks whose regex does not compile. + + Called on every input path that feeds masks into the comparison engine + (preview, recompare, save) — the library itself compiles patterns lazily + per text token and would raise deep inside a worker process otherwise. + Intentionally NOT applied when loading existing files, so a file with a + broken pattern can still be opened and repaired in the editor. + """ + import re as re_module + + for entry in masks: + if not isinstance(entry, dict) or entry.get("type") not in PATTERN_TYPES: + continue + pattern = entry.get("pattern") + if not pattern: + raise MaskError("Pattern mask has an empty 'pattern'") + try: + re_module.compile(pattern) + except re_module.error as error: + raise MaskError(f"Invalid regular expression {pattern!r}: {error}") + + +def normalize_masks(raw: Any) -> List[dict]: + """Normalize any library-accepted mask input into a list of dicts.""" + if raw is None: + return [] + masks = IgnoreAreaManager(mask=raw).read_ignore_areas() + if not isinstance(masks, list): + raise MaskError("Mask input did not normalize to a list") + for entry in masks: + if not isinstance(entry, dict): + raise MaskError(f"Mask entry is not an object: {entry!r}") + if "type" not in entry: + raise MaskError(f"Mask entry has no 'type': {entry!r}") + return masks + + +def load_mask_file(path: Path) -> List[dict]: + path = Path(path) + if not path.is_file(): + raise FileNotFoundError(f"Mask file not found: {path}") + masks = IgnoreAreaManager(ignore_area_file=str(path)).read_ignore_areas() + return masks if isinstance(masks, list) else [masks] + + +def _ordered(entry: dict) -> dict: + ordered = {key: entry[key] for key in KEY_ORDER if key in entry} + ordered.update({key: value for key, value in entry.items() if key not in ordered}) + return ordered + + +def dumps_masks(masks: List[dict]) -> str: + return json.dumps([_ordered(entry) for entry in masks], indent=4) + "\n" + + +def save_mask_file(path: Path, masks: List[dict]) -> str: + """Atomically write masks (temp + rename), keeping a ``.bak`` of the + previous content. Returns the SHA-256 of the new file.""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + content = dumps_masks(masks) + if path.exists(): + shutil.copyfile(path, path.with_suffix(path.suffix + ".bak")) + temp = path.with_suffix(path.suffix + ".tmp") + with open(temp, "w", encoding="utf-8") as file: + file.write(content) + file.flush() + os.fsync(file.fileno()) + os.replace(temp, path) + return hashlib.sha256(content.encode("utf-8")).hexdigest() diff --git a/doctest_dashboard/models/__init__.py b/doctest_dashboard/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/doctest_dashboard/models/sidecar.py b/doctest_dashboard/models/sidecar.py new file mode 100644 index 0000000..732fdc9 --- /dev/null +++ b/doctest_dashboard/models/sidecar.py @@ -0,0 +1,80 @@ +"""Pydantic models for the comparison sidecar, schema v1. + +This is the data contract between the core library's ``ResultWriter`` and +the dashboard. The contract test in ``tests/test_sidecar_contract.py`` runs +the real library and validates its output against these models — change +either side only together. +""" + +from typing import Any, Dict, List, Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field + +SUPPORTED_SCHEMA_VERSION = 1 + + +class DiffRegion(BaseModel): + model_config = ConfigDict(extra="forbid") + + x: int + y: int + width: int + height: int + + +class DocumentRef(BaseModel): + model_config = ConfigDict(extra="allow") + + path: str + pages: Optional[int] = None + dpi: Optional[int] = None + + +class PageResult(BaseModel): + model_config = ConfigDict(extra="allow") + + page: Union[int, str] + status: Literal["PASS", "FAIL"] + score: Optional[float] = None + threshold: Optional[float] = None + diff_regions: List[DiffRegion] = Field(default_factory=list) + images: Dict[str, str] = Field(default_factory=dict) + notes: List[str] = Field(default_factory=list) + resolved_masks: List[DiffRegion] = Field(default_factory=list) + + +class Timing(BaseModel): + model_config = ConfigDict(extra="allow") + + started: str + elapsed_ms: int + + +class ComparisonResult(BaseModel): + """One comparison sidecar (``doctest_results/{uuid}.json``).""" + + model_config = ConfigDict(extra="allow") + + schema_version: int + keyword: str + library: str + status: Literal["PASS", "FAIL"] + reference: DocumentRef + candidate: DocumentRef + settings: Dict[str, Any] = Field(default_factory=dict) + masks: Dict[str, Any] = Field(default_factory=dict) + pages: List[PageResult] = Field(default_factory=list) + llm: Optional[Dict[str, Any]] = None + notes: List[str] = Field(default_factory=list) + timing: Timing + + +def parse_sidecar(data: Dict[str, Any]) -> ComparisonResult: + """Validate sidecar data, rejecting unknown schema majors explicitly.""" + version = data.get("schema_version") + if version != SUPPORTED_SCHEMA_VERSION: + raise ValueError( + f"Unsupported sidecar schema_version {version!r}; " + f"this dashboard supports version {SUPPORTED_SCHEMA_VERSION}" + ) + return ComparisonResult.model_validate(data) diff --git a/doctest_dashboard/review.py b/doctest_dashboard/review.py new file mode 100644 index 0000000..3f1cc30 --- /dev/null +++ b/doctest_dashboard/review.py @@ -0,0 +1,189 @@ +"""Review actions: accept (baseline promotion), reject, bug-data bundles. + +Accept copies the candidate source file over the reference file — exactly +the layout a ``REFERENCE_RUN`` produces — and records SHA-256 of the file +before and after in the audit table. All writes are confined to the +configured roots. +""" + +import hashlib +import io +import json +import shutil +import zipfile +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Optional + +from doctest_dashboard.config import AppConfig +from doctest_dashboard.db import Database + +DOCUMENT_SUFFIXES = {".pdf", ".ps", ".pcl"} + + +class ReviewError(Exception): + """Review action failed; ``status_code`` maps to the HTTP layer.""" + + def __init__(self, message: str, status_code: int = 400, **payload): + super().__init__(message) + self.status_code = status_code + self.payload = payload + + +def _sha256(path: Path) -> Optional[str]: + try: + digest = hashlib.sha256() + with open(path, "rb") as file: + for chunk in iter(lambda: file.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + except OSError: + return None + + +def _get_comparison(database: Database, comparison_id: int) -> Dict[str, Any]: + comparison = database.query_one( + "SELECT * FROM comparisons WHERE id = ?", (comparison_id,)) + if not comparison: + raise ReviewError("Comparison not found", status_code=404) + return comparison + + +def _paths_for_promotion(config: AppConfig, comparison: Dict[str, Any]) -> tuple: + reference = comparison["reference_path"] + candidate = comparison["candidate_path"] + if not reference or not candidate: + raise ReviewError( + "Comparison has no recorded reference/candidate paths " + "(degraded record — re-run with result_json enabled)", + status_code=409) + reference_path = Path(reference) + candidate_path = Path(candidate) + if not candidate_path.is_file(): + raise ReviewError(f"Candidate file no longer exists: {candidate}", status_code=409) + if not config.is_within_roots(candidate_path): + raise ReviewError("Candidate path outside configured roots", status_code=403) + # The reference may not exist yet; validate its parent directory instead. + probe = reference_path if reference_path.exists() else reference_path.parent + if not config.is_within_roots(probe): + raise ReviewError("Reference path outside configured roots", status_code=403) + return reference_path, candidate_path + + +def is_document_artifact(path: Optional[str]) -> bool: + return bool(path) and Path(path).suffix.lower() in DOCUMENT_SUFFIXES + + +def accept_comparison( + database: Database, + config: AppConfig, + comparison_id: int, + actor: Optional[str] = None, + reason: Optional[str] = None, +) -> Dict[str, Any]: + """Promote the candidate document/image to be the new reference.""" + comparison = _get_comparison(database, comparison_id) + reference_path, candidate_path = _paths_for_promotion(config, comparison) + prev_hash = _sha256(reference_path) + reference_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(candidate_path, reference_path) + new_hash = _sha256(reference_path) + database.insert_decision( + action="accept", actor=actor, reason=reason, + comparison_id=comparison_id, prev_sha256=prev_hash, new_sha256=new_hash) + database.set_comparison_state(comparison_id, "accepted") + for page in database.query( + "SELECT id FROM pages WHERE comparison_id = ? AND review_state = 'unresolved'", + (comparison_id,)): + database.set_page_state(page["id"], "accepted") + return { + "comparison_id": comparison_id, + "reference_path": str(reference_path), + "prev_sha256": prev_hash, + "new_sha256": new_hash, + "review_state": "accepted", + } + + +def accept_page( + database: Database, + config: AppConfig, + page_id: int, + actor: Optional[str] = None, + reason: Optional[str] = None, +) -> Dict[str, Any]: + """Accept a single page. + + For single-image artifacts this is a file copy. For multi-page document + artifacts (PDF/PS/PCL) page-level promotion is impossible at file level + — respond with the documented alternatives instead of pretending. + """ + page = database.query_one("SELECT * FROM pages WHERE id = ?", (page_id,)) + if not page: + raise ReviewError("Page not found", status_code=404) + comparison = _get_comparison(database, page["comparison_id"]) + page_count = len(database.query( + "SELECT id FROM pages WHERE comparison_id = ?", (comparison["id"],))) + if is_document_artifact(comparison["candidate_path"]) and page_count > 1: + raise ReviewError( + "Page-level accept is not possible for multi-page documents: the " + "reference is a single file. Accept the whole document, or mask " + "the intended change instead.", + status_code=409, + alternatives=["accept_document", "create_mask"]) + result = accept_comparison(database, config, comparison["id"], actor, reason) + database.set_page_state(page_id, "accepted") + result["page_id"] = page_id + return result + + +def reject_comparison( + database: Database, + config: AppConfig, + comparison_id: int, + actor: Optional[str] = None, + reason: Optional[str] = None, +) -> Dict[str, Any]: + comparison = _get_comparison(database, comparison_id) + database.insert_decision( + action="reject", actor=actor, reason=reason, comparison_id=comparison_id) + database.set_comparison_state(comparison_id, "rejected") + for page in database.query( + "SELECT id FROM pages WHERE comparison_id = ? AND review_state = 'unresolved'", + (comparison_id,)): + database.set_page_state(page["id"], "rejected") + return {"comparison_id": comparison_id, "review_state": "rejected"} + + +def build_bug_bundle(database: Database, config: AppConfig, comparison_id: int) -> bytes: + """ZIP with reference, candidate, diff images, sidecar, and metadata.""" + comparison = _get_comparison(database, comparison_id) + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as bundle: + for label in ("reference_path", "candidate_path"): + value = comparison[label] + if value and Path(value).is_file() and config.is_within_roots(Path(value)): + bundle.write(value, f"{label.replace('_path', '')}/{Path(value).name}") + if comparison["sidecar_path"] and Path(comparison["sidecar_path"]).is_file(): + bundle.write(comparison["sidecar_path"], "comparison_result.json") + for page in database.query( + "SELECT * FROM pages WHERE comparison_id = ? AND status = 'FAIL'", + (comparison_id,)): + images = json.loads(page["images_json"]) if page["images_json"] else {} + for kind, token in images.items(): + path = database.resolve_asset(token) + if path and Path(path).is_file() and config.is_within_roots(Path(path)): + bundle.write(path, f"pages/page_{page['page_no']}_{kind}{Path(path).suffix}") + decisions = database.query( + "SELECT * FROM decisions WHERE comparison_id = ? ORDER BY id", (comparison_id,)) + metadata = { + "comparison_id": comparison_id, + "identity": comparison["identity"], + "keyword": comparison["keyword"], + "status": comparison["status"], + "review_state": comparison["review_state"], + "exported_at": datetime.now().isoformat(timespec="seconds"), + "decisions": decisions, + } + bundle.writestr("metadata.json", json.dumps(metadata, indent=2, default=str)) + return buffer.getvalue() diff --git a/doctest_dashboard/server/__init__.py b/doctest_dashboard/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/doctest_dashboard/server/app.py b/doctest_dashboard/server/app.py new file mode 100644 index 0000000..a9bbb56 --- /dev/null +++ b/doctest_dashboard/server/app.py @@ -0,0 +1,547 @@ +"""FastAPI application factory.""" + +import json +import logging +import mimetypes +from pathlib import Path +from typing import List, Optional + +from fastapi import Depends, FastAPI, HTTPException, Request, UploadFile +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +from doctest_dashboard import __version__ +from doctest_dashboard.config import AppConfig +from doctest_dashboard.db import Database +from doctest_dashboard.ingest import ingest_output_xml +from doctest_dashboard.review import ( + ReviewError, + accept_comparison, + accept_page, + build_bug_bundle, + reject_comparison, +) + +LOG = logging.getLogger(__name__) + +# Capabilities of this backend build, advertised via /api/health. The UI +# checks them at load time and tells the user to restart the server when it +# is older than the served frontend (static files are re-read from disk, the +# Python process is not). +API_FEATURES = [ + "ingest", "review", "masks", "engine", "recompare", "browse", "upload", + "upload-results", +] + +STATIC_DIR = Path(__file__).parent.parent / "static" +# Editable/dev installs serve the built frontend from the source tree instead +DEV_DIST_DIR = Path(__file__).parents[2] / "frontend" / "dist" + + +class IngestRequest(BaseModel): + output_xml: str + + +class DecisionRequest(BaseModel): + actor: Optional[str] = None + reason: Optional[str] = None + + +class MaskSaveRequest(BaseModel): + file: str + masks: object + + +class MaskPreviewRequest(BaseModel): + file: str + page: int = 1 + masks: object = None + dpi: Optional[int] = None + ocr_engine: Optional[str] = None + force_ocr: bool = False + + +class RecompareRequest(BaseModel): + comparison_id: int + masks: object = None + settings: Optional[dict] = None + + +class RecompareBatchRequest(BaseModel): + masks: object = None + comparison_ids: Optional[list] = None + masks_file: Optional[str] = None + settings: Optional[dict] = None + + +def create_app(config: AppConfig, database: Optional[Database] = None) -> FastAPI: + from doctest_dashboard.engine import EngineService + from doctest_dashboard.ingest import asset_token + + config.data_dir.mkdir(parents=True, exist_ok=True) + db = database or Database(config.db_path) + engine = EngineService(scratch_root=config.data_dir / "scratch") + config.add_root(config.data_dir / "scratch") + uploads_dir = config.data_dir / "uploads" + uploads_dir.mkdir(parents=True, exist_ok=True) + config.add_root(uploads_dir) + + app = FastAPI(title="doctest-dashboard", version=__version__) + app.state.config = config + app.state.db = db + app.state.engine = engine + + def require_token(request: Request) -> None: + if config.token is None: + return + header = request.headers.get("authorization", "") + if header != f"Bearer {config.token}": + raise HTTPException(status_code=401, detail="Missing or invalid token") + + api = Depends(require_token) + + @app.get("/api/health", dependencies=[api]) + def health(): + return {"status": "ok", "version": __version__, "features": API_FEATURES} + + @app.post("/api/ingest", dependencies=[api]) + def ingest(request: IngestRequest): + try: + summary = ingest_output_xml(db, config, request.output_xml) + except FileNotFoundError as error: + raise HTTPException(status_code=404, detail=str(error)) + return summary + + @app.get("/api/runs", dependencies=[api]) + def list_runs(): + runs = db.query("SELECT * FROM runs ORDER BY imported_at DESC") + for run in runs: + counts = db.query_one( + "SELECT COUNT(*) AS total, " + "SUM(CASE WHEN c.review_state = 'unresolved' THEN 1 ELSE 0 END) AS unresolved, " + "SUM(CASE WHEN c.status = 'FAIL' THEN 1 ELSE 0 END) AS failed " + "FROM comparisons c JOIN tests t ON c.test_id = t.id WHERE t.run_id = ?", + (run["id"],)) + run["comparisons"] = counts["total"] or 0 + run["unresolved"] = counts["unresolved"] or 0 + run["failed"] = counts["failed"] or 0 + return runs + + @app.get("/api/runs/{run_id}/tests", dependencies=[api]) + def list_tests(run_id: int, status: Optional[str] = None, review_state: Optional[str] = None): + rows = db.query( + "SELECT t.id AS test_id, t.suite, t.name, t.status AS test_status, " + "c.id AS comparison_id, c.keyword, c.library, c.status, c.degraded, " + "c.review_state, c.identity " + "FROM tests t JOIN comparisons c ON c.test_id = t.id " + "WHERE t.run_id = ? ORDER BY t.id, c.id", + (run_id,)) + if status: + rows = [row for row in rows if row["status"] == status.upper()] + if review_state: + rows = [row for row in rows if row["review_state"] == review_state] + for row in rows: + thumb = db.query_one( + "SELECT images_json FROM pages WHERE comparison_id = ? AND status = 'FAIL' " + "ORDER BY page_no LIMIT 1", (row["comparison_id"],)) + images = json.loads(thumb["images_json"]) if thumb and thumb["images_json"] else {} + row["thumbnail"] = images.get("diff") or images.get("candidate") + return rows + + @app.get("/api/comparisons/{comparison_id}", dependencies=[api]) + def get_comparison(comparison_id: int): + comparison = db.query_one("SELECT * FROM comparisons WHERE id = ?", (comparison_id,)) + if not comparison: + raise HTTPException(status_code=404, detail="Comparison not found") + comparison["sidecar_json"] = ( + json.loads(comparison["sidecar_json"]) if comparison["sidecar_json"] else None + ) + comparison["images"] = ( + json.loads(comparison["images_json"]) if comparison["images_json"] else [] + ) + pages = db.query( + "SELECT * FROM pages WHERE comparison_id = ? ORDER BY page_no", (comparison_id,)) + for page in pages: + page["regions"] = json.loads(page["regions_json"]) if page["regions_json"] else [] + page["images"] = json.loads(page["images_json"]) if page["images_json"] else {} + del page["regions_json"], page["images_json"] + comparison["pages"] = pages + return comparison + + def _review(action, *args, **kwargs): + try: + return action(db, config, *args, **kwargs) + except ReviewError as error: + detail = {"message": str(error), **error.payload} + raise HTTPException(status_code=error.status_code, detail=detail) + + @app.post("/api/comparisons/{comparison_id}/accept", dependencies=[api]) + def comparison_accept(comparison_id: int, decision: DecisionRequest): + return _review(accept_comparison, comparison_id, + actor=decision.actor, reason=decision.reason) + + @app.post("/api/pages/{page_id}/accept", dependencies=[api]) + def page_accept(page_id: int, decision: DecisionRequest): + return _review(accept_page, page_id, + actor=decision.actor, reason=decision.reason) + + @app.post("/api/comparisons/{comparison_id}/reject", dependencies=[api]) + def comparison_reject(comparison_id: int, decision: DecisionRequest): + return _review(reject_comparison, comparison_id, + actor=decision.actor, reason=decision.reason) + + @app.get("/api/comparisons/{comparison_id}/bugdata", dependencies=[api]) + def comparison_bugdata(comparison_id: int): + from fastapi.responses import Response + + data = _review(build_bug_bundle, comparison_id) + return Response( + content=data, media_type="application/zip", + headers={"Content-Disposition": + f'attachment; filename="bugdata_comparison_{comparison_id}.zip"'}) + + @app.get("/api/comparisons/{comparison_id}/decisions", dependencies=[api]) + def comparison_decisions(comparison_id: int): + return db.query( + "SELECT * FROM decisions WHERE comparison_id = ? ORDER BY id", (comparison_id,)) + + # -- uploads --------------------------------------------------------------- + + UPLOAD_EXTENSIONS = { + ".png", ".jpg", ".jpeg", ".pdf", ".ps", ".pcl", + ".tif", ".tiff", ".bmp", ".gif", ".json", + } + MAX_UPLOAD_BYTES = 100 * 1024 * 1024 + + @app.post("/api/upload", dependencies=[api]) + async def upload(file: UploadFile): + """Store a file from the user's machine in the dashboard workspace. + + The workspace (``{data_dir}/uploads``) is a configured root, so the + stored file is immediately usable in the mask editor and browsable + in the file picker — no --root setup required. + """ + import re + import uuid as uuid_module + + original = Path(file.filename or "upload") + if original.suffix.lower() not in UPLOAD_EXTENSIONS: + raise HTTPException( + status_code=415, + detail=f"Unsupported file type '{original.suffix}'. " + f"Allowed: {', '.join(sorted(UPLOAD_EXTENSIONS))}") + safe_stem = re.sub(r"[^A-Za-z0-9._-]+", "_", original.stem)[:80] or "upload" + target_dir = uploads_dir / uuid_module.uuid4().hex[:8] + target_dir.mkdir(parents=True, exist_ok=True) + target = target_dir / f"{safe_stem}{original.suffix.lower()}" + size = 0 + with open(target, "wb") as out: + while chunk := await file.read(1 << 20): + size += len(chunk) + if size > MAX_UPLOAD_BYTES: + out.close() + target.unlink(missing_ok=True) + raise HTTPException( + status_code=413, + detail=f"File exceeds the {MAX_UPLOAD_BYTES // (1024 * 1024)} MB upload limit") + out.write(chunk) + return {"path": str(target), "name": target.name, "size": size} + + RESULTS_UPLOAD_EXTENSIONS = UPLOAD_EXTENSIONS | {".xml", ".txt", ".log"} + MAX_RESULTS_UPLOAD_BYTES = 500 * 1024 * 1024 + + @app.post("/api/upload-results", dependencies=[api]) + async def upload_results(files: List[UploadFile]): + """Ingest a Robot Framework results folder uploaded from the browser. + + The folder picker (webkitdirectory) sends every file with its path + relative to the chosen folder; the tree is stored in the workspace + with that structure intact — which is what output.xml, screenshot + references and sidecars rely on — then ingested in place. + """ + import uuid as uuid_module + from pathlib import PurePosixPath + + if not files: + raise HTTPException(status_code=400, detail="No files in upload") + target_root = uploads_dir / f"run_{uuid_module.uuid4().hex[:8]}" + stored, skipped, total = 0, 0, 0 + output_xmls = [] + for file in files: + relative = PurePosixPath((file.filename or "").replace("\\", "/")) + if ( + not relative.parts + or relative.is_absolute() + or ".." in relative.parts + or relative.suffix.lower() not in RESULTS_UPLOAD_EXTENSIONS + ): + skipped += 1 + continue + target = target_root.joinpath(*relative.parts) + target.parent.mkdir(parents=True, exist_ok=True) + with open(target, "wb") as out: + while chunk := await file.read(1 << 20): + total += len(chunk) + if total > MAX_RESULTS_UPLOAD_BYTES: + raise HTTPException( + status_code=413, + detail=f"Upload exceeds the " + f"{MAX_RESULTS_UPLOAD_BYTES // (1024 * 1024)} MB limit") + out.write(chunk) + stored += 1 + if relative.name == "output.xml": + output_xmls.append(target) + if not output_xmls: + raise HTTPException( + status_code=422, + detail="The selected folder contains no output.xml — pick the " + "Robot Framework output directory of a run") + summaries = [ + ingest_output_xml(db, config, output_xml) + for output_xml in sorted(output_xmls, key=lambda p: len(p.parts)) + ] + return {"stored": stored, "skipped": skipped, "runs": summaries} + + # -- file browsing -------------------------------------------------------- + + @app.get("/api/browse", dependencies=[api]) + def browse(path: Optional[str] = None): + """List configured roots, or the contents of a directory under them. + + Powers the file picker in the UI; every path is validated against + the configured roots like any other filesystem access. + """ + if not path: + # Hide the internal recompare scratch area; the uploads workspace + # stays visible so uploaded files can be browsed again. + visible = [root for root in config.roots + if root != config.data_dir / "scratch"] + return {"roots": [{"name": root.name or str(root), "path": str(root)} + for root in visible]} + directory = Path(path) + if not config.is_within_roots(directory): + raise HTTPException(status_code=403, detail="Path outside configured roots") + if not directory.is_dir(): + raise HTTPException(status_code=404, detail="Not a directory") + entries = [] + try: + children = sorted( + directory.iterdir(), + key=lambda child: (not child.is_dir(), child.name.lower())) + except OSError as error: + raise HTTPException(status_code=400, detail=str(error)) + for child in children: + if child.name.startswith("."): + continue + try: + is_dir = child.is_dir() + entries.append({ + "name": child.name, + "path": str(child), + "type": "dir" if is_dir else "file", + "size": None if is_dir else child.stat().st_size, + }) + except OSError: + continue + parent = directory.parent + return { + "path": str(directory), + "parent": str(parent) if config.is_within_roots(parent) else None, + "entries": entries, + } + + # -- masks ---------------------------------------------------------------- + + @app.get("/api/masks", dependencies=[api]) + def get_masks(file: str): + from doctest_dashboard.masks import MaskError, load_mask_file, normalize_masks + + path = Path(file) + if not config.is_within_roots(path): + raise HTTPException(status_code=403, detail="Mask file outside configured roots") + try: + return {"file": str(path), "masks": normalize_masks(load_mask_file(path))} + except FileNotFoundError as error: + raise HTTPException(status_code=404, detail=str(error)) + except MaskError as error: + raise HTTPException(status_code=422, detail=str(error)) + + @app.put("/api/masks", dependencies=[api]) + def put_masks(request: MaskSaveRequest): + from doctest_dashboard.masks import ( + MaskError, + normalize_masks, + save_mask_file, + validate_pattern_masks, + ) + + path = Path(request.file) + probe = path if path.exists() else path.parent + if not config.is_within_roots(probe): + raise HTTPException(status_code=403, detail="Mask file outside configured roots") + try: + masks = normalize_masks(request.masks) + validate_pattern_masks(masks) + except MaskError as error: + raise HTTPException(status_code=422, detail=str(error)) + file_hash = save_mask_file(path, masks) + db.execute( + "INSERT INTO mask_files (path, last_seen_hash, updated_at) VALUES (?, ?, datetime('now')) " + "ON CONFLICT(path) DO UPDATE SET last_seen_hash = excluded.last_seen_hash, " + "updated_at = excluded.updated_at", + (str(path), file_hash)) + return {"file": str(path), "masks": masks, "sha256": file_hash} + + # -- embedded engine ------------------------------------------------------ + + @app.get("/api/capabilities", dependencies=[api]) + def capabilities(): + return {"capabilities": engine.capabilities, "ocr_available": engine.ocr_available} + + @app.post("/api/mask-preview", dependencies=[api]) + def mask_preview(request: MaskPreviewRequest): + from doctest_dashboard.masks import MaskError, validate_pattern_masks + + path = Path(request.file) + if not config.is_within_roots(path): + raise HTTPException(status_code=403, detail="File outside configured roots") + try: + validate_pattern_masks(_as_mask_list(request.masks)) + except MaskError as error: + raise HTTPException(status_code=422, detail=str(error)) + mask_types = {m.get("type") for m in _as_mask_list(request.masks)} + pattern_types = {"pattern", "line_pattern", "word_pattern"} + if (mask_types & pattern_types) and path.suffix.lower() not in ( + ".pdf",) and not engine.ocr_available: + raise HTTPException( + status_code=409, + detail="Pattern mask preview requires OCR (tesseract), " + "which is not available in this environment") + try: + return engine.mask_preview( + str(path), request.page, request.masks, + dpi=request.dpi, ocr_engine=request.ocr_engine, + force_ocr=request.force_ocr) + except (ValueError, FileNotFoundError) as error: + raise HTTPException(status_code=400, detail=str(error)) + + @app.get("/api/page-image", dependencies=[api]) + def page_image(file: str, page: int = 1, dpi: Optional[int] = None): + path = Path(file) + if not config.is_within_roots(path): + raise HTTPException(status_code=403, detail="File outside configured roots") + try: + info = engine.page_image(str(path), page, dpi) + except (ValueError, FileNotFoundError) as error: + raise HTTPException(status_code=400, detail=str(error)) + token = asset_token(info["png_path"]) + db.register_asset(token, info["png_path"]) + return { + "page": info["page"], + "page_count": info["page_count"], + "dpi": info["dpi"], + "image_size": info["image_size"], + "image": token, + } + + def _as_mask_list(masks) -> list: + if masks is None: + return [] + if isinstance(masks, dict): + return [masks] + if isinstance(masks, list): + return masks + return [] + + def _run_recompare(comparison: dict, masks, settings) -> dict: + from doctest_dashboard.masks import MaskError, validate_pattern_masks + + try: + validate_pattern_masks(_as_mask_list(masks)) + except MaskError as error: + raise HTTPException(status_code=422, detail=str(error)) + reference = comparison["reference_path"] + candidate = comparison["candidate_path"] + if not reference or not candidate: + raise HTTPException( + status_code=409, + detail="Comparison has no recorded paths (degraded record)") + for path in (reference, candidate): + if not config.is_within_roots(Path(path)): + raise HTTPException(status_code=403, detail="Path outside configured roots") + try: + outcome = engine.recompare(reference, candidate, masks, settings) + except TimeoutError as error: + raise HTTPException(status_code=504, detail=str(error)) + sidecar = outcome["result"] + scratch = Path(outcome["scratch_dir"]) + for page in sidecar.get("pages", []): + tokens = {} + for kind, rel in page.get("images", {}).items(): + absolute = (scratch / rel).resolve() + token = asset_token(str(absolute)) + db.register_asset(token, str(absolute)) + tokens[kind] = token + page["images"] = tokens + return { + "comparison_id": comparison["id"], + "status": sidecar["status"], + "pages": sidecar.get("pages", []), + "cached": outcome.get("cached", False), + } + + @app.post("/api/recompare", dependencies=[api]) + def recompare(request: RecompareRequest): + comparison = db.query_one( + "SELECT * FROM comparisons WHERE id = ?", (request.comparison_id,)) + if not comparison: + raise HTTPException(status_code=404, detail="Comparison not found") + return _run_recompare(comparison, request.masks, request.settings) + + @app.post("/api/recompare-batch", dependencies=[api]) + def recompare_batch(request: RecompareBatchRequest): + if request.comparison_ids: + comparisons = [ + row for cid in request.comparison_ids + if (row := db.query_one("SELECT * FROM comparisons WHERE id = ?", (cid,))) + ] + elif request.masks_file: + comparisons = [ + row for row in db.query( + "SELECT * FROM comparisons WHERE sidecar_json IS NOT NULL") + if json.loads(row["sidecar_json"]).get("masks", {}).get("placeholder_file") + == request.masks_file + ] + else: + raise HTTPException( + status_code=400, detail="Provide comparison_ids or masks_file") + results = [] + for comparison in comparisons: + try: + results.append(_run_recompare(comparison, request.masks, request.settings)) + except HTTPException as error: + results.append({ + "comparison_id": comparison["id"], + "status": "ERROR", + "error": error.detail, + }) + return {"results": results} + + @app.get("/api/assets/{token}", dependencies=[api]) + def get_asset(token: str): + path = db.resolve_asset(token) + if not path: + raise HTTPException(status_code=404, detail="Unknown asset") + if not config.is_within_roots(Path(path)): + raise HTTPException(status_code=403, detail="Asset outside configured roots") + media_type = mimetypes.guess_type(path)[0] or "application/octet-stream" + return FileResponse( + path, media_type=media_type, + headers={"Cache-Control": "private, max-age=86400"}) + + static_root = STATIC_DIR if STATIC_DIR.is_dir() else DEV_DIST_DIR + if static_root.is_dir(): + app.mount("/", StaticFiles(directory=str(static_root), html=True), name="static") + + return app diff --git a/e2e/conftest.py b/e2e/conftest.py new file mode 100644 index 0000000..a70e8d6 --- /dev/null +++ b/e2e/conftest.py @@ -0,0 +1,59 @@ +"""End-to-end fixtures: a real served dashboard + real robot runs. + +The server is `doctest-dashboard serve` (uvicorn subprocess) with the built +frontend; test data is generated by executing the actual DocTest library +under Robot Framework into a session workspace that is the server's +configured root. Nothing is mocked. +""" + +import socket +import subprocess +import sys +import time +from pathlib import Path + +import httpx +import pytest + + +def _free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +@pytest.fixture(scope="session") +def workspace(tmp_path_factory) -> Path: + return tmp_path_factory.mktemp("e2e_workspace") + + +@pytest.fixture(scope="session") +def server_url(workspace): + port = _free_port() + process = subprocess.Popen( + [sys.executable, "-m", "doctest_dashboard.cli", + "--data-dir", str(workspace / ".dashboard"), + "serve", "--port", str(port), "--root", str(workspace)], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + ) + url = f"http://127.0.0.1:{port}" + try: + for _ in range(100): + try: + if httpx.get(f"{url}/api/health", timeout=1).status_code == 200: + break + except httpx.HTTPError: + time.sleep(0.2) + else: + output = process.stdout.read().decode(errors="replace") if process.stdout else "" + raise RuntimeError(f"Server did not start:\n{output}") + yield url + finally: + process.terminate() + process.wait(timeout=10) + + +@pytest.fixture +def api(server_url): + with httpx.Client(base_url=server_url, timeout=180) as client: + yield client diff --git a/e2e/e2e_helpers.py b/e2e/e2e_helpers.py new file mode 100644 index 0000000..4fa9437 --- /dev/null +++ b/e2e/e2e_helpers.py @@ -0,0 +1,33 @@ +"""Shared e2e helpers: suite template and run factory.""" + +import shutil +import sys +from pathlib import Path + +E2E_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(E2E_DIR.parent / "utest" / "dashboard")) + +from helpers import CAND_IMAGE, REF_IMAGE, run_robot_suite # noqa: E402, F401 + +SUITE_TEMPLATE = """ +*** Settings *** +Library DocTest.VisualTest result_json=true take_screenshots=false + +*** Test Cases *** +Comparison To Review + Run Keyword And Expect Error The compared images are different. + ... Compare Images {reference} {candidate} +""" + + +def make_image_run(base_dir: Path): + """Reference/candidate copies + a real robot run, all inside base_dir.""" + artifacts = base_dir / "artifacts" + artifacts.mkdir(parents=True, exist_ok=True) + reference = artifacts / "reference.png" + candidate = artifacts / "candidate.png" + shutil.copyfile(REF_IMAGE, reference) + shutil.copyfile(CAND_IMAGE, candidate) + suite = SUITE_TEMPLATE.format(reference=reference, candidate=candidate) + output_xml = run_robot_suite(suite, base_dir / "run") + return output_xml, reference, candidate diff --git a/e2e/test_journeys.py b/e2e/test_journeys.py new file mode 100644 index 0000000..c565eb5 --- /dev/null +++ b/e2e/test_journeys.py @@ -0,0 +1,300 @@ +"""Full user journeys against the served dashboard (Playwright, no mocks). + +J1 ingest → browse → diff viewer → accept → file changed on disk + audit +J2 reject with reason → bug-data ZIP with expected contents +J3 diff region → mask editor → recompare PASS → save → robot re-run passes +J4 shorthand import → edit → export round trip +""" + +import io +import json +import shutil +import zipfile + +from e2e_helpers import REF_IMAGE, SUITE_TEMPLATE, make_image_run +from playwright.sync_api import Page, expect + + +def ingest_via_ui(page: Page, server_url: str, output_xml) -> None: + page.goto(f"{server_url}/#/") + page.fill('[data-testid="ingest-path"]', str(output_xml)) + page.click('[data-testid="ingest-button"]') + expect(page.get_by_test_id("ingest-message")).to_contain_text("Ingested run") + + +def open_first_failing_comparison(page: Page) -> None: + page.locator('[data-testid="run-list"] tr.clickable').first.click() + page.select_option('[data-testid="status-filter"]', "fail") + page.locator('[data-testid="test-grid"] tr.clickable').first.click() + expect(page.get_by_test_id("comparison-status")).to_have_text("FAIL") + + +def test_j1_ingest_review_accept(page: Page, server_url, workspace, api): + output_xml, reference, candidate = make_image_run(workspace / "j1") + before = reference.read_bytes() + + ingest_via_ui(page, server_url, output_xml) + open_first_failing_comparison(page) + + # viewer modes: overlay default, keyboard switch to side-by-side and swipe + expect(page.get_by_test_id("viewer-overlay")).to_be_visible() + page.keyboard.press("1") + expect(page.get_by_test_id("viewer-side-by-side")).to_be_visible() + page.keyboard.press("4") + expect(page.get_by_test_id("viewer-swipe")).to_be_visible() + + # diff-region navigation + page.click('[data-testid="next-region"]') + expect(page.get_by_test_id("region-indicator")).to_contain_text("region 1/") + + # accept the page → baseline promoted on disk + page.fill('[data-testid="decision-reason"]', "intended change") + page.click('[data-testid="accept-page"]') + expect(page.get_by_test_id("action-message")).to_contain_text("accepted", ignore_case=True) + expect(page.get_by_test_id("review-state")).to_have_text("accepted") + + assert reference.read_bytes() == candidate.read_bytes() != before + + # audit row exists with hashes + comparison_id = int(page.url.rsplit("/", 1)[-1]) + decisions = api.get(f"/api/comparisons/{comparison_id}/decisions").json() + assert decisions[0]["action"] == "accept" + assert decisions[0]["reason"] == "intended change" + assert decisions[0]["prev_sha256"] and decisions[0]["new_sha256"] + + +def test_j2_reject_with_bug_bundle(page: Page, server_url, workspace, api): + output_xml, reference, candidate = make_image_run(workspace / "j2") + ingest_via_ui(page, server_url, output_xml) + open_first_failing_comparison(page) + + page.fill('[data-testid="decision-reason"]', "real rendering bug") + page.click('[data-testid="reject-comparison"]') + expect(page.get_by_test_id("review-state")).to_have_text("rejected") + + comparison_id = int(page.url.rsplit("/", 1)[-1]) + response = api.get(f"/api/comparisons/{comparison_id}/bugdata") + assert response.status_code == 200 + archive = zipfile.ZipFile(io.BytesIO(response.content)) + names = archive.namelist() + assert "reference/reference.png" in names + assert "candidate/candidate.png" in names + assert "comparison_result.json" in names + assert any("diff" in name for name in names if name.startswith("pages/")) + metadata = json.loads(archive.read("metadata.json")) + assert metadata["decisions"][0]["reason"] == "real rendering bug" + + # reference untouched by reject + assert reference.read_bytes() != candidate.read_bytes() + + +def test_j3_mask_from_diff_recompare_save_rerun(page: Page, server_url, workspace, api): + output_xml, reference, candidate = make_image_run(workspace / "j3") + ingest_via_ui(page, server_url, output_xml) + open_first_failing_comparison(page) + + # seed the editor from every diff region: step through them, take the first + page.click('[data-testid="next-region"]') + with page.expect_navigation(): + page.click('[data-testid="add-mask-from-region"]') + expect(page.get_by_test_id("editor-dpi")).to_be_visible() + expect(page.get_by_test_id("mask-row-0")).to_contain_text("coordinates") + + # the seeded region may not cover all diffs — import masks covering all + # regions like a user drawing the remaining ones + comparison_id = int( + dict(p.split("=") for p in page.url.split("?")[1].split("&"))["comparison"]) + detail = api.get(f"/api/comparisons/{comparison_id}").json() + regions = detail["pages"][0]["regions"] + extra = [{ + "page": "all", "type": "coordinates", + "x": max(0, r["x"] - 5), "y": max(0, r["y"] - 5), + "width": r["width"] + 10, "height": r["height"] + 10, "unit": "px", + } for r in regions[1:]] + if extra: + page.fill('[data-testid="import-text"]', json.dumps(extra)) + page.click('[data-testid="import-button"]') + + # recompare with the masks: FAIL flips to PASS + page.click('[data-testid="recompare"]') + expect(page.get_by_test_id("recompare-result")).to_have_text("PASS", timeout=120_000) + + # save to masks.json + masks_file = workspace / "j3" / "artifacts" / "masks.json" + page.fill('[data-testid="mask-file"]', str(masks_file)) + page.click('[data-testid="save-masks"]') + expect(page.get_by_test_id("editor-message")).to_contain_text("Saved") + + # the saved file parses through the library schema... + from DocTest.IgnoreAreaManager import IgnoreAreaManager + parsed = IgnoreAreaManager(ignore_area_file=str(masks_file)).read_ignore_areas() + assert parsed and all(entry["type"] == "coordinates" for entry in parsed) + + # ...and an actual robot re-run with that mask passes + sys_path_suite = f""" +*** Settings *** +Library DocTest.VisualTest take_screenshots=false + +*** Test Cases *** +Masked Comparison Passes + Compare Images {reference} {candidate} placeholder_file={masks_file} +""" + from e2e_helpers import run_robot_suite + run_robot_suite(sys_path_suite, workspace / "j3" / "rerun") # asserts rc == 0 + + +def _browse_to(page: Page, workspace, *names: str) -> None: + """Navigate the file-browser modal into workspace/. + + Handles both browser states: a roots list (several roots configured) + and the auto-entered directory view (single root). + """ + expect(page.get_by_test_id("file-browser")).to_be_visible() + page.wait_for_timeout(400) # let the initial listing (and auto-enter) settle + if page.get_by_test_id(f"fb-entry-{names[0]}").count() == 0: + page.locator(".fb-entry", has_text=str(workspace)).first.click() + for name in names: + page.click(f'[data-testid="fb-entry-{name}"]') + + +def test_j5_file_browser_picks_document_and_mask_target(page: Page, server_url, workspace): + artifacts = workspace / "j5browse" / "artifacts" + artifacts.mkdir(parents=True, exist_ok=True) + shutil.copyfile(REF_IMAGE, artifacts / "reference.png") + + page.goto(f"{server_url}/#/editor") + + # pick the document via Browse… instead of typing a path + page.click('[data-testid="browse-doc"]') + _browse_to(page, workspace, "j5browse", "artifacts", "reference.png") + expect(page.get_by_test_id("doc-file")).to_have_value(str(artifacts / "reference.png")) + expect(page.get_by_test_id("editor-dpi")).to_be_visible() + + # pick the masks.json target via the save-mode browser + page.click('[data-testid="browse-mask"]') + _browse_to(page, workspace, "j5browse", "artifacts") + page.fill('[data-testid="fb-filename"]', "browser_masks.json") + page.click('[data-testid="fb-select"]') + expect(page.get_by_test_id("mask-file")).to_have_value( + str(artifacts / "browser_masks.json")) + + # add a mask and save to the picked location + page.click('[data-testid="add-coordinates-mask"]') + page.click('[data-testid="save-masks"]') + expect(page.get_by_test_id("editor-message")).to_contain_text("Saved") + saved = json.loads((artifacts / "browser_masks.json").read_text()) + assert saved[0]["type"] == "coordinates" + + # re-opening the mask browser and picking the existing file auto-loads it + page.reload() + page.click('[data-testid="browse-mask"]') + _browse_to(page, workspace, "j5browse", "artifacts", "browser_masks.json") + page.click('[data-testid="fb-select"]') + expect(page.get_by_test_id("editor-message")).to_contain_text("Loaded 1 masks") + + +def test_j6_upload_local_image_and_edit_masks(page: Page, server_url, workspace): + """A user with zero configured roots uploads an image from their machine + and immediately edits + saves masks for it.""" + page.goto(f"{server_url}/#/editor") + + page.set_input_files('[data-testid="upload-input"]', str(REF_IMAGE)) + expect(page.get_by_test_id("editor-message")).to_contain_text("Uploaded birthday_1080.png") + expect(page.get_by_test_id("editor-dpi")).to_be_visible() + # uploaded path landed in the document field, masks.json suggested next to it + doc_value = page.get_by_test_id("doc-file").input_value() + assert "uploads" in doc_value and doc_value.endswith("birthday_1080.png") + mask_value = page.get_by_test_id("mask-file").input_value() + assert mask_value == doc_value.rsplit("/", 1)[0] + "/masks.json" + + page.click('[data-testid="add-coordinates-mask"]') + page.click('[data-testid="save-masks"]') + expect(page.get_by_test_id("editor-message")).to_contain_text("Saved 1 masks") + + +def test_j7_typing_pattern_mask_never_errors(page: Page, server_url, workspace): + """Reproduces the reported journey: upload an image, add a text-pattern + mask, and type a regex character by character. Mid-typing states are + invalid regexes — the editor must pause the preview with a hint, produce + no failed requests, and resume once the pattern compiles.""" + failures = [] + page.on( + "response", + lambda response: failures.append(response) + if "/api/mask-preview" in response.url and response.status >= 500 + else None, + ) + + page.goto(f"{server_url}/#/editor") + page.set_input_files('[data-testid="upload-input"]', str(REF_IMAGE)) + expect(page.get_by_test_id("editor-dpi")).to_be_visible() + + page.click('[data-testid="add-pattern-mask"]') + pattern_input = page.locator('[data-testid="prop-pattern"]') + + # simulate keystroke-by-keystroke typing of "[0-9]{2}" + for prefix in ("[", "[0", "[0-9", "[0-9]", "[0-9]{2", "[0-9]{2}"): + pattern_input.fill(prefix) + page.wait_for_timeout(120) + + status = page.get_by_test_id("pattern-status") + expect(status).to_be_visible() + # final pattern is valid: hint must clear and preview must run through + page.wait_for_timeout(1000) + expect(status).not_to_contain_text("Incomplete") + expect(status).to_contain_text("highlighted on this page") + expect(page.get_by_test_id("editor-message")).to_have_count(0) + + # an unterminated set left standing shows the hint, still without errors + pattern_input.fill("[0-9]{2}[") + expect(status).to_contain_text("Incomplete or invalid") + + assert not failures, f"mask-preview returned 5xx: {failures}" + + +def test_j8_upload_results_folder_from_disk(page: Page, server_url, workspace): + """A user picks their Robot Framework output folder in the browser; the + whole tree (output.xml + sidecars + images) uploads and ingests, and the + run is immediately reviewable.""" + output_xml, reference, candidate = make_image_run(workspace / "j8") + run_dir = output_xml.parent + + page.goto(f"{server_url}/#/") + # Playwright supports directory upload for webkitdirectory inputs + page.set_input_files('[data-testid="upload-results-input"]', str(run_dir)) + message = page.get_by_test_id("ingest-message") + expect(message).to_contain_text("ingested run", timeout=30_000) + + # the uploaded run is reviewable end to end + page.locator('[data-testid="run-list"] tr.clickable').first.click() + page.select_option('[data-testid="status-filter"]', "fail") + page.locator('[data-testid="test-grid"] tr.clickable').first.click() + expect(page.get_by_test_id("comparison-status")).to_have_text("FAIL") + expect(page.get_by_test_id("viewer-overlay")).to_be_visible() + + +def test_j4_shorthand_import_export_roundtrip(page: Page, server_url, workspace): + page.goto(f"{server_url}/#/editor") + page.fill('[data-testid="import-text"]', "top:10;bottom:5") + page.click('[data-testid="import-button"]') + expect(page.get_by_test_id("mask-row-0")).to_contain_text("area") + expect(page.get_by_test_id("mask-row-1")).to_contain_text("area") + + # edit: change percent of the first mask via the panel + page.click('[data-testid="mask-row-0"]') + page.locator('[data-testid="prop-percent"]').fill("15") + + masks_file = workspace / "j4_masks.json" + page.fill('[data-testid="mask-file"]', str(masks_file)) + page.click('[data-testid="save-masks"]') + expect(page.get_by_test_id("editor-message")).to_contain_text("Saved") + + saved = json.loads(masks_file.read_text()) + assert [entry["type"] for entry in saved] == ["area", "area"] + assert saved[0]["location"] == "top" + assert int(saved[0]["percent"]) == 15 + assert saved[1]["location"] == "bottom" + + # round trip: load it back, list shows both masks again + page.click('[data-testid="load-masks"]') + expect(page.get_by_test_id("editor-message")).to_contain_text("Loaded 2 masks") diff --git a/e2e/test_version_skew.py b/e2e/test_version_skew.py new file mode 100644 index 0000000..19de5b3 --- /dev/null +++ b/e2e/test_version_skew.py @@ -0,0 +1,56 @@ +"""Version-skew journeys: a newer UI served by an older backend. + +Static frontend files are re-read from disk on every request while the +Python process keeps running old code — so after an update, users can see +new buttons that hit routes the running server does not have (POST to the +static mount answers 405). These tests cover both sides: the happy path +(no warning on a current server) and the stale path, simulated by +intercepting the backend responses in the browser. +""" + +from e2e_helpers import REF_IMAGE +from playwright.sync_api import Page, expect + + +def test_no_server_warning_on_current_server(page: Page, server_url): + page.goto(f"{server_url}/#/") + expect(page.get_by_test_id("run-list")).to_be_visible() + expect(page.get_by_test_id("server-warning")).to_have_count(0) + + +def test_stale_backend_shows_restart_banner(page: Page, server_url): + # Old backends answered /api/health without a feature list + page.route( + "**/api/health", + lambda route: route.fulfill( + status=200, + content_type="application/json", + body='{"status": "ok", "version": "0.0.9"}', + ), + ) + page.goto(f"{server_url}/#/") + banner = page.get_by_test_id("server-warning") + expect(banner).to_be_visible() + expect(banner).to_contain_text("older than this user interface") + expect(banner).to_contain_text("Restart") + expect(banner).to_contain_text("browse, upload, recompare, upload-results") + + +def test_unreachable_backend_shows_warning(page: Page, server_url): + page.route("**/api/health", lambda route: route.abort()) + page.goto(f"{server_url}/#/") + expect(page.get_by_test_id("server-warning")).to_contain_text( + "Cannot reach the dashboard server") + + +def test_stale_backend_upload_405_explains_restart(page: Page, server_url): + # POST /api/upload on an old backend falls through to the static mount → 405 + page.route( + "**/api/upload", + lambda route: route.fulfill(status=405, body="Method Not Allowed"), + ) + page.goto(f"{server_url}/#/editor") + page.set_input_files('[data-testid="upload-input"]', str(REF_IMAGE)) + message = page.get_by_test_id("editor-message") + expect(message).to_contain_text("does not support uploads yet") + expect(message).to_contain_text("Restart") diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a6533c4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + doctest-dashboard + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..b888fa6 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1826 @@ +{ + "name": "doctest-dashboard-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "doctest-dashboard-frontend", + "version": "0.1.0", + "dependencies": { + "konva": "^9.3.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-konva": "^18.2.10" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "^5.5.0", + "vite": "^5.4.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.35", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz", + "integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001797", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", + "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.371", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz", + "integrity": "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/its-fine": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", + "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.0" + }, + "peerDependencies": { + "react": ">=18.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/konva": { + "version": "9.3.22", + "resolved": "https://registry.npmjs.org/konva/-/konva-9.3.22.tgz", + "integrity": "sha512-yQI5d1bmELlD/fowuyfOp9ff+oamg26WOCkyqUyc+nczD/lhRa3EvD2MZOoc4c1293TAubW9n34fSQLgSeEgSw==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-konva": { + "version": "18.2.16", + "resolved": "https://registry.npmjs.org/react-konva/-/react-konva-18.2.16.tgz", + "integrity": "sha512-bYs5TuTpaSSxBTZ79btaSXjDvLaQIZOVgBEg2ITMyc2p3jwbdIJDh7kOuK4ivxY8uZHWxmQP32bLxlwA17EgXQ==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.2", + "its-fine": "^1.1.1", + "react-reconciler": "~0.29.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "konva": "^8.0.1 || ^7.2.5 || ^9.0.0 || ^10.0.0", + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", + "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..77b81b4 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "doctest-dashboard-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "konva": "^9.3.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-konva": "^18.2.10" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "^5.5.0", + "vite": "^5.4.0" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..3dabcfb --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,267 @@ +import React, { useEffect, useState } from "react"; +import { api, Run, TestRow, assetUrl } from "./api"; +import { ComparisonView } from "./ComparisonView"; +import { MaskEditor } from "./MaskEditor"; + +/** Backend features this UI build depends on (advertised by /api/health). + * When any are missing, the running server is older than the served UI. */ +const REQUIRED_FEATURES = ["browse", "upload", "recompare", "upload-results"]; + +const RESULT_FILE_EXTENSIONS = [".xml", ".png", ".jpg", ".jpeg", ".json", ".pdf"]; + +/** Tiny hash router: #/ | #/runs/:id | #/comparisons/:id | #/editor?... */ +function useHashRoute(): [string, (hash: string) => void] { + const [route, setRoute] = useState(window.location.hash || "#/"); + useEffect(() => { + const onChange = () => setRoute(window.location.hash || "#/"); + window.addEventListener("hashchange", onChange); + return () => window.removeEventListener("hashchange", onChange); + }, []); + return [route, (hash) => (window.location.hash = hash)]; +} + +function useBackendCheck(): string | null { + const [warning, setWarning] = useState(null); + useEffect(() => { + fetch("/api/health") + .then(async (response) => { + if (!response.ok) throw new Error(`health check failed (${response.status})`); + const health = await response.json(); + const features: string[] = health.features ?? []; + const missing = REQUIRED_FEATURES.filter((f) => !features.includes(f)); + if (missing.length) { + setWarning( + `The running dashboard server (v${health.version ?? "?"}) is older than this ` + + `user interface — it lacks: ${missing.join(", ")}. ` + + "Restart it (stop and start `doctest-dashboard serve`) to update.", + ); + } + }) + .catch((e) => setWarning(`Cannot reach the dashboard server: ${e.message}`)); + }, []); + return warning; +} + +export function App() { + const [route] = useHashRoute(); + const backendWarning = useBackendCheck(); + let view: React.ReactNode; + if (route.startsWith("#/runs/")) { + view = ; + } else if (route.startsWith("#/comparisons/")) { + view = ; + } else if (route.startsWith("#/editor")) { + view = ; + } else { + view = ; + } + return ( + <> +
+

+ doctest-dashboard +

+ Runs + Mask Editor +
+ {backendWarning && ( +
+ ⚠ {backendWarning} +
+ )} +
{view}
+ + ); +} + +function RunList() { + const [runs, setRuns] = useState([]); + const [ingestPath, setIngestPath] = useState(""); + const [message, setMessage] = useState(null); + const folderInputRef = React.useRef(null); + const load = () => api.runs().then(setRuns).catch((e) => setMessage(e.message)); + useEffect(() => { + load(); + }, []); + const ingest = async () => { + setMessage(null); + try { + const summary = await api.ingest(ingestPath); + setMessage(`Ingested run ${summary.run_id}: ${summary.comparisons} comparisons`); + load(); + } catch (e: any) { + setMessage(e.message); + } + }; + const uploadResultsFolder = async (files: FileList) => { + setMessage(null); + const form = new FormData(); + let count = 0; + for (const file of Array.from(files)) { + const relative = (file as any).webkitRelativePath || file.name; + if (!RESULT_FILE_EXTENSIONS.some((ext) => relative.toLowerCase().endsWith(ext))) continue; + form.append("files", file, relative); + count++; + } + if (!count) { + setMessage("The selected folder contains no result files (output.xml, images, sidecars)"); + return; + } + setMessage(`Uploading ${count} files…`); + try { + const response = await fetch("/api/upload-results", { method: "POST", body: form }); + if (!response.ok) { + const detail = (await response.json()).detail; + throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail)); + } + const result = await response.json(); + const summary = result.runs + .map((run: any) => `run ${run.run_id}: ${run.comparisons} comparisons`) + .join(", "); + setMessage(`Uploaded ${result.stored} files — ingested ${summary}`); + load(); + } catch (e: any) { + setMessage(String(e.message || e)); + } + }; + return ( +
+
+ setIngestPath(e.target.value)} + style={{ width: 420 }} + /> + + + { + if (e.target.files?.length) uploadResultsFolder(e.target.files); + e.target.value = ""; + }} + /> +
+ {message &&

{message}

} + + + + + + + + + + + + {runs.map((run) => ( + (window.location.hash = `#/runs/${run.id}`)} + > + + + + + + + ))} + +
RunImportedComparisonsFailedUnresolved
{run.name}{run.imported_at}{run.comparisons}{run.failed} + + {run.unresolved} + +
+ {runs.length === 0 &&

No runs ingested yet.

} +
+ ); +} + +function RunDetail({ runId }: { runId: number }) { + const [rows, setRows] = useState([]); + const [statusFilter, setStatusFilter] = useState(""); + const [stateFilter, setStateFilter] = useState(""); + useEffect(() => { + const params = new URLSearchParams(); + if (statusFilter) params.set("status", statusFilter); + if (stateFilter) params.set("review_state", stateFilter); + const query = params.toString() ? `?${params.toString()}` : ""; + api.tests(runId, query).then(setRows).catch(() => setRows([])); + }, [runId, statusFilter, stateFilter]); + return ( +
+
+ ← Runs + + +
+ + + + + + + + + + + + {rows.map((row) => ( + (window.location.hash = `#/comparisons/${row.comparison_id}`)} + > + + + + + + + ))} + +
DiffTestKeywordComparisonReview
{row.thumbnail ? : "—"}{row.name}{row.keyword} + {row.status}{" "} + {row.degraded ? degraded : null} + + {row.review_state} +
+
+ ); +} diff --git a/frontend/src/ComparisonView.tsx b/frontend/src/ComparisonView.tsx new file mode 100644 index 0000000..679a22e --- /dev/null +++ b/frontend/src/ComparisonView.tsx @@ -0,0 +1,332 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { api, assetUrl, ComparisonDetail, Page, Region } from "./api"; + +type Mode = "side-by-side" | "overlay" | "blink" | "swipe"; +const MODES: Mode[] = ["side-by-side", "overlay", "blink", "swipe"]; + +export function ComparisonView({ comparisonId }: { comparisonId: number }) { + const [detail, setDetail] = useState(null); + const [pageIndex, setPageIndex] = useState(0); + const [mode, setMode] = useState("overlay"); + const [regionIndex, setRegionIndex] = useState(-1); + const [message, setMessage] = useState<{ kind: string; text: string } | null>(null); + const [reason, setReason] = useState(""); + + const load = () => + api.comparison(comparisonId).then(setDetail).catch((e) => + setMessage({ kind: "error", text: e.message }), + ); + useEffect(() => { + load(); + }, [comparisonId]); + + const page: Page | undefined = detail?.pages[pageIndex]; + const regions: Region[] = page?.regions ?? []; + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (["INPUT", "TEXTAREA", "SELECT"].includes((event.target as HTMLElement).tagName)) return; + const num = parseInt(event.key, 10); + if (num >= 1 && num <= MODES.length) setMode(MODES[num - 1]); + if (event.key === "n") nextRegion(1); + if (event.key === "p") nextRegion(-1); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }); + + const nextRegion = (step: number) => { + if (!regions.length) return; + setRegionIndex((current) => (current + step + regions.length) % regions.length); + }; + + const act = async (action: () => Promise, successText: string) => { + setMessage(null); + try { + await action(); + setMessage({ kind: "success", text: successText }); + await load(); + } catch (e: any) { + const alternatives = e.detail?.alternatives; + setMessage({ + kind: "error", + text: alternatives ? `${e.message} (alternatives: ${alternatives.join(", ")})` : e.message, + }); + } + }; + + if (!detail) return

Loading…{message && ` ${message.text}`}

; + + const isDegraded = !!detail.degraded; + const dpi = detail.sidecar_json?.reference?.dpi; + + return ( +
+
+ ← Back + {detail.keyword} + + {detail.status} + + + {detail.review_state} + + {isDegraded && degraded} + {dpi && DPI {dpi}} +
+ + {message && ( +

+ {message.text} +

+ )} + + {isDegraded ? ( + + ) : ( + <> +
+ {detail.pages.map((p, index) => ( + + ))} +
+
+ {MODES.map((m, index) => ( + + ))} + + + {regionIndex >= 0 && regions[regionIndex] && ( + <> + + region {regionIndex + 1}/{regions.length} + + + + + + )} +
+ {page && = 0 ? regions[regionIndex] : null} />} + + )} + +
+
+ setReason(e.target.value)} + style={{ width: 380 }} + /> + {page && detail.pages.length >= 1 && ( + + )} + + + + + +
+
+
+ ); +} + +function editorLink(detail: ComparisonDetail, region: Region, dpi?: number): string { + const params = new URLSearchParams({ + file: detail.reference_path || "", + comparison: String(detail.id), + x: String(Math.max(0, region.x - 5)), + y: String(Math.max(0, region.y - 5)), + width: String(region.width + 10), + height: String(region.height + 10), + }); + if (dpi) params.set("dpi", String(dpi)); + return `#/editor?${params.toString()}`; +} + +function PageViewer({ page, mode, highlight }: { page: Page; mode: Mode; highlight: Region | null }) { + const ref = page.images["reference"]; + const cand = page.images["candidate"]; + const diff = page.images["diff"]; + const [blinkOn, setBlinkOn] = useState(false); + const [swipe, setSwipe] = useState(50); + const [overlayOpacity, setOverlayOpacity] = useState(50); + + useEffect(() => { + if (mode !== "blink") return; + const interval = setInterval(() => setBlinkOn((value) => !value), 600); + return () => clearInterval(interval); + }, [mode]); + + const highlightStyle = useMemo(() => { + if (!highlight) return null; + return { + position: "absolute" as const, + left: highlight.x, + top: highlight.y, + width: highlight.width, + height: highlight.height, + outline: "3px solid #ff9800", + pointerEvents: "none" as const, + }; + }, [highlight]); + + if (!ref || !cand) { + return ( +
+ {Object.entries(page.images).map(([kind, token]) => ( +
+

{kind}

+ +
+ ))} +
+ ); + } + + return ( +
+ {mode === "side-by-side" && ( +
+
+

Reference

+
+ + {highlightStyle &&
} +
+
+
+

Candidate

+
+ + {highlightStyle &&
} +
+
+ {diff && ( +
+

Diff

+ +
+ )} +
+ )} + {mode === "overlay" && ( +
+
+ + setOverlayOpacity(parseInt(e.target.value, 10))} + /> +
+
+ + + {highlightStyle &&
} +
+
+ )} + {mode === "blink" && ( +
+ + + {highlightStyle &&
} +
+ )} + {mode === "swipe" && ( +
+
+ + setSwipe(parseInt(e.target.value, 10))} + /> +
+
+ +
+ +
+ {highlightStyle &&
} +
+
+ )} +

+ SSIM: {page.score?.toFixed(6) ?? "n/a"} | threshold: {page.threshold ?? "n/a"} | regions:{" "} + {page.regions.length} +

+
+ ); +} + +function DegradedView({ detail }: { detail: ComparisonDetail }) { + return ( +
+

+ Limited data: this run was ingested without result_json sidecars. Only combined + screenshots are available — per-page review, diff-region navigation and accept are disabled. + Enable result_json=true on the DocTest library to unlock full review. +

+
+ {detail.images.map((token) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/FileBrowser.tsx b/frontend/src/FileBrowser.tsx new file mode 100644 index 0000000..dc8d2a7 --- /dev/null +++ b/frontend/src/FileBrowser.tsx @@ -0,0 +1,182 @@ +import React, { useEffect, useState } from "react"; + +interface Entry { + name: string; + path: string; + type: "dir" | "file"; + size: number | null; +} + +interface BrowseResponse { + roots?: { name: string; path: string }[]; + path?: string; + parent?: string | null; + entries?: Entry[]; +} + +export interface FileBrowserProps { + title: string; + /** "open": pick an existing file. "save": pick a folder (or existing file) and a filename. */ + mode: "open" | "save"; + /** Show only files whose name matches (directories always shown). */ + fileFilter?: (name: string) => boolean; + defaultFilename?: string; + onSelect: (path: string) => void; + onClose: () => void; +} + +async function browse(path?: string): Promise { + const url = path ? `/api/browse?path=${encodeURIComponent(path)}` : "/api/browse"; + const response = await fetch(url); + if (!response.ok) { + let detail: string; + try { + detail = (await response.json()).detail; + } catch { + detail = response.statusText; + } + if (response.status === 404 && !path) { + // The route itself is missing: UI is newer than the running backend + detail = + "The running dashboard server does not provide the file browser yet. " + + "Restart it (stop and start `doctest-dashboard serve`) to pick up the update."; + } + throw new Error(detail); + } + return response.json(); +} + +function formatSize(size: number | null): string { + if (size === null) return ""; + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / 1024 / 1024).toFixed(1)} MB`; +} + +export function FileBrowser({ + title, mode, fileFilter, defaultFilename, onSelect, onClose, +}: FileBrowserProps) { + const [listing, setListing] = useState(null); + const [error, setError] = useState(null); + const [filename, setFilename] = useState(defaultFilename ?? ""); + + const navigate = (path?: string) => + browse(path) + .then((result) => { + setListing(result); + setError(null); + // Jump straight in when there is exactly one root + if (!path && result.roots && result.roots.length === 1) { + navigate(result.roots[0].path); + } + }) + .catch((e) => setError(String(e.message || e))); + + useEffect(() => { + navigate(); + }, []); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + const entries = (listing?.entries ?? []).filter( + (entry) => entry.type === "dir" || !fileFilter || fileFilter(entry.name), + ); + + const pickFile = (entry: Entry) => { + if (mode === "open") { + onSelect(entry.path); + } else { + setFilename(entry.name); + } + }; + + return ( +
+
event.stopPropagation()}> +
+ {title} + +
+ + {error &&

{error}

} + {listing?.roots && listing.roots.length === 0 && ( +

+ No browsable locations yet. Use the Upload… button to bring a + file from your machine, start the server with --root /your/testdata, + or ingest a run — ingested run folders become browsable automatically. +

+ )} + + {listing?.path && ( +
+ + {listing.path} +
+ )} + +
+ {listing?.roots?.map((root) => ( +
navigate(root.path)} + > + 🗄 + {root.path} +
+ ))} + {listing?.path && + entries.map((entry) => ( +
(entry.type === "dir" ? navigate(entry.path) : pickFile(entry))} + onDoubleClick={() => entry.type === "file" && mode === "save" && onSelect(entry.path)} + > + {entry.type === "dir" ? "📁" : "📄"} + {entry.name} + {formatSize(entry.size)} +
+ ))} + {listing?.path && entries.length === 0 && ( +

No matching files in this folder.

+ )} +
+ + {mode === "save" && listing?.path && ( +
+ + setFilename(event.target.value)} + placeholder="masks.json" + /> + +
+ )} +
+
+ ); +} diff --git a/frontend/src/MaskEditor.tsx b/frontend/src/MaskEditor.tsx new file mode 100644 index 0000000..a2ede75 --- /dev/null +++ b/frontend/src/MaskEditor.tsx @@ -0,0 +1,747 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Image as KonvaImage, Layer, Rect, Stage, Transformer } from "react-konva"; +import Konva from "konva"; +import { api, assetUrl, MaskEntry, Region } from "./api"; +import { FileBrowser } from "./FileBrowser"; + +const DOCUMENT_EXTENSIONS = [".png", ".jpg", ".jpeg", ".pdf", ".ps", ".pcl", ".tif", ".tiff", ".bmp", ".gif"]; +const isDocumentFile = (name: string) => + DOCUMENT_EXTENSIONS.some((extension) => name.toLowerCase().endsWith(extension)); +const isJsonFile = (name: string) => name.toLowerCase().endsWith(".json"); + +type Unit = "px" | "mm" | "cm" | "pt"; +const UNIT_FACTORS: Record number> = { + px: () => 1, + mm: (dpi) => dpi / 25.4, + cm: (dpi) => dpi / 2.54, + pt: (dpi) => dpi / 72.0, +}; + +const toPx = (value: number, unit: Unit, dpi: number) => value * UNIT_FACTORS[unit](dpi); +const fromPx = (px: number, unit: Unit, dpi: number) => px / UNIT_FACTORS[unit](dpi); +const round2 = (value: number) => Math.round(value * 100) / 100; + +/** Client-side mirror of IgnoreAreaManager's shorthand rules ("top:10;bottom:5"). */ +function parseShorthand(input: string): MaskEntry[] | null { + const entries: MaskEntry[] = []; + for (const part of input.split(";")) { + if (!part.trim()) continue; + const [location, percent] = part.split(":"); + if (!["top", "bottom", "left", "right"].includes(location)) return null; + if (!/^\d+$/.test(percent ?? "")) return null; + entries.push({ page: "all", type: "area", location, percent }); + } + return entries.length ? entries : null; +} + +export function MaskEditor({ routeQuery }: { routeQuery: string }) { + const query = useMemo(() => new URLSearchParams(routeQuery), [routeQuery]); + const [docFile, setDocFile] = useState(query.get("file") || ""); + const [maskFile, setMaskFile] = useState(query.get("maskfile") || ""); + const [pageNo, setPageNo] = useState(1); + const [pageCount, setPageCount] = useState(1); + const [dpi, setDpi] = useState(parseInt(query.get("dpi") || "0", 10) || 0); + const [imageToken, setImageToken] = useState(null); + const [imageSize, setImageSize] = useState({ width: 800, height: 600 }); + const [image, setImage] = useState(null); + const [masks, setMasks] = useState(() => { + if (query.get("x") !== null) { + return [{ + page: "all", + name: "From diff region", + type: "coordinates", + x: parseInt(query.get("x")!, 10), + y: parseInt(query.get("y")!, 10), + width: parseInt(query.get("width")!, 10), + height: parseInt(query.get("height")!, 10), + unit: "px", + }]; + } + return []; + }); + const [selected, setSelected] = useState(-1); + const [previewBoxes, setPreviewBoxes] = useState([]); + const [diffRegions, setDiffRegions] = useState([]); + const [showDiffRegions, setShowDiffRegions] = useState(true); + const [message, setMessage] = useState<{ kind: string; text: string } | null>(null); + const [recompareResult, setRecompareResult] = useState(null); + const [importText, setImportText] = useState(""); + const [browser, setBrowser] = useState<"doc" | "mask" | null>(null); + const uploadInputRef = useRef(null); + const comparisonId = query.get("comparison") ? parseInt(query.get("comparison")!, 10) : null; + const drawState = useRef<{ startX: number; startY: number; index: number } | null>(null); + + // Load page rendering + useEffect(() => { + if (!docFile) return; + fetch( + `/api/page-image?file=${encodeURIComponent(docFile)}&page=${pageNo}` + + (dpi ? `&dpi=${dpi}` : ""), + ) + .then(async (response) => { + if (!response.ok) throw new Error((await response.json()).detail || response.statusText); + return response.json(); + }) + .then((info) => { + setImageToken(info.image); + setImageSize(info.image_size); + setPageCount(info.page_count); + setDpi(info.dpi); + setMessage(null); + }) + .catch((e) => setMessage({ kind: "error", text: String(e.message || e) })); + }, [docFile, pageNo]); + + useEffect(() => { + if (!imageToken) return; + const element = new window.Image(); + element.src = assetUrl(imageToken); + element.onload = () => setImage(element); + }, [imageToken]); + + // Diff-region overlay when arriving from a comparison + useEffect(() => { + if (!comparisonId) return; + api.comparison(comparisonId).then((detail) => { + const failing = detail.pages.find((page) => page.status === "FAIL"); + if (failing) setDiffRegions(failing.regions); + }); + }, [comparisonId]); + + // Debounced live pattern preview through the real extraction path. + // Patterns are validated locally first: while the user is mid-keystroke + // ("[", "[0-9]{2}[") the regex does not compile — show a hint instead of + // sending requests that can only fail. + const isCompilable = (pattern: string) => { + try { + new RegExp(pattern); + return true; + } catch { + return false; + } + }; + const patternMasks = masks.filter( + (mask) => ["pattern", "line_pattern", "word_pattern"].includes(mask.type) && mask.pattern, + ); + const validPatternMasks = patternMasks.filter((mask) => isCompilable(String(mask.pattern))); + const hasIncompletePattern = validPatternMasks.length < patternMasks.length; + const patternKey = JSON.stringify(validPatternMasks); + useEffect(() => { + if (!docFile || validPatternMasks.length === 0) { + setPreviewBoxes([]); + return; + } + const timer = setTimeout(() => { + api + .maskPreview({ + file: docFile, page: pageNo, masks: validPatternMasks, dpi: dpi || undefined, + }) + .then((result) => setPreviewBoxes(result.resolved_areas)) + .catch((e) => { + setPreviewBoxes([]); + // 422 = pattern the backend's regex dialect rejects — a typing-state + // condition, not an application error + if (e.status !== 422) setMessage({ kind: "error", text: e.message }); + }); + }, 400); + return () => clearTimeout(timer); + }, [patternKey, docFile, pageNo]); + + const scale = Math.min(1, 900 / imageSize.width); + + const updateMask = (index: number, patch: Partial) => { + setMasks((current) => current.map((mask, i) => (i === index ? { ...mask, ...patch } : mask))); + }; + + const loadMasks = async () => { + try { + const result = await api.getMasks(maskFile); + setMasks(result.masks); + setMessage({ kind: "success", text: `Loaded ${result.masks.length} masks` }); + } catch (e: any) { + setMessage({ kind: "error", text: e.message }); + } + }; + + const saveMasks = async () => { + try { + const result = await api.putMasks(maskFile, masks); + setMessage({ kind: "success", text: `Saved ${result.masks.length} masks to ${result.file}` }); + } catch (e: any) { + setMessage({ kind: "error", text: e.message }); + } + }; + + const importMasks = () => { + const text = importText.trim(); + if (!text) return; + try { + const parsed = JSON.parse(text); + setMasks((current) => [...current, ...(Array.isArray(parsed) ? parsed : [parsed])]); + setMessage({ kind: "success", text: "Imported JSON masks" }); + return; + } catch { + /* not JSON — try shorthand */ + } + const shorthand = parseShorthand(text); + if (shorthand) { + setMasks((current) => [...current, ...shorthand]); + setMessage({ kind: "success", text: `Imported ${shorthand.length} shorthand masks` }); + } else { + setMessage({ kind: "error", text: "Input is neither JSON nor top:10;bottom:5 shorthand" }); + } + }; + + const recompare = async () => { + if (!comparisonId) return; + setRecompareResult("running…"); + try { + const result = await api.recompare(comparisonId, masks); + setRecompareResult(result.status); + } catch (e: any) { + setRecompareResult(null); + setMessage({ kind: "error", text: e.message }); + } + }; + + // Drawing new coordinate masks by drag on empty canvas + const onStageMouseDown = (event: Konva.KonvaEventObject) => { + if (event.target !== event.target.getStage() && event.target.className !== "Image") return; + const stage = event.target.getStage()!; + const pointer = stage.getPointerPosition()!; + const x = pointer.x / scale; + const y = pointer.y / scale; + const entry: MaskEntry = { + page: "all", name: `Mask ${masks.length + 1}`, type: "coordinates", + x: Math.round(x), y: Math.round(y), width: 1, height: 1, unit: "px", + }; + drawState.current = { startX: x, startY: y, index: masks.length }; + setMasks((current) => [...current, entry]); + setSelected(masks.length); + }; + + const onStageMouseMove = (event: Konva.KonvaEventObject) => { + if (!drawState.current) return; + const stage = event.target.getStage()!; + const pointer = stage.getPointerPosition()!; + const { startX, startY, index } = drawState.current; + const x = pointer.x / scale; + const y = pointer.y / scale; + updateMask(index, { + x: Math.round(Math.min(startX, x)), + y: Math.round(Math.min(startY, y)), + width: Math.max(1, Math.round(Math.abs(x - startX))), + height: Math.max(1, Math.round(Math.abs(y - startY))), + }); + }; + + const onStageMouseUp = () => { + if (drawState.current) { + const { index } = drawState.current; + // Discard accidental click-draws + setMasks((current) => + current.filter( + (mask, i) => i !== index || (Number(mask.width) > 3 && Number(mask.height) > 3), + ), + ); + drawState.current = null; + } + }; + + const effectiveDpi = dpi || 72; + + const uploadFile = async (file: File) => { + setMessage(null); + const form = new FormData(); + form.append("file", file); + try { + const response = await fetch("/api/upload", { method: "POST", body: form }); + if (!response.ok) { + if (response.status === 405 || response.status === 404) { + // POST fell through to the static-file mount: the backend predates uploads + throw new Error( + "The running dashboard server does not support uploads yet. " + + "Restart it (stop and start `doctest-dashboard serve`) to pick up the update.", + ); + } + let detail: any; + try { + detail = (await response.json()).detail; + } catch { + detail = response.statusText; + } + throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail)); + } + const result = await response.json(); + setDocFile(result.path); + setPageNo(1); + if (!maskFile) { + // suggest saving masks next to the uploaded file + const folder = result.path.slice(0, result.path.lastIndexOf("/")); + setMaskFile(`${folder}/masks.json`); + } + setMessage({ kind: "success", text: `Uploaded ${result.name}` }); + } catch (e: any) { + setMessage({ kind: "error", text: String(e.message || e) }); + } + }; + + const onBrowseSelect = (path: string) => { + if (browser === "doc") { + setDocFile(path); + setPageNo(1); + } else if (browser === "mask") { + setMaskFile(path); + // Picking an existing file loads it right away; new files just set the target + api + .getMasks(path) + .then((result) => { + setMasks(result.masks); + setMessage({ kind: "success", text: `Loaded ${result.masks.length} masks` }); + }) + .catch(() => undefined); + } + setBrowser(null); + }; + + return ( +
+ {browser && ( + setBrowser(null)} + /> + )} +
+ setDocFile(e.target.value)} + style={{ width: 420 }} + /> + + + { + const file = e.target.files?.[0]; + if (file) uploadFile(file); + e.target.value = ""; + }} + /> + {pageCount > 1 && ( + <> + + page {pageNo}/{pageCount} + + + )} + DPI {effectiveDpi} + {diffRegions.length > 0 && ( + + )} +
+ +
+ setMaskFile(e.target.value)} + style={{ width: 420 }} + /> + + + + {comparisonId && ( + <> + + {recompareResult && ( + + {recompareResult} + + )} + + )} +
+ + {message && ( +

{message.text}

+ )} + {patternMasks.length > 0 && ( +

+ {hasIncompletePattern + ? "⚠ Incomplete or invalid regular expression — live preview is paused while you type" + : `${previewBoxes.length} pattern match${previewBoxes.length === 1 ? "" : "es"} highlighted on this page`} +

+ )} + +
+ + + {image && } + + + {showDiffRegions && + diffRegions.map((region, index) => ( + + ))} + {previewBoxes.map((box, index) => ( + + ))} + {masks.map((mask, index) => + mask.type === "area" ? ( + + ) : null, + )} + + + {masks.map((mask, index) => + mask.type === "coordinates" ? ( + setSelected(index)} + onChange={(patch) => updateMask(index, patch)} + /> + ) : null, + )} + + + +
+
+ Masks +
+ {masks.map((mask, index) => ( +
setSelected(index)} + > + {mask.type} + {mask.name || `#${index + 1}`} + p:{String(mask.page ?? "all")} + +
+ ))} +
+
+ + + +
+
+ + {selected >= 0 && masks[selected] && ( + updateMask(selected, patch)} + /> + )} + +
+ Import +

JSON list/object or shorthand like top:10;bottom:5

+