Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/openadapt_viewer/recording_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""


Expand Down
13 changes: 13 additions & 0 deletions src/openadapt_viewer/viewers/benchmark/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
"""
Expand All @@ -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)
Expand Down
52 changes: 50 additions & 2 deletions src/openadapt_viewer/viewers/capture/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import json
from importlib import resources
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -89,12 +90,15 @@ def _generate_viewer_html(
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Capture Viewer - {capture_id}</title>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<link rel="stylesheet" href="src/openadapt_viewer/styles/episode_timeline.css">
<script src="src/openadapt_viewer/components/episode_timeline.js"></script>

<style>
{_get_core_css()}
{_get_episode_timeline_css()}
</style>

<script>
{_get_episode_timeline_js()}
</script>
</head>
<body style="background: var(--oa-bg-primary); color: var(--oa-text-primary); font-family: var(--oa-font-sans); min-height: 100vh; margin: 0;">

Expand Down Expand Up @@ -401,6 +405,50 @@ def _generate_viewer_html(
</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 ``<link>`` or ``<script
src>`` resolves against the directory the output HTML lands in, which is
not a source checkout for anyone who installed this package.

``importlib.resources`` is used rather than a path built from ``__file__``
so the lookup answers for an installed distribution as well as this tree.

Args:
package: Dotted name of the package holding the file.
name: The file's name within that package.

Returns:
The file's text, or an empty string when it cannot be read. An
unreadable asset degrades one component of the page rather than
failing the whole generation, and says so on stdout.
"""
try:
return resources.files(package).joinpath(name).read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError, ModuleNotFoundError) as e:
# Silence here would render a page whose episode timeline is unstyled
# and inert, with nothing anywhere saying why.
print(f"Warning: Could not read {package}/{name}, omitting it: {e}")
return ""


def _get_episode_timeline_css() -> str:
"""Return the episode timeline's stylesheet, for inlining in a <style>."""
return _read_package_asset("openadapt_viewer.styles", "episode_timeline.css")


def _get_episode_timeline_js() -> str:
"""Return the episode timeline's script, for inlining in a <script>.

It defines the ``EpisodeTimeline`` class the page constructs during Alpine's
``init``, so it must run before that: the caller places it in ``<head>``
without ``defer``.
"""
return _read_package_asset("openadapt_viewer.components", "episode_timeline.js")


def _get_core_css() -> str:
"""Return core CSS with variables and component styles."""
# Try to read from core.css file
Expand Down
36 changes: 36 additions & 0 deletions tests/capture_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
79 changes: 79 additions & 0 deletions tests/test_generator.py
Original file line number Diff line number Diff line change
@@ -1,10 +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 openadapt_viewer.viewers.capture import generate_capture_html

from .capture_examples import write_legacy_capture


class TestGenerateBenchmarkHtml:
Expand Down Expand Up @@ -452,3 +459,75 @@ def test_html_no_xss_vulnerability(self, temp_dir):
# or other methods - what matters is the dangerous code isn't executable
assert "<script>alert('xss')</script>" not in html_content or "&lt;script&gt;" in html_content
assert "<script>evil()" not in html_content or "&lt;script&gt;" in html_content


class TestLegacyCaptureDirectoryIsReported:
"""A legacy capture directory must report the conversion command.

``recording_db`` refuses the pre-2026-07-17 ``capture.db`` and raises
``LegacyCaptureError`` naming the migration script. The benchmark generator
caught that error as part of a format-sniffing fallback and wrote a viewer
holding zero tasks, so the user read ``Generated: ...`` and opened an empty
page. The command they needed was in the exception nobody saw.
"""

def test_a_legacy_directory_raises_rather_than_generating(self, tmp_path):
directory = write_legacy_capture(tmp_path / "old-recording")
(directory / "episodes.json").write_text(json.dumps({"episodes": []}))
output_path = tmp_path / "benchmark.html"

with pytest.raises(LegacyCaptureError, match="migrate_legacy_capture.py"):
generate_benchmark_html(data_path=directory, output_path=output_path)

assert not output_path.exists()

def test_a_directory_of_neither_format_still_falls_back(self, benchmark_data_dir, tmp_path):
"""The fallback itself stays: benchmark result directories use it."""
output_path = tmp_path / "benchmark.html"

generate_benchmark_html(data_path=benchmark_data_dir, output_path=output_path)

