snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s

This commit is contained in:
upstream-mirror
2026-09-16 05:31:04 +05:00
commit 1a8d07ca6c
1286 changed files with 360144 additions and 0 deletions

View File

@@ -0,0 +1,225 @@
import { RequestError } from "../../../errors";
import type { LLMMessage, TTTDDialog } from "../types";
interface RateLimitInfo {
rateLimit?: number;
rateLimitRemaining?: number;
}
interface StreamingOptions {
url: string;
messages: readonly LLMMessage[];
onChunk?: (chunk: string) => void;
extractRateLimits?: boolean;
signal?: AbortSignal;
onStreamCreated?: () => void;
}
export type StreamChunk =
| {
type: "content";
delta: string;
}
| {
type: "done";
finishReason: "stop" | "length" | "content_filter" | "tool_calls" | null;
}
| {
type: "error";
error: {
message: string;
status?: number;
};
};
function extractRateLimitHeaders(headers: Headers): RateLimitInfo {
const rateLimit = headers.get("X-Ratelimit-Limit");
const rateLimitRemaining = headers.get("X-Ratelimit-Remaining");
return {
rateLimit: rateLimit ? parseInt(rateLimit, 10) : undefined,
rateLimitRemaining: rateLimitRemaining
? parseInt(rateLimitRemaining, 10)
: undefined,
};
}
export async function* parseSSEStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
): AsyncGenerator<string, void, unknown> {
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) {
continue;
}
if (trimmedLine.startsWith("data: ")) {
const data = trimmedLine.slice(6);
yield data;
}
}
}
} finally {
reader.releaseLock();
}
}
export async function TTDStreamFetch(
options: StreamingOptions,
): Promise<TTTDDialog.OnTextSubmitRetValue> {
const {
url,
messages,
onChunk,
onStreamCreated,
extractRateLimits = true,
signal,
} = options;
try {
let fullResponse = "";
let rateLimitInfo: RateLimitInfo = {};
let error: RequestError | null = null;
const response = await fetch(url, {
method: "POST",
headers: {
Accept: "text/event-stream",
"Content-Type": "application/json",
},
body: JSON.stringify({ messages }),
signal,
});
if (extractRateLimits) {
rateLimitInfo = extractRateLimitHeaders(response.headers);
}
if (!response.ok) {
if (response.status === 429) {
return {
...rateLimitInfo,
error: new RequestError({
message: "Rate limit exceeded",
status: 429,
}),
};
}
const text = await response.text();
throw new RequestError({
message: text || "Generation failed...",
status: response.status,
});
}
const reader = response.body?.getReader();
if (!reader) {
throw new RequestError({
message: "Couldn't get reader from response body",
status: 500,
});
}
onStreamCreated?.();
try {
for await (const data of parseSSEStream(reader)) {
if (data === "[DONE]") {
break;
}
try {
const chunk: StreamChunk = JSON.parse(data);
if (chunk === null) {
break;
}
switch (chunk.type) {
case "content": {
const delta = chunk.delta;
if (delta) {
fullResponse += delta;
onChunk?.(delta);
}
break;
}
case "error":
error = new RequestError({
message: chunk.error.message,
status: 500,
});
break;
case "done":
break;
}
} catch (e) {
console.warn("Failed to parse SSE data:", data, e);
}
}
} catch (streamError: any) {
if (streamError.name === "AbortError") {
error = new RequestError({ message: "Request aborted", status: 499 });
} else {
error = new RequestError({
message: streamError.message || "Streaming error",
status: 500,
});
}
}
if (error) {
return {
...rateLimitInfo,
error,
};
}
if (!fullResponse) {
return {
...rateLimitInfo,
error: new RequestError({
message: "Generation failed...",
status: response.status,
}),
};
}
return {
generatedResponse: fullResponse,
error: null,
...rateLimitInfo,
};
} catch (err: any) {
if (err.name === "AbortError") {
return {
error: new RequestError({ message: "Request aborted", status: 499 }),
};
}
return {
error: new RequestError({
message: err.message || "Request failed",
status: 500,
}),
};
}
}

View File

