2c3541d311
* 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 <>
101 lines
3.2 KiB
Python
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
|