diff --git a/src/frontend/src/components/core/chatComponents/ContentDisplay.tsx b/src/frontend/src/components/core/chatComponents/ContentDisplay.tsx index e256384bf78f..855c2b0e9c61 100644 --- a/src/frontend/src/components/core/chatComponents/ContentDisplay.tsx +++ b/src/frontend/src/components/core/chatComponents/ContentDisplay.tsx @@ -3,6 +3,7 @@ import Markdown from "react-markdown"; import rehypeMathjax from "rehype-mathjax/browser"; import remarkGfm from "remark-gfm"; import type { ContentType } from "@/types/chat"; +import { extractLanguage, isCodeBlock } from "@/utils/codeBlockUtils"; import ForwardedIconComponent from "../../common/genericIconComponent"; import SimplifiedCodeTabComponent from "../codeTabsComponent"; import DurationDisplay from "./DurationDisplay"; @@ -74,7 +75,6 @@ export default function ContentDisplay({ return <>{props.children}; }, code: ({ node, className, children, ...props }) => { - const inline = !(props as any).hasOwnProperty("data-language"); let content = children as string; if ( Array.isArray(children) && @@ -90,14 +90,16 @@ export default function ContentDisplay({ } } - const match = /language-(\w+)/.exec(className || ""); + if (isCodeBlock(className, props, content)) { + return ( + + ); + } - return !inline ? ( - - ) : ( + return ( {content} @@ -171,16 +173,16 @@ export default function ContentDisplay({ return ; }, code: ({ node, className, children, ...props }) => { - const inline = !(props as any).hasOwnProperty( - "data-language", - ); - const match = /language-(\w+)/.exec(className || ""); - return !inline ? ( - - ) : ( + const content = String(children); + if (isCodeBlock(className, props, content)) { + return ( + + ); + } + return ( {children} diff --git a/src/frontend/src/modals/IOModal/components/chatView/chatMessage/components/content-view.tsx b/src/frontend/src/modals/IOModal/components/chatView/chatMessage/components/content-view.tsx index 31ac6be427df..dc78b85cc372 100644 --- a/src/frontend/src/modals/IOModal/components/chatView/chatMessage/components/content-view.tsx +++ b/src/frontend/src/modals/IOModal/components/chatView/chatMessage/components/content-view.tsx @@ -3,6 +3,7 @@ import Markdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { ForwardedIconComponent } from "@/components/common/genericIconComponent"; import { TextShimmer } from "@/components/ui/TextShimmer"; +import { extractLanguage, isCodeBlock } from "@/utils/codeBlockUtils"; import { cn } from "@/utils/utils"; import CodeTabsComponent from "../../../../../../components/core/codeTabsComponent"; import LogoIcon from "./chat-logo-icon"; @@ -121,9 +122,6 @@ export const ErrorView = ({ children, ...props }) => { - const inline = !( - props as any - ).hasOwnProperty("data-language"); let content = children as string; if ( Array.isArray(children) && @@ -141,19 +139,23 @@ export const ErrorView = ({ } } - const match = /language-(\w+)/.exec( - className || "", - ); + if ( + isCodeBlock(className, props, content) + ) { + return ( + + ); + } - return !inline ? ( - - ) : ( + return ( { - const inline = !(props as any).hasOwnProperty("data-language"); let content = children as string; if ( Array.isArray(children) && @@ -88,14 +88,16 @@ export const MarkdownField = ({ } } - const match = /language-(\w+)/.exec(className || ""); + if (isCodeBlock(className, props, content)) { + return ( + + ); + } - return !inline ? ( - - ) : ( + return ( {content} diff --git a/src/frontend/src/utils/__tests__/codeBlockUtils.test.ts b/src/frontend/src/utils/__tests__/codeBlockUtils.test.ts new file mode 100644 index 000000000000..7e5045197987 --- /dev/null +++ b/src/frontend/src/utils/__tests__/codeBlockUtils.test.ts @@ -0,0 +1,172 @@ +import { extractLanguage, isCodeBlock } from "../codeBlockUtils"; + +describe("codeBlockUtils", () => { + describe("isCodeBlock", () => { + describe("should return true (block code)", () => { + it("should_return_true_when_className_has_language_identifier", () => { + const result = isCodeBlock("language-python", {}, "print('hello')"); + expect(result).toBe(true); + }); + + it("should_return_true_when_className_has_language_javascript", () => { + const result = isCodeBlock("language-javascript", {}, "const x = 1"); + expect(result).toBe(true); + }); + + it("should_return_true_when_className_has_language_with_other_classes", () => { + const result = isCodeBlock( + "hljs language-typescript some-other-class", + {}, + "const x: number = 1", + ); + expect(result).toBe(true); + }); + + it("should_return_true_when_props_has_data_language", () => { + const result = isCodeBlock( + undefined, + { "data-language": "python" }, + "print('hello')", + ); + expect(result).toBe(true); + }); + + it("should_return_true_when_props_has_data_language_empty_string", () => { + const result = isCodeBlock(undefined, { "data-language": "" }, "code"); + expect(result).toBe(true); + }); + + it("should_return_true_when_content_has_newlines", () => { + const result = isCodeBlock(undefined, {}, "line1\nline2\nline3"); + expect(result).toBe(true); + }); + + it("should_return_true_when_content_has_single_newline", () => { + const result = isCodeBlock(undefined, {}, "line1\nline2"); + expect(result).toBe(true); + }); + + it("should_return_true_when_multiple_conditions_are_met", () => { + const result = isCodeBlock( + "language-python", + { "data-language": "python" }, + "def hello():\n print('world')", + ); + expect(result).toBe(true); + }); + }); + + describe("should return false (inline code)", () => { + it("should_return_false_when_no_language_class_no_data_language_no_newlines", () => { + const result = isCodeBlock(undefined, {}, "inline code"); + expect(result).toBe(false); + }); + + it("should_return_false_when_className_is_empty_string", () => { + const result = isCodeBlock("", {}, "simple code"); + expect(result).toBe(false); + }); + + it("should_return_false_when_className_has_no_language_prefix", () => { + const result = isCodeBlock("hljs some-class", {}, "code"); + expect(result).toBe(false); + }); + + it("should_return_false_when_props_is_undefined", () => { + const result = isCodeBlock(undefined, undefined, "inline"); + expect(result).toBe(false); + }); + + it("should_return_false_when_props_is_empty_object", () => { + const result = isCodeBlock(undefined, {}, "x = 1"); + expect(result).toBe(false); + }); + + it("should_return_false_when_content_is_single_line", () => { + const result = isCodeBlock(undefined, {}, "print('hello')"); + expect(result).toBe(false); + }); + + it("should_return_false_when_content_is_empty", () => { + const result = isCodeBlock(undefined, {}, ""); + expect(result).toBe(false); + }); + }); + + describe("edge cases", () => { + it("should_handle_language_class_with_numbers", () => { + const result = isCodeBlock("language-es2020", {}, "code"); + expect(result).toBe(true); + }); + + it("should_handle_content_with_carriage_return_only", () => { + // \r alone should not be treated as newline for code block + const result = isCodeBlock(undefined, {}, "line1\rline2"); + expect(result).toBe(false); + }); + + it("should_handle_content_with_crlf", () => { + const result = isCodeBlock(undefined, {}, "line1\r\nline2"); + expect(result).toBe(true); + }); + + it("should_handle_content_with_trailing_newline_only", () => { + const result = isCodeBlock(undefined, {}, "single line\n"); + expect(result).toBe(true); + }); + + it("should_handle_content_with_leading_newline_only", () => { + const result = isCodeBlock(undefined, {}, "\nsingle line"); + expect(result).toBe(true); + }); + + it("should_handle_null_props_gracefully", () => { + // TypeScript would prevent this, but testing runtime safety + const result = isCodeBlock(undefined, null as any, "code"); + expect(result).toBe(false); + }); + }); + }); + + describe("extractLanguage", () => { + it("should_extract_python_from_language_class", () => { + const result = extractLanguage("language-python"); + expect(result).toBe("python"); + }); + + it("should_extract_javascript_from_language_class", () => { + const result = extractLanguage("language-javascript"); + expect(result).toBe("javascript"); + }); + + it("should_extract_language_when_mixed_with_other_classes", () => { + const result = extractLanguage("hljs language-typescript highlight"); + expect(result).toBe("typescript"); + }); + + it("should_return_empty_string_when_no_language_class", () => { + const result = extractLanguage("hljs some-class"); + expect(result).toBe(""); + }); + + it("should_return_empty_string_when_className_is_undefined", () => { + const result = extractLanguage(undefined); + expect(result).toBe(""); + }); + + it("should_return_empty_string_when_className_is_empty", () => { + const result = extractLanguage(""); + expect(result).toBe(""); + }); + + it("should_handle_language_with_numbers", () => { + const result = extractLanguage("language-es6"); + expect(result).toBe("es6"); + }); + + it("should_extract_first_language_when_multiple_present", () => { + const result = extractLanguage("language-python language-javascript"); + expect(result).toBe("python"); + }); + }); +}); diff --git a/src/frontend/src/utils/codeBlockUtils.ts b/src/frontend/src/utils/codeBlockUtils.ts new file mode 100644 index 000000000000..f8284050e3b0 --- /dev/null +++ b/src/frontend/src/utils/codeBlockUtils.ts @@ -0,0 +1,30 @@ +/** + * Determines if a code element should be rendered as a block (with copy button) + * or as inline code. + * + * A code element is considered a block if any of the following conditions are met: + * 1. It has a language class (e.g., "language-python") + * 2. It has the "data-language" attribute (from some markdown parsers) + * 3. The content contains newlines (multi-line code) + */ +export function isCodeBlock( + className: string | undefined, + props: Record | undefined, + content: string, +): boolean { + const languageMatch = /language-(\w+)/.exec(className ?? ""); + const hasLanguageClass = !!languageMatch; + const hasDataLanguage = "data-language" in (props ?? {}); + const hasNewlines = content.includes("\n"); + + return hasLanguageClass || hasDataLanguage || hasNewlines; +} + +/** + * Extracts the language identifier from a className. + * Returns empty string if no language is found. + */ +export function extractLanguage(className: string | undefined): string { + const match = /language-(\w+)/.exec(className ?? ""); + return match?.[1] ?? ""; +} diff --git a/src/frontend/tests/core/features/freeze-path.spec.ts b/src/frontend/tests/core/features/freeze-path.spec.ts index 5d3513813d9a..26f34f8c6990 100644 --- a/src/frontend/tests/core/features/freeze-path.spec.ts +++ b/src/frontend/tests/core/features/freeze-path.spec.ts @@ -1,4 +1,3 @@ -import { type Page } from "@playwright/test"; import * as dotenv from "dotenv"; import path from "path"; import { expect, test } from "../../fixtures"; @@ -6,7 +5,6 @@ import { addFlowToTestOnEmptyLangflow } from "../../utils/add-flow-to-test-on-em import { adjustScreenView } from "../../utils/adjust-screen-view"; import { awaitBootstrapTest } from "../../utils/await-bootstrap-test"; import { initialGPTsetup } from "../../utils/initialGPTsetup"; -import { selectGptModel } from "../../utils/select-gpt-model"; test( "user must be able to freeze a path", @@ -37,11 +35,13 @@ test( await initialGPTsetup(page); + // Use unique prompts to avoid OpenAI caching returning identical responses + const timestamp = Date.now(); await page .getByTestId("textarea_str_input_value") .first() .fill( - "say a random number between 1 and 300000 and a random animal that lives in the sea", + `say a random number between 1 and 300000 and a random animal that lives in the sea. Request ID: ${timestamp}-1`, ); await adjustScreenView(page); @@ -66,8 +66,13 @@ test( await page.getByText("Close").last().click(); - // Change model to force different output - await selectGptModel(page); + // Change the prompt to ensure different output (avoid OpenAI caching) + await page + .getByTestId("textarea_str_input_value") + .first() + .fill( + `say a random number between 1 and 300000 and a random animal that lives in the sea. Request ID: ${timestamp}-2`, + ); await page.waitForSelector('[data-testid="button_run_chat output"]', { timeout: 3000, @@ -133,27 +138,3 @@ test( expect(secondRandomTextGeneratedByAI).toEqual(thirdRandomTextGeneratedByAI); }, ); - -async function _moveSlider( - page: Page, - side: "left" | "right", - advanced: boolean = false, -) { - const thumbSelector = `slider_thumb${advanced ? "_advanced" : ""}`; - const trackSelector = `slider_track${advanced ? "_advanced" : ""}`; - - await page.getByTestId(thumbSelector).click(); - - const trackBoundingBox = await page.getByTestId(trackSelector).boundingBox(); - - if (trackBoundingBox) { - const moveDistance = - trackBoundingBox.width * 0.1 * (side === "left" ? -1 : 1); - const centerX = trackBoundingBox.x + trackBoundingBox.width / 2; - const centerY = trackBoundingBox.y + trackBoundingBox.height / 2; - - await page.mouse.move(centerX + moveDistance, centerY); - await page.mouse.down(); - await page.mouse.up(); - } -} diff --git a/src/frontend/tests/core/integrations/Research Translation Loop.spec.ts b/src/frontend/tests/core/integrations/Research Translation Loop.spec.ts index e899c30b2ccd..e9a510cf7e0b 100644 --- a/src/frontend/tests/core/integrations/Research Translation Loop.spec.ts +++ b/src/frontend/tests/core/integrations/Research Translation Loop.spec.ts @@ -4,6 +4,7 @@ import { expect, test } from "../../fixtures"; import { awaitBootstrapTest } from "../../utils/await-bootstrap-test"; import { initialGPTsetup } from "../../utils/initialGPTsetup"; import { withEventDeliveryModes } from "../../utils/withEventDeliveryModes"; +import { selectGptModel } from "../../utils/select-gpt-model"; withEventDeliveryModes( "Research Translation Loop.spec", @@ -31,25 +32,13 @@ withEventDeliveryModes( await initialGPTsetup(page, { skipAdjustScreenView: true, - skipAddNewApiKeys: true, skipSelectGptModel: true, }); // TODO: Uncomment this when we have a way to test Anthropic // await page.getByTestId("dropdown_str_provider").click(); // await page.getByTestId("Anthropic-1-option").click(); - await page - .getByTestId("popover-anchor-input-api_key") - .last() - .fill(process.env.OPENAI_API_KEY ?? ""); - - await page.waitForSelector('[data-testid="dropdown_str_model_name"]', { - timeout: 5000, - }); - - await page.getByTestId("dropdown_str_model_name").click(); - - await page.keyboard.press("Enter"); + await selectGptModel(page); await page.getByTestId("playground-btn-flow-io").click(); diff --git a/src/frontend/tests/extended/regression/generalBugs-shard-3.spec.ts b/src/frontend/tests/extended/regression/generalBugs-shard-3.spec.ts index 996199872aa4..1cebbad2d061 100644 --- a/src/frontend/tests/extended/regression/generalBugs-shard-3.spec.ts +++ b/src/frontend/tests/extended/regression/generalBugs-shard-3.spec.ts @@ -59,6 +59,10 @@ test( await initialGPTsetup(page); await adjustScreenView(page); + await page + .getByTestId("popover-anchor-input-api_key") + .fill(process.env.OPENAI_API_KEY || ""); + await page .getByTestId("handle-chatinput-noshownode-chat message-source") .click(); diff --git a/src/frontend/tests/utils/add-open-ai-input-key.ts b/src/frontend/tests/utils/add-open-ai-input-key.ts new file mode 100644 index 000000000000..413c443b2b83 --- /dev/null +++ b/src/frontend/tests/utils/add-open-ai-input-key.ts @@ -0,0 +1,20 @@ +import type { Page } from "@playwright/test"; + +export const addOpenAiInputKey = async (page: Page) => { + const numberOfOpenAiFields = await page + .getByTestId("popover-anchor-input-openai_api_key") + .count(); + + for (let i = 0; i < numberOfOpenAiFields; i++) { + const openAiInput = page + .getByTestId("popover-anchor-input-openai_api_key") + .nth(i); + const inputValue = await openAiInput.inputValue(); + + if (!inputValue) { + await openAiInput.fill(process.env.OPENAI_API_KEY!); + } + + await page.waitForTimeout(500); + } +}; diff --git a/src/frontend/tests/utils/adjust-screen-view.ts b/src/frontend/tests/utils/adjust-screen-view.ts index 009be25fe888..2a05da5ebb69 100644 --- a/src/frontend/tests/utils/adjust-screen-view.ts +++ b/src/frontend/tests/utils/adjust-screen-view.ts @@ -9,7 +9,7 @@ export async function adjustScreenView( } = {}, ) { await page.waitForSelector('[data-testid="canvas_controls_dropdown"]', { - timeout: 5000, + timeout: 30000, }); let fitViewButton = await page.getByTestId("fit_view").count(); diff --git a/src/frontend/tests/utils/initialGPTsetup.ts b/src/frontend/tests/utils/initialGPTsetup.ts index 4b01606360ab..cf7ffa5e9e7d 100644 --- a/src/frontend/tests/utils/initialGPTsetup.ts +++ b/src/frontend/tests/utils/initialGPTsetup.ts @@ -2,6 +2,7 @@ import type { Page } from "@playwright/test"; import { adjustScreenView } from "./adjust-screen-view"; import { selectGptModel } from "./select-gpt-model"; import { updateOldComponents } from "./update-old-components"; +import { addOpenAiInputKey } from "./add-open-ai-input-key"; export async function initialGPTsetup( page: Page, @@ -9,6 +10,7 @@ export async function initialGPTsetup( skipAdjustScreenView?: boolean; skipUpdateOldComponents?: boolean; skipSelectGptModel?: boolean; + skipAddOpenAiInputKey?: boolean; }, ) { if (!options?.skipAdjustScreenView) { @@ -20,4 +22,7 @@ export async function initialGPTsetup( if (!options?.skipSelectGptModel) { await selectGptModel(page); } + if (!options?.skipAddOpenAiInputKey) { + await addOpenAiInputKey(page); + } } diff --git a/src/frontend/tests/utils/select-gpt-model.ts b/src/frontend/tests/utils/select-gpt-model.ts index c64a4e0d53e2..783c322aaa90 100644 --- a/src/frontend/tests/utils/select-gpt-model.ts +++ b/src/frontend/tests/utils/select-gpt-model.ts @@ -9,11 +9,14 @@ export const selectGptModel = async (page: Page) => { const gptOMiniOption = await page.getByTestId("gpt-4o-mini-option").count(); + await page.waitForTimeout(500); + if (gptOMiniOption === 0) { await page.getByTestId("manage-model-providers").click(); await page.waitForSelector("text=Model providers", { timeout: 30000 }); await page.getByTestId("provider-item-OpenAI").click(); + await page.waitForTimeout(500); const checkExistingKey = await page.getByTestId("input-end-icon").count(); if (checkExistingKey === 0) { @@ -26,6 +29,8 @@ export const selectGptModel = async (page: Page) => { await page.getByTestId("llm-toggle-gpt-4o-mini").click(); await page.getByText("Close").last().click(); } else { + await page.waitForTimeout(500); + const isChecked = await page .getByTestId("llm-toggle-gpt-4o-mini") .isChecked(); @@ -36,7 +41,7 @@ export const selectGptModel = async (page: Page) => { await page.getByTestId("model_model").nth(i).click(); } } - + await page.waitForTimeout(500); await page.getByTestId("gpt-4o-mini-option").click(); } };