Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 97 additions & 1 deletion frontend/e2e/converters.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ const MOCK_CATALOG = {
is_llm_based: false,
description: "Compresses images.",
},
{
converter_type: "AddImageTextConverter",
supported_input_types: ["text"],
supported_output_types: ["image_path"],
parameters: [],
is_llm_based: false,
description: "Renders text onto a generated image.",
},
{
converter_type: "TranslationConverter",
supported_input_types: ["text"],
Expand All @@ -61,6 +69,17 @@ const MOCK_CATALOG = {

const MOCK_CONVERSATION_ID = "e2e-conv-001";

// 1x1 transparent PNG returned by the mock /api/media route so that the
// inline image preview <img> element can resolve its src in headless mode.
const TINY_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGIAAgAABQABDQotsgAAAABJRU5ErkJggg==";

// Map of mock image-output converter types → path returned by the preview
// mock. Used to decide whether to emit text vs. image_path output.
const IMAGE_OUTPUT_CONVERTERS: Record<string, string> = {
AddImageTextConverter: "/tmp/output.png",
};

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand All @@ -73,6 +92,9 @@ const MOCK_CONVERSATION_ID = "e2e-conv-001";
*/
async function mockBackendAPIs(page: Page) {
let accumulatedMessages: Record<string, unknown>[] = [];
// Track the converter type for each created converter instance so the
// preview mock can decide between text and image_path output.
const converterTypeById: Record<string, string> = {};

// ── Converter-specific routes ──────────────────────────────────────────

Expand All @@ -89,6 +111,26 @@ async function mockBackendAPIs(page: Page) {
await page.route(/\/api\/converters\/preview/, async (route) => {
if (route.request().method() === "POST") {
const body = JSON.parse(route.request().postData() ?? "{}");
const converterIds: string[] = body.converter_ids ?? [];
const converterType = converterTypeById[converterIds[0] ?? ""] ?? "";

// Image-output converters emit a file path the frontend renders via
// /api/media → triggers the convertedFileChip + inline preview branch.
if (IMAGE_OUTPUT_CONVERTERS[converterType]) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
original_value: body.original_value,
original_value_data_type: body.original_value_data_type ?? "text",
converted_value: IMAGE_OUTPUT_CONVERTERS[converterType],
converted_value_data_type: "image_path",
steps: [],
}),
});
return;
}

const converted = Buffer.from(body.original_value ?? "").toString("base64");
await route.fulfill({
status: 200,
Expand All @@ -108,11 +150,13 @@ async function mockBackendAPIs(page: Page) {
await page.route(/\/api\/converters$/, async (route) => {
if (route.request().method() === "POST") {
const body = JSON.parse(route.request().postData() ?? "{}");
const converterId = `mock-converter-${body.type}`;
converterTypeById[converterId] = body.type;
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({
converter_id: "mock-converter-001",
converter_id: converterId,
converter_type: body.type,
display_name: null,
}),
Expand All @@ -122,6 +166,16 @@ async function mockBackendAPIs(page: Page) {
}
});

// Media route — serves the generated image referenced by the preview
// mock. Returning a tiny valid PNG keeps the <img> element layout-stable.
await page.route(/\/api\/media\?path=/, async (route) => {
await route.fulfill({
status: 200,
contentType: "image/png",
body: Buffer.from(TINY_PNG_BASE64, "base64"),
});
});

// ── Standard chat routes (matching chat.spec.ts pattern) ───────────────

// Targets list
Expand Down Expand Up @@ -515,4 +569,46 @@ test.describe("Converter Panel", () => {
// The converter badge should be visible in the attack table
await expect(page.getByText("Base64Converter")).toBeVisible({ timeout: 10000 });
});

test("should render inline image preview in input box after a text→image conversion", async ({ page }) => {
// Type a prompt before opening the panel
await page.getByTestId("chat-input").fill("hello world");

// Select AddImageTextConverter (text input → image_path output)
await selectConverter(page, "AddImageTextConverter");

// Auto-preview only fires for text-output converters, so click Preview
await page.getByTestId("converter-preview-btn").click();
await expect(page.getByTestId("converter-preview-result")).toBeVisible({ timeout: 10000 });

// Apply the converted value to the input
await page.getByTestId("use-converted-btn").click();

// Close converter panel so the input area is unobscured
await page.getByTestId("close-converter-panel-btn").click();
await expect(page.getByTestId("converter-panel")).not.toBeVisible();

// The converted-file-chip block is rendered in the input area for image_path outputs
const chip = page.getByTestId("converted-file-chip");
await expect(chip).toBeVisible();
await expect(chip).toContainText("output.png");

// The inline image preview is rendered alongside the filename + Open link
const preview = page.getByTestId("converted-file-preview-image");
await expect(preview).toBeVisible({ timeout: 10000 });
await expect(preview).toHaveAttribute("src", /\/api\/media\?path=/);
await expect(preview).toHaveAttribute("alt", "output.png");

// The Open link is still present alongside the preview
await expect(page.getByTestId("converted-file-open")).toHaveAttribute("href", /\/api\/media\?path=/);

// Audio / video previews must NOT be rendered for an image conversion
await expect(page.getByTestId("converted-file-preview-audio")).toHaveCount(0);
await expect(page.getByTestId("converted-file-preview-video")).toHaveCount(0);

// Dismissing the chip clears the entire block (including the preview)
await page.getByTestId("clear-converted-file-chip").click();
await expect(chip).not.toBeVisible();
await expect(page.getByTestId("converted-file-preview-image")).toHaveCount(0);
});
});
45 changes: 41 additions & 4 deletions frontend/src/components/Chat/ChatInputArea.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,13 +238,32 @@ export const useChatInputAreaStyles = makeStyles({
borderRadius: '4px',
},
},
convertedMediaPreview: {
maxHeight: '60px',
convertedFileBlock: {
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalXXS,
minWidth: 0,
},
convertedImagePreview: {
display: 'block',
maxHeight: '120px',
maxWidth: '100%',
borderRadius: tokens.borderRadiusSmall,
objectFit: 'contain' as const,
flex: 1,
minWidth: 0,
alignSelf: 'flex-start',
},
convertedAudioPreview: {
display: 'block',
width: '100%',
maxWidth: '360px',
height: '32px',
},
convertedVideoPreview: {
display: 'block',
maxHeight: '160px',
maxWidth: '100%',
borderRadius: tokens.borderRadiusSmall,
alignSelf: 'flex-start',
},
convertedFilename: {
flex: 1,
Expand All @@ -256,6 +275,24 @@ export const useChatInputAreaStyles = makeStyles({
fontSize: tokens.fontSizeBase200,
color: tokens.colorNeutralForeground2,
},
openLink: {
display: 'inline-flex',
alignItems: 'center',
gap: tokens.spacingHorizontalXXS,
padding: `0 ${tokens.spacingHorizontalS}`,
borderRadius: tokens.borderRadiusSmall,
backgroundColor: tokens.colorBrandBackground,
color: tokens.colorNeutralForegroundOnBrand,
fontSize: tokens.fontSizeBase200,
fontWeight: tokens.fontWeightSemibold as unknown as string,
textDecoration: 'none',
flexShrink: 0,
height: '20px',
':hover': {
backgroundColor: tokens.colorBrandBackgroundHover,
color: tokens.colorNeutralForegroundOnBrand,
},
},
unsupportedWarning: {
display: 'flex',
alignItems: 'center',
Expand Down
138 changes: 136 additions & 2 deletions frontend/src/components/Chat/ChatInputArea.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,7 @@ describe("ChatInputArea", () => {
<ChatInputArea
{...defaultProps}
activeTarget={{ target_registry_name: "t", target_type: "T", endpoint: "e", model_name: "m" }}
mediaConversions={[{ pieceType: "image", convertedValue: "/tmp/converted.png" }]}
mediaConversions={[{ pieceType: "image", convertedValue: "/tmp/converted.png", convertedDataType: "image_path" }]}
/>
</TestWrapper>
);
Expand Down Expand Up @@ -602,7 +602,7 @@ describe("ChatInputArea", () => {
<ChatInputArea
{...defaultProps}
activeTarget={{ target_registry_name: "t", target_type: "T", endpoint: "e", model_name: "m" }}
mediaConversions={[{ pieceType: "image", convertedValue: "/tmp/converted.png" }]}
mediaConversions={[{ pieceType: "image", convertedValue: "/tmp/converted.png", convertedDataType: "image_path" }]}
onClearMediaConversion={onClearMediaConversion}
/>
</TestWrapper>
Expand Down Expand Up @@ -679,6 +679,140 @@ describe("ChatInputArea", () => {
expect(onClearConversion).toHaveBeenCalled();
});

it("should render converted file chip with Open link for text→file conversion", async () => {
render(
<TestWrapper>
<ChatInputArea
{...defaultProps}
activeTarget={{ target_registry_name: "t", target_type: "T", endpoint: "e", model_name: "m" }}
convertedFileChip={{
name: "result.pdf",
url: "/api/media?path=%2Ftmp%2Fresult.pdf",
iconKind: "file",
}}
/>
</TestWrapper>
);

expect(screen.getByTestId("original-banner")).toBeInTheDocument();
const chip = screen.getByTestId("converted-file-chip");
expect(chip).toHaveTextContent("result.pdf");
const openLink = screen.getByTestId("converted-file-open");
expect(openLink).toHaveAttribute("href", "/api/media?path=%2Ftmp%2Fresult.pdf");
expect(openLink).toHaveAttribute("target", "_blank");
});

it("should call onClearConvertedFileChip when chip dismiss is clicked", async () => {
const onClearConvertedFileChip = jest.fn();
const user = userEvent.setup();

render(
<TestWrapper>
<ChatInputArea
{...defaultProps}
activeTarget={{ target_registry_name: "t", target_type: "T", endpoint: "e", model_name: "m" }}
convertedFileChip={{
name: "result.pdf",
url: "/api/media?path=%2Ftmp%2Fresult.pdf",
iconKind: "file",
}}
onClearConvertedFileChip={onClearConvertedFileChip}
/>
</TestWrapper>
);

await user.click(screen.getByTestId("clear-converted-file-chip"));
expect(onClearConvertedFileChip).toHaveBeenCalledTimes(1);
});

it("should render an inline image preview for an image conversion", () => {
render(
<TestWrapper>
<ChatInputArea
{...defaultProps}
activeTarget={{ target_registry_name: "t", target_type: "T", endpoint: "e", model_name: "m" }}
convertedFileChip={{
name: "result.png",
url: "/api/media?path=%2Ftmp%2Fresult.png",
iconKind: "image",
}}
/>
</TestWrapper>
);

const preview = screen.getByTestId("converted-file-preview-image");
expect(preview.tagName).toBe("IMG");
expect(preview).toHaveAttribute("src", "/api/media?path=%2Ftmp%2Fresult.png");
expect(preview).toHaveAttribute("alt", "result.png");
// Header info (filename + Open link) is still rendered alongside the preview.
expect(screen.getByTestId("converted-file-chip")).toHaveTextContent("result.png");
expect(screen.getByTestId("converted-file-open")).toHaveAttribute("href", "/api/media?path=%2Ftmp%2Fresult.png");
});

it("should render an inline audio preview for an audio conversion", () => {
render(
<TestWrapper>
<ChatInputArea
{...defaultProps}
activeTarget={{ target_registry_name: "t", target_type: "T", endpoint: "e", model_name: "m" }}
convertedFileChip={{
name: "speech.wav",
url: "/api/media?path=%2Ftmp%2Fspeech.wav",
iconKind: "audio",
}}
/>
</TestWrapper>
);

const preview = screen.getByTestId("converted-file-preview-audio");
expect(preview.tagName).toBe("AUDIO");
expect(preview).toHaveAttribute("src", "/api/media?path=%2Ftmp%2Fspeech.wav");
expect(preview).toHaveAttribute("controls");
expect(screen.queryByTestId("converted-file-preview-image")).not.toBeInTheDocument();
expect(screen.queryByTestId("converted-file-preview-video")).not.toBeInTheDocument();
});

it("should render an inline video preview for a video conversion", () => {
render(
<TestWrapper>
<ChatInputArea
{...defaultProps}
activeTarget={{ target_registry_name: "t", target_type: "T", endpoint: "e", model_name: "m" }}
convertedFileChip={{
name: "clip.mp4",
url: "/api/media?path=%2Ftmp%2Fclip.mp4",
iconKind: "video",
}}
/>
</TestWrapper>
);

const preview = screen.getByTestId("converted-file-preview-video");
expect(preview.tagName).toBe("VIDEO");
expect(preview).toHaveAttribute("src", "/api/media?path=%2Ftmp%2Fclip.mp4");
expect(preview).toHaveAttribute("controls");
});

it("should not render a media preview for a generic file conversion", () => {
render(
<TestWrapper>
<ChatInputArea
{...defaultProps}
activeTarget={{ target_registry_name: "t", target_type: "T", endpoint: "e", model_name: "m" }}
convertedFileChip={{
name: "result.pdf",
url: "/api/media?path=%2Ftmp%2Fresult.pdf",
iconKind: "file",
}}
/>
</TestWrapper>
);

expect(screen.queryByTestId("converted-file-preview-image")).not.toBeInTheDocument();
expect(screen.queryByTestId("converted-file-preview-audio")).not.toBeInTheDocument();
expect(screen.queryByTestId("converted-file-preview-video")).not.toBeInTheDocument();
});

// ---------------------------------------------------------------------------
// Unsupported modality warnings
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading