Skip to content

fix(viewers): report a legacy capture directory, and inline the timeline assets - #22

Merged
abrichr merged 2 commits into
mainfrom
fix/generator-legacy-error-and-asset-inlining
Aug 28, 2026
Merged

fix(viewers): report a legacy capture directory, and inline the timeline assets#22
abrichr merged 2 commits into
mainfrom
fix/generator-legacy-error-and-asset-inlining

Conversation

@abrichr

@abrichr abrichr commented Aug 28, 2026

Copy link
Copy Markdown
Member

Two defects in generator code, in one PR because they sit in the same corner of the repo and both are small.

1. A legacy capture directory generated an empty viewer

Point openadapt-viewer benchmark --data DIR at a pre-2026-07-17 capture (a capture.db plus episodes.json, no recording.db) and it printed Generated: ... and wrote a viewer holding zero tasks. No error, no hint.

generate_benchmark_html works out which of two formats data_path holds by trying the capture loader and catching (FileNotFoundError, ValueError, KeyError). LegacyCaptureError is a subclass of FileNotFoundError, so that clause caught it and fell through to load_benchmark_data, which reads a recording directory as a benchmark result directory with no results in it. The migration command that recording_db puts in the exception message never got out.

It now re-raises ahead of the broad clause.

Before:

$ openadapt-viewer benchmark --data ./old-recording
Generating benchmark viewer from: ./old-recording
Generated: benchmark_viewer.html      # 0 tasks

After:

$ openadapt-viewer benchmark --data ./old-recording
Generating benchmark viewer from: ./old-recording
Error: ./old-recording holds the legacy capture.db format, which the current
viewer cannot read. Convert it with openadapt-capture:
python scripts/migrate_legacy_capture.py <src> <dest>
$ echo $?
1

Why LegacyCaptureError still subclasses FileNotFoundError

That inheritance is what made the defect possible, so I looked at whether to drop it. I kept it, because every other place in the repo that catches FileNotFoundError wants precisely what it gives:

  • cli.py:295, the benchmark command's handler. It prints the message and exits 1. That's the "After" output above, and it reads correctly today only because of the base class.
  • scanner.py, whose per-directory clause is except (OSError, sqlite3.Error, ValueError). A bad directory skips, the scan carries on.
  • scripts/generate_readme_screenshots.py:120, which turns it into a ScreenshotGenerationError naming the capture.
  • Two tests in tests/test_scanner_recording_db.py assert pytest.raises(FileNotFoundError, match="migrate_legacy_capture.py").

Narrowing the base class fixes one call site and breaks four. The thing that was actually wrong is using a broad exception clause as a format test. The class docstring now says so: a caller catching FileNotFoundError to mean "try the other format" has to re-raise this one first.

2. Generated pages pointed at paths inside a source checkout

generate_capture_html wrote this into every page:

<link rel="stylesheet" href="src/openadapt_viewer/styles/episode_timeline.css">
<script src="src/openadapt_viewer/components/episode_timeline.js"></script>

Both resolve against wherever the output HTML lands, so they only ever loaded when that file happened to sit at the root of a checkout. Both files ship inside the installed package. For anyone who ran pip install openadapt-viewer, they were two guaranteed 404s, and the episode timeline came out unstyled and inert.

They're now read with importlib.resources and inlined, which is how the same 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, and that is the case that was broken. The script keeps its position in <head> with no defer: it defines the EpisodeTimeline class the page constructs inside Alpine's init.

An asset that cannot be read prints a warning and is left out, rather than failing the page or degrading in silence.

Tests

Both regression tests were written first and watched fail on main:

FAILED tests/test_generator.py::TestLegacyCaptureDirectoryIsReported::test_a_legacy_directory_raises_rather_than_generating
  Failed: DID NOT RAISE <class 'openadapt_viewer.recording_db.LegacyCaptureError'>

FAILED tests/test_generator.py::TestGeneratedPagesAreSelfContained::test_the_capture_page_references_no_repository_path
  assert 'src/openadapt_viewer/' not in '<!DOCTYPE h...dy>\n</html>'

FAILED tests/test_generator.py::TestGeneratedPagesAreSelfContained::test_the_capture_page_carries_the_timeline_css_and_js
  assert '.oa-episode-timeline' in '<!DOCTYPE html>...'

A companion test covers the fallback that stays: a benchmark result directory still loads through it.

_legacy_capture moved out of tests/test_scanner_recording_db.py into tests/capture_examples.py, so both modules that need a legacy directory build the same one.

Verified against an installed wheel

The reproduce case for defect 2 needs an install, not a checkout, so I built the wheel and ran the generator from an empty directory:

$ unzip -l openadapt_viewer-0.2.0-py3-none-any.whl | grep episode_timeline
    15022  openadapt_viewer/components/episode_timeline.js
     6134  openadapt_viewer/styles/episode_timeline.css

$ cd empty && python -c "from openadapt_viewer.viewers.capture import generate_capture_html; ..."
$ grep -c 'src/openadapt_viewer/' x.html
0
$ grep -c '.oa-episode-timeline' x.html
5
$ grep -c 'class EpisodeTimeline' x.html
1

The only remaining external reference in that page is the Alpine.js CDN tag, which predates this change.

ruff check . passes over the whole repo. Full suite: 193 passed, 45 skipped, with the openadapt-capture example recordings checked out.

abrichr and others added 2 commits August 28, 2026 11:55
…ommand

`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 <noreply@anthropic.com>
Every generated capture page carried:

    <link rel="stylesheet" href="src/openadapt_viewer/styles/episode_timeline.css">
    <script src="src/openadapt_viewer/components/episode_timeline.js"></script>

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 `<head>` 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 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📸 Generated Screenshots Preview

Screenshots have been generated. Download the artifacts to preview them.

Generated Files:

  • demo_new_controls.png (0.10 MB)
  • demo_new_events.png (0.09 MB)
  • demo_new_full.png (0.12 MB)
  • turn-off-nightshift_controls.png (0.10 MB)
  • turn-off-nightshift_events.png (0.10 MB)
  • turn-off-nightshift_full.png (0.12 MB)

🔗 Download screenshots artifact

@abrichr
abrichr merged commit 01e87e5 into main Aug 28, 2026
5 checks passed
@abrichr
abrichr deleted the fix/generator-legacy-error-and-asset-inlining branch August 28, 2026 15:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant