mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
The srcDoc foreignObject snapshot bridge legitimately fails on real-world artifacts (Chromium often refuses to rasterize <foreignObject> HTML loaded via <img>, surfacing as empty-render / snapshot-image-failed). The markup composer treated every capture failure as a dead end: it blocked the send with 'Could not capture the preview. Please try again.' even though retrying replays the same failure, so annotations on affected previews could never be sent at all. Only block when the annotation has no meaning without pixels (ink/box-only marks). A typed note or attached images still carry the user's intent, so those now send without the screenshot and the toolbar shows an honest status: the note went out, the pixels did not. New i18n key chat.annotationSentWithoutScreenshot across all 19 locales.
134 lines
4.8 KiB
TypeScript
134 lines
4.8 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
// Issue #4064: the srcDoc foreignObject snapshot bridge legitimately fails on
|
|
// real-world artifacts (Chromium often refuses to rasterize <foreignObject>
|
|
// HTML loaded via <img>). A failed screenshot must not dead-end an annotation
|
|
// that carries its own meaning without pixels (typed note / attached images) —
|
|
// the retry warning is a dead end because retrying the same pipeline fails the
|
|
// same way. Ink/box-only annotations still block: without the bitmap there is
|
|
// nothing to send.
|
|
|
|
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { PreviewDrawOverlay } from '../../src/components/PreviewDrawOverlay';
|
|
import { requestPreviewSnapshot } from '../../src/runtime/exports';
|
|
|
|
vi.mock('../../src/runtime/exports', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../../src/runtime/exports')>();
|
|
return {
|
|
...actual,
|
|
// The snapshot bridge fails — the shape every foreignObject failure mode
|
|
// (empty-render, snapshot image failed, timeout) collapses into.
|
|
requestPreviewSnapshot: vi.fn(async () => null),
|
|
};
|
|
});
|
|
|
|
let restoreRect: (() => void) | null = null;
|
|
|
|
beforeEach(() => {
|
|
// jsdom reports 0x0 client rects, which would collapse the drawn selection
|
|
// box into nothing. Give the ink canvas a real-looking rect so pointer
|
|
// events produce a valid normalized box, like in a browser.
|
|
const rectSpy = vi
|
|
.spyOn(HTMLCanvasElement.prototype, 'getBoundingClientRect')
|
|
.mockReturnValue({
|
|
x: 0,
|
|
y: 0,
|
|
left: 0,
|
|
top: 0,
|
|
width: 320,
|
|
height: 200,
|
|
right: 320,
|
|
bottom: 200,
|
|
toJSON: () => ({}),
|
|
} as DOMRect);
|
|
restoreRect = () => rectSpy.mockRestore();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
restoreRect?.();
|
|
restoreRect = null;
|
|
vi.mocked(requestPreviewSnapshot).mockClear();
|
|
});
|
|
|
|
function drawSelectionBox(canvas: HTMLCanvasElement) {
|
|
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 30, pointerId: 1 });
|
|
fireEvent.pointerMove(canvas, { clientX: 220, clientY: 150, pointerId: 1 });
|
|
fireEvent.pointerUp(canvas, { clientX: 220, clientY: 150, pointerId: 1 });
|
|
}
|
|
|
|
describe('PreviewDrawOverlay capture fallback (issue #4064)', () => {
|
|
it('sends a box annotation with a typed note even when the snapshot fails', async () => {
|
|
const annotation = vi.fn((event: Event) => {
|
|
const detail = (event as CustomEvent<{ ack?: (result: { ok: boolean }) => void }>).detail;
|
|
detail.ack?.({ ok: true });
|
|
});
|
|
window.addEventListener('opendesign:annotation', annotation);
|
|
|
|
try {
|
|
const { container, getByRole, getByText } = render(
|
|
<PreviewDrawOverlay active>
|
|
<iframe title="srcdoc" data-od-render-mode="srcdoc" />
|
|
</PreviewDrawOverlay>,
|
|
);
|
|
|
|
const canvas = container.querySelector<HTMLCanvasElement>('canvas');
|
|
expect(canvas).toBeTruthy();
|
|
drawSelectionBox(canvas!);
|
|
|
|
const input = container.querySelector<HTMLInputElement>('.preview-draw-note-input');
|
|
expect(input).toBeTruthy();
|
|
fireEvent.change(input!, { target: { value: 'This section is missing its bar chart.' } });
|
|
|
|
fireEvent.click(getByRole('button', { name: 'Send' }));
|
|
|
|
await waitFor(() => expect(annotation).toHaveBeenCalledTimes(1));
|
|
expect(annotation.mock.calls[0]?.[0]).toMatchObject({
|
|
detail: expect.objectContaining({
|
|
action: 'send',
|
|
note: 'This section is missing its bar chart.',
|
|
file: null,
|
|
}),
|
|
});
|
|
// The user is told the annotation went out without its screenshot.
|
|
await waitFor(() =>
|
|
expect(
|
|
getByText('Could not capture the preview. The annotation was sent without a screenshot.'),
|
|
).toBeTruthy(),
|
|
);
|
|
} finally {
|
|
window.removeEventListener('opendesign:annotation', annotation);
|
|
}
|
|
});
|
|
|
|
it('still blocks a box-only annotation with no note when the snapshot fails', async () => {
|
|
const annotation = vi.fn();
|
|
window.addEventListener('opendesign:annotation', annotation);
|
|
|
|
try {
|
|
const { container, getByRole, getByText } = render(
|
|
<PreviewDrawOverlay active>
|
|
<iframe title="srcdoc" data-od-render-mode="srcdoc" />
|
|
</PreviewDrawOverlay>,
|
|
);
|
|
|
|
const canvas = container.querySelector<HTMLCanvasElement>('canvas');
|
|
expect(canvas).toBeTruthy();
|
|
drawSelectionBox(canvas!);
|
|
|
|
fireEvent.click(getByRole('button', { name: 'Send' }));
|
|
|
|
await waitFor(() =>
|
|
expect(
|
|
getByText('Could not capture the preview. Try again to avoid sending only ink.'),
|
|
).toBeTruthy(),
|
|
);
|
|
expect(annotation).not.toHaveBeenCalled();
|
|
} finally {
|
|
window.removeEventListener('opendesign:annotation', annotation);
|
|
}
|
|
});
|
|
});
|