mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
📸 fix: Guard Screenshot Export Against Main-Thread Freezes (#14733)
* 📸 fix: Guard Screenshot Export Against Main-Thread Freezes * 🧪 test: Cover Conversation Export Flows End-to-End * 🧪 test: Stabilize Export Spec CSV and Toast Assertions
This commit is contained in:
parent
f7d9f36922
commit
c93609cb82
7 changed files with 321 additions and 39 deletions
|
|
@ -143,7 +143,7 @@ function MessagesViewContent({
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div ref={screenshotTargetRef}>
|
||||
<div ref={screenshotTargetRef} data-testid="screenshot-target">
|
||||
<MultiMessage
|
||||
messagesTree={_messagesTree}
|
||||
messageId={conversationId ?? null}
|
||||
|
|
|
|||
|
|
@ -14,13 +14,15 @@ import { useLocalize, useExportConversation } from '~/hooks';
|
|||
import { normalizeExportFilename } from '~/utils';
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'screenshot', label: 'screenshot (.png)' },
|
||||
{ value: 'text', label: 'text (.txt)' },
|
||||
{ value: 'markdown', label: 'markdown (.md)' },
|
||||
{ value: 'text', label: 'text (.txt)' },
|
||||
{ value: 'json', label: 'json (.json)' },
|
||||
{ value: 'csv', label: 'csv (.csv)' },
|
||||
{ value: 'screenshot', label: 'screenshot (.png)' },
|
||||
];
|
||||
|
||||
const DEFAULT_TYPE = 'markdown';
|
||||
|
||||
export default function ExportModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
|
@ -37,7 +39,7 @@ export default function ExportModal({
|
|||
const localize = useLocalize();
|
||||
|
||||
const [filename, setFileName] = useState('');
|
||||
const [type, setType] = useState<string>('screenshot');
|
||||
const [type, setType] = useState<string>(DEFAULT_TYPE);
|
||||
|
||||
const [includeOptions, setIncludeOptions] = useState<boolean | 'indeterminate'>(true);
|
||||
const [exportBranches, setExportBranches] = useState<boolean | 'indeterminate'>(false);
|
||||
|
|
@ -51,24 +53,21 @@ export default function ExportModal({
|
|||
|
||||
useEffect(() => {
|
||||
setFileName(filenamify(String(conversation?.title ?? 'file')));
|
||||
setType('screenshot');
|
||||
setType(DEFAULT_TYPE);
|
||||
setIncludeOptions(true);
|
||||
setExportBranches(false);
|
||||
setRecursive(true);
|
||||
}, [conversation?.title, open]);
|
||||
|
||||
const handleTypeChange = useCallback((newType: string) => {
|
||||
const branches = newType === 'json' || newType === 'csv' || newType === 'webpage';
|
||||
const branches = newType === 'json' || newType === 'csv';
|
||||
const options = newType !== 'csv' && newType !== 'screenshot';
|
||||
setExportBranches(branches);
|
||||
setIncludeOptions(options);
|
||||
setType(newType);
|
||||
}, []);
|
||||
|
||||
const exportBranchesSupport = useMemo(
|
||||
() => type === 'json' || type === 'csv' || type === 'webpage',
|
||||
[type],
|
||||
);
|
||||
const exportBranchesSupport = useMemo(() => type === 'json' || type === 'csv', [type]);
|
||||
const exportOptionsSupport = useMemo(() => type !== 'csv' && type !== 'screenshot', [type]);
|
||||
|
||||
const { exportConversation } = useExportConversation({
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ import { useCallback } from 'react';
|
|||
import download from 'downloadjs';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import exportFromJSON from 'export-from-json';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { buildTree, QueryKeys } from 'librechat-data-provider';
|
||||
import type { TConversation, TMessage, TPreset } from 'librechat-data-provider';
|
||||
import { ScreenshotLimitError, useScreenshot } from '~/hooks/ScreenshotContext';
|
||||
import useBuildMessageTree from '~/hooks/Messages/useBuildMessageTree';
|
||||
import { useScreenshot } from '~/hooks/ScreenshotContext';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { formatMessageText } from './format';
|
||||
import { cleanupPreset } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
|
@ -33,6 +35,7 @@ export default function useExportConversation({
|
|||
recursive: boolean | 'indeterminate';
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToastContext();
|
||||
const { captureScreenshot } = useScreenshot();
|
||||
const buildMessageTree = useBuildMessageTree();
|
||||
const localize = useLocalize();
|
||||
|
|
@ -48,12 +51,21 @@ export default function useExportConversation({
|
|||
}, [paramId, conversation?.conversationId, queryClient]);
|
||||
|
||||
const exportScreenshot = async () => {
|
||||
let data;
|
||||
let data: Blob;
|
||||
try {
|
||||
data = await captureScreenshot();
|
||||
} catch (err) {
|
||||
console.error('Failed to capture screenshot');
|
||||
return console.error(err);
|
||||
console.error('Failed to capture screenshot', err);
|
||||
showToast({
|
||||
message: localize(
|
||||
err instanceof ScreenshotLimitError
|
||||
? 'com_nav_export_screenshot_too_large'
|
||||
: 'com_nav_export_screenshot_error',
|
||||
),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
showIcon: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
download(data, `${filename}.png`, 'image/png');
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createContext, useRef, useContext, RefObject } from 'react';
|
||||
import { createContext, useRef, useContext, RefObject, ReactNode } from 'react';
|
||||
import { toCanvas } from 'html-to-image';
|
||||
import { ThemeContext, isDark } from '@librechat/client';
|
||||
|
||||
|
|
@ -6,47 +6,73 @@ type ScreenshotContextType = {
|
|||
ref?: RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
/** Canvas area ceiling (~16.7 MP, 4096²) — WebKit rejects larger canvases and PNG encode cost grows linearly past it */
|
||||
const MAX_CAPTURE_AREA = 16_777_216;
|
||||
/** Chromium/Firefox cap a canvas edge at 32767px; beyond it drawing silently produces a blank canvas */
|
||||
const MAX_CANVAS_EDGE = 32_767;
|
||||
/** Below half-resolution the capture is illegible, so abort rather than degrade further */
|
||||
const MIN_PIXEL_RATIO = 0.5;
|
||||
/** html-to-image clones every node and copies ~340 computed styles each; cap the synchronous clone work */
|
||||
const MAX_CAPTURE_ELEMENTS = 50_000;
|
||||
|
||||
export class ScreenshotLimitError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ScreenshotLimitError';
|
||||
}
|
||||
}
|
||||
|
||||
const ScreenshotContext = createContext<ScreenshotContextType>({});
|
||||
|
||||
export const useScreenshot = () => {
|
||||
const { ref } = useContext(ScreenshotContext);
|
||||
const { theme } = useContext(ThemeContext);
|
||||
|
||||
const takeScreenShot = async (node?: HTMLElement) => {
|
||||
const takeScreenShot = async (node?: HTMLElement): Promise<Blob> => {
|
||||
if (!node) {
|
||||
throw new Error('You should provide correct html node.');
|
||||
}
|
||||
|
||||
const backgroundColor = isDark(theme) ? '#171717' : 'white';
|
||||
const width = node.scrollWidth;
|
||||
const height = node.scrollHeight;
|
||||
if (!width || !height) {
|
||||
throw new Error('Cannot capture an empty node.');
|
||||
}
|
||||
|
||||
const elementCount = node.querySelectorAll('*').length;
|
||||
if (elementCount > MAX_CAPTURE_ELEMENTS) {
|
||||
throw new ScreenshotLimitError(
|
||||
`Screenshot capture aborted: ${elementCount} elements exceed the ${MAX_CAPTURE_ELEMENTS} limit`,
|
||||
);
|
||||
}
|
||||
|
||||
const pixelRatio = Math.min(
|
||||
window.devicePixelRatio || 1,
|
||||
Math.sqrt(MAX_CAPTURE_AREA / (width * height)),
|
||||
MAX_CANVAS_EDGE / Math.max(width, height),
|
||||
);
|
||||
if (pixelRatio < MIN_PIXEL_RATIO) {
|
||||
throw new ScreenshotLimitError(
|
||||
`Screenshot capture aborted: ${width}x${height} CSS px cannot fit within ${MAX_CAPTURE_AREA} device px`,
|
||||
);
|
||||
}
|
||||
|
||||
const backgroundColor = isDark(theme) ? '#171717' : 'white';
|
||||
const canvas = await toCanvas(node, {
|
||||
backgroundColor,
|
||||
pixelRatio,
|
||||
imagePlaceholder:
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=',
|
||||
});
|
||||
|
||||
const croppedCanvas = document.createElement('canvas');
|
||||
const croppedCanvasContext = croppedCanvas.getContext('2d') as CanvasRenderingContext2D;
|
||||
// init data
|
||||
const cropPositionTop = 0;
|
||||
const cropPositionLeft = 0;
|
||||
const cropWidth = canvas.width;
|
||||
const cropHeight = canvas.height;
|
||||
|
||||
croppedCanvas.width = cropWidth;
|
||||
croppedCanvas.height = cropHeight;
|
||||
|
||||
croppedCanvasContext.fillStyle = backgroundColor;
|
||||
croppedCanvasContext.fillRect(0, 0, cropWidth, cropHeight);
|
||||
|
||||
croppedCanvasContext.drawImage(canvas, cropPositionLeft, cropPositionTop);
|
||||
|
||||
const base64Image = croppedCanvas.toDataURL('image/png', 1);
|
||||
|
||||
return base64Image;
|
||||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||||
if (!blob) {
|
||||
throw new Error('Failed to encode screenshot canvas.');
|
||||
}
|
||||
return blob;
|
||||
};
|
||||
|
||||
const captureScreenshot = async () => {
|
||||
const captureScreenshot = async (): Promise<Blob> => {
|
||||
if (ref instanceof Function) {
|
||||
throw new Error('Ref callback is not supported.');
|
||||
}
|
||||
|
|
@ -59,8 +85,8 @@ export const useScreenshot = () => {
|
|||
return { screenshotTargetRef: ref, captureScreenshot };
|
||||
};
|
||||
|
||||
export const ScreenshotProvider = ({ children }) => {
|
||||
const ref = useRef(null);
|
||||
export const ScreenshotProvider = ({ children }: { children: ReactNode }) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
return <ScreenshotContext.Provider value={{ ref }}>{children}</ScreenshotContext.Provider>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -510,6 +510,8 @@
|
|||
"com_nav_export_include_endpoint_options": "Include endpoint options",
|
||||
"com_nav_export_recursive": "Recursive",
|
||||
"com_nav_export_recursive_or_sequential": "Recursive or sequential?",
|
||||
"com_nav_export_screenshot_error": "Couldn't capture the screenshot. Try a different export type.",
|
||||
"com_nav_export_screenshot_too_large": "This conversation is too large to export as a screenshot. Try a different export type.",
|
||||
"com_nav_export_type": "Type",
|
||||
"com_nav_external": "External",
|
||||
"com_nav_font_size": "Message Font Size",
|
||||
|
|
|
|||
|
|
@ -81,6 +81,47 @@ export async function deleteConversations(conversationIds: string[]): Promise<vo
|
|||
});
|
||||
}
|
||||
|
||||
export interface SeedMessage {
|
||||
messageId: string;
|
||||
parentMessageId: string;
|
||||
text: string;
|
||||
isCreatedByUser: boolean;
|
||||
sender: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts message documents directly so specs can build conversations far larger
|
||||
* than the mock model could produce through the UI in reasonable time.
|
||||
*/
|
||||
export async function seedMessages(
|
||||
userEmail: string,
|
||||
conversationId: string,
|
||||
messages: SeedMessage[],
|
||||
): Promise<void> {
|
||||
await withMongo(async (db) => {
|
||||
const userId = await resolveUserId(db, userEmail);
|
||||
const start = Date.now();
|
||||
const docs = messages.map((message, index) => ({
|
||||
...message,
|
||||
conversationId,
|
||||
user: userId,
|
||||
endpoint: 'openAI',
|
||||
error: false,
|
||||
unfinished: false,
|
||||
createdAt: new Date(start + index * 1000),
|
||||
updatedAt: new Date(start + index * 1000),
|
||||
__v: 0,
|
||||
}));
|
||||
await db.collection('messages').insertMany(docs);
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteMessagesByConversation(conversationIds: string[]): Promise<void> {
|
||||
await withMongo(async (db) => {
|
||||
await db.collection('messages').deleteMany({ conversationId: { $in: conversationIds } });
|
||||
});
|
||||
}
|
||||
|
||||
/** Clears every conversation for the user so the seeded date groups are not pushed
|
||||
* below the virtualized viewport by rows left behind by other specs. */
|
||||
export async function clearUserConversations(userEmail: string): Promise<void> {
|
||||
|
|
|
|||
202
e2e/specs/mock/export.spec.ts
Normal file
202
e2e/specs/mock/export.spec.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import fs from 'fs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { Download, Locator, Page } from '@playwright/test';
|
||||
import { getE2EUser } from '../../setup/user';
|
||||
import {
|
||||
MOCK_ENDPOINTS,
|
||||
MOCK_REPLY_TEXT,
|
||||
NEW_CHAT_PATH,
|
||||
messagesView,
|
||||
mockReply,
|
||||
selectMockEndpoint,
|
||||
sendMessage,
|
||||
} from './helpers';
|
||||
import {
|
||||
deleteConversations,
|
||||
deleteMessagesByConversation,
|
||||
seedConversations,
|
||||
seedMessages,
|
||||
} from './db';
|
||||
import type { SeedMessage } from './db';
|
||||
|
||||
const NO_PARENT = '00000000-0000-0000-0000-000000000000';
|
||||
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
/** Mirrors MAX_CAPTURE_AREA in client/src/hooks/ScreenshotContext.tsx. */
|
||||
const MAX_CAPTURE_AREA = 16_777_216;
|
||||
/** The capture aborts once the CSS area needs a pixel ratio below 0.5, i.e. above 4× the max area. */
|
||||
const ABORT_CSS_AREA = 4 * MAX_CAPTURE_AREA;
|
||||
|
||||
const OVERSIZED_TITLE = 'Oversized export fixture';
|
||||
const OVERSIZED_PARAGRAPH = 'Oversized export fixture paragraph';
|
||||
const OVERSIZED_MESSAGES = 80;
|
||||
const OVERSIZED_PARAGRAPHS_PER_MESSAGE = 50;
|
||||
|
||||
const userEmail = getE2EUser().email;
|
||||
const cleanupConversationIds: string[] = [];
|
||||
|
||||
async function startMockConversation(page: Page): Promise<string> {
|
||||
await page.goto(NEW_CHAT_PATH, { timeout: 15000 });
|
||||
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
|
||||
await sendMessage(page, 'Export fixture prompt');
|
||||
await expect(mockReply(page).first()).toBeVisible({ timeout: 15000 });
|
||||
await expect(page).toHaveURL(/\/c\/(?!new$)[\w-]+/, { timeout: 15000 });
|
||||
const conversationId = new URL(page.url()).pathname.split('/').pop();
|
||||
if (!conversationId) {
|
||||
throw new Error(`Could not parse conversation id from ${page.url()}`);
|
||||
}
|
||||
cleanupConversationIds.push(conversationId);
|
||||
return conversationId;
|
||||
}
|
||||
|
||||
async function openExportModal(page: Page): Promise<Locator> {
|
||||
await page.getByRole('button', { name: 'Export/Share' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Export' }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Export conversation' });
|
||||
await expect(dialog).toBeVisible();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
const typeDropdown = (dialog: Locator) => dialog.getByTestId('dropdown-menu');
|
||||
|
||||
async function selectExportType(page: Page, dialog: Locator, label: string) {
|
||||
await typeDropdown(dialog).click();
|
||||
await page.getByRole('option', { name: label }).click();
|
||||
await expect(typeDropdown(dialog)).toContainText(label);
|
||||
}
|
||||
|
||||
/** The modal stays open after exporting, so consecutive exports reuse one dialog. */
|
||||
async function exportCurrentType(page: Page, dialog: Locator): Promise<Download> {
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download', { timeout: 30000 }),
|
||||
dialog.getByRole('button', { name: 'Export', exact: true }).click(),
|
||||
]);
|
||||
return download;
|
||||
}
|
||||
|
||||
async function downloadText(download: Download): Promise<string> {
|
||||
return fs.promises.readFile(await download.path(), 'utf8');
|
||||
}
|
||||
|
||||
function buildOversizedMessages(): SeedMessage[] {
|
||||
const text = Array.from(
|
||||
{ length: OVERSIZED_PARAGRAPHS_PER_MESSAGE },
|
||||
(_, index) => `${OVERSIZED_PARAGRAPH} ${index}.`,
|
||||
).join('\n\n');
|
||||
const messages: SeedMessage[] = [];
|
||||
let parentMessageId = NO_PARENT;
|
||||
for (let i = 0; i < OVERSIZED_MESSAGES; i++) {
|
||||
const messageId = randomUUID();
|
||||
messages.push({
|
||||
messageId,
|
||||
parentMessageId,
|
||||
text,
|
||||
isCreatedByUser: i % 2 === 0,
|
||||
sender: i % 2 === 0 ? 'User' : 'Assistant',
|
||||
});
|
||||
parentMessageId = messageId;
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (cleanupConversationIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
await deleteMessagesByConversation(cleanupConversationIds);
|
||||
await deleteConversations(cleanupConversationIds);
|
||||
});
|
||||
|
||||
test.describe('conversation export', () => {
|
||||
test('defaults to markdown with screenshot demoted to the last option', async ({ page }) => {
|
||||
await startMockConversation(page);
|
||||
const dialog = await openExportModal(page);
|
||||
|
||||
await expect(typeDropdown(dialog)).toContainText('markdown (.md)');
|
||||
|
||||
await typeDropdown(dialog).click();
|
||||
const options = page.getByRole('option');
|
||||
await expect(options.first()).toContainText('markdown (.md)');
|
||||
await expect(options.last()).toContainText('screenshot (.png)');
|
||||
});
|
||||
|
||||
test('exports the conversation in every format', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await startMockConversation(page);
|
||||
const dialog = await openExportModal(page);
|
||||
|
||||
const markdownDownload = await exportCurrentType(page, dialog);
|
||||
expect(markdownDownload.suggestedFilename()).toMatch(/\.md$/);
|
||||
const markdown = await downloadText(markdownDownload);
|
||||
expect(markdown).toContain('# Conversation');
|
||||
expect(markdown).toContain(MOCK_REPLY_TEXT);
|
||||
|
||||
await selectExportType(page, dialog, 'text (.txt)');
|
||||
const textDownload = await exportCurrentType(page, dialog);
|
||||
expect(textDownload.suggestedFilename()).toMatch(/\.txt$/);
|
||||
expect(await downloadText(textDownload)).toContain(MOCK_REPLY_TEXT);
|
||||
|
||||
await selectExportType(page, dialog, 'json (.json)');
|
||||
const jsonDownload = await exportCurrentType(page, dialog);
|
||||
expect(jsonDownload.suggestedFilename()).toMatch(/\.json$/);
|
||||
const parsed = JSON.parse(await downloadText(jsonDownload)) as Record<string, unknown>;
|
||||
expect(JSON.stringify(parsed)).toContain(MOCK_REPLY_TEXT);
|
||||
|
||||
await selectExportType(page, dialog, 'csv (.csv)');
|
||||
const csvDownload = await exportCurrentType(page, dialog);
|
||||
expect(csvDownload.suggestedFilename()).toMatch(/\.csv$/);
|
||||
const csv = await downloadText(csvDownload);
|
||||
expect(csv).toContain('sender');
|
||||
/** CSV export maps only the legacy `text` field, which is empty for the mock
|
||||
* model's content-parts reply — assert on the user message instead. */
|
||||
expect(csv).toContain('Export fixture prompt');
|
||||
|
||||
await selectExportType(page, dialog, 'screenshot (.png)');
|
||||
const screenshotDownload = await exportCurrentType(page, dialog);
|
||||
expect(screenshotDownload.suggestedFilename()).toMatch(/\.png$/);
|
||||
const png = await fs.promises.readFile(await screenshotDownload.path());
|
||||
expect(png.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC)).toBe(true);
|
||||
expect(png.byteLength).toBeGreaterThan(1000);
|
||||
});
|
||||
|
||||
test('aborts screenshot export of an oversized conversation with an error toast', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const conversationId = randomUUID();
|
||||
cleanupConversationIds.push(conversationId);
|
||||
await seedConversations(userEmail, [
|
||||
{ conversationId, title: OVERSIZED_TITLE, updatedAt: new Date() },
|
||||
]);
|
||||
await seedMessages(userEmail, conversationId, buildOversizedMessages());
|
||||
|
||||
await page.goto(`/c/${conversationId}`, { timeout: 30000 });
|
||||
await expect(messagesView(page).getByText(`${OVERSIZED_PARAGRAPH} 0.`).first()).toBeVisible({
|
||||
timeout: 60000,
|
||||
});
|
||||
|
||||
const target = page.getByTestId('screenshot-target');
|
||||
const area = await target.evaluate((node) => node.scrollWidth * node.scrollHeight);
|
||||
expect(area).toBeGreaterThan(ABORT_CSS_AREA * 1.15);
|
||||
|
||||
const dialog = await openExportModal(page);
|
||||
await selectExportType(page, dialog, 'screenshot (.png)');
|
||||
|
||||
let downloadFired = false;
|
||||
page.on('download', () => {
|
||||
downloadFired = true;
|
||||
});
|
||||
await dialog.getByRole('button', { name: 'Export', exact: true }).click();
|
||||
|
||||
await expect(page.getByText('too large to export as a screenshot').first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
expect(downloadFired).toBe(false);
|
||||
|
||||
await selectExportType(page, dialog, 'markdown (.md)');
|
||||
const fallbackDownload = await exportCurrentType(page, dialog);
|
||||
expect(fallbackDownload.suggestedFilename()).toMatch(/\.md$/);
|
||||
expect(await downloadText(fallbackDownload)).toContain(`${OVERSIZED_PARAGRAPH} 0.`);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue