fix: improves table formatting in the playground and adds Jest tests (#8743)
* feat(tests): add Jest configuration and setup for testing environment - Introduced Jest configuration file to set up testing environment with TypeScript support and JSDOM. - Added setupTests.ts for global test configurations, including mocks for ResizeObserver and IntersectionObserver. - Updated package.json and package-lock.json to include Jest and related dependencies. - Implemented utility functions for processing markdown content, including handling tables and <think> tags. - Added comprehensive tests for markdown utility functions to ensure proper functionality. * refactor(makefile): separate frontend commands into a dedicated Makefile - Removed frontend-related targets from the main Makefile and created a new Makefile.frontend to manage frontend-specific commands. - Updated the main Makefile to include a reference to the new frontend Makefile and added a help message for frontend commands. - This restructuring improves organization and clarity for managing backend and frontend build processes.
This commit is contained in:
parent
0034c0b8cc
commit
ff00eb582f
10 changed files with 5004 additions and 73 deletions
20
src/frontend/jest.config.js
Normal file
20
src/frontend/jest.config.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "jsdom",
|
||||
injectGlobals: true,
|
||||
moduleNameMapper: {
|
||||
"^@/(.*)$": "<rootDir>/src/$1",
|
||||
"\\.(css|less|scss|sass)$": "identity-obj-proxy",
|
||||
},
|
||||
setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"],
|
||||
testMatch: [
|
||||
"<rootDir>/src/**/__tests__/**/*.{ts,tsx}",
|
||||
"<rootDir>/src/**/*.{test,spec}.{ts,tsx}",
|
||||
],
|
||||
transform: {
|
||||
"^.+\\.(ts|tsx)$": "ts-jest",
|
||||
},
|
||||
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json"],
|
||||
// Ignore node_modules except for packages that need transformation
|
||||
transformIgnorePatterns: ["node_modules/(?!(.*\\.mjs$|@testing-library))"],
|
||||
};
|
||||
4455
src/frontend/package-lock.json
generated
4455
src/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -93,6 +93,8 @@
|
|||
"dev:docker": "vite --host 0.0.0.0",
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"serve": "vite preview",
|
||||
"format": "npx prettier --write \"{tests,src}/**/*.{js,jsx,ts,tsx,json,md}\" --ignore-path .prettierignore",
|
||||
"check-format": "npx prettier --check \"{tests,src}/**/*.{js,jsx,ts,tsx,json,md}\" --ignore-path .prettierignore",
|
||||
|
|
@ -118,6 +120,7 @@
|
|||
},
|
||||
"proxy": "http://localhost:7860",
|
||||
"devDependencies": {
|
||||
"@jest/types": "^30.0.1",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@swc/cli": "^0.5.2",
|
||||
"@swc/core": "^1.6.1",
|
||||
|
|
@ -135,6 +138,8 @@
|
|||
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^9.5.0",
|
||||
"jest": "^30.0.3",
|
||||
"jest-environment-jsdom": "^30.0.2",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-organize-imports": "^3.2.4",
|
||||
|
|
@ -142,8 +147,9 @@
|
|||
"simple-git-hooks": "^2.11.1",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tailwindcss-dotted-background": "^1.1.0",
|
||||
"ts-jest": "^29.4.0",
|
||||
"typescript": "^5.4.5",
|
||||
"ua-parser-js": "^1.0.38",
|
||||
"vite": "^5.4.19"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { ProfileIcon } from "@/components/core/appHeaderComponent/components/ProfileIcon";
|
||||
import { ContentBlockDisplay } from "@/components/core/chatComponents/ContentBlockDisplay";
|
||||
import { useUpdateMessage } from "@/controllers/API/queries/messages";
|
||||
import { CustomProfileIcon } from "@/customization/components/custom-profile-icon";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { EMPTY_OUTPUT_SEND_MESSAGE } from "@/constants/constants";
|
||||
import { preprocessChatMessage } from "@/utils/markdownUtils";
|
||||
import { cn } from "@/utils/utils";
|
||||
import Markdown from "react-markdown";
|
||||
import rehypeMathjax from "rehype-mathjax";
|
||||
|
|
@ -14,14 +15,6 @@ type MarkdownFieldProps = {
|
|||
isAudioMessage?: boolean;
|
||||
};
|
||||
|
||||
// Function to replace <think> tags with a placeholder before markdown processing
|
||||
const preprocessChatMessage = (text: string): string => {
|
||||
// Replace <think> tags with `<span class="think-tag">think:</span>`
|
||||
return text
|
||||
.replace(/<think>/g, "`<think>`")
|
||||
.replace(/<\/think>/g, "`</think>`");
|
||||
};
|
||||
|
||||
export const MarkdownField = ({
|
||||
chat,
|
||||
isEmpty,
|
||||
|
|
@ -29,7 +22,7 @@ export const MarkdownField = ({
|
|||
editedFlag,
|
||||
isAudioMessage,
|
||||
}: MarkdownFieldProps) => {
|
||||
// Process the chat message to handle <think> tags
|
||||
// Process the chat message to handle <think> tags and clean up tables
|
||||
const processedChatMessage = preprocessChatMessage(chatMessage);
|
||||
|
||||
return (
|
||||
|
|
|
|||
62
src/frontend/src/setupTests.ts
Normal file
62
src/frontend/src/setupTests.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// Jest setup file for testing environment
|
||||
import "@testing-library/jest-dom";
|
||||
|
||||
// Mock ResizeObserver if not available in test environment
|
||||
global.ResizeObserver = jest.fn().mockImplementation(() => ({
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock IntersectionObserver if not available in test environment
|
||||
global.IntersectionObserver = jest.fn().mockImplementation(() => ({
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock window.matchMedia for components that use it
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(), // deprecated
|
||||
removeListener: jest.fn(), // deprecated
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Suppress console.error and console.warn in tests unless explicitly needed
|
||||
const originalError = console.error;
|
||||
const originalWarn = console.warn;
|
||||
|
||||
beforeAll(() => {
|
||||
console.error = (...args) => {
|
||||
if (
|
||||
typeof args[0] === "string" &&
|
||||
args[0].includes("Warning: ReactDOM.render is deprecated")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalError.call(console, ...args);
|
||||
};
|
||||
|
||||
console.warn = (...args) => {
|
||||
if (
|
||||
typeof args[0] === "string" &&
|
||||
args[0].includes("componentWillReceiveProps has been renamed")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalWarn.call(console, ...args);
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
console.error = originalError;
|
||||
console.warn = originalWarn;
|
||||
});
|
||||
215
src/frontend/src/utils/__tests__/markdownUtils.test.ts
Normal file
215
src/frontend/src/utils/__tests__/markdownUtils.test.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import {
|
||||
cleanupTableEmptyCells,
|
||||
isMarkdownTable,
|
||||
preprocessChatMessage,
|
||||
} from "../markdownUtils";
|
||||
|
||||
describe("markdownUtils", () => {
|
||||
describe("isMarkdownTable", () => {
|
||||
it("should return true for valid markdown table", () => {
|
||||
const table = `| Header 1 | Header 2 |
|
||||
|----------|----------|
|
||||
| Cell 1 | Cell 2 |`;
|
||||
expect(isMarkdownTable(table)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for table with alignment", () => {
|
||||
const table = `| Left | Center | Right |
|
||||
|:-----|:------:|------:|
|
||||
| L1 | C1 | R1 |`;
|
||||
expect(isMarkdownTable(table)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for non-table content", () => {
|
||||
expect(isMarkdownTable("Just some text")).toBe(false);
|
||||
expect(isMarkdownTable("# Header\nSome content")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for empty or null input", () => {
|
||||
expect(isMarkdownTable("")).toBe(false);
|
||||
expect(isMarkdownTable(" ")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for table without separator", () => {
|
||||
const invalidTable = `| Header 1 | Header 2 |
|
||||
| Cell 1 | Cell 2 |`;
|
||||
expect(isMarkdownTable(invalidTable)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true for table with extra whitespace", () => {
|
||||
const table = ` | Header 1 | Header 2 |
|
||||
|----------|----------|
|
||||
| Cell 1 | Cell 2 | `;
|
||||
expect(isMarkdownTable(table)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupTableEmptyCells", () => {
|
||||
it("should remove completely empty rows", () => {
|
||||
const table = `| Header 1 | Header 2 |
|
||||
|----------|----------|
|
||||
| Cell 1 | Cell 2 |
|
||||
| | |
|
||||
| Cell 3 | Cell 4 |`;
|
||||
|
||||
const expected = `| Header 1 | Header 2 |
|
||||
|----------|----------|
|
||||
| Cell 1 | Cell 2 |
|
||||
| Cell 3 | Cell 4 |`;
|
||||
|
||||
expect(cleanupTableEmptyCells(table)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should keep rows with at least one non-empty cell", () => {
|
||||
const table = `| Header 1 | Header 2 |
|
||||
|----------|----------|
|
||||
| Cell 1 | |
|
||||
| | Cell 2 |`;
|
||||
|
||||
const expected = `| Header 1 | Header 2 |
|
||||
|----------|----------|
|
||||
| Cell 1 | |
|
||||
| | Cell 2 |`;
|
||||
|
||||
expect(cleanupTableEmptyCells(table)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should preserve separator rows", () => {
|
||||
const table = `| Header 1 | Header 2 |
|
||||
|----------|----------|
|
||||
| | |`;
|
||||
|
||||
const expected = `| Header 1 | Header 2 |
|
||||
|----------|----------|`;
|
||||
|
||||
expect(cleanupTableEmptyCells(table)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should handle mixed content with tables", () => {
|
||||
const content = `Some text before
|
||||
|
||||
| Header 1 | Header 2 |
|
||||
|----------|----------|
|
||||
| Cell 1 | Cell 2 |
|
||||
| | |
|
||||
|
||||
Some text after`;
|
||||
|
||||
const expected = `Some text before
|
||||
|
||||
| Header 1 | Header 2 |
|
||||
|----------|----------|
|
||||
| Cell 1 | Cell 2 |
|
||||
|
||||
Some text after`;
|
||||
|
||||
expect(cleanupTableEmptyCells(content)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should handle non-table content unchanged", () => {
|
||||
const content = `# Header
|
||||
This is just regular text.
|
||||
No tables here.`;
|
||||
|
||||
expect(cleanupTableEmptyCells(content)).toBe(content);
|
||||
});
|
||||
|
||||
it("should handle empty input", () => {
|
||||
expect(cleanupTableEmptyCells("")).toBe("");
|
||||
expect(cleanupTableEmptyCells(" \n \n ")).toBe(" \n \n ");
|
||||
});
|
||||
|
||||
it("should handle table with alignment separators", () => {
|
||||
const table = `| Left | Center | Right |
|
||||
|:-----|:------:|------:|
|
||||
| L1 | C1 | R1 |
|
||||
| | | |`;
|
||||
|
||||
const expected = `| Left | Center | Right |
|
||||
|:-----|:------:|------:|
|
||||
| L1 | C1 | R1 |`;
|
||||
|
||||
expect(cleanupTableEmptyCells(table)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preprocessChatMessage", () => {
|
||||
it("should replace <think> tags with backticks", () => {
|
||||
const message = "Before <think>thinking</think> after";
|
||||
const expected = "Before `<think>`thinking`</think>` after";
|
||||
expect(preprocessChatMessage(message)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should handle multiple <think> tags", () => {
|
||||
const message = "<think>first</think> and <think>second</think>";
|
||||
const expected = "`<think>`first`</think>` and `<think>`second`</think>`";
|
||||
expect(preprocessChatMessage(message)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should clean up tables when present", () => {
|
||||
const message = `<think>analyzing</think>
|
||||
|
||||
| Header 1 | Header 2 |
|
||||
|----------|----------|
|
||||
| Cell 1 | Cell 2 |
|
||||
| | |`;
|
||||
|
||||
const result = preprocessChatMessage(message);
|
||||
|
||||
// Should replace think tags
|
||||
expect(result).toContain("`<think>`analyzing`</think>`");
|
||||
|
||||
// Should remove empty table row
|
||||
expect(result).not.toContain("| | |");
|
||||
|
||||
// Should keep good content
|
||||
expect(result).toContain("| Cell 1 | Cell 2 |");
|
||||
});
|
||||
|
||||
it("should handle messages without tables", () => {
|
||||
const message = "<think>pondering</think> Just some regular text";
|
||||
const expected = "`<think>`pondering`</think>` Just some regular text";
|
||||
expect(preprocessChatMessage(message)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should handle messages without think tags", () => {
|
||||
const message = `| Header 1 | Header 2 |
|
||||
|----------|----------|
|
||||
| Cell 1 | Cell 2 |
|
||||
| | |`;
|
||||
|
||||
const result = preprocessChatMessage(message);
|
||||
expect(result).not.toContain("| | |");
|
||||
expect(result).toContain("| Cell 1 | Cell 2 |");
|
||||
});
|
||||
|
||||
it("should handle empty messages", () => {
|
||||
expect(preprocessChatMessage("")).toBe("");
|
||||
expect(preprocessChatMessage(" ")).toBe(" ");
|
||||
});
|
||||
|
||||
it("should handle complex nested scenarios", () => {
|
||||
const message = `<think>Let me create a table</think>
|
||||
|
||||
| Name | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| John | Active | Good |
|
||||
| | | |
|
||||
| Jane | Active | |
|
||||
|
||||
<think>Done</think>`;
|
||||
|
||||
const result = preprocessChatMessage(message);
|
||||
|
||||
// Think tags should be replaced
|
||||
expect(result).toContain("`<think>`Let me create a table`</think>`");
|
||||
expect(result).toContain("`<think>`Done`</think>`");
|
||||
|
||||
// Empty row should be removed
|
||||
expect(result).not.toContain("| | | |");
|
||||
|
||||
// Partial row should be kept
|
||||
expect(result).toContain("| Jane | Active | |");
|
||||
});
|
||||
});
|
||||
});
|
||||
52
src/frontend/src/utils/markdownUtils.ts
Normal file
52
src/frontend/src/utils/markdownUtils.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Utility functions for processing markdown content, particularly tables
|
||||
*/
|
||||
|
||||
/**
|
||||
* Detects if the given text contains a markdown table
|
||||
*/
|
||||
export const isMarkdownTable = (text: string): boolean => {
|
||||
if (!text?.trim()) return false;
|
||||
|
||||
// Single regex to detect markdown table with header separator
|
||||
return /\|.*\|.*\n\s*\|[\s\-:]+\|/m.test(text);
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes completely empty rows from markdown tables
|
||||
*/
|
||||
export const cleanupTableEmptyCells = (text: string): string => {
|
||||
return text
|
||||
.split("\n")
|
||||
.filter((line) => {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Keep non-table lines
|
||||
if (!trimmed.includes("|")) return true;
|
||||
|
||||
// Keep separator rows (contain only |, -, :, spaces)
|
||||
if (/^\|[\s\-:]+\|$/.test(trimmed)) return true;
|
||||
|
||||
// For data rows, check if any cell has content
|
||||
const cells = trimmed.split("|").slice(1, -1); // Remove delimiter cells
|
||||
return cells.some((cell) => cell.trim() !== "");
|
||||
})
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
/**
|
||||
* Preprocesses chat messages by handling <think> tags and cleaning up tables
|
||||
*/
|
||||
export const preprocessChatMessage = (text: string): string => {
|
||||
// Handle <think> tags
|
||||
let processed = text
|
||||
.replace(/<think>/g, "`<think>`")
|
||||
.replace(/<\/think>/g, "`</think>`");
|
||||
|
||||
// Clean up tables if present
|
||||
if (isMarkdownTable(processed)) {
|
||||
processed = cleanupTableEmptyCells(processed);
|
||||
}
|
||||
|
||||
return processed;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue