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.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* apes:* tab registration tests (Phase 1/1b, C6: 3 tabs). The fork registers
|
||||
* its three information-layer tabs (agents / apps / profile) through the same
|
||||
* BetterSidebarService the upstream builtins use (dogfood). `apes:tasks` was
|
||||
* removed in C6 — it overlaps the official DSH session tree in the native
|
||||
* sidebar. The spec pins the registration contract (id / single / order /
|
||||
* component presence) and the isolated i18n contract (the apes namespace
|
||||
* resolves zh vs en through the attached locale service, and every apesZh
|
||||
* key has an apesEn entry).
|
||||
*
|
||||
* Runs in the node vitest environment like builtins.spec.ts — no DOM, no
|
||||
* real DSH host (a live cordis scratch mount is Phase 2+).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
// Browser globals before the client graph loads (mirror of builtins.spec.ts).
|
||||
import './browser-globals.ts'
|
||||
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
import { createBetterSidebarService } from '../src/client/service.ts'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
import { registerApesTabs } from '../src/client/apes/index.ts'
|
||||
import { apesZh, apesEn, attachApesLocale, t, type ApesCopyKey } from '../src/client/apes/locales.ts'
|
||||
|
||||
function setup(): {
|
||||
service: ReturnType<typeof createBetterSidebarService>
|
||||
store: ReturnType<typeof createSidebarStore>
|
||||
dispose: () => void
|
||||
} {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const dispose = registerApesTabs({} as Context, service)
|
||||
return { service, store, dispose }
|
||||
}
|
||||
|
||||
describe('apes:* tab registrations', () => {
|
||||
it('registers the 3 apes tabs with the apes: namespace', () => {
|
||||
const { service, dispose } = setup()
|
||||
try {
|
||||
expect(service.getTabs().map(t => t.id).sort()).toEqual(
|
||||
['apes:agents', 'apes:apps', 'apes:profile'],
|
||||
)
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('every apes tab is single-instance, visible, and component-carrying', () => {
|
||||
const { service, dispose } = setup()
|
||||
try {
|
||||
for (const id of ['apes:agents', 'apes:apps', 'apes:profile']) {
|
||||
const tab = service.getTab(id)
|
||||
expect(tab, `${id} registered`).toBeDefined()
|
||||
expect(tab?.single, `${id} single`).toBe(true)
|
||||
expect(tab?.hidden, `${id} hidden`).not.toBe(true)
|
||||
expect(tab?.component, `${id} component`).toBeDefined()
|
||||
expect(tab?.icon, `${id} icon`).toBeDefined()
|
||||
}
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('apes tabs sort after the upstream builtins (order 40+)', () => {
|
||||
const { service, dispose } = setup()
|
||||
try {
|
||||
const orders = {
|
||||
agents: service.getTab('apes:agents')?.order,
|
||||
apps: service.getTab('apes:apps')?.order,
|
||||
profile: service.getTab('apes:profile')?.order,
|
||||
}
|
||||
// C8: order 40-50 places apes tabs immediately after the native builtins
|
||||
// (10-35), not in a far-away 900+ band — visual unity in the tab strip.
|
||||
expect(orders.agents).toBeGreaterThanOrEqual(40)
|
||||
expect(orders.apps).toBeGreaterThanOrEqual(40)
|
||||
expect(orders.profile).toBeGreaterThanOrEqual(40)
|
||||
// Strictly ordered: agents < apps < profile.
|
||||
expect(orders.agents!).toBeLessThan(orders.apps!)
|
||||
expect(orders.apps!).toBeLessThan(orders.profile!)
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('the disposer unregisters all apes tabs (HMR-safe)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const dispose = registerApesTabs({} as Context, service)
|
||||
expect(service.getTabs()).toHaveLength(3)
|
||||
dispose()
|
||||
expect(service.getTabs()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('apes i18n contract', () => {
|
||||
it('every apesZh key has an apesEn entry (no missing translations)', () => {
|
||||
const zhKeys = Object.keys(apesZh) as ApesCopyKey[]
|
||||
for (const key of zhKeys) {
|
||||
expect(apesEn[key], `en entry for ${key}`).toBeDefined()
|
||||
expect(typeof apesEn[key]).toBe('string')
|
||||
expect((apesEn[key] ?? '').length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves zh when the DSH preference is zh', () => {
|
||||
attachApesLocale({ getSnapshot: () => ({ active: 'zh-CN' }) })
|
||||
expect(t('apesAgentsTitle')).toBe('智能体')
|
||||
expect(t('apesAppsTitle')).toBe('应用')
|
||||
expect(t('apesProfileTitle')).toBe('画像')
|
||||
})
|
||||
|
||||
it('resolves en when the DSH preference is en', () => {
|
||||
attachApesLocale({ getSnapshot: () => ({ active: 'en' }) })
|
||||
expect(t('apesAgentsTitle')).toBe('Agents')
|
||||
expect(t('apesAppsTitle')).toBe('Apps')
|
||||
expect(t('apesProfileTitle')).toBe('Profile')
|
||||
})
|
||||
|
||||
it('falls back to zh when no locale service is attached', () => {
|
||||
attachApesLocale(undefined)
|
||||
expect(t('apesAgentsTitle')).toBe('智能体')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* HTML preview vocabulary tests: the srcdoc delivery path needs the previewed
|
||||
* document's relative references to resolve back into its own
|
||||
* `/sidebar/html/…` route (a srcdoc document has no URL of its own — the
|
||||
* browser would otherwise resolve `./style.css` against the GUI page).
|
||||
*
|
||||
* Pure module, plain node environment.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { previewBaseOf, withPreviewBase } from '../src/client/html-preview.ts'
|
||||
|
||||
const URL_ = '/sidebar/html/s1/p/a/index.html'
|
||||
|
||||
describe('previewBaseOf', () => {
|
||||
it('keeps everything up to the last slash', () => {
|
||||
expect(previewBaseOf(URL_)).toBe('/sidebar/html/s1/p/a/')
|
||||
})
|
||||
|
||||
it('keeps the UNC double slash intact (the route encodes it that way)', () => {
|
||||
expect(previewBaseOf('/sidebar/html/s1//server/share/a.html')).toBe('/sidebar/html/s1//server/share/')
|
||||
})
|
||||
|
||||
it('returns the input unchanged when it has no slash', () => {
|
||||
expect(previewBaseOf('s1')).toBe('s1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('withPreviewBase', () => {
|
||||
it('injects the base as the first child of <head>, ahead of the document’s own elements', () => {
|
||||
const out = withPreviewBase('<html><head><title>t</title></head><body></body></html>', URL_)
|
||||
expect(out).toBe('<html><head><base href="/sidebar/html/s1/p/a/"><title>t</title></head><body></body></html>')
|
||||
})
|
||||
|
||||
it('falls back to <html> when the document declares no head', () => {
|
||||
const out = withPreviewBase('<html><body>x</body></html>', URL_)
|
||||
expect(out).toBe('<html><base href="/sidebar/html/s1/p/a/"><body>x</body></html>')
|
||||
})
|
||||
|
||||
it('prepends for a headless fragment', () => {
|
||||
expect(withPreviewBase('<h1>x</h1>', URL_)).toBe('<base href="/sidebar/html/s1/p/a/"><h1>x</h1>')
|
||||
})
|
||||
|
||||
it('leaves a document that declares its own base alone (the author owns it)', () => {
|
||||
const doc = '<head><base href="https://example.com/"></head>'
|
||||
expect(withPreviewBase(doc, URL_)).toBe(doc)
|
||||
})
|
||||
|
||||
it('quotes the injected href', () => {
|
||||
expect(withPreviewBase('<head></head>', '/x/"y/')).toBe('<head><base href="/x/"y/"></head>')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Panel-host parenting tests — the layering half of a real defect: on DSH
|
||||
* Desktop's framed modes the shell tags the upstream `#root` with
|
||||
* `data-dsh-desktop-content-viewport` and gives it `isolation: isolate`, a
|
||||
* stacking context around the whole app INCLUDING the shell's overlay layer
|
||||
* (`.dshDesktopOverlay`, z-index 1000 — where a plugin's modal renders, e.g.
|
||||
* the community market's). A host parented to `document.body` sits outside
|
||||
* that context, so the panel layer's documented 25 outranked every overlay
|
||||
* inside it and covered host modals.
|
||||
*
|
||||
* jsdom: the resolution is a DOM query, so the stub-globals spec style used
|
||||
* by desktop-env.spec.ts cannot exercise it.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { panelHostParent } from '../src/client/desktop-env.ts'
|
||||
|
||||
beforeEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
describe('panelHostParent', () => {
|
||||
it('falls back to the body when the shell framed no content viewport', () => {
|
||||
document.body.innerHTML = '<div id="root"></div>'
|
||||
expect(panelHostParent(document)).toBe(document.body)
|
||||
})
|
||||
|
||||
it('falls back to the body when there is no root at all (plain web)', () => {
|
||||
expect(panelHostParent(document)).toBe(document.body)
|
||||
})
|
||||
|
||||
it('resolves the framed content viewport when the shell stamped one', () => {
|
||||
document.body.innerHTML = '<div id="root" data-dsh-desktop-content-viewport=""><div class="dshDesktopFrame"></div></div>'
|
||||
const root = document.getElementById('root')
|
||||
expect(panelHostParent(document)).toBe(root)
|
||||
// The host lands INSIDE the shell's stacking context — beside the overlay
|
||||
// layer the shell renders there, and never as a body-level sibling.
|
||||
const host = document.createElement('div')
|
||||
panelHostParent(document).appendChild(host)
|
||||
expect(root?.contains(host)).toBe(true)
|
||||
expect(document.body.lastElementChild).toBe(root)
|
||||
})
|
||||
})
|
||||
@@ -50,9 +50,13 @@ describe('HTML preview iframe sandbox', () => {
|
||||
// ...which must never contain the dangerous tokens.
|
||||
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-same-origin')
|
||||
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-top-navigation')
|
||||
// Cross-origin framing by construction: route-src (never srcdoc).
|
||||
expect(iframe).toContain('src="/sidebar/html/s1/p/a/index.html"')
|
||||
expect(iframe).not.toContain('srcdoc=')
|
||||
// Opaque-origin framing by construction: the document arrives through
|
||||
// srcdoc (fetched by the MAIN frame, the only one the host trusts with
|
||||
// the /sidebar route on DSH Desktop) and the frame is never pointed at
|
||||
// the route itself. Empty until that fetch resolves. (The server
|
||||
// renderer passes srcDoc through camelCase, like referrerPolicy below.)
|
||||
expect(iframe).toContain('srcDoc=""')
|
||||
expect(iframe).not.toContain('src=')
|
||||
// Referrer + permissions policy stay locked even when sandboxed.
|
||||
// (React SSR renders the referrerPolicy prop camelCase as written.)
|
||||
expect(iframe).toContain('referrerPolicy="no-referrer"')
|
||||
@@ -126,7 +130,7 @@ describe('changes tab HTML render preview sandbox', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it('render iframe is ALWAYS sandboxed (no escape hatch) with the route src, never srcdoc', () => {
|
||||
it('render iframe is ALWAYS sandboxed (no escape hatch) and delivered through srcdoc', () => {
|
||||
const html = renderToString(createElement(HtmlRenderPreview, { src: '/sidebar/html/s1/p/a/index.html', title: '/p/a/index.html' }))
|
||||
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
|
||||
expect(iframe).toBeDefined()
|
||||
@@ -135,9 +139,10 @@ describe('changes tab HTML render preview sandbox', () => {
|
||||
expect(iframe).toContain(`sandbox="${HTML_IFRAME_SANDBOX}"`)
|
||||
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-same-origin')
|
||||
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-top-navigation')
|
||||
// Cross-origin framing by construction: route-src (never srcdoc).
|
||||
expect(iframe).toContain('src="/sidebar/html/s1/p/a/index.html"')
|
||||
expect(iframe).not.toContain('srcdoc=')
|
||||
// Same delivery path as the editor viewer: main-frame fetch -> srcdoc,
|
||||
// never a route src the opaque frame could not fetch itself.
|
||||
expect(iframe).toContain('srcDoc=""')
|
||||
expect(iframe).not.toContain('src=')
|
||||
expect(iframe).toContain('referrerPolicy="no-referrer"')
|
||||
expect(iframe).toContain('allow=""')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user