From 434f717ec6240e6fbf480123197faccce543845f Mon Sep 17 00:00:00 2001 From: abrichr Date: Fri, 28 Aug 2026 11:55:19 -0400 Subject: [PATCH 1/2] fix(benchmark): let a legacy capture directory report its migration command `generate_benchmark_html` wrapped `load_real_capture_data` in `except (FileNotFoundError, ValueError, KeyError)` to sniff which of two formats `data_path` holds. `LegacyCaptureError` is a subclass of `FileNotFoundError`, so the clause caught it too and fell through to `load_benchmark_data`, which reads a recording directory as a benchmark result directory with no results in it. The user pointed `openadapt-viewer benchmark --data DIR` at a pre-2026-07-17 capture, read `Generated: ...`, and opened a viewer holding zero tasks. The conversion command `recording_db` raises was in the exception nobody saw. `LegacyCaptureError` is re-raised ahead of the broad clause. The inheritance stays: the absence of a readable recording.db is a FileNotFoundError, and the CLI's top-level handler and the scanner's per-directory handler both report it correctly through that base class without an extra except clause. Its docstring now states the obligation this defect broke -- a caller catching FileNotFoundError to mean "try another format" must re-raise this class first. `_legacy_capture` moves from tests/test_scanner_recording_db.py to tests/capture_examples.py, so the two test modules that need a legacy directory build the same one. Co-Authored-By: Claude Opus 5 --- src/openadapt_viewer/recording_db.py | 9 ++++- .../viewers/benchmark/generator.py | 13 ++++++ tests/capture_examples.py | 36 +++++++++++++++++ tests/test_generator.py | 35 ++++++++++++++++ tests/test_scanner_recording_db.py | 40 +++++-------------- 5 files changed, 103 insertions(+), 30 deletions(-) diff --git a/src/openadapt_viewer/recording_db.py b/src/openadapt_viewer/recording_db.py index 699c20f..d108322 100644 --- a/src/openadapt_viewer/recording_db.py +++ b/src/openadapt_viewer/recording_db.py @@ -55,7 +55,14 @@ class LegacyCaptureError(FileNotFoundError): A subclass of ``FileNotFoundError`` because that is what the absence of a readable ``recording.db`` is, and because callers that only want to report - "this directory is not loadable" need no new except clause. + "this directory is not loadable" need no new except clause. The CLI's + top-level handler and the scanner's per-directory handler both rely on + that. + + The cost of the inheritance is that a caller which catches + ``FileNotFoundError`` to mean "try a different format" swallows this too, + and the message it carries is the only place the conversion command + appears. Such a caller must re-raise this class ahead of the broad clause. """ diff --git a/src/openadapt_viewer/viewers/benchmark/generator.py b/src/openadapt_viewer/viewers/benchmark/generator.py index f164a27..d2fdf4f 100644 --- a/src/openadapt_viewer/viewers/benchmark/generator.py +++ b/src/openadapt_viewer/viewers/benchmark/generator.py @@ -8,6 +8,7 @@ from openadapt_viewer.core.html_builder import HTMLBuilder from openadapt_viewer.core.types import BenchmarkRun +from openadapt_viewer.recording_db import LegacyCaptureError from openadapt_viewer.viewers.benchmark.data import create_sample_data, load_benchmark_data from openadapt_viewer.viewers.benchmark.real_data_loader import load_real_capture_data @@ -31,6 +32,10 @@ def generate_benchmark_html( Returns: Path to the generated HTML file + Raises: + LegacyCaptureError: If data_path holds a pre-2026-07-17 ``capture.db``. + The message names the conversion command. + POLICY: ALWAYS defaults to real data, from $OPENADAPT_CAPTURE_RECORDING. Set use_real_data=False ONLY for unit tests with sample data. """ @@ -41,6 +46,14 @@ def generate_benchmark_html( # Try to load as capture directory first, fall back to benchmark data try: run = load_real_capture_data(data_path) + except LegacyCaptureError: + # Re-raised ahead of the fallback because it is a subclass of + # FileNotFoundError and the fallback would otherwise swallow it. + # This directory is a recording, not a benchmark result directory, + # and load_benchmark_data reads it as an empty one: the user got + # "Generated: ..." and a viewer holding zero tasks, while the + # conversion command sat unread in this exception. + raise except (FileNotFoundError, ValueError, KeyError): # Fall back to benchmark data format run = load_benchmark_data(data_path) diff --git a/tests/capture_examples.py b/tests/capture_examples.py index f740050..e08406a 100644 --- a/tests/capture_examples.py +++ b/tests/capture_examples.py @@ -9,11 +9,17 @@ Two readers depend on this now -- the catalog scanner and the benchmark loader -- so the lookup lives here rather than in either test module. + +The legacy format has no committed example, because openadapt-capture stopped +writing it. ``write_legacy_capture`` below builds one, which is sound for the +opposite reason: the only thing read from a legacy directory is that it holds a +``capture.db`` this viewer refuses. """ from __future__ import annotations import os +import sqlite3 from pathlib import Path import pytest @@ -70,3 +76,33 @@ def require_examples() -> Path: if missing: pytest.fail(f"{directory} is missing recording.db for: {', '.join(missing)}") return directory + + +def write_legacy_capture(directory: Path) -> Path: + """Write a pre-#28 capture directory, in the format this viewer refuses. + + The shape is the one openadapt-capture's ``migrate_legacy_capture.py`` + reads: one ``capture`` row and a generic ``events`` table. + + Args: + directory: Where to write it. Created if it does not exist. + + Returns: + The directory, now holding a ``capture.db`` and no ``recording.db``. + """ + directory.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(directory / "capture.db") as conn: + conn.execute( + "CREATE TABLE capture (id INTEGER PRIMARY KEY, started_at REAL, " + "ended_at REAL, platform TEXT, screen_width INTEGER, " + "screen_height INTEGER, pixel_ratio REAL, task_description TEXT)" + ) + conn.execute( + "INSERT INTO capture VALUES (1, 1000.0, 1012.0, 'darwin', 1920, 1080, 2.0, 'old')" + ) + conn.execute( + "CREATE TABLE events (id INTEGER PRIMARY KEY, timestamp REAL, " + "type TEXT, data TEXT, parent_id INTEGER)" + ) + conn.execute("INSERT INTO events VALUES (1, 1000.5, 'mouse.move', '{}', NULL)") + return directory diff --git a/tests/test_generator.py b/tests/test_generator.py index e1cdcad..0871b7a 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -1,11 +1,17 @@ """Tests for HTML generation functionality.""" +import json from pathlib import Path +import pytest + from openadapt_viewer.core.html_builder import HTMLBuilder from openadapt_viewer.core.types import BenchmarkRun +from openadapt_viewer.recording_db import LegacyCaptureError from openadapt_viewer.viewers.benchmark import create_sample_data, generate_benchmark_html +from .capture_examples import write_legacy_capture + class TestGenerateBenchmarkHtml: """Tests for the generate_benchmark_html function.""" @@ -452,3 +458,32 @@ def test_html_no_xss_vulnerability(self, temp_dir): # or other methods - what matters is the dangerous code isn't executable assert "" not in html_content or "<script>" in html_content assert " Both resolve against the directory the output HTML lands in, so they loaded only when that file happened to sit at the root of a source checkout. The two files ship inside the installed package, so anyone who ran `pip install openadapt-viewer` got two 404s and an episode timeline that was unstyled and did nothing. They are now read with `importlib.resources` and inlined, which is how the page already carries core.css and how PageBuilder already carries its JavaScript. `importlib.resources` rather than a path built from `__file__` because the lookup has to answer for an installed distribution, which is the case that was broken. The script keeps its place in `` without `defer`: it defines the `EpisodeTimeline` class the page constructs during Alpine's `init`. An asset that cannot be read prints a warning and is omitted, rather than failing the whole page or degrading in silence. Co-Authored-By: Claude Opus 5 --- .../viewers/capture/generator.py | 52 ++++++++++++++++++- tests/test_generator.py | 44 ++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/openadapt_viewer/viewers/capture/generator.py b/src/openadapt_viewer/viewers/capture/generator.py index 6ffb0f8..6b5d28e 100644 --- a/src/openadapt_viewer/viewers/capture/generator.py +++ b/src/openadapt_viewer/viewers/capture/generator.py @@ -7,6 +7,7 @@ from __future__ import annotations import json +from importlib import resources from pathlib import Path from typing import Any @@ -89,12 +90,15 @@ def _generate_viewer_html( Capture Viewer - {capture_id} - - + + @@ -401,6 +405,50 @@ def _generate_viewer_html( """ +def _read_package_asset(package: str, name: str) -> str: + """Return the text of a file that ships inside the installed package. + + The generated page is one file the user can move anywhere, so its stylesheet + and its script are inlined rather than linked. A ```` or ``