LibreChat/e2e/specs/mock/mermaid-artifacts.spec.ts
Marco Beretta 8da51562f5
🧜 feat: Open Mermaid Diagrams as Artifacts with SVG/PNG Export (#14713)
* feat: open Mermaid diagrams in the artifact panel with SVG and PNG export

Mermaid diagrams previously rendered inline only, and the artifact panel
routed every artifact through Sandpack even when no bundler was needed.

- Route Mermaid artifacts to a direct renderer in ArtifactTabs, moving the
  Sandpack path into a lazily loaded SandboxArtifactTabs so opening a
  diagram no longer pulls in the bundler chrome or the startup config.
- Add an inline artifact card that opens the diagram in the panel instead
  of rendering the same diagram twice.
- Add SVG and PNG export from both the inline diagram and the panel
  header, with size-capped canvas scaling and background compositing.
- Lazy-load the artifact panel in Presentation and ShareArtifacts.
- Accessibility: label the panel as a dialog on mobile with a focus trap,
  make the mobile resize handle keyboard operable, restore focus to the
  opener on close, and honor prefers-reduced-motion.
- Fix the generated Sandpack wrapper to serialize diagram source instead
  of interpolating it into a template literal.
- Cover the new paths with unit tests and a cross-browser Playwright spec.

* fix: keep Mermaid artifact identity and render state per diagram

Addresses three review findings on the Mermaid artifact panel.

Mermaid fences do not consume a code-block index, so every diagram in a
message received the same `mermaid-${blockIndex}` and therefore the same
Recoil artifact key: expanding one overwrote the other, and both cards
read as selected. Mermaid fences now carry their own index sequence,
seeded per markdown block the same way the code and artifact counters
are, so the id stays stable across streamed tokens.

The panel renderer is keyed by artifact id, so switching directly
between two diagrams can no longer carry the previous render, its
dimensions, or its export payload across the boundary while the new
source debounces. Editing an open diagram still does not remount.

The preview Refresh action drives the Sandpack client, which a Mermaid
preview never populates, so it only covered the panel with a spinner.
It is hidden for Mermaid, which offers its own retry on render failure.

Also drops com_ui_mermaid_export_preparing and com_ui_mermaid_source,
which no longer have call sites, fixing the unused-i18n-keys check.

* fix: bind Mermaid preview and export to the artifact on screen

Three further review findings, all on state outliving what it describes.

The editor reset in ArtifactTabs only lands after commit, so the render
that switched artifacts still passed the previous artifact's editor text
to the freshly keyed renderer, which mounted showing (and exporting) the
diagram just navigated away from. Editor text is now ignored until the
reset catches up. SandboxArtifactTabs carried the same pattern and gets
the same guard.

Switching to the code tab unmounts the preview, but the export payload
survived it, so the toolbar kept exporting a diagram that was no longer
on screen and no longer matched an edited source. The renderer now
withdraws its payload on unmount, and the export action is scoped to the
preview tab.

The diagram canvas mounts only once there is a diagram to show, so the
ResizeObserver ran against a null ref while the placeholder was up and
never saw the real element. Wide diagrams were fitted to the default
700px and clipped in narrower panels. Observation now re-runs when the
canvas appears.

* fix: scope Mermaid artifact ids to the content part

Each content part renders its own markdown tree, so the per-message
Mermaid counter restarts at zero in every part. Diagrams sitting either
side of a tool call therefore both resolved to
`mermaid-artifact-${messageId}-mermaid-0`: one registration overwrote
the other and both cards shared a selection state. The part index the
message context already carries now takes part in the scope.

* fix: keep the Mermaid export menu reachable in fullscreen

The artifact panel gained a fullscreen mode on dev, which re-roots the
panel into the fullscreen element and portals the copy and version
popovers there so they stay visible. The Mermaid export menu portals to
the body, so once these branches met it opened outside the fullscreen
element and rendered invisible. It now takes the same portal target.

* fix: heal Mermaid registrations and cap PNG canvases after rounding

Two findings from the latest review pass.

Closing the panel unmounts Artifacts, whose useArtifacts cleanup wipes
artifactsState while the inline cards stay on screen. The Mermaid card
never observed that, so reopening one card restored only itself and any
other expanded diagram vanished from the version navigator until it was
clicked again. It now subscribes to its own slice and re-registers when
the entry goes missing, matching the self-heal ToolArtifactCard already
documents. The write is a no-op when the entry matches, so it settles.

Rounding each PNG side independently could carry the product back over
the 16.7M pixel budget the scale was picked to satisfy: 3129x50000
resolved to 1025x16374, which is 16,783,350 pixels and enough for a
browser enforcing the area limit to reject toBlob outright. Rounding
down cannot exceed the budget, since the bounding scale is derived from
it.
2026-08-09 09:02:42 -04:00

199 lines
8.2 KiB
TypeScript

import { expect, test } from '@playwright/test';
import type { Download, Page, Request } from '@playwright/test';
import {
MOCK_ENDPOINTS,
NEW_CHAT_PATH,
messagesView,
selectMockEndpoint,
sendMessage,
} from './helpers';
const isStartupConfigRequest = (request: Request) =>
new URL(request.url()).pathname === '/api/config';
const isSandboxResourceRequest = (request: Request) => {
if (request.resourceType() !== 'script') {
return false;
}
const pathname = new URL(request.url()).pathname.toLowerCase();
return pathname.includes('/sandboxartifacttabs.') || pathname.includes('/sandpack.');
};
const waitForForbiddenMermaidRequest = (page: Page) =>
page
.waitForRequest(
(request) => isStartupConfigRequest(request) || isSandboxResourceRequest(request),
{ timeout: 1500 },
)
.then((request) => request.url())
.catch(() => null);
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const MAX_EXPORT_DIMENSION = 16_384;
const MAX_EXPORT_PIXELS = 16_777_216;
const REPEATED_PNG_EXPORTS = 5;
async function downloadBytes(download: Download): Promise<Buffer> {
const stream = await download.createReadStream();
const chunks: Buffer[] = [];
for await (const chunk of stream as AsyncIterable<Uint8Array>) {
chunks.push(Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
test.describe('Mermaid Artifact resource boundary', () => {
test('opens Mermaid as an Artifact without startup config or Sandpack requests', async ({
page,
}) => {
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
const response = await sendMessage(page, 'E2E_MERMAID_ARTIFACT_REPLY');
expect(response.ok()).toBeTruthy();
const messages = messagesView(page);
await expect(messages.getByRole('img', { name: 'Mermaid diagram' })).toBeVisible();
const unexpectedRequest = waitForForbiddenMermaidRequest(page);
await messages.getByRole('button', { name: 'Open as artifact', exact: true }).click();
const panel = page.getByRole('region', { name: 'Mermaid diagram' });
await expect(panel).toBeVisible();
await expect(panel.getByRole('img', { name: 'Mermaid diagram' })).toBeVisible();
const canvas = panel.getByTestId('mermaid-artifact-canvas');
await expect(canvas).toHaveClass(/\bh-full\b/);
await expect(canvas).toHaveClass(/\brounded-lg\b/);
const panelBox = await panel.boundingBox();
const canvasBox = await canvas.boundingBox();
expect(panelBox).not.toBeNull();
expect(canvasBox).not.toBeNull();
expect(canvasBox!.height).toBeGreaterThan(panelBox!.height * 0.75);
await expect(panel.locator('iframe')).toHaveCount(0);
const artifactCard = messages.locator('[data-artifact-trigger^="mermaid-artifact-"]');
await expect(artifactCard).toHaveAttribute('aria-expanded', 'true');
await expect(artifactCard).toHaveClass(/\bw-fit\b/);
await expect(artifactCard.locator('.lucide-workflow').locator('..')).toHaveClass(
/\bbg-status-info-subtle\b/,
);
expect(await unexpectedRequest).toBeNull();
});
test('keeps HTML Artifacts on the lazy Sandpack path', async ({ page }) => {
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
const response = await sendMessage(page, 'E2E_HTML_ARTIFACT_REPLY');
expect(response.ok()).toBeTruthy();
const artifactButton = messagesView(page).getByRole('button', {
name: 'E2E HTML Artifact Click to open',
exact: true,
});
await expect(artifactButton).toBeVisible();
const sandpackRequest = page.waitForRequest(isSandboxResourceRequest, { timeout: 10000 });
await artifactButton.click();
await expect(page.getByRole('region', { name: 'E2E HTML Artifact' })).toBeVisible();
expect((await sandpackRequest).resourceType()).toBe('script');
});
test('exports a Mermaid Artifact as valid SVG and PNG files', async ({ page }) => {
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
const response = await sendMessage(page, 'E2E_MERMAID_ARTIFACT_REPLY');
expect(response.ok()).toBeTruthy();
const messages = messagesView(page);
await expect(messages.getByRole('img', { name: 'Mermaid diagram' })).toBeVisible();
await messages.getByRole('button', { name: 'Open as artifact', exact: true }).click();
const panel = page.getByRole('region', { name: 'Mermaid diagram' });
await expect(panel.getByRole('img', { name: 'Mermaid diagram' })).toBeVisible();
const exportButton = panel.getByRole('button', { name: 'Export diagram' });
await exportButton.click();
const svgItem = page.getByRole('menuitem', { name: 'Export as SVG', exact: true });
await expect(svgItem).toBeEnabled();
const [svgDownload] = await Promise.all([page.waitForEvent('download'), svgItem.click()]);
expect(svgDownload.suggestedFilename()).toBe('Mermaid diagram.svg');
const svg = (await downloadBytes(svgDownload)).toString('utf8');
expect(svg).toMatch(/<svg\b/);
expect(svg).toContain('xmlns="http://www.w3.org/2000/svg"');
expect(svg).toContain('rx="8"');
await exportButton.click();
const pngItem = page.getByRole('menuitem', { name: 'Export as PNG', exact: true });
await expect(pngItem).toBeEnabled();
const [pngDownload] = await Promise.all([
page.waitForEvent('download', { timeout: 15000 }),
pngItem.click(),
]);
expect(pngDownload.suggestedFilename()).toBe('Mermaid diagram.png');
const png = await downloadBytes(pngDownload);
expect(png.length).toBeGreaterThan(24);
expect(png.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)).toBe(true);
expect(png.readUInt32BE(16)).toBeGreaterThan(0);
expect(png.readUInt32BE(20)).toBeGreaterThan(0);
await expect(panel.getByRole('img', { name: 'Mermaid diagram' })).toBeVisible();
});
test('repeatedly exports a large Mermaid Artifact without crashing the browser', async ({
page,
}, testInfo) => {
testInfo.setTimeout(90_000);
let didCrash = false;
page.on('crash', () => {
didCrash = true;
});
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
const response = await sendMessage(page, 'E2E_LARGE_MERMAID_ARTIFACT_REPLY');
expect(response.ok()).toBeTruthy();
const messages = messagesView(page);
await expect(messages.getByRole('img', { name: 'Mermaid diagram' })).toBeVisible();
await messages.getByRole('button', { name: 'Open as artifact', exact: true }).click();
const panel = page.getByRole('region', { name: 'Mermaid diagram' });
const diagram = panel.getByRole('img', { name: 'Mermaid diagram' });
await expect(diagram).toBeVisible();
const exportButton = panel.getByRole('button', { name: 'Export diagram' });
await exportButton.click();
const svgItem = page.getByRole('menuitem', { name: 'Export as SVG', exact: true });
const [svgDownload] = await Promise.all([page.waitForEvent('download'), svgItem.click()]);
const svg = (await downloadBytes(svgDownload)).toString('utf8');
expect(svg.length).toBeGreaterThan(100_000);
expect(svg).toContain('Processing stage 179 with representative content');
const pngItem = page.getByRole('menuitem', { name: 'Export as PNG', exact: true });
for (let attempt = 0; attempt < REPEATED_PNG_EXPORTS; attempt++) {
await exportButton.click();
await expect(pngItem).toBeEnabled();
const [pngDownload] = await Promise.all([
page.waitForEvent('download', { timeout: 30_000 }),
pngItem.click(),
]);
expect(pngDownload.suggestedFilename()).toBe('Mermaid diagram.png');
const png = await downloadBytes(pngDownload);
expect(png.length).toBeGreaterThan(24);
expect(png.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)).toBe(true);
const width = png.readUInt32BE(16);
const height = png.readUInt32BE(20);
expect(Math.max(width, height)).toBeLessThanOrEqual(MAX_EXPORT_DIMENSION);
expect(width * height).toBeLessThanOrEqual(MAX_EXPORT_PIXELS);
await expect(exportButton).not.toHaveAttribute('aria-busy', 'true');
expect(didCrash).toBe(false);
await expect(diagram).toBeVisible();
}
});
});