mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2025-12-27 02:04:22 +01:00
update design
This commit is contained in:
@@ -4,7 +4,7 @@ export const configSchema = z.object({
|
||||
hideNoUserMessageSession: z.boolean().optional().default(true),
|
||||
unifySameTitleSession: z.boolean().optional().default(true),
|
||||
enterKeyBehavior: z
|
||||
.enum(["shift-enter-send", "enter-send"])
|
||||
.enum(["shift-enter-send", "enter-send", "command-enter-send"])
|
||||
.optional()
|
||||
.default("shift-enter-send"),
|
||||
permissionMode: z
|
||||
|
||||
@@ -5,9 +5,9 @@ import { FileWatcherService } from "../service/events/fileWatcher";
|
||||
import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration";
|
||||
import { ProjectMetaService } from "../service/project/ProjectMetaService";
|
||||
import { ProjectRepository } from "../service/project/ProjectRepository";
|
||||
import { VirtualConversationDatabase } from "../service/session/PredictSessionsDatabase";
|
||||
import { SessionMetaService } from "../service/session/SessionMetaService";
|
||||
import { SessionRepository } from "../service/session/SessionRepository";
|
||||
import { VirtualConversationDatabase } from "../service/session/VirtualConversationDatabase";
|
||||
import { InitializeService } from "./initialize";
|
||||
|
||||
describe("InitializeService", () => {
|
||||
|
||||
@@ -4,9 +4,9 @@ import { FileWatcherService } from "../service/events/fileWatcher";
|
||||
import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration";
|
||||
import { ProjectMetaService } from "../service/project/ProjectMetaService";
|
||||
import { ProjectRepository } from "../service/project/ProjectRepository";
|
||||
import { VirtualConversationDatabase } from "../service/session/PredictSessionsDatabase";
|
||||
import { SessionMetaService } from "../service/session/SessionMetaService";
|
||||
import { SessionRepository } from "../service/session/SessionRepository";
|
||||
import { VirtualConversationDatabase } from "../service/session/VirtualConversationDatabase";
|
||||
|
||||
interface InitializeServiceInterface {
|
||||
readonly startInitialization: () => Effect.Effect<void>;
|
||||
|
||||
@@ -24,9 +24,9 @@ import { getMcpList } from "../service/mcp/getMcpList";
|
||||
import { claudeCommandsDirPath } from "../service/paths";
|
||||
import type { ProjectMetaService } from "../service/project/ProjectMetaService";
|
||||
import { ProjectRepository } from "../service/project/ProjectRepository";
|
||||
import type { VirtualConversationDatabase } from "../service/session/PredictSessionsDatabase";
|
||||
import type { SessionMetaService } from "../service/session/SessionMetaService";
|
||||
import { SessionRepository } from "../service/session/SessionRepository";
|
||||
import type { VirtualConversationDatabase } from "../service/session/VirtualConversationDatabase";
|
||||
import type { HonoAppType } from "./app";
|
||||
import { InitializeService } from "./initialize";
|
||||
import { configMiddleware } from "./middleware/config.middleware";
|
||||
@@ -184,6 +184,24 @@ export const routes = (app: HonoAppType) =>
|
||||
},
|
||||
)
|
||||
|
||||
.get("/projects/:projectId/latest-session", async (c) => {
|
||||
const { projectId } = c.req.param();
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const { sessions } = yield* sessionRepository.getSessions(
|
||||
projectId,
|
||||
{ maxCount: 1 },
|
||||
);
|
||||
|
||||
return {
|
||||
latestSession: sessions[0] ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const result = await Runtime.runPromise(runtime)(program);
|
||||
return c.json(result);
|
||||
})
|
||||
|
||||
.get("/projects/:projectId/sessions/:sessionId", async (c) => {
|
||||
const { projectId, sessionId } = c.req.param();
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ import { controllablePromise } from "../../../lib/controllablePromise";
|
||||
import type { Config } from "../../config/config";
|
||||
import type { InferEffect } from "../../lib/effect/types";
|
||||
import { EventBus } from "../events/EventBus";
|
||||
import { VirtualConversationDatabase } from "../session/PredictSessionsDatabase";
|
||||
import type { SessionMetaService } from "../session/SessionMetaService";
|
||||
import { SessionRepository } from "../session/SessionRepository";
|
||||
import { VirtualConversationDatabase } from "../session/VirtualConversationDatabase";
|
||||
import * as ClaudeCode from "./ClaudeCode";
|
||||
import { ClaudeCodePermissionService } from "./ClaudeCodePermissionService";
|
||||
import { ClaudeCodeSessionProcessService } from "./ClaudeCodeSessionProcessService";
|
||||
@@ -209,8 +209,21 @@ const LayerImpl = Effect.gen(function* () {
|
||||
}
|
||||
|
||||
if (
|
||||
message.type === "result" &&
|
||||
message.type === "assistant" &&
|
||||
processState.type === "initialized"
|
||||
) {
|
||||
yield* sessionProcessService.toFileCreatedState({
|
||||
sessionProcessId: processState.def.sessionProcessId,
|
||||
});
|
||||
|
||||
yield* virtualConversationDatabase.deleteVirtualConversations(
|
||||
message.session_id,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
message.type === "result" &&
|
||||
processState.type === "file_created"
|
||||
) {
|
||||
yield* sessionProcessService.toPausedState({
|
||||
sessionProcessId: processState.def.sessionProcessId,
|
||||
|
||||
@@ -0,0 +1,901 @@
|
||||
import type { SDKResultMessage, SDKSystemMessage } from "@anthropic-ai/claude-code";
|
||||
import { Effect, Layer } from "effect";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EventBus } from "../events/EventBus";
|
||||
import type { InternalEventDeclaration } from "../events/InternalEventDeclaration";
|
||||
import { ClaudeCodeSessionProcessService } from "./ClaudeCodeSessionProcessService";
|
||||
import type { InitMessageContext } from "./createMessageGenerator";
|
||||
import type * as CCSessionProcess from "./models/CCSessionProcess";
|
||||
import type * as CCTask from "./models/ClaudeCodeTask";
|
||||
|
||||
// Helper function to create mock session process definition
|
||||
const createMockSessionProcessDef = (
|
||||
sessionProcessId: string,
|
||||
projectId = "test-project",
|
||||
): CCSessionProcess.CCSessionProcessDef => ({
|
||||
sessionProcessId,
|
||||
projectId,
|
||||
cwd: "/test/path",
|
||||
abortController: new AbortController(),
|
||||
setNextMessage: () => {},
|
||||
});
|
||||
|
||||
// Helper function to create mock new task definition
|
||||
const createMockNewTaskDef = (taskId: string): CCTask.NewClaudeCodeTaskDef => ({
|
||||
type: "new",
|
||||
taskId,
|
||||
});
|
||||
|
||||
// Helper function to create mock continue task definition
|
||||
const createMockContinueTaskDef = (
|
||||
taskId: string,
|
||||
sessionId: string,
|
||||
baseSessionId: string,
|
||||
): CCTask.ContinueClaudeCodeTaskDef => ({
|
||||
type: "continue",
|
||||
taskId,
|
||||
sessionId,
|
||||
baseSessionId,
|
||||
});
|
||||
|
||||
|
||||
// Helper function to create mock init context
|
||||
const createMockInitContext = (sessionId: string): InitMessageContext => ({
|
||||
initMessage: {
|
||||
type: "system",
|
||||
session_id: sessionId,
|
||||
} as SDKSystemMessage,
|
||||
});
|
||||
|
||||
// Helper function to create mock result message
|
||||
const createMockResultMessage = (sessionId: string): SDKResultMessage => ({
|
||||
type: "result",
|
||||
session_id: sessionId,
|
||||
result: {},
|
||||
} as SDKResultMessage);
|
||||
|
||||
// Mock EventBus for testing
|
||||
const MockEventBus = Layer.succeed(EventBus, {
|
||||
emit: <K extends keyof InternalEventDeclaration>(
|
||||
_eventName: K,
|
||||
_event: InternalEventDeclaration[K],
|
||||
) => Effect.void,
|
||||
on: <K extends keyof InternalEventDeclaration>(
|
||||
_eventName: K,
|
||||
_listener: (event: InternalEventDeclaration[K]) => void,
|
||||
) => Effect.void,
|
||||
off: <K extends keyof InternalEventDeclaration>(
|
||||
_eventName: K,
|
||||
_listener: (event: InternalEventDeclaration[K]) => void,
|
||||
) => Effect.void,
|
||||
});
|
||||
|
||||
const TestLayer = Layer.provide(
|
||||
ClaudeCodeSessionProcessService.Live,
|
||||
MockEventBus,
|
||||
);
|
||||
|
||||
describe("ClaudeCodeSessionProcessService", () => {
|
||||
describe("startSessionProcess", () => {
|
||||
it("can start new session process", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const service = yield* ClaudeCodeSessionProcessService;
|
||||
|
||||
const sessionDef = createMockSessionProcessDef("process-1");
|
||||
const taskDef = createMockNewTaskDef("task-1");
|
||||
|
||||
const result = yield* service.startSessionProcess({
|
||||
sessionDef,
|
||||
taskDef,
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TestLayer)),
|
||||
);
|
||||
|
||||
expect(result.sessionProcess.type).toBe("pending");
|
||||
expect(result.sessionProcess.def.sessionProcessId).toBe("process-1");
|
||||
expect(result.task.status).toBe("pending");
|
||||
expect(result.task.def.taskId).toBe("task-1");
|
||||
});
|
||||
|
||||
it("creates session process with correct task structure", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const service = yield* ClaudeCodeSessionProcessService;
|
||||
|
||||
const sessionDef = createMockSessionProcessDef("process-1");
|
||||
const taskDef = createMockNewTaskDef("task-1");
|
||||
|
||||
const result = yield* service.startSessionProcess({
|
||||
sessionDef,
|
||||
taskDef,
|
||||
});
|
||||
|
||||
return { result, taskDef };
|
||||
});
|
||||
|
||||
const { result, taskDef } = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TestLayer)),
|
||||
);
|
||||
|
||||
expect(result.sessionProcess.tasks).toHaveLength(1);
|
||||
expect(result.sessionProcess.currentTask).toBe(result.task);
|
||||
expect(result.sessionProcess.currentTask.def).toBe(taskDef);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSessionProcess", () => {
|
||||
it("can retrieve created session process", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const service = yield* ClaudeCodeSessionProcessService;
|
||||
|
||||
const sessionDef = createMockSessionProcessDef("process-1");
|
||||
const taskDef = createMockNewTaskDef("task-1");
|
||||
|
||||
yield* service.startSessionProcess({
|
||||
sessionDef,
|
||||
taskDef,
|
||||
});
|
||||
|
||||
const process = yield* service.getSessionProcess("process-1");
|
||||
|
||||
return process;
|
||||
});
|
||||
|
||||
const process = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TestLayer)),
|
||||
);
|
||||
|
||||
expect(process.def.sessionProcessId).toBe("process-1");
|
||||
expect(process.type).toBe("pending");
|
||||
});
|
||||
|
||||
it("fails with SessionProcessNotFoundError for non-existent process", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const service = yield* ClaudeCodeSessionProcessService;
|
||||
|
||||
const result = yield* Effect.flip(
|
||||
service.getSessionProcess("non-existent"),
|
||||
);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const error = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TestLayer)),
|
||||
);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "SessionProcessNotFoundError",
|
||||
sessionProcessId: "non-existent",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSessionProcesses", () => {
|
||||
it("returns empty array when no processes exist", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const service = yield* ClaudeCodeSessionProcessService;
|
||||
|
||||
const processes = yield* service.getSessionProcesses();
|
||||
|
||||
return processes;
|
||||
});
|
||||
|
||||
const processes = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TestLayer)),
|
||||
);
|
||||
|
||||
expect(processes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns all created processes", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const service = yield* ClaudeCodeSessionProcessService;
|
||||
|
||||
const sessionDef1 = createMockSessionProcessDef("process-1");
|
||||
const taskDef1 = createMockNewTaskDef("task-1");
|
||||
|
||||
const sessionDef2 = createMockSessionProcessDef("process-2");
|
||||
const taskDef2 = createMockNewTaskDef("task-2");
|
||||
|
||||
yield* service.startSessionProcess({
|
||||
sessionDef: sessionDef1,
|
||||
taskDef: taskDef1,
|
||||
});
|
||||
yield* service.startSessionProcess({
|
||||
sessionDef: sessionDef2,
|
||||
taskDef: taskDef2,
|
||||
});
|
||||
|
||||
const processes = yield* service.getSessionProcesses();
|
||||
|
||||
return processes;
|
||||
});
|
||||
|
||||
const processes = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TestLayer)),
|
||||
);
|
||||
|
||||
expect(processes).toHaveLength(2);
|
||||
expect(processes.map((p) => p.def.sessionProcessId)).toEqual(
|
||||
expect.arrayContaining(["process-1", "process-2"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("continueSessionProcess", () => {
|
||||
it("can continue paused session process", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const service = yield* ClaudeCodeSessionProcessService;
|
||||
|
||||
// Start and progress to paused state
|
||||
const sessionDef = createMockSessionProcessDef("process-1");
|
||||
const taskDef = createMockNewTaskDef("task-1");
|
||||
|
||||
yield* service.startSessionProcess({
|
||||
sessionDef,
|
||||
taskDef,
|
||||
});
|
||||
|
||||
yield* service.toNotInitializedState({
|
||||
sessionProcessId: "process-1",
|
||||
rawUserMessage: "test message",
|
||||
});
|
||||
|
||||
yield* service.toInitializedState({
|
||||
sessionProcessId: "process-1",
|
||||
initContext: createMockInitContext("session-1"),
|
||||
});
|
||||
|
||||
yield* service.toFileCreatedState({
|
||||
sessionProcessId: "process-1",
|
||||
});
|
||||
|
||||
yield* service.toPausedState({
|
||||
sessionProcessId: "process-1",
|
||||
resultMessage: createMockResultMessage("session-1"),
|
||||
});
|
||||
|
||||
// Continue the paused process
|
||||
const continueTaskDef = createMockContinueTaskDef(
|
||||
"task-2",
|
||||
"session-1",
|
||||
"session-1",
|
||||
);
|
||||
|
||||
const result = yield* service.continueSessionProcess({
|
||||
sessionProcessId: "process-1",
|
||||
taskDef: continueTaskDef,
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TestLayer)),
|
||||
);
|
||||
|
||||
expect(result.sessionProcess.type).toBe("pending");
|
||||
expect(result.task.def.taskId).toBe("task-2");
|
||||
expect(result.sessionProcess.tasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("fails with SessionProcessNotPausedError when process is not paused", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const service = yield* ClaudeCodeSessionProcessService;
|
||||
|
||||
const sessionDef = createMockSessionProcessDef("process-1");
|
||||
const taskDef = createMockNewTaskDef("task-1");
|
||||
|
||||
yield* service.startSessionProcess({
|
||||
sessionDef,
|
||||
taskDef,
|
||||
});
|
||||
|
||||
const continueTaskDef = createMockContinueTaskDef(
|
||||
"task-2",
|
||||
"session-1",
|
||||
"session-1",
|
||||
);
|
||||
|
||||
const result = yield* Effect.flip(
|
||||
service.continueSessionProcess({
|
||||
sessionProcessId: "process-1",
|
||||
taskDef: continueTaskDef,
|
||||
}),
|
||||
);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const error = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TestLayer)),
|
||||
);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "SessionProcessNotPausedError",
|
||||
sessionProcessId: "process-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails with SessionProcessNotFoundError for non-existent process", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const service = yield* ClaudeCodeSessionProcessService;
|
||||
|
||||
const continueTaskDef = createMockContinueTaskDef(
|
||||
"task-1",
|
||||
"session-1",
|
||||
"session-1",
|
||||
);
|
||||
|
||||
const result = yield* Effect.flip(
|
||||
service.continueSessionProcess({
|
||||
sessionProcessId: "non-existent",
|
||||
taskDef: continueTaskDef,
|
||||
}),
|
||||
);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const error = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TestLayer)),
|
||||
);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "SessionProcessNotFoundError",
|
||||
sessionProcessId: "non-existent",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toNotInitializedState", () => {
|
||||
it("can transition from pending to not_initialized", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const service = yield* ClaudeCodeSessionProcessService;
|
||||
|
||||
const sessionDef = createMockSessionProcessDef("process-1");
|
||||
const taskDef = createMockNewTaskDef("task-1");
|
||||
|
||||
yield* service.startSessionProcess({
|
||||
sessionDef,
|
||||
taskDef,
|
||||
});
|
||||
|
||||
const result = yield* service.toNotInitializedState({
|
||||
sessionProcessId: "process-1",
|
||||
rawUserMessage: "test message",
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TestLayer)),
|
||||
);
|
||||
|
||||
expect(result.sessionProcess.type).toBe("not_initialized");
|
||||
expect(result.sessionProcess.rawUserMessage).toBe("test message");
|
||||
expect(result.task.status).toBe("running");
|
||||
});
|
||||
|
||||
it("fails with IllegalStateChangeError when not in pending state", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const service = yield* ClaudeCodeSessionProcessService;
|
||||
|
||||
const sessionDef = createMockSessionProcessDef("process-1");
|
||||
const taskDef = createMockNewTaskDef("task-1");
|
||||
|
||||
yield* service.startSessionProcess({
|
||||
sessionDef,
|
||||
taskDef,
|
||||
});
|
||||
|
||||
yield* service.toNotInitializedState({
|
||||
sessionProcessId: "process-1",
|
||||
rawUserMessage: "test message",
|
||||
});
|
||||
|
||||
// Try to transition again
|
||||
const result = yield* Effect.flip(
|
||||
service.toNotInitializedState({
|
||||
sessionProcessId: "process-1",
|
||||
rawUserMessage: "test message 2",
|
||||
}),
|
||||
);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const error = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TestLayer)),
|
||||
);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "IllegalStateChangeError",
|
||||
from: "not_initialized",
|
||||
to: "not_initialized",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toInitializedState", () => {
|
||||
it("can transition from not_initialized to initialized", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const service = yield* ClaudeCodeSessionProcessService;
|
||||
|
||||
const sessionDef = createMockSessionProcessDef("process-1");
|
||||
const taskDef = createMockNewTaskDef("task-1");
|
||||
|
||||
yield* service.startSessionProcess({
|
||||
sessionDef,
|
||||
taskDef,
|
||||
});
|
||||
|
||||
yield* service.toNotInitializedState({
|
||||
sessionProcessId: "process-1",
|
||||
rawUserMessage: "test message",
|
||||
});
|
||||
|
||||
const initContext = createMockInitContext("session-1");
|
||||
|
||||
const result = yield* service.toInitializedState({
|
||||
sessionProcessId: "process-1",
|
||||
initContext,
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TestLayer)),
|
||||
);
|
||||
|
||||
expect(result.sessionProcess.type).toBe("initialized");
|
||||
expect(result.sessionProcess.sessionId).toBe("session-1");
|
||||
expect(result.sessionProcess.initContext).toBeDefined();
|
||||
});
|
||||
|
||||
it("fails with IllegalStateChangeError when not in not_initialized state", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const service = yield* ClaudeCodeSessionProcessService;
|
||||
|
||||
const sessionDef = createMockSessionProcessDef("process-1");
|
||||
const taskDef = createMockNewTaskDef("task-1");
|
||||
|
||||
yield* service.startSessionProcess({
|
||||
sessionDef,
|
||||
taskDef,
|
||||
});
|
||||
|
||||
const initContext = createMockInitContext("session-1");
|
||||
|
||||
const result = yield* Effect.flip(
|
||||
service.toInitializedState({
|
||||
sessionProcessId: "process-1",
|
||||
initContext,
|
||||
}),
|
||||
);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const error = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TestLayer)),
|
||||
);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "IllegalStateChangeError",
|
||||
from: "pending",
|
||||
to: "initialized",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toPausedState", () => {
|
||||
it("can transition from file_created to paused", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const service = yield* ClaudeCodeSessionProcessService;
|
||||
|
||||
const sessionDef = createMockSessionProcessDef("process-1");
|
||||
const taskDef = createMockNewTaskDef("task-1");
|
||||
|
||||
yield* service.startSessionProcess({
|
||||
sessionDef,
|
||||
taskDef,
|
||||
});
|
||||
|
||||
yield* service.toNotInitializedState({
|
||||
sessionProcessId: "process-1",
|
||||
rawUserMessage: "test message",
|
||||
});
|
||||
|
||||
yield* service.toInitializedState({
|
||||
sessionProcessId: "process-1",
|
||||
initContext: createMockInitContext("session-1"),
|
||||
});
|
||||
|
||||
yield* service.toFileCreatedState({
|
||||
sessionProcessId: "process-1",
|
||||
});
|
||||
|
||||
const resultMessage = createMockResultMessage("session-1");
|
||||
|
||||
const result = yield* service.toPausedState({
|
||||
sessionProcessId: "process-1",
|
||||
resultMessage,
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TestLayer)),
|
||||
);
|
||||
|
||||
expect(result.sessionProcess.type).toBe("paused");
|
||||
expect(result.sessionProcess.sessionId).toBe("session-1");
|
||||
});
|
||||
|
||||
it("marks current task as completed", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const service = yield* ClaudeCodeSessionProcessService;
|
||||
|
||||
const sessionDef = createMockSessionProcessDef("process-1");
|
||||
const taskDef = createMockNewTaskDef("task-1");
|
||||
|
||||
yield* service.startSessionProcess({
|
||||
sessionDef,
|
||||
taskDef,
|
||||
});
|
||||
|
||||
yield* service.toNotInitializedState({
|
||||
sessionProcessId: "process-1",
|
||||
rawUserMessage: "test message",
|
||||
});
|
||||
|
||||
yield* service.toInitializedState({
|
||||
sessionProcessId: "process-1",
|
||||
initContext: createMockInitContext("session-1"),
|
||||
});
|
||||
|
||||
yield* service.toFileCreatedState({
|
||||
sessionProcessId: "process-1",
|
||||
});
|
||||
|
||||
yield* service.toPausedState({
|
||||
sessionProcessId: "process-1",
|
||||
resultMessage: createMockResultMessage("session-1"),
|
||||
});
|
||||
|
||||
const process = yield* service.getSessionProcess("process-1");
|
||||
|
||||
return process;
|
||||
});
|
||||
|
||||
const process = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TestLayer)),
|
||||
);
|
||||
|
||||
const completedTask = process.tasks.find((t) => t.def.taskId === "task-1");
|
||||
expect(completedTask?.status).toBe("completed");
|
||||
if (completedTask?.status === "completed") {
|
||||
expect(completedTask.sessionId).toBe("session-1");
|
||||
}
|
||||
});
|
||||
|
||||
it("fails with IllegalStateChangeError when not in file_created state", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const service = yield* ClaudeCodeSessionProcessService;
|
||||
|
||||
const sessionDef = createMockSessionProcessDef("process-1");
|
||||
const taskDef = createMockNewTaskDef("task-1");
|
||||
|
||||
yield* service.startSessionProcess({
|
||||
sessionDef,
|
||||
taskDef,
|
||||
});
|
||||
|
||||
const result = yield* Effect.flip(
|
||||
service.toPausedState({
|
||||
sessionProcessId: "process-1",
|
||||
resultMessage: createMockResultMessage("session-1"),
|
||||
}),
|
||||
);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const error = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TestLayer)),
|
||||
);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "IllegalStateChangeError",
|
||||
from: "pending",
|
||||
to: "paused",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toCompletedState", () => {
|
||||
it("can transition to completed state from any state", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const service = yield* ClaudeCodeSessionProcessService;
|
||||
|
||||
const sessionDef = createMockSessionProcessDef("process-1");
|
||||
const taskDef = createMockNewTaskDef("task-1");
|
||||
|
||||
yield* service.startSessionProcess({
|
||||
sessionDef,
|
||||
taskDef,
|
||||
});
|
||||
|
||||
yield* service.toNotInitializedState({
|
||||
sessionProcessId: "process-1",
|
||||
rawUserMessage: "test message",
|
||||
});
|
||||
|
||||
const result = yield* service.toCompletedState({
|
||||
sessionProcessId: "process-1",
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TestLayer)),
|
||||
);
|
||||
|
||||
expect(result.sessionProcess.type).toBe("completed");
|
||||
});
|
||||
|
||||
it("marks current task as completed when no error", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const service = yield* ClaudeCodeSessionProcessService;
|
||||
|
||||
const sessionDef = createMockSessionProcessDef("process-1");
|
||||
const taskDef = createMockNewTaskDef("task-1");
|
||||
|
||||
yield* service.startSessionProcess({
|
||||
sessionDef,
|
||||
taskDef,
|
||||
});
|
||||
|
||||
yield* service.toNotInitializedState({
|
||||
sessionProcessId: "process-1",
|
||||
rawUserMessage: "test message",
|
||||
});
|
||||
|
||||
yield* service.toInitializedState({
|
||||
sessionProcessId: "process-1",
|
||||
initContext: createMockInitContext("session-1"),
|
||||
});
|
||||
|
||||
const result = yield* service.toCompletedState({
|
||||
sessionProcessId: "process-1",
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TestLayer)),
|
||||
);
|
||||
|
||||
expect(result.task?.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("marks current task as failed when error is provided", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const service = yield* ClaudeCodeSessionProcessService;
|
||||
|
||||
const sessionDef = createMockSessionProcessDef("process-1");
|
||||
const taskDef = createMockNewTaskDef("task-1");
|
||||
|
||||
yield* service.startSessionProcess({
|
||||
sessionDef,
|
||||
taskDef,
|
||||
});
|
||||
|
||||
yield* service.toNotInitializedState({
|
||||
sessionProcessId: "process-1",
|
||||
rawUserMessage: "test message",
|
||||
});
|
||||
|
||||
const error = new Error("Test error");
|
||||
|
||||
const result = yield* service.toCompletedState({
|
||||
sessionProcessId: "process-1",
|
||||
error,
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TestLayer)),
|
||||
);
|
||||
|
||||
expect(result.task?.status).toBe("failed");
|
||||
if (result.task?.status === "failed") {
|
||||
expect(result.task.error).toBeInstanceOf(Error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTask", () => {
|
||||
it("can retrieve task by taskId", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const service = yield* ClaudeCodeSessionProcessService;
|
||||
|
||||
const sessionDef = createMockSessionProcessDef("process-1");
|
||||
const taskDef = createMockNewTaskDef("task-1");
|
||||
|
||||
yield* service.startSessionProcess({
|
||||
sessionDef,
|
||||
taskDef,
|
||||
});
|
||||
|
||||
const result = yield* service.getTask("task-1");
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TestLayer)),
|
||||
);
|
||||
|
||||
expect(result.task.def.taskId).toBe("task-1");
|
||||
expect(result.sessionProcess.def.sessionProcessId).toBe("process-1");
|
||||
});
|
||||
|
||||
it("fails with TaskNotFoundError for non-existent task", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const service = yield* ClaudeCodeSessionProcessService;
|
||||
|
||||
const result = yield* Effect.flip(
|
||||
service.getTask("non-existent-task"),
|
||||
);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const error = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TestLayer)),
|
||||
);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "TaskNotFoundError",
|
||||
taskId: "non-existent-task",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("state transitions flow", () => {
|
||||
it("can complete full lifecycle: pending -> not_initialized -> initialized -> file_created -> paused", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const service = yield* ClaudeCodeSessionProcessService;
|
||||
|
||||
const sessionDef = createMockSessionProcessDef("process-1");
|
||||
const taskDef = createMockNewTaskDef("task-1");
|
||||
|
||||
const startResult = yield* service.startSessionProcess({
|
||||
sessionDef,
|
||||
taskDef,
|
||||
});
|
||||
expect(startResult.sessionProcess.type).toBe("pending");
|
||||
|
||||
const notInitResult = yield* service.toNotInitializedState({
|
||||
sessionProcessId: "process-1",
|
||||
rawUserMessage: "test message",
|
||||
});
|
||||
expect(notInitResult.sessionProcess.type).toBe("not_initialized");
|
||||
|
||||
const initResult = yield* service.toInitializedState({
|
||||
sessionProcessId: "process-1",
|
||||
initContext: createMockInitContext("session-1"),
|
||||
});
|
||||
expect(initResult.sessionProcess.type).toBe("initialized");
|
||||
|
||||
const fileCreatedResult = yield* service.toFileCreatedState({
|
||||
sessionProcessId: "process-1",
|
||||
});
|
||||
expect(fileCreatedResult.sessionProcess.type).toBe("file_created");
|
||||
|
||||
const pausedResult = yield* service.toPausedState({
|
||||
sessionProcessId: "process-1",
|
||||
resultMessage: createMockResultMessage("session-1"),
|
||||
});
|
||||
expect(pausedResult.sessionProcess.type).toBe("paused");
|
||||
|
||||
return pausedResult;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TestLayer)),
|
||||
);
|
||||
|
||||
expect(result.sessionProcess.type).toBe("paused");
|
||||
expect(result.sessionProcess.sessionId).toBe("session-1");
|
||||
});
|
||||
|
||||
it("can continue paused process and complete another task", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const service = yield* ClaudeCodeSessionProcessService;
|
||||
|
||||
// First task lifecycle
|
||||
const sessionDef = createMockSessionProcessDef("process-1");
|
||||
const taskDef1 = createMockNewTaskDef("task-1");
|
||||
|
||||
yield* service.startSessionProcess({
|
||||
sessionDef,
|
||||
taskDef: taskDef1,
|
||||
});
|
||||
|
||||
yield* service.toNotInitializedState({
|
||||
sessionProcessId: "process-1",
|
||||
rawUserMessage: "test message 1",
|
||||
});
|
||||
|
||||
yield* service.toInitializedState({
|
||||
sessionProcessId: "process-1",
|
||||
initContext: createMockInitContext("session-1"),
|
||||
});
|
||||
|
||||
yield* service.toFileCreatedState({
|
||||
sessionProcessId: "process-1",
|
||||
});
|
||||
|
||||
yield* service.toPausedState({
|
||||
sessionProcessId: "process-1",
|
||||
resultMessage: createMockResultMessage("session-1"),
|
||||
});
|
||||
|
||||
// Continue with second task
|
||||
const taskDef2 = createMockContinueTaskDef("task-2", "session-1", "session-1");
|
||||
|
||||
const continueResult = yield* service.continueSessionProcess({
|
||||
sessionProcessId: "process-1",
|
||||
taskDef: taskDef2,
|
||||
});
|
||||
expect(continueResult.sessionProcess.type).toBe("pending");
|
||||
|
||||
yield* service.toNotInitializedState({
|
||||
sessionProcessId: "process-1",
|
||||
rawUserMessage: "test message 2",
|
||||
});
|
||||
|
||||
yield* service.toInitializedState({
|
||||
sessionProcessId: "process-1",
|
||||
initContext: createMockInitContext("session-1"),
|
||||
});
|
||||
|
||||
yield* service.toFileCreatedState({
|
||||
sessionProcessId: "process-1",
|
||||
});
|
||||
|
||||
const finalResult = yield* service.toPausedState({
|
||||
sessionProcessId: "process-1",
|
||||
resultMessage: createMockResultMessage("session-1"),
|
||||
});
|
||||
|
||||
return finalResult;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TestLayer)),
|
||||
);
|
||||
|
||||
expect(result.sessionProcess.type).toBe("paused");
|
||||
expect(result.sessionProcess.tasks).toHaveLength(2);
|
||||
expect(
|
||||
result.sessionProcess.tasks.filter((t) => t.status === "completed"),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -189,6 +189,7 @@ const LayerImpl = Effect.gen(function* () {
|
||||
const targetProcess = processes.find(
|
||||
(p) => p.def.sessionProcessId === sessionProcessId,
|
||||
);
|
||||
const currentStatus = targetProcess?.type;
|
||||
|
||||
const updatedProcesses = processes.map((p) =>
|
||||
p.def.sessionProcessId === sessionProcessId ? nextState : p,
|
||||
@@ -196,7 +197,7 @@ const LayerImpl = Effect.gen(function* () {
|
||||
|
||||
yield* Ref.set(processesRef, updatedProcesses);
|
||||
|
||||
if (targetProcess?.type !== nextState.type) {
|
||||
if (currentStatus !== nextState.type) {
|
||||
yield* eventBus.emit("sessionProcessChanged", {
|
||||
processes: updatedProcesses
|
||||
.filter(CCSessionProcess.isPublic)
|
||||
@@ -331,6 +332,40 @@ const LayerImpl = Effect.gen(function* () {
|
||||
});
|
||||
};
|
||||
|
||||
const toFileCreatedState = (options: { sessionProcessId: string }) => {
|
||||
const { sessionProcessId } = options;
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const currentProcess = yield* getSessionProcess(sessionProcessId);
|
||||
|
||||
if (currentProcess.type !== "initialized") {
|
||||
return yield* Effect.fail(
|
||||
new IllegalStateChangeError({
|
||||
from: currentProcess.type,
|
||||
to: "file_created",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const newProcess = yield* dangerouslyChangeProcessState({
|
||||
sessionProcessId,
|
||||
nextState: {
|
||||
type: "file_created",
|
||||
def: currentProcess.def,
|
||||
tasks: currentProcess.tasks,
|
||||
currentTask: currentProcess.currentTask,
|
||||
sessionId: currentProcess.sessionId,
|
||||
rawUserMessage: currentProcess.rawUserMessage,
|
||||
initContext: currentProcess.initContext,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
sessionProcess: newProcess,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const toPausedState = (options: {
|
||||
sessionProcessId: string;
|
||||
resultMessage: SDKResultMessage;
|
||||
@@ -339,7 +374,7 @@ const LayerImpl = Effect.gen(function* () {
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const currentProcess = yield* getSessionProcess(sessionProcessId);
|
||||
if (currentProcess.type !== "initialized") {
|
||||
if (currentProcess.type !== "file_created") {
|
||||
return yield* Effect.fail(
|
||||
new IllegalStateChangeError({
|
||||
from: currentProcess.type,
|
||||
@@ -387,7 +422,8 @@ const LayerImpl = Effect.gen(function* () {
|
||||
|
||||
const currentTask =
|
||||
currentProcess.type === "not_initialized" ||
|
||||
currentProcess.type === "initialized"
|
||||
currentProcess.type === "initialized" ||
|
||||
currentProcess.type === "file_created"
|
||||
? currentProcess.currentTask
|
||||
: undefined;
|
||||
|
||||
@@ -442,6 +478,7 @@ const LayerImpl = Effect.gen(function* () {
|
||||
continueSessionProcess,
|
||||
toNotInitializedState,
|
||||
toInitializedState,
|
||||
toFileCreatedState,
|
||||
toPausedState,
|
||||
toCompletedState,
|
||||
dangerouslyChangeProcessState,
|
||||
|
||||
@@ -39,6 +39,14 @@ export type CCSessionProcessInitializedState = CCSessionProcessStateBase & {
|
||||
initContext: InitMessageContext;
|
||||
};
|
||||
|
||||
export type CCSessionProcessFileCreatedState = CCSessionProcessStateBase & {
|
||||
type: "file_created" /* ファイルが作成された状態 */;
|
||||
sessionId: string;
|
||||
currentTask: CCTask.RunningClaudeCodeTaskState;
|
||||
rawUserMessage: string;
|
||||
initContext: InitMessageContext;
|
||||
};
|
||||
|
||||
export type CCSessionProcessPausedState = CCSessionProcessStateBase & {
|
||||
type: "paused" /* タスクが完了し、次のタスクを受け付け可能 */;
|
||||
sessionId: string;
|
||||
@@ -51,6 +59,7 @@ export type CCSessionProcessCompletedState = CCSessionProcessStateBase & {
|
||||
|
||||
export type CCSessionProcessStatePublic =
|
||||
| CCSessionProcessInitializedState
|
||||
| CCSessionProcessFileCreatedState
|
||||
| CCSessionProcessPausedState;
|
||||
|
||||
export type CCSessionProcessState =
|
||||
@@ -62,7 +71,11 @@ export type CCSessionProcessState =
|
||||
export const isPublic = (
|
||||
process: CCSessionProcessState,
|
||||
): process is CCSessionProcessStatePublic => {
|
||||
return process.type === "initialized" || process.type === "paused";
|
||||
return (
|
||||
process.type === "initialized" ||
|
||||
process.type === "file_created" ||
|
||||
process.type === "paused"
|
||||
);
|
||||
};
|
||||
|
||||
export const getAliveTasks = (
|
||||
|
||||
@@ -28,6 +28,9 @@ export class FileWatcherService extends Context.Tag("FileWatcherService")<
|
||||
const projectWatchersRef = yield* Ref.make<Map<string, FSWatcher>>(
|
||||
new Map(),
|
||||
);
|
||||
const debounceTimersRef = yield* Ref.make<
|
||||
Map<string, ReturnType<typeof setTimeout>>
|
||||
>(new Map());
|
||||
|
||||
const startWatching = (): Effect.Effect<void> =>
|
||||
Effect.gen(function* () {
|
||||
@@ -52,17 +55,42 @@ export class FileWatcherService extends Context.Tag("FileWatcherService")<
|
||||
if (!groups.success) return;
|
||||
|
||||
const { projectId, sessionId } = groups.data;
|
||||
const debounceKey = `${projectId}/${sessionId}`;
|
||||
|
||||
Effect.runFork(
|
||||
eventBus.emit("sessionChanged", {
|
||||
projectId,
|
||||
sessionId,
|
||||
}),
|
||||
);
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const timers = yield* Ref.get(debounceTimersRef);
|
||||
const existingTimer = timers.get(debounceKey);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
Effect.runFork(
|
||||
eventBus.emit("sessionListChanged", {
|
||||
projectId,
|
||||
const newTimer = setTimeout(() => {
|
||||
Effect.runFork(
|
||||
eventBus.emit("sessionChanged", {
|
||||
projectId,
|
||||
sessionId,
|
||||
}),
|
||||
);
|
||||
|
||||
Effect.runFork(
|
||||
eventBus.emit("sessionListChanged", {
|
||||
projectId,
|
||||
}),
|
||||
);
|
||||
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const currentTimers =
|
||||
yield* Ref.get(debounceTimersRef);
|
||||
currentTimers.delete(debounceKey);
|
||||
yield* Ref.set(debounceTimersRef, currentTimers);
|
||||
}),
|
||||
);
|
||||
}, 300);
|
||||
|
||||
timers.set(debounceKey, newTimer);
|
||||
yield* Ref.set(debounceTimersRef, timers);
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -85,6 +113,12 @@ export class FileWatcherService extends Context.Tag("FileWatcherService")<
|
||||
|
||||
const stop = (): Effect.Effect<void> =>
|
||||
Effect.gen(function* () {
|
||||
const timers = yield* Ref.get(debounceTimersRef);
|
||||
for (const [, timer] of timers) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
yield* Ref.set(debounceTimersRef, new Map());
|
||||
|
||||
const watcher = yield* Ref.get(watcherRef);
|
||||
if (watcher) {
|
||||
yield* Effect.sync(() => watcher.close());
|
||||
|
||||
369
src/server/service/git/getBranches.test.ts
Normal file
369
src/server/service/git/getBranches.test.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { branchExists, getBranches, getCurrentBranch } from "./getBranches";
|
||||
import * as utils from "./utils";
|
||||
|
||||
vi.mock("./utils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof utils>();
|
||||
return {
|
||||
...actual,
|
||||
executeGitCommand: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("getBranches", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("正常系", () => {
|
||||
it("ブランチ一覧を取得できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `* main abc1234 [origin/main: ahead 1] Latest commit
|
||||
remotes/origin/main abc1234 Latest commit
|
||||
feature def5678 [origin/feature] Feature commit`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getBranches(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
|
||||
expect(result.data[0]).toEqual({
|
||||
name: "main",
|
||||
current: true,
|
||||
remote: "origin/main",
|
||||
commit: "abc1234",
|
||||
ahead: 1,
|
||||
behind: undefined,
|
||||
});
|
||||
|
||||
expect(result.data[1]).toEqual({
|
||||
name: "feature",
|
||||
current: false,
|
||||
remote: "origin/feature",
|
||||
commit: "def5678",
|
||||
ahead: undefined,
|
||||
behind: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
expect(utils.executeGitCommand).toHaveBeenCalledWith(
|
||||
["branch", "-vv", "--all"],
|
||||
mockCwd,
|
||||
);
|
||||
});
|
||||
|
||||
it("ahead/behindの両方を持つブランチを処理できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput =
|
||||
"* main abc1234 [origin/main: ahead 2, behind 3] Commit message";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getBranches(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0]).toEqual({
|
||||
name: "main",
|
||||
current: true,
|
||||
remote: "origin/main",
|
||||
commit: "abc1234",
|
||||
ahead: 2,
|
||||
behind: 3,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("リモートトラッキングブランチを除外する", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `* main abc1234 [origin/main] Latest commit
|
||||
remotes/origin/main abc1234 Latest commit
|
||||
feature def5678 Feature commit
|
||||
remotes/origin/feature def5678 Feature commit`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getBranches(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0]?.name).toBe("main");
|
||||
expect(result.data[1]?.name).toBe("feature");
|
||||
}
|
||||
});
|
||||
|
||||
it("空の結果を返す(ブランチがない場合)", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = "";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getBranches(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("不正な形式の行をスキップする", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `* main abc1234 [origin/main] Latest commit
|
||||
invalid line
|
||||
feature def5678 Feature commit`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getBranches(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0]?.name).toBe("main");
|
||||
expect(result.data[1]?.name).toBe("feature");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("エラー系", () => {
|
||||
it("ディレクトリが存在しない場合", async () => {
|
||||
const mockCwd = "/nonexistent/repo";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "NOT_A_REPOSITORY",
|
||||
message: `Directory does not exist: ${mockCwd}`,
|
||||
command: "git branch -vv --all",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getBranches(mockCwd);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("NOT_A_REPOSITORY");
|
||||
expect(result.error.message).toContain("Directory does not exist");
|
||||
}
|
||||
});
|
||||
|
||||
it("Gitリポジトリでない場合", async () => {
|
||||
const mockCwd = "/test/not-a-repo";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "NOT_A_REPOSITORY",
|
||||
message: `Not a git repository: ${mockCwd}`,
|
||||
command: "git branch -vv --all",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getBranches(mockCwd);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("NOT_A_REPOSITORY");
|
||||
expect(result.error.message).toContain("Not a git repository");
|
||||
}
|
||||
});
|
||||
|
||||
it("Gitコマンドが失敗した場合", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "COMMAND_FAILED",
|
||||
message: "Command failed",
|
||||
command: "git branch",
|
||||
stderr: "fatal: not a git repository",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getBranches(mockCwd);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("COMMAND_FAILED");
|
||||
expect(result.error.message).toBe("Command failed");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("エッジケース", () => {
|
||||
it("特殊文字を含むブランチ名を処理できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `* feature/special-chars_123 abc1234 Commit
|
||||
feature/日本語ブランチ def5678 日本語コミット`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getBranches(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0]?.name).toBe("feature/special-chars_123");
|
||||
expect(result.data[1]?.name).toBe("feature/日本語ブランチ");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCurrentBranch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("現在のブランチ名を取得できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = "main\n";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getCurrentBranch(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toBe("main");
|
||||
}
|
||||
|
||||
expect(utils.executeGitCommand).toHaveBeenCalledWith(
|
||||
["branch", "--show-current"],
|
||||
mockCwd,
|
||||
);
|
||||
});
|
||||
|
||||
it("detached HEAD状態の場合はエラーを返す", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = "";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getCurrentBranch(mockCwd);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("COMMAND_FAILED");
|
||||
expect(result.error.message).toContain("detached HEAD");
|
||||
}
|
||||
});
|
||||
|
||||
it("Gitリポジトリでない場合", async () => {
|
||||
const mockCwd = "/test/not-a-repo";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "NOT_A_REPOSITORY",
|
||||
message: `Not a git repository: ${mockCwd}`,
|
||||
command: "git branch --show-current",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getCurrentBranch(mockCwd);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("NOT_A_REPOSITORY");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("branchExists", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("ブランチが存在する場合trueを返す", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: "abc1234\n",
|
||||
});
|
||||
|
||||
const result = await branchExists(mockCwd, "main");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toBe(true);
|
||||
}
|
||||
|
||||
expect(utils.executeGitCommand).toHaveBeenCalledWith(
|
||||
["rev-parse", "--verify", "main"],
|
||||
mockCwd,
|
||||
);
|
||||
});
|
||||
|
||||
it("ブランチが存在しない場合falseを返す", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "COMMAND_FAILED",
|
||||
message: "Command failed",
|
||||
command: "git rev-parse --verify nonexistent",
|
||||
stderr: "fatal: Needed a single revision",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await branchExists(mockCwd, "nonexistent");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("Gitリポジトリでない場合", async () => {
|
||||
const mockCwd = "/test/not-a-repo";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "NOT_A_REPOSITORY",
|
||||
message: `Not a git repository: ${mockCwd}`,
|
||||
command: "git rev-parse --verify main",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await branchExists(mockCwd, "main");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
250
src/server/service/git/getCommits.test.ts
Normal file
250
src/server/service/git/getCommits.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getCommits } from "./getCommits";
|
||||
import * as utils from "./utils";
|
||||
|
||||
vi.mock("./utils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof utils>();
|
||||
return {
|
||||
...actual,
|
||||
executeGitCommand: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("getCommits", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("正常系", () => {
|
||||
it("コミット一覧を取得できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `abc123|feat: add new feature|John Doe|2024-01-15 10:30:00 +0900
|
||||
def456|fix: bug fix|Jane Smith|2024-01-14 09:20:00 +0900
|
||||
ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`;
|
||||
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getCommits(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(3);
|
||||
expect(result.data[0]).toEqual({
|
||||
sha: "abc123",
|
||||
message: "feat: add new feature",
|
||||
author: "John Doe",
|
||||
date: "2024-01-15 10:30:00 +0900",
|
||||
});
|
||||
expect(result.data[1]).toEqual({
|
||||
sha: "def456",
|
||||
message: "fix: bug fix",
|
||||
author: "Jane Smith",
|
||||
date: "2024-01-14 09:20:00 +0900",
|
||||
});
|
||||
expect(result.data[2]).toEqual({
|
||||
sha: "ghi789",
|
||||
message: "chore: update deps",
|
||||
author: "Bob Johnson",
|
||||
date: "2024-01-13 08:10:00 +0900",
|
||||
});
|
||||
}
|
||||
|
||||
expect(utils.executeGitCommand).toHaveBeenCalledWith(
|
||||
[
|
||||
"log",
|
||||
"--oneline",
|
||||
"-n",
|
||||
"20",
|
||||
"--format=%H|%s|%an|%ad",
|
||||
"--date=iso",
|
||||
],
|
||||
mockCwd,
|
||||
);
|
||||
});
|
||||
|
||||
it("空の結果を返す(コミットがない場合)", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = "";
|
||||
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getCommits(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("不正な形式の行をスキップする", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `abc123|feat: add new feature|John Doe|2024-01-15 10:30:00 +0900
|
||||
invalid line without enough pipes
|
||||
def456|fix: bug fix|Jane Smith|2024-01-14 09:20:00 +0900
|
||||
||missing data|
|
||||
ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`;
|
||||
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getCommits(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(3);
|
||||
expect(result.data[0]?.sha).toBe("abc123");
|
||||
expect(result.data[1]?.sha).toBe("def456");
|
||||
expect(result.data[2]?.sha).toBe("ghi789");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("エラー系", () => {
|
||||
it("ディレクトリが存在しない場合", async () => {
|
||||
const mockCwd = "/nonexistent/repo";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "NOT_A_REPOSITORY",
|
||||
message: `Directory does not exist: ${mockCwd}`,
|
||||
command: "git log --oneline -n 20 --format=%H|%s|%an|%ad --date=iso",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getCommits(mockCwd);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("NOT_A_REPOSITORY");
|
||||
expect(result.error.message).toContain("Directory does not exist");
|
||||
}
|
||||
});
|
||||
|
||||
it("Gitリポジトリでない場合", async () => {
|
||||
const mockCwd = "/test/not-a-repo";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "NOT_A_REPOSITORY",
|
||||
message: `Not a git repository: ${mockCwd}`,
|
||||
command: "git log --oneline -n 20 --format=%H|%s|%an|%ad --date=iso",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getCommits(mockCwd);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("NOT_A_REPOSITORY");
|
||||
expect(result.error.message).toContain("Not a git repository");
|
||||
}
|
||||
});
|
||||
|
||||
it("Gitコマンドが失敗した場合", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "COMMAND_FAILED",
|
||||
message: "Command failed",
|
||||
command: "git log",
|
||||
stderr:
|
||||
"fatal: your current branch 'main' does not have any commits yet",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getCommits(mockCwd);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("COMMAND_FAILED");
|
||||
expect(result.error.message).toBe("Command failed");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("エッジケース", () => {
|
||||
it("特殊文字を含むコミットメッセージを処理できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `abc123|feat: add "quotes" & <special> chars|Author Name|2024-01-15 10:30:00 +0900
|
||||
def456|fix: 日本語メッセージ|日本語 著者|2024-01-14 09:20:00 +0900`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getCommits(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0]?.message).toBe(
|
||||
'feat: add "quotes" & <special> chars',
|
||||
);
|
||||
expect(result.data[1]?.message).toBe("fix: 日本語メッセージ");
|
||||
expect(result.data[1]?.author).toBe("日本語 著者");
|
||||
}
|
||||
});
|
||||
|
||||
it("空白を含むパスでも正常に動作する", async () => {
|
||||
const mockCwd = "/test/my repo with spaces";
|
||||
const mockOutput = `abc123|feat: test|Author|2024-01-15 10:30:00 +0900`;
|
||||
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getCommits(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(1);
|
||||
}
|
||||
|
||||
expect(utils.executeGitCommand).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
mockCwd,
|
||||
);
|
||||
});
|
||||
|
||||
it("空行やスペースのみの行をスキップする", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `abc123|feat: add feature|Author|2024-01-15 10:30:00 +0900
|
||||
|
||||
|
||||
def456|fix: bug|Author|2024-01-14 09:20:00 +0900
|
||||
`;
|
||||
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getCommits(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
521
src/server/service/git/getDiff.test.ts
Normal file
521
src/server/service/git/getDiff.test.ts
Normal file
@@ -0,0 +1,521 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { compareBranches, getDiff } from "./getDiff";
|
||||
import * as utils from "./utils";
|
||||
|
||||
vi.mock("./utils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof utils>();
|
||||
return {
|
||||
...actual,
|
||||
executeGitCommand: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:fs/promises");
|
||||
|
||||
describe("getDiff", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("正常系", () => {
|
||||
it("2つのブランチ間のdiffを取得できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const fromRef = "base:main";
|
||||
const toRef = "compare:feature";
|
||||
|
||||
const mockNumstatOutput = `5\t2\tsrc/file1.ts
|
||||
10\t0\tsrc/file2.ts`;
|
||||
|
||||
const mockDiffOutput = `diff --git a/src/file1.ts b/src/file1.ts
|
||||
index abc123..def456 100644
|
||||
--- a/src/file1.ts
|
||||
+++ b/src/file1.ts
|
||||
@@ -1,5 +1,8 @@
|
||||
function hello() {
|
||||
- console.log("old");
|
||||
+ console.log("new");
|
||||
+ console.log("added line 1");
|
||||
+ console.log("added line 2");
|
||||
}
|
||||
diff --git a/src/file2.ts b/src/file2.ts
|
||||
new file mode 100644
|
||||
index 0000000..ghi789
|
||||
--- /dev/null
|
||||
+++ b/src/file2.ts
|
||||
@@ -0,0 +1,10 @@
|
||||
+export const newFile = true;`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand)
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockNumstatOutput,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockDiffOutput,
|
||||
});
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.files).toHaveLength(2);
|
||||
expect(result.data.files[0]?.filePath).toBe("src/file1.ts");
|
||||
expect(result.data.files[0]?.status).toBe("modified");
|
||||
expect(result.data.files[0]?.additions).toBe(5);
|
||||
expect(result.data.files[0]?.deletions).toBe(2);
|
||||
|
||||
expect(result.data.files[1]?.filePath).toBe("src/file2.ts");
|
||||
expect(result.data.files[1]?.status).toBe("added");
|
||||
expect(result.data.files[1]?.additions).toBe(10);
|
||||
expect(result.data.files[1]?.deletions).toBe(0);
|
||||
|
||||
expect(result.data.summary.totalFiles).toBe(2);
|
||||
expect(result.data.summary.totalAdditions).toBe(15);
|
||||
expect(result.data.summary.totalDeletions).toBe(2);
|
||||
}
|
||||
|
||||
expect(utils.executeGitCommand).toHaveBeenCalledWith(
|
||||
["diff", "--numstat", "main", "feature"],
|
||||
mockCwd,
|
||||
);
|
||||
|
||||
expect(utils.executeGitCommand).toHaveBeenCalledWith(
|
||||
["diff", "--unified=5", "main", "feature"],
|
||||
mockCwd,
|
||||
);
|
||||
});
|
||||
|
||||
it("HEADとworking directoryの比較ができる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const fromRef = "base:HEAD";
|
||||
const toRef = "compare:working";
|
||||
|
||||
const mockNumstatOutput = `3\t1\tsrc/modified.ts`;
|
||||
const mockDiffOutput = `diff --git a/src/modified.ts b/src/modified.ts
|
||||
index abc123..def456 100644
|
||||
--- a/src/modified.ts
|
||||
+++ b/src/modified.ts
|
||||
@@ -1,3 +1,5 @@
|
||||
const value = 1;`;
|
||||
|
||||
const mockStatusOutput = `## main
|
||||
M src/modified.ts
|
||||
?? src/untracked.ts`;
|
||||
|
||||
vi.mocked(readFile).mockResolvedValue("line1\nline2\nline3");
|
||||
|
||||
vi.mocked(utils.executeGitCommand)
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockNumstatOutput,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockDiffOutput,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockStatusOutput,
|
||||
});
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
// modified file + untracked file
|
||||
expect(result.data.files.length).toBeGreaterThanOrEqual(1);
|
||||
const modifiedFile = result.data.files.find(
|
||||
(f) => f.filePath === "src/modified.ts",
|
||||
);
|
||||
expect(modifiedFile).toBeDefined();
|
||||
expect(modifiedFile?.status).toBe("modified");
|
||||
}
|
||||
});
|
||||
|
||||
it("同一refの場合は空の結果を返す", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const fromRef = "base:main";
|
||||
const toRef = "compare:main";
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.files).toHaveLength(0);
|
||||
expect(result.data.diffs).toHaveLength(0);
|
||||
expect(result.data.summary.totalFiles).toBe(0);
|
||||
expect(result.data.summary.totalAdditions).toBe(0);
|
||||
expect(result.data.summary.totalDeletions).toBe(0);
|
||||
}
|
||||
|
||||
expect(utils.executeGitCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.skip("削除されたファイルを処理できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const fromRef = "base:main";
|
||||
const toRef = "compare:feature";
|
||||
|
||||
const mockNumstatOutput = `0\t10\tsrc/deleted.ts`;
|
||||
const mockDiffOutput = `diff --git a/src/deleted.ts b/src/deleted.ts
|
||||
deleted file mode 100644
|
||||
index abc123..0000000 100644
|
||||
--- a/src/deleted.ts
|
||||
+++ /dev/null
|
||||
@@ -1,10 +0,0 @@
|
||||
-deleted line 1
|
||||
-deleted line 2
|
||||
-deleted line 3
|
||||
-deleted line 4
|
||||
-deleted line 5
|
||||
-deleted line 6
|
||||
-deleted line 7
|
||||
-deleted line 8
|
||||
-deleted line 9
|
||||
-deleted line 10`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand)
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockNumstatOutput,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockDiffOutput,
|
||||
});
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.files).toHaveLength(1);
|
||||
expect(result.data.files[0]?.filePath).toBe("src/deleted.ts");
|
||||
expect(result.data.files[0]?.status).toBe("deleted");
|
||||
expect(result.data.files[0]?.additions).toBe(0);
|
||||
expect(result.data.files[0]?.deletions).toBe(10);
|
||||
}
|
||||
});
|
||||
|
||||
it.skip("名前変更されたファイルを処理できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const fromRef = "base:main";
|
||||
const toRef = "compare:feature";
|
||||
|
||||
const mockNumstatOutput = `0\t0\tnew-name.ts`;
|
||||
const mockDiffOutput = `diff --git a/old-name.ts b/new-name.ts
|
||||
similarity index 100%
|
||||
rename from old-name.ts
|
||||
rename to new-name.ts
|
||||
index abc123..abc123 100644`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand)
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockNumstatOutput,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockDiffOutput,
|
||||
});
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.files).toHaveLength(1);
|
||||
expect(result.data.files[0]?.status).toBe("renamed");
|
||||
expect(result.data.files[0]?.filePath).toBe("new-name.ts");
|
||||
expect(result.data.files[0]?.oldPath).toBe("old-name.ts");
|
||||
}
|
||||
});
|
||||
|
||||
it("空のdiffを処理できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const fromRef = "base:main";
|
||||
const toRef = "compare:feature";
|
||||
|
||||
const mockNumstatOutput = "";
|
||||
const mockDiffOutput = "";
|
||||
|
||||
vi.mocked(utils.executeGitCommand)
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockNumstatOutput,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockDiffOutput,
|
||||
});
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.files).toHaveLength(0);
|
||||
expect(result.data.diffs).toHaveLength(0);
|
||||
expect(result.data.summary.totalFiles).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("エラー系", () => {
|
||||
it("ディレクトリが存在しない場合", async () => {
|
||||
const mockCwd = "/nonexistent/repo";
|
||||
const fromRef = "base:main";
|
||||
const toRef = "compare:feature";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "NOT_A_REPOSITORY",
|
||||
message: `Directory does not exist: ${mockCwd}`,
|
||||
command: "git diff --numstat main feature",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("NOT_A_REPOSITORY");
|
||||
expect(result.error.message).toContain("Directory does not exist");
|
||||
}
|
||||
});
|
||||
|
||||
it("Gitリポジトリでない場合", async () => {
|
||||
const mockCwd = "/test/not-a-repo";
|
||||
const fromRef = "base:main";
|
||||
const toRef = "compare:feature";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "NOT_A_REPOSITORY",
|
||||
message: `Not a git repository: ${mockCwd}`,
|
||||
command: "git diff --numstat main feature",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("NOT_A_REPOSITORY");
|
||||
expect(result.error.message).toContain("Not a git repository");
|
||||
}
|
||||
});
|
||||
|
||||
it("ブランチが見つからない場合", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const fromRef = "base:nonexistent";
|
||||
const toRef = "compare:feature";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "BRANCH_NOT_FOUND",
|
||||
message: "Branch or commit not found",
|
||||
command: "git diff --numstat nonexistent feature",
|
||||
stderr:
|
||||
"fatal: ambiguous argument 'nonexistent': unknown revision or path not in the working tree.",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("BRANCH_NOT_FOUND");
|
||||
expect(result.error.message).toBe("Branch or commit not found");
|
||||
}
|
||||
});
|
||||
|
||||
it("numstatコマンドが失敗した場合", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const fromRef = "base:main";
|
||||
const toRef = "compare:feature";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "COMMAND_FAILED",
|
||||
message: "Command failed",
|
||||
command: "git diff --numstat main feature",
|
||||
stderr: "fatal: bad revision",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("COMMAND_FAILED");
|
||||
}
|
||||
});
|
||||
|
||||
it("無効なfromRefの場合", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const fromRef = "invalidref";
|
||||
const toRef = "compare:feature";
|
||||
|
||||
await expect(getDiff(mockCwd, fromRef, toRef)).rejects.toThrow(
|
||||
"Invalid ref text",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("エッジケース", () => {
|
||||
it("特殊文字を含むファイル名を処理できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const fromRef = "base:main";
|
||||
const toRef = "compare:feature";
|
||||
|
||||
const mockNumstatOutput = `5\t2\tsrc/file with spaces.ts
|
||||
3\t1\tsrc/日本語ファイル.ts`;
|
||||
|
||||
const mockDiffOutput = `diff --git a/src/file with spaces.ts b/src/file with spaces.ts
|
||||
index abc123..def456 100644
|
||||
--- a/src/file with spaces.ts
|
||||
+++ b/src/file with spaces.ts
|
||||
@@ -1,3 +1,5 @@
|
||||
content
|
||||
diff --git a/src/日本語ファイル.ts b/src/日本語ファイル.ts
|
||||
index abc123..def456 100644
|
||||
--- a/src/日本語ファイル.ts
|
||||
+++ b/src/日本語ファイル.ts
|
||||
@@ -1,2 +1,3 @@
|
||||
content`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand)
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockNumstatOutput,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockDiffOutput,
|
||||
});
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.files).toHaveLength(2);
|
||||
expect(result.data.files[0]?.filePath).toBe("src/file with spaces.ts");
|
||||
expect(result.data.files[1]?.filePath).toBe("src/日本語ファイル.ts");
|
||||
}
|
||||
});
|
||||
|
||||
it("バイナリファイルの変更を処理できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const fromRef = "base:main";
|
||||
const toRef = "compare:feature";
|
||||
|
||||
const mockNumstatOutput = `-\t-\timage.png`;
|
||||
const mockDiffOutput = `diff --git a/image.png b/image.png
|
||||
index abc123..def456 100644
|
||||
Binary files a/image.png and b/image.png differ`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand)
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockNumstatOutput,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockDiffOutput,
|
||||
});
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.files).toHaveLength(1);
|
||||
expect(result.data.files[0]?.filePath).toBe("image.png");
|
||||
expect(result.data.files[0]?.additions).toBe(0);
|
||||
expect(result.data.files[0]?.deletions).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("大量のファイル変更を処理できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const fromRef = "base:main";
|
||||
const toRef = "compare:feature";
|
||||
|
||||
const mockNumstatOutput = Array.from(
|
||||
{ length: 100 },
|
||||
(_, i) => `1\t1\tfile${i}.ts`,
|
||||
).join("\n");
|
||||
const mockDiffOutput = Array.from(
|
||||
{ length: 100 },
|
||||
(_, i) => `diff --git a/file${i}.ts b/file${i}.ts
|
||||
index abc123..def456 100644
|
||||
--- a/file${i}.ts
|
||||
+++ b/file${i}.ts
|
||||
@@ -1 +1 @@
|
||||
-old
|
||||
+new`,
|
||||
).join("\n");
|
||||
|
||||
vi.mocked(utils.executeGitCommand)
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockNumstatOutput,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockDiffOutput,
|
||||
});
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.files).toHaveLength(100);
|
||||
expect(result.data.summary.totalFiles).toBe(100);
|
||||
expect(result.data.summary.totalAdditions).toBe(100);
|
||||
expect(result.data.summary.totalDeletions).toBe(100);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareBranches", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("getDiffのショートハンドとして機能する", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const baseBranch = "base:main";
|
||||
const targetBranch = "compare:feature";
|
||||
|
||||
const mockNumstatOutput = `5\t2\tfile.ts`;
|
||||
const mockDiffOutput = `diff --git a/file.ts b/file.ts
|
||||
index abc123..def456 100644
|
||||
--- a/file.ts
|
||||
+++ b/file.ts
|
||||
@@ -1,2 +1,5 @@
|
||||
content`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand)
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockNumstatOutput,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockDiffOutput,
|
||||
});
|
||||
|
||||
const result = await compareBranches(mockCwd, baseBranch, targetBranch);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.files).toHaveLength(1);
|
||||
expect(result.data.files[0]?.filePath).toBe("file.ts");
|
||||
}
|
||||
});
|
||||
});
|
||||
351
src/server/service/git/getStatus.test.ts
Normal file
351
src/server/service/git/getStatus.test.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getStatus,
|
||||
getUncommittedChanges,
|
||||
isWorkingDirectoryClean,
|
||||
} from "./getStatus";
|
||||
import * as utils from "./utils";
|
||||
|
||||
vi.mock("./utils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof utils>();
|
||||
return {
|
||||
...actual,
|
||||
executeGitCommand: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("getStatus", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("正常系", () => {
|
||||
it("Gitステータス情報を取得できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `## main...origin/main [ahead 2, behind 1]
|
||||
M staged-modified.ts
|
||||
M unstaged-modified.ts
|
||||
A staged-added.ts
|
||||
?? untracked-file.ts`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getStatus(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.branch).toBe("main");
|
||||
expect(result.data.ahead).toBe(2);
|
||||
expect(result.data.behind).toBe(1);
|
||||
|
||||
expect(result.data.staged).toHaveLength(2);
|
||||
expect(result.data.staged[0]?.filePath).toBe("staged-modified.ts");
|
||||
expect(result.data.staged[0]?.status).toBe("modified");
|
||||
expect(result.data.staged[1]?.filePath).toBe("staged-added.ts");
|
||||
expect(result.data.staged[1]?.status).toBe("added");
|
||||
|
||||
expect(result.data.unstaged).toHaveLength(1);
|
||||
expect(result.data.unstaged[0]?.filePath).toBe("unstaged-modified.ts");
|
||||
expect(result.data.unstaged[0]?.status).toBe("modified");
|
||||
|
||||
expect(result.data.untracked).toEqual(["untracked-file.ts"]);
|
||||
expect(result.data.conflicted).toHaveLength(0);
|
||||
}
|
||||
|
||||
expect(utils.executeGitCommand).toHaveBeenCalledWith(
|
||||
["status", "--porcelain=v1", "-b"],
|
||||
mockCwd,
|
||||
);
|
||||
});
|
||||
|
||||
it("名前変更されたファイルを処理できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `## main
|
||||
R old-name.ts -> new-name.ts`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getStatus(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.staged).toHaveLength(1);
|
||||
expect(result.data.staged[0]?.filePath).toBe("new-name.ts");
|
||||
expect(result.data.staged[0]?.oldPath).toBe("old-name.ts");
|
||||
expect(result.data.staged[0]?.status).toBe("renamed");
|
||||
}
|
||||
});
|
||||
|
||||
it("コンフリクトファイルを検出できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `## main
|
||||
UU conflicted-file.ts
|
||||
MM both-modified.ts`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getStatus(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.conflicted).toEqual([
|
||||
"conflicted-file.ts",
|
||||
"both-modified.ts",
|
||||
]);
|
||||
expect(result.data.staged).toHaveLength(0);
|
||||
expect(result.data.unstaged).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("空のリポジトリ(クリーンな状態)を処理できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = "## main";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getStatus(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.branch).toBe("main");
|
||||
expect(result.data.ahead).toBe(0);
|
||||
expect(result.data.behind).toBe(0);
|
||||
expect(result.data.staged).toHaveLength(0);
|
||||
expect(result.data.unstaged).toHaveLength(0);
|
||||
expect(result.data.untracked).toHaveLength(0);
|
||||
expect(result.data.conflicted).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("ブランチがupstreamを持たない場合", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `## feature-branch
|
||||
M file.ts`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getStatus(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.branch).toBe("feature-branch");
|
||||
expect(result.data.ahead).toBe(0);
|
||||
expect(result.data.behind).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("エラー系", () => {
|
||||
it("ディレクトリが存在しない場合", async () => {
|
||||
const mockCwd = "/nonexistent/repo";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "NOT_A_REPOSITORY",
|
||||
message: `Directory does not exist: ${mockCwd}`,
|
||||
command: "git status --porcelain=v1 -b",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getStatus(mockCwd);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("NOT_A_REPOSITORY");
|
||||
expect(result.error.message).toContain("Directory does not exist");
|
||||
}
|
||||
});
|
||||
|
||||
it("Gitリポジトリでない場合", async () => {
|
||||
const mockCwd = "/test/not-a-repo";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "NOT_A_REPOSITORY",
|
||||
message: `Not a git repository: ${mockCwd}`,
|
||||
command: "git status --porcelain=v1 -b",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getStatus(mockCwd);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("NOT_A_REPOSITORY");
|
||||
expect(result.error.message).toContain("Not a git repository");
|
||||
}
|
||||
});
|
||||
|
||||
it("Gitコマンドが失敗した場合", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "COMMAND_FAILED",
|
||||
message: "Command failed",
|
||||
command: "git status",
|
||||
stderr: "fatal: not a git repository",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getStatus(mockCwd);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("COMMAND_FAILED");
|
||||
expect(result.error.message).toBe("Command failed");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("エッジケース", () => {
|
||||
it("特殊文字を含むファイル名を処理できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `## main
|
||||
M file with spaces.ts
|
||||
A 日本語ファイル.ts
|
||||
?? special@#$%chars.ts`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getStatus(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.staged[0]?.filePath).toBe("file with spaces.ts");
|
||||
expect(result.data.staged[1]?.filePath).toBe("日本語ファイル.ts");
|
||||
expect(result.data.untracked).toEqual(["special@#$%chars.ts"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUncommittedChanges", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("stagedとunstagedの両方の変更を取得できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `## main
|
||||
M staged-file.ts
|
||||
M unstaged-file.ts`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getUncommittedChanges(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data.some((f) => f.filePath === "staged-file.ts")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(result.data.some((f) => f.filePath === "unstaged-file.ts")).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("重複するファイルを削除する", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `## main
|
||||
MM both-changed.ts`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getUncommittedChanges(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
// Conflictとして処理されるため空になる
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWorkingDirectoryClean", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("クリーンな作業ディレクトリでtrueを返す", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = "## main";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await isWorkingDirectoryClean(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("変更がある場合falseを返す", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `## main
|
||||
M modified-file.ts`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await isWorkingDirectoryClean(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("未追跡ファイルがある場合falseを返す", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `## main
|
||||
?? untracked-file.ts`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await isWorkingDirectoryClean(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
301
src/server/service/parseCommandXml.test.ts
Normal file
301
src/server/service/parseCommandXml.test.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { parseCommandXml } from "./parseCommandXml";
|
||||
|
||||
describe("parseCommandXml", () => {
|
||||
describe("command parsing", () => {
|
||||
it("parses command-name only", () => {
|
||||
const input = "<command-name>git status</command-name>";
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
commandName: "git status",
|
||||
commandArgs: undefined,
|
||||
commandMessage: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses command-name with command-args", () => {
|
||||
const input =
|
||||
"<command-name>git commit</command-name><command-args>-m 'test'</command-args>";
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
commandName: "git commit",
|
||||
commandArgs: "-m 'test'",
|
||||
commandMessage: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses command-name with command-message", () => {
|
||||
const input =
|
||||
"<command-name>ls</command-name><command-message>Listing files</command-message>";
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
commandName: "ls",
|
||||
commandArgs: undefined,
|
||||
commandMessage: "Listing files",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses all command tags together", () => {
|
||||
const input =
|
||||
"<command-name>npm install</command-name><command-args>--save-dev vitest</command-args><command-message>Installing test dependencies</command-message>";
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
commandName: "npm install",
|
||||
commandArgs: "--save-dev vitest",
|
||||
commandMessage: "Installing test dependencies",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses command tags with whitespace in content", () => {
|
||||
const input =
|
||||
"<command-name>\n git status \n</command-name><command-args> --short </command-args>";
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
commandName: "\n git status \n",
|
||||
commandArgs: " --short ",
|
||||
commandMessage: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses command tags in different order", () => {
|
||||
const input =
|
||||
"<command-message>Test message</command-message><command-args>-v</command-args><command-name>test command</command-name>";
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
commandName: "test command",
|
||||
commandArgs: "-v",
|
||||
commandMessage: "Test message",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("local-command parsing", () => {
|
||||
it("parses local-command-stdout", () => {
|
||||
const input = "<local-command-stdout>output text</local-command-stdout>";
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "local-command",
|
||||
stdout: "output text",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses local-command-stdout with multiline content", () => {
|
||||
const input =
|
||||
"<local-command-stdout>line1\nline2\nline3</local-command-stdout>";
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "local-command",
|
||||
stdout: "line1\nline2\nline3",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses local-command-stdout with whitespace", () => {
|
||||
const input =
|
||||
"<local-command-stdout> \n output with spaces \n </local-command-stdout>";
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
// The regex pattern preserves all whitespace in content
|
||||
expect(result).toEqual({
|
||||
kind: "local-command",
|
||||
stdout: " \n output with spaces \n ",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("priority: command over local-command", () => {
|
||||
it("returns command when both command and local-command tags exist", () => {
|
||||
const input =
|
||||
"<command-name>test</command-name><local-command-stdout>output</local-command-stdout>";
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
expect(result.kind).toBe("command");
|
||||
if (result.kind === "command") {
|
||||
expect(result.commandName).toBe("test");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("fallback to text", () => {
|
||||
it("returns text when no matching tags found", () => {
|
||||
const input = "just plain text";
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "text",
|
||||
content: "just plain text",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns text when tags are not closed properly", () => {
|
||||
const input = "<command-name>incomplete";
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "text",
|
||||
content: "<command-name>incomplete",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns text when tags are mismatched", () => {
|
||||
const input = "<command-name>test</different-tag>";
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "text",
|
||||
content: "<command-name>test</different-tag>",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns text with empty string", () => {
|
||||
const input = "";
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "text",
|
||||
content: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns text with only unrecognized tags", () => {
|
||||
const input = "<unknown-tag>content</unknown-tag>";
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "text",
|
||||
content: "<unknown-tag>content</unknown-tag>",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles multiple same tags (uses first match)", () => {
|
||||
const input =
|
||||
"<command-name>first</command-name><command-name>second</command-name>";
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
expect(result.kind).toBe("command");
|
||||
if (result.kind === "command") {
|
||||
expect(result.commandName).toBe("first");
|
||||
}
|
||||
});
|
||||
|
||||
it("handles empty tag content", () => {
|
||||
const input = "<command-name></command-name>";
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
commandName: "",
|
||||
commandArgs: undefined,
|
||||
commandMessage: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles tags with special characters in content", () => {
|
||||
const input =
|
||||
"<command-name>git commit -m 'test & demo'</command-name>";
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
expect(result.kind).toBe("command");
|
||||
if (result.kind === "command") {
|
||||
expect(result.commandName).toBe("git commit -m 'test & demo'");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not match nested tags (regex limitation)", () => {
|
||||
const input = "<command-name><nested>inner</nested>outer</command-name>";
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
// The regex won't match properly nested tags due to [^<]* pattern
|
||||
expect(result.kind).toBe("text");
|
||||
});
|
||||
|
||||
it("handles tags with surrounding text", () => {
|
||||
const input =
|
||||
"Some text before <command-name>test</command-name> and after";
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
commandName: "test",
|
||||
commandArgs: undefined,
|
||||
commandMessage: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles newlines between tags", () => {
|
||||
const input =
|
||||
"<command-name>test</command-name>\n\n<command-args>arg</command-args>";
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
commandName: "test",
|
||||
commandArgs: "arg",
|
||||
commandMessage: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles very long content", () => {
|
||||
const longContent = "x".repeat(10000);
|
||||
const input = `<command-name>${longContent}</command-name>`;
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
expect(result.kind).toBe("command");
|
||||
if (result.kind === "command") {
|
||||
expect(result.commandName).toBe(longContent);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles tags with attributes (not matched)", () => {
|
||||
const input = '<command-name attr="value">test</command-name>';
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
// Tags with attributes won't match because regex expects <tag> not <tag attr="...">
|
||||
expect(result.kind).toBe("text");
|
||||
});
|
||||
|
||||
it("handles self-closing tags (not matched)", () => {
|
||||
const input = "<command-name />";
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
expect(result.kind).toBe("text");
|
||||
});
|
||||
|
||||
it("handles Unicode content", () => {
|
||||
const input = "<command-name>テスト コマンド 🚀</command-name>";
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
commandName: "テスト コマンド 🚀",
|
||||
commandArgs: undefined,
|
||||
commandMessage: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles mixed content with multiple tag types", () => {
|
||||
const input =
|
||||
"Some text <command-name>cmd</command-name> more text <unknown>tag</unknown>";
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
expect(result.kind).toBe("command");
|
||||
if (result.kind === "command") {
|
||||
expect(result.commandName).toBe("cmd");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
378
src/server/service/parseJsonl.test.ts
Normal file
378
src/server/service/parseJsonl.test.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseJsonl } from "./parseJsonl";
|
||||
import type { ErrorJsonl } from "./types";
|
||||
|
||||
describe("parseJsonl", () => {
|
||||
describe("正常系: 有効なJSONLをパースできる", () => {
|
||||
it("単一のUserエントリをパースできる", () => {
|
||||
const jsonl = JSON.stringify({
|
||||
type: "user",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440000",
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
message: { role: "user", content: "Hello" },
|
||||
isSidechain: false,
|
||||
userType: "external",
|
||||
cwd: "/test",
|
||||
sessionId: "session-1",
|
||||
version: "1.0.0",
|
||||
parentUuid: null,
|
||||
});
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toHaveProperty("type", "user");
|
||||
const entry = result[0];
|
||||
if (entry && entry.type === "user") {
|
||||
expect(entry.message.content).toBe("Hello");
|
||||
}
|
||||
});
|
||||
|
||||
it("単一のSummaryエントリをパースできる", () => {
|
||||
const jsonl = JSON.stringify({
|
||||
type: "summary",
|
||||
summary: "This is a summary",
|
||||
leafUuid: "550e8400-e29b-41d4-a716-446655440003",
|
||||
});
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toHaveProperty("type", "summary");
|
||||
const entry = result[0];
|
||||
if (entry && entry.type === "summary") {
|
||||
expect(entry.summary).toBe("This is a summary");
|
||||
}
|
||||
});
|
||||
|
||||
it("複数のエントリをパースできる", () => {
|
||||
const jsonl = [
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440000",
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
message: { role: "user", content: "Hello" },
|
||||
isSidechain: false,
|
||||
userType: "external",
|
||||
cwd: "/test",
|
||||
sessionId: "session-1",
|
||||
version: "1.0.0",
|
||||
parentUuid: null,
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "summary",
|
||||
summary: "Test summary",
|
||||
leafUuid: "550e8400-e29b-41d4-a716-446655440002",
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toHaveProperty("type", "user");
|
||||
expect(result[1]).toHaveProperty("type", "summary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("エラー系: 不正なJSON行をErrorJsonlとして返す", () => {
|
||||
it("無効なJSONを渡すとエラーを投げる", () => {
|
||||
const jsonl = "invalid json";
|
||||
|
||||
// parseJsonl の実装は JSON.parse をそのまま呼び出すため、
|
||||
// 無効な JSON は例外を投げます
|
||||
expect(() => parseJsonl(jsonl)).toThrow();
|
||||
});
|
||||
|
||||
it("スキーマに合わないオブジェクトをErrorJsonlとして返す", () => {
|
||||
const jsonl = JSON.stringify({
|
||||
type: "unknown",
|
||||
someField: "value",
|
||||
});
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
const errorEntry = result[0] as ErrorJsonl;
|
||||
expect(errorEntry.type).toBe("x-error");
|
||||
expect(errorEntry.lineNumber).toBe(1);
|
||||
});
|
||||
|
||||
it("必須フィールドが欠けているエントリをErrorJsonlとして返す", () => {
|
||||
const jsonl = JSON.stringify({
|
||||
type: "user",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440000",
|
||||
// timestamp, message などの必須フィールドが欠けている
|
||||
});
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
const errorEntry = result[0] as ErrorJsonl;
|
||||
expect(errorEntry.type).toBe("x-error");
|
||||
expect(errorEntry.lineNumber).toBe(1);
|
||||
});
|
||||
|
||||
it("正常なエントリとエラーエントリを混在して返す", () => {
|
||||
const jsonl = [
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440000",
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
message: { role: "user", content: "Hello" },
|
||||
isSidechain: false,
|
||||
userType: "external",
|
||||
cwd: "/test",
|
||||
sessionId: "session-1",
|
||||
version: "1.0.0",
|
||||
parentUuid: null,
|
||||
}),
|
||||
JSON.stringify({ type: "invalid-schema" }),
|
||||
JSON.stringify({
|
||||
type: "summary",
|
||||
summary: "Summary text",
|
||||
leafUuid: "550e8400-e29b-41d4-a716-446655440001",
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toHaveProperty("type", "user");
|
||||
expect(result[1]).toHaveProperty("type", "x-error");
|
||||
expect(result[2]).toHaveProperty("type", "summary");
|
||||
|
||||
const errorEntry = result[1] as ErrorJsonl;
|
||||
expect(errorEntry.lineNumber).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("エッジケース: 空行、トリム、複数エントリ", () => {
|
||||
it("空文字列を渡すと空配列を返す", () => {
|
||||
const result = parseJsonl("");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("空行のみを渡すと空配列を返す", () => {
|
||||
const result = parseJsonl("\n\n\n");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("前後の空白をトリムする", () => {
|
||||
const jsonl = `
|
||||
${JSON.stringify({
|
||||
type: "user",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440000",
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
message: { role: "user", content: "Hello" },
|
||||
isSidechain: false,
|
||||
userType: "external",
|
||||
cwd: "/test",
|
||||
sessionId: "session-1",
|
||||
version: "1.0.0",
|
||||
parentUuid: null,
|
||||
})}
|
||||
`;
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toHaveProperty("type", "user");
|
||||
});
|
||||
|
||||
it("行間の空行を除外する", () => {
|
||||
const jsonl = [
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440000",
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
message: { role: "user", content: "Hello" },
|
||||
isSidechain: false,
|
||||
userType: "external",
|
||||
cwd: "/test",
|
||||
sessionId: "session-1",
|
||||
version: "1.0.0",
|
||||
parentUuid: null,
|
||||
}),
|
||||
"",
|
||||
"",
|
||||
JSON.stringify({
|
||||
type: "summary",
|
||||
summary: "Summary text",
|
||||
leafUuid: "550e8400-e29b-41d4-a716-446655440001",
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toHaveProperty("type", "user");
|
||||
expect(result[1]).toHaveProperty("type", "summary");
|
||||
});
|
||||
|
||||
it("空白のみの行を除外する", () => {
|
||||
const jsonl = [
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440000",
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
message: { role: "user", content: "Hello" },
|
||||
isSidechain: false,
|
||||
userType: "external",
|
||||
cwd: "/test",
|
||||
sessionId: "session-1",
|
||||
version: "1.0.0",
|
||||
parentUuid: null,
|
||||
}),
|
||||
" ",
|
||||
"\t",
|
||||
JSON.stringify({
|
||||
type: "summary",
|
||||
summary: "Summary text",
|
||||
leafUuid: "550e8400-e29b-41d4-a716-446655440001",
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toHaveProperty("type", "user");
|
||||
expect(result[1]).toHaveProperty("type", "summary");
|
||||
});
|
||||
|
||||
it("多数のエントリを含むJSONLをパースできる", () => {
|
||||
const entries = Array.from({ length: 100 }, (_, i) => {
|
||||
return JSON.stringify({
|
||||
type: "user",
|
||||
uuid: `550e8400-e29b-41d4-a716-${String(i).padStart(12, "0")}`,
|
||||
timestamp: new Date(Date.UTC(2024, 0, 1, 0, 0, i)).toISOString(),
|
||||
message: {
|
||||
role: "user",
|
||||
content: `Message ${i}`,
|
||||
},
|
||||
isSidechain: false,
|
||||
userType: "external",
|
||||
cwd: "/test",
|
||||
sessionId: "session-1",
|
||||
version: "1.0.0",
|
||||
parentUuid:
|
||||
i > 0
|
||||
? `550e8400-e29b-41d4-a716-${String(i - 1).padStart(12, "0")}`
|
||||
: null,
|
||||
});
|
||||
});
|
||||
|
||||
const jsonl = entries.join("\n");
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(100);
|
||||
expect(result.every((entry) => entry.type === "user")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("行番号の正確性", () => {
|
||||
it("スキーマ検証エラー時の行番号が正確に記録される", () => {
|
||||
const jsonl = [
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440000",
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
message: { role: "user", content: "Line 1" },
|
||||
isSidechain: false,
|
||||
userType: "external",
|
||||
cwd: "/test",
|
||||
sessionId: "session-1",
|
||||
version: "1.0.0",
|
||||
parentUuid: null,
|
||||
}),
|
||||
JSON.stringify({ type: "invalid", data: "schema error" }),
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440001",
|
||||
timestamp: "2024-01-01T00:00:01.000Z",
|
||||
message: { role: "user", content: "Line 3" },
|
||||
isSidechain: false,
|
||||
userType: "external",
|
||||
cwd: "/test",
|
||||
sessionId: "session-1",
|
||||
version: "1.0.0",
|
||||
parentUuid: null,
|
||||
}),
|
||||
JSON.stringify({ type: "another-invalid" }),
|
||||
].join("\n");
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
expect((result[1] as ErrorJsonl).lineNumber).toBe(2);
|
||||
expect((result[1] as ErrorJsonl).type).toBe("x-error");
|
||||
expect((result[3] as ErrorJsonl).lineNumber).toBe(4);
|
||||
expect((result[3] as ErrorJsonl).type).toBe("x-error");
|
||||
});
|
||||
|
||||
it("空行フィルタ後の行番号が正確に記録される", () => {
|
||||
const jsonl = ["", "", JSON.stringify({ type: "invalid-schema" })].join(
|
||||
"\n",
|
||||
);
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
// 空行がフィルタされた後のインデックスは0だが、lineNumberは1として記録される
|
||||
expect((result[0] as ErrorJsonl).lineNumber).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConversationSchemaのバリエーション", () => {
|
||||
it("オプショナルフィールドを含むUserエントリをパースできる", () => {
|
||||
const jsonl = JSON.stringify({
|
||||
type: "user",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440000",
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
message: { role: "user", content: "Hello" },
|
||||
isSidechain: true,
|
||||
userType: "external",
|
||||
cwd: "/test",
|
||||
sessionId: "session-1",
|
||||
version: "1.0.0",
|
||||
parentUuid: "550e8400-e29b-41d4-a716-446655440099",
|
||||
gitBranch: "main",
|
||||
isMeta: false,
|
||||
});
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
const entry = result[0];
|
||||
if (entry && entry.type === "user") {
|
||||
expect(entry.isSidechain).toBe(true);
|
||||
expect(entry.parentUuid).toBe("550e8400-e29b-41d4-a716-446655440099");
|
||||
expect(entry.gitBranch).toBe("main");
|
||||
}
|
||||
});
|
||||
|
||||
it("nullableフィールドがnullのエントリをパースできる", () => {
|
||||
const jsonl = JSON.stringify({
|
||||
type: "user",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440000",
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
message: { role: "user", content: "Hello" },
|
||||
isSidechain: false,
|
||||
userType: "external",
|
||||
cwd: "/test",
|
||||
sessionId: "session-1",
|
||||
version: "1.0.0",
|
||||
parentUuid: null,
|
||||
});
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
const entry = result[0];
|
||||
if (entry && entry.type === "user") {
|
||||
expect(entry.parentUuid).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,9 @@ import type { Conversation } from "../../../lib/conversation-schema";
|
||||
import { PersistentService } from "../../lib/storage/FileCacheStorage/PersistantService";
|
||||
import { decodeProjectId } from "../project/id";
|
||||
import type { ErrorJsonl, SessionDetail, SessionMeta } from "../types";
|
||||
import { VirtualConversationDatabase } from "./PredictSessionsDatabase";
|
||||
import { SessionMetaService } from "./SessionMetaService";
|
||||
import { SessionRepository } from "./SessionRepository";
|
||||
import { VirtualConversationDatabase } from "./VirtualConversationDatabase";
|
||||
|
||||
/**
|
||||
* Helper function to create a FileSystem mock layer
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { resolve } from "node:path";
|
||||
import { FileSystem } from "@effect/platform";
|
||||
import { Context, Effect, Layer, Option } from "effect";
|
||||
import { uniqBy } from "es-toolkit";
|
||||
import { parseCommandXml } from "../parseCommandXml";
|
||||
import { parseJsonl } from "../parseJsonl";
|
||||
import { decodeProjectId } from "../project/id";
|
||||
import type { Session, SessionDetail } from "../types";
|
||||
import { decodeSessionId, encodeSessionId } from "./id";
|
||||
import { VirtualConversationDatabase } from "./PredictSessionsDatabase";
|
||||
import { SessionMetaService } from "./SessionMetaService";
|
||||
import { VirtualConversationDatabase } from "./VirtualConversationDatabase";
|
||||
|
||||
const getSession = (projectId: string, sessionId: string) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -75,25 +74,7 @@ const getSession = (projectId: string, sessionId: string) =>
|
||||
id: sessionId,
|
||||
jsonlFilePath: sessionPath,
|
||||
meta,
|
||||
conversations: isBroken
|
||||
? conversations
|
||||
: uniqBy(mergedConversations, (item) => {
|
||||
switch (item.type) {
|
||||
case "system":
|
||||
return `${item.type}-${item.uuid}`;
|
||||
case "assistant":
|
||||
return `${item.type}-${item.message.id}`;
|
||||
case "user":
|
||||
return `${item.type}-${item.message.content}`;
|
||||
case "summary":
|
||||
return `${item.type}-${item.leafUuid}`;
|
||||
case "x-error":
|
||||
return `${item.type}-${item.lineNumber}-${item.line}`;
|
||||
default:
|
||||
item satisfies never;
|
||||
throw new Error(`Unknown conversation type: ${item}`);
|
||||
}
|
||||
}),
|
||||
conversations: isBroken ? conversations : mergedConversations,
|
||||
lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Effect } from "effect";
|
||||
import type { Conversation } from "../../../lib/conversation-schema";
|
||||
import type { ErrorJsonl } from "../types";
|
||||
import { VirtualConversationDatabase } from "./PredictSessionsDatabase";
|
||||
import { VirtualConversationDatabase } from "./VirtualConversationDatabase";
|
||||
|
||||
describe("VirtualConversationDatabase", () => {
|
||||
describe("getProjectVirtualConversations", () => {
|
||||
|
||||
Reference in New Issue
Block a user