导出面板失败通道按 ADR 分流:真故障原样抛出,复制失败不再静默
- 新增 state/xhsMinitoolOutputTail.ts:宿主只回尾部原文与省略量,「已省略前 N 个字符」这句话在前端拼,失败卡片与成功提示共用 - xhsMinitoolFailure.ts 接住新变体 commandDenied(策略拒绝是预期拒绝,句子在前端拼),commandFailed 的现场改走尾部说明 - useXhsMinitoolExport 的三处 catch:认不出形状的拒绝与 exportUnavailable 先给现场再原样抛出进错误池,按指纹去重(面板 2 秒一轮重读,不会把报告池 count 刷成轮询次数),不再把裸 message 当业务提示 - CopyButton 改用 Tauri 剪贴板插件并显示失败态:原来的 navigator.clipboard 在 WebView 里可能根本没有、失败还被吞掉 - 用例 18 → 19 例:省略说明由前端拼、未分类拒绝原样抛出、宿主故障先给卡片再抛一次、复制失败可见
This commit is contained in:
+55
-10
@@ -10,6 +10,11 @@
|
||||
* - 注册表指纹是写回时的同步细节,不进对外状态:它只在 `baselineRef` 里,且只有进编辑会话或
|
||||
* 内容真的被采纳时才单独取一次。自动刷新([CONTENT_REFRESH_INTERVAL_MS])读的是不含指纹的
|
||||
* 内容命令,所以「刷新」在类型上就动不了写回基线。
|
||||
*
|
||||
* 失败按 ADR 的两道口子分流(`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`、
|
||||
* `【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`):typed 变体里除了宿主侧事实故障
|
||||
* (`exportUnavailable`)都留在面板里就地消化;**宿主故障与认不出形状的拒绝原样抛出**,由全局
|
||||
* `unhandledrejection` 送进错误池,面板不把原始 message 当成业务提示糊给用户。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
@@ -36,6 +41,7 @@ import {
|
||||
buildXhsMinitoolRepairInstruction,
|
||||
XHS_MINITOOL_ARTIFACT_RELATIVE_PATH,
|
||||
} from './xhsMinitoolInstruction';
|
||||
import { describeOutputTail } from './xhsMinitoolOutputTail';
|
||||
|
||||
export type XhsMinitoolExportState = ReturnType<typeof useXhsMinitoolExport>;
|
||||
|
||||
@@ -104,6 +110,8 @@ export function useXhsMinitoolExport({
|
||||
const baselineRef = useRef('');
|
||||
/** 轮询回调里要判断「用户手上有没有未保存输入」,但它们不该把轮询节拍打乱。 */
|
||||
const adoptableRef = useRef(false);
|
||||
/** 上一次抛进错误池的故障指纹:同一个只抛一次,见 [useXhsMinitoolExport.throwToErrorPool]。 */
|
||||
const reportedFailureRef = useRef('');
|
||||
const formRef = useRef<XHSMiniToolExportForm | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -114,6 +122,22 @@ export function useXhsMinitoolExport({
|
||||
adoptableRef.current = !dirty && !conflict && !fieldError;
|
||||
}, [dirty, conflict, fieldError]);
|
||||
|
||||
/**
|
||||
* 真故障(认不出形状的拒绝、宿主侧事实故障)原样抛出,交给全局 `unhandledrejection` 结算。
|
||||
*
|
||||
* 面板每 2 秒重读一次内容,同一个故障会反复落到这里:按指纹去重,只把第一次抛出去,故障变了再抛。
|
||||
* 否则报告池里的 `count` 统计的是「面板开了多久」,不是故障发生了几次。去重不改变「有人接手」的
|
||||
* 判定——第一次已经报过,后续是同一个故障的重复现场。
|
||||
*/
|
||||
const throwToErrorPool = useCallback(
|
||||
(fingerprint: string, error: unknown): void => {
|
||||
if (reportedFailureRef.current === fingerprint) return;
|
||||
reportedFailureRef.current = fingerprint;
|
||||
throw error;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* 重读内容:注册表(宿主缺则补空表单)+ 脚本是否存在。首次进入与自动刷新走同一条。
|
||||
*
|
||||
@@ -151,12 +175,19 @@ export function useXhsMinitoolExport({
|
||||
);
|
||||
} catch (error) {
|
||||
const structured = asXhsMinitoolFailure(error);
|
||||
if (structured) setFailure(failureState(structured));
|
||||
else setNotice(error instanceof Error ? error.message : String(error));
|
||||
if (!structured) {
|
||||
throwToErrorPool(String(error), error);
|
||||
return;
|
||||
}
|
||||
setFailure(failureState(structured));
|
||||
// 宿主侧事实故障:先让面板显示现场,再抛进错误池(策略拒绝等预期拒绝不走这一步)。
|
||||
if (structured.type === 'exportUnavailable') {
|
||||
throwToErrorPool(JSON.stringify(structured), error);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectPath]);
|
||||
}, [projectPath, throwToErrorPool]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
@@ -193,15 +224,20 @@ export function useXhsMinitoolExport({
|
||||
setDirty(false);
|
||||
const structured = asXhsMinitoolFailure(error);
|
||||
if (!structured) {
|
||||
setNotice(error instanceof Error ? error.message : String(error));
|
||||
throwToErrorPool(String(error), error);
|
||||
return;
|
||||
}
|
||||
if (structured.type === 'formInvalid') setFieldError(structured);
|
||||
else if (structured.type === 'saveConflict') setConflict(structured);
|
||||
else setFailure(failureState(structured));
|
||||
else {
|
||||
setFailure(failureState(structured));
|
||||
if (structured.type === 'exportUnavailable') {
|
||||
throwToErrorPool(JSON.stringify(structured), error);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[projectPath],
|
||||
[projectPath, throwToErrorPool],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -251,18 +287,27 @@ export function useXhsMinitoolExport({
|
||||
const result = await runXhsMinitoolExportBuild(invoke, projectPath);
|
||||
// 打包脚本会把 zip 的条目数与体积打在输出尾部,正是用户上传前要核对的数字。
|
||||
setNotice(
|
||||
[`已重新产出 ${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}`, result.outputTail]
|
||||
[
|
||||
`已重新产出 ${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}`,
|
||||
describeOutputTail(result),
|
||||
]
|
||||
.filter((line) => line.trim() !== '')
|
||||
.join('\n'),
|
||||
);
|
||||
} catch (error) {
|
||||
const structured = asXhsMinitoolFailure(error);
|
||||
if (structured) setFailure(failureState(structured));
|
||||
else setNotice(error instanceof Error ? error.message : String(error));
|
||||
if (!structured) {
|
||||
throwToErrorPool(String(error), error);
|
||||
return;
|
||||
}
|
||||
setFailure(failureState(structured));
|
||||
if (structured.type === 'exportUnavailable') {
|
||||
throwToErrorPool(JSON.stringify(structured), error);
|
||||
}
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}, [projectPath]);
|
||||
}, [projectPath, throwToErrorPool]);
|
||||
|
||||
const sendToAgent = useCallback(
|
||||
async (instruction: string): Promise<void> => {
|
||||
|
||||
+16
-2
@@ -2,11 +2,18 @@
|
||||
* 结构化失败 → 用户看得懂的一句话 + 下一步动作。
|
||||
*
|
||||
* 与入队失败同一口径:分流只看 `error.type`,句子按变体写死,**不从载荷里"读"出文案**。载荷
|
||||
* 字段只是拼句子的原料(路径、退出码、解析器原文)。穷尽性由 `expectNever` 在编译期钉住。
|
||||
* 字段只是拼句子的原料(路径、退出码、输出尾部、解析器原文)。穷尽性由 `expectNever` 在编译期钉住。
|
||||
*
|
||||
* 这里是**纯映射**,不判「要不要上报」:上报判据写在 `useXhsMinitoolExport` 的 catch 里
|
||||
* (策略拒绝留在面板,宿主侧事实故障给完现场后原样抛出进错误池)。它与
|
||||
* [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`]「不抽 presenter」的差异是刻意的:
|
||||
* 那条针对只有一个消费方的认证 catch,而这份映射有两个消费方——面板卡片与交给 agent 的修复指令
|
||||
* (两者必须说同一句话,否则用户在面板里看到的和 agent 拿到的会分叉)。
|
||||
*/
|
||||
import { expectNever } from '../../../../app/expectNever';
|
||||
import type { XHSMiniToolExportError } from '../generated/XHSMiniToolExportError';
|
||||
import { XHS_MINITOOL_FIELD_LABELS } from './xhsMinitoolFields';
|
||||
import { describeOutputTail } from './xhsMinitoolOutputTail';
|
||||
|
||||
/**
|
||||
* 失败之后最合适的下一步,也就是那颗按钮干什么:
|
||||
@@ -52,13 +59,20 @@ export function describeXhsMinitoolFailure(
|
||||
detail: '',
|
||||
action: 'retry',
|
||||
};
|
||||
case 'commandDenied':
|
||||
return {
|
||||
// 句子在这里拼:宿主只给被拒的命令点位,不拼人话。
|
||||
message: `项目权限策略拒绝执行:${error.commandId}`,
|
||||
detail: '',
|
||||
action: 'retry',
|
||||
};
|
||||
case 'commandFailed':
|
||||
return {
|
||||
message:
|
||||
error.exitCode === null
|
||||
? '构建脚本没能启动(沙箱 / 运行环境层面就失败了)。'
|
||||
: `构建脚本没有正常退出(exit code ${error.exitCode})。`,
|
||||
detail: error.outputTail,
|
||||
detail: describeOutputTail(error),
|
||||
action: 'repair',
|
||||
};
|
||||
case 'artifactMissing':
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 构建输出尾部的显示文本:宿主只给两个事实(原文 + 省略了多少字符),句子在这里拼。
|
||||
*
|
||||
* 宿主不预拼用户可见文案([`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`]):省略量是事实,
|
||||
* 「省略了多少」怎么说、放在前还是后、要不要提示「尾巴不完整」,都由拿得到现场的这一层决定。
|
||||
* 失败卡片与导出成功提示共用这一份,避免两处各写一句。
|
||||
*/
|
||||
export function describeOutputTail(input: {
|
||||
outputTail: string;
|
||||
omittedCharacters: number;
|
||||
}): string {
|
||||
const tail = input.outputTail.trim();
|
||||
if (input.omittedCharacters <= 0) return tail;
|
||||
const note = `(日志过长,已省略前 ${input.omittedCharacters} 个字符)`;
|
||||
return tail ? `${note}\n${tail}` : note;
|
||||
}
|
||||
+34
-14
@@ -1,43 +1,63 @@
|
||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/** 复制按钮:成功后显示一小会儿「已复制」,不弹提示、不动别处状态。 */
|
||||
type CopyState = 'idle' | 'copied' | 'failed';
|
||||
|
||||
/**
|
||||
* 复制按钮:成功后显示一小会儿「已复制」,失败也**必须**说一声。
|
||||
*
|
||||
* 走 Tauri 的 clipboard 插件而不是 `navigator.clipboard`:后者在 WebView 里是安全上下文相关的能力,
|
||||
* 拿不到时只会静默失败,用户点了没反应;插件是仓库既有口径(`UiEditorCopyPathButton`、
|
||||
* `useAccountReferral`、`RichInputArea` 三处),权限位为 `clipboard-manager:allow-write-text`。
|
||||
* 失败不再吞掉:写剪贴板是宿主/环境事实,调用方给一行可见反馈就是这一层该做的事。
|
||||
*/
|
||||
export function CopyButton({ value, label }: { value: string; label: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [state, setState] = useState<CopyState>('idle');
|
||||
|
||||
useEffect(() => {
|
||||
if (!copied) return;
|
||||
const timer = setTimeout(() => setCopied(false), 1500);
|
||||
if (state === 'idle') return;
|
||||
const timer = setTimeout(() => setState('idle'), 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [copied]);
|
||||
}, [state]);
|
||||
|
||||
const text =
|
||||
state === 'copied' ? '已复制' : state === 'failed' ? '复制失败' : '复制';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`复制${label}`}
|
||||
title={`复制${label}`}
|
||||
aria-label={`${text}${label}`}
|
||||
title={
|
||||
state === 'failed' ? `复制${label}失败,请手动复制` : `复制${label}`
|
||||
}
|
||||
disabled={!value}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-lg border border-[#e2cbb8] bg-[#fffaf5] px-2 py-1 text-xs text-[#6f5848] transition hover:border-[#c7653d] hover:text-[#c7653d] disabled:cursor-not-allowed disabled:opacity-45"
|
||||
className={`inline-flex shrink-0 items-center gap-1 rounded-lg border px-2 py-1 text-xs transition disabled:cursor-not-allowed disabled:opacity-45 ${
|
||||
state === 'failed'
|
||||
? 'border-red-300 bg-red-50 text-red-600'
|
||||
: 'border-[#e2cbb8] bg-[#fffaf5] text-[#6f5848] hover:border-[#c7653d] hover:text-[#c7653d]'
|
||||
}`}
|
||||
onClick={() => {
|
||||
void copyText(value).then(setCopied);
|
||||
setState('idle');
|
||||
void copyText(value).then((copied) =>
|
||||
setState(copied ? 'copied' : 'failed'),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{copied ? (
|
||||
{state === 'copied' ? (
|
||||
<Check size={13} aria-hidden="true" />
|
||||
) : (
|
||||
<Copy size={13} aria-hidden="true" />
|
||||
)}
|
||||
<span>{copied ? '已复制' : '复制'}</span>
|
||||
<span>{text}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
async function copyText(value: string): Promise<boolean> {
|
||||
if (!value) return false;
|
||||
const clipboard = navigator.clipboard;
|
||||
if (!clipboard) return false;
|
||||
try {
|
||||
await clipboard.writeText(value);
|
||||
await writeText(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* - 左半面板的可见动作(复制、下载、适配 / 导出 / 刷新、冲突逐字段二选一、失败卡片的下一步);
|
||||
* - 状态 hook 的自动保存与入队(宿主命令参数、`baseHash` 回传、以及"适配才叫 agent")。
|
||||
*/
|
||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog';
|
||||
import {
|
||||
act,
|
||||
@@ -40,6 +41,8 @@ import { ArtifactsPane } from '../src/view/project-development/export/tabs/xiaoh
|
||||
import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake';
|
||||
|
||||
vi.mock('@tauri-apps/plugin-dialog', () => ({ save: vi.fn() }));
|
||||
// 剪贴板走 Tauri 插件(`navigator.clipboard` 在 WebView 里可能根本没有)。
|
||||
vi.mock('@tauri-apps/plugin-clipboard-manager', () => ({ writeText: vi.fn() }));
|
||||
|
||||
const PROJECT_PATH = '/tmp/export-panel-project';
|
||||
|
||||
@@ -56,10 +59,7 @@ const FORM = {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: vi.fn(async () => undefined) },
|
||||
});
|
||||
vi.mocked(writeText).mockResolvedValue(undefined);
|
||||
vi.mocked(saveNativeFileDialog).mockResolvedValue('/tmp/icon.png');
|
||||
});
|
||||
|
||||
@@ -125,12 +125,33 @@ describe('交给 code agent 的指令', () => {
|
||||
type: 'commandFailed',
|
||||
exitCode: 1,
|
||||
outputTail: 'vite build failed',
|
||||
omittedCharacters: 0,
|
||||
}),
|
||||
).toMatchObject({ action: 'repair', detail: 'vite build failed' });
|
||||
// 截断是宿主给的事实,提示那句话在这里拼:载荷原文里不含任何宿主写的说明。
|
||||
expect(
|
||||
describeXhsMinitoolFailure({
|
||||
type: 'commandFailed',
|
||||
exitCode: 1,
|
||||
outputTail: '最后一行报错',
|
||||
omittedCharacters: 4096,
|
||||
}).detail,
|
||||
).toBe('(日志过长,已省略前 4096 个字符)\n最后一行报错');
|
||||
// 策略拒绝是预期拒绝:宿主只给点位,句子在前端,且不进错误池。
|
||||
expect(
|
||||
describeXhsMinitoolFailure({
|
||||
type: 'commandDenied',
|
||||
commandId: 'command.exec',
|
||||
}),
|
||||
).toEqual({
|
||||
message: '项目权限策略拒绝执行:command.exec',
|
||||
detail: '',
|
||||
action: 'retry',
|
||||
});
|
||||
expect(
|
||||
describeXhsMinitoolFailure({
|
||||
type: 'exportUnavailable',
|
||||
cause: '项目权限策略拒绝执行:command.exec',
|
||||
cause: '项目根不可用:找不到目录',
|
||||
}).action,
|
||||
).toBe('retry');
|
||||
expect(
|
||||
@@ -149,8 +170,23 @@ describe('面板左半', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制名称' }));
|
||||
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith('小工具'));
|
||||
// 成功态也写进无障碍名,用户与读屏都看得到「已复制」。
|
||||
await waitFor(() =>
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('小工具'),
|
||||
expect(screen.getByRole('button', { name: '已复制名称' })).not.toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it('复制失败不静默吞掉,按钮自己说一声', async () => {
|
||||
vi.mocked(writeText).mockRejectedValue(new Error('剪贴板不可用'));
|
||||
renderPane();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制名称' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: '复制失败名称' }),
|
||||
).not.toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -278,6 +314,7 @@ describe('面板左半', () => {
|
||||
type: 'commandFailed',
|
||||
exitCode: 1,
|
||||
outputTail: 'vite build failed',
|
||||
omittedCharacters: 0,
|
||||
}),
|
||||
});
|
||||
renderPane(broken);
|
||||
@@ -425,10 +462,11 @@ describe('状态 hook', () => {
|
||||
it('导出把打包脚本的输出尾部一并报给用户', async () => {
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'read_xhs_minitool_export') {
|
||||
return { form: FORM, hasScript: true, contentHash: CONTENT_HASH };
|
||||
return { form: FORM, hasScript: true };
|
||||
}
|
||||
return {
|
||||
outputTail: `zip 已生成:${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}(3 个条目,0.42 MiB)`,
|
||||
omittedCharacters: 0,
|
||||
};
|
||||
});
|
||||
installTauriRuntime({ core: { invoke } });
|
||||
@@ -455,6 +493,97 @@ describe('状态 hook', () => {
|
||||
expect(result.current.running).toBe(false);
|
||||
});
|
||||
|
||||
it('输出被宿主截断时,说明那句话由前端拼', async () => {
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'read_xhs_minitool_export') {
|
||||
return { form: FORM, hasScript: true };
|
||||
}
|
||||
if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH;
|
||||
return { outputTail: 'zip 已生成', omittedCharacters: 4096 };
|
||||
});
|
||||
installTauriRuntime({ core: { invoke } });
|
||||
const { result } = renderHook(() =>
|
||||
useXhsMinitoolExport({
|
||||
enabled: true,
|
||||
projectPath: PROJECT_PATH,
|
||||
submitTurn: vi.fn(async () => true),
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(result.current.form).toEqual(FORM));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.runExport();
|
||||
});
|
||||
|
||||
// 宿主只回事实(原文 + 省略量),提示里那句「已省略」是这里拼的。
|
||||
expect(result.current.notice).toBe(
|
||||
`已重新产出 ${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}\n(日志过长,已省略前 4096 个字符)\nzip 已生成`,
|
||||
);
|
||||
});
|
||||
|
||||
it('认不出形状的拒绝原样抛出进错误池,不当成业务提示糊给用户', async () => {
|
||||
// Tauri 传输失败 / 命令 panic / 参数序列化失败都是真 `Error`:它不是结构化拒绝,
|
||||
// 面板不许把它的 message 当成业务失败的解释,只把它交回错误池。
|
||||
const transport = new Error('ipc 通道断了');
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'read_xhs_minitool_export') {
|
||||
return { form: FORM, hasScript: true };
|
||||
}
|
||||
if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH;
|
||||
throw transport;
|
||||
});
|
||||
installTauriRuntime({ core: { invoke } });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useXhsMinitoolExport({
|
||||
enabled: true,
|
||||
projectPath: PROJECT_PATH,
|
||||
submitTurn: vi.fn(async () => true),
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(result.current.form).toEqual(FORM));
|
||||
|
||||
await expect(result.current.runExport()).rejects.toBe(transport);
|
||||
expect(result.current.notice).toBe('');
|
||||
expect(result.current.failure).toBeNull();
|
||||
// 同一个故障只抛一次:轮询每 2 秒重读一次,不能让错误池的 count 变成轮询次数。
|
||||
await expect(result.current.runExport()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('宿主侧事实故障先给卡片、再把同一个故障抛一次', async () => {
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'read_xhs_minitool_export') {
|
||||
return { form: FORM, hasScript: true };
|
||||
}
|
||||
if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH;
|
||||
throw { type: 'exportUnavailable', cause: '项目根不可用:找不到目录' };
|
||||
});
|
||||
installTauriRuntime({ core: { invoke } });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useXhsMinitoolExport({
|
||||
enabled: true,
|
||||
projectPath: PROJECT_PATH,
|
||||
submitTurn: vi.fn(async () => true),
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(result.current.form).toEqual(FORM));
|
||||
|
||||
// 卡片照常给现场,同时原样抛出(真实 WebView 里由全局 handler 结算进错误池)。
|
||||
await act(async () => {
|
||||
await expect(result.current.runExport()).rejects.toBeInstanceOf(Error);
|
||||
});
|
||||
expect(result.current.failure?.notice.message).toBe(
|
||||
'宿主这次没能执行导出。',
|
||||
);
|
||||
expect(result.current.failure?.notice.detail).toBe(
|
||||
'项目根不可用:找不到目录',
|
||||
);
|
||||
await act(async () => {
|
||||
await expect(result.current.runExport()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('适配把指令当成一条用户消息交给同一个会话', async () => {
|
||||
const submitTurn = vi.fn(async () => true);
|
||||
installTauriRuntime({
|
||||
@@ -462,7 +591,6 @@ describe('状态 hook', () => {
|
||||
invoke: vi.fn(async () => ({
|
||||
form: FORM,
|
||||
hasScript: false,
|
||||
contentHash: CONTENT_HASH,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user