commit 5d4d2200921bb673425fca00631bfca322ff456b
parent 09088c9fbaa41df654ec15b57c3579f977385fc4
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 8 Sep 2026 22:21:42 -0400
editor: a kept run panel is not a second Run button, and the alert goes with the warning
Review follow-ups on 09088c9, plus the fourth instance of the same rule.
A panel kept mounted so its log survives is kept for the LOG. Its button had
stayed enabled, so "Persist source video" invited a second re-fetch of a
container already in the store, and each banner invited a re-run of the
operation that had just cleared its own flag. StreamActionLog already takes
`disabled`, so each of the four passes the now-cleared condition to it:
`!!savedVideo`, `!coverage`, `!flagged`, `hasTranscript`. No new prop.
The banners' wrapper also kept `role="alert"` and its warning aria-label after
the warning text was gone — a box announcing itself as an alert named
"incomplete transcript" for a video that is now fine, and one that would still
answer getByLabel("incomplete transcript") (truncated-check.spec.ts:69 asserts
it does not). Both attributes are conditional on the flag now and unchanged
while it holds.
The fourth instance is PerFileTranscribeRow. `hasTranscript` is server-derived
(VideoPanel :360) and this row's own run writes transcript.json, so the refresh
the run fires swapped the panel for "Transcribe disabled — transcript already
present." and destroyed the log. The row itself survives (keyed by file name),
so the fix is local and the same shape as the banners: message and run panel in
separate slots, the panel kept — disabled — once a run has started here.
video-page.spec.ts's existing transcribe-one case now reads the log's closing
line AFTER that message is visible, and asserts the button is not actionable.
Extended in place rather than added, so no test gains a new neighbour.
PerFileTranscodeRow was checked and is NOT an instance: its condition is the
file's extension against a static format list, which a transcode cannot change.
The four cases also get their own 120 s budget (as attribution.spec.ts does).
They spend three sequential waits, and under the config's 30 s a real failure
read "Test timeout of 30000ms exceeded" instead of naming the assertion.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
5 files changed, 96 insertions(+), 9 deletions(-)
diff --git a/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx b/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx
@@ -718,6 +718,15 @@ function PerFileTranscribeRow({
replaceAutoSubs?: boolean;
}) {
const [transcribeQueue, setTranscribeQueue] = useState("");
+ // `hasTranscript` is SERVER-derived (VideoPanel :360) and this row's own run
+ // is what writes transcript.json — so the run's own router.refresh() used to
+ // swap this panel for the "already present" line and take the log with it.
+ // Same shape as the two banners: the row survives (it is keyed by file name),
+ // the message and the run panel are separate slots, and the panel stays
+ // mounted — disabled, since the transcript now exists — once a run has
+ // started here. With no run it is still absent whenever a transcript is
+ // already present, so the DOM is unchanged.
+ const [ranHere, setRanHere] = useState(false);
return (
<div className="flex flex-col gap-2 rounded border border-border p-3">
@@ -739,11 +748,20 @@ function PerFileTranscribeRow({
<p className="text-sm text-muted-foreground">
Transcribe disabled — transcript already present.
</p>
- ) : (
+ ) : null}
+ {hasTranscript && !ranHere ? null : (
<StreamActionLog
- trigger={() =>
- transcribeOneAction(slug, videoId, file.name, transcribeQueue)
- }
+ key="transcribe-run"
+ disabled={hasTranscript}
+ trigger={() => {
+ setRanHere(true);
+ return transcribeOneAction(
+ slug,
+ videoId,
+ file.name,
+ transcribeQueue,
+ );
+ }}
cancelAction={cancelJobAction}
buttonLabel={
replaceAutoSubs
@@ -1093,6 +1111,10 @@ function SourceVideoSection({
{savedVideo && !ranHere ? null : (
<StreamActionLog
key="persist-run"
+ // Mounted after a run so its log survives, but NOT actionable once the
+ // pointer is on disk: re-running would re-fetch a container the store
+ // already has.
+ disabled={!!savedVideo}
trigger={() => {
setRanHere(true);
return redownloadToArchiveAction(slug, videoId, queueKey);
@@ -1534,8 +1556,16 @@ function IncompleteTranscriptBanner({
const actionLabel = `Re-download & re-transcribe ${videoId}`;
return (
<div
- role="alert"
- aria-label="incomplete transcript"
+ // The alert role and its label belong to the WARNING, not to the box that
+ // outlives it: once the run has cleared the flag this wrapper holds only
+ // the log, and announcing that as an alert named "incomplete transcript"
+ // would be a lie to a screen reader (and would keep answering
+ // getByLabel("incomplete transcript") for a video that is now fine —
+ // truncated-check.spec.ts:69 asserts exactly that it does not). While the
+ // flag holds, both attributes are exactly as they were.
+ {...(coverage
+ ? { role: "alert", "aria-label": "incomplete transcript" }
+ : {})}
className="flex flex-col gap-3 rounded border px-3 py-2 text-sm border-warning/30 bg-warning-soft text-warning"
>
{coverage ? (
@@ -1552,6 +1582,9 @@ function IncompleteTranscriptBanner({
) : null}
<StreamActionLog
key="run"
+ // Kept for its log, not for a second run: the transcript is no longer
+ // truncated once the flag is gone.
+ disabled={!coverage}
trigger={() => {
setRanHere(true);
return redownloadIncompleteTranscriptAction(slug, videoId, queueKey);
@@ -1607,8 +1640,10 @@ function ShortAudioBanner({
const actionLabel = `Re-download ${videoId} as Original`;
return (
<div
- role="alert"
- aria-label="short audio"
+ // Conditional for the same reason as IncompleteTranscriptBanner above: the
+ // alert belongs to the warning, and the warning is gone once the
+ // re-download has cleared the flag.
+ {...(flagged ? { role: "alert", "aria-label": "short audio" } : {})}
className="flex flex-col gap-3 rounded border px-3 py-2 text-sm border-warning/30 bg-warning-soft text-warning"
>
{flagged ? (
@@ -1629,6 +1664,9 @@ function ShortAudioBanner({
) : null}
<StreamActionLog
key="run"
+ // Kept for its log, not for a second run: the download that cleared the
+ // flag is the one whose output this is.
+ disabled={!flagged}
trigger={() => {
setRanHere(true);
return downloadVideoPipelineAction(slug, videoId, queueKey, "original");
diff --git a/editor/e2e/download-format-guard.spec.ts b/editor/e2e/download-format-guard.spec.ts
@@ -170,6 +170,8 @@ test("duration guard flags a source-truncated download and keeps the stub", asyn
test("the short-audio re-download's log survives the refresh that clears the banner", async ({
page,
}) => {
+ // See saved-videos.spec.ts: sequential waits need their own budget.
+ test.setTimeout(120_000);
const SLUG = "shortaudio-log";
// No `truncaud` sentinel: the re-download lands full-length audio, so the
// duration guard passes and the outcome is rewritten as a success.
@@ -217,6 +219,11 @@ test("the short-audio re-download's log survives the refresh that clears the ban
).toHaveCount(0, { timeout: 15_000 });
// And the log is STILL on screen, carrying the run's closing line.
await expect(log).toContainText("download complete");
+ // Kept for its log, not for a second run: the download that cleared the flag
+ // is the one whose output this is.
+ await expect(
+ page.getByRole("button", { name: "Re-download as Original" }),
+ ).toBeDisabled();
});
test("download format: Odysee auto picks original; YouTube uses bestaudio; channel override forces original", async ({
diff --git a/editor/e2e/incomplete-transcript.spec.ts b/editor/e2e/incomplete-transcript.spec.ts
@@ -166,6 +166,9 @@ test("incomplete-transcript filter, glyph, panel banner, and the transcription p
test("the fix run's log survives the refresh that clears the banner", async ({
page,
}) => {
+ // See saved-videos.spec.ts: sequential waits, so give the case its own budget
+ // rather than let the 30 s default mask which assertion failed.
+ test.setTimeout(120_000);
await resetData();
const slug = "incomplete-log";
const id = "vidVttTrunc";
@@ -226,6 +229,12 @@ test("the fix run's log survives the refresh that clears the banner", async ({
});
// And the log is STILL on screen, carrying the run's closing line.
await expect(log).toContainText(`Normalized ${slug}/${id}`);
+ // Kept for its log, not for a second run: the transcript is no longer
+ // truncated. (Page-scoped, not banner-scoped — the wrapper drops the alert
+ // label with the warning it belonged to.)
+ await expect(
+ page.getByRole("button", { name: "Re-download & re-transcribe" }),
+ ).toBeDisabled();
});
test("channel bulk bar: clear incomplete resets the video and enables auto-runners", async ({
diff --git a/editor/e2e/saved-videos.spec.ts b/editor/e2e/saved-videos.spec.ts
@@ -126,6 +126,10 @@ test("video page shows persisted source status and an unpersist control", async
test("the persist run's log survives the refresh that lands the pointer", async ({
page,
}) => {
+ // Three waits in sequence (the run, the pointer landing, the log) — without
+ // this the config's 30 s budget expires first and the failure reads as a test
+ // timeout instead of naming the assertion that actually failed.
+ test.setTimeout(120_000);
await resetData("one-transcribe-channel-with-audio");
// redownloadToArchiveAction resolves the source URL from metadata.info.json
// or the playlist, and this fixture ships neither.
@@ -138,7 +142,12 @@ test("the persist run's log survives the refresh that lands the pointer", async
await page.goto(`/channels/${SLUG}/videos/vidA`);
// Nothing is persisted yet, so the card starts closed.
await page.getByLabel("Source video stage summary").click();
- const run = page.getByRole("button", { name: "Persist source video" });
+ // exact: the persisted view's "unpersist source video vidA" button matches
+ // this name as a substring once the run has landed the pointer.
+ const run = page.getByRole("button", {
+ name: "Persist source video",
+ exact: true,
+ });
await expect(run).toBeEnabled();
await run.click();
@@ -154,4 +163,6 @@ test("the persist run's log survives the refresh that lands the pointer", async
});
// And the log is STILL on screen, carrying the run's closing line.
await expect(log).toContainText("Persisted source video to");
+ // Kept for its log, not for a second run: the container is in the store now.
+ await expect(run).toBeDisabled();
});
diff --git a/editor/e2e/video-page.spec.ts b/editor/e2e/video-page.spec.ts
@@ -99,6 +99,9 @@ test("failed entry stays in the list across reloads (skip semantics)", async ({
test("transcribe-one writes transcript.json for a specific audio file", async ({
page,
}) => {
+ // The disk poll plus the three UI waits below outrun the config's 30 s budget
+ // on a slow box, and a budget timeout hides which assertion failed.
+ test.setTimeout(120_000);
await resetData("one-transcribe-channel-with-audio");
await page.goto("/channels/test-transcribe/videos/vidA");
await page.getByRole("button", { name: "Transcribe audio.m4a" }).click();
@@ -113,6 +116,25 @@ test("transcribe-one writes transcript.json for a specific audio file", async ({
{ timeout: 30_000 },
)
.toBe(true);
+
+ // AND THE ROW'S LOG SURVIVES THE REFRESH ITS OWN RUN FIRES. `hasTranscript`
+ // is server-derived, so the transcript this run just wrote makes the row
+ // render its "already present" line; when that line REPLACED the run panel,
+ // StreamActionLog was unmounted and the log the operator was reading went
+ // with it (plans/FACTS.md, "A run log lives in the panel's React state").
+ // Asserted only after that line is on screen, so the refresh has provably
+ // landed — the same happens-before edge attribution.spec.ts takes on the
+ // freshness pill.
+ await expect(
+ page.getByText("Transcribe disabled — transcript already present."),
+ ).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByLabel("Transcribe audio.m4a output")).toContainText(
+ "Transcribe vidA done in",
+ );
+ // Kept for its log, not for a second run.
+ await expect(
+ page.getByRole("button", { name: "Transcribe audio.m4a" }),
+ ).toBeDisabled();
});
test("transcode keeps source and writes audio.<target>", async ({ page }) => {