Files
apes-sidebar/tests/theme.spec.ts
T
Menghuan1918 5912274e41 feat: yield the terminal and browser to DSH 0.1.6's own sidebar types
DSH 0.1.6-alpha.2 ships a right-Sidebar terminal and a right-Sidebar browser
of its own. Both were things this plugin had built from scratch, so keeping
them meant two implementations of one surface plus a plugin that shadows a
host type the product is actively developing.

Terminal: the plugin's whole PTY stack goes — the UI-tab manager, the
agent-owned registry, the lazy node-pty loader, the eight model-facing
terminal_* tools, the xterm view and its font/link/scrollback helpers, and
the cross-session 'pinned terminal' rail that existed only to keep a terminal
alive across a session switch. The host's terminal kind becomes the only
provider, which also retires the node-pty dependency, its pnpm allowBuilds
entry, and the installer's -Repair mode (issue #140's whole class of
'node-pty failed to load' support).

Model-facing capability note: the plugin used to inject terminal_* tools
(off by default). Those are the model's only cross-call persistent terminal,
and DSH's own equivalent (@deepseek-ai/dsh-tool-terminal) is not mounted by
any shipped bundle, so removing them leaves the model with the one-shot
bash/pwsh tools only. That is the accepted cost of not maintaining a second
PTY surface; the README documents how to enable the upstream toolset.

Browser: the plugin's browser tab type is dropped, so the host's own browser
kind owns embedded pages. The DOM-level link takeover STAYS (it is the only
thing that can route an https link to the system browser by protocol, which
the host does not do) but now delegates to the host's browser tab via
ctx.sidebarRight.openTab('browser', …) with a window.open fallback, and the
two settings that only ever configured the plugin's own iframe (sandbox
escape hatch, loopback allowlist) go with it — along with the host-side
browser.probe route they fed.

Cards: the empty-pane welcome cards adopt the host's guide-capsule recipe
(0.5px l4 hairline, 24px radius, layer-1 fill, 56px floor, 14px/20px padding)
so the plugin's 'pick what opens here' surface reads as the same design as
DSH's own. A theme.spec guard pins the geometry and the token-only paint —
including that the upstream-only --dsw-alias-bg-l1/-l2 spellings (defined
nowhere, so their declarations are silently dropped upstream) never appear
here.

Also drops the dead state the removed features left behind (SidebarTab.pin,
nextTerminal, agentWaits, bottomOpenedOnce, the agent-tab helpers, the
pinned-tab memo key), re-points every test fixture that used them at
surviving equivalents, and prunes the specs of the deleted features.
2026-09-21 20:35:11 +08:00