assert "Test Benchmark" in output_path.read_text()


class TestGeneratedPagesAreSelfContained:
"""A generated page must not link to paths inside a source checkout.

``episode_timeline.css`` and ``episode_timeline.js`` were referenced as
``src/openadapt_viewer/...``, which resolves against the directory the
output HTML lands in. Both files ship inside the installed package, so for
anyone who installed openadapt-viewer the two requests were always 404 and
the episode timeline rendered unstyled and inert.
"""

def test_the_capture_page_references_no_repository_path(self, tmp_path):
output_path = tmp_path / "capture.html"

generate_capture_html(
steps=[{"timestamp": 0.0, "duration": 1.0, "action": {"type": "click"}}],
episodes=[{"episode_id": "e1", "name": "Episode", "start": 0.0, "end": 1.0}],
output_path=output_path,
)

assert "src/openadapt_viewer/" not in output_path.read_text()

def test_the_capture_page_carries_the_timeline_css_and_js(self, tmp_path):
output_path = tmp_path / "capture.html"

generate_capture_html(
steps=[{"timestamp": 0.0, "duration": 1.0, "action": {"type": "click"}}],
output_path=output_path,
)

html = output_path.read_text()
assert ".oa-episode-timeline" in html
assert "class EpisodeTimeline" in html

def test_the_benchmark_page_references_no_repository_path(
self, sample_benchmark_run, tmp_path
):
output_path = tmp_path / "benchmark.html"

generate_benchmark_html(run_data=sample_benchmark_run, output_path=output_path)

assert "src/openadapt_viewer/" not in output_path.read_text()
40 changes: 11 additions & 29 deletions tests/test_scanner_recording_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,38 +31,20 @@
from openadapt_viewer.cli import main
from openadapt_viewer.scanner import RecordingScanner

from .capture_examples import EXAMPLES_ENV, REAL_RECORDINGS, examples_dir, require_examples
from .capture_examples import (
EXAMPLES_ENV,
REAL_RECORDINGS,
examples_dir,
require_examples,
write_legacy_capture,
)


@pytest.fixture
def scanner(tmp_path):
return RecordingScanner(RecordingCatalog(db_path=str(tmp_path / "catalog.db")))


def _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.
"""
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


class TestCommittedRecordingsAreFound:
"""The defect, stated as the user sees it: scan finds nothing."""

Expand Down Expand Up @@ -174,7 +156,7 @@ class TestLegacyCapturesAreReportedNotIndexed:
def test_scan_skips_a_legacy_directory_and_names_the_migration(
self, scanner, tmp_path, capsys
):
_legacy_capture(tmp_path / "old-recording")
write_legacy_capture(tmp_path / "old-recording")

found = scanner.scan_recording_directory(str(tmp_path))

Expand All @@ -185,7 +167,7 @@ def test_scan_skips_a_legacy_directory_and_names_the_migration(
assert "migrate_legacy_capture.py" in out

def test_extract_refuses_a_legacy_directory(self, scanner, tmp_path):
directory = _legacy_capture(tmp_path / "old-recording")
directory = write_legacy_capture(tmp_path / "old-recording")

with pytest.raises(FileNotFoundError, match="migrate_legacy_capture.py"):
scanner._extract_recording_info(directory, "old-recording")
Expand All @@ -194,7 +176,7 @@ def test_a_converted_directory_is_indexed_without_a_legacy_warning(
self, scanner, tmp_path, capsys
):
examples = require_examples()
directory = _legacy_capture(tmp_path / "demo_new")
directory = write_legacy_capture(tmp_path / "demo_new")
(directory / "recording.db").write_bytes(
(examples / "demo_new" / "recording.db").read_bytes()
)
Expand Down Expand Up @@ -252,7 +234,7 @@ def test_register_indexes_a_real_recording(self, tmp_path, monkeypatch, capsys):
assert "Events: 14" in out

def test_register_rejects_a_legacy_directory(self, tmp_path, monkeypatch, capsys):
directory = _legacy_capture(tmp_path / "captures" / "old-recording")
directory = write_legacy_capture(tmp_path / "captures" / "old-recording")
_isolate_catalog_home(monkeypatch, tmp_path / "home")
monkeypatch.setattr(
sys, "argv", ["openadapt-viewer", "catalog", "register", str(directory)]
Expand Down
Loading