实现小游戏与小程序项目创建发布
This commit is contained in:
@@ -16,6 +16,12 @@ test.describe('Project-level Superpowers setting', () => {
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
await page.getByTestId('sidebar-create-project').click();
|
||||
await expect(page.getByRole('radio', { name: '小游戏' })).toBeChecked();
|
||||
await expect(page.getByRole('radio', { name: '小程序' })).not.toBeChecked();
|
||||
await expect(page.getByRole('radio', { name: '自定义项目' })).not.toBeChecked();
|
||||
await page.getByRole('radio', { name: '小程序' }).click();
|
||||
await expect(page.getByRole('radio', { name: '小程序' })).toBeChecked();
|
||||
await page.getByRole('radio', { name: '小游戏' }).click();
|
||||
await expect(page.getByRole('radio', { name: '直接使用所选文件夹(默认)' })).toBeChecked();
|
||||
await page.getByRole('button', { name: '选择路径' }).click();
|
||||
await expect(page.getByLabel('项目路径')).toHaveValue(parentPath);
|
||||
|
||||
@@ -515,6 +515,53 @@ describe('opencode host api routes', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['mini_game', ['game.json', 'game.js']],
|
||||
['mini_program', ['app.json', 'pages/index/index.js']],
|
||||
] as const)('creates a complete %s project template', async (projectType, expectedTypeFiles) => {
|
||||
const parentPath = await mkdtemp(join(tmpdir(), `niancode-create-${projectType}-`));
|
||||
try {
|
||||
const response = createResponse();
|
||||
const expectedProjectPath = join(parentPath, '新作品');
|
||||
const project = {
|
||||
id: `prj_${projectType}`,
|
||||
path: expectedProjectPath,
|
||||
name: '新作品',
|
||||
};
|
||||
const rememberProject = vi.fn(async () => project);
|
||||
|
||||
const handled = await handleOpencodeRoutes(
|
||||
createRequest('POST', { parentPath, projectName: '新作品', projectType }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/opencode/projects/create'),
|
||||
{
|
||||
opencodeProjectStore: {
|
||||
rememberProject,
|
||||
listProjects: vi.fn(async () => [project]),
|
||||
getActiveProject: vi.fn(async () => null),
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
config: { projectType },
|
||||
});
|
||||
const config = JSON.parse(await readFile(join(expectedProjectPath, '.niancode', 'project.json'), 'utf8')) as Record<string, unknown>;
|
||||
expect(config.projectType).toBe(projectType);
|
||||
await expect(stat(join(expectedProjectPath, 'package.json'))).resolves.toBeTruthy();
|
||||
await expect(stat(join(expectedProjectPath, 'package-lock.json'))).resolves.toBeTruthy();
|
||||
await expect(stat(join(expectedProjectPath, 'index.html'))).resolves.toBeTruthy();
|
||||
for (const relativePath of expectedTypeFiles) {
|
||||
await expect(stat(join(expectedProjectPath, relativePath))).resolves.toBeTruthy();
|
||||
}
|
||||
} finally {
|
||||
await rm(parentPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('initializes the selected non-empty folder in place without changing existing files', async () => {
|
||||
const projectPath = await mkdtemp(join(tmpdir(), 'niancode-direct-project-'));
|
||||
try {
|
||||
|
||||
@@ -3650,6 +3650,7 @@ describe('opencode store', () => {
|
||||
directoryMode: 'create-child-directory',
|
||||
selectedPath: '/Users/kid/NianCode',
|
||||
projectName: '星星收集器',
|
||||
projectType: 'mini_game',
|
||||
});
|
||||
|
||||
expect(opened).toEqual(project);
|
||||
@@ -3658,6 +3659,7 @@ describe('opencode store', () => {
|
||||
body: JSON.stringify({
|
||||
parentPath: '/Users/kid/NianCode',
|
||||
projectName: '星星收集器',
|
||||
projectType: 'mini_game',
|
||||
}),
|
||||
});
|
||||
expect(useOpencodeStore.getState().activeProject).toBeNull();
|
||||
@@ -3676,6 +3678,7 @@ describe('opencode store', () => {
|
||||
const opened = await useOpencodeStore.getState().createProject({
|
||||
directoryMode: 'use-selected-directory',
|
||||
selectedPath: '/Users/kid/NianCode/星星收集器',
|
||||
projectType: 'custom',
|
||||
});
|
||||
|
||||
expect(opened).toEqual(project);
|
||||
@@ -3683,6 +3686,7 @@ describe('opencode store', () => {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
projectPath: '/Users/kid/NianCode/星星收集器',
|
||||
projectType: 'custom',
|
||||
}),
|
||||
});
|
||||
expect(useOpencodeStore.getState().activeProject).toBeNull();
|
||||
|
||||
@@ -45,6 +45,7 @@ describe('project-owned contact configuration', () => {
|
||||
it('creates an empty, template-free project config', () => {
|
||||
const config = createProjectConfig('2026-07-11T00:00:00.000Z');
|
||||
expect(config).toMatchObject({
|
||||
projectType: 'custom',
|
||||
initialized: false,
|
||||
superpowersEnabled: false,
|
||||
agents: [],
|
||||
@@ -53,6 +54,19 @@ describe('project-owned contact configuration', () => {
|
||||
expect('templateId' in config).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes legacy configs without a project type as custom', () => {
|
||||
const legacyConfig = { ...createProjectConfig(), projectType: undefined };
|
||||
expect(normalizeProjectConfig(legacyConfig).projectType).toBe('custom');
|
||||
expect(normalizeProjectConfig({
|
||||
...legacyConfig,
|
||||
projectType: 'mini_game',
|
||||
}).projectType).toBe('mini_game');
|
||||
expect(() => normalizeProjectConfig({
|
||||
...legacyConfig,
|
||||
projectType: 'future_project',
|
||||
})).toThrow('Invalid project type');
|
||||
});
|
||||
|
||||
it('requires a unique name, preset avatar, model, and responsibility for every contact', () => {
|
||||
expect(validateAgentConfigs([])).toEqual([]);
|
||||
expect(validateAgentConfigs([createContact({ name: '' })])).toContain('agent-test:name-required');
|
||||
@@ -130,4 +144,18 @@ describe('project-owned contact configuration', () => {
|
||||
agents: [createContact({ model: null })],
|
||||
})).rejects.toThrow('model-required');
|
||||
});
|
||||
|
||||
it('does not allow the persisted project type to change', async () => {
|
||||
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-project-type-'));
|
||||
const initial = await createInitialProjectConfig(projectPath, { projectType: 'mini_game' });
|
||||
|
||||
await expect(writeProjectConfig(projectPath, {
|
||||
...initial,
|
||||
projectType: 'mini_program',
|
||||
})).rejects.toThrow('已有项目类型不可更改');
|
||||
await expect(readProjectConfig(projectPath)).resolves.toMatchObject({
|
||||
status: 'valid',
|
||||
config: { projectType: 'mini_game' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('ProjectConfiguration', () => {
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
const config = createProjectConfig();
|
||||
const config = createProjectConfig(undefined, 'mini_game');
|
||||
useOpencodeStore.setState({ activeProject: project, projects: [project] });
|
||||
useProjectConfigStore.setState({ configsByProjectId: { [project.id]: config }, knowledgeByProjectId: { [project.id]: [] }, errorsByProjectId: {}, loadingProjectId: null });
|
||||
useProviderStore.setState({
|
||||
@@ -85,6 +85,29 @@ describe('ProjectConfiguration', () => {
|
||||
expect(screen.queryAllByTestId(/^project-agent-/)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not offer one-click submission for custom projects', async () => {
|
||||
const customConfig = createProjectConfig();
|
||||
useProjectConfigStore.setState({
|
||||
configsByProjectId: { [project.id]: customConfig },
|
||||
knowledgeByProjectId: { [project.id]: [] },
|
||||
errorsByProjectId: {},
|
||||
loadingProjectId: null,
|
||||
});
|
||||
const fallback = hostApiFetchMock.getMockImplementation();
|
||||
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
|
||||
if (path === `/api/opencode/projects/config?projectId=${project.id}`) {
|
||||
return { status: 'valid', config: customConfig, knowledgeFiles: [] };
|
||||
}
|
||||
if (!fallback) throw new Error(`Unexpected path ${path}`);
|
||||
return fallback(path, init);
|
||||
});
|
||||
|
||||
render(<MemoryRouter><ProjectConfiguration /></MemoryRouter>);
|
||||
|
||||
expect(await screen.findByText('自定义项目暂未配置发布方式。如需一键提交,请新建小游戏或小程序项目。')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '一键提交审核' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates a contact with the required basics and leaves advanced settings empty', async () => {
|
||||
render(<MemoryRouter><ProjectConfiguration /></MemoryRouter>);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '新增伙伴' }));
|
||||
|
||||
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { initializeProjectDirectory } from '@electron/opencode/project-directory-initialization';
|
||||
import type { ProjectType } from '../../shared/project-config';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
@@ -13,6 +14,98 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe('project directory initialization', () => {
|
||||
it.each([
|
||||
['mini_game', ['game.json', 'game.js']],
|
||||
['mini_program', ['app.json', 'app.js', join('pages', 'index', 'index.js')]],
|
||||
] satisfies Array<[ProjectType, string[]]>)('creates a complete %s Vite project template', async (projectType, typeFiles) => {
|
||||
const parentPath = await mkdtemp(join(tmpdir(), 'makelore-project-parent-'));
|
||||
temporaryDirectories.push(parentPath);
|
||||
const projectPath = join(parentPath, projectType);
|
||||
|
||||
const result = await initializeProjectDirectory({
|
||||
projectPath,
|
||||
projectType,
|
||||
allowExistingDirectory: false,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
reusedExistingConfig: false,
|
||||
config: { projectType },
|
||||
});
|
||||
const packageJson = JSON.parse(await readFile(join(projectPath, 'package.json'), 'utf8')) as {
|
||||
scripts: Record<string, string>;
|
||||
devDependencies: Record<string, string>;
|
||||
};
|
||||
const packageLock = JSON.parse(await readFile(join(projectPath, 'package-lock.json'), 'utf8')) as {
|
||||
lockfileVersion: number;
|
||||
packages: Record<string, unknown>;
|
||||
};
|
||||
expect(packageJson.scripts.build).toBe('vite build');
|
||||
expect(packageJson.devDependencies.vite).toBe('7.3.1');
|
||||
expect(packageLock.lockfileVersion).toBe(3);
|
||||
expect(packageLock.packages['node_modules/vite']).toBeTruthy();
|
||||
await expect(readFile(join(projectPath, 'index.html'), 'utf8')).resolves.toContain('type="module"');
|
||||
for (const relativePath of typeFiles) {
|
||||
await expect(readFile(join(projectPath, relativePath), 'utf8')).resolves.not.toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps custom projects on the existing minimal project structure', async () => {
|
||||
const parentPath = await mkdtemp(join(tmpdir(), 'makelore-custom-parent-'));
|
||||
temporaryDirectories.push(parentPath);
|
||||
const projectPath = join(parentPath, 'custom-project');
|
||||
|
||||
const result = await initializeProjectDirectory({
|
||||
projectPath,
|
||||
projectType: 'custom',
|
||||
allowExistingDirectory: false,
|
||||
});
|
||||
|
||||
expect(result.config.projectType).toBe('custom');
|
||||
expect((await readdir(projectPath)).sort()).toEqual(['.niancode', 'TASKS.md', 'VERSION.md', 'knowledge']);
|
||||
});
|
||||
|
||||
it('reuses an existing project only when an explicitly selected type matches', async () => {
|
||||
const parentPath = await mkdtemp(join(tmpdir(), 'makelore-existing-parent-'));
|
||||
temporaryDirectories.push(parentPath);
|
||||
const projectPath = join(parentPath, 'existing-project');
|
||||
await initializeProjectDirectory({
|
||||
projectPath,
|
||||
projectType: 'mini_game',
|
||||
allowExistingDirectory: false,
|
||||
});
|
||||
|
||||
await expect(initializeProjectDirectory({
|
||||
projectPath,
|
||||
projectType: 'mini_game',
|
||||
allowExistingDirectory: true,
|
||||
})).resolves.toMatchObject({ reusedExistingConfig: true, config: { projectType: 'mini_game' } });
|
||||
await expect(initializeProjectDirectory({
|
||||
projectPath,
|
||||
projectType: 'mini_program',
|
||||
allowExistingDirectory: true,
|
||||
})).rejects.toThrow('已有项目类型不可更改');
|
||||
await expect(initializeProjectDirectory({
|
||||
projectPath,
|
||||
allowExistingDirectory: true,
|
||||
})).resolves.toMatchObject({ reusedExistingConfig: true, config: { projectType: 'mini_game' } });
|
||||
});
|
||||
|
||||
it('does not change an existing folder when a publishable template file conflicts', async () => {
|
||||
const projectPath = await mkdtemp(join(tmpdir(), 'makelore-template-conflict-'));
|
||||
temporaryDirectories.push(projectPath);
|
||||
await writeFile(join(projectPath, 'package.json'), '{"name":"user-project"}\n', 'utf8');
|
||||
|
||||
await expect(initializeProjectDirectory({
|
||||
projectPath,
|
||||
projectType: 'mini_game',
|
||||
allowExistingDirectory: true,
|
||||
})).rejects.toThrow('Project initialization conflict: package.json already exists');
|
||||
|
||||
expect((await readdir(projectPath)).sort()).toEqual(['package.json']);
|
||||
await expect(readFile(join(projectPath, 'package.json'), 'utf8')).resolves.toBe('{"name":"user-project"}\n');
|
||||
});
|
||||
|
||||
it('rolls back only files and directories created by a failed in-place merge', async () => {
|
||||
const projectPath = await mkdtemp(join(tmpdir(), 'niancode-in-place-rollback-'));
|
||||
temporaryDirectories.push(projectPath);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
createStaticProjectPackage,
|
||||
ProjectPackageError,
|
||||
} from '@electron/services/project-packager';
|
||||
import { createProjectConfig, type ProjectType } from '../../shared/project-config';
|
||||
|
||||
const openRaceHook = vi.hoisted(() => ({
|
||||
beforeOpen: null as null | ((path: string) => Promise<void>),
|
||||
@@ -39,11 +40,17 @@ const AdmZip = require('adm-zip') as typeof import('adm-zip');
|
||||
|
||||
const tempDirectories: string[] = [];
|
||||
|
||||
async function createProject(): Promise<string> {
|
||||
async function createProject(projectType: ProjectType = 'mini_game'): Promise<string> {
|
||||
const projectPath = await mkdtemp(join(tmpdir(), 'makelore-project-packager-'));
|
||||
tempDirectories.push(projectPath);
|
||||
await mkdir(join(projectPath, 'src'), { recursive: true });
|
||||
await mkdir(join(projectPath, 'public'), { recursive: true });
|
||||
await mkdir(join(projectPath, '.niancode'), { recursive: true });
|
||||
await writeFile(
|
||||
join(projectPath, '.niancode', 'project.json'),
|
||||
JSON.stringify(createProjectConfig('2026-08-09T00:00:00.000Z', projectType)),
|
||||
'utf8',
|
||||
);
|
||||
await writeFile(
|
||||
join(projectPath, 'package.json'),
|
||||
JSON.stringify({
|
||||
@@ -83,7 +90,6 @@ describe('project packager', () => {
|
||||
await mkdir(join(projectPath, 'node_modules', 'vite'), { recursive: true });
|
||||
await mkdir(join(projectPath, '.git'), { recursive: true });
|
||||
await mkdir(join(projectPath, 'dist'), { recursive: true });
|
||||
await mkdir(join(projectPath, '.niancode'), { recursive: true });
|
||||
for (const directory of ['.ssh', '.aws', '.gnupg', '.kube', '.docker', 'secrets']) {
|
||||
await mkdir(join(projectPath, directory), { recursive: true });
|
||||
await writeFile(join(projectPath, directory, 'credential'), 'private', 'utf8');
|
||||
@@ -91,7 +97,7 @@ describe('project packager', () => {
|
||||
await writeFile(join(projectPath, 'node_modules', 'vite', 'index.js'), 'generated', 'utf8');
|
||||
await writeFile(join(projectPath, '.git', 'config'), 'private', 'utf8');
|
||||
await writeFile(join(projectPath, 'dist', 'index.html'), 'built', 'utf8');
|
||||
await writeFile(join(projectPath, '.niancode', 'project.json'), '{}', 'utf8');
|
||||
await writeFile(join(projectPath, '.niancode', 'local-state.json'), '{}', 'utf8');
|
||||
await writeFile(join(projectPath, '.env'), 'TOKEN=secret', 'utf8');
|
||||
await writeFile(join(projectPath, '.env.example'), 'TOKEN=replace-me', 'utf8');
|
||||
await writeFile(join(projectPath, '.npmrc'), '//registry/:_authToken=secret', 'utf8');
|
||||
@@ -170,6 +176,7 @@ describe('project packager', () => {
|
||||
]);
|
||||
expect(archive.readAsText('niancode.yml')).toBe(
|
||||
'schema_version: 1\n'
|
||||
+ 'project_type: mini_game\n'
|
||||
+ 'kind: web\n'
|
||||
+ 'runtime: static\n'
|
||||
+ 'build:\n'
|
||||
@@ -177,10 +184,44 @@ describe('project packager', () => {
|
||||
+ ' package_manager: npm\n'
|
||||
+ ' entry: index.html\n',
|
||||
);
|
||||
expect(first.manifest.project_type).toBe('mini_game');
|
||||
expect(entries.every((entry) => entry.header.time.getFullYear() === 1980)).toBe(true);
|
||||
await expect(readFile(join(projectPath, 'niancode.yml'), 'utf8')).resolves.toBe('runtime: compose\n');
|
||||
});
|
||||
|
||||
it('writes the selected publishable project type into the generated manifest', async () => {
|
||||
const projectPath = await createProject('mini_program');
|
||||
const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-'));
|
||||
tempDirectories.push(outputDirectory);
|
||||
const archivePath = join(outputDirectory, 'project.zip');
|
||||
|
||||
const summary = await createStaticProjectPackage({ projectPath, archivePath });
|
||||
|
||||
expect(summary.manifest.project_type).toBe('mini_program');
|
||||
expect(new AdmZip(archivePath).readAsText('niancode.yml')).toContain('project_type: mini_program\n');
|
||||
});
|
||||
|
||||
it('rejects custom projects before validating Vite source files', async () => {
|
||||
const projectPath = await mkdtemp(join(tmpdir(), 'makelore-custom-packager-'));
|
||||
tempDirectories.push(projectPath);
|
||||
await mkdir(join(projectPath, '.niancode'), { recursive: true });
|
||||
await writeFile(
|
||||
join(projectPath, '.niancode', 'project.json'),
|
||||
JSON.stringify(createProjectConfig('2026-08-09T00:00:00.000Z', 'custom')),
|
||||
'utf8',
|
||||
);
|
||||
const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-'));
|
||||
tempDirectories.push(outputDirectory);
|
||||
|
||||
await expect(createStaticProjectPackage({
|
||||
projectPath,
|
||||
archivePath: join(outputDirectory, 'project.zip'),
|
||||
})).rejects.toMatchObject<ProjectPackageError>({
|
||||
code: 'PROJECT_TYPE_UNPUBLISHABLE',
|
||||
message: '自定义项目暂未配置发布方式,请新建小游戏或小程序项目',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a project that is not a locked npm Vite project', async () => {
|
||||
const projectPath = await createProject();
|
||||
await writeFile(
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('ProjectPublishAction', () => {
|
||||
|
||||
it('submits automatic metadata and locks after Builder succeeds', async () => {
|
||||
fetchCurrentWorksProjectStatusMock.mockResolvedValue(projectStatus('succeeded'));
|
||||
render(<ProjectPublishAction project={project} />);
|
||||
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
|
||||
expect(screen.getByRole('button', { name: '正在检查并提交…' })).toBeDisabled();
|
||||
@@ -89,7 +89,7 @@ describe('ProjectPublishAction', () => {
|
||||
title: 'space-cleaner',
|
||||
summary: 'space-cleaner,由 Makelore 创建的作品。',
|
||||
cover_url: null,
|
||||
category: 'web',
|
||||
category: 'mini_game',
|
||||
age_band: null,
|
||||
difficulty: null,
|
||||
},
|
||||
@@ -109,7 +109,7 @@ describe('ProjectPublishAction', () => {
|
||||
fetchCurrentWorksProjectStatusMock.mockResolvedValue(
|
||||
projectStatus('failed', 'BROWSER_SMOKE_FAILED'),
|
||||
);
|
||||
render(<ProjectPublishAction project={project} />);
|
||||
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
|
||||
await flushSubmission();
|
||||
@@ -128,7 +128,7 @@ describe('ProjectPublishAction', () => {
|
||||
fetchCurrentWorksProjectStatusMock.mockRejectedValue(
|
||||
new Error('socket closed at /srv/private/status.py'),
|
||||
);
|
||||
render(<ProjectPublishAction project={project} />);
|
||||
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
|
||||
await flushSubmission();
|
||||
@@ -145,7 +145,7 @@ describe('ProjectPublishAction', () => {
|
||||
const status = projectStatus('queued');
|
||||
status.latest_version.id = 'another-version';
|
||||
fetchCurrentWorksProjectStatusMock.mockResolvedValue(status);
|
||||
render(<ProjectPublishAction project={project} />);
|
||||
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
|
||||
await flushSubmission();
|
||||
@@ -165,7 +165,7 @@ describe('ProjectPublishAction', () => {
|
||||
new Error('Traceback: failed at /srv/private/package.ts token=secret'),
|
||||
{ code: 'INTERNAL_DATABASE_FAILURE', statusCode: 503 },
|
||||
));
|
||||
render(<ProjectPublishAction project={project} />);
|
||||
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
|
||||
await flushSubmission();
|
||||
@@ -182,14 +182,14 @@ describe('ProjectPublishAction', () => {
|
||||
new Error('D:/repo/space-cleaner/package-lock.json does not exist'),
|
||||
{ code: 'PROJECT_FILE_MISSING' },
|
||||
));
|
||||
render(<ProjectPublishAction project={project} />);
|
||||
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
|
||||
await flushSubmission();
|
||||
|
||||
const failure = screen.getByTestId('project-publish-failure');
|
||||
expect(failure).toHaveTextContent('项目文件不完整');
|
||||
expect(failure).toHaveTextContent('请让开发助手补齐 package.json、package-lock.json 和 index.html');
|
||||
expect(failure).toHaveTextContent('请让开发助手修复当前项目模板');
|
||||
expect(failure).not.toHaveTextContent('D:/repo');
|
||||
expect(failure).not.toHaveTextContent('PROJECT_FILE_MISSING');
|
||||
expect(fetchCurrentWorksProjectStatusMock).not.toHaveBeenCalled();
|
||||
@@ -197,7 +197,7 @@ describe('ProjectPublishAction', () => {
|
||||
|
||||
it('keeps generated metadata within the server limits', async () => {
|
||||
const longName = `作品-${'x'.repeat(250)}`;
|
||||
render(<ProjectPublishAction project={{ ...project, name: longName }} />);
|
||||
render(<ProjectPublishAction project={{ ...project, name: longName }} projectType="mini_program" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
|
||||
await flushSubmission();
|
||||
@@ -206,5 +206,6 @@ describe('ProjectPublishAction', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -232,9 +232,9 @@ describe('Sidebar project initialization flow', () => {
|
||||
expect(update).toHaveAccessibleName('下载新版本 0.9.2');
|
||||
});
|
||||
|
||||
it('creates a path-backed project without selecting a template and opens configuration', async () => {
|
||||
it('defaults to a publishable mini-game project and opens configuration', async () => {
|
||||
const project = { id: 'prj_game', path: 'D:/repo/星星收集器', name: '星星收集器', createdAt: '2026-07-11T00:00:00.000Z', updatedAt: '2026-07-11T00:00:00.000Z', lastOpenedAt: '2026-07-11T00:00:00.000Z' };
|
||||
const config = createProjectConfig();
|
||||
const config = createProjectConfig(undefined, 'mini_game');
|
||||
invokeIpcMock.mockResolvedValue({ canceled: false, filePaths: ['D:/repo/星星收集器'] });
|
||||
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
|
||||
if (path === '/api/opencode/projects') return { projects: [], activeProject: null };
|
||||
@@ -245,19 +245,22 @@ describe('Sidebar project initialization flow', () => {
|
||||
});
|
||||
render(<MemoryRouter><Sidebar /></MemoryRouter>);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '新建项目' }));
|
||||
expect(screen.getByRole('radio', { name: '小游戏' })).toBeChecked();
|
||||
expect(screen.getByRole('radio', { name: '小程序' })).not.toBeChecked();
|
||||
expect(screen.getByRole('radio', { name: '自定义项目' })).not.toBeChecked();
|
||||
expect(screen.getByRole('radio', { name: '直接使用所选文件夹(默认)' })).toBeChecked();
|
||||
expect(screen.queryByLabelText('项目名称')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择路径' }));
|
||||
await waitFor(() => expect(screen.getByLabelText('项目路径')).toHaveValue('D:/repo/星星收集器'));
|
||||
expect(screen.getByText('将直接接入此文件夹,项目名称使用“星星收集器”,不会重命名目录。')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认创建' }));
|
||||
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/projects/create', { method: 'POST', body: JSON.stringify({ projectPath: 'D:/repo/星星收集器' }) }));
|
||||
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/projects/create', { method: 'POST', body: JSON.stringify({ projectPath: 'D:/repo/星星收集器', projectType: 'mini_game' }) }));
|
||||
await waitFor(() => expect(navigateMock).toHaveBeenCalledWith('/project-config'));
|
||||
});
|
||||
|
||||
it('keeps creating a named child folder when that directory mode is selected', async () => {
|
||||
const project = { id: 'prj_child', path: 'D:/repo/星星收集器', name: '星星收集器', createdAt: '', updatedAt: '', lastOpenedAt: '' };
|
||||
const config = createProjectConfig();
|
||||
const config = createProjectConfig(undefined, 'mini_program');
|
||||
invokeIpcMock.mockResolvedValue({ canceled: false, filePaths: ['D:/repo'] });
|
||||
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
|
||||
if (path === '/api/opencode/projects') return { projects: [], activeProject: null };
|
||||
@@ -269,6 +272,7 @@ describe('Sidebar project initialization flow', () => {
|
||||
|
||||
render(<MemoryRouter><Sidebar /></MemoryRouter>);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '新建项目' }));
|
||||
fireEvent.click(screen.getByRole('radio', { name: '小程序' }));
|
||||
fireEvent.click(screen.getByRole('radio', { name: '在所选位置新建下级文件夹' }));
|
||||
fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '星星收集器' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择路径' }));
|
||||
@@ -280,6 +284,7 @@ describe('Sidebar project initialization flow', () => {
|
||||
body: JSON.stringify({
|
||||
parentPath: 'D:/repo',
|
||||
projectName: '星星收集器',
|
||||
projectType: 'mini_program',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
|
||||
describe('works project publish guidance', () => {
|
||||
it.each([
|
||||
['PROJECT_FILE_MISSING', '项目文件不完整', 'package-lock.json'],
|
||||
['PROJECT_FILE_MISSING', '项目文件不完整', '修复当前项目模板'],
|
||||
['PROJECT_TYPE_UNPUBLISHABLE', '这个项目没有配置发布方式', '新建小游戏或小程序项目'],
|
||||
['DEPENDENCY_PREFETCH_FAILED', '暂时无法下载项目依赖', 'package-lock.json'],
|
||||
['OUTPUT_MISSING', '没有生成可运行页面', 'index.html'],
|
||||
['RELEASE_STORE_FAILED', '平台暂时无法保存发布文件', '无需修改项目'],
|
||||
|
||||
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { handleWorksRoutes } from '@electron/api/routes/works';
|
||||
import { createProjectConfig } from '../../shared/project-config';
|
||||
|
||||
const readWorksPublishFileMock = vi.hoisted(() => vi.fn());
|
||||
const readWorksDeployCheckMock = vi.hoisted(() => vi.fn());
|
||||
@@ -57,6 +58,12 @@ function createRequest(method: string, body?: unknown, headers: Record<string, s
|
||||
|
||||
async function writePublishableProject(projectPath: string): Promise<void> {
|
||||
await mkdir(join(projectPath, 'src'), { recursive: true });
|
||||
await mkdir(join(projectPath, '.niancode'), { recursive: true });
|
||||
await writeFile(
|
||||
join(projectPath, '.niancode', 'project.json'),
|
||||
JSON.stringify(createProjectConfig('2026-08-09T00:00:00.000Z', 'mini_game')),
|
||||
'utf8',
|
||||
);
|
||||
await writeFile(join(projectPath, 'package.json'), JSON.stringify({
|
||||
name: 'space-cleaner',
|
||||
private: true,
|
||||
|
||||
Reference in New Issue
Block a user