mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* feat: improve responsive design handoff * feat: refine cross-platform design outputs Changelog:\n- Add auto-fit responsive preview behavior for tablet/mobile frames.\n- Add landing page and OS widgets metadata options with project header chips.\n- Strengthen prompt contracts for modern breakpoints, app-specific modules, CJX-ready UX, and final product surfaces.\n- Require cross-platform outputs to use separate platform files instead of tabbed demo selectors.\n- Add DESIGN-MANIFEST.json plus richer handoff guidance to daemon/client exports.\n- Update archive/export tests for manifest and responsive viewport matrix. * feat: enforce screen-file design outputs Changelog:\n- Enforce screen-file-first generation for landing pages, app screens, platform surfaces, and OS widgets.\n- Update design handoff and manifest exports so coding tools map each screen file to separate routes/surfaces.\n- Strengthen minimal-brief visual guidance to avoid monochrome or unstyled design outputs. * fix: address responsive handoff review feedback * fix: address handoff review blockers * fix: preserve proxy auth and normalized export entry * fix: narrow frame wrapper filter to directory paths only * fix: make artifact save failure banner generic --------- Co-authored-by: Huy Hoàng <macos@MacBook-Pro-Hoang.local>
527 lines
22 KiB
TypeScript
527 lines
22 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import {
|
||
archiveFilenameFrom,
|
||
archiveRootFromFilePath,
|
||
buildDesignHandoffContent,
|
||
buildDesignManifestContent,
|
||
buildSandboxedPreviewDocument,
|
||
exportAsMd,
|
||
exportAsPdf,
|
||
exportProjectAsPdf,
|
||
openSandboxedPreviewInNewTab,
|
||
} from '../../src/runtime/exports';
|
||
|
||
function mockResponse(headers: Record<string, string>): Response {
|
||
return { headers: new Headers(headers) } as Response;
|
||
}
|
||
|
||
describe('archiveRootFromFilePath', () => {
|
||
it('returns the top-level directory name when present', () => {
|
||
expect(archiveRootFromFilePath('ui-design/index.html')).toBe('ui-design');
|
||
expect(archiveRootFromFilePath('ui-design/src/app.css')).toBe('ui-design');
|
||
});
|
||
|
||
it('returns empty for files at the project root', () => {
|
||
expect(archiveRootFromFilePath('index.html')).toBe('');
|
||
expect(archiveRootFromFilePath('README.md')).toBe('');
|
||
});
|
||
|
||
it('strips a leading slash before scanning', () => {
|
||
expect(archiveRootFromFilePath('/ui-design/index.html')).toBe('ui-design');
|
||
expect(archiveRootFromFilePath('//ui-design/index.html')).toBe('ui-design');
|
||
});
|
||
|
||
it('returns empty for empty/garbage input', () => {
|
||
expect(archiveRootFromFilePath('')).toBe('');
|
||
expect(archiveRootFromFilePath('/')).toBe('');
|
||
});
|
||
});
|
||
|
||
describe('archiveFilenameFrom', () => {
|
||
it('decodes the RFC 5987 UTF-8 filename* form (preserves multi-byte chars)', () => {
|
||
// 'café-design.zip' encoded — the é is a 2-byte UTF-8 sequence (%C3%A9),
|
||
// which is enough to fail under naive ASCII-only handling.
|
||
const resp = mockResponse({
|
||
'content-disposition':
|
||
"attachment; filename=\"project.zip\"; filename*=UTF-8''caf%C3%A9-design.zip",
|
||
});
|
||
expect(archiveFilenameFrom(resp, 'fallback', 'ui-design')).toBe('café-design.zip');
|
||
});
|
||
|
||
it('falls back to the legacy quoted filename= when filename* is absent', () => {
|
||
const resp = mockResponse({
|
||
'content-disposition': 'attachment; filename="ui-design.zip"',
|
||
});
|
||
expect(archiveFilenameFrom(resp, 'fallback', 'ui-design')).toBe('ui-design.zip');
|
||
});
|
||
|
||
it('falls back to the active root slug when the header is missing', () => {
|
||
const resp = mockResponse({});
|
||
expect(archiveFilenameFrom(resp, 'fallback-title', 'ui-design')).toBe('ui-design.zip');
|
||
});
|
||
|
||
it('falls back to the title slug when both header and root are absent', () => {
|
||
const resp = mockResponse({});
|
||
expect(archiveFilenameFrom(resp, 'My Artifact', '')).toBe('My-Artifact.zip');
|
||
});
|
||
|
||
it('falls through to the slug when filename* is malformed', () => {
|
||
// Truncated percent-escape — decodeURIComponent throws; we should not
|
||
// surface the exception, just fall back to the next strategy.
|
||
const resp = mockResponse({
|
||
'content-disposition': "attachment; filename*=UTF-8''%E9%9D",
|
||
});
|
||
expect(archiveFilenameFrom(resp, 'fallback', 'ui-design')).toBe('ui-design.zip');
|
||
});
|
||
});
|
||
|
||
describe('buildDesignHandoffContent', () => {
|
||
it('documents coding handoff and responsive verification expectations', () => {
|
||
const content = buildDesignHandoffContent({
|
||
title: 'Checkout Design',
|
||
entryFile: 'index.html',
|
||
files: ['index.html', 'src/app.css', 'src/app.js'],
|
||
});
|
||
|
||
expect(content).toContain('Checkout Design implementation handoff');
|
||
expect(content).toContain('Mobile compact: 360×800');
|
||
expect(content).toContain('Tablet portrait: 820×1180');
|
||
expect(content).toContain('Wide desktop: 1920×1080');
|
||
expect(content).toContain('`src/app.css`');
|
||
expect(content).toContain('visual system');
|
||
expect(content).toContain('Design fidelity contract');
|
||
expect(content).toContain('CJX-ready UX contract');
|
||
expect(content).toContain('DESIGN-MANIFEST.json');
|
||
expect(content).toContain('in-app modules/components');
|
||
expect(content).toContain('OS widgets are home-screen/lock-screen/quick-access surfaces');
|
||
expect(content).toContain('Color and brand contract');
|
||
expect(content).toContain('Do not introduce warm beige / cream / peach / pink / orange-brown background washes');
|
||
expect(content).toContain('Build product screens and domain-specific in-app modules');
|
||
});
|
||
|
||
it('builds a machine-readable design manifest for coding tools', () => {
|
||
const manifest = JSON.parse(buildDesignManifestContent({
|
||
title: 'Checkout Design',
|
||
entryFile: 'index.html',
|
||
files: ['index.html', 'src/app.css', 'src/app.js'],
|
||
}));
|
||
|
||
expect(manifest.schema).toBe('open-design.design-manifest.v1');
|
||
expect(manifest.entryFile).toBe('index.html');
|
||
expect(manifest.sourceFiles.css).toEqual(['src/app.css']);
|
||
expect(manifest.sourceFiles.scriptsAndComponents).toEqual(['src/app.js']);
|
||
expect(manifest.appModules.join(' ')).toContain('domain-specific in-app modules');
|
||
expect(manifest.osWidgets.join(' ')).toContain('home-screen');
|
||
expect(manifest.responsiveViewports).toContainEqual({
|
||
name: 'tablet-portrait',
|
||
width: 820,
|
||
height: 1180,
|
||
category: 'tablet',
|
||
mustAvoidHorizontalScroll: true,
|
||
});
|
||
expect(manifest.implementationChecklist.join(' ')).toContain('landing pages, in-app modules, and OS widgets');
|
||
});
|
||
|
||
it('does not classify plain home.html as a landing page in the manifest', () => {
|
||
const manifest = JSON.parse(buildDesignManifestContent({
|
||
title: 'Product App',
|
||
entryFile: 'home.html',
|
||
files: ['home.html', 'dashboard.html', 'marketing.html'],
|
||
}));
|
||
|
||
const screens = new Map(manifest.screens.map((screen: { file: string; role: string }) => [screen.file, screen.role]));
|
||
expect(screens.get('home.html')).not.toBe('landing-page');
|
||
expect(screens.get('marketing.html')).toBe('landing-page');
|
||
expect(screens.get('dashboard.html')).toBe('product-screen');
|
||
});
|
||
|
||
it('keeps frame wrapper HTML out of client export manifest screens', () => {
|
||
const manifest = JSON.parse(buildDesignManifestContent({
|
||
title: 'Framed App',
|
||
entryFile: 'index.html',
|
||
files: ['index.html', 'frames/iphone-15-pro.html', 'browser-chrome.html', 'src/app.css'],
|
||
}));
|
||
|
||
expect(manifest.sourceFiles.html).toEqual(['browser-chrome.html', 'frames/iphone-15-pro.html', 'index.html']);
|
||
expect(manifest.screens.map((screen: { file: string }) => screen.file)).toEqual(['index.html']);
|
||
});
|
||
|
||
it('normalizes a frame-wrapper active file to the implementable screen entry in manifest and handoff', () => {
|
||
const manifest = JSON.parse(buildDesignManifestContent({
|
||
title: 'Framed App',
|
||
entryFile: 'frames/iphone-15-pro.html',
|
||
files: ['index.html', 'landing.html', 'frames/iphone-15-pro.html', 'src/app.css'],
|
||
}));
|
||
const handoff = buildDesignHandoffContent({
|
||
title: 'Framed App',
|
||
entryFile: 'frames/iphone-15-pro.html',
|
||
files: ['index.html', 'landing.html', 'frames/iphone-15-pro.html', 'src/app.css'],
|
||
});
|
||
|
||
expect(manifest.entryFile).toBe('index.html');
|
||
expect(manifest.screens.map((screen: { file: string }) => screen.file)).toEqual(['index.html', 'landing.html']);
|
||
expect(handoff).toContain('Primary entry: `index.html`');
|
||
expect(handoff).toContain('Open `index.html` and `DESIGN-MANIFEST.json`');
|
||
expect(handoff).not.toContain('Primary entry: `frames/iphone-15-pro.html`');
|
||
});
|
||
|
||
it('keeps phone.html and iphone-upgrade.html as real screens when outside frames/ directory', () => {
|
||
// phone.html as a carrier storefront screen, iphone-upgrade.html as a
|
||
// product surface — neither should be silently dropped from screens just
|
||
// because the filename resembles a device name.
|
||
const manifest = JSON.parse(buildDesignManifestContent({
|
||
title: 'Carrier Storefront',
|
||
entryFile: 'phone.html',
|
||
files: ['phone.html', 'iphone-upgrade.html', 'frames/browser-shell.html', 'src/app.css'],
|
||
}));
|
||
|
||
const screenFiles = manifest.screens.map((screen: { file: string }) => screen.file);
|
||
expect(screenFiles).toContain('phone.html');
|
||
expect(screenFiles).toContain('iphone-upgrade.html');
|
||
// frame wrapper inside frames/ is still excluded
|
||
expect(screenFiles).not.toContain('frames/browser-shell.html');
|
||
// both real screens appear in sourceFiles.html
|
||
expect(manifest.sourceFiles.html).toContain('phone.html');
|
||
expect(manifest.sourceFiles.html).toContain('iphone-upgrade.html');
|
||
expect(manifest.sourceFiles.html).toContain('frames/browser-shell.html');
|
||
});
|
||
});
|
||
|
||
describe('exportProjectAsPdf', () => {
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it('uses the daemon desktop PDF export API before falling back to browser print', async () => {
|
||
const fallback = vi.fn();
|
||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })));
|
||
|
||
const result = await exportProjectAsPdf({
|
||
deck: true,
|
||
fallbackPdf: fallback,
|
||
filePath: 'deck/index.html',
|
||
projectId: 'proj-1',
|
||
title: 'Seed Deck',
|
||
});
|
||
|
||
expect(result).toBe('desktop');
|
||
expect(fallback).not.toHaveBeenCalled();
|
||
expect(fetch).toHaveBeenCalledWith('/api/projects/proj-1/export/pdf', {
|
||
body: JSON.stringify({ deck: true, fileName: 'deck/index.html', title: 'Seed Deck' }),
|
||
headers: { 'content-type': 'application/json' },
|
||
method: 'POST',
|
||
});
|
||
});
|
||
|
||
it('falls back to browser print when the desktop PDF export API is unavailable', async () => {
|
||
const fallback = vi.fn();
|
||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ error: { message: 'unavailable' } }), { status: 501 })));
|
||
|
||
const result = await exportProjectAsPdf({
|
||
deck: false,
|
||
fallbackPdf: fallback,
|
||
filePath: 'index.html',
|
||
projectId: 'proj-1',
|
||
title: 'Landing',
|
||
});
|
||
|
||
expect(result).toBe('fallback');
|
||
expect(fallback).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|
||
|
||
// `exportAsMd` is a pass-through (the file body is the artifact source
|
||
// verbatim, only the extension and Content-Type flip). Tests exercise it
|
||
// end-to-end by stubbing the few DOM globals `triggerDownload` touches —
|
||
// we run under `environment: 'node'`, so `document` and `URL` aren't
|
||
// available by default. See issue #279.
|
||
describe('exportAsMd', () => {
|
||
let capturedBlob: Blob | undefined;
|
||
let capturedFilename: string | undefined;
|
||
|
||
beforeEach(() => {
|
||
capturedBlob = undefined;
|
||
capturedFilename = undefined;
|
||
vi.stubGlobal('URL', {
|
||
createObjectURL: (blob: Blob) => {
|
||
capturedBlob = blob;
|
||
return 'blob:test';
|
||
},
|
||
revokeObjectURL: () => {},
|
||
});
|
||
vi.stubGlobal('document', {
|
||
createElement: () => {
|
||
const anchor = { href: '', click: () => {} } as { href: string; download?: string; click: () => void };
|
||
Object.defineProperty(anchor, 'download', {
|
||
set(value: string) {
|
||
capturedFilename = value;
|
||
},
|
||
get() {
|
||
return capturedFilename ?? '';
|
||
},
|
||
});
|
||
return anchor;
|
||
},
|
||
body: { appendChild: () => {}, removeChild: () => {} },
|
||
});
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
it('downloads the source bytes verbatim under a `.md` extension', async () => {
|
||
const source = '<!doctype html>\n<html lang="en"><body>hi</body></html>\n';
|
||
|
||
exportAsMd(source, 'TTC — Seed Round · 2026');
|
||
|
||
expect(capturedBlob).toBeDefined();
|
||
expect(capturedBlob!.type).toBe('text/markdown;charset=utf-8');
|
||
// Critical: no transformation, no normalization, no trimming. Whatever
|
||
// the Source view shows is what lands in the .md.
|
||
expect(await capturedBlob!.text()).toBe(source);
|
||
expect(capturedFilename).toBe('TTC-Seed-Round-2026.md');
|
||
});
|
||
|
||
it('falls back to "artifact.md" when the title is empty or unsafe', () => {
|
||
exportAsMd('hello', '');
|
||
expect(capturedFilename).toBe('artifact.md');
|
||
|
||
exportAsMd('hello', '???');
|
||
expect(capturedFilename).toBe('artifact.md');
|
||
});
|
||
|
||
it('keeps multi-byte content (UTF-8) intact end-to-end', async () => {
|
||
const source = '# 中文标题\n\n这是 markdown 文件 — でも本当は HTML 源代码 (مرحبا)。\n';
|
||
|
||
exportAsMd(source, 'mixed');
|
||
|
||
expect(await capturedBlob!.text()).toBe(source);
|
||
});
|
||
});
|
||
|
||
describe('sandboxed preview Blob exports', () => {
|
||
let capturedBlob: Blob | undefined;
|
||
let openedFeatures: string | undefined;
|
||
let mockWin: { opener: unknown; location: { href: string } };
|
||
let openCalls: string[][];
|
||
|
||
beforeEach(() => {
|
||
capturedBlob = undefined;
|
||
openedFeatures = undefined;
|
||
openCalls = [];
|
||
mockWin = { opener: {}, location: { href: '' } };
|
||
vi.stubGlobal('URL', {
|
||
createObjectURL: (blob: Blob) => {
|
||
capturedBlob = blob;
|
||
return 'blob:test';
|
||
},
|
||
revokeObjectURL: vi.fn(),
|
||
});
|
||
vi.stubGlobal('window', {
|
||
open: (_url: string, _target: string, features?: string) => {
|
||
openCalls.push([_url, _target]);
|
||
openedFeatures = features;
|
||
return mockWin;
|
||
},
|
||
addEventListener: () => {},
|
||
});
|
||
vi.stubGlobal('alert', vi.fn());
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
it('wraps generated HTML in an opaque-origin sandbox for new-tab previews', async () => {
|
||
openSandboxedPreviewInNewTab('<script>window.parent.localStorage.clear()</script>', 'Unsafe preview');
|
||
|
||
expect(openedFeatures).toBe('noopener,noreferrer');
|
||
expect(capturedBlob).toBeDefined();
|
||
const wrapper = await capturedBlob!.text();
|
||
expect(wrapper).toContain('sandbox="allow-scripts"');
|
||
expect(wrapper).not.toContain('allow-same-origin');
|
||
expect(wrapper).toContain('<script>window.parent.localStorage.clear()</script>');
|
||
expect(wrapper).not.toContain('<script>window.parent.localStorage.clear()</script>');
|
||
});
|
||
|
||
it('passes srcdoc options through the sandboxed new-tab wrapper', async () => {
|
||
openSandboxedPreviewInNewTab('<section class="slide">One</section>', 'Deck preview', {
|
||
deck: true,
|
||
baseHref: '/artifacts/project/assets/',
|
||
initialSlideIndex: 2,
|
||
});
|
||
|
||
expect(openedFeatures).toBe('noopener,noreferrer');
|
||
expect(capturedBlob).toBeDefined();
|
||
const wrapper = await capturedBlob!.text();
|
||
expect(wrapper).toContain('sandbox="allow-scripts"');
|
||
expect(wrapper).not.toContain('allow-same-origin');
|
||
expect(wrapper).toContain('<base href="/artifacts/project/assets/">');
|
||
expect(wrapper).toContain('od:slide');
|
||
});
|
||
|
||
it('can build a print wrapper without granting same-origin access', () => {
|
||
const wrapper = buildSandboxedPreviewDocument('<!doctype html><title>x</title>', 'Print', {
|
||
allowModals: true,
|
||
});
|
||
|
||
expect(wrapper).toContain('sandbox="allow-scripts allow-modals"');
|
||
expect(wrapper).not.toContain('allow-same-origin');
|
||
});
|
||
|
||
it('uses a sandboxed Blob wrapper with synchronous popup detection for PDF exports', async () => {
|
||
await exportAsPdf('<script>window.parent.document.body.innerHTML="owned"</script>', 'PDF');
|
||
|
||
expect(openCalls).toEqual([['', '_blank']]);
|
||
expect(mockWin.opener).toBeNull();
|
||
expect(mockWin.location.href).toBe('blob:test');
|
||
expect(capturedBlob).toBeDefined();
|
||
const wrapper = await capturedBlob!.text();
|
||
expect(wrapper).toContain('sandbox="allow-scripts allow-modals"');
|
||
expect(wrapper).not.toContain('allow-same-origin');
|
||
expect(wrapper).toContain('<script>window.parent.document.body.innerHTML="owned"</script>');
|
||
expect(wrapper).not.toContain('<script>window.parent.document.body.innerHTML="owned"</script>');
|
||
});
|
||
|
||
it('preserves deck print handling inside sandboxed PDF exports', async () => {
|
||
await exportAsPdf('<section class="slide">One</section>', 'Deck PDF', { deck: true });
|
||
|
||
expect(openCalls).toEqual([['', '_blank']]);
|
||
expect(mockWin.opener).toBeNull();
|
||
expect(mockWin.location.href).toBe('blob:test');
|
||
expect(capturedBlob).toBeDefined();
|
||
const wrapper = await capturedBlob!.text();
|
||
expect(wrapper).toContain('sandbox="allow-scripts allow-modals"');
|
||
expect(wrapper).toContain('data-deck-print="injected"');
|
||
expect(wrapper).toContain('page-break-after: always;');
|
||
});
|
||
|
||
it('allows explicit trusted PDF opt-out without changing the secure default', async () => {
|
||
await exportAsPdf('<main>Trusted local document</main>', 'Trusted PDF', {
|
||
sandboxedPreview: false,
|
||
});
|
||
|
||
expect(openCalls).toEqual([['', '_blank']]);
|
||
expect(mockWin.opener).toEqual({});
|
||
expect(mockWin.location.href).toBe('blob:test');
|
||
expect(capturedBlob).toBeDefined();
|
||
const doc = await capturedBlob!.text();
|
||
expect(doc).not.toContain('sandbox="allow-scripts allow-modals"');
|
||
expect(doc).toContain('<main>Trusted local document</main>');
|
||
});
|
||
|
||
it('shows an alert and revokes the blob URL when the popup is blocked', async () => {
|
||
vi.stubGlobal('window', {
|
||
open: () => null,
|
||
addEventListener: () => {},
|
||
});
|
||
|
||
const revokeSpy = URL.revokeObjectURL as ReturnType<typeof vi.fn>;
|
||
revokeSpy.mockClear();
|
||
|
||
await exportAsPdf('<p>test</p>', 'Blocked');
|
||
|
||
expect(alert).toHaveBeenCalledWith('Popup blocked! Click the popup-blocked icon in your browser address bar (or browser menu), choose "Always allow pop-ups" for this site, then retry Export PDF.');
|
||
expect(revokeSpy).toHaveBeenCalledWith('blob:test');
|
||
});
|
||
|
||
it('uses the desktop native print bridge when __odDesktop.printPdf is available', async () => {
|
||
const printPdfMock = vi.fn().mockResolvedValue(undefined);
|
||
vi.stubGlobal('window', {
|
||
open: (_url: string, _target: string, features?: string) => {
|
||
openCalls.push([_url, _target]);
|
||
openedFeatures = features;
|
||
return mockWin;
|
||
},
|
||
addEventListener: () => {},
|
||
__odDesktop: { printPdf: printPdfMock, isDesktop: true },
|
||
});
|
||
|
||
await exportAsPdf('<script>window.parent.document.body.innerHTML="owned"</script>', 'Desktop PDF');
|
||
|
||
expect(printPdfMock).toHaveBeenCalledTimes(1);
|
||
expect(openCalls).toEqual([]);
|
||
|
||
const htmlArg = printPdfMock.mock.calls[0]![0];
|
||
expect(htmlArg).toContain('sandbox="allow-scripts"');
|
||
expect(htmlArg).not.toContain('allow-modals');
|
||
expect(htmlArg).toContain('<script>window.parent.document.body.innerHTML="owned"</script>');
|
||
expect(htmlArg).not.toContain('<script>window.parent.document.body.innerHTML="owned"</script>');
|
||
// Verify the readiness handshake is present — the sandboxed iframe posts
|
||
// 'OD_PRINT_READY' to the parent once fonts and images are loaded.
|
||
expect(htmlArg).toContain('OD_PRINT_READY');
|
||
// Verify the parent-wrapper cache script is present so the handshake is
|
||
// never missed even if 'OD_PRINT_READY' fires before the listener attaches.
|
||
expect(htmlArg).toContain('__odPrintReady');
|
||
// Verify the print script is NOT injected — Electron calls
|
||
// webContents.print() natively, so a self-printing document would
|
||
// trigger a second print dialog.
|
||
expect(htmlArg).not.toContain('window.print()');
|
||
});
|
||
|
||
it('injects image-waiting logic into the print-ready handshake for the desktop bridge', async () => {
|
||
const printPdfMock = vi.fn().mockResolvedValue(undefined);
|
||
vi.stubGlobal('window', {
|
||
open: () => mockWin,
|
||
addEventListener: () => {},
|
||
__odDesktop: { printPdf: printPdfMock, isDesktop: true },
|
||
});
|
||
|
||
// HTML with an intentionally non-loadable image to exercise the
|
||
// incomplete-image detection in the injected handshake.
|
||
const html = '<div><img src="https://example.com/will-not-load.png" alt="test"/></div>';
|
||
await exportAsPdf(html, 'Image Test');
|
||
|
||
const htmlArg = printPdfMock.mock.calls[0]![0];
|
||
// In the sandboxed wrapper the srcdoc attribute is HTML-escaped, so the
|
||
// handshake script content is present as unescaped JS fragments.
|
||
expect(htmlArg).toContain('document.images');
|
||
expect(htmlArg).toContain("img.addEventListener('load'");
|
||
expect(htmlArg).toContain("img.addEventListener('error'");
|
||
expect(htmlArg).toContain('img.complete');
|
||
// The original font- and load-waiting logic must still be present.
|
||
expect(htmlArg).toContain('document.fonts');
|
||
expect(htmlArg).toContain('OD_PRINT_READY');
|
||
// The handshake posts an object with a per-export nonce to prevent
|
||
// spoofing by untrusted artifact code.
|
||
expect(htmlArg).toContain("type:'OD_PRINT_READY'");
|
||
expect(htmlArg).toContain("nonce:'");
|
||
// The cache script also validates the nonce and event source.
|
||
expect(htmlArg).toContain("e.data.type==='OD_PRINT_READY'");
|
||
expect(htmlArg).toContain("e.data.nonce===");
|
||
expect(htmlArg).toContain('e.source===');
|
||
// The parent cache should still be injected.
|
||
expect(htmlArg).toContain('__odPrintReady');
|
||
// No window.print() since the desktop bridge handles printing natively.
|
||
expect(htmlArg).not.toContain('window.print()');
|
||
});
|
||
|
||
it('injects the readiness cache for non-sandboxed desktop exports too', async () => {
|
||
const printPdfMock = vi.fn().mockResolvedValue(undefined);
|
||
vi.stubGlobal('window', {
|
||
open: () => mockWin,
|
||
addEventListener: () => {},
|
||
__odDesktop: { printPdf: printPdfMock, isDesktop: true },
|
||
});
|
||
|
||
await exportAsPdf('<main>Trusted local document</main>', 'Trusted', {
|
||
sandboxedPreview: false,
|
||
});
|
||
|
||
expect(printPdfMock).toHaveBeenCalledTimes(1);
|
||
const htmlArg = printPdfMock.mock.calls[0]![0];
|
||
// No sandbox wrapper — the document is passed through directly.
|
||
expect(htmlArg).not.toContain('sandbox="allow-scripts"');
|
||
expect(htmlArg).toContain('<main>Trusted local document</main>');
|
||
// The readiness handshake must still be injected.
|
||
expect(htmlArg).toContain('OD_PRINT_READY');
|
||
// The cache must be present so waitForPrintReadyHandshake never hangs.
|
||
expect(htmlArg).toContain('__odPrintReady');
|
||
// No window.print() since the desktop bridge handles printing natively.
|
||
expect(htmlArg).not.toContain('window.print()');
|
||
});
|
||
});
|