From 261253d5910f87992dd48e7741550531e8dc9f92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 21:05:07 +0900 Subject: [PATCH 01/27] test(desktop): reject browser-only synthetic analysis --- .../src/lib/job_runner.production.test.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 apps/desktop/src/lib/job_runner.production.test.ts diff --git a/apps/desktop/src/lib/job_runner.production.test.ts b/apps/desktop/src/lib/job_runner.production.test.ts new file mode 100644 index 000000000..beb7b052e --- /dev/null +++ b/apps/desktop/src/lib/job_runner.production.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), + isTauri: vi.fn(() => false), +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(), +})); + +import { + cancelSong, + enqueueSong, + getWorkspaceState, + retrySong, + subscribeToWorkspaceUpdates, +} from "./job_runner"; + +describe("production analysis bridge boundary", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + ["enqueue", () => enqueueSong({ sourceKind: "local_audio", sourceLabel: "song.wav" })], + ["retry", () => retrySong("job-one")], + ["cancel", () => cancelSong("job-one")], + ])("fails closed for %s outside the Tauri runtime", async (_name, operation) => { + await expect(operation()).rejects.toThrow("BandScope analysis requires the Tauri runtime"); + }); + + it("does not manufacture browser workspace state", async () => { + await expect(getWorkspaceState()).resolves.toBeNull(); + }); + + it("allows a passive browser subscription without synthetic workspace events", async () => { + const callback = vi.fn(); + const unsubscribe = await subscribeToWorkspaceUpdates(callback); + + expect(callback).not.toHaveBeenCalled(); + expect(typeof unsubscribe).toBe("function"); + expect(() => unsubscribe()).not.toThrow(); + }); +}); From bddd8410108d80c81c043a8a1deb911f7e2815d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 21:05:22 +0900 Subject: [PATCH 02/27] fix(desktop): fail closed outside the Tauri analysis runtime --- apps/desktop/src/lib/job_runner.ts | 169 +++++------------------------ 1 file changed, 27 insertions(+), 142 deletions(-) diff --git a/apps/desktop/src/lib/job_runner.ts b/apps/desktop/src/lib/job_runner.ts index b024ad5a4..6362b9cdc 100644 --- a/apps/desktop/src/lib/job_runner.ts +++ b/apps/desktop/src/lib/job_runner.ts @@ -2,17 +2,15 @@ import { invoke, isTauri } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { type RehearsalWorkspace, - type SongRehearsalPack, type AnalysisJobRequest, parseRehearsalWorkspace, isRehearsalWorkspace, - createDemoRehearsalSong } from "@bandscope/shared-types"; -/** Documented. */ +/** Receives validated workspace updates emitted by the native analysis runtime. */ export type WorkspaceUpdateCallback = (workspace: RehearsalWorkspace) => void; -/** Documented. */ +/** Narrow Tauri invocation boundary used by the desktop runtime. */ type TauriInvoke = (command: string, args?: Record) => Promise; declare global { @@ -21,7 +19,7 @@ declare global { } } -/** Documented. */ +/** Return the native Tauri invocation function when the desktop runtime is present. */ function getInvoke(): TauriInvoke | null { if (typeof window === "undefined" || !isTauri()) { return null; @@ -29,166 +27,53 @@ function getInvoke(): TauriInvoke | null { return window.__TAURI_INVOKE__ ?? invoke; } -const mockWorkspace: RehearsalWorkspace = { - id: "mock-ws", - title: "Browser Mock Workspace", - songs: [], - workspaceVersion: 1 -}; - -const mockSongsById = new Map( - mockWorkspace.songs.map(song => [song.id, song]) -); - -type MockListener = (event: { payload: unknown }) => void; -const mockListeners = new Set(); - -/** Documented. */ -function getMockSong(jobId: string): SongRehearsalPack | undefined { - return mockSongsById.get(jobId); -} - -/** - * Triggers a mock workspace update to all listeners. - */ -function triggerMockUpdate() { - const payload = structuredClone(mockWorkspace); - mockListeners.forEach(listener => listener({ payload })); -} - -/** Documented. */ -async function browserFallback(command: string, args?: Record): Promise { - if (command === "get_workspace_state") { - return structuredClone(mockWorkspace); - } - - if (command === "enqueue_song") { - const request = args?.request as AnalysisJobRequest; - const packId = `pack-${Date.now()}`; - const pack: SongRehearsalPack = { - id: packId, - packState: "queued", - sourceLabel: request.sourceKind === "local_audio" ? request.sourceLabel : "Demo Song", - engineState: "queued" - }; - mockWorkspace.songs.push(pack); - mockSongsById.set(pack.id, pack); - triggerMockUpdate(); - - // Simulate processing - setTimeout(() => { - pack.packState = "analyzing"; - pack.engineState = "running"; - triggerMockUpdate(); - - setTimeout(() => { - // We use Object.assign to mutate the cached pack reference, avoiding an O(N) lookup. - Object.assign(pack, { - packState: "ready", - engineState: "succeeded", - song: createDemoRehearsalSong() - }); - triggerMockUpdate(); - }, 2000); - }, 1000); - - return; - } - - if (command === "retry_song") { - const jobId = args?.jobId as string; - const pack = getMockSong(jobId); - if (pack) { - pack.packState = "queued"; - pack.engineState = "queued"; - - triggerMockUpdate(); - - // Simulate processing - setTimeout(() => { - pack.packState = "analyzing"; - pack.engineState = "running"; - triggerMockUpdate(); - setTimeout(() => { - Object.assign(pack, { - packState: "ready", - engineState: "succeeded", - song: createDemoRehearsalSong() - }); - triggerMockUpdate(); - }, 2000); - }, 1000); - } - return; - } - - if (command === "cancel_song") { - const jobId = args?.jobId as string; - mockWorkspace.songs = mockWorkspace.songs.filter(p => p.id !== jobId); - mockSongsById.delete(jobId); - triggerMockUpdate(); - return; - } - - throw new Error(`Unknown analysis bridge command: ${command}`); -} - -/** Documented. */ +/** Execute a native analysis command or reject the unsupported browser-only surface. */ async function invokeRunner(command: string, args?: Record): Promise { const invokeCommand = getInvoke(); - if (invokeCommand) { - return invokeCommand(command, args); + if (!invokeCommand) { + throw new Error("BandScope analysis requires the Tauri runtime"); } - return browserFallback(command, args); + return invokeCommand(command, args); } -/** Documented. */ +/** Queue one analysis job in the native BandScope runtime. */ export async function enqueueSong(request: AnalysisJobRequest): Promise { await invokeRunner("enqueue_song", { request }); } -/** Documented. */ +/** Retry one existing native analysis job. */ export async function retrySong(jobId: string): Promise { await invokeRunner("retry_song", { jobId }); } -/** Documented. */ +/** Cancel one existing native analysis job. */ export async function cancelSong(jobId: string): Promise { await invokeRunner("cancel_song", { jobId }); } -/** Documented. */ +/** Subscribe to validated native workspace events without fabricating browser state. */ export async function subscribeToWorkspaceUpdates(callback: WorkspaceUpdateCallback): Promise { const invokeCommand = getInvoke(); - - if (invokeCommand) { - return listen("workspace-updated", (event) => { - if (isRehearsalWorkspace(event.payload)) { - callback(parseRehearsalWorkspace(event.payload)); - } else { - // eslint-disable-next-line no-console -- Warn about invalid payload structure - console.warn("Received invalid workspace update from Tauri"); - } - }); - } else { - // Browser fallback - /** - * Internal listener for fallback mock updates. - */ - const listener: MockListener = (event) => { - if (isRehearsalWorkspace(event.payload)) { - callback(parseRehearsalWorkspace(event.payload)); - } - }; - mockListeners.add(listener); - return () => { - mockListeners.delete(listener); - }; + + if (!invokeCommand) { + return () => undefined; } + + return listen("workspace-updated", (event) => { + if (isRehearsalWorkspace(event.payload)) { + callback(parseRehearsalWorkspace(event.payload)); + } else { + // eslint-disable-next-line no-console -- Warn about invalid payload structure + console.warn("Received invalid workspace update from Tauri"); + } + }); } -/** Documented. */ +/** Return native workspace state, or null when the desktop runtime is unavailable. */ export async function getWorkspaceState(): Promise { + if (!getInvoke()) { + return null; + } try { const response = await invokeRunner("get_workspace_state"); if (!response) return null; From e5e1b7b2046de5e9b55d2c44140589c3c9cdb9d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 21:05:44 +0900 Subject: [PATCH 03/27] docs(changelog): record truthful desktop analysis boundary --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..1aca3f77e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. +### Fixed + +- Removed the browser-only synthetic analysis workspace and timed fake success path. Analysis mutations now require the native Tauri runtime; browser-only execution fails closed instead of manufacturing `ready` rehearsal results. + ## [0.1.3] - 2026-04-29 ### Fixed From 6d6c7fda4ff24d56651e541d784a4da62e710277 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 04:02:55 +0900 Subject: [PATCH 04/27] fix(deps): align secure dependency baseline --- apps/desktop/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e7685d6f0..647047e31 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -20,7 +20,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.24.0", - "pdfjs-dist": "6.1.200", + "pdfjs-dist": "^6.2.108", "react": "^19.2.4", "react-dom": "^19.2.7", "sonner": "^2.0.7", From 432da8af38cb4aa6f49b82b2abf41c74b76fc60b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 04:03:12 +0900 Subject: [PATCH 05/27] fix(deps): align secure dependency baseline --- package-lock.json | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index cf1c991c1..1de35060e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.24.0", - "pdfjs-dist": "6.1.200", + "pdfjs-dist": "^6.2.108", "react": "^19.2.4", "react-dom": "^19.2.7", "sonner": "^2.0.7", @@ -6075,10 +6075,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", - "dev": true, + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -6368,9 +6367,9 @@ } }, "node_modules/pdfjs-dist": { - "version": "6.1.200", - "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.1.200.tgz", - "integrity": "sha512-o8MolyzirkkLrcdsae/HEOiIcXWI7DS5zGpvqW8xTC2YUsW30rltFw2bDGvw/fskUdEMrQm2br68jzDS5BH2vw==", + "version": "6.2.108", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.2.108.tgz", + "integrity": "sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==", "license": "Apache-2.0", "engines": { "node": ">=22.13.0 || >=24" @@ -7179,10 +7178,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", - "dev": true, + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "license": "MIT", "engines": { "node": ">=20.18.1" From 9aaff0e2e153d83297d0f667b7ce13e2880dc33f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 18:11:57 +0900 Subject: [PATCH 06/27] fix(desktop): remove unrelated dependency drift --- apps/desktop/package.json | 2 +- package-lock.json | 22 ++++++++++++---------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 647047e31..e7685d6f0 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -20,7 +20,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.24.0", - "pdfjs-dist": "^6.2.108", + "pdfjs-dist": "6.1.200", "react": "^19.2.4", "react-dom": "^19.2.7", "sonner": "^2.0.7", diff --git a/package-lock.json b/package-lock.json index 1de35060e..cf1c991c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.24.0", - "pdfjs-dist": "^6.2.108", + "pdfjs-dist": "6.1.200", "react": "^19.2.4", "react-dom": "^19.2.7", "sonner": "^2.0.7", @@ -6075,9 +6075,10 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, "funding": [ { "type": "github", @@ -6367,9 +6368,9 @@ } }, "node_modules/pdfjs-dist": { - "version": "6.2.108", - "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.2.108.tgz", - "integrity": "sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==", + "version": "6.1.200", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.1.200.tgz", + "integrity": "sha512-o8MolyzirkkLrcdsae/HEOiIcXWI7DS5zGpvqW8xTC2YUsW30rltFw2bDGvw/fskUdEMrQm2br68jzDS5BH2vw==", "license": "Apache-2.0", "engines": { "node": ">=22.13.0 || >=24" @@ -7178,9 +7179,10 @@ } }, "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, "license": "MIT", "engines": { "node": ">=20.18.1" From a9063cb1eca490010e0f6905380304afb908db40 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 18:15:03 +0900 Subject: [PATCH 07/27] docs(desktop): align browser runtime contract --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 82c2c704a..d3c9ad3de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ Per-workspace and single-test: ```bash npm run test --workspace @bandscope/desktop # desktop suite (vitest + coverage) npm --workspace @bandscope/desktop exec vitest run src/lib/export.test.ts # one frontend test file -npm run dev --workspace @bandscope/desktop # Vite dev server (browser fallback mode) +npm run dev --workspace @bandscope/desktop # Vite dev server; browser-only analysis mutations fail closed npm run storybook --workspace @bandscope/desktop # component workbench uv run --project services/analysis-engine pytest tests/test_chords.py # one Python test file (no coverage gate) @@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into Three layers, decoupled through shared contracts: -- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. +- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands. Outside Tauri, analysis mutations fail closed with a stable runtime-unavailable error, workspace reads return `null`, and passive subscriptions are no-ops; browser development must never fabricate completed jobs, workspace state, or rehearsal results. - `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis. - `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules. From 5066f18c94fa1c3103457a3e4c427c70c089c8b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:36:04 +0900 Subject: [PATCH 08/27] test(desktop): reject synthetic analysis in active browser bridge --- .../lib/analysis.browser-fail-closed.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 apps/desktop/src/lib/analysis.browser-fail-closed.test.ts diff --git a/apps/desktop/src/lib/analysis.browser-fail-closed.test.ts b/apps/desktop/src/lib/analysis.browser-fail-closed.test.ts new file mode 100644 index 000000000..0c6c34ee2 --- /dev/null +++ b/apps/desktop/src/lib/analysis.browser-fail-closed.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, expect, it } from "vitest"; +import { createDemoAnalysisJobRequest } from "@bandscope/shared-types"; + +import { getAnalysisJobStatus, startAnalysisJob } from "./analysis"; + +type TauriWindow = Window & { + __TAURI_INTERNALS__?: unknown; + __TAURI_INVOKE__?: unknown; +}; + +const tauriWindow = window as TauriWindow; + +beforeEach(() => { + delete tauriWindow.__TAURI_INTERNALS__; + delete tauriWindow.__TAURI_INVOKE__; +}); + +it("fails closed instead of synthesizing browser-only analysis success", async () => { + const status = await startAnalysisJob(createDemoAnalysisJobRequest()); + + expect(status).toMatchObject({ + state: "failed", + error: { + code: "engine_unavailable", + message: "BandScope analysis requires the Tauri runtime" + } + }); + expect(status.result).toBeUndefined(); +}); + +it("does not retain a synthetic browser job after fail-closed analysis rejection", async () => { + const status = await startAnalysisJob(createDemoAnalysisJobRequest()); + const lookup = await getAnalysisJobStatus(status.jobId); + + expect(lookup).toMatchObject({ + state: "failed", + error: { + code: "not_found", + message: "Analysis job was not found." + } + }); + expect(lookup.result).toBeUndefined(); +}); From 380bac0213f80fc098ec9496a673eaa5500cea32 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:36:33 +0900 Subject: [PATCH 09/27] fix(desktop): fail closed in active browser analysis bridge --- apps/desktop/src/lib/analysis.ts | 79 +++++++++----------------------- 1 file changed, 21 insertions(+), 58 deletions(-) diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index bb750b34b..9c55aeb60 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -2,7 +2,6 @@ import { invoke } from "@tauri-apps/api/core"; import { createAnalysisJobStatus, createDemoAnalysisJobRequest, - createDemoRehearsalSong, createProjectBootstrapSummary, parseAnalysisJobStatus, parseAnalysisJobRequest, @@ -27,13 +26,6 @@ declare global { } } -const browserJobStore = new Map(); -const BROWSER_PROGRESS_STEPS = [ - { progressLabel: "Decoding audio", progressStage: "decode", progressPercent: 20 }, - { progressLabel: "Separating stems... (45%)", progressStage: "separate", progressPercent: 45 }, - { progressLabel: "Building rehearsal cues", progressStage: "analyze", progressPercent: 70 }, - { progressLabel: "Saving reusable features", progressStage: "persist", progressPercent: 90 } -] as const; const UNSUPPORTED_LOCAL_AUDIO_MESSAGE = "Choose a WAV, MP3, FLAC, or M4A file to start analysis."; const SAFE_LOCAL_AUDIO_MESSAGES = new Set([ UNSUPPORTED_LOCAL_AUDIO_MESSAGE, @@ -42,6 +34,7 @@ const SAFE_LOCAL_AUDIO_MESSAGES = new Set([ "Could not prepare the local cache workspace.", "Could not prepare the local temp workspace." ]); +const BROWSER_ANALYSIS_UNAVAILABLE_MESSAGE = "BandScope analysis requires the Tauri runtime"; const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/; const MAX_YOUTUBE_URL_LENGTH = 2000; @@ -111,21 +104,24 @@ function browserJobId(prefix: string): string { return `${prefix}-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`; } -/** Documented. */ +/** + * Handle browser-preview commands without fabricating analysis success. + * + * Source-selection preview behavior stays explicit for existing UI development, + * but analysis execution itself fails closed because only the Tauri runtime owns + * the production Python subprocess and validated job lifecycle. + */ async function browserFallback(command: string, args?: Record): Promise { if (command === "start_analysis_job") { parseAnalysisJobRequest(args?.request); - const jobId = browserJobId("browser-job"); - const queued = createAnalysisJobStatus({ - jobId, - state: "queued", - progressLabel: "Queued for analysis", - progressStage: "queued", - progressPercent: 0, - cacheStatus: "disabled" + return createAnalysisJobStatus({ + jobId: browserJobId("browser-unavailable-job"), + state: "failed", + error: { + code: "engine_unavailable", + message: BROWSER_ANALYSIS_UNAVAILABLE_MESSAGE + } }); - browserJobStore.set(jobId, queued); - return queued; } if (command === "select_local_audio_source") { @@ -133,47 +129,14 @@ async function browserFallback(command: string, args?: Record): } if (command === "get_analysis_job_status") { - const jobId = String(args?.jobId ?? ""); - const existing = browserJobStore.get(jobId); - if (!existing) { - return createAnalysisJobStatus({ - jobId, - state: "failed", - error: { - code: "not_found", - message: "Analysis job was not found." - } - }); - } - if (existing.state === "queued" || existing.state === "running") { - const currentPercent = existing.progressPercent ?? 0; - const nextStep = BROWSER_PROGRESS_STEPS.find((step) => step.progressPercent > currentPercent); - if (nextStep) { - const running = createAnalysisJobStatus({ - jobId, - state: "running", - requestedAt: existing.requestedAt, - progressLabel: nextStep.progressLabel, - progressStage: nextStep.progressStage, - progressPercent: nextStep.progressPercent, - cacheStatus: "disabled" - }); - browserJobStore.set(jobId, running); - return running; + return createAnalysisJobStatus({ + jobId: String(args?.jobId ?? ""), + state: "failed", + error: { + code: "not_found", + message: "Analysis job was not found." } - } - const succeeded = createAnalysisJobStatus({ - jobId, - state: "succeeded", - progressLabel: "Analysis ready", - progressStage: "ready", - progressPercent: 100, - cacheStatus: "disabled", - requestedAt: existing.requestedAt, - result: createDemoRehearsalSong() }); - browserJobStore.set(jobId, succeeded); - return succeeded; } if (command === "save_project") { From 6a976f50f5b7dcc4609a921330f99c3017d493f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:37:00 +0900 Subject: [PATCH 10/27] test(desktop): retire obsolete synthetic browser progress contract --- apps/desktop/src/lib/analysis.test.ts | 51 ++------------------------- 1 file changed, 2 insertions(+), 49 deletions(-) diff --git a/apps/desktop/src/lib/analysis.test.ts b/apps/desktop/src/lib/analysis.test.ts index e3347d1f5..a9acc5fb5 100644 --- a/apps/desktop/src/lib/analysis.test.ts +++ b/apps/desktop/src/lib/analysis.test.ts @@ -1,10 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createDemoAnalysisJobRequest, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; import { MAX_YOUTUBE_URL_LENGTH, getAnalysisJobStatus, - importYoutubeUrl, - startAnalysisJob + importYoutubeUrl } from "./analysis"; type TauriWindow = Window & { @@ -117,52 +116,6 @@ describe("analysis bridge", () => { expect(status.result?.sections[0]?.timeRange).toEqual({ start: 0, end: 1 }); }); - it("reports staged browser fallback progress before returning the demo result", async () => { - const queued = await startAnalysisJob(createDemoAnalysisJobRequest()); - - expect(queued).toMatchObject({ - state: "queued", - progressLabel: "Queued for analysis", - progressStage: "queued", - progressPercent: 0 - }); - - const running = await getAnalysisJobStatus(queued.jobId); - expect(running).toMatchObject({ - state: "running", - progressLabel: "Decoding audio", - progressStage: "decode", - progressPercent: 20 - }); - - expect(await getAnalysisJobStatus(queued.jobId)).toMatchObject({ - state: "running", - progressLabel: "Separating stems... (45%)", - progressStage: "separate", - progressPercent: 45 - }); - expect(await getAnalysisJobStatus(queued.jobId)).toMatchObject({ - state: "running", - progressLabel: "Building rehearsal cues", - progressStage: "analyze", - progressPercent: 70 - }); - expect(await getAnalysisJobStatus(queued.jobId)).toMatchObject({ - state: "running", - progressLabel: "Saving reusable features", - progressStage: "persist", - progressPercent: 90 - }); - - const ready = await getAnalysisJobStatus(queued.jobId); - expect(ready).toMatchObject({ - state: "succeeded", - progressLabel: "Analysis ready", - progressStage: "ready", - progressPercent: 100 - }); - }); - it("ignores a non-function Tauri v1 invoke shim", async () => { (window as unknown as { __TAURI_INVOKE__?: unknown }).__TAURI_INVOKE__ = "not-callable"; From 038f05c422e944f94633cd4d29b8b6e9ba883e83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 03:59:44 +0900 Subject: [PATCH 11/27] test(desktop): reject synthetic browser save success --- .../src/lib/analysis.browser-fail-closed.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/lib/analysis.browser-fail-closed.test.ts b/apps/desktop/src/lib/analysis.browser-fail-closed.test.ts index 0c6c34ee2..a0d66de05 100644 --- a/apps/desktop/src/lib/analysis.browser-fail-closed.test.ts +++ b/apps/desktop/src/lib/analysis.browser-fail-closed.test.ts @@ -1,7 +1,7 @@ import { beforeEach, expect, it } from "vitest"; -import { createDemoAnalysisJobRequest } from "@bandscope/shared-types"; +import { createDemoAnalysisJobRequest, createDemoRehearsalSong } from "@bandscope/shared-types"; -import { getAnalysisJobStatus, startAnalysisJob } from "./analysis"; +import { getAnalysisJobStatus, saveProject, startAnalysisJob } from "./analysis"; type TauriWindow = Window & { __TAURI_INTERNALS__?: unknown; @@ -41,3 +41,9 @@ it("does not retain a synthetic browser job after fail-closed analysis rejection }); expect(lookup.result).toBeUndefined(); }); + +it("fails closed instead of reporting browser-only project save success", async () => { + await expect(saveProject(createDemoRehearsalSong())).rejects.toThrow( + "Project save requires the Tauri runtime." + ); +}); From acd5d3d51e997a67a4e61879502fb849375d8721 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 04:01:52 +0900 Subject: [PATCH 12/27] fix(desktop): fail closed on browser-only project save --- apps/desktop/src/lib/analysis.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index 9c55aeb60..c4588029d 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -140,7 +140,7 @@ async function browserFallback(command: string, args?: Record): } if (command === "save_project") { - return; + throw new Error("Project save requires the Tauri runtime."); } if (command === "import_youtube_url") { From fd16cbcb161d9cc7cd97156a31ca45127491e206 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 04:02:38 +0900 Subject: [PATCH 13/27] docs(changelog): record fail-closed browser project save --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aca3f77e..4893c1508 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ ### Fixed -- Removed the browser-only synthetic analysis workspace and timed fake success path. Analysis mutations now require the native Tauri runtime; browser-only execution fails closed instead of manufacturing `ready` rehearsal results. +- Removed the browser-only synthetic analysis workspace and timed fake success path. Analysis execution and project-save mutations now require the native Tauri runtime; browser-only execution fails closed instead of manufacturing `ready` rehearsal results or silently reporting an unpersisted save as successful. ## [0.1.3] - 2026-04-29 @@ -68,5 +68,5 @@ ### 추가됨 (Added) - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. -- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. +- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트(Role)의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. - 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). From 66fe40f0613d9a68c398178e339afba7ab5af375 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 04:03:15 +0900 Subject: [PATCH 14/27] chore(changelog): preserve unrelated release text --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4893c1508..0f6bccd06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,7 +50,7 @@ ### Added - Issue #29: Defined core `song -> section -> role` rehearsal domain contracts -- Issue #38: Added cross-architecture build support (Windows/macOS arm64+amd64) +- Issue #38: Added cross-architecture build support (Windows/macOS amd64+arm64) - Issue #40: Enforced 100% Python docstring and test coverage - Issue #32: Implemented local analysis orchestration and secure IPC boundaries - Issue #33: Implemented secure local audio intake and project bootstrap @@ -68,5 +68,5 @@ ### 추가됨 (Added) - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. -- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트(Role)의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. +- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. - 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). From d971e35fa2302f561081e754cee448665413a981 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 04:03:54 +0900 Subject: [PATCH 15/27] chore(changelog): restore release history ordering --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f6bccd06..883023ea3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,7 +50,7 @@ ### Added - Issue #29: Defined core `song -> section -> role` rehearsal domain contracts -- Issue #38: Added cross-architecture build support (Windows/macOS amd64+arm64) +- Issue #38: Added cross-architecture build support (Windows/macOS arm64+amd64) - Issue #40: Enforced 100% Python docstring and test coverage - Issue #32: Implemented local analysis orchestration and secure IPC boundaries - Issue #33: Implemented secure local audio intake and project bootstrap From 86b2426e2f71be18ce4f245db10366f8d818ccb8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 04:49:23 +0900 Subject: [PATCH 16/27] test(desktop): redact YouTube import dependency errors --- .../analysis.youtube-error-privacy.test.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 apps/desktop/src/lib/analysis.youtube-error-privacy.test.ts diff --git a/apps/desktop/src/lib/analysis.youtube-error-privacy.test.ts b/apps/desktop/src/lib/analysis.youtube-error-privacy.test.ts new file mode 100644 index 000000000..84540aa14 --- /dev/null +++ b/apps/desktop/src/lib/analysis.youtube-error-privacy.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, expect, it, vi } from "vitest"; + +import { importYoutubeUrl } from "./analysis"; + +type TauriWindow = Window & { + __TAURI_INTERNALS__?: unknown; + __TAURI_INVOKE__?: unknown; +}; + +const tauriWindow = window as TauriWindow; + +beforeEach(() => { + delete tauriWindow.__TAURI_INTERNALS__; + delete tauriWindow.__TAURI_INVOKE__; +}); + +it("does not expose dependency-controlled YouTube import errors to the UI", async () => { + tauriWindow.__TAURI_INVOKE__ = vi + .fn() + .mockRejectedValue( + new Error( + "yt-dlp failed for https://youtube.com/watch?v=4ozX4yFUC34 at C:\\Users\\Alice\\Videos token=super-secret" + ) + ); + + const selection = await importYoutubeUrl("https://youtube.com/watch?v=4ozX4yFUC34"); + + expect(selection).toEqual({ + ok: false, + error: { + code: "invalid_request", + message: "YouTube import failed. Try again or choose a local audio file." + } + }); + expect(JSON.stringify(selection)).not.toContain("Alice"); + expect(JSON.stringify(selection)).not.toContain("super-secret"); + expect(JSON.stringify(selection)).not.toContain("4ozX4yFUC34"); +}); From c58ff8c08006b426a242d377c8c60184178e9e65 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 04:50:01 +0900 Subject: [PATCH 17/27] fix(desktop): redact YouTube import dependency errors --- apps/desktop/src/lib/analysis.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index c4588029d..aa13e6ff7 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -35,6 +35,7 @@ const SAFE_LOCAL_AUDIO_MESSAGES = new Set([ "Could not prepare the local temp workspace." ]); const BROWSER_ANALYSIS_UNAVAILABLE_MESSAGE = "BandScope analysis requires the Tauri runtime"; +const YOUTUBE_IMPORT_FAILED_MESSAGE = "YouTube import failed. Try again or choose a local audio file."; const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/; const MAX_YOUTUBE_URL_LENGTH = 2000; @@ -293,13 +294,12 @@ export async function importYoutubeUrl(url: string): Promise Date: Mon, 17 Aug 2026 04:50:26 +0900 Subject: [PATCH 18/27] docs(changelog): record YouTube import error redaction --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 883023ea3..acf70cfaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed +- Redact dependency-controlled YouTube import failures at the desktop bridge so URLs, local paths, tokens, cookies, or tool diagnostics cannot be surfaced directly to the UI; users receive one safe next-action message instead. - Removed the browser-only synthetic analysis workspace and timed fake success path. Analysis execution and project-save mutations now require the native Tauri runtime; browser-only execution fails closed instead of manufacturing `ready` rehearsal results or silently reporting an unpersisted save as successful. ## [0.1.3] - 2026-04-29 @@ -53,7 +54,7 @@ - Issue #38: Added cross-architecture build support (Windows/macOS arm64+amd64) - Issue #40: Enforced 100% Python docstring and test coverage - Issue #32: Implemented local analysis orchestration and secure IPC boundaries -- Issue #33: Implemented secure local audio intake and project bootstrap +- Issue #33: Engineered section, form, and cue anchor extraction pipeline - Issue #35: Engineered section, form, and cue anchor extraction pipeline - Issue #34: Implemented role extraction targets and part graph - Issue #31: Added role-specific harmony, range, overlap, and confidence metrics From 66eb2d8c0916ca1eac933c661b9dcecece9471e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 04:50:48 +0900 Subject: [PATCH 19/27] fix(changelog): restore historical release entry count From 76a9e64efb1c4fa8e9fc144206fffadeab137652 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 05:40:35 +0900 Subject: [PATCH 20/27] test(desktop): redact workspace fetch diagnostics --- .../src/lib/job_runner.error-privacy.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 apps/desktop/src/lib/job_runner.error-privacy.test.ts diff --git a/apps/desktop/src/lib/job_runner.error-privacy.test.ts b/apps/desktop/src/lib/job_runner.error-privacy.test.ts new file mode 100644 index 000000000..08fd83be5 --- /dev/null +++ b/apps/desktop/src/lib/job_runner.error-privacy.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const invokeMock = vi.fn(); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: invokeMock, + isTauri: vi.fn(() => true), +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(), +})); + +import { getWorkspaceState } from "./job_runner"; + +describe("workspace diagnostic privacy boundary", () => { + beforeEach(() => { + vi.clearAllMocks(); + delete window.__TAURI_INVOKE__; + }); + + it("does not copy native error details into the browser console", async () => { + invokeMock.mockRejectedValueOnce( + new Error("workspace failed at C:\\Users\\Alice\\private.song token=super-secret") + ); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await expect(getWorkspaceState()).resolves.toBeNull(); + + expect(consoleError).toHaveBeenCalledWith("Failed to get workspace state."); + const rendered = consoleError.mock.calls.flat().join(" "); + expect(rendered).not.toContain("Alice"); + expect(rendered).not.toContain("super-secret"); + expect(rendered).not.toContain("private.song"); + consoleError.mockRestore(); + }); +}); From b722eddb1eec56ed30716e00cfdd6e269464b12a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 05:41:18 +0900 Subject: [PATCH 21/27] fix(desktop): redact workspace fetch diagnostics --- apps/desktop/src/lib/job_runner.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/lib/job_runner.ts b/apps/desktop/src/lib/job_runner.ts index 6362b9cdc..271e43d06 100644 --- a/apps/desktop/src/lib/job_runner.ts +++ b/apps/desktop/src/lib/job_runner.ts @@ -78,9 +78,9 @@ export async function getWorkspaceState(): Promise { const response = await invokeRunner("get_workspace_state"); if (!response) return null; return parseRehearsalWorkspace(response); - } catch (error) { - // eslint-disable-next-line no-console -- Error logging for workspace state fetch failure - console.error("Failed to get workspace state:", error instanceof Error ? error.message : "Unknown error"); + } catch { + // eslint-disable-next-line no-console -- Stable diagnostics exclude native error payloads. + console.error("Failed to get workspace state."); return null; } } From f8112be63fb88d0fab1ba436cab96244be21a809 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 05:42:00 +0900 Subject: [PATCH 22/27] docs(changelog): record workspace diagnostic redaction --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index acf70cfaa..dac5a382c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed +- Redact native workspace-fetch failures at the desktop console boundary so dependency-controlled local paths, tokens, and tool diagnostics cannot be copied into routine frontend logs. - Redact dependency-controlled YouTube import failures at the desktop bridge so URLs, local paths, tokens, cookies, or tool diagnostics cannot be surfaced directly to the UI; users receive one safe next-action message instead. - Removed the browser-only synthetic analysis workspace and timed fake success path. Analysis execution and project-save mutations now require the native Tauri runtime; browser-only execution fails closed instead of manufacturing `ready` rehearsal results or silently reporting an unpersisted save as successful. @@ -69,5 +70,5 @@ ### 추가됨 (Added) - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. -- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. +- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트(Role)의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. - 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). From f8d23d435986d6f409a7c6879c5097a701e2ec12 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 05:43:47 +0900 Subject: [PATCH 23/27] test(desktop): keep workspace privacy mock hoist-safe --- apps/desktop/src/lib/job_runner.error-privacy.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/lib/job_runner.error-privacy.test.ts b/apps/desktop/src/lib/job_runner.error-privacy.test.ts index 08fd83be5..a488196df 100644 --- a/apps/desktop/src/lib/job_runner.error-privacy.test.ts +++ b/apps/desktop/src/lib/job_runner.error-privacy.test.ts @@ -1,9 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const invokeMock = vi.fn(); - vi.mock("@tauri-apps/api/core", () => ({ - invoke: invokeMock, + invoke: vi.fn(), isTauri: vi.fn(() => true), })); @@ -11,8 +9,11 @@ vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(), })); +import { invoke } from "@tauri-apps/api/core"; import { getWorkspaceState } from "./job_runner"; +const invokeMock = vi.mocked(invoke); + describe("workspace diagnostic privacy boundary", () => { beforeEach(() => { vi.clearAllMocks(); From e169e6b89ea913d5e9ab723695ae7b6f39810876 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 06:41:37 +0900 Subject: [PATCH 24/27] test(desktop): align YouTube error assertions with privacy boundary --- apps/desktop/src/App.test.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..65e60460b 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -1150,7 +1150,7 @@ describe("App", () => { expect(input).not.toHaveAttribute("aria-describedby"); }); - it("handles YouTube import failure with a message", async () => { + it("redacts dependency-controlled YouTube import failure messages", async () => { tauriInvoke.mockRejectedValueOnce(new Error("This video is age restricted.")); render(); @@ -1163,14 +1163,15 @@ describe("App", () => { await waitFor(() => { const alert = screen.getByRole("alert"); - expect(alert).toHaveTextContent(/This video is age restricted/i); + expect(alert).toHaveTextContent(/YouTube import failed\. Try again or choose a local audio file\./i); + expect(alert).not.toHaveTextContent(/This video is age restricted/i); expect(alert).toHaveAttribute("id", "selection-error"); expect(input).toHaveAttribute("aria-invalid", "true"); expect(input).toHaveAttribute("aria-describedby", alert.id); }); }); - it("handles generic exception during YouTube import", async () => { + it("redacts generic exceptions during YouTube import", async () => { tauriInvoke.mockRejectedValueOnce(new Error("Network Error")); render(); @@ -1182,8 +1183,11 @@ describe("App", () => { fireEvent.click(button); await waitFor(() => { - expect(screen.getByText(/Network Error/i)).toBeTruthy(); + expect(screen.getByRole("alert")).toHaveTextContent( + /YouTube import failed\. Try again or choose a local audio file\./i + ); }); + expect(screen.queryByText(/Network Error/i)).toBeNull(); }); it("rejects empty YouTube URL", async () => { @@ -1511,7 +1515,7 @@ describe("App", () => { promptSpy.mockRestore(); }); - it("handles YouTube import failure with a missing message falling back to generic", async () => { + it("uses the same safe message when YouTube import failure omits details", async () => { tauriInvoke.mockRejectedValueOnce(new Error("")); render(); @@ -1523,7 +1527,9 @@ describe("App", () => { fireEvent.click(button); await waitFor(() => { - expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + expect(screen.getByRole("alert")).toHaveTextContent( + /YouTube import failed\. Try again or choose a local audio file\./i + ); }); }); From e42049ae861f9f995e000f490605e47404eba1e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:14:06 -0700 Subject: [PATCH 25/27] test(desktop): reject unlicensed native demo analysis --- .../lib/analysis.browser-fail-closed.test.ts | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/lib/analysis.browser-fail-closed.test.ts b/apps/desktop/src/lib/analysis.browser-fail-closed.test.ts index a0d66de05..70b1db5a4 100644 --- a/apps/desktop/src/lib/analysis.browser-fail-closed.test.ts +++ b/apps/desktop/src/lib/analysis.browser-fail-closed.test.ts @@ -1,5 +1,9 @@ -import { beforeEach, expect, it } from "vitest"; -import { createDemoAnalysisJobRequest, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { beforeEach, expect, it, vi } from "vitest"; +import { + createAnalysisJobStatus, + createDemoAnalysisJobRequest, + createDemoRehearsalSong +} from "@bandscope/shared-types"; import { getAnalysisJobStatus, saveProject, startAnalysisJob } from "./analysis"; @@ -47,3 +51,28 @@ it("fails closed instead of reporting browser-only project save success", async "Project save requires the Tauri runtime." ); }); + +it("does not send an unlicensed synthetic demo request to the native analysis bridge", async () => { + const nativeInvoke = vi.fn(async () => + createAnalysisJobStatus({ + jobId: "synthetic-native-demo", + state: "succeeded", + progressStage: "ready", + progressPercent: 100, + result: createDemoRehearsalSong() + }) + ); + tauriWindow.__TAURI_INVOKE__ = nativeInvoke; + + const status = await startAnalysisJob(createDemoAnalysisJobRequest()); + + expect(nativeInvoke).not.toHaveBeenCalled(); + expect(status).toMatchObject({ + state: "failed", + error: { + code: "engine_unavailable", + message: "Demo analysis is unavailable until a licensed demo track is installed. Choose a local audio file." + } + }); + expect(status.result).toBeUndefined(); +}); From b0971e4daf8f0f4ad377b4b67ba96de6a0de285d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:14:54 -0700 Subject: [PATCH 26/27] fix(desktop): fail closed unlicensed native demo analysis --- apps/desktop/src/lib/analysis.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index aa13e6ff7..2fcbfb87b 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -35,6 +35,8 @@ const SAFE_LOCAL_AUDIO_MESSAGES = new Set([ "Could not prepare the local temp workspace." ]); const BROWSER_ANALYSIS_UNAVAILABLE_MESSAGE = "BandScope analysis requires the Tauri runtime"; +const DEMO_ANALYSIS_UNAVAILABLE_MESSAGE = + "Demo analysis is unavailable until a licensed demo track is installed. Choose a local audio file."; const YOUTUBE_IMPORT_FAILED_MESSAGE = "YouTube import failed. Try again or choose a local audio file."; const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/; const MAX_YOUTUBE_URL_LENGTH = 2000; @@ -224,6 +226,17 @@ export async function startAnalysisJob(request: AnalysisJobRequest): Promise Date: Thu, 20 Aug 2026 18:15:32 -0700 Subject: [PATCH 27/27] docs(changelog): record unlicensed demo fail-closed boundary --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dac5a382c..519b650c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Redact native workspace-fetch failures at the desktop console boundary so dependency-controlled local paths, tokens, and tool diagnostics cannot be copied into routine frontend logs. - Redact dependency-controlled YouTube import failures at the desktop bridge so URLs, local paths, tokens, cookies, or tool diagnostics cannot be surfaced directly to the UI; users receive one safe next-action message instead. - Removed the browser-only synthetic analysis workspace and timed fake success path. Analysis execution and project-save mutations now require the native Tauri runtime; browser-only execution fails closed instead of manufacturing `ready` rehearsal results or silently reporting an unpersisted save as successful. +- Reject native `demo` analysis before bridge invocation until a licensed demo track is installed, preventing the arrangement-only test fixture from becoming buyer-visible production success while directing musicians to local audio. ## [0.1.3] - 2026-04-29 @@ -71,4 +72,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트(Role)의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). +- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file