open-design/apps/web/tests/components/file-viewer-render-mode.test.ts
leessju 4e19c3f4f3
Prevent imported Claude canvases from zooming on scroll (#1726)
* Preserve HTML preview state across mode toggles

HTML previews could rebuild their iframe when switching into Edit or Comment, which reset scroll/canvas state and caused visible churn for multi-file artifacts. The viewer now keeps URL-loaded previews mounted when the artifact owns the mode bridge, relays file-refreshes through frame navigation, and restores preview scroll/viewport state across bridge mode changes.

Constraint: Generic srcdoc-only bridges are still required for unbridged artifacts, inspect mode, palette tweaks, decks, draw overlays, and forceInline.

Rejected: Keep all Edit/Comment previews on srcdoc | causes unnecessary iframe replacement for bridge-capable URL-loaded artifacts.

Confidence: high

Scope-risk: moderate

Directive: Do not enable URL-load for bridge-dependent modes unless the artifact has an owned postMessage bridge.

Tested: pnpm guard

Tested: pnpm --filter @open-design/web typecheck

Tested: pnpm --filter @open-design/web test

Tested: Playwright verified Edit and Comment toggles preserve iframe src and DOM node while receiving comment targets.

* Prevent preview wheel gestures from escaping into zoom

Trackpad pinch-like wheel events arrive with ctrl/meta modifiers on some platforms, which can make a normal vertical scroll feel like the preview zoomed. The preview now consumes those modified wheel events inside the host preview shell and in injected srcdoc previews, then maps the delta back to scroll where a scroll target exists.

Constraint: URL-loaded sandbox iframes cannot always be inspected by the host, so srcdoc previews need their own in-frame guard.

Rejected: Add allow-same-origin to preview iframes | weakens the sandbox boundary for generated artifacts.

Confidence: medium

Scope-risk: narrow

Directive: Do not broaden iframe sandbox permissions to fix gesture handling without a security review.

Tested: pnpm guard

Tested: pnpm --filter @open-design/web typecheck

Tested: pnpm --filter @open-design/web exec vitest run tests/components/FileViewer.test.tsx tests/runtime/srcdoc.test.ts

Tested: playwright-cli verified ctrl-wheel in preview keeps app zoom at 100% and prevents default in the iframe context

* Revert "Prevent preview wheel gestures from escaping into zoom"

This reverts commit 976407ab4c.

* Prevent imported Claude canvases from zooming on scroll

Claude Design exports can classify ordinary macOS two-finger vertical wheel events as mouse-wheel zoom clicks inside design-canvas.jsx. Normalize that imported canvas code so plain wheel input pans, while Cmd+wheel remains the explicit zoom gesture.

Constraint: The offending canvas code lives inside imported user artifacts rather than a tracked runtime component, so the fix belongs in the Claude Design zip import normalization path.\nRejected: Host-side wheel interception | wheel events inside the sandboxed iframe are handled by the artifact before the host can reliably classify them.\nRejected: Disable all wheel zoom | users still need Cmd+wheel as an explicit zoom control.\nConfidence: high\nScope-risk: narrow\nDirective: Keep plain wheel as pan-only for imported design-canvas.jsx unless a future bridge provides an explicit wheel-mode handshake.\nTested: pnpm --filter @open-design/daemon exec vitest run tests/claude-design-import.test.ts\nTested: pnpm --filter @open-design/daemon typecheck\nTested: pnpm guard

---------

Co-authored-by: nicejames <nicejames@gmail.com>
Co-authored-by: lefarcen <935902669@qq.com>
2026-05-15 16:37:57 +08:00

183 lines
8.3 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
hasUrlModeBridge,
htmlNeedsSandboxShim,
parseForceInline,
shouldUrlLoadHtmlPreview,
} from '../../src/components/file-viewer-render-mode';
describe('shouldUrlLoadHtmlPreview', () => {
const base = { mode: 'preview' as const, isDeck: false, commentMode: false, forceInline: false };
it('URL-loads a plain HTML preview by default', () => {
expect(shouldUrlLoadHtmlPreview(base)).toBe(true);
});
it('falls back to srcDoc when the file is a deck (deck bridge required)', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, isDeck: true })).toBe(false);
});
it('falls back to srcDoc when comment mode is active without an artifact-owned bridge', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, commentMode: true })).toBe(false);
});
it('keeps URL-load when comment mode is active and the artifact owns the bridge', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, commentMode: true, urlModeBridge: true })).toBe(true);
});
it('falls back to srcDoc when direct edit mode is active without an artifact-owned bridge', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, editMode: true })).toBe(false);
});
it('keeps URL-load when direct edit mode is active and the artifact owns the bridge', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, editMode: true, urlModeBridge: true })).toBe(true);
});
it('falls back to srcDoc when inspect mode is active (selection bridge required)', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, inspectMode: true })).toBe(false);
});
it('falls back to srcDoc when draw mode is active (snapshot bridge required)', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, drawMode: true })).toBe(false);
});
it('falls back to srcDoc when the user opts in via forceInline', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, forceInline: true })).toBe(false);
});
it('does not URL-load while the source-code tab is active', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, mode: 'source' })).toBe(false);
});
it('treats any disqualifying flag as sufficient on its own', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, isDeck: true, commentMode: true })).toBe(false);
expect(shouldUrlLoadHtmlPreview({ ...base, isDeck: true, forceInline: true })).toBe(false);
expect(shouldUrlLoadHtmlPreview({ ...base, commentMode: true, forceInline: true })).toBe(false);
expect(shouldUrlLoadHtmlPreview({ ...base, commentMode: true, urlModeBridge: true, inspectMode: true })).toBe(false);
});
});
describe('hasUrlModeBridge', () => {
it('detects an artifact-owned direct-edit bridge script', () => {
expect(hasUrlModeBridge('<script src="od-direct-edit.js"></script>')).toBe(true);
expect(hasUrlModeBridge('<script defer src="./assets/od-direct-edit.js?v=1"></script>')).toBe(true);
});
it('ignores comments, text nodes, and inline script bodies that only mention the bridge name', () => {
expect(hasUrlModeBridge('<!-- TODO: ship od-direct-edit.js -->')).toBe(false);
expect(hasUrlModeBridge('<p>Use od-direct-edit.js for editing</p>')).toBe(false);
expect(hasUrlModeBridge('<script>console.log("od-direct-edit.js")</script>')).toBe(false);
});
it('ignores unrelated script URLs', () => {
expect(hasUrlModeBridge('<script src="direct-edit.js"></script>')).toBe(false);
expect(hasUrlModeBridge(null)).toBe(false);
});
});
describe('parseForceInline', () => {
it('returns false when the parameter is absent', () => {
expect(parseForceInline('')).toBe(false);
expect(parseForceInline('?other=1')).toBe(false);
expect(parseForceInline(null)).toBe(false);
expect(parseForceInline(undefined)).toBe(false);
});
it('returns true for the documented opt-in values', () => {
expect(parseForceInline('?forceInline=1')).toBe(true);
expect(parseForceInline('?forceInline=true')).toBe(true);
expect(parseForceInline('?forceInline=TRUE')).toBe(true);
expect(parseForceInline('?forceInline=yes')).toBe(true);
expect(parseForceInline('?forceInline=on')).toBe(true);
});
it('returns false for explicit opt-out values and unrelated strings', () => {
expect(parseForceInline('?forceInline=0')).toBe(false);
expect(parseForceInline('?forceInline=false')).toBe(false);
expect(parseForceInline('?forceInline=no')).toBe(false);
expect(parseForceInline('?forceInline=off')).toBe(false);
expect(parseForceInline('?forceInline=banana')).toBe(false);
});
it('treats an empty value as absent (defensive: ?forceInline= shows up as "")', () => {
expect(parseForceInline('?forceInline=')).toBe(false);
});
it('accepts a pre-built URLSearchParams', () => {
const params = new URLSearchParams('forceInline=1&other=foo');
expect(parseForceInline(params)).toBe(true);
});
it('survives surrounding whitespace in the value', () => {
const params = new URLSearchParams();
params.set('forceInline', ' 1 ');
expect(parseForceInline(params)).toBe(true);
});
});
describe('htmlNeedsSandboxShim', () => {
it('returns false for plain static HTML', () => {
expect(htmlNeedsSandboxShim('<!doctype html><h1>hello</h1>')).toBe(false);
});
it('detects <script type="text/babel"> (Babel-standalone React prototypes)', () => {
// Real agent-emitted shape with src= and double-quoted attributes.
expect(
htmlNeedsSandboxShim(
'<script type="text/babel" src="components/Icon.jsx"></script>',
),
).toBe(true);
// Single quotes.
expect(htmlNeedsSandboxShim("<script type='text/babel'>const a = 1;</script>")).toBe(true);
// Extra attributes before type=.
expect(
htmlNeedsSandboxShim('<script defer type="text/babel" src="app.jsx"></script>'),
).toBe(true);
// Whitespace around the equals sign.
expect(htmlNeedsSandboxShim('<script type = "text/babel"></script>')).toBe(true);
// Case-insensitive type value.
expect(htmlNeedsSandboxShim('<script type="TEXT/BABEL"></script>')).toBe(true);
});
it('detects unquoted <script type=text/babel> (HTML5 permits unquoted attrs)', () => {
// Bare unquoted type value, no other attributes.
expect(htmlNeedsSandboxShim('<script type=text/babel></script>')).toBe(true);
// Unquoted with an unquoted src= following — terminates on whitespace.
expect(
htmlNeedsSandboxShim('<script type=text/babel src=app.jsx></script>'),
).toBe(true);
// Mixed: unquoted type=, then a quoted src=.
expect(
htmlNeedsSandboxShim('<script type=text/babel src="components/Icon.jsx"></script>'),
).toBe(true);
// Trailing `\b` rejects word continuations: `type=text/babelish` does
// not match because `l`→`i` is a word-internal transition. Hyphenated
// variants like `type=text/babel-other` still match per the helper
// docstring (`l`→`-` is a word boundary) — that's the documented safe
// false-positive direction, so it is intentionally not asserted here.
expect(htmlNeedsSandboxShim('<script type=text/babelish></script>')).toBe(false);
});
it('does not match plain <script> tags or unrelated MIME types', () => {
expect(htmlNeedsSandboxShim('<script src="app.js"></script>')).toBe(false);
expect(htmlNeedsSandboxShim('<script type="module" src="app.js"></script>')).toBe(false);
expect(htmlNeedsSandboxShim('<script type="application/json">{}</script>')).toBe(false);
// Substring-only matches must not trigger (e.g. text/babel-like custom type).
expect(htmlNeedsSandboxShim('<script type="text/babelish"></script>')).toBe(false);
});
it('detects direct localStorage / sessionStorage references in the source', () => {
expect(htmlNeedsSandboxShim('<script>localStorage.getItem("k")</script>')).toBe(true);
expect(htmlNeedsSandboxShim('<script>sessionStorage.setItem("k","v")</script>')).toBe(true);
// Inside an external script tag's surrounding markup still trips the
// scan when the literal name appears in the document the iframe loads.
expect(htmlNeedsSandboxShim('// uses localStorage to persist theme')).toBe(true);
});
it('does not match incidental substrings that are not the storage globals', () => {
expect(htmlNeedsSandboxShim('Storage')).toBe(false);
expect(htmlNeedsSandboxShim('mylocalStorageWrapper')).toBe(false);
expect(htmlNeedsSandboxShim('SuperLocalStorage')).toBe(false);
});
});