feat(sidebar): 桌面兼容三方案——自动检测(WCO 标准几何)/壳预设/自定义 CSS (#284)
* feat(sidebar): 桌面兼容三方案——自动检测(WCO 标准几何)/壳预设/自定义 CSS 核心只保留 Web 标准机制,壳专属适配全部下沉到用户空间(KISS): - prefs 新增 titleBarScheme(默认 auto)/titleBarPresetId/customCss,旧 titleBarCompat 布尔自动迁移(true→custom 保留 stripPx,false/缺失→auto) - wco.ts:navigator.windowControlsOverlay 反应式几何(geometrychange 实时更新; visible=false 的幽灵 API 视为缺失),实现 #257 - titlebar-strip.ts 唯一 strip 取值链:WCO → dsh-desktop-titlebar-inset 契约参数 → 预设 stripFor → 手动 px → 0;desktop-env.ts 移除 win32 32px 硬编码 - shell-presets.ts 数据驱动预设(准入:issue/PR 提及 + 100+ star;v1 仅 anywhere-labs DSH Desktop:darwin 20px / win32 无 WCO 时 32px),opt-in 手动 - 预设/自定义 CSS 注入(data-dsh-preset-css / data-dsh-custom-css 标签), chrome 补稳定寻址面 data-dsh-toggle-cluster / data-dsh-panel - 交互 chrome 统一 -webkit-app-region: no-drag(吸收 #111/#153) - layout.css 底栏推挤复合锚点([data-pane] 与 :has(> [data-slot]) 双保险, rc.8 实测前者失效、后者命中,吸收 #226 思路) - 设置 UI:位置兼容模式齿轮弹窗改三方案(自动检测/壳兼容方案/自定义方案) - 测试:wco/titlebar-strip/shell-presets 单测 + prefs 迁移 + e2e 5 场景 (保守 auto/WCO 反应式/预设/自定义 CSS/复合锚点),挂载门禁 12/12 全绿 核心 grep 无壳专属类名;为某壳做的兼容在另一壳会再坏——预设与自定义 CSS 是唯一出路(AGENTS.md §8 契约已更新)。 * refine(sidebar): 位置兼容模式改主行下拉四选项——自动检测/DSH官方Web/壳预设/自定义 按用户反馈定稿 UI: - 主行下拉:自动检测(默认)/ DSH官方Web(显式零适配,连 WCO 也强制 0)/ 各壳预设(命中环境带「已检测」后缀,仅提示)/ 自定义方案(保留齿轮弹窗: 下移距离 + 自定义 CSS) - 旧文档迁移:titleBarCompat=true 或 titleBarStripPx 非默认值(此前已有配置) → 默认落到 custom 方案保留用户数值;干净文档 → auto - prefs 新增 'web' 方案;strip 取值链最优先处理 web→0 - 测试:prefs 迁移(stripPx 48→custom / 40→auto)、web 强制 0、下拉渲染断言 - 单测 693 通过,挂载 e2e 12/12 全绿 * refine(sidebar): 方案下拉复用 SelectMenu 组件,不用原生 <select> - 抽出可复用 SelectMenu(primitives Menu:锚点按钮 + 下拉选项卡,支持 icon/desc/多选),SelectRow 改为其薄壳 - 位置兼容模式方案下拉改用 SelectMenu;选项带描述(自动检测/DSH官方Web/ 各壳预设(命中带「已检测」后缀)/自定义方案) - 移除原生 select 样式;单测 693 通过,挂载 e2e 12/12 全绿
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* Desktop-shell detection tests: URL stamps (dsh-desktop-mode/platform)
|
||||
* from the official Electron shell, the preload marker
|
||||
* (__DSH_DESKTOP_FILE_PATH__), and the win32 advanced overlay adaptation.
|
||||
* (__DSH_DESKTOP_FILE_PATH__), and the `dsh-desktop-titlebar-inset`
|
||||
* contract parameter. Geometry ADAPTATION is deliberately NOT here — the
|
||||
* strip resolution chain lives in titlebar-strip.ts (standard WCO first,
|
||||
* see wco.spec.ts), keeping this module a pure environment reporter.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import './browser-globals.ts'
|
||||
@@ -18,25 +21,25 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('parseDesktopEnv', () => {
|
||||
it('reports a plain browser page as non-desktop', () => {
|
||||
expect(parseDesktopEnv()).toEqual({ desktop: false, mode: null, platform: null, win32OverlayTop: 0 })
|
||||
it('reports a plain browser page as non-desktop with no inset', () => {
|
||||
expect(parseDesktopEnv()).toEqual({ desktop: false, mode: null, platform: null, titlebarInset: 0 })
|
||||
})
|
||||
|
||||
it('parses win32 advanced stamps with the 32px window-control overlay', () => {
|
||||
it('parses win32 advanced stamps (no overlay guess — geometry comes from WCO/preset)', () => {
|
||||
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-platform=win32')
|
||||
const env = parseDesktopEnv()
|
||||
expect(env.desktop).toBe(true)
|
||||
expect(env.mode).toBe('advanced')
|
||||
expect(env.platform).toBe('win32')
|
||||
expect(env.win32OverlayTop).toBe(32)
|
||||
expect(env.titlebarInset).toBe(0)
|
||||
})
|
||||
|
||||
it('parses darwin advanced stamps without an overlay (traffic lights are top-left)', () => {
|
||||
it('parses darwin advanced stamps', () => {
|
||||
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-platform=darwin')
|
||||
const env = parseDesktopEnv()
|
||||
expect(env.desktop).toBe(true)
|
||||
expect(env.platform).toBe('darwin')
|
||||
expect(env.win32OverlayTop).toBe(0)
|
||||
expect(env.titlebarInset).toBe(0)
|
||||
})
|
||||
|
||||
it('parses compatibility mode as desktop with the native frame (no adaptation)', () => {
|
||||
@@ -44,7 +47,7 @@ describe('parseDesktopEnv', () => {
|
||||
const env = parseDesktopEnv()
|
||||
expect(env.desktop).toBe(true)
|
||||
expect(env.mode).toBe('compatibility')
|
||||
expect(env.win32OverlayTop).toBe(0)
|
||||
expect(env.titlebarInset).toBe(0)
|
||||
})
|
||||
|
||||
it('detects the desktop preload marker even without URL stamps', () => {
|
||||
@@ -52,7 +55,7 @@ describe('parseDesktopEnv', () => {
|
||||
const env = parseDesktopEnv()
|
||||
expect(env.desktop).toBe(true)
|
||||
expect(env.mode).toBeNull()
|
||||
expect(env.win32OverlayTop).toBe(0)
|
||||
expect(env.titlebarInset).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores unknown mode values (exotic shells keep plain-browser semantics)', () => {
|
||||
@@ -61,6 +64,20 @@ describe('parseDesktopEnv', () => {
|
||||
expect(parseDesktopEnv().desktop).toBe(false)
|
||||
})
|
||||
|
||||
it('reads the documented titlebar-inset contract parameter (clamped 0–120)', () => {
|
||||
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-platform=win32&dsh-desktop-titlebar-inset=36')
|
||||
expect(parseDesktopEnv().titlebarInset).toBe(36)
|
||||
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-titlebar-inset=200')
|
||||
resetDesktopEnvForTests()
|
||||
expect(parseDesktopEnv().titlebarInset).toBe(120)
|
||||
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-titlebar-inset=-5')
|
||||
resetDesktopEnvForTests()
|
||||
expect(parseDesktopEnv().titlebarInset).toBe(0)
|
||||
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-titlebar-inset=abc')
|
||||
resetDesktopEnvForTests()
|
||||
expect(parseDesktopEnv().titlebarInset).toBe(0)
|
||||
})
|
||||
|
||||
it('memoizes across calls until the test hook resets', () => {
|
||||
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-platform=win32')
|
||||
const first = parseDesktopEnv()
|
||||
|
||||
@@ -702,3 +702,34 @@ test('bottom panel never flashes full-width after a width drag release (issue #2
|
||||
).toBeLessThanOrEqual(8)
|
||||
}
|
||||
})
|
||||
|
||||
test('the bottom-push anchor resolves through the composite selectors (at least one; same element when both)', async ({ page }) => {
|
||||
// layout.css pushes the bottom panel via the center column. The selector
|
||||
// is COMPOSITE on purpose: `[data-pane="conversation"]` (0.1.x naming)
|
||||
// and `:has(> [data-slot="conversation"])` (rc.8-era naming) — HOST
|
||||
// VERSIONS MAY RENAME THE ATTRIBUTE (issue #208 comment / PR #226), so
|
||||
// the contract is "at least one resolves", and when both resolve they
|
||||
// must hit the SAME element (otherwise the push would land twice).
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
|
||||
const anchors = await page.evaluate(() => {
|
||||
const a = document.querySelector('#root [data-dsh-frame] > [data-pane="conversation"]')
|
||||
const b = document.querySelector('#root :has(> [data-slot="conversation"])')
|
||||
const frame = document.querySelector('#root [data-dsh-frame]')
|
||||
return {
|
||||
a: a !== null,
|
||||
b: b !== null,
|
||||
same: a !== null && b !== null && a === b,
|
||||
frameChildren: frame !== null
|
||||
? [...frame.children].map(el => `${el.tagName}[${[...el.attributes].map(attr => attr.name).filter(name => name.startsWith('data-')).join(',')}]`)
|
||||
: [],
|
||||
}
|
||||
})
|
||||
expect(
|
||||
anchors.a || anchors.b,
|
||||
`at least one bottom-push anchor must resolve on this host (frame children: ${anchors.frameChildren.join(' / ') || 'no [data-dsh-frame]'})`,
|
||||
).toBe(true)
|
||||
if (anchors.a && anchors.b) {
|
||||
expect(anchors.same, 'both selectors must hit the SAME center-column element').toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
+111
-9
@@ -341,20 +341,122 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
|
||||
await page.screenshot({ path: 'test-results/mount-final.png' })
|
||||
})
|
||||
|
||||
test('desktop shell stamps auto-enable the win32 title-bar compatibility mode', async ({ page }) => {
|
||||
test('conservative auto: URL stamps alone never modify the layout; plugin chrome carries the stable data attributes', async ({ page }) => {
|
||||
// The official DSH Desktop shell stamps every render URL with
|
||||
// dsh-desktop-mode / dsh-desktop-platform (and exposes a preload marker).
|
||||
// The win32 advanced shell reserves a 32px overlay for the window
|
||||
// controls where the toggle cluster sits — the sidebar must auto-drop
|
||||
// below it (body[data-dsh-title-bar-compat]) without any manual pref.
|
||||
// dsh-desktop-mode / dsh-desktop-platform. Under the conservative AUTO
|
||||
// scheme, shell stamps are REPORTS, not geometry: without the standard
|
||||
// Window Controls Overlay API the layout must stay untouched (plain-web
|
||||
// semantics) — the strip/body attribute appear only for real standard
|
||||
// geometry (see the WCO scenario below) or an opt-in preset.
|
||||
await page.goto(`${BASE_URL}?dsh-desktop-mode=advanced&dsh-desktop-platform=win32`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
|
||||
await expect(
|
||||
page.locator('body[data-dsh-title-bar-compat]'),
|
||||
'win32 advanced shell must auto-enable title-bar compatibility',
|
||||
).toBeAttached({ timeout: 90_000 })
|
||||
// The strip variable must be sized to the shell's 32px overlay.
|
||||
'stamps alone must NOT auto-enable title-bar compatibility under auto',
|
||||
).toHaveCount(0)
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-title-bar-strip')))
|
||||
.toBe('32px')
|
||||
.toBe('')
|
||||
// The stable addressing surface for presets / custom CSS is mounted.
|
||||
await expect(page.locator('[data-dsh-toggle-cluster]')).toBeAttached()
|
||||
await expect(page.locator('[data-dsh-panel]').first()).toBeAttached()
|
||||
// The plugin's interactive chrome opts out of Electron drag regions
|
||||
// (issues #103/#111) — inert in plain browsers, present in the bundle
|
||||
// (the bundler minifies the property's whitespace, so match loosely).
|
||||
const hasNoDragRule = await page.evaluate(() => {
|
||||
for (const tag of document.querySelectorAll('style')) {
|
||||
if (tag.textContent !== null && /-webkit-app-region:\s*no-drag/.test(tag.textContent)) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
expect(hasNoDragRule, 'the bundle must ship the drag-region opt-out rule').toBe(true)
|
||||
})
|
||||
|
||||
test('standard WCO geometry drives the strip reactively (issue #257)', async ({ page }) => {
|
||||
// The Window Controls Overlay API is the STANDARD signal for shells that
|
||||
// draw the native caption buttons over web content (Electron
|
||||
// `titleBarOverlay`). Mock it with the real API shape: the strip must
|
||||
// follow the reported rect and react to geometrychange (maximize/restore).
|
||||
await page.addInitScript(() => {
|
||||
const rect = { x: 0, y: 0, width: 138, height: 36 }
|
||||
const listeners = new Set<() => void>()
|
||||
Object.defineProperty(navigator, 'windowControlsOverlay', {
|
||||
configurable: true,
|
||||
value: {
|
||||
visible: true,
|
||||
getTitlebarAreaRect: () => ({ ...rect }),
|
||||
addEventListener: (type: string, listener: () => void) => { if (type === 'geometrychange') listeners.add(listener) },
|
||||
removeEventListener: (type: string, listener: () => void) => { if (type === 'geometrychange') listeners.delete(listener) },
|
||||
},
|
||||
})
|
||||
;(globalThis as { __wcoMock?: { setHeight: (height: number) => void } }).__wcoMock = {
|
||||
setHeight: (height: number) => {
|
||||
rect.height = height
|
||||
for (const listener of listeners) listener()
|
||||
},
|
||||
}
|
||||
})
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
|
||||
// Real reported height (36px, not a hardcoded 32) drives the strip.
|
||||
await expect(page.locator('body[data-dsh-title-bar-compat]')).toBeAttached({ timeout: 90_000 })
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-title-bar-strip')))
|
||||
.toBe('36px')
|
||||
// Maximize → the overlay reports a zero rect → the strip is removed.
|
||||
await page.evaluate(() => (globalThis as { __wcoMock?: { setHeight: (height: number) => void } }).__wcoMock?.setHeight(0))
|
||||
await expect(page.locator('body[data-dsh-title-bar-compat]')).toHaveCount(0)
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-title-bar-strip')))
|
||||
.toBe('')
|
||||
// Restore → the strip comes back.
|
||||
await page.evaluate(() => (globalThis as { __wcoMock?: { setHeight: (height: number) => void } }).__wcoMock?.setHeight(36))
|
||||
await expect(page.locator('body[data-dsh-title-bar-compat]')).toBeAttached()
|
||||
})
|
||||
|
||||
test('opt-in shell preset applies its strip when WCO is absent (data-driven, manual)', async ({ request, page }) => {
|
||||
// The anywhere-labs DSH Desktop preset (shell-presets.ts) is OPT-IN: under
|
||||
// the preset scheme the win32 advanced stamp resolves to its 32px fallback
|
||||
// even without the WCO API; auto never does this.
|
||||
const update = await request.post(`${BASE_URL}/sidebar/api/settings.update`, {
|
||||
data: { patch: { titleBarScheme: 'preset', titleBarPresetId: 'dsh-desktop', titleBarCompat: true } },
|
||||
})
|
||||
expect(update.ok(), `settings.update: ${update.status()}`).toBe(true)
|
||||
try {
|
||||
await page.goto(`${BASE_URL}?dsh-desktop-mode=advanced&dsh-desktop-platform=win32`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
|
||||
await expect(page.locator('body[data-dsh-title-bar-compat]')).toBeAttached({ timeout: 90_000 })
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-title-bar-strip')))
|
||||
.toBe('32px')
|
||||
// The v1 anywhere-labs preset is PURE STRIP DATA (no extra CSS — the
|
||||
// injection mechanism is exercised by the custom-scheme test). Assert
|
||||
// the absence explicitly: a future preset that ADDS css would trip
|
||||
// here instead of silently shipping unstyled.
|
||||
await expect(page.locator('style[data-dsh-preset-css]')).toHaveCount(0)
|
||||
} finally {
|
||||
// Restore the shared server state for the lanes after this one.
|
||||
await request.post(`${BASE_URL}/sidebar/api/settings.update`, {
|
||||
data: { patch: { titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false } },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('custom scheme injects the user stylesheet live', async ({ request, page }) => {
|
||||
const update = await request.post(`${BASE_URL}/sidebar/api/settings.update`, {
|
||||
data: { patch: { titleBarScheme: 'custom', customCss: 'html { --dsh-e2e-marker: 1; }', titleBarCompat: true } },
|
||||
})
|
||||
expect(update.ok(), `settings.update: ${update.status()}`).toBe(true)
|
||||
try {
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
|
||||
await expect(page.locator('style[data-dsh-custom-css="custom"]')).toBeAttached()
|
||||
// The injected CSS is live (a custom property the page can read back).
|
||||
const marker = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--dsh-e2e-marker').trim())
|
||||
expect(marker).toBe('1')
|
||||
} finally {
|
||||
await request.post(`${BASE_URL}/sidebar/api/settings.update`, {
|
||||
data: { patch: { titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false } },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -69,8 +69,15 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
// and 13px.
|
||||
expect(resolved.terminalFontFamily).toBe('')
|
||||
expect(resolved.terminalFontSize).toBe(13)
|
||||
// The position-compat mode defaults OFF (the normal layout is default),
|
||||
// with the strip defaulting to 40px.
|
||||
// The position-compat scheme is declared WITHOUT a schema default so a
|
||||
// stored document that predates it resolves without the field — the
|
||||
// CLIENT parsePrefs then applies the conservative `auto` default (or
|
||||
// migrates the legacy boolean), which is exactly what makes old
|
||||
// documents migrate instead of silently flipping to a scheme. The
|
||||
// legacy strip keeps its schema default of 40px.
|
||||
expect(resolved.titleBarScheme).toBeUndefined()
|
||||
expect(resolved.titleBarPresetId).toBeUndefined()
|
||||
expect(resolved.customCss).toBeUndefined()
|
||||
expect(resolved.titleBarCompat).toBe(false)
|
||||
expect(resolved.titleBarStripPx).toBe(40)
|
||||
// The enable-switch maps resolve to {} (everything on) for old documents.
|
||||
|
||||
+50
-13
@@ -45,6 +45,9 @@ describe('side card preferences', () => {
|
||||
editorExplorer: false,
|
||||
terminalShell: '',
|
||||
terminalShellArgs: '',
|
||||
titleBarScheme: 'auto',
|
||||
titleBarPresetId: '',
|
||||
customCss: '',
|
||||
titleBarCompat: false,
|
||||
titleBarStripPx: 40,
|
||||
htmlViewerNoSandbox: false,
|
||||
@@ -74,6 +77,9 @@ describe('side card preferences', () => {
|
||||
editorExplorer: false,
|
||||
terminalShell: '',
|
||||
terminalShellArgs: '',
|
||||
titleBarScheme: 'auto',
|
||||
titleBarPresetId: '',
|
||||
customCss: '',
|
||||
titleBarCompat: false,
|
||||
titleBarStripPx: 40,
|
||||
htmlViewerNoSandbox: false,
|
||||
@@ -103,6 +109,9 @@ describe('side card preferences', () => {
|
||||
editorExplorer: false,
|
||||
terminalShell: '',
|
||||
terminalShellArgs: '',
|
||||
titleBarScheme: 'auto',
|
||||
titleBarPresetId: '',
|
||||
customCss: '',
|
||||
titleBarCompat: false,
|
||||
titleBarStripPx: 40,
|
||||
htmlViewerNoSandbox: false,
|
||||
@@ -151,14 +160,42 @@ describe('side card preferences', () => {
|
||||
expect((await loadPrefs(wire({ editorExplorer: true }))).editorExplorer).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults titleBarCompat to false; only an explicit true turns the position-compat mode on', async () => {
|
||||
// Absent or malformed → off (the normal layout is the default).
|
||||
expect((await loadPrefs(wire({}))).titleBarCompat).toBe(false)
|
||||
expect((await loadPrefs(wire({ titleBarCompat: 'yes' }))).titleBarCompat).toBe(false)
|
||||
expect((await loadPrefs(wire({ titleBarCompat: 1 }))).titleBarCompat).toBe(false)
|
||||
// Explicit booleans survive verbatim.
|
||||
expect((await loadPrefs(wire({ titleBarCompat: false }))).titleBarCompat).toBe(false)
|
||||
expect((await loadPrefs(wire({ titleBarCompat: true }))).titleBarCompat).toBe(true)
|
||||
it('defaults the title-bar scheme to the conservative auto with no preset or custom CSS', async () => {
|
||||
// Absent or malformed → auto (plain web keeps the untouched layout).
|
||||
expect((await loadPrefs(wire({}))).titleBarScheme).toBe('auto')
|
||||
expect((await loadPrefs(wire({ titleBarScheme: 'weird' }))).titleBarScheme).toBe('auto')
|
||||
expect((await loadPrefs(wire({ titleBarScheme: 1 }))).titleBarScheme).toBe('auto')
|
||||
expect((await loadPrefs(wire({}))).titleBarPresetId).toBe('')
|
||||
expect((await loadPrefs(wire({ titleBarPresetId: 5 }))).titleBarPresetId).toBe('')
|
||||
expect((await loadPrefs(wire({}))).customCss).toBe('')
|
||||
expect((await loadPrefs(wire({ customCss: 7 }))).customCss).toBe('')
|
||||
// Valid values survive verbatim (including the explicit web scheme).
|
||||
const picked = await loadPrefs(wire({ titleBarScheme: 'preset', titleBarPresetId: 'dsh-desktop', customCss: 'html { }' }))
|
||||
expect(picked.titleBarScheme).toBe('preset')
|
||||
expect(picked.titleBarPresetId).toBe('dsh-desktop')
|
||||
expect(picked.customCss).toBe('html { }')
|
||||
expect((await loadPrefs(wire({ titleBarScheme: 'web' }))).titleBarScheme).toBe('web')
|
||||
})
|
||||
|
||||
it('migrates LEGACY documents that ALREADY HAVE VALUES into the custom scheme', async () => {
|
||||
// A pre-scheme document with the manual compat flag on maps to the
|
||||
// custom scheme, keeping the strip px the user chose.
|
||||
const migrated = await loadPrefs(wire({ titleBarCompat: true, titleBarStripPx: 56 }))
|
||||
expect(migrated.titleBarScheme).toBe('custom')
|
||||
expect(migrated.titleBarStripPx).toBe(56)
|
||||
// A non-default strip px alone (only reachable through the old gear
|
||||
// popup) also counts as "already has values" → custom.
|
||||
const stripOnly = await loadPrefs(wire({ titleBarStripPx: 48 }))
|
||||
expect(stripOnly.titleBarScheme).toBe('custom')
|
||||
expect(stripOnly.titleBarStripPx).toBe(48)
|
||||
// A stored scheme always wins over the legacy fields (round-trip of the
|
||||
// mirrored write: preset stays preset even though the mirror is true).
|
||||
const roundTrip = await loadPrefs(wire({ titleBarScheme: 'preset', titleBarCompat: true }))
|
||||
expect(roundTrip.titleBarScheme).toBe('preset')
|
||||
// Legacy off / absent / default strip → the conservative auto scheme.
|
||||
expect((await loadPrefs(wire({ titleBarCompat: false }))).titleBarScheme).toBe('auto')
|
||||
expect((await loadPrefs(wire({ titleBarStripPx: 40 }))).titleBarScheme).toBe('auto')
|
||||
expect((await loadPrefs(wire({}))).titleBarScheme).toBe('auto')
|
||||
})
|
||||
|
||||
it('defaults titleBarStripPx to 40 and clamps stored values into the contract range', async () => {
|
||||
@@ -223,9 +260,9 @@ describe('side card preferences', () => {
|
||||
const store = createSidebarStore()
|
||||
// Node environment: no window → the width falls back to PANEL_DEFAULT,
|
||||
// while the open flag still follows the preference.
|
||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setSession('fresh-session')
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.sessionId).toBe('fresh-session')
|
||||
expect(snapshot.state?.panelOpen).toBe(false)
|
||||
@@ -261,7 +298,7 @@ describe('side card preferences', () => {
|
||||
|
||||
it('skips the default seed tab when the editor (files window) type is disabled', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setSession('no-editor')
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
|
||||
@@ -271,7 +308,7 @@ describe('side card preferences', () => {
|
||||
// editorExplorer modes.
|
||||
for (const editorExplorer of [true, false]) {
|
||||
const openStore = createSidebarStore()
|
||||
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
openStore.setSession(`with-editor-${editorExplorer}`)
|
||||
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
expect(openTabs.map(tab => tab.type)).toEqual(['editor'])
|
||||
@@ -281,7 +318,7 @@ describe('side card preferences', () => {
|
||||
it('seeds the empty editor home tab (files window) in both editorExplorer modes', () => {
|
||||
for (const editorExplorer of [true, false]) {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setSession(`fresh-${editorExplorer}`)
|
||||
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
expect(tabs).toHaveLength(1)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Built-in shell-preset data tests: registry integrity (unique ids, non-empty
|
||||
* titles, pure strip/detect functions) and the anywhere-labs DSH Desktop
|
||||
* entry's strip values (darwin advanced 20px, win32 advanced 32px as the
|
||||
* no-WCO fallback, compatibility/plain browser nothing).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getShellPreset, getShellPresets, presetStripFor } from '../src/client/shell-presets.ts'
|
||||
import type { DesktopEnv } from '../src/client/desktop-env.ts'
|
||||
|
||||
const env = (partial: Partial<DesktopEnv>): DesktopEnv => ({
|
||||
desktop: false,
|
||||
mode: null,
|
||||
platform: null,
|
||||
titlebarInset: 0,
|
||||
...partial,
|
||||
})
|
||||
|
||||
describe('shell presets', () => {
|
||||
it('keeps the registry well-formed (unique ids, titles, pure strip functions)', () => {
|
||||
const presets = getShellPresets()
|
||||
expect(presets.length).toBeGreaterThan(0)
|
||||
const ids = new Set<string>()
|
||||
for (const preset of presets) {
|
||||
expect(ids.has(preset.id)).toBe(false)
|
||||
ids.add(preset.id)
|
||||
expect(preset.title.length).toBeGreaterThan(0)
|
||||
expect(preset.desc.length).toBeGreaterThan(0)
|
||||
// The strip function must be total — an unknown environment yields
|
||||
// undefined, never a throw.
|
||||
expect(() => presetStripFor(preset, env({ desktop: false, mode: null, platform: null }))).not.toThrow()
|
||||
expect(() => preset.detect?.(env({ desktop: false, mode: null, platform: null }))).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('looks up presets by id; unknown/empty ids resolve to undefined', () => {
|
||||
expect(getShellPreset('dsh-desktop')).toBeDefined()
|
||||
expect(getShellPreset('')).toBeUndefined()
|
||||
expect(getShellPreset('no-such-shell')).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('dsh-desktop (DeepSeek Harness Desktop, anywhere-labs)', () => {
|
||||
const preset = getShellPreset('dsh-desktop')!
|
||||
|
||||
it('reserves 20px on darwin advanced (the caption row above the surfaces)', () => {
|
||||
expect(presetStripFor(preset, env({ desktop: true, mode: 'advanced', platform: 'darwin' }))).toBe(20)
|
||||
})
|
||||
|
||||
it('reserves 32px on win32 advanced as the no-WCO fallback', () => {
|
||||
expect(presetStripFor(preset, env({ desktop: true, mode: 'advanced', platform: 'win32' }))).toBe(32)
|
||||
})
|
||||
|
||||
it('reserves nothing in compatibility mode or on unknown platforms', () => {
|
||||
expect(presetStripFor(preset, env({ desktop: true, mode: 'compatibility', platform: 'win32' }))).toBeUndefined()
|
||||
expect(presetStripFor(preset, env({ desktop: true, mode: 'advanced', platform: 'linux' }))).toBeUndefined()
|
||||
expect(presetStripFor(preset, env({ desktop: false, mode: null, platform: null }))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('detects advanced shells only as a SUGGESTION signal (never auto-applies)', () => {
|
||||
expect(preset.detect?.(env({ desktop: true, mode: 'advanced', platform: 'darwin' }))).toBe(true)
|
||||
expect(preset.detect?.(env({ desktop: true, mode: 'advanced', platform: 'win32' }))).toBe(true)
|
||||
expect(preset.detect?.(env({ desktop: true, mode: 'compatibility', platform: 'win32' }))).toBe(false)
|
||||
expect(preset.detect?.(env({ desktop: false, mode: null, platform: null }))).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -125,8 +125,10 @@ describe('SideCardSection declarative inventory', () => {
|
||||
const html = renderSection(store, service)
|
||||
// Subagents declares a toggle → its card carries the settings gear
|
||||
// (aria-label = "<title> Feature settings"); Explorer and Image declare
|
||||
// none → no gear.
|
||||
// none → no gear. The position-compat row is a DROPDOWN (no gear unless
|
||||
// the custom scheme is active), so the total is 1.
|
||||
expect(html.match(/aria-label="[^"]*Feature settings"/g)?.length).toBe(1)
|
||||
expect(html).toContain('Subagents Feature settings')
|
||||
})
|
||||
|
||||
it('renders the two dashed "add plugin" cards (tab grid + viewer grid)', () => {
|
||||
@@ -159,28 +161,35 @@ describe('SideCardSection declarative inventory', () => {
|
||||
const { store, service } = mount()
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { subagent: false } })
|
||||
const html = renderSection(store, service)
|
||||
// The disabled Subagents card loses its gear; the position-compat row
|
||||
// is a dropdown (default auto → no gear either).
|
||||
expect(html).not.toContain('Feature settings')
|
||||
})
|
||||
|
||||
it('renders the position-compat mode general row: off by default, checked when the pref is on', () => {
|
||||
it('renders the position-compat mode row as a scheme dropdown: auto default, custom keeps the gear', () => {
|
||||
const { store, service } = mount()
|
||||
let html = renderSection(store, service)
|
||||
// The general row renders its title and description.
|
||||
// The general row renders its title and description; the scheme is the
|
||||
// conservative auto by default and the row is the shared SelectMenu
|
||||
// dropdown (the closed anchor shows the picked option — NOT a native
|
||||
// <select>).
|
||||
expect(html).toContain('Position compatibility mode')
|
||||
expect(html).toContain('Reserve space for the native Windows title bar')
|
||||
// Three general rows now: only interceptOpenPath is checked by default
|
||||
// (openByDefault defaults off, the titleBarCompat row starts UNCHECKED) —
|
||||
// the checked checkbox count is 1 while the total checkbox count is 3.
|
||||
expect(html.match(/type="checkbox"/g)?.length).toBe(3)
|
||||
expect(html).toContain('Pick the title-bar compatibility scheme: auto-detect (default, conservative) / DSH official web / known desktop shells / custom (shift distance + custom CSS)')
|
||||
expect(html).not.toContain('<select')
|
||||
expect(html).toContain('>Auto-detect<')
|
||||
// Two general-row switches remain (openByDefault + interceptOpenPath),
|
||||
// only interceptOpenPath checked by default — the scheme row is a
|
||||
// dropdown, not a switch.
|
||||
expect(html.match(/type="checkbox"/g)?.length).toBe(2)
|
||||
expect(html.match(/checked=""/g)?.length).toBe(1)
|
||||
// The row's gear (customize the shift distance) is dormant while the
|
||||
// mode is off — the feature-card convention.
|
||||
// Auto (default) needs no further settings → no gear.
|
||||
expect(html).not.toContain('Position compatibility mode Feature settings')
|
||||
|
||||
// When the pref is on, the new switch is checked and the gear appears.
|
||||
store.setPrefs({ ...store.getPrefs(), titleBarCompat: true })
|
||||
// The custom scheme keeps its settings button (the px + CSS popup);
|
||||
// the anchor now shows the picked custom option.
|
||||
store.setPrefs({ ...store.getPrefs(), titleBarScheme: 'custom', titleBarCompat: true })
|
||||
html = renderSection(store, service)
|
||||
expect(html.match(/checked=""/g)?.length).toBe(2)
|
||||
expect(html).toContain('>Custom<')
|
||||
expect(html).toContain('aria-label="Position compatibility mode Feature settings"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* The title-bar strip resolution chain: standard WCO geometry first
|
||||
* (authoritative even when 0), then the documented URL inset contract
|
||||
* parameter, then the opt-in preset / custom scheme — and 0 (plain web,
|
||||
* nothing modified) otherwise. No per-shell branch lives in the core.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computeTitleBarStrip } from '../src/client/titlebar-strip.ts'
|
||||
import type { DesktopEnv } from '../src/client/desktop-env.ts'
|
||||
import type { WcoSnapshot } from '../src/client/wco.ts'
|
||||
import type { ShellPreset } from '../src/client/shell-presets.ts'
|
||||
|
||||
const env = (partial: Partial<DesktopEnv>): DesktopEnv => ({
|
||||
desktop: false,
|
||||
mode: null,
|
||||
platform: null,
|
||||
titlebarInset: 0,
|
||||
...partial,
|
||||
})
|
||||
|
||||
const wco = (present: boolean, height = 0): WcoSnapshot => ({ present, height })
|
||||
|
||||
const noPreset: ShellPreset | undefined = undefined
|
||||
|
||||
describe('computeTitleBarStrip', () => {
|
||||
it('keeps plain web untouched: no WCO, no inset, no scheme → 0', () => {
|
||||
expect(computeTitleBarStrip(env({}), wco(false), 'auto', noPreset, 40)).toBe(0)
|
||||
expect(computeTitleBarStrip(env({}), wco(false), 'custom', noPreset, 40)).toBe(40)
|
||||
})
|
||||
|
||||
it('the explicit WEB scheme forces 0 — not even standard WCO geometry applies', () => {
|
||||
const desktop = env({ desktop: true, mode: 'advanced', platform: 'win32' })
|
||||
// The user declared "DSH official web": no adaptation at all, even when
|
||||
// a real overlay exists or a preset is active.
|
||||
expect(computeTitleBarStrip(desktop, wco(true, 36), 'web', noPreset, 40)).toBe(0)
|
||||
expect(computeTitleBarStrip(desktop, wco(false), 'web', noPreset, 40)).toBe(0)
|
||||
expect(computeTitleBarStrip(desktop, wco(false), 'web', {
|
||||
id: 't', title: 't', desc: '', stripFor: () => 20,
|
||||
}, 40)).toBe(0)
|
||||
})
|
||||
|
||||
it('trusts the standard WCO geometry first in every scheme (even 0 — e.g. maximized)', () => {
|
||||
const desktop = env({ desktop: true, mode: 'advanced', platform: 'win32' })
|
||||
expect(computeTitleBarStrip(desktop, wco(true, 36), 'auto', noPreset, 0)).toBe(36)
|
||||
expect(computeTitleBarStrip(desktop, wco(true, 36), 'preset', noPreset, 0)).toBe(36)
|
||||
expect(computeTitleBarStrip(desktop, wco(true, 36), 'custom', noPreset, 40)).toBe(36)
|
||||
// Authoritative zero: the overlay is hidden while maximized — no strip.
|
||||
expect(computeTitleBarStrip(desktop, wco(true, 0), 'auto', noPreset, 40)).toBe(0)
|
||||
})
|
||||
|
||||
it('applies the documented URL inset contract parameter before any scheme', () => {
|
||||
const stamped = env({ desktop: true, mode: 'advanced', platform: 'darwin', titlebarInset: 20 })
|
||||
expect(computeTitleBarStrip(stamped, wco(false), 'auto', noPreset, 0)).toBe(20)
|
||||
expect(computeTitleBarStrip(stamped, wco(false), 'preset', noPreset, 0)).toBe(20)
|
||||
})
|
||||
|
||||
it('uses the opt-in preset strip under the preset scheme only', () => {
|
||||
const preset: ShellPreset = {
|
||||
id: 't', title: 't', desc: '',
|
||||
stripFor: (e) => e.platform === 'darwin' ? 20 : undefined,
|
||||
}
|
||||
const darwin = env({ desktop: true, mode: 'advanced', platform: 'darwin' })
|
||||
expect(computeTitleBarStrip(darwin, wco(false), 'preset', preset, 0)).toBe(20)
|
||||
// Auto never applies the preset — only standard signals.
|
||||
expect(computeTitleBarStrip(darwin, wco(false), 'auto', preset, 0)).toBe(0)
|
||||
// Custom never applies the preset either.
|
||||
expect(computeTitleBarStrip(darwin, wco(false), 'custom', preset, 0)).toBe(0)
|
||||
})
|
||||
|
||||
it('uses the manual strip px under the custom scheme only', () => {
|
||||
expect(computeTitleBarStrip(env({}), wco(false), 'custom', noPreset, 56)).toBe(56)
|
||||
expect(computeTitleBarStrip(env({}), wco(false), 'auto', noPreset, 56)).toBe(0)
|
||||
expect(computeTitleBarStrip(env({}), wco(false), 'preset', noPreset, 56)).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Window Controls Overlay (WCO) geometry store tests: feature detection,
|
||||
* subscribe/getSnapshot, geometrychange re-reads, and the absent-API
|
||||
* fallback (the conservative "auto" signal — plain browsers and non-overlay
|
||||
* shells report NONE and the sidebar adapts nothing).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import './browser-globals.ts'
|
||||
import {
|
||||
getWcoSnapshot, setWcoSourceForTests, subscribeWco, WCO_NONE,
|
||||
type WcoSource,
|
||||
} from '../src/client/wco.ts'
|
||||
|
||||
/** A controllable fake of the standard API surface. */
|
||||
function makeSource(initial: { width: number; height: number; visible?: boolean }): {
|
||||
source: WcoSource
|
||||
setRect: (width: number, height: number) => void
|
||||
fireGeometryChange: () => void
|
||||
listeners: Set<() => void>
|
||||
} {
|
||||
const listeners = new Set<() => void>()
|
||||
let rect = { x: 0, y: 0, width: initial.width, height: initial.height }
|
||||
const source: WcoSource = {
|
||||
visible: initial.visible ?? true,
|
||||
getTitlebarAreaRect: () => rect,
|
||||
addEventListener: (type, listener) => { if (type === 'geometrychange') listeners.add(listener) },
|
||||
removeEventListener: (type, listener) => { if (type === 'geometrychange') listeners.delete(listener) },
|
||||
}
|
||||
return {
|
||||
source,
|
||||
setRect: (width, height) => { rect = { x: 0, y: 0, width, height } },
|
||||
fireGeometryChange: () => { for (const listener of listeners) listener() },
|
||||
listeners,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setWcoSourceForTests(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setWcoSourceForTests(undefined)
|
||||
})
|
||||
|
||||
describe('wco geometry store', () => {
|
||||
it('reports NONE (absent API) in plain browsers / non-overlay shells', () => {
|
||||
expect(getWcoSnapshot()).toBe(WCO_NONE)
|
||||
expect(getWcoSnapshot().present).toBe(false)
|
||||
expect(getWcoSnapshot().height).toBe(0)
|
||||
})
|
||||
|
||||
it('reads the real overlay rect once a source is present', () => {
|
||||
const { source } = makeSource({ width: 138, height: 36 })
|
||||
setWcoSourceForTests(source)
|
||||
expect(getWcoSnapshot()).toEqual({ present: true, height: 36 })
|
||||
})
|
||||
|
||||
it('rounds fractional overlay heights to whole pixels', () => {
|
||||
const { source } = makeSource({ width: 100, height: 35.6 })
|
||||
setWcoSourceForTests(source)
|
||||
expect(getWcoSnapshot().height).toBe(36)
|
||||
})
|
||||
|
||||
it('re-reads geometry on geometrychange (maximize/restore) and notifies subscribers', () => {
|
||||
const { source, setRect, fireGeometryChange, listeners } = makeSource({ width: 138, height: 32 })
|
||||
setWcoSourceForTests(source)
|
||||
const calls: number[] = []
|
||||
const off = subscribeWco(() => { calls.push(getWcoSnapshot().height) })
|
||||
// Maximized: the overlay reports a zero/empty rect.
|
||||
setRect(0, 0)
|
||||
fireGeometryChange()
|
||||
expect(getWcoSnapshot()).toEqual({ present: true, height: 0 })
|
||||
expect(calls).toEqual([0])
|
||||
// Restored.
|
||||
setRect(138, 36)
|
||||
fireGeometryChange()
|
||||
expect(getWcoSnapshot()).toEqual({ present: true, height: 36 })
|
||||
expect(calls).toEqual([0, 36])
|
||||
// The disposer stops notifications.
|
||||
off()
|
||||
setRect(138, 40)
|
||||
fireGeometryChange()
|
||||
expect(calls).toEqual([0, 36])
|
||||
expect(listeners.size).toBe(0)
|
||||
})
|
||||
|
||||
it('treats a PHANTOM API (present but not visible) as absent — headless/macOS expose the interface with no overlay', () => {
|
||||
const { source } = makeSource({ width: 0, height: 0, visible: false })
|
||||
setWcoSourceForTests(source)
|
||||
// present=false lets the strip chain fall through to the inset / preset
|
||||
// / custom scheme instead of trusting an empty rect.
|
||||
expect(getWcoSnapshot()).toEqual({ present: false, height: 0 })
|
||||
// A real overlay flips to authoritative immediately.
|
||||
const real = makeSource({ width: 138, height: 36, visible: true })
|
||||
setWcoSourceForTests(real.source)
|
||||
expect(getWcoSnapshot()).toEqual({ present: true, height: 36 })
|
||||
})
|
||||
|
||||
it('treats a throwing API as absent (never breaks the layout)', () => {
|
||||
const source: WcoSource = {
|
||||
visible: true,
|
||||
getTitlebarAreaRect: () => { throw new Error('racy API') },
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
}
|
||||
setWcoSourceForTests(source)
|
||||
expect(getWcoSnapshot()).toEqual({ present: false, height: 0 })
|
||||
})
|
||||
|
||||
it('re-attaches cleanly when the source is swapped (test hook)', () => {
|
||||
const first = makeSource({ width: 138, height: 32 })
|
||||
const second = makeSource({ width: 138, height: 36 })
|
||||
setWcoSourceForTests(first.source)
|
||||
expect(first.listeners.size).toBe(0) // no subscriber yet
|
||||
const off = subscribeWco(() => {})
|
||||
expect(first.listeners.size).toBe(1)
|
||||
setWcoSourceForTests(second.source)
|
||||
expect(first.listeners.size).toBe(0)
|
||||
expect(second.listeners.size).toBe(1)
|
||||
expect(getWcoSnapshot().height).toBe(36)
|
||||
off()
|
||||
setWcoSourceForTests(undefined)
|
||||
expect(getWcoSnapshot()).toBe(WCO_NONE)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user