Files
claudecodeui/server/modules/projects/tests/project-clone.service.test.ts
Haileyesus bb86236520 refactor: modularize project services, and wizard create/clone flow
Restructure project creation, listing, GitHub clone progress, and TaskMaster
details behind a dedicated TypeScript module under server/modules/projects/,
and align the client wizard with a single path-based flow.

Server / routing
- Remove server/routes/projects.js and mount server/modules/projects/
  projects.routes.ts at /api/projects (still behind authenticateToken).
- Drop duplicate handlers from server/index.js for GET /api/projects and
  GET /api/projects/:projectId/taskmaster; those live on the new router.
- Import WORKSPACES_ROOT and validateWorkspacePath from shared utils in
  index.js instead of the deleted projects route module.

Projects router (projects.routes.ts)
- GET /: list projects with sessions (existing snapshot behavior).
- POST /create-project: validate body, reject legacy workspaceType and
  mixed clone fields, delegate to createProject service, return distinct
  success copy when an archived path is reactivated.
- GET /clone-progress: Server-Sent Events for clone progress/complete/error;
  requires authenticated user id for token resolution; wires startCloneProject.
- GET /:projectId/taskmaster: delegates to getProjectTaskMaster.

Services (new)
- project-management.service.ts: path validation, workspace directory
  creation, persistence via projectsDb.createProjectPath, mapping to API
  project shape; surfaces AppError for validation, conflict, and not-found
  cases; optional dependency injection for tests.
- project-clone.service.ts: validates workspace, resolves GitHub auth
  (stored token or inline token), runs git clone with progress callbacks,
  registers project via createProject on success; sanitizes errors and
  supports cancellation; injectable dependencies for tests.
- projects-has-taskmaster.service.ts: moves TaskMaster detection and
  normalization out of server/projects.js; resolve-by-id and public
  getProjectTaskMaster with structured AppError responses.

Persistence and shared types
- projectsDb.createProjectPath now returns CreateProjectPathResult
  (created | reactivated_archived | active_conflict) using INSERT … ON
  CONFLICT with selective update when the row is archived; normalizes
  display name from path or custom name; repository row typing moves to
  shared ProjectRepositoryRow.
- getProjectPaths() returns only non-archived rows (isArchived = 0).
- shared/types.ts: ProjectRepositoryRow, CreateProjectPathResult/outcome,
  WorkspacePathValidationResult.
- shared/utils.ts: WORKSPACES_ROOT, forbidden path lists, validateWorkspacePath,
  asyncHandler for Express async routes.

Legacy cleanup
- server/projects.js: remove detectTaskMasterFolder, normalizeTaskMasterInfo,
  and getProjectTaskMasterById (logic lives in the new service).
- server/routes/agent.js: register external API project paths with
  projectsDb.createProjectPath instead of addProjectManually try/catch;
  treat active_conflict as an existing registration and continue.

Tests
- Add Node test suites for project-management, project-clone, and
  projects-has-taskmaster services; update projects.service test import
  for renamed projects-with-sessions-fetch.service.ts.

Rename
- projects.service.ts → projects-with-sessions-fetch.service.ts;
  re-export from modules/projects/index.ts.

Client (project creation wizard)
- Remove StepTypeSelection and workspaceType from form state and types;
  wizard is two steps (configure path/GitHub auth, then review).
- createWorkspaceRequest → createProjectRequest; clone vs create-only
  inferred from githubUrl (pathUtils / isCloneWorkflow).
- Adjust step indices, WizardProgress, StepConfiguration/Review,
  WorkspacePathField, and src/utils/api.js as needed for the new API.

Docs
- Minor websocket README touch-up.

Net: ~1.6k insertions / ~0.9k deletions across 29 files; behavior is
centralized in typed services with explicit HTTP errors and test seams.
2026-04-25 19:36:47 +03:00

161 lines
4.7 KiB
TypeScript