156 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Live theme-token access tests — issue #90's guard.
*
* `effectiveTokenValue` must treat visually inert values (CSS reset
* keywords, `transparent`, and any color below the opacity floor) as UNSET
* so callers' `|| fallback` chains fire. Skin systems set global tokens like
* `--dsw-alias-bg-base` to `transparent` or translucent glass values (the
* dsh-web-ui skins use rgba 0.16–0.7); without this guard a
* truthy-but-inert value would leave the terminal/editor see-through over
* the skin's backdrop. Effectively opaque values (>= 0.9 alpha, including a
* skin's scoped 0.96 porcelain) pass through so the skin still controls the
* surface.
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest'
import { readFileSync, readdirSync } from 'node:fs'
import { resolve } from 'node:path'
import { colorAlpha, effectiveTokenValue, tokenValue } from '../src/client/theme.ts'
afterEach(() => {
document.body.removeAttribute('style')
})
describe('colorAlpha', () => {
it('parses the hex family', () => {
expect(colorAlpha('#fff')).toBe(1)
expect(colorAlpha('#fff8')).toBeCloseTo(0x88 / 255)
expect(colorAlpha('#112233')).toBe(1)
expect(colorAlpha('#11223344')).toBeCloseTo(0x44 / 255)
})
it('parses rgb()/rgba() in comma and space syntax', () => {
expect(colorAlpha('rgb(1, 2, 3)')).toBe(1)
expect(colorAlpha('rgba(255, 255, 255, 0.45)')).toBeCloseTo(0.45)
expect(colorAlpha('rgb(255 255 255 / 0.7)')).toBeCloseTo(0.7)
})
it('parses hsl()/hsla()', () => {
expect(colorAlpha('hsl(200, 50%, 50%)')).toBe(1)
expect(colorAlpha('hsla(200, 50%, 50%, 0.3)')).toBeCloseTo(0.3)
})
it('treats unparseable formats as opaque', () => {
expect(colorAlpha('white')).toBeNull()
expect(colorAlpha('')).toBeNull()
})
})
describe('effectiveTokenValue', () => {
it('passes through real opaque paint values verbatim', () => {
document.body.style.setProperty('--probe', '#112233')
expect(effectiveTokenValue('--probe')).toBe('#112233')
// Effectively opaque translucency (a skin's scoped 0.96 porcelain glass)
// is a deliberate surface choice — it passes through.
document.body.style.setProperty('--probe', 'rgba(10, 22, 54, 0.96)')
expect(effectiveTokenValue('--probe')).toBe('rgba(10, 22, 54, 0.96)')
})
it('treats transparent and CSS reset keywords as unset', () => {
for (const inert of ['transparent', 'initial', 'inherit', 'unset']) {
document.body.style.setProperty('--probe', inert)
expect(effectiveTokenValue('--probe'), inert).toBe('')
}
})
it('treats translucent glass values below the opacity floor as unset', () => {
// The dsh-web-ui skins set bg-base to rgba 0.16–0.7 for glass panels;
// the terminal must fall back to an opaque background there.
for (const glass of ['rgba(255, 255, 255, 0.45)', 'rgba(20, 26, 46, 0.7)', '#11223344']) {
document.body.style.setProperty('--probe', glass)
expect(effectiveTokenValue('--probe'), glass).toBe('')
}
})
it('treats a missing token as unset', () => {
expect(effectiveTokenValue('--never-defined')).toBe('')
})
it('tokenValue still returns the raw value', () => {
document.body.style.setProperty('--probe', 'transparent')
expect(tokenValue('--probe')).toBe('transparent')
expect(effectiveTokenValue('--probe')).toBe('')
})
})
/**
* Skin contract (guide §12): every visual value rides a `--dsw-alias-*` /
* `--dsw-font-*` / `--ds-*` token, and the plugin draws no color of its own.
*
* File icons are no exception to check any more: the built-in set IS DSH's
* `FileTypeIcon` artwork from a platform module (the host owns those pixels
* and its own palette), and everything the plugin renders around them —
* including the colored tab glyphs — rides theme tokens. So the guard is
* simply that no plugin module carries a color literal, and that no icon
* dataset sneaked back in as a chunk.
*/
// jsdom has no file:// import.meta.url; vitest runs from the repo root.
const ROOT = process.cwd()
describe('skin contract: the plugin owns no color of its own', () => {
it('the icon modules carry no color literals', () => {
for (const file of ['src/client/file-icons.tsx', 'src/client/icons.tsx']) {
const source = readFileSync(resolve(ROOT, file), 'utf8')
expect(source, file).not.toMatch(/#[0-9a-fA-F]{3,8}\b/)
expect(source, file).not.toMatch(/\brgba?\(/)
}
})
it('the colored tab glyphs take their color from theme tokens, never from a literal', () => {
const module = readFileSync(resolve(ROOT, 'src/client/builtins/tab-icons.tsx'), 'utf8')
expect(module).not.toMatch(/#[0-9a-fA-F]{3,8}\b/)
expect(module).not.toMatch(/\brgba?\(/)
// Each glyph is wrapped in a themed class, which is how the color arrives.
const styles = readFileSync(resolve(ROOT, 'src/client/builtins/tab-icons.module.css'), 'utf8')
const classes = [...module.matchAll(/css\.([a-zA-Z]+)/g)].map(m => m[1])
expect(classes.length).toBeGreaterThan(0)
for (const name of new Set(classes)) expect(styles, name).toContain(`.${name}`)
// Every declaration that paints a color resolves to a token.
for (const declaration of styles.matchAll(/color:\s*([^;]+);/g)) {
expect(declaration[1], declaration[0]).toContain('var(--dsw-')
}
})
it('the empty-pane welcome capsule follows the host guide recipe', () => {
// The card is the same surface as DSH's own guide capsule ("pick what
// opens here"), so this pins the shared geometry and the token-only
// paint. A literal here would drift away from the host under every skin.
const styles = readFileSync(resolve(ROOT, 'src/client/sidebar.module.css'), 'utf8')
const block = /\.paneCard \{([\s\S]*?)\n\}/.exec(styles)?.[1]
expect(block, 'the .paneCard rule must exist').toBeDefined()
expect(block!).toContain('border: 0.5px solid var(--dsw-alias-border-l4)')
expect(block!).toContain('border-radius: 24px')
expect(block!).toContain('background: var(--dsw-alias-bg-layer-1)')
expect(block!).toContain('min-height: 56px')
expect(block!).toContain('padding: 14px 20px')
// No paint of its own: every color-ish declaration resolves to a token,
// and the upstream-only spellings this repo must not copy are absent.
for (const declaration of block!.matchAll(/(?:^|\n)\s*(?:background|border|color|font):\s*([^;]+);/g)) {
const value = declaration[1]
if (!/var\(--dsw-/.test(value)) continue
expect(value, declaration[0]).toContain('var(--dsw-')
}
expect(block!).not.toContain('--dsw-alias-bg-l1')
expect(block!).not.toContain('--dsw-alias-bg-l2')
})
it('no icon dataset is shipped as a lazy chunk', () => {
const chunkDir = resolve(ROOT, 'src/client/chunks')
const chunks = readdirSync(chunkDir).filter(name => /\.tsx?$/.test(name))
for (const name of chunks) {
const source = readFileSync(resolve(chunkDir, name), 'utf8')
expect(source, name).not.toMatch(/#[0-9a-fA-F]{6}\b/)
}
})
})