mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
Proxy preview scroll wheel deltas through the iframe bridge so Draw can keep scrolling URL-loaded previews without cross-origin access failures. Agent-Model: gpt-5 Agent-Family: openai Agent-Session: 019e6ceb-c33d-7cd3-bff0-cbc20c642197 Agent-Step: 0.0.4
255 lines
8.5 KiB
TypeScript
255 lines
8.5 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react';
|
|
import { afterEach, 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,
|
|
requestPreviewSnapshot: vi.fn(async () => ({ dataUrl: 'data:image/png;base64,AAAA', w: 10, h: 10 })),
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.mocked(requestPreviewSnapshot).mockClear();
|
|
});
|
|
|
|
describe('PreviewDrawOverlay', () => {
|
|
it('uses the visible primary send action when Enter submits a note', async () => {
|
|
const annotation = vi.fn();
|
|
window.addEventListener('opendesign:annotation', annotation);
|
|
|
|
try {
|
|
const { container } = render(
|
|
<PreviewDrawOverlay active>
|
|
<div style={{ width: 320, height: 200 }} />
|
|
</PreviewDrawOverlay>,
|
|
);
|
|
|
|
const input = container.querySelector<HTMLInputElement>('.preview-draw-note-input');
|
|
expect(input).toBeTruthy();
|
|
|
|
fireEvent.change(input!, { target: { value: 'Please inspect this panel.' } });
|
|
fireEvent.keyDown(input!, { key: 'Enter' });
|
|
|
|
await waitFor(() => expect(annotation).toHaveBeenCalledTimes(1));
|
|
expect(annotation.mock.calls[0]?.[0].detail).toMatchObject({
|
|
action: 'send',
|
|
note: 'Please inspect this panel.',
|
|
});
|
|
} finally {
|
|
window.removeEventListener('opendesign:annotation', annotation);
|
|
}
|
|
});
|
|
|
|
it('does not submit a note when Enter confirms IME composition', () => {
|
|
const annotation = vi.fn();
|
|
window.addEventListener('opendesign:annotation', annotation);
|
|
|
|
try {
|
|
const { container } = render(
|
|
<PreviewDrawOverlay active>
|
|
<div style={{ width: 320, height: 200 }} />
|
|
</PreviewDrawOverlay>,
|
|
);
|
|
|
|
const input = container.querySelector<HTMLInputElement>('.preview-draw-note-input');
|
|
expect(input).toBeTruthy();
|
|
|
|
fireEvent.change(input!, { target: { value: '检查这个面板' } });
|
|
fireEvent.compositionStart(input!);
|
|
fireEvent.keyDown(input!, { key: 'Enter', keyCode: 229 });
|
|
|
|
expect(annotation).not.toHaveBeenCalled();
|
|
} finally {
|
|
window.removeEventListener('opendesign:annotation', annotation);
|
|
}
|
|
});
|
|
|
|
it('disables only the primary send action when sending is blocked', 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 } = render(
|
|
<PreviewDrawOverlay active sendDisabled sendDisabledReason="Task running">
|
|
<div style={{ width: 320, height: 200 }} />
|
|
</PreviewDrawOverlay>,
|
|
);
|
|
|
|
const input = container.querySelector<HTMLInputElement>('.preview-draw-note-input');
|
|
expect(input).toBeTruthy();
|
|
fireEvent.change(input!, { target: { value: 'Please queue this note.' } });
|
|
|
|
const sendButton = getByRole('button', { name: 'Send' }) as HTMLButtonElement;
|
|
const queueButton = getByRole('button', { name: 'Queue' }) as HTMLButtonElement;
|
|
expect(sendButton.disabled).toBe(true);
|
|
expect(sendButton.title).toBe('Task running');
|
|
expect(queueButton.disabled).toBe(false);
|
|
|
|
fireEvent.keyDown(input!, { key: 'Enter' });
|
|
fireEvent.click(sendButton);
|
|
expect(annotation).not.toHaveBeenCalled();
|
|
|
|
fireEvent.click(queueButton);
|
|
await waitFor(() => expect(annotation).toHaveBeenCalledTimes(1));
|
|
expect(annotation.mock.calls[0]?.[0]).toMatchObject({
|
|
detail: expect.objectContaining({ action: 'queue' }),
|
|
});
|
|
} finally {
|
|
window.removeEventListener('opendesign:annotation', annotation);
|
|
}
|
|
});
|
|
|
|
it('clears transient ink when draw mode exits', async () => {
|
|
const { container, rerender } = render(
|
|
<PreviewDrawOverlay active>
|
|
<div style={{ width: 320, height: 200 }} />
|
|
</PreviewDrawOverlay>,
|
|
);
|
|
|
|
const canvas = container.querySelector('canvas');
|
|
expect(canvas).toBeTruthy();
|
|
|
|
fireEvent.pointerDown(canvas!, { clientX: 10, clientY: 10, pointerId: 1 });
|
|
fireEvent.pointerMove(canvas!, { clientX: 40, clientY: 40, pointerId: 1 });
|
|
fireEvent.pointerUp(canvas!, { pointerId: 1 });
|
|
|
|
rerender(
|
|
<PreviewDrawOverlay active={false}>
|
|
<div style={{ width: 320, height: 200 }} />
|
|
</PreviewDrawOverlay>,
|
|
);
|
|
|
|
await waitFor(() => expect(container.querySelector('canvas')).toBeNull());
|
|
});
|
|
|
|
it('forwards wheel scrolling to the preview iframe while drawing', () => {
|
|
const { container } = render(
|
|
<PreviewDrawOverlay active>
|
|
<iframe title="preview" />
|
|
</PreviewDrawOverlay>,
|
|
);
|
|
|
|
const canvas = container.querySelector('canvas');
|
|
const iframe = container.querySelector('iframe');
|
|
expect(canvas).toBeTruthy();
|
|
expect(iframe?.contentWindow).toBeTruthy();
|
|
|
|
const scrollBy = vi.fn();
|
|
Object.defineProperty(iframe!.contentWindow!, 'scrollBy', {
|
|
value: scrollBy,
|
|
configurable: true,
|
|
});
|
|
|
|
fireEvent.wheel(canvas!, {
|
|
deltaX: 12,
|
|
deltaY: 180,
|
|
});
|
|
|
|
expect(scrollBy).toHaveBeenCalledWith({ left: 12, top: 180, behavior: 'auto' });
|
|
});
|
|
|
|
it('uses the postMessage scroll bridge for sandboxed preview iframes', () => {
|
|
const { container } = render(
|
|
<PreviewDrawOverlay active>
|
|
<iframe title="preview" sandbox="allow-scripts allow-downloads" />
|
|
</PreviewDrawOverlay>,
|
|
);
|
|
|
|
const canvas = container.querySelector('canvas');
|
|
const iframe = container.querySelector('iframe');
|
|
expect(canvas).toBeTruthy();
|
|
expect(iframe?.contentWindow).toBeTruthy();
|
|
|
|
const postMessage = vi.fn();
|
|
Object.defineProperty(iframe!.contentWindow!, 'postMessage', {
|
|
value: postMessage,
|
|
configurable: true,
|
|
});
|
|
|
|
fireEvent.wheel(canvas!, {
|
|
deltaX: 8,
|
|
deltaY: 96,
|
|
});
|
|
|
|
expect(postMessage).toHaveBeenCalledWith(
|
|
{ type: 'od:preview-scroll-by', left: 8, top: 96 },
|
|
'*',
|
|
);
|
|
});
|
|
|
|
it('falls back to the scroll bridge when direct frame scroll is cross-origin blocked', () => {
|
|
const { container } = render(
|
|
<PreviewDrawOverlay active>
|
|
<iframe title="preview" />
|
|
</PreviewDrawOverlay>,
|
|
);
|
|
|
|
const canvas = container.querySelector('canvas');
|
|
const iframe = container.querySelector('iframe');
|
|
expect(canvas).toBeTruthy();
|
|
expect(iframe?.contentWindow).toBeTruthy();
|
|
|
|
const postMessage = vi.fn();
|
|
Object.defineProperty(iframe!.contentWindow!, 'postMessage', {
|
|
value: postMessage,
|
|
configurable: true,
|
|
});
|
|
Object.defineProperty(iframe!.contentWindow!, 'scrollBy', {
|
|
get() {
|
|
throw new DOMException('Blocked a frame from accessing a cross-origin frame.', 'SecurityError');
|
|
},
|
|
configurable: true,
|
|
});
|
|
|
|
fireEvent.wheel(canvas!, {
|
|
deltaX: 4,
|
|
deltaY: 72,
|
|
});
|
|
|
|
expect(postMessage).toHaveBeenCalledWith(
|
|
{ type: 'od:preview-scroll-by', left: 4, top: 72 },
|
|
'*',
|
|
);
|
|
});
|
|
|
|
it('closes the draw toolbar from an explicit close button', async () => {
|
|
const onActiveChange = vi.fn();
|
|
const { getByRole } = render(
|
|
<PreviewDrawOverlay active onActiveChange={onActiveChange}>
|
|
<div style={{ width: 320, height: 200 }} />
|
|
</PreviewDrawOverlay>,
|
|
);
|
|
|
|
fireEvent.click(getByRole('button', { name: 'Close' }));
|
|
|
|
expect(onActiveChange).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it('snapshots the srcDoc bridge iframe, not the visible URL-load frame', async () => {
|
|
const snapshot = vi.mocked(requestPreviewSnapshot);
|
|
const { getByRole } = render(
|
|
<PreviewDrawOverlay active captureViewport>
|
|
{/* URL-load frame is the visible/active one (e.g. a deck) but has no bridge */}
|
|
<iframe title="url" data-od-active="true" />
|
|
{/* srcDoc frame is mounted but hidden; it hosts the snapshot bridge */}
|
|
<iframe title="srcdoc" data-od-render-mode="srcdoc" data-od-active="false" />
|
|
</PreviewDrawOverlay>,
|
|
);
|
|
|
|
fireEvent.click(getByRole('button', { name: 'Send' }));
|
|
|
|
await waitFor(() => expect(snapshot).toHaveBeenCalled());
|
|
const usedIframe = snapshot.mock.calls[0]?.[0] as HTMLIFrameElement;
|
|
expect(usedIframe.getAttribute('data-od-render-mode')).toBe('srcdoc');
|
|
});
|
|
});
|