Files
Tha.Les 2c3541d311 Report a failure from the notification centre (#372)
* feat(ui): report a failure from the notification centre

A failure used to live in a transient #error banner. Dismiss it, or reload,
and the evidence was gone -- which is the position #359 complained about,
where a reporter has nothing to paste and guesses at a cause instead. #343 is
the standing proof: its author blamed a GPU and sent the investigation the
wrong way. This session hit the same wall, a "demucs exited 1 (no stderr
captured)" that was really a missing ffmpeg on PATH.

Failures now land in the notification centre, survive a reload, and open a
dialog that can hand the whole thing to GitHub as a pre-filled bug report --
version, OS, install method, stage, device, model and the stderr tail already
in the form. The user adds what they were doing and ticks the two preflight
boxes, which GitHub cannot prefill and which are the point.

Covers import (foreground and background), playback, export and update
failures. A background import that failed used to say nothing whatsoever: no
banner, no queue UI, just a console warning and a library row identical to a
healthy one. Queue three tracks, lose one, never find out.

- Deliberately not wired into showError wholesale: it also carries benign
  validation ("Only MP3, WAV... are supported"), which must not file a bug.
- One failure, one card. The foreground SSE handler and the background queue
  reconciler can both notice the same dead job, and applyState can run its
  error branch on more than one frame, so records key on the job id.
- classify_failure()'s "unknown" sentinel is dropped rather than shown: as a
  card it read "Import failed - unknown", and as an issue title it grouped
  every unclassified failure under one meaningless heading.

Privacy: the report carries technical details only. Track title and source URL
are never included -- issues are public, and the user adds them if they help.
GET /api/jobs/{id}/failure enforces that server-side by parsing error.txt and
serving a whitelist, rather than trusting the client to filter the file.

That endpoint also closes a gap: the pipeline has written the quarantined
error.txt since #277 -- classified cause, device, model, timings, 40-line
stderr tail -- and nothing ever read it back, so the UI had only the one-line
error_detail. It is the difference between "demucs failed" and "CUDA out of
memory: tried to allocate 2.40 GiB".

The notification centre had no generic add-a-card path: one hardcoded release
card, and badge/empty-state toggled inline at its two call sites assuming
exactly one card. That is centralised in notifications.js now, with the
release card keeping its own per-version dismissal key.

Tests: tests/js/report-url.test.mjs pins the dropdown strings (an OS that does
not match an option exactly is dropped by GitHub without complaint), the URL
length ceiling, tail truncation keeping the end where the error is, and that
no title or source URL can appear. tests/e2e/report-failure.spec.mjs covers
the desktop path, where the link is intercepted and handed to open_url rather
than navigating -- a break there would do nothing in the shipped app while
working in every browser a developer tests in.

* fix(settings): registry pane stuck on "Loading…", and add the backend log view

Two Settings defects, both found by looking at the pane rather than the code.

**Registry never loaded.** loadRegistryView selected `.settings-registry-view`
unscoped, but the two log viewers reuse that class for its read-only-textarea
styling and sit earlier in the markup. The lookup therefore returned the
*application log* box: the registry JSON was written into a hidden textarea
while the registry pane kept its literal "Loading…" placeholder for ever, and
the application log showed registry JSON until it was refreshed. Scope the
lookup to the registry pane. Not web-only -- it never worked anywhere.

**backend.log had no viewer.** It was listed under Logs → Location and shipped
in the logs zip, but the only two views were application and setup, so the one
log that holds what killed a backend before its own logging was configured was
the one log you could not read in the app. It gets a "Backend log" tab beside
the other two, reading backend.log plus its two rotations.

The sub-tab wiring is already generic (loadLogTail(overlay, name)), so the tab
needed markup and a view entry, no new JS.

Tests: the backend view's window filtering and rotation ordering, plus one that
walks _LOG_FILES against _LOG_VIEWS and fails if a file the Settings pane
advertises has no view to read it in -- which is exactly how backend.log stayed
invisible.

* fix(ui): keep a failure recorded during startup from being overwritten

initNotifications assigned the stored list over whatever was already in
memory. Reading the store is async, so a failure recorded while that read was
in flight was dropped -- losing exactly the notification the user would then
go looking for. Merge by id instead, newest first.

