feat(coding): add project identity UX
This commit is contained in:
117
tests/e2e/coding-project-identity.spec.ts
Normal file
117
tests/e2e/coding-project-identity.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
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';
|
||||
|
||||
async function readProjectConfig(projectPath: string): Promise<Record<string, unknown>> {
|
||||
return JSON.parse(await readFile(path.join(projectPath, '.niancode', 'project.json'), 'utf8')) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function selectProgrammingProjectFolder(
|
||||
app: Parameters<typeof closeElectronApp>[0],
|
||||
projectPath: string,
|
||||
) {
|
||||
await app.evaluate(({ ipcMain }, selectedPath) => {
|
||||
ipcMain.removeHandler('dialog:open');
|
||||
ipcMain.handle('dialog:open', async () => ({ canceled: false, filePaths: [selectedPath] }));
|
||||
}, projectPath);
|
||||
const page = await getStableWindow(app);
|
||||
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
|
||||
await page.getByTestId('ai-module-option-programming').click();
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible();
|
||||
return page;
|
||||
}
|
||||
|
||||
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-'));
|
||||
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 createDialog.getByRole('button', { name: '选择路径' }).click();
|
||||
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();
|
||||
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();
|
||||
|
||||
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');
|
||||
|
||||
const legacyConfig = await readProjectConfig(projectPath);
|
||||
delete legacyConfig.projectId;
|
||||
await writeFile(path.join(projectPath, '.niancode', '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);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await rm(projectPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -50,4 +50,53 @@ describe('coding project Host facade', () => {
|
||||
['/api/coding/conversations/conversation%2F1', { method: 'DELETE' }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('sends explicit identity choices and a literal independent-copy confirmation', async () => {
|
||||
hostApiFetch
|
||||
.mockResolvedValueOnce({ snapshot: { project: { id: 'local-1' }, config: { projectId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, knowledgeFiles: [] } })
|
||||
.mockResolvedValueOnce({ snapshot: { project: { id: 'local-1' }, config: { projectId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' }, knowledgeFiles: [] } })
|
||||
.mockResolvedValueOnce({ snapshot: { project: { id: 'local-1' }, config: { projectId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' }, knowledgeFiles: [] } });
|
||||
const {
|
||||
createCodingProject,
|
||||
isCanonicalCodingProjectId,
|
||||
makeCodingProjectIndependentCopy,
|
||||
resolveCodingProjectIdentity,
|
||||
} = await import('@/lib/coding-projects');
|
||||
|
||||
await createCodingProject({
|
||||
projectPath: 'C:/projects/new',
|
||||
projectType: 'custom',
|
||||
identity: { kind: 'create' },
|
||||
});
|
||||
await resolveCodingProjectIdentity('local-1', {
|
||||
kind: 'bind',
|
||||
projectId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
|
||||
});
|
||||
await makeCodingProjectIndependentCopy('local-1');
|
||||
|
||||
expect(isCanonicalCodingProjectId('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb')).toBe(true);
|
||||
expect(isCanonicalCodingProjectId('BBBBBBBB-BBBB-4BBB-8BBB-BBBBBBBBBBBB')).toBe(false);
|
||||
expect(isCanonicalCodingProjectId('not-a-project-id')).toBe(false);
|
||||
expect(hostApiFetch.mock.calls).toEqual([
|
||||
['/api/coding/projects/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
projectPath: 'C:/projects/new',
|
||||
projectType: 'custom',
|
||||
identity: { kind: 'create' },
|
||||
}),
|
||||
}],
|
||||
['/api/coding/projects/identity', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
localProjectId: 'local-1',
|
||||
identity: { kind: 'bind', projectId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' },
|
||||
}),
|
||||
}],
|
||||
['/api/coding/projects/identity/independent-copy', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ localProjectId: 'local-1', confirmed: true }),
|
||||
}],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,6 +106,31 @@ describe('coding workspace store', () => {
|
||||
expect(createConversation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the explicit identity choice through project creation', async () => {
|
||||
const createProject = vi.fn(async () => ({
|
||||
project,
|
||||
config: config([agent('agent-a')]),
|
||||
knowledgeFiles: [],
|
||||
}));
|
||||
const store = createCodingWorkspaceStore({
|
||||
listProjects: vi.fn(async () => ({ projects: [project], activeProjectId: project.id })),
|
||||
getConfig: vi.fn(async () => ({ project, config: config([agent('agent-a')]) })),
|
||||
listConversations: vi.fn(async () => []),
|
||||
createProject,
|
||||
});
|
||||
|
||||
await expect(store.getState().createProject({
|
||||
projectPath: 'C:/projects/new',
|
||||
projectType: 'custom',
|
||||
identity: { kind: 'bind', projectId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' },
|
||||
})).resolves.toEqual(project);
|
||||
expect(createProject).toHaveBeenCalledWith({
|
||||
projectPath: 'C:/projects/new',
|
||||
projectType: 'custom',
|
||||
identity: { kind: 'bind', projectId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' },
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a missing first Conversation once for concurrent callers', async () => {
|
||||
let resolveCreate!: (value: CodingConversationMetadata) => void;
|
||||
const createFlight = new Promise<CodingConversationMetadata>((resolve) => {
|
||||
|
||||
74
tests/unit/project-config-store.test.ts
Normal file
74
tests/unit/project-config-store.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useProjectConfigStore } from '@/stores/project-config';
|
||||
import type { CodingProjectConfigSnapshot } from '@/types/coding-project';
|
||||
|
||||
const resolveCodingProjectIdentity = vi.hoisted(() => vi.fn());
|
||||
const makeCodingProjectIndependentCopy = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/coding-projects', () => ({
|
||||
makeCodingProjectIndependentCopy,
|
||||
resolveCodingProjectIdentity,
|
||||
}));
|
||||
|
||||
const projectId = 'local-project-1';
|
||||
|
||||
function snapshot(identity: string): CodingProjectConfigSnapshot {
|
||||
return {
|
||||
project: {
|
||||
id: projectId,
|
||||
name: 'Local project',
|
||||
createdAt: '2026-08-23T00:00:00.000Z',
|
||||
updatedAt: '2026-08-23T00:00:00.000Z',
|
||||
lastOpenedAt: '2026-08-23T00:00:00.000Z',
|
||||
},
|
||||
config: {
|
||||
schemaVersion: 2,
|
||||
projectType: 'custom',
|
||||
projectId: identity,
|
||||
initialized: true,
|
||||
agents: [],
|
||||
knowledgeDirectory: 'knowledge',
|
||||
legacyConversationNotice: 'none',
|
||||
createdAt: '2026-08-23T00:00:00.000Z',
|
||||
updatedAt: '2026-08-23T00:00:00.000Z',
|
||||
},
|
||||
knowledgeFiles: ['README.md'],
|
||||
};
|
||||
}
|
||||
|
||||
describe('project config identity projection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useProjectConfigStore.setState({
|
||||
configsByProjectId: {},
|
||||
knowledgeByProjectId: {},
|
||||
loadingProjectId: null,
|
||||
errorsByProjectId: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('projects identity resolution snapshots into the renderer cache', async () => {
|
||||
const resolved = snapshot('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa');
|
||||
resolveCodingProjectIdentity.mockResolvedValue(resolved);
|
||||
|
||||
await expect(useProjectConfigStore.getState().resolveIdentity(projectId, { kind: 'create' }))
|
||||
.resolves.toEqual(resolved.config);
|
||||
|
||||
expect(resolveCodingProjectIdentity).toHaveBeenCalledWith(projectId, { kind: 'create' });
|
||||
expect(useProjectConfigStore.getState()).toMatchObject({
|
||||
configsByProjectId: { [projectId]: resolved.config },
|
||||
knowledgeByProjectId: { [projectId]: resolved.knowledgeFiles },
|
||||
});
|
||||
});
|
||||
|
||||
it('projects independent-copy snapshots without writing any renderer-owned data', async () => {
|
||||
const copied = snapshot('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb');
|
||||
makeCodingProjectIndependentCopy.mockResolvedValue(copied);
|
||||
|
||||
await expect(useProjectConfigStore.getState().makeIndependentCopy(projectId))
|
||||
.resolves.toEqual(copied.config);
|
||||
|
||||
expect(makeCodingProjectIndependentCopy).toHaveBeenCalledWith(projectId);
|
||||
expect(useProjectConfigStore.getState().configsByProjectId[projectId]).toEqual(copied.config);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user