commit e6310a8c1230604409f5b2df88151203e4e360dd
parent e4bc59181ffa629a0c7c6d4fb36383af208a3398
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 12:46:59 -0400
channels: a Diagnostics retry keeps its card and its log when it empties the bucket
Both Diagnostics grids dropped an emptied bucket's card — `populated` for the
channel-health cards, `listed` for the availability cards — and a retry
empties its own bucket while it streams (the per-video snapshot regen and
the end-of-run refresh), so the card took RetryBucketControl, its
StreamActionLog and the run's log with it. The same hole O3 closed on the
Download stage (cookies-mode.spec:241).
DiagnosticsStage and AvailabilitySummary now keep a `ran` set (bucket keys
whose Retry ran on this page, told by RetryBucketControl's onRun); a ran
card stays, its button disabled at (0). AvailabilitySummary's hook sits
above its `total === 0` early return, which also yields to a ran card. A
reload drops an empty card as before. Labels and test ids unchanged.
e2e: diagnostics-retry-log.spec.ts (2) — a Missing metadata retry and a
Needs auth retry whose bucket empties mid-run; each waits for the
refreshed EMPTY list, which renders only inside a card that survived.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 190 insertions(+), 4 deletions(-)
diff --git a/editor/app/channels/[slug]/components/stages/DiagnosticsStage.tsx b/editor/app/channels/[slug]/components/stages/DiagnosticsStage.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useCallback, useState } from "react";
import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
import {
AVAILABILITY_VALUES,
@@ -153,7 +153,15 @@ export function DiagnosticsStage({
ariaLabel: "chat only pending",
},
];
- const populated = buckets.filter((b) => b.ids.length > 0);
+ // A RETRY EMPTIES ITS OWN BUCKET, and its card must outlive that (release 13
+ // slice W1; plans/FACTS.md, "A run log lives in the panel's React state").
+ // The run refreshes the page after every video it fetches, and a card whose
+ // bucket had gone to 0 was filtered out here — RetryBucketControl, its
+ // StreamActionLog and the log with it. `ran` holds the buckets whose Retry
+ // ran on this page; such a card stays, its button disabled at (0). A reload
+ // drops it as before.
+ const [ran, markRan] = useRanBuckets();
+ const populated = buckets.filter((b) => b.ids.length > 0 || ran.has(b.ariaLabel));
const total = populated.reduce((acc, b) => acc + b.ids.length, 0);
return (
<div className="flex flex-col gap-6">
@@ -265,6 +273,7 @@ export function DiagnosticsStage({
actionLabel={b.ariaLabel}
defaultQueueKey={downloadDefaultQueueKey}
existingQueues={existingQueues}
+ onRun={() => markRan(b.ariaLabel)}
/>
) : null}
</div>
@@ -372,6 +381,18 @@ function VerifyResultBucket({
);
}
+// The bucket keys whose Retry has run on this page (see DiagnosticsStage and
+// AvailabilitySummary). Client state only: a reload starts empty.
+function useRanBuckets(): [ReadonlySet<string>, (key: string) => void] {
+ const [ran, setRan] = useState<ReadonlySet<string>>(() => new Set());
+ const markRan = useCallback(
+ (key: string) =>
+ setRan((prev) => (prev.has(key) ? prev : new Set([...prev, key]))),
+ [],
+ );
+ return [ran, markRan];
+}
+
function Heading({ title, desc }: { title: string; desc: string }) {
return (
<div>
@@ -672,12 +693,17 @@ function AvailabilitySummary({
existingQueues: string[];
downloadDefaultQueueKey: string;
}) {
+ // Above the early return (hooks run on every render). A needs-auth or error
+ // retry does not empty its own bucket — only a probe rewrites
+ // availability.json — but a check landing while it streams does, and the
+ // card went with its log exactly as the channel-health cards did.
+ const [ran, markRan] = useRanBuckets();
const total =
AVAILABILITY_VALUES.reduce(
(acc, v) => acc + availability.byStatus[v].length,
0,
) + availability.unchecked.length;
- if (total === 0) {
+ if (total === 0 && ran.size === 0) {
return (
<p
aria-label="availability summary empty"
@@ -692,7 +718,7 @@ function AvailabilitySummary({
n: availability.byStatus[v].length,
})).filter((x) => x.n > 0);
const listed = AVAILABILITY_LIST_STATUSES.filter(
- (v) => availability.byStatus[v].length > 0,
+ (v) => availability.byStatus[v].length > 0 || ran.has(v),
);
return (
<div
@@ -744,6 +770,7 @@ function AvailabilitySummary({
actionLabel={AVAILABILITY_LABELS[v].toLowerCase()}
defaultQueueKey={downloadDefaultQueueKey}
existingQueues={existingQueues}
+ onRun={() => markRan(v)}
/>
)}
</div>
diff --git a/editor/e2e/diagnostics-retry-log.spec.ts b/editor/e2e/diagnostics-retry-log.spec.ts
@@ -0,0 +1,159 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import { dirname } from "node:path";
+import { test, expect } from "@playwright/test";
+import {
+ channelStage,
+ generateReport,
+ readJson,
+ resetData,
+ resolvePath,
+} from "./helpers";
+
+// A DIAGNOSTICS RETRY KEEPS ITS LOG WHEN ITS BUCKET EMPTIES (release 13 slice
+// W1) — the Diagnostics twin of cookies-mode.spec's "defer" test (release 11
+// slice O3), which closed the same hole on the Download stage.
+//
+// A retry empties its own bucket, and the run refreshes the page after every
+// video it fetches (recordTaskDone → the snapshot regen → the pulse →
+// AutoRefresh), then once more when it ends. Both Diagnostics grids used to
+// filter an emptied bucket's card out — `populated` for the channel-health
+// cards, `listed` for the availability cards — and the card took the
+// RetryBucketControl, its StreamActionLog and the run's log with it. Each test
+// below waits for the refreshed, EMPTY list, which renders only inside a card
+// that survived the refresh: on the old code that wait is what fails, every
+// time, rather than the log check racing the refresh.
+
+const CHANNEL = "availability-test";
+const FIXTURE = "availability-baseline";
+const ROOT = `test-transcripts/channels/${CHANNEL}`;
+
+type Snapshot = {
+ buckets?: { noMetadata?: string[] };
+ availability?: { byStatus?: Record<string, string[]> };
+};
+
+async function writePlaylist(ids: string[]): Promise<void> {
+ // retry-bucket reads the channel playlist to learn each id's URL.
+ await writeFile(
+ resolvePath(`${ROOT}/playlist`),
+ ids.map((id) => `https://www.youtube.com/watch?v=${id}\n`).join(""),
+ );
+}
+
+async function writeAvailability(id: string, availability: string) {
+ const path = resolvePath(`${ROOT}/data/${id}/availability.json`);
+ await mkdir(dirname(path), { recursive: true });
+ await writeFile(
+ path,
+ JSON.stringify({
+ checkedAt: new Date().toISOString(),
+ availability,
+ webpageUrl: `https://www.youtube.com/watch?v=${id}`,
+ }),
+ );
+}
+
+async function waitForSnapshot(
+ read: (snap: Snapshot) => number | undefined,
+ size: number,
+): Promise<void> {
+ await expect
+ .poll(
+ async () => {
+ const snap = await readJson<Snapshot>(`${ROOT}/snapshot.json`).catch(
+ () => null,
+ );
+ return snap ? (read(snap) ?? -1) : -1;
+ },
+ { timeout: 15_000 },
+ )
+ .toBe(size);
+}
+
+test("channel health: a Retry that empties Missing metadata keeps its card and its log", async ({
+ page,
+}) => {
+ test.setTimeout(90_000);
+ await resetData(FIXTURE);
+ // A video directory with nothing in it: no metadata.info.json, so it is in
+ // the retryable "Missing metadata.info.json" bucket. The retry's download
+ // writes the metadata, which is what empties the bucket.
+ const ID = "vidnometa1";
+ await mkdir(resolvePath(`${ROOT}/data/${ID}`), { recursive: true });
+ await writePlaylist([ID]);
+
+ await generateReport(page, CHANNEL);
+ await waitForSnapshot((s) => s.buckets?.noMetadata?.length, 1);
+ await page.goto(channelStage(CHANNEL, "diagnostics"));
+
+ const card = page.getByLabel("retry missing metadata bucket");
+ await card.getByRole("button", { name: /^Retry \(1\)$/ }).click();
+ const log = page.getByLabel("Retry missing metadata output");
+ await expect(log).toContainText("Retry allowlist: kept 1 of 1", {
+ timeout: 30_000,
+ });
+ await expect(log).toContainText("Managed download complete", {
+ timeout: 30_000,
+ });
+
+ // The bucket empties once the snapshot regenerates…
+ await waitForSnapshot((s) => s.buckets?.noMetadata?.length, 0);
+ // …and the page refreshes onto the EMPTY list, inside the card that must
+ // have survived that refresh, with the run's log still in it.
+ await expect(page.getByLabel("missing metadata empty")).toBeVisible({
+ timeout: 20_000,
+ });
+ await expect(
+ page.getByRole("heading", { name: "Missing metadata.info.json (0)" }),
+ ).toBeVisible();
+ await expect(log).toContainText("Managed download complete");
+ await expect(card.getByRole("button", { name: /^Retry \(0\)$/ })).toBeDisabled();
+ // "All clear." is what the grid used to fall back to once its only card went.
+ await expect(page.getByLabel("channel health empty")).toHaveCount(0);
+
+ // A fresh page has nothing to show for an empty bucket.
+ await page.reload();
+ await expect(page.getByLabel("channel health empty")).toBeVisible();
+ await expect(page.getByLabel("retry missing metadata bucket")).toHaveCount(0);
+});
+
+test("availability: a Needs auth card whose bucket empties during its Retry keeps its log", async ({
+ page,
+}) => {
+ test.setTimeout(90_000);
+ await resetData(FIXTURE);
+ const ID = "vidneedsauth1";
+ await writeAvailability(ID, "needs_auth");
+ await writePlaylist([ID]);
+
+ await generateReport(page, CHANNEL);
+ await waitForSnapshot((s) => s.availability?.byStatus?.needs_auth?.length, 1);
+ await page.goto(channelStage(CHANNEL, "diagnostics"));
+ const card = page.getByLabel("retry needs auth bucket");
+ await expect(card.getByRole("button", { name: /^Retry \(1\)$/ })).toBeVisible();
+
+ // A retry never rewrites availability.json — a download records its
+ // availability history only, with the top level untouched — so what empties
+ // an availability bucket is a PROBE. Land one now, as a check running beside
+ // the retry would: the page still shows the snapshot it rendered from, and
+ // the regeneration the retry's own download triggers is the first to read it.
+ await writeAvailability(ID, "public");
+
+ await card.getByRole("button", { name: /^Retry \(1\)$/ }).click();
+ const log = page.getByLabel("Retry needs auth output");
+ await expect(log).toContainText("Managed download complete", {
+ timeout: 30_000,
+ });
+
+ await waitForSnapshot((s) => s.availability?.byStatus?.needs_auth?.length, 0);
+ await expect(page.getByLabel("availability needs_auth empty")).toBeVisible({
+ timeout: 20_000,
+ });
+ await expect(page.getByRole("heading", { name: "Needs auth (0)" })).toBeVisible();
+ await expect(log).toContainText("Managed download complete");
+ await expect(card.getByRole("button", { name: /^Retry \(0\)$/ })).toBeDisabled();
+
+ await page.reload();
+ await expect(page.getByLabel("availability public count")).toBeVisible();
+ await expect(page.getByLabel("retry needs auth bucket")).toHaveCount(0);
+});