From 2069b1cec82836fab7e395f8031834b98953459d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 12:19:03 +0000 Subject: [PATCH 01/18] feat(workspace): name Paste another YouTube link after import fails When YouTube intake fails, name the next rehearsal action and keep the failed URL ready to replace. Field-level diagnostics stay redacted; the recovery action only focuses the existing YouTube field. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 4 +- apps/desktop/src/App.test.tsx | 70 +++++++++++++++- apps/desktop/src/App.tsx | 21 +++++ ...pp.youtubeImportFailureVisibility.test.tsx | 79 ++++++++++++++++++ .../workspace/WorkspaceStates.test.tsx | 82 +++++++++++++++++++ .../features/workspace/WorkspaceStates.tsx | 39 ++++++++- apps/desktop/src/locales/en/common.json | 3 + apps/desktop/src/locales/ko/common.json | 3 + 11 files changed, 295 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/src/App.youtubeImportFailureVisibility.test.tsx create mode 100644 apps/desktop/src/features/workspace/WorkspaceStates.test.tsx diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..ca253f39c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working - Keep UI and analysis engine decoupled through shared contracts. - Prefer minimal, test-first changes for production code. - Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language. +- When YouTube import fails, customer-facing copy must name Paste another YouTube link as the next rehearsal action. The field-level message stays redacted; the recovery action only focuses the existing YouTube field. - Do not reduce the product to a chord analyzer when form, timing, player coordination, simplification, and setup cues are the real rehearsal blockers. - Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3302a6fc3..384e7e5e2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,6 +6,7 @@ Last updated: 2026-03-11 - Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`. - Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth. +- Failed YouTube import names Paste another YouTube link as the next action. Buyer-visible copy stays redacted and does not retry the network from the recovery card. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..1448395e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- When a YouTube link cannot be imported, the workspace names Paste another YouTube link as the next rehearsal action and keeps the failed URL ready to replace. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index 82c2c704a..ac0f1e85d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co `AGENTS.md` is the canonical agent operating guide — read and follow it before making changes. It defines the security workflow (`Security Notes`), supply-chain workflow, cross-platform build rules, GitHub bootstrap rules, code style, and safety guardrails. This file complements it with commands and architecture; when in doubt, `AGENTS.md` and the docs it references win. -Agent execution and delegation rules live in `docs/agents/README.md`. PR canonicalization rules live in `docs/workflow/pr-continuity.md`. +When YouTube import fails, the workspace must name Paste another YouTube link as the next action. The recovery action only focuses the YouTube field. + +Agent execution and delegation rules live in `docs/agents/README.md`. PR continuity and duplicate-handling policy live in `docs/workflow/pr-continuity.md`. ## Common commands diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..14edfe210 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -1162,11 +1162,13 @@ describe("App", () => { fireEvent.click(button); await waitFor(() => { - const alert = screen.getByRole("alert"); - expect(alert).toHaveTextContent(/This video is age restricted/i); - expect(alert).toHaveAttribute("id", "selection-error"); + const fieldError = document.getElementById("selection-error"); + expect(fieldError).toHaveTextContent(/This video is age restricted/i); + expect(fieldError).toHaveAttribute("id", "selection-error"); expect(input).toHaveAttribute("aria-invalid", "true"); - expect(input).toHaveAttribute("aria-describedby", alert.id); + expect(input).toHaveAttribute("aria-describedby", "selection-error"); + expect(screen.getByRole("heading", { name: "That YouTube link can't start tonight" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy(); }); }); @@ -1183,6 +1185,7 @@ describe("App", () => { await waitFor(() => { expect(screen.getByText(/Network Error/i)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy(); }); }); @@ -1198,6 +1201,7 @@ describe("App", () => { await waitFor(() => { expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy(); }); }); @@ -1210,6 +1214,7 @@ describe("App", () => { await waitFor(() => { expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy(); }); }); @@ -1222,6 +1227,7 @@ describe("App", () => { await waitFor(() => { expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy(); }); }); @@ -1234,6 +1240,7 @@ describe("App", () => { await waitFor(() => { expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy(); }); expect(tauriInvoke).not.toHaveBeenCalled(); }); @@ -1247,6 +1254,7 @@ describe("App", () => { await waitFor(() => { expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy(); }); expect(tauriInvoke).not.toHaveBeenCalled(); }); @@ -1260,10 +1268,62 @@ describe("App", () => { await waitFor(() => { expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy(); }); expect(tauriInvoke).not.toHaveBeenCalled(); }); + it("focuses the YouTube field from the import-failure next action", async () => { + render(); + const input = screen.getByRole("textbox", { name: /YouTube URL/i }); + fireEvent.change(input, { target: { value: "not-a-url" } }); + fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Paste another YouTube link" })); + + expect(document.activeElement).toBe(input); + expect(input).toHaveValue("not-a-url"); + }); + + it("redacts URL-shaped YouTube import diagnostics before they become buyer copy", async () => { + tauriInvoke.mockRejectedValueOnce( + new Error("download failed: https://youtube.com/watch?v=secret-token") + ); + + render(); + const input = screen.getByPlaceholderText(/YouTube URL.../i); + fireEvent.change(input, { target: { value: "https://youtube.com/watch?v=abc123DEF45" } }); + fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i })); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "That YouTube link can't start tonight" })).toBeTruthy(); + }); + expect(document.getElementById("selection-error")?.textContent).toMatch(/download failed: \[link\]/i); + expect(screen.queryByText(/secret-token/i)).toBeNull(); + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy(); + }); + + it("localizes the YouTube import-failure next action", async () => { + const languageSpy = vi.spyOn(window.navigator, "language", "get").mockReturnValue("ko-KR"); + + try { + render(); + const input = screen.getByRole("textbox", { name: /유튜브 URL/i }); + fireEvent.change(input, { target: { value: "not-a-url" } }); + fireEvent.click(screen.getByRole("button", { name: /유튜브 가져오기/i })); + await waitFor(() => { + expect(screen.getByRole("heading", { name: "그 유튜브 링크로는 오늘 합주를 시작할 수 없습니다" })).toBeTruthy(); + }); + expect(screen.getByRole("button", { name: "다른 유튜브 링크 붙여넣기" })).toBeTruthy(); + } finally { + languageSpy.mockRestore(); + } + }); + it("loads a project and updates the UI", async () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); @@ -1524,6 +1584,7 @@ describe("App", () => { await waitFor(() => { expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy(); }); }); @@ -1549,6 +1610,7 @@ describe("App", () => { await waitFor(() => { expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy(); }); }); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f3d678454..f48e4c699 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -431,6 +431,10 @@ export function App() { /** Documented. */ const handleImportYoutube = async () => { + if (isImporting) { + return; + } + setSelectionError(null); setSelectionErrorSource(null); const normalizedUrl = youtubeUrl.trim(); @@ -464,6 +468,12 @@ export function App() { } }; + /** Focus the YouTube field so the next paste replaces the failed link. */ + const handlePasteAnotherYoutubeLink = () => { + youtubeInputRef.current?.focus(); + youtubeInputRef.current?.select(); + }; + /** Documented. */ const handleClearYoutubeUrl = () => { youtubeInputRef.current?.focus(); @@ -511,6 +521,17 @@ export function App() { if (analysisInFlight || isStarting) { return ; } + if (selectionError && selectionErrorSource === "youtube") { + return ( + + ); + } if (jobResult) { return ; } diff --git a/apps/desktop/src/App.youtubeImportFailureVisibility.test.tsx b/apps/desktop/src/App.youtubeImportFailureVisibility.test.tsx new file mode 100644 index 000000000..b197e31c0 --- /dev/null +++ b/apps/desktop/src/App.youtubeImportFailureVisibility.test.tsx @@ -0,0 +1,79 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { App } from "./App"; + +const analysisMocks = vi.hoisted(() => ({ + getAnalysisJobStatus: vi.fn(), + importYoutubeUrl: vi.fn(), + isSupportedYoutubeUrl: vi.fn(() => true), + loadProject: vi.fn(), + saveProject: vi.fn(), + selectLocalAudioSource: vi.fn(), + startAnalysisJob: vi.fn(), + subscribeToAnalysisJobUpdates: vi.fn(async () => () => undefined) +})); + +vi.mock("./lib/analysis", () => ({ + createDefaultAnalysisRequest: () => ({ + sourceKind: "demo", + sourceLabel: "Late Night Set", + roleFocus: ["bass-guitar", "keys-right", "lead-vocal"] + }), + getAnalysisJobStatus: analysisMocks.getAnalysisJobStatus, + importYoutubeUrl: analysisMocks.importYoutubeUrl, + isSupportedYoutubeUrl: analysisMocks.isSupportedYoutubeUrl, + loadProject: analysisMocks.loadProject, + MAX_YOUTUBE_URL_LENGTH: 2048, + saveProject: analysisMocks.saveProject, + selectLocalAudioSource: analysisMocks.selectLocalAudioSource, + startAnalysisJob: analysisMocks.startAnalysisJob, + subscribeToAnalysisJobUpdates: analysisMocks.subscribeToAnalysisJobUpdates +})); + +vi.mock("./features/score/ScoreView", () => ({ + ScoreView: () =>
Score view
+})); + +/** + * Security Notes: + * - Untrusted input: YouTube import failure copy, including URL-shaped diagnostics. + * - Trust boundary: mocked import bridge → buyer-visible recovery card. + * - Safe failure: the named action only focuses the YouTube field; it does not retry the network. + * - Privacy: live URLs and local paths must not appear in the recovery heading or action label. + */ +describe("App YouTube import failure visibility", () => { + beforeEach(() => { + for (const mock of Object.values(analysisMocks)) { + mock.mockReset(); + } + analysisMocks.isSupportedYoutubeUrl.mockReturnValue(true); + analysisMocks.subscribeToAnalysisJobUpdates.mockResolvedValue(() => undefined); + analysisMocks.loadProject.mockResolvedValue(createDemoRehearsalSong()); + analysisMocks.importYoutubeUrl.mockResolvedValue({ + ok: false, + error: { + code: "invalid_request", + message: "This video is age restricted." + } + }); + }); + + it("surfaces YouTube import failure even when a rehearsal result is already loaded", async () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Project" })); + await waitFor(() => expect(screen.getByText("Late Night Set")).toBeTruthy()); + + const input = screen.getByRole("textbox", { name: "YouTube URL" }); + fireEvent.change(input, { target: { value: "https://youtube.com/watch?v=abc123DEF45" } }); + fireEvent.click(screen.getByRole("button", { name: "Import YouTube" })); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "That YouTube link can't start tonight" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy(); + }); + expect(screen.getByText(/This video is age restricted/i)).toBeTruthy(); + expect(screen.queryByText(/https:\/\/youtube\.com/i)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx new file mode 100644 index 000000000..03aaa52b8 --- /dev/null +++ b/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx @@ -0,0 +1,82 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EmptyState, ErrorState, LoadingState } from "./WorkspaceStates"; + +const originalLanguage = window.navigator.language; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(window.navigator, "language", { + configurable: true, + value: language + }); +} + +/** + * Security Notes: + * - Untrusted input: YouTube import failure copy rendered into the recovery card. + * - Trust boundary: workspace error card → named next action; no URL, path, or network access. + * - Safe failure: the action only focuses the existing YouTube field. + * - Privacy: tests use canned copy and never pass live URLs into the card body. + */ +describe("WorkspaceStates YouTube import failure", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + }); + + it("names pasting another YouTube link as the next rehearsal action", () => { + const onAction = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Paste another YouTube link" })); + + expect(onAction).toHaveBeenCalledTimes(1); + expect(screen.getByRole("heading", { name: "That YouTube link can't start tonight" })).toBeTruthy(); + expect(screen.getByText(/keeps the audio on this device/i)).toBeTruthy(); + }); + + it("disables the next action while import is already running", () => { + const onAction = vi.fn(); + render( + + ); + + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Paste another YouTube link" })); + expect(onAction).not.toHaveBeenCalled(); + }); + + it("keeps analysis failures message-only when no recovery action is provided", () => { + render(); + + expect(screen.getByRole("heading", { name: "An error occurred during analysis. Please try again." })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /paste another youtube link/i })).toBeNull(); + }); + + it("localizes empty, loading, and YouTube-import-failure titles", () => { + setNavigatorLanguage("ko-KR"); + render( + <> + + + + + ); + + expect(screen.getByRole("heading", { name: "분석 준비 완료" })).toBeTruthy(); + expect(screen.getByRole("heading", { name: "오디오 분석 중" })).toBeTruthy(); + expect(screen.getByRole("heading", { name: "그 유튜브 링크로는 오늘 합주를 시작할 수 없습니다" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "다른 유튜브 링크 붙여넣기" })).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.tsx index 8f9aba1b1..440d110f7 100644 --- a/apps/desktop/src/features/workspace/WorkspaceStates.tsx +++ b/apps/desktop/src/features/workspace/WorkspaceStates.tsx @@ -1,6 +1,7 @@ import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { Loader2, Music, AlertCircle } from "lucide-react"; +import { Loader2, Music, AlertCircle, Link2 } from "lucide-react"; /** Documented. */ export function EmptyState() { @@ -38,8 +39,25 @@ export function LoadingState() { ); } -/** Documented. */ -export function ErrorState({ error }: { error?: string }) { +/** Recovery action for a workspace error that still has a next rehearsal step. */ +export interface ErrorStateProps { + error?: string; + title?: string; + guidance?: string; + actionLabel?: string; + onAction?: () => void; + actionDisabled?: boolean; +} + +/** Render a workspace failure and name the next rehearsal action when one exists. */ +export function ErrorState({ + error, + title, + guidance, + actionLabel, + onAction, + actionDisabled = false +}: ErrorStateProps) { const t = createTranslator(detectPreferredLocale()); return ( @@ -47,8 +65,21 @@ export function ErrorState({ error }: { error?: string }) {
-

{t("workspaceErrorState")}

+

{title ?? t("workspaceErrorState")}

{error &&

{error}

} + {guidance &&

{guidance}

} + {onAction && actionLabel && ( + + )}
); diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..0e635aa9b 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -93,6 +93,9 @@ "importYoutube": "Import YouTube", "importingYoutube": "Importing...", "youtubeImportFailed": "Failed to import YouTube URL.", + "youtubeImportFailureTitle": "That YouTube link can't start tonight", + "youtubeImportFailureGuidance": "Paste a standard YouTube watch, Shorts, or youtu.be link. After import, BandScope keeps the audio on this device.", + "pasteAnotherYoutubeLink": "Paste another YouTube link", "brandMarkAriaLabel": "BandScope circular equalizer mark", "rehearsalCockpit": "Rehearsal cockpit", "navWorkspace": "Workspace", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..8c6229876 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -93,6 +93,9 @@ "importYoutube": "유튜브 가져오기", "importingYoutube": "가져오는 중...", "youtubeImportFailed": "유튜브 URL 가져오기에 실패했습니다.", + "youtubeImportFailureTitle": "그 유튜브 링크로는 오늘 합주를 시작할 수 없습니다", + "youtubeImportFailureGuidance": "표준 YouTube watch, Shorts 또는 youtu.be 링크를 붙여넣으세요. 가져온 뒤에는 오디오가 이 기기에만 남습니다.", + "pasteAnotherYoutubeLink": "다른 유튜브 링크 붙여넣기", "brandMarkAriaLabel": "BandScope 원형 이퀄라이저 마크", "rehearsalCockpit": "합주 컨트롤룸", "navWorkspace": "작업 공간", From 960a0ef769ce715330f154f2f6472575dd73db51 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 05:42:11 -0700 Subject: [PATCH 02/18] test(workspace): expose stale YouTube recovery state --- ...pp.youtubeImportFailureVisibility.test.tsx | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/apps/desktop/src/App.youtubeImportFailureVisibility.test.tsx b/apps/desktop/src/App.youtubeImportFailureVisibility.test.tsx index b197e31c0..cc658f669 100644 --- a/apps/desktop/src/App.youtubeImportFailureVisibility.test.tsx +++ b/apps/desktop/src/App.youtubeImportFailureVisibility.test.tsx @@ -35,6 +35,20 @@ vi.mock("./features/score/ScoreView", () => ({ ScoreView: () =>
Score view
})); +const localBootstrap = { + projectId: "project-1", + sourceMode: "reference", + projectRoot: "/tmp/bandscope/projects/project-1", + cacheRoot: "/tmp/bandscope/cache/project-1", + tempRoot: "/tmp/bandscope/temp/project-1", + source: { + sourcePath: "/tmp/bandscope/song.wav", + fileName: "song.wav", + extension: "wav", + fileSizeBytes: 1024 + } +}; + /** * Security Notes: * - Untrusted input: YouTube import failure copy, including URL-shaped diagnostics. @@ -76,4 +90,64 @@ describe("App YouTube import failure visibility", () => { expect(screen.getByText(/This video is age restricted/i)).toBeTruthy(); expect(screen.queryByText(/https:\/\/youtube\.com/i)).toBeNull(); }); + + it("clears stale YouTube recovery after a project opens successfully", async () => { + render(); + + const input = screen.getByRole("textbox", { name: "YouTube URL" }); + fireEvent.change(input, { target: { value: "https://youtube.com/watch?v=abc123DEF45" } }); + fireEvent.click(screen.getByRole("button", { name: "Import YouTube" })); + await waitFor(() => + expect(screen.getByRole("heading", { name: "That YouTube link can't start tonight" })).toBeTruthy() + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Project" })); + + await waitFor(() => expect(screen.getByText("Late Night Set")).toBeTruthy()); + expect(screen.queryByRole("heading", { name: "That YouTube link can't start tonight" })).toBeNull(); + }); + + it("clears stale YouTube recovery when analysis starts from an admitted local source", async () => { + analysisMocks.selectLocalAudioSource.mockResolvedValue({ ok: true, bootstrap: localBootstrap }); + analysisMocks.startAnalysisJob.mockResolvedValue({ + jobId: "job-1", + state: "succeeded", + requestedAt: "2026-08-22T00:00:00.000Z", + updatedAt: "2026-08-22T00:00:01.000Z", + progressLabel: "Analysis ready", + result: createDemoRehearsalSong() + }); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Choose local audio" })); + await waitFor(() => expect(analysisMocks.selectLocalAudioSource).toHaveBeenCalledTimes(1)); + + const input = screen.getByRole("textbox", { name: "YouTube URL" }); + fireEvent.change(input, { target: { value: "https://youtube.com/watch?v=abc123DEF45" } }); + fireEvent.click(screen.getByRole("button", { name: "Import YouTube" })); + await waitFor(() => + expect(screen.getByRole("heading", { name: "That YouTube link can't start tonight" })).toBeTruthy() + ); + + fireEvent.click(screen.getByRole("button", { name: "Start Analysis" })); + + await waitFor(() => expect(analysisMocks.startAnalysisJob).toHaveBeenCalledTimes(1)); + expect(screen.getByText("Late Night Set")).toBeTruthy(); + expect(screen.queryByRole("heading", { name: "That YouTube link can't start tonight" })).toBeNull(); + }); + + it("announces a YouTube failure through one assertive alert while keeping field description", async () => { + render(); + + const input = screen.getByRole("textbox", { name: "YouTube URL" }); + fireEvent.change(input, { target: { value: "https://youtube.com/watch?v=abc123DEF45" } }); + fireEvent.click(screen.getByRole("button", { name: "Import YouTube" })); + + await waitFor(() => + expect(screen.getByRole("heading", { name: "That YouTube link can't start tonight" })).toBeTruthy() + ); + expect(screen.getAllByRole("alert")).toHaveLength(1); + expect(input.getAttribute("aria-describedby")).toBe("selection-error"); + expect(document.getElementById("selection-error")?.textContent).toContain("This video is age restricted."); + }); }); From 343c878ffa7cc63f9ddd295e05c0edc082bb34e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 06:12:14 -0700 Subject: [PATCH 03/18] test: correct YouTube recovery action query --- apps/desktop/src/App.youtubeImportFailureVisibility.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/App.youtubeImportFailureVisibility.test.tsx b/apps/desktop/src/App.youtubeImportFailureVisibility.test.tsx index cc658f669..f5ab35c67 100644 --- a/apps/desktop/src/App.youtubeImportFailureVisibility.test.tsx +++ b/apps/desktop/src/App.youtubeImportFailureVisibility.test.tsx @@ -129,7 +129,7 @@ describe("App YouTube import failure visibility", () => { expect(screen.getByRole("heading", { name: "That YouTube link can't start tonight" })).toBeTruthy() ); - fireEvent.click(screen.getByRole("button", { name: "Start Analysis" })); + fireEvent.click(screen.getByRole("button", { name: "Start analysis" })); await waitFor(() => expect(analysisMocks.startAnalysisJob).toHaveBeenCalledTimes(1)); expect(screen.getByText("Late Night Set")).toBeTruthy(); From 40d88f9634260b9cd4a3d2ab736610609f015f75 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 06:15:59 -0700 Subject: [PATCH 04/18] fix: clear stale YouTube recovery state --- apps/desktop/src/App.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f48e4c699..e5ed119bf 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -388,6 +388,8 @@ export function App() { /** Documented. */ const handleStartAnalysis = async () => { const submittedBootstrap = selectedBootstrap; + setSelectionError(null); + setSelectionErrorSource(null); setJobError(null); setJobResult(null); setJobResultBootstrap(null); @@ -484,6 +486,8 @@ export function App() { const handleLoadProject = async () => { try { const song = await loadProject(); + setSelectionError(null); + setSelectionErrorSource(null); setJobResult(song); setJobResultBootstrap(null); setJobError(null); @@ -846,7 +850,13 @@ export function App() { )} {selectionError && ( -