小红书导出面板的前端契约测试
- 新增 apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx:十例覆盖指令正文(三条硬契约、示例脚本落点、修复现场)、失败变体到人话与动作的映射、字段复制、icon 与 zip 的下载链路、三颗按钮的职责、冲突逐字段二选一、失败卡片下一步,以及状态 hook 的自动保存(baseHash 带回宿主)与「适配把指令当一条用户消息入队」。
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user