import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import path from 'node:path';
import { PassThrough } from 'node:stream';
import test from 'node:test';
import { startCloneProject } from '@/modules/projects/services/project-clone.service.js';
import { AppError } from '@/shared/utils.js';
type TestDependencies = Parameters<typeof startCloneProject>[2];
function buildDependencies(overrides: Partial<NonNullable<TestDependencies>> = {}): NonNullable<TestDependencies> {
return {
validatePath: async () => ({ valid: true, resolvedPath: '/workspace/root' }),
ensureDirectory: async () => undefined,
pathExists: async () => false,
removePath: async () => undefined,
getGithubTokenById: async () => ({ github_token: 'token-value' }),
spawnGitClone: () => {
throw new Error('spawnGitClone should be overridden in this test');
},
registerProject: async () => ({ project: { projectId: 'project-1' } }),
logError: () => undefined,
...overrides,
};
}
function createMockGitProcess() {
const emitter = new EventEmitter() as EventEmitter & {
stdout: PassThrough;
stderr: PassThrough;
kill: () => void;
};
emitter.stdout = new PassThrough();
emitter.stderr = new PassThrough();
emitter.kill = () => {
emitter.emit('close', null);
};
return emitter;
}
test('startCloneProject rejects when workspace path is missing', async () => {
await assert.rejects(
async () =>
startCloneProject(
{
workspacePath: '',
githubUrl: 'https://github.com/example/repo',
userId: 1,
},
{
onProgress: () => undefined,
onComplete: () => undefined,
},
buildDependencies(),
),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'WORKSPACE_PATH_REQUIRED');
return true;
},
);
});
test('startCloneProject rejects when github URL is missing', async () => {
await assert.rejects(
async () =>
startCloneProject(
{
workspacePath: '/workspace/root',
githubUrl: '',
userId: 1,
},
{
onProgress: () => undefined,
onComplete: () => undefined,
},
buildDependencies(),
),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'GITHUB_URL_REQUIRED');
return true;
},
);
});
test('startCloneProject rejects when selected github token does not exist', async () => {
await assert.rejects(
async () =>
startCloneProject(
{
workspacePath: '/workspace/root',
githubUrl: 'https://github.com/example/repo',
githubTokenId: 12,
userId: 1,
},
{
onProgress: () => undefined,
onComplete: () => undefined,
},
buildDependencies({
getGithubTokenById: async () => null,
}),
),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'GITHUB_TOKEN_NOT_FOUND');
return true;
},
);
});
test('startCloneProject completes and emits complete payload when git exits successfully', async () => {
const gitProcess = createMockGitProcess();
const progressMessages: string[] = [];
let completePayload: { project: Record<string, unknown>; message: string } | null = null;
let capturedProjectPath = '';
let capturedCustomName = '';
const operation = await startCloneProject(
{
workspacePath: '/workspace/root',
githubUrl: 'https://github.com/example/repo.git',
userId: 1,
},
{
onProgress: (message) => {
progressMessages.push(message);
},
onComplete: (payload: { project: Record<string, unknown>; message: string }) => {
completePayload = payload;
},
},
buildDependencies({
spawnGitClone: () => gitProcess as any,
registerProject: async (projectPath, customName) => {
capturedProjectPath = projectPath;
capturedCustomName = customName;
return { project: { projectId: 'project-1', path: projectPath } };
},
}),
);
gitProcess.emit('close', 0);
await operation.waitForCompletion;
assert.ok(progressMessages.some((message) => message.includes("Cloning into 'repo'")));
assert.equal(capturedCustomName, 'repo');
assert.equal(path.basename(capturedProjectPath), 'repo');
assert.notEqual(completePayload, null);
const resolvedCompletePayload = completePayload as unknown as {
project: Record<string, unknown>;
message: string;
};
assert.equal(resolvedCompletePayload.message, 'Repository cloned successfully');
assert.equal((resolvedCompletePayload.project.projectId as string) || '', 'project-1');
});