@@ -0,0 +1,497 @@
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import { TTDStreamFetch } from "./TTDStreamFetch";
import type { StreamChunk } from "./TTDStreamFetch";
function createMockStream(chunks: string[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
let index = 0;
return new ReadableStream({
async pull(controller) {
if (index < chunks.length) {
controller.enqueue(encoder.encode(chunks[index]));
index++;
} else {
controller.close();
}
},
});
}
const createContentChunkData = (delta: string): string => {
const data: StreamChunk & { type: "content" } = { type: "content", delta };
return JSON.stringify(data);
};
const createContentChunk = (delta: string): string => {
return `data: ${createContentChunkData(delta)}\n\n`;
};
const createDataChunk = (data: string): string => {
return `data: ${data}\n\n`;
};
const DONE_CHUNK = "data: [DONE]\n\n";
describe("TTDStreamFetch", () => {
let originalFetch: typeof global.fetch;
beforeEach(() => {
originalFetch = global.fetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe("successful streaming", () => {
it("should stream data chunks and return full response", async () => {
const chunks: string[] = [];
const mockChunks = [
createContentChunk("Hello "),
createContentChunk("world"),
DONE_CHUNK,
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
onChunk: (chunk) => chunks.push(chunk),
});
expect(result.generatedResponse).toBe("Hello world");
expect(chunks).toEqual(["Hello ", "world"]);
expect(result.error).toBeNull();
});
it("should handle multi-line chunks", async () => {
const mockChunks = [
createContentChunk("Line 1\n"),
createContentChunk("Line 2"),
DONE_CHUNK,
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.generatedResponse).toBe("Line 1\nLine 2");
});
it("should call onStreamCreated callback", async () => {
const onStreamCreated = vi.fn();
const mockChunks = [createContentChunk("test"), DONE_CHUNK];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream(mockChunks),
});
await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
onStreamCreated,
});
expect(onStreamCreated).toHaveBeenCalledTimes(1);
});
it("should handle empty chunks gracefully", async () => {
const mockChunks = [
createContentChunk(""),
createContentChunk("valid"),
DONE_CHUNK,
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.generatedResponse).toBe("valid");
});
it("should handle null chunk as stream termination", async () => {
const mockChunks = [
createContentChunk("before null"),
createDataChunk("null"),
createContentChunk("after null"),
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.generatedResponse).toBe("before null");
});
});
describe("rate limit handling", () => {
it("should extract rate limit headers when enabled", async () => {
const mockChunks = [createContentChunk("test"), DONE_CHUNK];
const headers = new Headers();
headers.set("X-Ratelimit-Limit", "100");
headers.set("X-Ratelimit-Remaining", "95");
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers,
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
extractRateLimits: true,
});
expect(result.rateLimit).toBe(100);
expect(result.rateLimitRemaining).toBe(95);
});
it("should not extract rate limits when disabled", async () => {
const mockChunks = [createContentChunk("test"), DONE_CHUNK];
const headers = new Headers();
headers.set("X-Ratelimit-Limit", "100");
headers.set("X-Ratelimit-Remaining", "95");
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers,
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
extractRateLimits: false,
});
expect(result.rateLimit).toBeUndefined();
expect(result.rateLimitRemaining).toBeUndefined();
});
it("should handle missing rate limit headers", async () => {
const mockChunks = [createContentChunk("test"), DONE_CHUNK];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
extractRateLimits: true,
});
expect(result.rateLimit).toBeUndefined();
expect(result.rateLimitRemaining).toBeUndefined();
});
it("should return specific error for 429 rate limit", async () => {
const headers = new Headers();
headers.set("X-Ratelimit-Limit", "100");
headers.set("X-Ratelimit-Remaining", "0");
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 429,
headers,
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.error).toBeDefined();
expect(result.error?.message).toBe("Rate limit exceeded");
expect(result.rateLimit).toBe(100);
expect(result.rateLimitRemaining).toBe(0);
});
});
describe("error handling", () => {
it("should handle non-ok response with text", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
headers: new Headers(),
text: async () => "Server error occurred",
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.error).toBeDefined();
expect(result.error?.message).toBe("Server error occurred");
});
it("should handle non-ok response without text", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
headers: new Headers(),
text: async () => "",
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.error).toBeDefined();
expect(result.error?.message).toBe("Generation failed...");
});
it("should handle missing response body", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: null,
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.error).toBeDefined();
expect(result.error?.message).toBe(
"Couldn't get reader from response body",
);
});
it("should handle empty response", async () => {
const mockChunks = [DONE_CHUNK];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.error).toBeDefined();
expect(result.error?.message).toBe("Generation failed...");
});
it("should handle network errors", async () => {
global.fetch = vi
.fn()
.mockRejectedValue(new Error("Network connection failed"));
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.error).toBeDefined();
expect(result.error?.message).toBe("Network connection failed");
});
it("should handle invalid JSON in stream", async () => {
const consoleWarnSpy = vi
.spyOn(console, "warn")
.mockImplementation(() => {});
const mockChunks = [
createDataChunk("invalid"),
createContentChunk("valid"),
DONE_CHUNK,
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.generatedResponse).toBe("valid");
expect(consoleWarnSpy).toHaveBeenCalled();
consoleWarnSpy.mockRestore();
});
});
describe("abort handling", () => {
it("should handle abort signal during fetch", async () => {
const abortController = new AbortController();
global.fetch = vi.fn().mockImplementation(() => {
abortController.abort();
const error = new Error("The operation was aborted");
error.name = "AbortError";
return Promise.reject(error);
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
signal: abortController.signal,
});
expect(result.error).toBeDefined();
expect(result.error?.message).toBe("Request aborted");
});
it("should handle abort error thrown during streaming", async () => {
// Create a stream that throws an AbortError
const stream = new ReadableStream({
async pull() {
const error = new Error("The operation was aborted");
error.name = "AbortError";
throw error;
},
});
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: stream,
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.error).toBeDefined();
expect(result.error?.message).toBe("Request aborted");
});
});
describe("SSE parsing", () => {
it("should handle lines without data prefix", async () => {
const mockChunks = [
": comment line\n",
createContentChunk("valid"),
DONE_CHUNK,
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.generatedResponse).toBe("valid");
});
it("should handle empty lines in stream", async () => {
const mockChunks = [
createContentChunk("first"),
"\n\n",
createContentChunk("second"),
DONE_CHUNK,
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.generatedResponse).toBe("firstsecond");
});
it("should handle partial chunks across reads", async () => {
// Split an SSE message across multiple chunks
const mockChunks = [
"data: ",
createContentChunkData("partial"),
"\n\n",
DONE_CHUNK,
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.generatedResponse).toBe("partial");
});
it("should handle [DONE] marker to terminate stream", async () => {
const mockChunks = [
createContentChunk("content"),
DONE_CHUNK,
createContentChunk("should not appear"),
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.generatedResponse).toBe("content");
});
});
});

View File

@@ -0,0 +1,658 @@
import {
addMessages,
getLastAssistantMessage,
getMessagesForLLM,
removeLastAssistantMessage,
updateAssistantContent,
} from "./chat";
import type { TChat } from "../types";
describe("chat utils", () => {
describe("updateAssistantContent", () => {
it("should update the last assistant message with new payload", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "user",
content: "Hello",
timestamp: new Date("2024-01-01"),
},
{
id: "2",
type: "assistant",
content: "Hi there",
timestamp: new Date("2024-01-01"),
},
],
};
const result = updateAssistantContent(chatHistory, {
content: "Hi there, how can I help?",
});
expect(result.messages[1].content).toBe("Hi there, how can I help?");
expect(result.messages[1].id).toBe("2"); // ID should remain the same
});
it("should update only the last assistant message when multiple exist", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "assistant",
content: "First assistant message",
timestamp: new Date("2024-01-01"),
},
{
id: "2",
type: "user",
content: "User message",
timestamp: new Date("2024-01-01"),
},
{
id: "3",
type: "assistant",
content: "Second assistant message",
timestamp: new Date("2024-01-01"),
},
],
};
const result = updateAssistantContent(chatHistory, {
content: "Updated second message",
});
expect(result.messages[0].content).toBe("First assistant message");
expect(result.messages[2].content).toBe("Updated second message");
});
it("should update isGenerating flag", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "assistant",
content: "Message",
timestamp: new Date("2024-01-01"),
isGenerating: false,
},
],
};
const result = updateAssistantContent(chatHistory, {
isGenerating: true,
});
expect(result.messages[0].isGenerating).toBe(true);
});
it("should update error information", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "assistant",
content: "Message",
timestamp: new Date("2024-01-01"),
},
],
};
const result = updateAssistantContent(chatHistory, {
error: "Something went wrong",
errorType: "network",
});
expect(result.messages[0].error).toBe("Something went wrong");
expect(result.messages[0].errorType).toBe("network");
});
it("should return unchanged chatHistory if no assistant message exists", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "user",
content: "Hello",
timestamp: new Date("2024-01-01"),
},
],
};
const result = updateAssistantContent(chatHistory, {
content: "New content",
});
expect(result).toEqual(chatHistory);
});
it("should not mutate original chatHistory", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "assistant",
content: "Original",
timestamp: new Date("2024-01-01"),
},
],
};
const result = updateAssistantContent(chatHistory, {
content: "Updated",
});
expect(chatHistory.messages[0].content).toBe("Original");
expect(result.messages[0].content).toBe("Updated");
});
});
describe("getLastAssistantMessage", () => {
it("should return the last assistant message", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "user",
content: "Hello",
timestamp: new Date("2024-01-01"),
},
{
id: "2",
type: "assistant",
content: "Hi there",
timestamp: new Date("2024-01-01"),
},
],
};
const result = getLastAssistantMessage(chatHistory);
expect(result).toEqual({
id: "2",
type: "assistant",
content: "Hi there",
timestamp: new Date("2024-01-01"),
});
});
it("should return the last assistant message when multiple exist", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "assistant",
content: "First",
timestamp: new Date("2024-01-01"),
},
{
id: "2",
type: "user",
content: "User",
timestamp: new Date("2024-01-01"),
},
{
id: "3",
type: "assistant",
content: "Second",
timestamp: new Date("2024-01-01"),
},
],
};
const result = getLastAssistantMessage(chatHistory);
expect(result.id).toBe("3");
expect(result.content).toBe("Second");
});
it("should return undefined if no assistant message exists", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "user",
content: "Hello",
timestamp: new Date("2024-01-01"),
},
],
};
const result = getLastAssistantMessage(chatHistory);
expect(result).toBeUndefined();
});
it("should return undefined for empty messages array", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [],
};
const result = getLastAssistantMessage(chatHistory);
expect(result).toBeUndefined();
});
});
describe("addMessages", () => {
it("should add a single message with id and timestamp", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [],
};
const result = addMessages(chatHistory, [
{
type: "user",
content: "Hello",
},
]);
expect(result.messages).toHaveLength(1);
expect(result.messages[0]).toMatchObject({
type: "user",
content: "Hello",
});
expect(result.messages[0].id).toBeDefined();
expect(result.messages[0].timestamp).toBeInstanceOf(Date);
});
it("should add multiple messages", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [],
};
const result = addMessages(chatHistory, [
{
type: "user",
content: "Hello",
},
{
type: "assistant",
content: "Hi there",
},
]);
expect(result.messages).toHaveLength(2);
expect(result.messages[0].type).toBe("user");
expect(result.messages[1].type).toBe("assistant");
});
it("should append to existing messages", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "user",
content: "Existing",
timestamp: new Date("2024-01-01"),
},
],
};
const result = addMessages(chatHistory, [
{
type: "assistant",
content: "New message",
},
]);
expect(result.messages).toHaveLength(2);
expect(result.messages[0].content).toBe("Existing");
expect(result.messages[1].content).toBe("New message");
});
it("should add warning messages", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [],
};
const result = addMessages(chatHistory, [
{
type: "warning",
},
]);
expect(result.messages).toHaveLength(1);
expect(result.messages[0].type).toBe("warning");
});
it("should preserve additional message properties", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [],
};
const result = addMessages(chatHistory, [
{
type: "assistant",
content: "Message",
isGenerating: true,
error: "Error text",
errorType: "parse",
},
]);
expect(result.messages[0].isGenerating).toBe(true);
expect(result.messages[0].error).toBe("Error text");
expect(result.messages[0].errorType).toBe("parse");
});
it("should not mutate original chatHistory", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [],
};
const result = addMessages(chatHistory, [
{
type: "user",
content: "Hello",
},
]);
expect(chatHistory.messages).toHaveLength(0);
expect(result.messages).toHaveLength(1);
});
it("should generate unique IDs for each message", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [],
};
const result = addMessages(chatHistory, [
{
type: "user",
content: "Message 1",
},
{
type: "assistant",
content: "Message 2",
},
]);
expect(result.messages[0].id).not.toBe(result.messages[1].id);
});
});
describe("removeLastAssistantMessage", () => {
it("should remove the last assistant message", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "user",
content: "Hello",
timestamp: new Date("2024-01-01"),
},
{
id: "2",
type: "assistant",
content: "Hi there",
timestamp: new Date("2024-01-01"),
},
{
id: "3",
type: "user",
content: "How are you?",
timestamp: new Date("2024-01-01"),
},
],
};
const result = removeLastAssistantMessage(chatHistory);
expect(result.messages).toHaveLength(2);
expect(result.messages[0].id).toBe("1");
expect(result.messages[1].id).toBe("3");
});
it("should remove only the last assistant message when multiple exist", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "assistant",
content: "First",
timestamp: new Date("2024-01-01"),
},
{
id: "2",
type: "user",
content: "User",
timestamp: new Date("2024-01-01"),
},
{
id: "3",
type: "assistant",
content: "Second",
timestamp: new Date("2024-01-01"),
},
],
};
const result = removeLastAssistantMessage(chatHistory);
expect(result.messages).toHaveLength(2);
expect(result.messages[0].id).toBe("1");
expect(result.messages[1].id).toBe("2");
});
it("should return unchanged chatHistory if no assistant message exists", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "user",
content: "Hello",
timestamp: new Date("2024-01-01"),
},
],
};
const result = removeLastAssistantMessage(chatHistory);
expect(result).toEqual(chatHistory);
});
it("should handle empty messages array", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [],
};
const result = removeLastAssistantMessage(chatHistory);
expect(result).toEqual(chatHistory);
});
it("should not mutate original chatHistory", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "assistant",
content: "Message",
timestamp: new Date("2024-01-01"),
},
],
};
const result = removeLastAssistantMessage(chatHistory);
expect(chatHistory.messages).toHaveLength(1);
expect(result.messages).toHaveLength(0);
});
});
describe("getMessagesForApi", () => {
it("should filter out messages with empty content", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "user",
content: "",
timestamp: new Date("2024-01-01"),
},
{
id: "2",
type: "user",
content: "Valid question",
timestamp: new Date("2024-01-01"),
},
{
id: "3",
type: "assistant",
content: "",
timestamp: new Date("2024-01-01"),
},
{
id: "4",
type: "assistant",
content: "Valid answer",
timestamp: new Date("2024-01-01"),
},
],
};
const result = getMessagesForLLM(chatHistory);
expect(result).toHaveLength(2);
expect(result[0].content).toBe("Valid question");
expect(result[1].content).toBe("Valid answer");
});
it("should return only user message if no assistant messages", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "user",
content: "Question",
timestamp: new Date("2024-01-01"),
},
],
};
const result = getMessagesForLLM(chatHistory);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
role: "user",
content: "Question",
});
});
it("should return only assistant messages if no user message", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "assistant",
content: "Answer",
timestamp: new Date("2024-01-01"),
},
],
};
const result = getMessagesForLLM(chatHistory);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
role: "assistant",
content: "Answer",
});
});
it("should return empty array for empty messages", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [],
};
const result = getMessagesForLLM(chatHistory);
expect(result).toEqual([]);
});
it("should filter out warning messages", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "user",
content: "Question",
timestamp: new Date("2024-01-01"),
},
{
id: "2",
type: "warning",
content: "warning",
timestamp: new Date("2024-01-01"),
},
],
};
const result = getMessagesForLLM(chatHistory);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(
expect.objectContaining({
role: "user",
}),
);
});
});
});

View File

@@ -0,0 +1,92 @@
import { findLastIndex, randomId } from "@excalidraw/common";
import type { LLMMessage, TChat } from "../types";
export const updateAssistantContent = (
chatHistory: TChat.ChatHistory,
payload: Partial<TChat.ChatMessage>,
) => {
const { messages } = chatHistory;
const lastAssistantIndex = findLastIndex(
messages,
(msg) => msg.type === "assistant",
);
if (lastAssistantIndex === -1) {
return chatHistory;
}
const lastMessage = messages[lastAssistantIndex];
const updatedMessages = messages.slice();
updatedMessages[lastAssistantIndex] = {
...lastMessage,
...payload,
};
return {
...chatHistory,
messages: updatedMessages,
};
};
export const getLastAssistantMessage = (chatHistory: TChat.ChatHistory) => {
const { messages } = chatHistory;
const lastAssistantIndex = findLastIndex(
messages,
(msg) => msg.type === "assistant",
);
return messages[lastAssistantIndex];
};
export const addMessages = (
chatHistory: TChat.ChatHistory,
messages: Array<Omit<TChat.ChatMessage, "id" | "timestamp">>,
) => {
const newMessages: Array<TChat.ChatMessage> = messages.map((message) => ({
...message,
id: randomId(),
timestamp: new Date(),
}));
return {
...chatHistory,
messages: [...chatHistory.messages, ...newMessages],
};
};
export const removeLastAssistantMessage = (chatHistory: TChat.ChatHistory) => {
const lastMsgIdx = findLastIndex(
chatHistory.messages ?? [],
(msg) => msg.type === "assistant",
);
if (lastMsgIdx !== -1) {
return {
...chatHistory,
messages: chatHistory.messages.filter((_, idx) => idx !== lastMsgIdx),
};
}
return chatHistory;
};
export const getMessagesForLLM = (
chatHistory: TChat.ChatHistory,
): LLMMessage[] => {
const messages: LLMMessage[] = [];
for (const msg of chatHistory.messages) {
if (msg.content && (msg.type === "user" || msg.type === "assistant")) {
messages.push({
role: msg.type,
content: msg.content,
});
}
}
return messages;
};

View File

@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import { getMermaidAutoFixCandidates } from "./mermaidAutoFix";
describe("getMermaidAutoFixCandidates", () => {
it("suggests removing trailing token after a closed label shape", () => {
const sourceText = `graph TD
L3_TCP["TCP (Transmission Control Protocol)"]x
L3_UDP["UDP (User Datagram Protocol)"]`;
const errorMessage = `Parse error on line 2:
...ission Control Protocol)"]x
-----------------------------^
Expecting 'SEMI', got 'NODE_STRING'`;
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
expect(candidates).toContain(`graph TD
L3_TCP["TCP (Transmission Control Protocol)"]
L3_UDP["UDP (User Datagram Protocol)"]`);
});
it("suggests appending missing end statements", () => {
const sourceText = `graph TD
subgraph A
A1[Start]`;
const errorMessage = `Parse error on line 3:
... A1[Start]
-------------^
Expecting 'end'`;
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
expect(candidates).toContain(`graph TD
subgraph A
A1[Start]
end`);
});
it("returns empty list for non-parse errors", () => {
expect(
getMermaidAutoFixCandidates("graph TD\nA-->B", "Network error"),
).toEqual([]);
});
it("extracts line index from lexical error format too", () => {
const sourceText = `graph TD
subgraph Layers["X"]x
direction TB`;
const errorMessage = `Lexical error on line 2. Unrecognized text.
... subgraph Layers["X"]x direction
-----------------------^`;
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
expect(candidates).toContain(`graph TD
subgraph Layers["X"]
direction TB`);
});
it("removes extra > after edge label", () => {
const sourceText = `flowchart TD
A["User Input"] -->|text|> B["Tokenization"]
A["User Input"] -->|text|> B["Tokenization"]`;
const errorMessage = `Parse error on line 2:
...A["User Input"] -->|text|> B["Tokenization"]
---------------------------^
Expecting 'NODE_STRING', got 'GT'`;
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
expect(candidates).toContain(`flowchart TD
A["User Input"] -->|text| B["Tokenization"]
A["User Input"] -->|text| B["Tokenization"]`);
});
it("suggests removing the last invalid deactivate for participant errors", () => {
const sourceText = `sequenceDiagram
participant QAEngineer as QA
activate QA
QA->>QA: Verifies Fix
deactivate QA
QA->>QA: Verifies Again
deactivate QA`;
const errorMessage = "Trying to inactivate an inactive participant (QA)";
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
expect(candidates).toContain(`sequenceDiagram
participant QAEngineer as QA
activate QA
QA->>QA: Verifies Fix
deactivate QA
QA->>QA: Verifies Again`);
});
it("adds a fallback candidate that removes all invalid deactivations", () => {
const sourceText = `sequenceDiagram
participant QAEngineer as QA
deactivate QA
QA->>QA: Verifies Fix
deactivate QA`;
const errorMessage = "Trying to inactivate an inactive participant (QA)";
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
expect(candidates).toContain(`sequenceDiagram
participant QAEngineer as QA
QA->>QA: Verifies Fix`);
});
});

