导出面板失败通道按 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` 里,且只有进编辑会话或
|
* - 注册表指纹是写回时的同步细节,不进对外状态:它只在 `baselineRef` 里,且只有进编辑会话或
|
||||||
* 内容真的被采纳时才单独取一次。自动刷新([CONTENT_REFRESH_INTERVAL_MS])读的是不含指纹的
|
* 内容真的被采纳时才单独取一次。自动刷新([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';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
@@ -36,6 +41,7 @@ import {
|
|||||||
buildXhsMinitoolRepairInstruction,
|
buildXhsMinitoolRepairInstruction,
|
||||||
XHS_MINITOOL_ARTIFACT_RELATIVE_PATH,
|
XHS_MINITOOL_ARTIFACT_RELATIVE_PATH,
|
||||||
} from './xhsMinitoolInstruction';
|
} from './xhsMinitoolInstruction';
|
||||||
|
import { describeOutputTail } from './xhsMinitoolOutputTail';
|
||||||
|
|
||||||
export type XhsMinitoolExportState = ReturnType<typeof useXhsMinitoolExport>;
|
export type XhsMinitoolExportState = ReturnType<typeof useXhsMinitoolExport>;
|
||||||
|
|
||||||
@@ -104,6 +110,8 @@ export function useXhsMinitoolExport({
|
|||||||
const baselineRef = useRef('');
|
const baselineRef = useRef('');
|
||||||
/** 轮询回调里要判断「用户手上有没有未保存输入」,但它们不该把轮询节拍打乱。 */
|
/** 轮询回调里要判断「用户手上有没有未保存输入」,但它们不该把轮询节拍打乱。 */
|
||||||
const adoptableRef = useRef(false);
|
const adoptableRef = useRef(false);
|
||||||
|
/** 上一次抛进错误池的故障指纹:同一个只抛一次,见 [useXhsMinitoolExport.throwToErrorPool]。 */
|
||||||
|
const reportedFailureRef = useRef('');
|
||||||
const formRef = useRef<XHSMiniToolExportForm | null>(null);
|
const formRef = useRef<XHSMiniToolExportForm | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -114,6 +122,22 @@ export function useXhsMinitoolExport({
|
|||||||
adoptableRef.current = !dirty && !conflict && !fieldError;
|
adoptableRef.current = !dirty && !conflict && !fieldError;
|
||||||
}, [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) {
|
} catch (error) {
|
||||||
const structured = asXhsMinitoolFailure(error);
|
const structured = asXhsMinitoolFailure(error);
|
||||||
if (structured) setFailure(failureState(structured));
|
if (!structured) {
|
||||||
else setNotice(error instanceof Error ? error.message : String(error));
|
throwToErrorPool(String(error), error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFailure(failureState(structured));
|
||||||
|
// 宿主侧事实故障:先让面板显示现场,再抛进错误池(策略拒绝等预期拒绝不走这一步)。
|
||||||
|
if (structured.type === 'exportUnavailable') {
|
||||||
|
throwToErrorPool(JSON.stringify(structured), error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [projectPath]);
|
}, [projectPath, throwToErrorPool]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
@@ -193,15 +224,20 @@ export function useXhsMinitoolExport({
|
|||||||
setDirty(false);
|
setDirty(false);
|
||||||
const structured = asXhsMinitoolFailure(error);
|
const structured = asXhsMinitoolFailure(error);
|
||||||
if (!structured) {
|
if (!structured) {
|
||||||
setNotice(error instanceof Error ? error.message : String(error));
|
throwToErrorPool(String(error), error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (structured.type === 'formInvalid') setFieldError(structured);
|
if (structured.type === 'formInvalid') setFieldError(structured);
|
||||||
else if (structured.type === 'saveConflict') setConflict(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(() => {
|
useEffect(() => {
|
||||||
@@ -251,18 +287,27 @@ export function useXhsMinitoolExport({
|
|||||||
const result = await runXhsMinitoolExportBuild(invoke, projectPath);
|
const result = await runXhsMinitoolExportBuild(invoke, projectPath);
|
||||||
// 打包脚本会把 zip 的条目数与体积打在输出尾部,正是用户上传前要核对的数字。
|
// 打包脚本会把 zip 的条目数与体积打在输出尾部,正是用户上传前要核对的数字。
|
||||||
setNotice(
|
setNotice(
|
||||||
[`已重新产出 ${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}`, result.outputTail]
|
[
|
||||||
|
`已重新产出 ${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}`,
|
||||||
|
describeOutputTail(result),
|
||||||
|
]
|
||||||
.filter((line) => line.trim() !== '')
|
.filter((line) => line.trim() !== '')
|
||||||
.join('\n'),
|
.join('\n'),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const structured = asXhsMinitoolFailure(error);
|
const structured = asXhsMinitoolFailure(error);
|
||||||
if (structured) setFailure(failureState(structured));
|
if (!structured) {
|
||||||
else setNotice(error instanceof Error ? error.message : String(error));
|
throwToErrorPool(String(error), error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFailure(failureState(structured));
|
||||||
|
if (structured.type === 'exportUnavailable') {
|
||||||
|
throwToErrorPool(JSON.stringify(structured), error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
}
|
}
|
||||||
}, [projectPath]);
|
}, [projectPath, throwToErrorPool]);
|
||||||
|
|
||||||
const sendToAgent = useCallback(
|
const sendToAgent = useCallback(
|
||||||
async (instruction: string): Promise<void> => {
|
async (instruction: string): Promise<void> => {
|
||||||
|
|||||||
+16
-2
@@ -2,11 +2,18 @@
|
|||||||
* 结构化失败 → 用户看得懂的一句话 + 下一步动作。
|
* 结构化失败 → 用户看得懂的一句话 + 下一步动作。
|
||||||
*
|
*
|
||||||
* 与入队失败同一口径:分流只看 `error.type`,句子按变体写死,**不从载荷里"读"出文案**。载荷
|
* 与入队失败同一口径:分流只看 `error.type`,句子按变体写死,**不从载荷里"读"出文案**。载荷
|
||||||
* 字段只是拼句子的原料(路径、退出码、解析器原文)。穷尽性由 `expectNever` 在编译期钉住。
|
* 字段只是拼句子的原料(路径、退出码、输出尾部、解析器原文)。穷尽性由 `expectNever` 在编译期钉住。
|
||||||
|
*
|
||||||
|
* 这里是**纯映射**,不判「要不要上报」:上报判据写在 `useXhsMinitoolExport` 的 catch 里
|
||||||
|
* (策略拒绝留在面板,宿主侧事实故障给完现场后原样抛出进错误池)。它与
|
||||||
|
* [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`]「不抽 presenter」的差异是刻意的:
|
||||||
|
* 那条针对只有一个消费方的认证 catch,而这份映射有两个消费方——面板卡片与交给 agent 的修复指令
|
||||||
|
* (两者必须说同一句话,否则用户在面板里看到的和 agent 拿到的会分叉)。
|
||||||
*/
|
*/
|
||||||
import { expectNever } from '../../../../app/expectNever';
|
import { expectNever } from '../../../../app/expectNever';
|
||||||
import type { XHSMiniToolExportError } from '../generated/XHSMiniToolExportError';
|
import type { XHSMiniToolExportError } from '../generated/XHSMiniToolExportError';
|
||||||
import { XHS_MINITOOL_FIELD_LABELS } from './xhsMinitoolFields';
|
import { XHS_MINITOOL_FIELD_LABELS } from './xhsMinitoolFields';
|
||||||
|
import { describeOutputTail } from './xhsMinitoolOutputTail';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 失败之后最合适的下一步,也就是那颗按钮干什么:
|
* 失败之后最合适的下一步,也就是那颗按钮干什么:
|
||||||
@@ -52,13 +59,20 @@ export function describeXhsMinitoolFailure(
|
|||||||
detail: '',
|
detail: '',
|
||||||
action: 'retry',
|
action: 'retry',
|
||||||
};
|
};
|
||||||
|
case 'commandDenied':
|
||||||
|
return {
|
||||||
|
// 句子在这里拼:宿主只给被拒的命令点位,不拼人话。
|
||||||
|
message: `项目权限策略拒绝执行:${error.commandId}`,
|
||||||
|
detail: '',
|
||||||
|
action: 'retry',
|
||||||
|
};
|
||||||
case 'commandFailed':
|
case 'commandFailed':
|
||||||
return {
|
return {
|
||||||
message:
|
message:
|
||||||
error.exitCode === null
|
error.exitCode === null
|
||||||
? '构建脚本没能启动(沙箱 / 运行环境层面就失败了)。'
|
? '构建脚本没能启动(沙箱 / 运行环境层面就失败了)。'
|
||||||
: `构建脚本没有正常退出(exit code ${error.exitCode})。`,
|
: `构建脚本没有正常退出(exit code ${error.exitCode})。`,
|
||||||
detail: error.outputTail,
|
detail: describeOutputTail(error),
|
||||||
action: 'repair',
|
action: 'repair',
|
||||||
};
|
};
|
||||||
case 'artifactMissing':
|
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 { Check, Copy } from 'lucide-react';
|
||||||
import { useEffect, useState } from '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 }) {
|
export function CopyButton({ value, label }: { value: string; label: string }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [state, setState] = useState<CopyState>('idle');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!copied) return;
|
if (state === 'idle') return;
|
||||||
const timer = setTimeout(() => setCopied(false), 1500);
|
const timer = setTimeout(() => setState('idle'), 1500);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [copied]);
|
}, [state]);
|
||||||
|
|
||||||
|
const text =
|
||||||
|
state === 'copied' ? '已复制' : state === 'failed' ? '复制失败' : '复制';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`复制${label}`}
|
aria-label={`${text}${label}`}
|
||||||
title={`复制${label}`}
|
title={
|
||||||
|
state === 'failed' ? `复制${label}失败,请手动复制` : `复制${label}`
|
||||||
|
}
|
||||||
disabled={!value}
|
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={() => {
|
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" />
|
<Check size={13} aria-hidden="true" />
|
||||||
) : (
|
) : (
|
||||||
<Copy size={13} aria-hidden="true" />
|
<Copy size={13} aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
<span>{copied ? '已复制' : '复制'}</span>
|
<span>{text}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyText(value: string): Promise<boolean> {
|
async function copyText(value: string): Promise<boolean> {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
const clipboard = navigator.clipboard;
|
|
||||||
if (!clipboard) return false;
|
|
||||||
try {
|
try {
|
||||||
await clipboard.writeText(value);
|
await writeText(value);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* - 左半面板的可见动作(复制、下载、适配 / 导出 / 刷新、冲突逐字段二选一、失败卡片的下一步);
|
* - 左半面板的可见动作(复制、下载、适配 / 导出 / 刷新、冲突逐字段二选一、失败卡片的下一步);
|
||||||
* - 状态 hook 的自动保存与入队(宿主命令参数、`baseHash` 回传、以及"适配才叫 agent")。
|
* - 状态 hook 的自动保存与入队(宿主命令参数、`baseHash` 回传、以及"适配才叫 agent")。
|
||||||
*/
|
*/
|
||||||
|
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||||
import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog';
|
import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog';
|
||||||
import {
|
import {
|
||||||
act,
|
act,
|
||||||
@@ -40,6 +41,8 @@ import { ArtifactsPane } from '../src/view/project-development/export/tabs/xiaoh
|
|||||||
import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake';
|
import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake';
|
||||||
|
|
||||||
vi.mock('@tauri-apps/plugin-dialog', () => ({ save: vi.fn() }));
|
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';
|
const PROJECT_PATH = '/tmp/export-panel-project';
|
||||||
|
|
||||||
@@ -56,10 +59,7 @@ const FORM = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
Object.defineProperty(navigator, 'clipboard', {
|
vi.mocked(writeText).mockResolvedValue(undefined);
|
||||||
configurable: true,
|
|
||||||
value: { writeText: vi.fn(async () => undefined) },
|
|
||||||
});
|
|
||||||
vi.mocked(saveNativeFileDialog).mockResolvedValue('/tmp/icon.png');
|
vi.mocked(saveNativeFileDialog).mockResolvedValue('/tmp/icon.png');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,12 +125,33 @@ describe('交给 code agent 的指令', () => {
|
|||||||
type: 'commandFailed',
|
type: 'commandFailed',
|
||||||
exitCode: 1,
|
exitCode: 1,
|
||||||
outputTail: 'vite build failed',
|
outputTail: 'vite build failed',
|
||||||
|
omittedCharacters: 0,
|
||||||
}),
|
}),
|
||||||
).toMatchObject({ action: 'repair', detail: 'vite build failed' });
|
).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(
|
expect(
|
||||||
describeXhsMinitoolFailure({
|
describeXhsMinitoolFailure({
|
||||||
type: 'exportUnavailable',
|
type: 'exportUnavailable',
|
||||||
cause: '项目权限策略拒绝执行:command.exec',
|
cause: '项目根不可用:找不到目录',
|
||||||
}).action,
|
}).action,
|
||||||
).toBe('retry');
|
).toBe('retry');
|
||||||
expect(
|
expect(
|
||||||
@@ -149,8 +170,23 @@ describe('面板左半', () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '复制名称' }));
|
fireEvent.click(screen.getByRole('button', { name: '复制名称' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(writeText).toHaveBeenCalledWith('小工具'));
|
||||||
|
// 成功态也写进无障碍名,用户与读屏都看得到「已复制」。
|
||||||
await waitFor(() =>
|
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',
|
type: 'commandFailed',
|
||||||
exitCode: 1,
|
exitCode: 1,
|
||||||
outputTail: 'vite build failed',
|
outputTail: 'vite build failed',
|
||||||
|
omittedCharacters: 0,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
renderPane(broken);
|
renderPane(broken);
|
||||||
@@ -425,10 +462,11 @@ describe('状态 hook', () => {
|
|||||||
it('导出把打包脚本的输出尾部一并报给用户', async () => {
|
it('导出把打包脚本的输出尾部一并报给用户', async () => {
|
||||||
const invoke = vi.fn(async (command: string) => {
|
const invoke = vi.fn(async (command: string) => {
|
||||||
if (command === 'read_xhs_minitool_export') {
|
if (command === 'read_xhs_minitool_export') {
|
||||||
return { form: FORM, hasScript: true, contentHash: CONTENT_HASH };
|
return { form: FORM, hasScript: true };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
outputTail: `zip 已生成:${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}(3 个条目,0.42 MiB)`,
|
outputTail: `zip 已生成:${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}(3 个条目,0.42 MiB)`,
|
||||||
|
omittedCharacters: 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
installTauriRuntime({ core: { invoke } });
|
installTauriRuntime({ core: { invoke } });
|
||||||
@@ -455,6 +493,97 @@ describe('状态 hook', () => {
|
|||||||
expect(result.current.running).toBe(false);
|
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 () => {
|
it('适配把指令当成一条用户消息交给同一个会话', async () => {
|
||||||
const submitTurn = vi.fn(async () => true);
|
const submitTurn = vi.fn(async () => true);
|
||||||
installTauriRuntime({
|
installTauriRuntime({
|
||||||
@@ -462,7 +591,6 @@ describe('状态 hook', () => {
|
|||||||
invoke: vi.fn(async () => ({
|
invoke: vi.fn(async () => ({
|
||||||
form: FORM,
|
form: FORM,
|
||||||
hasScript: false,
|
hasScript: false,
|
||||||
contentHash: CONTENT_HASH,
|
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user