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); }); } describe('ProjectPublishAction', () => { beforeEach(() => { vi.useFakeTimers(); publishWorksProjectSourceMock.mockReset(); fetchCurrentWorksProjectStatusMock.mockReset(); publishWorksProjectSourceMock.mockResolvedValue({ package: {}, upload }); }); afterEach(() => { vi.useRealTimers(); }); it('submits automatic metadata and locks after Builder succeeds', async () => { fetchCurrentWorksProjectStatusMock.mockResolvedValue(projectStatus('succeeded')); render(); fireEvent.click(screen.getByRole('button', { name: '一键提交审核' })); expect(screen.getByRole('button', { name: '正在检查并提交…' })).toBeDisabled(); await flushSubmission(); expect(publishWorksProjectSourceMock).toHaveBeenCalledWith({ projectId: 'prj_space_cleaner', project: { app_id: 'makelore-prj-space-cleaner', title: 'space-cleaner', summary: 'space-cleaner,由 Makelore 创建的作品。', cover_url: null, category: 'mini_game', age_band: null, difficulty: null, }, }); 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(); fireEvent.click(screen.getByRole('button', { name: '一键提交审核' })); await flushSubmission(); await advancePoll(); const failure = screen.getByTestId('project-publish-failure'); expect(failure).toHaveTextContent('自动打开作品时发现问题'); expect(failure).toHaveTextContent('下一步:请让开发助手运行 npm run build'); 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(); fireEvent.click(screen.getByRole('button', { name: '一键提交审核' })); 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(); fireEvent.click(screen.getByRole('button', { name: '一键提交审核' })); 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(); fireEvent.click(screen.getByRole('button', { name: '一键提交审核' })); 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(); fireEvent.click(screen.getByRole('button', { name: '一键提交审核' })); 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(); fireEvent.click(screen.getByRole('button', { name: '一键提交审核' })); 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(); fireEvent.click(screen.getByRole('button', { name: '一键提交审核' })); 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(`${input.project.title},由 Makelore 创建的作品。`); expect(input.project.category).toBe('mini_program'); }); });