mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2025-12-20 14:54:19 +01:00
refactor: move directories
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("computeClaudeProjectFilePath", () => {
|
||||
const TEST_GLOBAL_CLAUDE_DIR = "/test/mock/claude";
|
||||
const TEST_PROJECTS_DIR = path.join(TEST_GLOBAL_CLAUDE_DIR, "projects");
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("../../../lib/env", () => ({
|
||||
env: {
|
||||
get: (key: string) => {
|
||||
if (key === "GLOBAL_CLAUDE_DIR") {
|
||||
return TEST_GLOBAL_CLAUDE_DIR;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it("プロジェクトパスからClaudeの設定ディレクトリパスを計算する", async () => {
|
||||
const { computeClaudeProjectFilePath } = await import(
|
||||
"./computeClaudeProjectFilePath"
|
||||
);
|
||||
|
||||
const projectPath = "/home/me/dev/example";
|
||||
const expected = `${TEST_PROJECTS_DIR}/-home-me-dev-example`;
|
||||
|
||||
const result = computeClaudeProjectFilePath(projectPath);
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it("末尾にスラッシュがある場合も正しく処理される", async () => {
|
||||
const { computeClaudeProjectFilePath } = await import(
|
||||
"./computeClaudeProjectFilePath"
|
||||
);
|
||||
|
||||
const projectPath = "/home/me/dev/example/";
|
||||
const expected = `${TEST_PROJECTS_DIR}/-home-me-dev-example`;
|
||||
|
||||
const result = computeClaudeProjectFilePath(projectPath);
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import path from "node:path";
|
||||
import { claudeProjectsDirPath } from "../../../lib/config/paths";
|
||||
|
||||
export function computeClaudeProjectFilePath(projectPath: string): string {
|
||||
return path.join(
|
||||
claudeProjectsDirPath,
|
||||
projectPath.replace(/\/$/, "").replace(/\//g, "-"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-code";
|
||||
import { controllablePromise } from "../../../../lib/controllablePromise";
|
||||
|
||||
export type OnMessage = (message: SDKMessage) => void | Promise<void>;
|
||||
|
||||
export type MessageGenerator = () => AsyncGenerator<
|
||||
SDKUserMessage,
|
||||
void,
|
||||
unknown
|
||||
>;
|
||||
|
||||
export const createMessageGenerator = (): {
|
||||
generateMessages: MessageGenerator;
|
||||
setNextMessage: (message: string) => void;
|
||||
setHooks: (hooks: {
|
||||
onNextMessageSet?: (message: string) => void | Promise<void>;
|
||||
onNewUserMessageResolved?: (message: string) => void | Promise<void>;
|
||||
}) => void;
|
||||
} => {
|
||||
let sendMessagePromise = controllablePromise<string>();
|
||||
let registeredHooks: {
|
||||
onNextMessageSet: ((message: string) => void | Promise<void>)[];
|
||||
onNewUserMessageResolved: ((message: string) => void | Promise<void>)[];
|
||||
} = {
|
||||
onNextMessageSet: [],
|
||||
onNewUserMessageResolved: [],
|
||||
};
|
||||
|
||||
const createMessage = (message: string): SDKUserMessage => {
|
||||
return {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: message,
|
||||
},
|
||||
} as SDKUserMessage;
|
||||
};
|
||||
|
||||
async function* generateMessages(): ReturnType<MessageGenerator> {
|
||||
sendMessagePromise = controllablePromise<string>();
|
||||
|
||||
while (true) {
|
||||
const message = await sendMessagePromise.promise;
|
||||
sendMessagePromise = controllablePromise<string>();
|
||||
void Promise.allSettled(
|
||||
registeredHooks.onNewUserMessageResolved.map((hook) => hook(message)),
|
||||
);
|
||||
|
||||
yield createMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
const setNextMessage = (message: string) => {
|
||||
sendMessagePromise.resolve(message);
|
||||
void Promise.allSettled(
|
||||
registeredHooks.onNextMessageSet.map((hook) => hook(message)),
|
||||
);
|
||||
};
|
||||
|
||||
const setHooks = (hooks: {
|
||||
onNextMessageSet?: (message: string) => void | Promise<void>;
|
||||
onNewUserMessageResolved?: (message: string) => void | Promise<void>;
|
||||
}) => {
|
||||
registeredHooks = {
|
||||
onNextMessageSet: [
|
||||
...(hooks?.onNextMessageSet ? [hooks.onNextMessageSet] : []),
|
||||
...registeredHooks.onNextMessageSet,
|
||||
],
|
||||
onNewUserMessageResolved: [
|
||||
...(hooks?.onNewUserMessageResolved
|
||||
? [hooks.onNewUserMessageResolved]
|
||||
: []),
|
||||
...registeredHooks.onNewUserMessageResolved,
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
generateMessages,
|
||||
setNextMessage,
|
||||
setHooks,
|
||||
};
|
||||
};
|
||||
301
src/server/core/claude-code/functions/parseCommandXml.test.ts
Normal file
301
src/server/core/claude-code/functions/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");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
74
src/server/core/claude-code/functions/parseCommandXml.ts
Normal file
74
src/server/core/claude-code/functions/parseCommandXml.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const regExp = /<(?<tag>[^>]+)>(?<content>\s*[^<]*?\s*)<\/\k<tag>>/g;
|
||||
|
||||
const matchSchema = z.object({
|
||||
tag: z.string(),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const parsedCommandSchema = z.union([
|
||||
z.object({
|
||||
kind: z.literal("command"),
|
||||
commandName: z.string(),
|
||||
commandArgs: z.string().optional(),
|
||||
commandMessage: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("local-command"),
|
||||
stdout: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("text"),
|
||||
content: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type ParsedCommand = z.infer<typeof parsedCommandSchema>;
|
||||
|
||||
export const parseCommandXml = (content: string): ParsedCommand => {
|
||||
const matches = Array.from(content.matchAll(regExp))
|
||||
.map((match) => matchSchema.safeParse(match.groups))
|
||||
.filter((result) => result.success)
|
||||
.map((result) => result.data);
|
||||
|
||||
if (matches.length === 0) {
|
||||
return {
|
||||
kind: "text",
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
const commandName = matches.find(
|
||||
(match) => match.tag === "command-name",
|
||||
)?.content;
|
||||
const commandArgs = matches.find(
|
||||
(match) => match.tag === "command-args",
|
||||
)?.content;
|
||||
const commandMessage = matches.find(
|
||||
(match) => match.tag === "command-message",
|
||||
)?.content;
|
||||
const localCommandStdout = matches.find(
|
||||
(match) => match.tag === "local-command-stdout",
|
||||
)?.content;
|
||||
|
||||
switch (true) {
|
||||
case commandName !== undefined:
|
||||
return {
|
||||
kind: "command",
|
||||
commandName,
|
||||
commandArgs,
|
||||
commandMessage,
|
||||
};
|
||||
case localCommandStdout !== undefined:
|
||||
return {
|
||||
kind: "local-command",
|
||||
stdout: localCommandStdout,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
kind: "text",
|
||||
content,
|
||||
};
|
||||
}
|
||||
};
|
||||
378
src/server/core/claude-code/functions/parseJsonl.test.ts
Normal file
378
src/server/core/claude-code/functions/parseJsonl.test.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ErrorJsonl } from "../../types";
|
||||
import { parseJsonl } from "./parseJsonl";
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
23
src/server/core/claude-code/functions/parseJsonl.ts
Normal file
23
src/server/core/claude-code/functions/parseJsonl.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ConversationSchema } from "../../../../lib/conversation-schema";
|
||||
import type { ErrorJsonl } from "../../types";
|
||||
|
||||
export const parseJsonl = (content: string) => {
|
||||
const lines = content
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim() !== "");
|
||||
|
||||
return lines.map((line, index) => {
|
||||
const parsed = ConversationSchema.safeParse(JSON.parse(line));
|
||||
if (!parsed.success) {
|
||||
const errorData: ErrorJsonl = {
|
||||
type: "x-error",
|
||||
line,
|
||||
lineNumber: index + 1,
|
||||
};
|
||||
return errorData;
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user