布局落盘 / 读盘失败不再吞掉原因:脱敏后留一行日志
- 两处 .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:
@@ -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,
|
||||
|
||||
+29
-2
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user