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:
Deon Sanchez 2025-06-30 15:13:04 -06:00 committed by GitHub
commit ff00eb582f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 5004 additions and 73 deletions

View 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))"],
};

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}
}

View file

@ -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";

View file

@ -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 (

View 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;
});

View 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 | |");
});
});
});

View 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;
};