268 lines
10 KiB
TypeScript
268 lines
10 KiB
TypeScript
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { ProjectPublishAction } from '@/components/works/ProjectPublishAction';
|
|
|
|
const publishWorksProjectSourceMock = vi.hoisted(() => vi.fn());
|
|
const fetchCurrentWorksProjectStatusMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock('@/lib/works-square', () => ({
|
|
publishWorksProjectSource: (...args: unknown[]) => publishWorksProjectSourceMock(...args),
|
|
fetchCurrentWorksProjectStatus: (...args: unknown[]) => fetchCurrentWorksProjectStatusMock(...args),
|
|
}));
|
|
|
|
const project = {
|
|
id: 'prj_space_cleaner',
|
|
path: 'D:/repo/space-cleaner',
|
|
name: 'space-cleaner',
|
|
createdAt: '',
|
|
updatedAt: '',
|
|
lastOpenedAt: '',
|
|
};
|
|
|
|
const upload = {
|
|
version_id: 'version-1',
|
|
review_status: 'building',
|
|
build_job_id: 'job-1',
|
|
build_status: 'queued',
|
|
};
|
|
|
|
function projectStatus(buildStatus: string, buildErrorCode: string | null = null) {
|
|
return {
|
|
project: {
|
|
app_id: 'space-cleaner',
|
|
title: 'space-cleaner',
|
|
summary: 'A project',
|
|
},
|
|
latest_version: {
|
|
id: 'version-1',
|
|
version_name: 'v1.0.0',
|
|
review_status: buildStatus === 'succeeded' ? 'pending_review' : 'build_failed',
|
|
change_log: '首次提交',
|
|
build_job_id: 'job-1',
|
|
build_status: buildStatus,
|
|
build_error_code: buildErrorCode,
|
|
build_error_message: 'Traceback: /srv/private/builder.py token=secret',
|
|
release_id: buildStatus === 'succeeded' ? 'release-1' : null,
|
|
created_at: '2026-08-07T12:00:00Z',
|
|
},
|
|
versions: [],
|
|
};
|
|
}
|
|
|
|
async function flushSubmission() {
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
}
|
|
|
|
async function advancePoll() {
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(2_000);
|
|
});
|
|
}
|
|
|
|
async function submitProjectMetadata() {
|
|
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
|
|
fireEvent.change(screen.getByLabelText(/发布者姓名/), { target: { value: '小明' } });
|
|
fireEvent.change(screen.getByLabelText(/发布者年龄/), { target: { value: '12' } });
|
|
fireEvent.change(screen.getByLabelText(/项目简介/), { target: { value: '这是一个太空清洁小游戏。' } });
|
|
const cover = new File([new Uint8Array([1, 2, 3])], 'cover.png', { type: 'image/png' });
|
|
fireEvent.change(screen.getByTestId('project-publish-cover'), { target: { files: [cover] } });
|
|
fireEvent.click(screen.getByRole('button', { name: '提交审核' }));
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
}
|
|
|
|
describe('ProjectPublishAction', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
publishWorksProjectSourceMock.mockReset();
|
|
fetchCurrentWorksProjectStatusMock.mockReset();
|
|
publishWorksProjectSourceMock.mockResolvedValue({ package: {}, upload });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('submits user-entered metadata and cover, then locks after Builder succeeds', async () => {
|
|
fetchCurrentWorksProjectStatusMock.mockResolvedValue(projectStatus('succeeded'));
|
|
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
|
|
|
await submitProjectMetadata();
|
|
expect(screen.getByRole('button', { name: '正在等待云端检查…' })).toBeDisabled();
|
|
await flushSubmission();
|
|
|
|
expect(screen.getByTestId('project-publish-status')).toHaveTextContent(
|
|
'本次构建结果已通过本地预览检查并上传,正在等待平台校验。',
|
|
);
|
|
|
|
expect(publishWorksProjectSourceMock).toHaveBeenCalledWith({
|
|
projectId: 'prj_space_cleaner',
|
|
project: {
|
|
app_id: 'makelore-prj-space-cleaner',
|
|
title: 'space-cleaner',
|
|
summary: '这是一个太空清洁小游戏。',
|
|
description: null,
|
|
cover_url: null,
|
|
creator_name: '小明',
|
|
creator_age: 12,
|
|
category: 'mini_game',
|
|
age_band: null,
|
|
difficulty: null,
|
|
},
|
|
cover: expect.objectContaining({
|
|
fileName: 'cover.png',
|
|
mimeType: 'image/png',
|
|
dataBase64: 'AQID',
|
|
}),
|
|
});
|
|
expect(fetchCurrentWorksProjectStatusMock).not.toHaveBeenCalled();
|
|
|
|
await advancePoll();
|
|
|
|
expect(fetchCurrentWorksProjectStatusMock).toHaveBeenCalledWith(
|
|
'makelore-prj-space-cleaner',
|
|
);
|
|
expect(screen.getByRole('button', { name: '已提交,等待运营审核' })).toBeDisabled();
|
|
expect(screen.getByTestId('project-publish-status')).toHaveTextContent('已提交,等待运营审核');
|
|
});
|
|
|
|
it('shows a friendly Builder failure without raw details', async () => {
|
|
fetchCurrentWorksProjectStatusMock.mockResolvedValue(
|
|
projectStatus('failed', 'BROWSER_SMOKE_FAILED'),
|
|
);
|
|
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
|
|
|
await submitProjectMetadata();
|
|
await flushSubmission();
|
|
await advancePoll();
|
|
|
|
const failure = screen.getByTestId('project-publish-failure');
|
|
expect(failure).toHaveTextContent('旧版提交无法继续处理');
|
|
expect(failure).toHaveTextContent('下一步:升级 Makelore 并重新提交');
|
|
expect(failure).not.toHaveTextContent('BROWSER_SMOKE_FAILED');
|
|
expect(failure).not.toHaveTextContent('Traceback');
|
|
expect(failure).not.toHaveTextContent('/srv/private');
|
|
expect(screen.getByRole('button', { name: '重新提交审核' })).toBeEnabled();
|
|
});
|
|
|
|
it('keeps an uncertain submission locked when status polling is interrupted', async () => {
|
|
fetchCurrentWorksProjectStatusMock.mockRejectedValue(
|
|
new Error('socket closed at /srv/private/status.py'),
|
|
);
|
|
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
|
|
|
await submitProjectMetadata();
|
|
await flushSubmission();
|
|
await advancePoll();
|
|
|
|
const failure = screen.getByTestId('project-publish-failure');
|
|
expect(failure).toHaveTextContent('暂时无法读取构建进度');
|
|
expect(failure).toHaveTextContent('不需要重复提交');
|
|
expect(failure).not.toHaveTextContent('socket closed');
|
|
expect(screen.getByRole('button', { name: '已提交,请稍后查看' })).toBeDisabled();
|
|
});
|
|
|
|
it('shows a safe local binding warning while continuing cloud build polling', async () => {
|
|
publishWorksProjectSourceMock.mockResolvedValueOnce({
|
|
package: {},
|
|
upload,
|
|
bindingWarning: {
|
|
code: 'LOCAL_PREVIEW_BINDING_SAVE_FAILED',
|
|
message: '已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交。',
|
|
},
|
|
});
|
|
fetchCurrentWorksProjectStatusMock.mockResolvedValue(projectStatus('succeeded'));
|
|
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
|
|
|
await submitProjectMetadata();
|
|
await flushSubmission();
|
|
|
|
expect(screen.getByTestId('project-publish-binding-warning')).toHaveTextContent(
|
|
'已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交。',
|
|
);
|
|
expect(screen.getByRole('button', { name: '正在等待云端检查…' })).toBeDisabled();
|
|
|
|
await advancePoll();
|
|
|
|
expect(fetchCurrentWorksProjectStatusMock).toHaveBeenCalledOnce();
|
|
expect(screen.getByRole('button', { name: '已提交,等待运营审核' })).toBeDisabled();
|
|
expect(screen.getByTestId('project-publish-binding-warning')).toBeVisible();
|
|
});
|
|
|
|
it('shows a safe timeout instead of inviting a duplicate submission', async () => {
|
|
const status = projectStatus('queued');
|
|
status.latest_version.id = 'another-version';
|
|
fetchCurrentWorksProjectStatusMock.mockResolvedValue(status);
|
|
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
|
|
|
await submitProjectMetadata();
|
|
await flushSubmission();
|
|
await act(async () => {
|
|
await vi.runAllTimersAsync();
|
|
});
|
|
|
|
const failure = screen.getByTestId('project-publish-failure');
|
|
expect(failure).toHaveTextContent('构建时间比平时更长');
|
|
expect(failure).toHaveTextContent('不需要重复提交');
|
|
expect(screen.getByRole('button', { name: '已提交,请稍后查看' })).toBeDisabled();
|
|
expect(fetchCurrentWorksProjectStatusMock).toHaveBeenCalledTimes(300);
|
|
});
|
|
|
|
it('sanitizes local and HTTP failures before rendering', async () => {
|
|
publishWorksProjectSourceMock.mockRejectedValue(Object.assign(
|
|
new Error('Traceback: failed at /srv/private/package.ts token=secret'),
|
|
{ code: 'INTERNAL_DATABASE_FAILURE', statusCode: 503 },
|
|
));
|
|
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
|
|
|
await submitProjectMetadata();
|
|
await flushSubmission();
|
|
|
|
const failure = screen.getByTestId('project-publish-failure');
|
|
expect(failure).toHaveTextContent('发布服务暂时不可用');
|
|
expect(failure).not.toHaveTextContent('Traceback');
|
|
expect(failure).not.toHaveTextContent('INTERNAL_DATABASE_FAILURE');
|
|
expect(fetchCurrentWorksProjectStatusMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('turns a local package error into instructions for the user', async () => {
|
|
publishWorksProjectSourceMock.mockRejectedValue(Object.assign(
|
|
new Error('D:/repo/space-cleaner/package-lock.json does not exist'),
|
|
{ code: 'PROJECT_FILE_MISSING' },
|
|
));
|
|
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
|
|
|
await submitProjectMetadata();
|
|
await flushSubmission();
|
|
|
|
const failure = screen.getByTestId('project-publish-failure');
|
|
expect(failure).toHaveTextContent('项目文件不完整');
|
|
expect(failure).toHaveTextContent('请让开发助手修复当前项目模板');
|
|
expect(failure).not.toHaveTextContent('D:/repo');
|
|
expect(failure).not.toHaveTextContent('PROJECT_FILE_MISSING');
|
|
expect(fetchCurrentWorksProjectStatusMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps generated metadata within the server limits', async () => {
|
|
const longName = `作品-${'x'.repeat(250)}`;
|
|
render(<ProjectPublishAction project={{ ...project, name: longName }} projectType="mini_program" />);
|
|
|
|
await submitProjectMetadata();
|
|
await flushSubmission();
|
|
|
|
const input = publishWorksProjectSourceMock.mock.calls[0]?.[0];
|
|
expect(input.project.app_id).toBe('makelore-prj-space-cleaner');
|
|
expect(input.project.title).toHaveLength(160);
|
|
expect(input.project.summary).toBe('这是一个太空清洁小游戏。');
|
|
expect(input.project.creator_name).toBe('小明');
|
|
expect(input.project.creator_age).toBe(12);
|
|
expect(input.project.category).toBe('mini_program');
|
|
});
|
|
});
|