diff --git a/src/lib/BrewCleanup.svelte b/src/lib/BrewCleanup.svelte index 905a36adb..b8fa573bc 100644 --- a/src/lib/BrewCleanup.svelte +++ b/src/lib/BrewCleanup.svelte @@ -26,7 +26,7 @@ try { judgment = await api.judgeBrewCleanup(); } catch (e) { - error = String(e); + error = "Homebrew 정리 계획을 만들지 못했습니다."; } finally { planning = false; } @@ -34,10 +34,10 @@ function approvalGuidance(): string { if (!judgment || judgment.verdict !== "safe") return ""; - if (!judgment.calibration || judgment.calibration.judgment_id !== judgment.judgment_id) { + if (!calibrationMatchesJudgment(judgment)) { return "이 정확한 LLM 판정에 연결된 fast-mlsirm calibration이 필요합니다."; } - if (!judgment.calibration.passed) { + if (!calibrationPassed(judgment)) { return "fast-mlsirm Judge calibration이 통과하지 않아 실행할 수 없습니다."; } if (confirmationPhrase.trim() !== judgment.exact_approval_phrase) { @@ -52,15 +52,21 @@ function executionReady(): boolean { return judgment !== null && judgment.verdict === "safe" - && judgment.calibration !== undefined - && judgment.calibration.judgment_id === judgment.judgment_id - && judgment.calibration.passed + && calibrationPassed(judgment) && confirmationPhrase.trim() === judgment.exact_approval_phrase && rationale.trim().length > 0 && !executing && execution === null; } + function calibrationMatchesJudgment(value: api.BrewCleanupJudgment): boolean { + return value.calibration?.judgment_id === value.judgment_id; + } + + function calibrationPassed(value: api.BrewCleanupJudgment): boolean { + return calibrationMatchesJudgment(value) && value.calibration?.passed === true; + } + async function executeCleanup() { if (!judgment || !executionReady()) return; const okay = await confirm( @@ -82,7 +88,7 @@ rationale.trim(), ); } catch (e) { - error = String(e); + error = "Homebrew 정리를 실행하지 못했습니다."; } finally { judgment = null; confirmationPhrase = ""; @@ -111,8 +117,11 @@

계획 지문: {report.plan_fingerprint}

실행 예정: brew cleanup --prune-prefix

{#if report.calibration} -

- Judge calibration ({report.calibration.engine}): {report.calibration.passed ? "통과" : "실패"} +

+ Judge calibration ({report.calibration.engine}): + {!calibrationMatchesJudgment(report) + ? "현재 판정과 불일치" + : report.calibration.passed ? "통과" : "실패"} · 표본 {report.calibration.sample_count}개 · 일치율 {Math.round(report.calibration.exact_agreement * 100)}%

{:else} @@ -144,15 +153,23 @@ {/if} {#if execution} -

- {execution.executed ? `실행 완료 (종료 코드 ${execution.status_code})` : "실행되지 않음"} +

+ {execution.executed + ? execution.status_code === 0 + ? `실행 성공 (종료 코드 ${execution.status_code})` + : `실행 실패 (종료 코드 ${execution.status_code})` + : "실행되지 않음"}

{#if execution.stdout}
{execution.stdout}
{/if} {#if execution.stderr}
{execution.stderr}
{/if} {#if execution.record_path}

감사 기록: {execution.record_path}

{:else} - + {/if} {/if} diff --git a/src/lib/brewCleanupErrorPrivacyContract.test.ts b/src/lib/brewCleanupErrorPrivacyContract.test.ts new file mode 100644 index 000000000..243a264f0 --- /dev/null +++ b/src/lib/brewCleanupErrorPrivacyContract.test.ts @@ -0,0 +1,40 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +function readSource(path: string): string { + return readFileSync(resolve(repositoryRoot, path), "utf8"); +} + +describe("BrewCleanup privacy-safe failure feedback", () => { + it("never renders arbitrary backend exception text", () => { + const source = readSource("src/lib/BrewCleanup.svelte"); + const evictionSource = readSource("src/lib/IcloudLocalEviction.svelte"); + + expect(source).not.toContain("String(e)"); + expect(source).not.toContain("record_error"); + expect(source).not.toContain("저장하지 못했습니다: {"); + expect(source).not.toMatch(/\{execution\.record_error\}/); + expect(evictionSource).not.toContain("String(e)"); + expect(evictionSource).not.toContain("result_record_error"); + expect(evictionSource).not.toMatch(/\{eviction\.result_record_error\}/); + expect(evictionSource).toContain("파일 선택 창을 열지 못했습니다."); + expect(evictionSource).toContain("iCloud 로컬 사본 상태를 확인하지 못했습니다."); + expect(evictionSource).toContain("iCloud 로컬 사본 축출을 실행하지 못했습니다."); + expect(source).toContain("Homebrew 정리 계획을 만들지 못했습니다."); + expect(source).toContain("Homebrew 정리를 실행하지 못했습니다."); + expect(source).toContain('role=\"alert\"'); + }); + + it("preserves the existing judgment and execution authority calls", () => { + const source = readSource("src/lib/BrewCleanup.svelte"); + + expect(source).toContain("api.judgeBrewCleanup()"); + expect(source).toContain("api.executeBrewCleanup("); + expect(source).toContain("submittedJudgment.plan_fingerprint"); + expect(source).toContain("submittedJudgment.judgment_id"); + }); +}); diff --git a/src/lib/brewCleanupExecutionStatusContract.test.ts b/src/lib/brewCleanupExecutionStatusContract.test.ts new file mode 100644 index 000000000..4e10746f6 --- /dev/null +++ b/src/lib/brewCleanupExecutionStatusContract.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +describe("BrewCleanup execution status contract", () => { + it("does not style a non-executed zero-status response as success", () => { + const source = readFileSync(resolve(repositoryRoot, "src/lib/BrewCleanup.svelte"), "utf8"); + + expect(source).toContain( + "class:success={execution.executed && execution.status_code === 0}", + ); + expect(source).toContain( + "class:error={execution.executed && execution.status_code !== 0}", + ); + expect(source).toContain("class:warning={!execution.executed}"); + expect(source).toContain("실행 성공"); + expect(source).toContain("실행 실패"); + expect(source).toContain("실행되지 않음"); + expect(source).toContain("execution.executed"); + expect(source).not.toContain("class:success={execution.status_code === 0}"); + }); +}); diff --git a/src/lib/brewCleanupSafetyUiContract.test.ts b/src/lib/brewCleanupSafetyUiContract.test.ts index 96c9ba64b..5fa577f15 100644 --- a/src/lib/brewCleanupSafetyUiContract.test.ts +++ b/src/lib/brewCleanupSafetyUiContract.test.ts @@ -33,6 +33,21 @@ describe("Homebrew cleanup safety UX", () => { expect(executeCleanup).toContain("judgment = null;"); }); + it("distinguishes executed and non-executed result states", () => { + const source = readSource("src/lib/BrewCleanup.svelte"); + const start = source.indexOf("{#if execution}"); + const end = source.indexOf("\n {/if}\n ", start); + const executionResult = source.slice(start, end); + + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + expect(executionResult).toContain("execution.executed"); + expect(executionResult).toContain("실행 성공 (종료 코드"); + expect(executionResult).toContain("실행 실패 (종료 코드"); + expect(executionResult).toContain("실행되지 않음"); + expect(executionResult).not.toContain("실행 완료

"); + }); + it("normalizes the approval phrase and explains why execution is unavailable", () => { const source = readSource("src/lib/BrewCleanup.svelte"); @@ -41,4 +56,15 @@ describe("Homebrew cleanup safety UX", () => { expect(source).toContain("승인 문구가 일치하지 않습니다."); expect(source).toContain("실행 사유를 입력하십시오."); }); + + it("distinguishes a failed subprocess from a successful execution", () => { + const source = readSource("src/lib/BrewCleanup.svelte"); + + expect(source).toContain("execution.status_code === 0"); + expect(source).toContain("실행 성공 (종료 코드"); + expect(source).toContain("실행 실패 (종료 코드"); + expect(source).not.toContain( + "execution.executed ? `실행 완료 (종료 코드 ${execution.status_code})`", + ); + }); }); diff --git a/src/lib/icloudLocalEvictionSafetyUiContract.test.ts b/src/lib/icloudLocalEvictionSafetyUiContract.test.ts index 3aa4fb37d..37cab0fb7 100644 --- a/src/lib/icloudLocalEvictionSafetyUiContract.test.ts +++ b/src/lib/icloudLocalEvictionSafetyUiContract.test.ts @@ -45,4 +45,15 @@ describe("iCloud local eviction safety UI", () => { "File Provider 상태 증거가 완전하지 않습니다. 잠시 후 다시 판정하세요.", ]); }); + + it("retains provider-aware progress and deduplicated blocker feedback", () => { + const source = readSource(); + + expect(source).toContain("function uploadLabel"); + expect(source).toContain("업로드 중"); + expect(source).toContain("function syncLabel"); + expect(source).toContain("공급자 상태"); + expect(source).toContain("planBlockerActions(plan.blockers"); + expect(source).not.toContain("plan.blockers.join(\", \")"); + }); });