布局落盘 / 读盘失败不再吞掉原因:脱敏后留一行日志

- 两处 .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 时必须脱敏成 <path> 且压成一行;
- 变异验证:把写回那处改回吞掉原因 → 写回用例红灯;把读盘那处改回吞掉原因 → 脱敏用例红灯(各自只红对应一条,既有 24 条全绿)。
This commit is contained in:
2026-09-11 21:00:16 +08:00
parent 3a4f95d7ca
commit 87968e3be3
3 changed files with 134 additions and 3 deletions
@@ -55,7 +55,13 @@ export async function invokeDiagnostic<T>(
}
}
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,
@@ -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);
@@ -1414,3 +1414,101 @@ describe('resource canvas layout read report', () => {
).toBe('打开项目时有 2 条坐标无法对齐已跳过(2 条分区与资源分类不匹配)');
});
});
describe('布局落盘 / 读盘失败的原因不再被吞掉', () => {
function warnMessages(warn: ReturnType<typeof vi.spyOn>) {
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<string, unknown>) => {
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('<path>');
// 压成一行:不许把多行堆栈糊进日志。
expect(logged[0]).not.toContain('\n');
});
});