feat: simplify code project creation

This commit is contained in:
inman
2026-09-07 10:06:37 +08:00
parent f8eee430f4
commit af13aca003
15 changed files with 351 additions and 486 deletions

View File

@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
const BOUND_PROJECT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const CANONICAL_PROJECT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
async function readProjectConfig(projectPath: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(path.join(projectPath, '.makelore', 'project.json'), 'utf8')) as Record<string, unknown>;
@@ -25,90 +25,50 @@ async function selectProgrammingProjectFolder(
}
test.describe('Coding project identity UX', () => {
test('supports default create, bind validation, cancellation, legacy resolution, and independent copies', async ({ launchElectronApp }) => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'niancode-identity-e2e-'));
test('keeps the service identity automatic and invisible to young users', async ({ launchElectronApp }) => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-identity-e2e-'));
const app = await launchElectronApp({ skipSetup: true });
try {
const page = await selectProgrammingProjectFolder(app, projectPath);
await page.getByTestId('sidebar-create-project').click();
const createDialog = page.getByRole('dialog', { name: '新建项目' });
await expect(createDialog.getByRole('radio', { name: '创建新的项目 ID' })).toBeChecked();
await createDialog.getByRole('radio', { name: '绑定已有项目 ID' }).click();
await createDialog.getByRole('textbox', { name: '已有项目 ID' }).fill('not-a-project-id');
await expect(createDialog.getByText(/项目 ID|项目身份|独立副本/u)).toHaveCount(0);
await expect(createDialog.getByRole('radio', { name: '创建新的项目 ID' })).toHaveCount(0);
await expect(createDialog.getByRole('radio', { name: '绑定已有项目 ID' })).toHaveCount(0);
await createDialog.getByRole('button', { name: '选择路径' }).click();
await expect(createDialog.getByLabel('项目路径')).toHaveValue(projectPath);
await createDialog.getByRole('button', { name: '确认创建' }).click();
await expect(createDialog).toContainText('请输入有效的项目 ID(小写 UUID)。');
await createDialog.getByRole('button', { name: '取消' }).first().click();
await expect(page.getByRole('dialog', { name: '新建项目' })).toHaveCount(0);
await page.getByTestId('sidebar-create-project').click();
const defaultCreateDialog = page.getByRole('dialog', { name: '新建项目' });
await expect(defaultCreateDialog.getByRole('radio', { name: '创建新的项目 ID' })).toBeChecked();
await defaultCreateDialog.getByRole('button', { name: '选择路径' }).click();
await expect(defaultCreateDialog.getByLabel('项目路径')).toHaveValue(projectPath);
await defaultCreateDialog.getByRole('button', { name: '确认创建' }).click();
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
await expect(page.getByTestId('coding-chat-empty-agent')).toBeVisible();
const createdConfig = await readProjectConfig(projectPath);
const generatedProjectId = createdConfig.projectId;
expect(generatedProjectId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u);
await expect(page.getByTestId('project-identity-value')).toHaveText(String(generatedProjectId));
await expect(page.getByText('同一账号绑定相同项目 ID 会共享开发数据;项目 ID 不是凭据。')).toBeVisible();
const generatedProjectId = String(createdConfig.projectId);
expect(generatedProjectId).toMatch(CANONICAL_PROJECT_ID);
await writeFile(path.join(projectPath, 'identity-sentinel.txt'), 'keep this file in place');
const independentButton = page.getByTestId('independent-copy-button');
await independentButton.click();
const independentDialog = page.getByRole('dialog', { name: '设为独立副本?' });
await expect(independentDialog).toContainText('不会移动或复制文件');
await expect(independentDialog).toContainText('不会复制云端开发数据');
await independentDialog.getByRole('button', { name: '取消' }).click();
await expect(independentDialog).toHaveCount(0);
expect((await readProjectConfig(projectPath)).projectId).toBe(generatedProjectId);
await independentButton.click();
await page.getByRole('dialog', { name: '设为独立副本?' }).getByRole('button', { name: '确认设为独立副本' }).click();
await expect(page.getByTestId('project-identity-value')).not.toHaveText(String(generatedProjectId));
const independentConfig = await readProjectConfig(projectPath);
expect(independentConfig.projectId).not.toBe(generatedProjectId);
expect(await readFile(path.join(projectPath, 'identity-sentinel.txt'), 'utf8')).toBe('keep this file in place');
await page.getByRole('button', { name: '创建项目智能体' }).click();
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
await expect(page.getByTestId('project-identity-card')).toHaveCount(0);
await expect(page.getByTestId('legacy-project-identity-card')).toHaveCount(0);
await expect(page.getByTestId('independent-copy-button')).toHaveCount(0);
await expect(page.getByText(/项目 ID|项目身份|独立副本/u)).toHaveCount(0);
const legacyConfig = await readProjectConfig(projectPath);
delete legacyConfig.projectId;
await writeFile(path.join(projectPath, '.makelore', 'project.json'), JSON.stringify(legacyConfig, null, 2));
await writeFile(
path.join(projectPath, '.makelore', 'project.json'),
JSON.stringify(legacyConfig, null, 2),
);
await page.reload();
await expect(page.getByTestId('legacy-project-identity-card')).toBeVisible();
const legacyCard = page.getByTestId('legacy-project-identity-card');
await legacyCard.getByRole('radio', { name: '绑定已有项目 ID' }).click();
await legacyCard.getByRole('textbox', { name: '已有项目 ID' }).fill(BOUND_PROJECT_ID);
await legacyCard.getByTestId('resolve-project-identity-button').click();
await expect(page.getByTestId('project-identity-value')).toHaveText(BOUND_PROJECT_ID);
await expect(page.getByTestId('legacy-project-identity-card')).toHaveCount(0);
} finally {
await closeElectronApp(app);
await rm(projectPath, { recursive: true, force: true });
}
});
test('creates a project bound to an explicit canonical project ID', async ({ launchElectronApp }) => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'niancode-bound-identity-e2e-'));
const app = await launchElectronApp({ skipSetup: true });
try {
const page = await selectProgrammingProjectFolder(app, projectPath);
await page.getByTestId('sidebar-create-project').click();
const createDialog = page.getByRole('dialog', { name: '新建项目' });
await createDialog.getByRole('radio', { name: '绑定已有项目 ID' }).click();
await expect(createDialog.getByText('同一账号绑定相同项目 ID 会共享开发数据;项目 ID 不是凭据。')).toBeVisible();
await createDialog.getByRole('textbox', { name: '已有项目 ID' }).fill(BOUND_PROJECT_ID);
await createDialog.getByRole('button', { name: '选择路径' }).click();
await createDialog.getByRole('button', { name: '确认创建' }).click();
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
await expect(page.getByTestId('project-identity-value')).toHaveText(BOUND_PROJECT_ID);
expect((await readProjectConfig(projectPath)).projectId).toBe(BOUND_PROJECT_ID);
const repairedConfig = await readProjectConfig(projectPath);
const repairedProjectId = String(repairedConfig.projectId);
expect(repairedProjectId).toMatch(CANONICAL_PROJECT_ID);
expect(repairedProjectId).not.toBe(generatedProjectId);
await expect(page.getByTestId('project-identity-card')).toHaveCount(0);
await expect(page.getByTestId('legacy-project-identity-card')).toHaveCount(0);
await expect(page.getByText(/项目 ID|项目身份|独立副本/u)).toHaveCount(0);
} finally {
await closeElectronApp(app);
await rm(projectPath, { recursive: true, force: true });

View File

@@ -20,8 +20,10 @@ test.describe('Project configuration skills', () => {
await page.getByTestId('ai-module-option-programming').click();
await expect(page.getByTestId('main-layout')).toBeVisible();
await page.getByTestId('sidebar-create-project').click();
await expect(page.getByRole('radio', { name: '交互式 AI 应用' })).toBeChecked();
await expect(page.getByRole('radio', { name: '自定义项目' })).not.toBeChecked();
await expect(page.getByRole('radio', { name: '创建新的项目 ID' })).toHaveCount(0);
await expect(page.getByRole('radio', { name: '绑定已有项目 ID' })).toHaveCount(0);
await expect(page.getByRole('radio', { name: '交互式 AI 应用' })).toHaveCount(0);
await expect(page.getByRole('radio', { name: '自定义项目' })).toHaveCount(0);
await expect(page.getByRole('radio', { name: '小游戏' })).toHaveCount(0);
await expect(page.getByRole('radio', { name: '小程序' })).toHaveCount(0);
await expect(page.getByRole('radio', { name: '直接使用所选文件夹(默认)' })).toBeChecked();
@@ -29,7 +31,16 @@ test.describe('Project configuration skills', () => {
await expect(page.getByLabel('项目路径')).toHaveValue(parentPath);
await page.getByRole('button', { name: '确认创建' }).click();
await expect(page.getByTestId('coding-chat-empty-agent')).toBeVisible();
await expect(page.getByTestId('project-initialization-gate')).toHaveCount(0);
await page.getByRole('button', { name: '创建项目智能体' }).click();
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
await expect(page.getByTestId('project-identity-card')).toHaveCount(0);
await expect(page.getByTestId('legacy-project-identity-card')).toHaveCount(0);
await expect(page.getByTestId('independent-copy-button')).toHaveCount(0);
await expect(page.getByText(/项目 ID|项目身份|独立副本/u)).toHaveCount(0);
await expect(page.getByRole('button', { name: '保存项目配置' })).toBeVisible();
await expect(page.getByRole('button', { name: '确认并完成初始化' })).toHaveCount(0);
await expect(page.getByTestId('sidebar-nav-publish')).toHaveCount(0);
await expect(page.getByText(/新项目还没有智能体/)).toHaveCount(0);
await expect(page.getByTestId('project-configuration-actions')).toBeVisible();
@@ -96,6 +107,9 @@ test.describe('Project configuration skills', () => {
}, null, 2));
await page.reload();
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
await expect(page.getByTestId('project-identity-card')).toHaveCount(0);
await expect(page.getByTestId('legacy-project-identity-card')).toHaveCount(0);
await expect(page.getByText(/项目 ID|项目身份|独立副本/u)).toHaveCount(0);
await page.getByTestId('resource-card-skills').click();
await expect(page.getByTestId('superpowers-card')).toHaveCount(0);

View File

@@ -6,6 +6,9 @@ import { useAuthStore } from '@/stores/auth';
import { useProviderStore } from '@/stores/providers';
import { useSettingsStore } from '@/stores/settings';
import { useUserSyncStore } from '@/stores/user-sync';
import { codingWorkspaceStore } from '@/stores/coding-workspace';
import { useProjectConfigStore } from '@/stores/project-config';
import { createCodingProjectConfigV2 } from '@electron/coding-projects/project-config';
vi.mock('@/components/layout/MainLayout', () => ({
MainLayout: () => (
@@ -41,6 +44,13 @@ vi.mock('@/pages/Settings', () => ({
describe('App programming provider initialization gate', () => {
const initProviders = vi.fn();
const project = {
id: 'local-project',
name: 'Local project',
createdAt: '2026-09-06T00:00:00.000Z',
updatedAt: '2026-09-06T00:00:00.000Z',
lastOpenedAt: '2026-09-06T00:00:00.000Z',
};
beforeEach(() => {
vi.clearAllMocks();
@@ -49,6 +59,18 @@ describe('App programming provider initialization gate', () => {
init: vi.fn(),
});
useProviderStore.setState({ init: initProviders });
codingWorkspaceStore.setState({
activeProjectId: project.id,
activeProject: project,
load: vi.fn().mockResolvedValue(undefined),
});
useProjectConfigStore.setState({
load: vi.fn().mockResolvedValue({
status: 'valid',
config: { ...createCodingProjectConfigV2(), initialized: false },
knowledgeFiles: [],
}),
});
useAuthStore.setState({
initialized: true,
accessToken: 'access-token',
@@ -94,6 +116,13 @@ describe('App programming provider initialization gate', () => {
await waitFor(() => expect(initProviders).toHaveBeenCalledTimes(1));
});
it('opens a valid project without requiring the legacy initialized flag', async () => {
await renderAt('/chat');
expect(await screen.findByText('Programming workspace')).toBeInTheDocument();
expect(screen.queryByText('项目尚未初始化')).not.toBeInTheDocument();
});
it('redirects an orphaned token without a user identity away from a direct workspace route', async () => {
useAuthStore.setState({
initialized: true,

View File

@@ -199,6 +199,24 @@ describe('CodingChatPanel first Conversation', () => {
vi.resetModules();
});
it('keeps a project with no Agent accessible and offers optional Agent setup', async () => {
const openProjectSettings = vi.fn();
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({
project,
config: { ...configForAgents([]), initialized: false },
});
projectApi.conversations.mockResolvedValue([]);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
render(<CodingChatPanel onOpenProjectSettings={openProjectSettings} />);
expect(await screen.findByTestId('coding-chat-empty-agent')).toHaveTextContent('项目已准备好');
expect(projectApi.create).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '创建项目智能体' }));
expect(openProjectSettings).toHaveBeenCalledOnce();
});
it('makes the first-Conversation textarea editable while runtime metadata is held', async () => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });

View File

@@ -736,11 +736,11 @@ describe('PI-100 coding core Host contract', () => {
it('maps persistence failures to a stable fixed Host error', async () => {
const result = await setup();
const current = await result.projects.getConfig('project-a');
const failingProjects = new CodingProjectService(result.store, {
writeConfig: async () => { throw new Error(`secret disk path=${result.root}`); },
});
const failingConversations = new CodingConversationService(failingProjects, result.runtime);
const current = await failingProjects.getConfig('project-a');
const response = await dispatchHostApiRequest(context({
...result,
projects: failingProjects,

View File

@@ -174,6 +174,36 @@ describe('coding project durable identity', () => {
expect((await projects.getConfig(local.project.id)).config.projectId).toBe(PROJECT_ID);
});
it('automatically assigns one identity when a legacy project is read concurrently', async () => {
const projectPath = await makeRoot();
const store = makeStore();
const local = await createLocalCodingProject({ projectPath, now: CREATED }, store);
const createProjectId = vi.fn(() => PROJECT_ID);
const events: string[] = [];
const projects = new CodingProjectService(store, {
createProjectId,
now: () => UPDATED,
onProjectIdentityChanging: () => { events.push('invalidate'); },
writeConfig: async (filePath, value) => {
events.push('write');
await writeCodingProjectConfigV2(filePath, value);
},
onResourcesChanged: () => { events.push('resources'); },
});
const [first, second] = await Promise.all([
projects.getConfig(local.project.id),
projects.getConfig(local.project.id),
]);
expect(first.config.projectId).toBe(PROJECT_ID);
expect(second.config.projectId).toBe(PROJECT_ID);
expect(createProjectId).toHaveBeenCalledTimes(1);
expect(events).toEqual(['invalidate', 'write', 'resources']);
await expect(readCodingProjectConfigV2(projectPath))
.resolves.toMatchObject({ status: 'valid', config: { projectId: PROJECT_ID, updatedAt: UPDATED } });
});
it('keeps ordinary saves immutable and supports a confirmed independent copy', async () => {
const projectPath = await makeRoot();
const store = makeStore();
@@ -280,8 +310,13 @@ describe('coding project durable identity', () => {
const legacyPath = await makeRoot('makelore-project-identity-legacy-');
const legacyStore = makeStore();
await createLocalCodingProject({ projectPath: legacyPath }, legacyStore);
await expect(new CodingProjectService(legacyStore).requireActiveRealProjectWithIdentity())
.rejects.toMatchObject({ code: 'CODING_PROJECT_IDENTITY_REQUIRED', status: 409 });
const legacyProjects = new CodingProjectService(legacyStore, {
createProjectId: () => NEXT_PROJECT_ID,
});
await expect(legacyProjects.requireActiveRealProjectWithIdentity())
.resolves.toMatchObject({ projectId: NEXT_PROJECT_ID });
await expect(readCodingProjectConfigV2(legacyPath))
.resolves.toMatchObject({ status: 'valid', config: { projectId: NEXT_PROJECT_ID } });
});
it('exposes identity resolution and independent-copy through the Host routes', async () => {

View File

@@ -2,10 +2,7 @@ import { render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MainLayout } from '@/components/layout/MainLayout';
import { codingWorkspaceStore } from '@/stores/coding-workspace';
import { useProjectConfigStore } from '@/stores/project-config';
import { useSettingsStore } from '@/stores/settings';
import { createCodingProjectConfigV2 } from '@electron/coding-projects/project-config';
const routeRenderMock = vi.hoisted(() => vi.fn());
@@ -46,20 +43,6 @@ function renderLayout(path: string) {
describe('MainLayout module isolation', () => {
beforeEach(() => {
routeRenderMock.mockReset();
const project = {
id: 'local-project',
path: '/tmp/local-project',
name: 'local-project',
createdAt: '',
updatedAt: '',
lastOpenedAt: '',
};
codingWorkspaceStore.setState({ activeProject: project });
useProjectConfigStore.setState({
configsByProjectId: {
[project.id]: { ...createCodingProjectConfigV2(), initialized: false },
},
});
useSettingsStore.setState({ sidebarCollapsed: false });
});
@@ -89,19 +72,17 @@ describe('MainLayout module isolation', () => {
});
it('does not overlay the local project initialization gate on AI hardware', () => {
const load = vi.fn().mockResolvedValue(undefined);
useProjectConfigStore.setState({ load });
renderLayout('/ai-hardware');
expect(screen.getByTestId('route-content')).toBeVisible();
expect(screen.queryByTestId('project-initialization-gate')).not.toBeInTheDocument();
expect(load).not.toHaveBeenCalled();
});
it('keeps the local project initialization gate for AI programming routes', () => {
it('does not restore the removed project initialization gate on AI programming routes', () => {
renderLayout('/chat');
expect(screen.getByTestId('project-initialization-gate')).toBeVisible();
expect(screen.getByTestId('route-content')).toBeVisible();
expect(screen.queryByTestId('project-initialization-gate')).not.toBeInTheDocument();
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-overlay', 'false');
});