Files
apes-sidebar/tests/html-preview-frame.spec.tsx
T
figmar 4c242acf7b
CI / ci (push) Canceled after 0s
CI / ci-windows (push) Canceled after 0s
CI / plugin-mount (push) Canceled after 0s
fix(html-preview): deliver the document through srcdoc so a sandboxed frame can load it
The preview showed nothing but the bare word `forbidden` in DSH Desktop. The
cause is not this plugin's routes but the host, one layer above them:
`DesktopWebServer` wraps EVERY route with `permits()`, and in the default
isolated/扩展 mode `ordinaryBrowserEnabled` is false, so a request is served
only when it carries the Electron renderer capability
(`x-dsh-desktop-renderer`). That header is attached solely to MAIN-frame and
SAME-ORIGIN-subframe traffic (`requestComesFromRendererOrigin`), and a frame
sandboxed WITHOUT allow-same-origin is an opaque origin at request time — so
the preview document request could never be served, while every other sidebar
route (all main-frame) worked fine.

The fix keeps the sandbox boundary exactly where it was and moves the delivery:
the document is fetched by the MAIN frame — which the host does trust — and
handed to the frame through `srcdoc`. New shared component HtmlPreviewFrame
owns that path plus the sandbox tokens for BOTH surfaces (the editor viewer and
the changes tab's op render preview, which had no escape hatch at all), so the
two can no longer drift; a failed fetch falls back to handing the frame the
route URL, leaving the route's own answer visible instead of a blank pane.

`withPreviewBase` injects `<base href="<the file's route directory>">`: a
srcdoc document has no URL of its own, so without it `./style.css` would
resolve against the GUI page. A document that declares its own base is left
alone.

Two consequences, both deliberate:
- sandboxed: the frame stays opaque, so a self-contained document previews
  completely while a multi-file one shows its markup with the relative assets
  missing (those requests are opaque-origin too — inlining them is a separate
  job);
- unlocked (the warned per-surface unlock, or the setting): the srcdoc frame
  inherits the GUI origin — which is what that escape hatch already meant — and
  there relative references resolve and load like any other same-origin
  request, so multi-file previews work in that mode.

Verified: typecheck clean; build clean; 1364 passed / 9 skipped (130 files,
+13: the pure base-rewrite spec, the delivery spec, and the updated
sandbox-contract assertions, which now pin srcdoc + no route src instead of
the old route-src shape).
2026-10-03 23:15:44 +08:00

96 lines
4.3 KiB
TypeScript

/**
* HtmlPreviewFrame delivery tests — the piece that exists ONLY because of the
* DSH Desktop renderer fence: the host refuses `/sidebar/html/…` to any
* request without the Electron renderer capability, which a frame sandboxed
* without allow-same-origin (an opaque origin) can never carry. The document
* is therefore fetched by the MAIN frame and handed over through `srcdoc`,
* with the sandbox attribute untouched.
*
* Mounted client-side (createRoot + act) because the delivery path is an
* effect; the sandbox-contract assertions live in sandbox-views.spec.tsx.
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { act } from 'react-dom/test-utils'
import { HtmlPreviewFrame } from '../src/client/HtmlPreviewFrame.tsx'
import { HTML_IFRAME_SANDBOX } from '../src/client/html-preview.ts'
import { renderRoot, setupReactAct } from './test-utils.ts'
setupReactAct()
const URL_ = '/sidebar/html/s1/p/a/index.html'
const DOC = '<html><head><title>t</title></head><body><h1>hi</h1></body></html>'
/** Stub the global fetch with one canned response. */
function stubFetch(text: string, ok = true): void {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok, status: ok ? 200 : 403, text: async () => text })))
}
/** Flush the fetch's microtasks + the React commit they trigger. */
async function flush(): Promise<void> {
await act(async () => { await new Promise(resolve => { setTimeout(resolve, 0) }) })
}
afterEach(() => { vi.unstubAllGlobals() })
describe('HtmlPreviewFrame delivery', () => {
it('starts empty, never pointing the frame at the route itself', () => {
stubFetch(DOC)
const r = renderRoot(createElement(HtmlPreviewFrame, { url: URL_, sandboxed: true, title: 'index.html' }))
const iframe = r.container.querySelector('iframe')!
expect(iframe.getAttribute('srcdoc')).toBe('')
expect(iframe.getAttribute('src')).toBe(null)
// The boundary is unchanged while the document is in flight.
expect(iframe.getAttribute('sandbox')).toBe(HTML_IFRAME_SANDBOX)
r.unmount()
})
it('hands the fetched markup to the frame through srcdoc, base-rewritten, sandbox kept', async () => {
stubFetch(DOC)
const r = renderRoot(createElement(HtmlPreviewFrame, { url: URL_, sandboxed: true, title: 'index.html' }))
await flush()
const iframe = r.container.querySelector('iframe')!
const srcdoc = iframe.getAttribute('srcdoc')
expect(srcdoc).toContain('<base href="/sidebar/html/s1/p/a/">')
expect(srcdoc).toContain('<h1>hi</h1>')
// Still no route src: the opaque frame must not be asked to fetch.
expect(iframe.getAttribute('src')).toBe(null)
expect(iframe.getAttribute('sandbox')).toBe(HTML_IFRAME_SANDBOX)
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-same-origin')
r.unmount()
})
it('drops the sandbox attribute for the warned unlock path (the frame inherits the GUI origin)', async () => {
stubFetch(DOC)
const r = renderRoot(createElement(HtmlPreviewFrame, { url: URL_, sandboxed: false, title: 'index.html' }))
await flush()
const iframe = r.container.querySelector('iframe')!
expect(iframe.getAttribute('sandbox')).toBe(null)
expect(iframe.getAttribute('srcdoc')).toContain('<h1>hi</h1>')
r.unmount()
})
it('falls back to the route URL when the fetch fails, so the route still answers', async () => {
stubFetch('', false)
const r = renderRoot(createElement(HtmlPreviewFrame, { url: URL_, sandboxed: true, title: 'index.html' }))
await flush()
const iframe = r.container.querySelector('iframe')!
expect(iframe.getAttribute('src')).toBe(URL_)
expect(iframe.getAttribute('srcdoc')).toBe(null)
r.unmount()
})
it('refetches when the previewed file changes', async () => {
stubFetch('<h1>one</h1>')
const r = renderRoot(createElement(HtmlPreviewFrame, { url: URL_, sandboxed: true, title: 'a.html' }))
await flush()
expect(r.container.querySelector('iframe')!.getAttribute('srcdoc')).toContain('<h1>one</h1>')
stubFetch('<h1>two</h1>')
r.rerender(createElement(HtmlPreviewFrame, { url: '/sidebar/html/s1/p/a/two.html', sandboxed: true, title: 'b.html' }))
await flush()
expect(r.container.querySelector('iframe')!.getAttribute('srcdoc')).toContain('<h1>two</h1>')
r.unmount()
})
})