mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-24 06:10:49 +08:00
test: Extract code block detection into shared utilities (#11222)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
17872f672b
commit
7e77cd1f8e
@ -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 (
|
||||
<SimplifiedCodeTabComponent
|
||||
language={extractLanguage(className)}
|
||||
code={String(content).replace(/\n$/, "")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return !inline ? (
|
||||
<SimplifiedCodeTabComponent
|
||||
language={(match && match[1]) || ""}
|
||||
code={String(content).replace(/\n$/, "")}
|
||||
/>
|
||||
) : (
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{content}
|
||||
</code>
|
||||
@ -171,16 +173,16 @@ export default function ContentDisplay({
|
||||
return <ul className="max-w-full">{props.children}</ul>;
|
||||
},
|
||||
code: ({ node, className, children, ...props }) => {
|
||||
const inline = !(props as any).hasOwnProperty(
|
||||
"data-language",
|
||||
);
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
return !inline ? (
|
||||
<SimplifiedCodeTabComponent
|
||||
language={(match && match[1]) || ""}
|
||||
code={String(children).replace(/\n$/, "")}
|
||||
/>
|
||||
) : (
|
||||
const content = String(children);
|
||||
if (isCodeBlock(className, props, content)) {
|
||||
return (
|
||||
<SimplifiedCodeTabComponent
|
||||
language={extractLanguage(className)}
|
||||
code={content.replace(/\n$/, "")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
|
||||
@ -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 (
|
||||
<CodeTabsComponent
|
||||
language={extractLanguage(
|
||||
className,
|
||||
)}
|
||||
code={String(content).replace(
|
||||
/\n$/,
|
||||
"",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return !inline ? (
|
||||
<CodeTabsComponent
|
||||
language={(match && match[1]) || ""}
|
||||
code={String(content).replace(
|
||||
/\n$/,
|
||||
"",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
return (
|
||||
<code
|
||||
className={className}
|
||||
{...props}
|
||||
|
||||
@ -3,6 +3,7 @@ import rehypeMathjax from "rehype-mathjax/browser";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { EMPTY_OUTPUT_SEND_MESSAGE } from "@/constants/constants";
|
||||
import { extractLanguage, isCodeBlock } from "@/utils/codeBlockUtils";
|
||||
import { preprocessChatMessage } from "@/utils/markdownUtils";
|
||||
import { cn } from "@/utils/utils";
|
||||
import CodeTabsComponent from "../../../../../../components/core/codeTabsComponent";
|
||||
@ -67,7 +68,6 @@ export const MarkdownField = ({
|
||||
);
|
||||
},
|
||||
code: ({ node, className, children, ...props }) => {
|
||||
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 (
|
||||
<CodeTabsComponent
|
||||
language={extractLanguage(className)}
|
||||
code={String(content).replace(/\n$/, "")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return !inline ? (
|
||||
<CodeTabsComponent
|
||||
language={(match && match[1]) || ""}
|
||||
code={String(content).replace(/\n$/, "")}
|
||||
/>
|
||||
) : (
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{content}
|
||||
</code>
|
||||
|
||||
172
src/frontend/src/utils/__tests__/codeBlockUtils.test.ts
Normal file
172
src/frontend/src/utils/__tests__/codeBlockUtils.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
30
src/frontend/src/utils/codeBlockUtils.ts
Normal file
30
src/frontend/src/utils/codeBlockUtils.ts
Normal file
@ -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<string, unknown> | 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] ?? "";
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
20
src/frontend/tests/utils/add-open-ai-input-key.ts
Normal file
20
src/frontend/tests/utils/add-open-ai-input-key.ts
Normal file
@ -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);
|
||||
}
|
||||
};
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user