View File

@@ -0,0 +1,175 @@
import {
getMermaidErrorLineNumber,
getMermaidInactiveParticipant,
isMermaidAutoFixableError,
isMermaidParseSyntaxError,
} from "./mermaidError";
const getErrorLineIndex = (message: string, sourceText: string) => {
const lineNumber = getMermaidErrorLineNumber(message, sourceText);
if (lineNumber == null) {
return null;
}
return lineNumber - 1;
};
const replaceLineAt = (
lines: string[],
index: number,
transform: (line: string) => string,
) => {
if (index < 0 || index >= lines.length) {
return null;
}
const nextLine = transform(lines[index]);
if (nextLine === lines[index]) {
return null;
}
const nextLines = [...lines];
nextLines[index] = nextLine;
return nextLines.join("\n");
};
const stripTrailingTokenAfterShape = (line: string) => {
const alphaTailMatch = line.match(
/^(.*(?:\[[^\]]*]|\([^)]*\)|\{[^}]*}|"(?:[^"]*)"|'(?:[^']*)'))([A-Za-z]+)\s*$/,
);
if (alphaTailMatch) {
return alphaTailMatch[1];
}
const punctuationTailMatch = line.match(
/^(.*(?:\[[^\]]*]|\([^)]*\)|\{[^}]*}|"(?:[^"]*)"|'(?:[^']*)'))([,;:])\s*$/,
);
if (punctuationTailMatch) {
return punctuationTailMatch[1];
}
return line;
};
const removeExtraArrowheadAfterEdgeLabel = (line: string) => {
// Common typo in generated Mermaid: `-->|label|> Target` (extra `>`).
// Convert it to `-->|label| Target`.
return line.replace(/(\|[^|\n]+\|)\s*>\s*(?=[A-Za-z0-9_("[{'`])/g, "$1 ");
};
const escapeRegExp = (value: string) =>
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const removeLastDeactivateForParticipant = (
sourceText: string,
participant: string,
) => {
const pattern = new RegExp(
`^\\s*deactivate\\s+${escapeRegExp(participant)}(?:\\s+%%.*)?\\s*$`,
);
const lines = sourceText.split(/\r?\n/);
for (let index = lines.length - 1; index >= 0; index--) {
if (pattern.test(lines[index])) {
return lines.filter((_, lineIndex) => lineIndex !== index).join("\n");
}
}
return null;
};
const removeAllDeactivateForParticipant = (
sourceText: string,
participant: string,
) => {
const pattern = new RegExp(
`^\\s*deactivate\\s+${escapeRegExp(participant)}(?:\\s+%%.*)?\\s*$`,
);
const lines = sourceText.split(/\r?\n/);
let removedAny = false;
const remainingLines = lines.filter((line) => {
if (!pattern.test(line)) {
return true;
}
removedAny = true;
return false;
});
return removedAny ? remainingLines.join("\n") : null;
};
const appendMissingEnds = (sourceText: string) => {
const subgraphCount = (sourceText.match(/^\s*subgraph\b/gm) || []).length;
const endCount = (sourceText.match(/^\s*end\s*$/gm) || []).length;
const missingCount = subgraphCount - endCount;
if (missingCount <= 0) {
return null;
}
const endings = Array.from({ length: missingCount }, () => "end").join("\n");
return `${sourceText.trimEnd()}\n${endings}`;
};
const normalizeSmartQuotes = (sourceText: string) =>
sourceText.replace(/[“”]/g, '"').replace(/[‘’]/g, "'");
export const getMermaidAutoFixCandidates = (
sourceText: string,
errorMessage: string,
) => {
if (!isMermaidAutoFixableError(errorMessage) || !sourceText.trim()) {
return [];
}
const candidates: string[] = [];
const seen = new Set<string>();
const addCandidate = (candidate: string | null) => {
if (!candidate || candidate === sourceText || seen.has(candidate)) {
return;
}
seen.add(candidate);
candidates.push(candidate);
};
const inactiveParticipant = getMermaidInactiveParticipant(errorMessage);
if (inactiveParticipant) {
addCandidate(
removeLastDeactivateForParticipant(sourceText, inactiveParticipant),
);
// Fallback for repeated invalid inactivations in one diagram.
addCandidate(
removeAllDeactivateForParticipant(sourceText, inactiveParticipant),
);
}
if (isMermaidParseSyntaxError(errorMessage)) {
const lines = sourceText.split(/\r?\n/);
const errorLineIndex = getErrorLineIndex(errorMessage, sourceText);
const lineIndexesToTry =
errorLineIndex == null
? []
: [errorLineIndex, errorLineIndex - 1, errorLineIndex + 1];
for (const lineIndex of lineIndexesToTry) {
addCandidate(
replaceLineAt(lines, lineIndex, (line) =>
stripTrailingTokenAfterShape(line),
),
);
addCandidate(
replaceLineAt(lines, lineIndex, (line) =>
removeExtraArrowheadAfterEdgeLabel(line),
),
);
}
// Also try full-text replacement so repeated occurrences on other lines
// are fixed together in a single candidate.
addCandidate(removeExtraArrowheadAfterEdgeLabel(sourceText));
addCandidate(appendMissingEnds(sourceText));
const normalizedQuotes = normalizeSmartQuotes(sourceText);
addCandidate(normalizedQuotes === sourceText ? null : normalizedQuotes);
}
return candidates;
};

View File

@@ -0,0 +1,155 @@
import { describe, expect, it } from "vitest";
import {
formatMermaidParseErrorMessage,
getMermaidErrorLineNumber,
getMermaidInactiveParticipant,
getMermaidSyntaxErrorGuidance,
isMermaidAutoFixableError,
isMermaidParseSyntaxError,
isMermaidCaretLine,
} from "./mermaidError";
describe("formatMermaidParseErrorMessage", () => {
it("strips the noisy Expecting clause from Mermaid parse errors", () => {
const message = `Parse error on line 6:
... Control Protocol)"]x L3_UDP
----------------------^
Expecting 'SEMI', 'NEWLINE', 'SPACE', got 'NODE_STRING'`;
expect(formatMermaidParseErrorMessage(message)).toBe(`Parse error on line 6:
... Control Protocol)"]x L3_UDP
----------------------^`);
});
it("keeps Mermaid parse errors unchanged when no Expecting clause exists", () => {
const message = `Parse error on line 3:
... some snippet
----^`;
expect(formatMermaidParseErrorMessage(message)).toBe(message);
});
it("does not modify non-Mermaid parse messages", () => {
const message =
"Unexpected token while parsing JSON. Expecting value at position 10.";
expect(formatMermaidParseErrorMessage(message)).toBe(message);
});
});
describe("isMermaidCaretLine", () => {
it("returns true for Mermaid caret lines", () => {
expect(isMermaidCaretLine("-----------------------^")).toBe(true);
});
it("returns false for regular lines", () => {
expect(isMermaidCaretLine(`... Control Protocol)"]x`)).toBe(false);
});
});
describe("isMermaidParseSyntaxError", () => {
it("returns true for Mermaid parser syntax errors", () => {
expect(isMermaidParseSyntaxError("Parse error on line 6: ...")).toBe(true);
});
it("returns true for Mermaid lexical syntax errors", () => {
expect(
isMermaidParseSyntaxError("Lexical error on line 2. Unrecognized text."),
).toBe(true);
});
it("returns false for non-parse errors", () => {
expect(isMermaidParseSyntaxError("Network error")).toBe(false);
});
});
describe("isMermaidAutoFixableError", () => {
it("returns true for Mermaid parser syntax errors", () => {
expect(isMermaidAutoFixableError("Parse error on line 6: ...")).toBe(true);
});
it("returns true for inactive participant runtime errors", () => {
expect(
isMermaidAutoFixableError(
"Trying to inactivate an inactive participant (QA)",
),
).toBe(true);
});
it("returns false for non-fixable errors", () => {
expect(isMermaidAutoFixableError("Network error")).toBe(false);
});
});
describe("getMermaidInactiveParticipant", () => {
it("extracts the participant id from inactive participant errors", () => {
expect(
getMermaidInactiveParticipant(
"Trying to inactivate an inactive participant (QA)",
),
).toBe("QA");
});
it("returns null for unrelated errors", () => {
expect(
getMermaidInactiveParticipant("Parse error on line 3: ..."),
).toBeNull();
});
});
describe("getMermaidErrorLineNumber", () => {
it("extracts line number from parse error format", () => {
expect(getMermaidErrorLineNumber("Parse error on line 6: ...")).toBe(6);
});
it("extracts line number from lexical error format", () => {
expect(
getMermaidErrorLineNumber("Lexical error on line 2. Unrecognized text."),
).toBe(2);
});
it("returns null for messages without Mermaid line details", () => {
expect(getMermaidErrorLineNumber("Network error")).toBeNull();
});
it("infers line from inactive participant errors when source text is provided", () => {
const sourceText = `sequenceDiagram
participant QA
deactivate QA
QA->>QA: Verifies Fix
deactivate QA`;
expect(
getMermaidErrorLineNumber(
"Trying to inactivate an inactive participant (QA)",
sourceText,
),
).toBe(5);
});
});
describe("getMermaidSyntaxErrorGuidance", () => {
it("returns summary and likely causes for Mermaid parse errors", () => {
const message = `Parse error on line 6:
... Control Protocol)"]x
----------------------^`;
const source = `graph TD
subgraph Layers["X"]
L3_TCP["TCP (Transmission Control Protocol)"]x`;
expect(getMermaidSyntaxErrorGuidance(message, source)).toEqual({
summary: "Syntax error near line 6.",
likelyCauses: expect.arrayContaining([
"A block is missing an `end` statement.",
]),
});
});
it("returns null for non-parse errors", () => {
expect(
getMermaidSyntaxErrorGuidance("Network error", "graph TD"),
).toBeNull();
});
});

View File

@@ -0,0 +1,133 @@
const MERMAID_SYNTAX_ERROR_LINE = /(?:Parse|Lexical) error on line (\d+)[.:]/i;
const MERMAID_INACTIVE_PARTICIPANT_ERROR =
/Trying to inactivate an inactive participant \((.+)\)/i;
const MERMAID_CARET_LINE = /^\s*-+\^\s*$/;
export const isMermaidParseSyntaxError = (message: string) =>
MERMAID_SYNTAX_ERROR_LINE.test(message);
export const isMermaidAutoFixableError = (message: string) =>
isMermaidParseSyntaxError(message) ||
MERMAID_INACTIVE_PARTICIPANT_ERROR.test(message);
export const isMermaidCaretLine = (line: string) =>
MERMAID_CARET_LINE.test(line);
export const getMermaidInactiveParticipant = (
message: string,
): string | null => {
const match = message.match(MERMAID_INACTIVE_PARTICIPANT_ERROR);
if (!match?.[1]) {
return null;
}
return match[1].trim();
};
const escapeRegExp = (value: string) =>
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const getInactiveParticipantLineNumber = (
message: string,
sourceText: string,
): number | null => {
const participant = getMermaidInactiveParticipant(message);
if (!participant) {
return null;
}
const deactivatePattern = new RegExp(
`^\\s*deactivate\\s+${escapeRegExp(participant)}(?:\\s+%%.*)?\\s*$`,
);
const lines = sourceText.split(/\r?\n/);
for (let index = lines.length - 1; index >= 0; index--) {
if (deactivatePattern.test(lines[index])) {
return index + 1;
}
}
return null;
};
export const getMermaidErrorLineNumber = (
message: string,
sourceText?: string,
): number | null => {
const match = message.match(MERMAID_SYNTAX_ERROR_LINE);
if (!match) {
if (!sourceText) {
return null;
}
return getInactiveParticipantLineNumber(message, sourceText);
}
return Number.parseInt(match[1], 10);
};
const countMatches = (text: string, re: RegExp) =>
(text.match(re) || []).length;
export const getMermaidSyntaxErrorGuidance = (
message: string,
sourceText?: string,
): { summary: string; likelyCauses: string[] } | null => {
if (!isMermaidParseSyntaxError(message)) {
return null;
}
const errorLine = getMermaidErrorLineNumber(message, sourceText);
const summary = errorLine
? `Syntax error near line ${errorLine}.`
: "Syntax error in Mermaid diagram.";
const likelyCauses: string[] = [];
if (sourceText) {
const openBrackets = countMatches(sourceText, /\[/g);
const closeBrackets = countMatches(sourceText, /\]/g);
if (openBrackets !== closeBrackets) {
likelyCauses.push("Unbalanced square brackets in a node label.");
}
const openParens = countMatches(sourceText, /\(/g);
const closeParens = countMatches(sourceText, /\)/g);
if (openParens !== closeParens) {
likelyCauses.push("Unbalanced parentheses in a node shape.");
}
const openBraces = countMatches(sourceText, /\{/g);
const closeBraces = countMatches(sourceText, /\}/g);
if (openBraces !== closeBraces) {
likelyCauses.push("Unbalanced braces in a decision node.");
}
const subgraphCount = countMatches(sourceText, /^\s*subgraph\b/gm);
const endCount = countMatches(sourceText, /^\s*end\s*$/gm);
if (subgraphCount > endCount) {
likelyCauses.push("A block is missing an `end` statement.");
}
}
if (/got 'NODE_STRING'/.test(message) || /got 'PS'/.test(message)) {
likelyCauses.push(
"An extra character/token may appear after a node or label definition.",
);
}
if (likelyCauses.length === 0) {
likelyCauses.push(
"A node or edge line is malformed (missing/extra delimiters).",
);
likelyCauses.push("A block (`subgraph`, `class`, etc.) may be incomplete.");
}
return {
summary,
likelyCauses: [...new Set(likelyCauses)],
};
};
export const formatMermaidParseErrorMessage = (message: string) => {
if (!isMermaidParseSyntaxError(message)) {
return message;
}
return message.replace(/\n\s*Expecting[\s\S]*$/, "").trimEnd();
};

View File

@@ -0,0 +1,214 @@
import { isValidMermaidSyntax } from "./mermaidValidation";
describe("isValidMermaidSyntax", () => {
describe("empty and whitespace content", () => {
it("should return false for empty string", () => {
expect(isValidMermaidSyntax("")).toBe(false);
});
it("should return false for whitespace-only content", () => {
expect(isValidMermaidSyntax(" ")).toBe(false);
expect(isValidMermaidSyntax("\n\n")).toBe(false);
expect(isValidMermaidSyntax("\t\t")).toBe(false);
});
});
describe("balanced brackets, braces, and parentheses", () => {
it("should return true for content with balanced brackets", () => {
expect(isValidMermaidSyntax("flowchart LR\nA[Start]")).toBe(true);
expect(isValidMermaidSyntax("A[Node] --> B[End]")).toBe(true);
expect(isValidMermaidSyntax("[[nested]]")).toBe(true);
});
it("should return true for content with balanced braces", () => {
expect(isValidMermaidSyntax("graph TD\nA{Decision}")).toBe(true);
expect(isValidMermaidSyntax("{{nested}}")).toBe(true);
});
it("should return true for content with balanced parentheses", () => {
expect(isValidMermaidSyntax("flowchart LR\nA(Round)")).toBe(true);
expect(isValidMermaidSyntax("((nested))")).toBe(true);
});
it("should return true for content with multiple balanced delimiters", () => {
expect(isValidMermaidSyntax("A[Node] --> B{Decision} --> C(End)")).toBe(
true,
);
expect(
isValidMermaidSyntax("flowchart\nA[Start] --> B{Check} --> C(Done)"),
).toBe(true);
});
});
describe("unbalanced brackets", () => {
it("should return false for single unclosed bracket", () => {
expect(isValidMermaidSyntax("flowchart LR\nA[Start")).toBe(false);
expect(isValidMermaidSyntax("A[Node")).toBe(false);
});
it("should return false for multiple unclosed brackets", () => {
expect(isValidMermaidSyntax("A[Node\nB[Another")).toBe(false);
expect(isValidMermaidSyntax("[[[text")).toBe(false);
});
it("should return false when closing brackets outnumber opening brackets", () => {
expect(isValidMermaidSyntax("A]")).toBe(false);
expect(isValidMermaidSyntax("text]]]")).toBe(false);
});
});
describe("unbalanced braces", () => {
it("should return false for single unclosed brace", () => {
expect(isValidMermaidSyntax("graph TD\nA{Decision")).toBe(false);
expect(isValidMermaidSyntax("A{Node")).toBe(false);
});
it("should return false for multiple unclosed braces", () => {
expect(isValidMermaidSyntax("A{Node\nB{Another")).toBe(false);
expect(isValidMermaidSyntax("{{{text")).toBe(false);
});
it("should return false when closing braces outnumber opening braces", () => {
expect(isValidMermaidSyntax("A}")).toBe(false);
expect(isValidMermaidSyntax("text}}}")).toBe(false);
});
});
describe("unbalanced parentheses", () => {
it("should return false for single unclosed parenthesis", () => {
expect(isValidMermaidSyntax("flowchart LR\nA(Round")).toBe(false);
expect(isValidMermaidSyntax("A(Node")).toBe(false);
});
it("should return false for multiple unclosed parentheses", () => {
expect(isValidMermaidSyntax("A(Node\nB(Another")).toBe(false);
expect(isValidMermaidSyntax("(((text")).toBe(false);
});
it("should return false when closing parentheses outnumber opening parentheses", () => {
expect(isValidMermaidSyntax("A)")).toBe(false);
expect(isValidMermaidSyntax("text)))")).toBe(false);
});
});
describe("incomplete patterns at end of line", () => {
it("should return false for arrow patterns", () => {
expect(isValidMermaidSyntax("A -->")).toBe(false);
expect(isValidMermaidSyntax("flowchart LR\nA --")).toBe(false);
expect(isValidMermaidSyntax("B --.")).toBe(false);
expect(isValidMermaidSyntax("C ==>")).toBe(false);
expect(isValidMermaidSyntax("D ==")).toBe(false);
expect(isValidMermaidSyntax("E ~~")).toBe(false);
});
it("should return false for colon patterns", () => {
expect(isValidMermaidSyntax("A::")).toBe(false);
expect(isValidMermaidSyntax("B:")).toBe(false);
});
it("should return false for pipe and ampersand patterns", () => {
expect(isValidMermaidSyntax("A|")).toBe(false);
expect(isValidMermaidSyntax("B&")).toBe(false);
});
it("should return true when incomplete patterns are not at the end", () => {
expect(isValidMermaidSyntax("A --> B")).toBe(true);
expect(isValidMermaidSyntax("A -- text --> B")).toBe(true);
expect(isValidMermaidSyntax("A: complete")).toBe(true);
expect(isValidMermaidSyntax("A & B")).toBe(true);
expect(isValidMermaidSyntax("A | B")).toBe(true);
});
it("should only check the last line for incomplete patterns", () => {
expect(isValidMermaidSyntax("A -->\nB --> C")).toBe(true);
expect(isValidMermaidSyntax("A:\nB --> C")).toBe(true);
});
});
describe("complex real-world scenarios", () => {
it("should return true for valid flowchart", () => {
const mermaid = `flowchart TD
Start[Start] --> Decision{Is it?}
Decision -->|Yes| End1[End 1]
Decision -->|No| End2[End 2]`;
expect(isValidMermaidSyntax(mermaid)).toBe(true);
});
it("should return true for valid sequence diagram", () => {
const mermaid = `sequenceDiagram
Alice->>John: Hello John
John-->>Alice: Great!`;
expect(isValidMermaidSyntax(mermaid)).toBe(true);
});
it("should return false for incomplete flowchart (unclosed bracket)", () => {
const mermaid = `flowchart TD
Start[Start --> Decision{Is it?}
Decision -->|Yes| End[End]`;
expect(isValidMermaidSyntax(mermaid)).toBe(false);
});
it("should return false for flowchart with incomplete arrow at end", () => {
const mermaid = `flowchart TD
Start[Start] --> Decision{Is it?}
Decision -->`;
expect(isValidMermaidSyntax(mermaid)).toBe(false);
});
it("should return true for diagram with multiple node types", () => {
const mermaid = `graph LR
A[Square] --> B(Round)
B --> C{Diamond}
C --> D[Square]`;
expect(isValidMermaidSyntax(mermaid)).toBe(true);
});
it("should return false for streaming content that is incomplete", () => {
// Simulates partial AI-generated content during streaming
expect(isValidMermaidSyntax("flowchart LR\nA[Start] --")).toBe(false);
expect(isValidMermaidSyntax("flowchart LR\nA[Start")).toBe(false);
expect(isValidMermaidSyntax("graph TD\nA{Decision")).toBe(false);
});
it("should return true for complete multi-line diagram", () => {
const mermaid = `graph TB
subgraph one
a1[First]
end
subgraph two
a2[Second]
end
a1 --> a2`;
expect(isValidMermaidSyntax(mermaid)).toBe(true);
});
});
describe("edge cases", () => {
it("should handle content with only delimiters", () => {
expect(isValidMermaidSyntax("[]")).toBe(true);
expect(isValidMermaidSyntax("{}")).toBe(true);
expect(isValidMermaidSyntax("()")).toBe(true);
expect(isValidMermaidSyntax("[")).toBe(false);
expect(isValidMermaidSyntax("{")).toBe(false);
expect(isValidMermaidSyntax("(")).toBe(false);
});
it("should handle mixed valid and invalid scenarios", () => {
expect(isValidMermaidSyntax("A[B] -->")).toBe(false);
expect(isValidMermaidSyntax("A[B --")).toBe(false);
expect(isValidMermaidSyntax("A{B:")).toBe(false);
});
it("should handle strings with special characters", () => {
expect(isValidMermaidSyntax("A[Node with spaces]")).toBe(true);
expect(
isValidMermaidSyntax("A[Node with 'quotes' and numbers 123]"),
).toBe(true);
});
it("should trim content before validation", () => {
expect(isValidMermaidSyntax(" A[Node] \n")).toBe(true);
expect(isValidMermaidSyntax("\n\nA[Node]\n\n")).toBe(true);
});
});
});

View File

@@ -0,0 +1,41 @@
export const isValidMermaidSyntax = (content: string): boolean => {
const trimmed = content.trim();
if (!trimmed) {
return false;
}
const openBrackets = (trimmed.match(/\[/g) || []).length;
const closeBrackets = (trimmed.match(/\]/g) || []).length;
const openBraces = (trimmed.match(/\{/g) || []).length;
const closeBraces = (trimmed.match(/\}/g) || []).length;
const openParens = (trimmed.match(/\(/g) || []).length;
const closeParens = (trimmed.match(/\)/g) || []).length;
if (
openBrackets !== closeBrackets ||
openBraces !== closeBraces ||
openParens !== closeParens
) {
return false;
}
const lastLine = trimmed.split("\n").pop()?.trim() || "";
const incompletePatterns = [
/-->$/,
/--$/,
/-\.$/,
/==>$/,
/==$/,
/~~$/,
/::$/,
/:$/,
/\|$/,
/&$/,
];
if (incompletePatterns.some((pattern) => pattern.test(lastLine))) {
return false;
}
return true;
};