From 87968e3be32afa61572e70b1abd429acdc3fcf9b Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 21:00:16 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B8=83=E5=B1=80=E8=90=BD=E7=9B=98=20/=20?= =?UTF-8?q?=E8=AF=BB=E7=9B=98=E5=A4=B1=E8=B4=A5=E4=B8=8D=E5=86=8D=E5=90=9E?= =?UTF-8?q?=E6=8E=89=E5=8E=9F=E5=9B=A0=EF=BC=9A=E8=84=B1=E6=95=8F=E5=90=8E?= =?UTF-8?q?=E7=95=99=E4=B8=80=E8=A1=8C=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 两处 .catch(() => {…}) 没有 message 参数,失败原因被整个吞掉:用户只看到「布局保存失败」「布局读取失败」,排障端也拿不到为什么(revision 越界 / IPC 拒绝 / 权限); - 写回失败(update_local_project_resource_canvas_layout 的 .catch)与读盘失败(read_local_project_resource_canvas_layout 的 .catch)都补上 error 参数,并落一行 console.warn; - 新增 describeResourceCanvasLayoutFailure:把 error.message 压成一行、截断 400 字,并复用 errorReporting 的 normalizeDiagnosticText 脱敏(路径 / token / URL / 长 id 打码); - errorReporting 的 normalizeDiagnosticText 改为导出:脱敏口径只留一份,业务文件不再各写一套正则; - 用户可见文案一字不改(LayoutNotice 联合类型与既有四条用例的断言原样保留),原因只进日志;日志由客户端已装的 webview→application.log 桥带进诊断日志; - 新增两条用例:写回失败必须留下原因(并断言可见文案仍是「布局保存失败,已恢复上次布局」)、IPC 拒绝且原因里带本机绝对路径与 token 时必须脱敏成 且压成一行; - 变异验证:把写回那处改回吞掉原因 → 写回用例红灯;把读盘那处改回吞掉原因 → 脱敏用例红灯(各自只红对应一条,既有 24 条全绿)。 --- .../src/services/errorReporting.ts | 8 +- .../useProjectResourceCanvasLayout.ts | 31 +++++- .../useProjectResourceCanvasLayout.test.ts | 98 +++++++++++++++++++ 3 files changed, 134 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/services/errorReporting.ts b/apps/ai-game-creator-shell/src/services/errorReporting.ts index 9b9af0027..cc82eb8f9 100644 --- a/apps/ai-game-creator-shell/src/services/errorReporting.ts +++ b/apps/ai-game-creator-shell/src/services/errorReporting.ts @@ -55,7 +55,13 @@ export async function invokeDiagnostic( } } -function normalizeDiagnosticText(value: string) { +/** + * 诊断文本脱敏:抹掉授权头、Bearer、api key / token、URL 与本地绝对路径、长十六进制 id。 + * + * 交互层的失败提示要保留原因时就复用它,别在业务文件里另写一套正则 —— + * 脱敏口径必须只有一份,否则"某条路径漏了"会随调用点漂移。 + */ +export function normalizeDiagnosticText(value: string) { return value .replace( /authorization\s*:\s*(?:bearer\s+)?\S+/giu, diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts index 7e641046a..becf44ea8 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts @@ -10,6 +10,7 @@ import { isSafeProjectResourceCanvasCoordinate, isSafeProjectResourceCanvasLayoutRevision, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { normalizeDiagnosticText } from '../../services/errorReporting'; import { createEmptyResourceCanvasLayout, moveResourceCanvasPosition, @@ -19,6 +20,23 @@ import { } from './resourceCanvasLayoutModel'; import { normalizeResourceCanvasPosition } from './resourceCanvasSectionMapping'; +/** + * 布局落盘 / 读盘失败时保留一份**脱敏后的原因**。 + * + * 这两个 `.catch` 此前写成 `() => { ... }` —— 没有 message 参数,失败原因被整个吞掉: + * 用户只看到"布局保存失败",排障端也拿不到为什么(revision 越界?IPC 拒绝?权限?)。 + * 这里把 `error.message` 压成一行并复用既有的 `normalizeDiagnosticText`(路径 / token / + * URL / 长 id 一律打码),再截断,避免多行堆栈糊进日志;日志由客户端已安装的 + * webview→application.log 桥带进诊断日志,遇到"某个窗口保存一直失败"能直接查。 + */ +function describeResourceCanvasLayoutFailure(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return normalizeDiagnosticText(message.replace(/\s+/gu, ' ').trim()).slice( + 0, + 400, + ); +} + type LayoutNotice = | '' | '布局已保存' @@ -696,11 +714,16 @@ export function useProjectResourceCanvasLayout({ } rebuildOptimisticLayout(currentScope.epoch, writePolicy); }) - .catch(() => { + .catch((error: unknown) => { const currentScope = scopeRef.current; if (currentScope.epoch !== intent.scopeEpoch) { return; } + // 原因先落一行脱敏日志:下面那条提示在部分分支里是刻意不显示的, + // 若不记日志,这种"用户看不到、排障也看不到"的失败就完全消失。 + console.warn( + `[resource-canvas-layout] 布局保存失败:${describeResourceCanvasLayoutFailure(error)}`, + ); removeWriteIntent(intent); rebuildOptimisticLayout(currentScope.epoch, writePolicy); if (intent.kind === 'manual') { @@ -843,10 +866,14 @@ export function useProjectResourceCanvasLayout({ ); pumpWritesRef.current(); }) - .catch(() => { + .catch((error: unknown) => { if (cancelled || scopeRef.current.epoch !== epoch) { return; } + // 读盘失败同样是"只报一句、不报原因",这里保留脱敏后的一行原因。 + console.warn( + `[resource-canvas-layout] 布局读取失败:${describeResourceCanvasLayoutFailure(error)}`, + ); persistedLayoutRef.current = initialFallback; initializedScopeEpochRef.current = epoch; setReadyScopeKey(scopeKey); diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts index 5615c7a82..d0eb28f1a 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts @@ -1414,3 +1414,101 @@ describe('resource canvas layout read report', () => { ).toBe('打开项目时有 2 条坐标无法对齐已跳过(2 条分区与资源分类不匹配)'); }); }); + +describe('布局落盘 / 读盘失败的原因不再被吞掉', () => { + function warnMessages(warn: ReturnType) { + return warn.mock.calls.map((call) => String(call[0])); + } + + it('keeps the update failure reason in a sanitized log line', async () => { + // 写回失败此前是 `.catch(() => {})`:没有 message 参数,原因被整个吞掉。 + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const resourceA = resource('resource-a'); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_canvas_layout') { + return persistedLayout('dependency', 1, [ + position('resource-a', 10, 20), + ]); + } + if (command === 'update_local_project_resource_canvas_layout') { + return { + status: 'updated', + layout: persistedLayout( + 'dependency', + GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1, + structuredClone( + args?.positions as ProjectResourceCanvasPosition[], + ), + ), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'dependency', + resources: [resourceA], + }), + ); + await waitFor(() => expect(result.current.layout.positions[0]?.x).toBe(10)); + + act(() => result.current.commitPosition('resource-a', 'document', 100, 30)); + await waitFor(() => + expect(result.current.notice).toBe('布局保存失败,已恢复上次布局'), + ); + + // 用户可见文案保持原样(不把内部原因塞进面板),原因落到日志里可查。 + const logged = warnMessages(warn).filter((message) => + message.includes('[resource-canvas-layout] 布局保存失败'), + ); + expect(logged).toHaveLength(1); + expect(logged[0]).toContain( + 'layout response revision or coordinates are invalid', + ); + }); + + it('sanitizes an IPC failure reason before logging it', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + // 原始错误里带本机绝对路径与 token:日志可以留原因,但不能把这两样带出去。 + const rawPath = 'C:\\Users\\someone\\workspace\\secret-project'; + const invoke = vi.fn(async (command: string) => { + if (command === 'read_local_project_resource_canvas_layout') { + // 带换行是为了证明日志被压成一行,不会把多行堆栈糊进去。 + throw new Error( + `读取布局失败:\n${rawPath}\\.agent\\layout.json token=abcdef123456`, + ); + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'dependency', + resources: [resource('resource-a')], + }), + ); + await waitFor(() => + expect(result.current.notice).toBe('布局读取失败,已使用当前会话布局'), + ); + + const logged = warnMessages(warn).filter((message) => + message.includes('[resource-canvas-layout] 布局读取失败'), + ); + expect(logged).toHaveLength(1); + expect(logged[0]).toContain('读取布局失败'); + expect(logged[0]).not.toContain('someone'); + expect(logged[0]).not.toContain('abcdef123456'); + expect(logged[0]).toContain(''); + // 压成一行:不许把多行堆栈糊进日志。 + expect(logged[0]).not.toContain('\n'); + }); +});