小红书导出面板的前端契约测试

- 新增 apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx:十例覆盖指令正文(三条硬契约、示例脚本落点、修复现场)、失败变体到人话与动作的映射、字段复制、icon 与 zip 的下载链路、三颗按钮的职责、冲突逐字段二选一、失败卡片下一步,以及状态 hook 的自动保存(baseHash 带回宿主)与「适配把指令当一条用户消息入队」。
This commit is contained in:
2026-10-05 16:02:44 +08:00
parent 9a8d77dd9a
commit f8517abaaa
@@ -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(
<ArtifactsPane minitool={minitool} projectPath={PROJECT_PATH} />,
);
}
function fakeMinitool(
overrides: Partial<XhsMinitoolExportState> = {},
): 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,
};
}