Files
apes-sidebar/tests/html-preview-frame.spec.tsx
T
figmar e5bbef1907 feat(sync): re-base the fork onto upstream dsh-better-sidebar v0.24.1 (fork 1.3.0)
Branch for hardware verification on a host that meets v0.24.1's peer range.

Base: upstream `dsh-better-sidebar` v0.24.1 (tag v0.24.1 = a2751cf), MIT.
Measured delta carried over from the v0.19.1 line: 78 rename-only files, 11 real
edits, 10 additions. Ported with the same measurement method as the v0.19.1
re-base: build a normalized base (upstream tree + the fork rename, excluding
README.md / package.json / docs/plans/* which deliberately keep the upstream
name), extract the delta against it, then three-way apply onto the renamed
v0.24.1 tree (`git apply -3`). 13 conflicts resolved by hand.

Identity:
- version 1.3.0, SIDEBAR_SERVICE_VERSION 1.3.0 (package.json ↔ service.ts pair
  is asserted by tests/service.spec.ts), forkedFrom -> v0.24.1/a2751cf.
- package.json keeps the union of both sides' metadata: upstream's new
  `zustand` + `icon`/`locale` files entries, plus the fork's `scripts/switch.sh`
  entry and its `forkedFrom` block.
- README.md takes upstream v0.24.1 wholesale (its own v0.24.1 changelog and all
  16 sections) and keeps the fork's two deltas: the fork-notice block at the top
  (now documenting the 0.2.0-rc.1+ requirement) and the fork package name.
- peerDependencies stay upstream's `^0.2.0-rc.1` — this line does NOT load on
  DSH 0.1.x: the host's semver pre-check rejects a caret range across a minor
  and silently disables the row (no error, no load).

Carried features, all re-verified against the new base:
- apes:* information-layer tabs (src/client/apes/) — the subtree's session feed
  changed shape in 0.1.7 and the port follows the host's own migration notes in
  context-types.ts: the per-session catalog is now
  `projectionsBySession[id].values.subagentCatalog` (a flat entry list) instead
  of 0.1.6's `subagentsByParent` catalog with `entries`/`kind`/`activity`, and
  the removed `list.current` is answered by `props.scope.sessionId`. Rows are
  normalized to { id, label, mode } with activity read from the durable
  summaries, so catalog and byId sources render alike.
- HTML preview delivery through srcdoc (sandbox kept intact).
- Panel host mounted inside the shell's framed content viewport.

Gate: typecheck clean; build clean; 1515 passed / 9 skipped (141 files).

NOT applied to ~/.dsh/profiles/desktop — that profile runs DSH 2.0.10 with
@deepseek-ai/dsh 0.1.5-rc.2, where this line is silently disabled by design.
2026-10-04 00:58:28 +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()
})
})