fix(review): resolve deep-review findings across quickfix batch #2
- Button: `loading` always disables (|| not ??) — stops a double-submit when a call-site passes an explicit boolean `disabled` (VideoEditor). - playlists: track un-ingestable-but-live YouTube ids in the sync baseline so a linked playlist containing a private/deleted video converges instead of re-classifying it as a pending pull forever; re-attempt ingestion each sync so a restored (private->public) video heals, and prune ids that leave YouTube. - SettingsPanel: download-auth section renders an honest error/loading state (StateMessage + retry) instead of a bare empty section on a failed credentials fetch; HU+EN loadError string. - tests: reorder-order, partial-failure baseline hold, un-ingestable converge/heal/prune, Button loading-disables, download-auth error/retry.
This commit is contained in:
@@ -33,7 +33,12 @@ import { DownloadAuth } from "./SettingsPanel";
|
||||
|
||||
const HOSTS = [
|
||||
{ host_key: "facebook", label: "Facebook", configured: false, updated_at: null },
|
||||
{ host_key: "instagram", label: "Instagram", configured: true, updated_at: "2026-08-01T00:00:00Z" },
|
||||
{
|
||||
host_key: "instagram",
|
||||
label: "Instagram",
|
||||
configured: true,
|
||||
updated_at: "2026-08-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
function renderPanel() {
|
||||
@@ -73,7 +78,9 @@ describe("DownloadAuth (write-only cookies)", () => {
|
||||
const field = (await screen.findByTestId("cookie-input-facebook")) as HTMLTextAreaElement;
|
||||
expect(field.value).toBe(""); // write-only: never prefilled
|
||||
|
||||
fireEvent.change(field, { target: { value: "# Netscape\n.facebook.com\tTRUE\t/\tT\t0\tc\t1\n" } });
|
||||
fireEvent.change(field, {
|
||||
target: { value: "# Netscape\n.facebook.com\tTRUE\t/\tT\t0\tc\t1\n" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("settings.downloadAuth.save"));
|
||||
await waitFor(() =>
|
||||
expect(h.setDownloadCredential).toHaveBeenCalledWith(
|
||||
@@ -90,4 +97,17 @@ describe("DownloadAuth (write-only cookies)", () => {
|
||||
const field = (await screen.findByTestId("cookie-input-instagram")) as HTMLTextAreaElement;
|
||||
expect(field.value).toBe(""); // the stored blob is never fetched into the field
|
||||
});
|
||||
|
||||
it("renders an honest error state with retry when the fetch fails, not an empty section", async () => {
|
||||
// Honest-states: a failed load must NOT render as a bare empty section (no host rows).
|
||||
h.downloadCredentials.mockRejectedValueOnce(new Error("boom"));
|
||||
h.downloadCredentials.mockResolvedValue({ hosts: HOSTS });
|
||||
renderPanel();
|
||||
// The error message shows and no host rows render.
|
||||
await screen.findByText("settings.downloadAuth.loadError");
|
||||
expect(screen.queryByTestId("cookie-host-facebook")).toBeNull();
|
||||
// Retry refetches → the list appears.
|
||||
fireEvent.click(screen.getByText("common.retry"));
|
||||
await screen.findByTestId("cookie-host-facebook");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { inputCls, Section, SettingRow, Switch } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
import { type SaveState } from "./ui/DraftSaveBar";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { StateMessage } from "./QueryState";
|
||||
import { useWizard } from "./WizardProvider";
|
||||
import { usePrefs } from "./PrefsProvider";
|
||||
import Tabs from "./Tabs";
|
||||
@@ -633,91 +634,107 @@ export function DownloadAuth() {
|
||||
return (
|
||||
<Section title={t("settings.downloadAuth.title")}>
|
||||
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.downloadAuth.intro")}</p>
|
||||
<div className="divide-y divide-border">
|
||||
{hosts.map((h) => {
|
||||
const when = h.updated_at ? new Date(h.updated_at).toLocaleDateString(i18n.language) : "";
|
||||
const open = openHost === h.host_key;
|
||||
return (
|
||||
<div key={h.host_key} className="py-2" data-testid={`cookie-host-${h.host_key}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{h.label}</div>
|
||||
<p className="text-xs text-muted mt-0.5" data-testid={`cookie-status-${h.host_key}`}>
|
||||
{h.configured
|
||||
? t("settings.downloadAuth.configured", { when })
|
||||
: t("settings.downloadAuth.notSet")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{h.configured && (
|
||||
<Button
|
||||
variant="danger-outline"
|
||||
size="sm"
|
||||
onClick={() => onRemove(h.host_key)}
|
||||
disabled={remove.isPending}
|
||||
{/* The host list is server-provided (always non-empty on success), so an empty `hosts` means
|
||||
the fetch failed or is still loading — render an honest state, never a bare empty section
|
||||
that reads as "nothing to configure here". */}
|
||||
{q.isError && hosts.length === 0 ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("settings.downloadAuth.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading ? (
|
||||
<StateMessage>{t("common.loading")}</StateMessage>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{hosts.map((h) => {
|
||||
const when = h.updated_at
|
||||
? new Date(h.updated_at).toLocaleDateString(i18n.language)
|
||||
: "";
|
||||
const open = openHost === h.host_key;
|
||||
return (
|
||||
<div key={h.host_key} className="py-2" data-testid={`cookie-host-${h.host_key}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{h.label}</div>
|
||||
<p
|
||||
className="text-xs text-muted mt-0.5"
|
||||
data-testid={`cookie-status-${h.host_key}`}
|
||||
>
|
||||
{t("settings.downloadAuth.remove")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setOpenHost(open ? null : h.host_key);
|
||||
setDraft("");
|
||||
}}
|
||||
>
|
||||
{h.configured
|
||||
? t("settings.downloadAuth.update")
|
||||
: t("settings.downloadAuth.set")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{/* Write-only: the field is ALWAYS empty on open — the stored blob is never
|
||||
fetched or prefilled. */}
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
rows={5}
|
||||
placeholder={t("settings.downloadAuth.placeholder")}
|
||||
className={`${inputCls} font-mono text-xs resize-y`}
|
||||
data-testid={`cookie-input-${h.host_key}`}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-2xs text-muted leading-relaxed">
|
||||
{t("settings.downloadAuth.help")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{h.configured
|
||||
? t("settings.downloadAuth.configured", { when })
|
||||
: t("settings.downloadAuth.notSet")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{h.configured && (
|
||||
<Button
|
||||
variant="danger-outline"
|
||||
size="sm"
|
||||
onClick={() => onRemove(h.host_key)}
|
||||
disabled={remove.isPending}
|
||||
>
|
||||
{t("settings.downloadAuth.remove")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={!draft.trim() || save.isPending}
|
||||
onClick={() => save.mutate({ host: h.host_key, cookies: draft })}
|
||||
>
|
||||
{save.isPending
|
||||
? t("settings.downloadAuth.saving")
|
||||
: t("settings.downloadAuth.save")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setOpenHost(null);
|
||||
setOpenHost(open ? null : h.host_key);
|
||||
setDraft("");
|
||||
}}
|
||||
>
|
||||
{t("settings.downloadAuth.cancel")}
|
||||
{h.configured
|
||||
? t("settings.downloadAuth.update")
|
||||
: t("settings.downloadAuth.set")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{open && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{/* Write-only: the field is ALWAYS empty on open — the stored blob is never
|
||||
fetched or prefilled. */}
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
rows={5}
|
||||
placeholder={t("settings.downloadAuth.placeholder")}
|
||||
className={`${inputCls} font-mono text-xs resize-y`}
|
||||
data-testid={`cookie-input-${h.host_key}`}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-2xs text-muted leading-relaxed">
|
||||
{t("settings.downloadAuth.help")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={!draft.trim() || save.isPending}
|
||||
onClick={() => save.mutate({ host: h.host_key, cookies: draft })}
|
||||
>
|
||||
{save.isPending
|
||||
? t("settings.downloadAuth.saving")
|
||||
: t("settings.downloadAuth.save")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setOpenHost(null);
|
||||
setDraft("");
|
||||
}}
|
||||
>
|
||||
{t("settings.downloadAuth.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,22 @@ describe("Button primitive (S5)", () => {
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loading disables even when an explicit disabled={false} is passed", () => {
|
||||
// Regression: `disabled ?? loading` let an explicit boolean disabled shadow loading, so a
|
||||
// call-site with `disabled={!valid} loading={pending}` (valid → disabled=false) stayed
|
||||
// clickable mid-mutation and double-submitted. The contract is: loading always disables.
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<Button disabled={false} loading onClick={onClick}>
|
||||
Create
|
||||
</Button>,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Create" }) as HTMLButtonElement;
|
||||
expect(btn.disabled).toBe(true);
|
||||
fireEvent.click(btn);
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fullWidth adds the w-full utility", () => {
|
||||
render(<Button fullWidth>Wide</Button>);
|
||||
expect(screen.getByRole("button", { name: "Wide" }).className).toContain("w-full");
|
||||
|
||||
@@ -86,7 +86,10 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
<button
|
||||
ref={ref}
|
||||
type={type ?? "button"}
|
||||
disabled={disabled ?? loading}
|
||||
// `loading` must always disable (see the prop's contract above), even when a call-site also
|
||||
// passes an explicit boolean `disabled` — so `||`, not `??`. With `??`, `disabled={false}`
|
||||
// would shadow `loading` and leave the button clickable mid-mutation (double-submit).
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || undefined}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
|
||||
Reference in New Issue
Block a user