diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx
new file mode 100644
index 000000000..910a8b4ea
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx
@@ -0,0 +1,338 @@
+// @vitest-environment jsdom
+/**
+ * 小红书小工具导出:前端这一层的契约。
+ *
+ * 三个切面各钉一块:
+ * - 交给 code agent 的指令正文(纯字符串组装,含三条硬契约与要拷的示例脚本);
+ * - 左半面板的可见动作(复制、下载、适配 / 导出 / 刷新、冲突逐字段二选一、失败卡片的下一步);
+ * - 状态 hook 的自动保存与入队(宿主命令参数、`baseHash` 回传、以及"适配才叫 agent")。
+ */
+import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog';
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ renderHook,
+ screen,
+ waitFor,
+} from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type {
+ XhsMinitoolExportState,
+ XhsMinitoolFailureState,
+} from '../src/view/project-development/export/state/useXhsMinitoolExport';
+import { useXhsMinitoolExport } from '../src/view/project-development/export/state/useXhsMinitoolExport';
+import { describeXhsMinitoolFailure } from '../src/view/project-development/export/state/xhsMinitoolFailure';
+import {
+ buildXhsMinitoolAdaptationInstruction,
+ buildXhsMinitoolRepairInstruction,
+} from '../src/view/project-development/export/state/xhsMinitoolInstruction';
+import { ArtifactsPane } from '../src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane';
+import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake';
+
+vi.mock('@tauri-apps/plugin-dialog', () => ({ save: vi.fn() }));
+
+const PROJECT_PATH = '/tmp/export-panel-project';
+const CONTENT_HASH = 'hash-1';
+
+const FORM = {
+ name: '小工具',
+ introduction: '一句话简介',
+ iconPath: 'assets/icon.png',
+};
+
+beforeEach(() => {
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText: vi.fn(async () => undefined) },
+ });
+ vi.mocked(saveNativeFileDialog).mockResolvedValue('/tmp/icon.png');
+});
+
+afterEach(() => {
+ cleanup();
+ resetTauriRuntime();
+ vi.clearAllMocks();
+});
+
+describe('交给 code agent 的指令', () => {
+ it('适配指令写清三条硬契约与要显式拷贝的示例脚本', () => {
+ const instruction = buildXhsMinitoolAdaptationInstruction(PROJECT_PATH);
+
+ expect(instruction).toContain(PROJECT_PATH);
+ expect(instruction).toContain('build:xhs-minitool');
+ expect(instruction).toContain('.export/xhs-minitool.zip');
+ expect(instruction).toContain('.export/xhs-minitool.json');
+ expect(instruction).toContain('"iconPath":""');
+ expect(instruction).toContain('vite-export-xhs-minitool');
+ // 示例脚本必须被显式要求拷贝,只"参考 skill"会退化成 agent 自由发挥。
+ expect(instruction).toContain('pack.mjs → .export/xhs-minitool.pack.mjs');
+ expect(instruction).toContain(
+ 'validate.mjs → .export/xhs-minitool.validate.mjs',
+ );
+ expect(instruction).toContain(
+ 'vite.config.xhs-minitool.mjs → .export/xhs-minitool.vite.config.mjs',
+ );
+ // pack.mjs 会删 --out-dir 里的非白名单文件,这条坑必须写进指令正文。
+ expect(instruction).toContain('--out-dir');
+ });
+
+ it('修复指令带上宿主的失败现场', () => {
+ const instruction = buildXhsMinitoolRepairInstruction(PROJECT_PATH, {
+ type: 'artifactMissing',
+ path: '.export/xhs-minitool.zip',
+ });
+
+ expect(instruction).toContain(PROJECT_PATH);
+ expect(instruction).toContain('.export/xhs-minitool.zip 不在');
+ expect(instruction).toContain('build:xhs-minitool');
+ });
+
+ it('每个失败变体都有一句人话与下一步', () => {
+ expect(
+ describeXhsMinitoolFailure({
+ type: 'buildScriptMissing',
+ script: 'build:xhs-minitool',
+ searched: ['package.json', 'game/package.json'],
+ }),
+ ).toEqual({
+ message:
+ '项目里还没有 build:xhs-minitool 脚本——这就是「还没适配」的判据。',
+ detail: '宿主找过:package.json、game/package.json',
+ action: 'adapt',
+ });
+ expect(
+ describeXhsMinitoolFailure({
+ type: 'commandFailed',
+ exitCode: 1,
+ outputTail: 'vite build failed',
+ }),
+ ).toMatchObject({ action: 'adapt', detail: 'vite build failed' });
+ expect(
+ describeXhsMinitoolFailure({
+ type: 'exportUnavailable',
+ cause: '项目权限策略拒绝执行:command.exec',
+ }).action,
+ ).toBe('retry');
+ expect(
+ describeXhsMinitoolFailure({
+ type: 'formInvalid',
+ field: 'iconPath',
+ reason: 'icon 只支持 png、jpg、jpeg,当前是 .webp',
+ }).message,
+ ).toContain('icon 文件');
+ });
+});
+
+describe('面板左半', () => {
+ it('复制按钮把字段值写进剪贴板', async () => {
+ renderPane();
+
+ fireEvent.click(screen.getByRole('button', { name: '复制名称' }));
+
+ await waitFor(() =>
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith('小工具'),
+ );
+ });
+
+ it('icon 与 zip 的下载都走原生保存对话框与素材保存命令', async () => {
+ const invoke = vi.fn(async () => ({ destinationPath: '/tmp/saved' }));
+ installTauriRuntime({ core: { invoke } });
+ renderPane();
+
+ fireEvent.click(screen.getByRole('button', { name: '下载 icon 文件' }));
+ await waitFor(() => expect(invoke).toHaveBeenCalledTimes(1));
+ expect(saveNativeFileDialog).toHaveBeenCalledWith(
+ expect.objectContaining({ defaultPath: 'icon.png' }),
+ );
+ expect(invoke).toHaveBeenCalledWith('save_local_project_asset_file', {
+ input: {
+ projectPath: PROJECT_PATH,
+ relativePath: 'assets/icon.png',
+ destinationPath: '/tmp/icon.png',
+ },
+ });
+
+ fireEvent.click(screen.getByRole('button', { name: '下载代码 zip' }));
+ await waitFor(() => expect(invoke).toHaveBeenCalledTimes(2));
+ expect(invoke).toHaveBeenLastCalledWith('save_local_project_asset_file', {
+ input: {
+ projectPath: PROJECT_PATH,
+ relativePath: '.export/xhs-minitool.zip',
+ destinationPath: '/tmp/icon.png',
+ },
+ });
+ });
+
+ it('三颗按钮各管一件事,没有脚本时导出不可点', () => {
+ const minitool = fakeMinitool({ hasScript: false });
+ renderPane(minitool);
+
+ expect(screen.getByRole('button', { name: '导出' })).toHaveProperty(
+ 'disabled',
+ true,
+ );
+ fireEvent.click(screen.getByRole('button', { name: '适配' }));
+ expect(minitool.adapt).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(screen.getByRole('button', { name: '刷新' }));
+ expect(minitool.load).toHaveBeenCalledTimes(1);
+
+ cleanup();
+ const withScript = fakeMinitool();
+ renderPane(withScript);
+ fireEvent.click(screen.getByRole('button', { name: '导出' }));
+ expect(withScript.runExport).toHaveBeenCalledTimes(1);
+ });
+
+ it('冲突面板逐字段二选一,按选择保存的合并结果交回上层', () => {
+ const conflict: XhsMinitoolExportState['conflict'] = {
+ current: { ...FORM, introduction: 'agent 写的简介' },
+ contentHash: 'hash-2',
+ };
+ const minitool = fakeMinitool({ conflict });
+ renderPane(minitool);
+
+ // 只有真的不一样的那个字段出现在冲突面板里。
+ expect(screen.queryByRole('radio', { name: '名称:文件里的' })).toBeNull();
+ fireEvent.click(screen.getByRole('radio', { name: '简介:文件里的' }));
+ fireEvent.click(screen.getByRole('button', { name: '按选择保存' }));
+
+ expect(minitool.resolveConflict).toHaveBeenCalledWith({
+ ...FORM,
+ introduction: 'agent 写的简介',
+ });
+ });
+
+ it('失败卡片按变体给出「让 code agent 修」或「重试」', () => {
+ const failure: XhsMinitoolFailureState = {
+ notice: {
+ message:
+ '项目里还没有 build:xhs-minitool 脚本——这就是「还没适配」的判据。',
+ detail: '宿主找过:package.json、game/package.json',
+ action: 'adapt',
+ },
+ error: {
+ type: 'buildScriptMissing',
+ script: 'build:xhs-minitool',
+ searched: ['package.json', 'game/package.json'],
+ },
+ };
+ const minitool = fakeMinitool({ hasScript: false, failure });
+ renderPane(minitool);
+
+ expect(
+ screen.getByText('宿主找过:package.json、game/package.json'),
+ ).not.toBeNull();
+ fireEvent.click(screen.getByRole('button', { name: '让 code agent 修' }));
+ expect(minitool.repair).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('状态 hook', () => {
+ it('读到现场后,改字段停手会自动保存并把 baseHash 带回宿主', 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, contentHash: 'hash-2' };
+ });
+ installTauriRuntime({ core: { invoke } });
+
+ const { result } = renderHook(() =>
+ useXhsMinitoolExport({
+ enabled: true,
+ projectPath: PROJECT_PATH,
+ submitTurn: vi.fn(async () => true),
+ }),
+ );
+ expect(invoke).toHaveBeenCalledWith('read_xhs_minitool_export', {
+ projectPath: PROJECT_PATH,
+ });
+ // 先等现场读回来:读到之前 `form` 还是 null,改字段无处可改。
+ await waitFor(() => expect(result.current.form).toEqual(FORM));
+
+ await act(async () => {
+ result.current.updateField('name', '新名字');
+ });
+
+ await waitFor(
+ () =>
+ expect(invoke).toHaveBeenCalledWith('save_xhs_minitool_export_form', {
+ projectPath: PROJECT_PATH,
+ form: { ...FORM, name: '新名字' },
+ baseHash: CONTENT_HASH,
+ }),
+ { timeout: 3000 },
+ );
+ });
+
+ it('适配把指令当成一条用户消息交给同一个会话', async () => {
+ const submitTurn = vi.fn(async () => true);
+ installTauriRuntime({
+ core: {
+ invoke: vi.fn(async () => ({
+ form: FORM,
+ hasScript: false,
+ contentHash: CONTENT_HASH,
+ })),
+ },
+ });
+ const { result } = renderHook(() =>
+ useXhsMinitoolExport({
+ enabled: true,
+ projectPath: PROJECT_PATH,
+ submitTurn,
+ }),
+ );
+
+ await act(async () => {
+ result.current.adapt();
+ });
+
+ expect(submitTurn).toHaveBeenCalledTimes(1);
+ const [content] = submitTurn.mock.calls[0] as unknown as [
+ Array<{ type: string; text: string }>,
+ ];
+ expect(content).toHaveLength(1);
+ expect(content[0]?.type).toBe('input_text');
+ expect(content[0]?.text).toContain('build:xhs-minitool');
+ });
+});
+
+function renderPane(minitool: XhsMinitoolExportState = fakeMinitool()) {
+ return render(
+ ,
+ );
+}
+
+function fakeMinitool(
+ overrides: Partial = {},
+): XhsMinitoolExportState {
+ return {
+ form: FORM,
+ hasScript: true,
+ contentHash: CONTENT_HASH,
+ fieldError: null,
+ conflict: null,
+ failure: null,
+ notice: '',
+ loading: false,
+ saving: false,
+ running: false,
+ dirty: false,
+ updateField: vi.fn(),
+ load: vi.fn(async () => undefined),
+ resolveConflict: vi.fn(),
+ runExport: vi.fn(async () => undefined),
+ adapt: vi.fn(),
+ repair: vi.fn(),
+ ...overrides,
+ };
+}