Latent rather than observed: the current call order records nothing that
early. It is one line, and the alternative is a bug that only ever appears
when something else has already gone wrong.

* test(e2e): stop the update check reaching GitHub, and pin the shared badge

CI failed two notification tests that pass on any developer machine. The
update check hits api.github.com for real; when the published release is newer
than the version under test, an update card appears and lights the same badge
failure notifications use. The tests then saw a lit badge with no failures.
Locally it never happened, because a dev build reports a version containing
"dev" and the check skips those -- the tests were passing for the wrong reason.

Answer the update check from the test instead, which also takes an external
service out of the path of every run.

The behaviour CI caught is correct and now has a test of its own: with an
update pending, dismissing the last failure card leaves the badge lit and the
empty state hidden, because the update is still there. openStudio grows an
`updateAvailable` option that forces that state (stubbing the version too --
the check skips dev builds, so a release-looking version is required for the
card to appear at all).

---------

Co-authored-by: Thales <>
2026-08-16 21:11:43 +01:00

101 lines
3.2 KiB
Python

"""GET /api/jobs/{id}/failure — the quarantined evidence, minus the private bits.
The pipeline has always written jobs/failed/<id>/error.txt on a failure (#277)
and nothing ever read it back, so a bug report could carry the classified cause
and one truncated stderr line at most. These tests pin the two things that make
the endpoint safe to feed into a public GitHub issue: it serves the technical
keys and the stderr tail, and it never serves the track title or source URL.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
JOB = "abcdefabcdef"
TITLE = "Someone's Private Demo Take 3"
SOURCE = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
ERROR_TXT = f"""time: 2026-08-16T18:00:00+00:00
job: {JOB}
title: {TITLE}
source: {SOURCE}
stage: Error: Processing failed
device: cuda, then cpu
model: htdemucs_6s
cause: out-of-memory
timings: {{"download": 4.2, "separate": 61.0}}
exception: SeparationError('demucs failed: exit status 1')
--- stderr tail ---
torch.OutOfMemoryError: CUDA out of memory.
Tried to allocate 2.40 GiB
"""
@pytest.fixture
def client(tmp_path, monkeypatch):
from app.api import jobs as jobs_mod
monkeypatch.setattr(jobs_mod, "JOBS_DIR", tmp_path)
from app.main import app
return TestClient(app)
@pytest.fixture
def quarantined(tmp_path):
d = tmp_path / "failed" / JOB
d.mkdir(parents=True)
(d / "error.txt").write_text(ERROR_TXT, encoding="utf-8")
return d
def test_serves_the_technical_fields(client, quarantined):
r = client.get(f"/api/jobs/{JOB}/failure")
assert r.status_code == 200
body = r.json()
assert body["job_id"] == JOB
assert body["cause"] == "out-of-memory"
assert body["device"] == "cuda, then cpu"
assert body["model"] == "htdemucs_6s"
# "stage: Error: Processing failed" must keep everything after the first
# colon, or the most useful field arrives as a bare "Error".
assert body["stage"] == "Error: Processing failed"
assert "SeparationError" in body["exception"]
def test_serves_the_stderr_tail(client, quarantined):
body = client.get(f"/api/jobs/{JOB}/failure").json()
assert body["tail"] == [
"torch.OutOfMemoryError: CUDA out of memory.",
"Tried to allocate 2.40 GiB",
]
def test_never_serves_the_title_or_source_url(client, quarantined):
"""The whole point of parsing error.txt instead of serving it: these issues
are public, and what the user was working on is theirs to disclose."""
r = client.get(f"/api/jobs/{JOB}/failure")
assert TITLE not in r.text
assert SOURCE not in r.text
body = r.json()
assert "title" not in body
assert "source" not in body
def test_404_when_the_job_never_failed(client, tmp_path):
assert client.get(f"/api/jobs/{JOB}/failure").status_code == 404
def test_404_for_a_malformed_job_id(client):
assert client.get("/api/jobs/not-a-job-id/failure").status_code == 404
def test_traversal_out_of_the_quarantine_is_refused(client):
"""The id pattern already rejects separators; this pins it at the route so a
future loosening of JOB_ID_RE cannot turn this into an arbitrary file read."""
for evil in ("../../etc/passwd", "..%2f..%2fsecret", "failed"):
assert client.get(f"/api/jobs/{evil}/failure").status_code == 404