Merge branch 'omdsh-dev:main' into fix/panel-drag-layout-flash

This commit is contained in:
ChenRuoT
2026-08-17 23:26:21 +08:00
committed by GitHub
91 changed files with 7633 additions and 2445 deletions
+15 -8
View File
@@ -155,7 +155,7 @@ describe('PluginListBody copy click (interactive)', () => {
vi.restoreAllMocks()
})
it('both the name button and the jump button open the repo in a NEW browser tab (window.open, not a link)', () => {
it('the name and jump buttons open each catalog entry in a NEW browser tab (window.open, not a link)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const opened: Array<[string, string, string]> = []
@@ -171,14 +171,21 @@ describe('PluginListBody copy click (interactive)', () => {
act(() => {
root.render(createElement(PluginListBody, { service, kind: 'viewer' }))
})
// Two window.open buttons per catalog entry — the name button and the
// jump button. The expected count is catalog-driven so adding or
// removing recommended plugins keeps the test honest instead of
// pinning a hardcoded total.
const jumpButtons = [...container.querySelectorAll('button[aria-label^="Open:"]')]
expect(jumpButtons).toHaveLength(2) // the name + the jump button
act(() => { (jumpButtons[0] as HTMLButtonElement).click() })
act(() => { (jumpButtons[1] as HTMLButtonElement).click() })
expect(opened).toEqual([
[officeEntry.url, '_blank', 'noopener'],
[officeEntry.url, '_blank', 'noopener'],
])
expect(jumpButtons).toHaveLength(builtinViewerPlugins.length * 2)
for (const button of jumpButtons) {
act(() => { (button as HTMLButtonElement).click() })
}
expect(opened).toEqual(
builtinViewerPlugins.flatMap((entry) => [
[entry.url, '_blank', 'noopener'],
[entry.url, '_blank', 'noopener'],
]),
)
act(() => { root.unmount() })
container.remove()
} finally {
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import {
allLeaves, makeDefaultState, openTabInActivePane, reconcileAgentTerminals,
tabOpenIn, toggleBottomPanel,
} from '../src/client/state.ts'
describe('agent terminal reconciliation', () => {
it('adds tabs for new agent terminals', () => {
let s = makeDefaultState(400, true)
s = reconcileAgentTerminals(s, [
{ uuid: 'aaa-111', title: 'dev server' },
{ uuid: 'bbb-222', title: 'python repl' },
])
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(true)
// The titles are preserved.
const tabs = allLeaves(s.splits).flatMap(leaf => leaf.tabs)
const agentTab = tabs.find(t => t.id === 'agent:aaa-111')
expect(agentTab?.title).toBe('dev server')
expect(agentTab?.type).toBe('terminal')
})
it('removes tabs for agent terminals that vanished from the server list', () => {
let s = makeDefaultState(400, true)
s = reconcileAgentTerminals(s, [
{ uuid: 'aaa-111', title: 'keep' },
{ uuid: 'bbb-222', title: 'remove' },
])
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(true)
// Next push drops bbb-222.
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'keep' }])
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(false)
})
it('is a no-op (same reference) when the lists already match', () => {
let s = makeDefaultState(400, true)
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }])
const next = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }])
expect(next).toBe(s)
})
it('does not touch non-agent terminal tabs', () => {
let s = makeDefaultState(400, true)
s = openTabInActivePane(s, { id: 'terminal:1', type: 'terminal', title: 'UI Tab' })
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'agent' }])
expect(tabOpenIn(s, 'terminal:1')).toBe(true)
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
})
it('handles an empty server list (removes all agent tabs)', () => {
let s = makeDefaultState(400, true)
s = reconcileAgentTerminals(s, [
{ uuid: 'aaa-111', title: 'a' },
{ uuid: 'bbb-222', title: 'b' },
])
s = reconcileAgentTerminals(s, [])
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(false)
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(false)
})
it('lands new agent terminals in the active tree (bottom panel pane)', () => {
let s = makeDefaultState(400, true)
s = toggleBottomPanel(s)
const bottomPane = (s.bottomSplits as { id: string }).id
s = { ...s, activePane: bottomPane }
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'dev server' }])
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
expect(allLeaves(s.bottomSplits).flatMap(l => l.tabs).some(t => t.id === 'agent:aaa-111')).toBe(true)
expect(allLeaves(s.splits).flatMap(l => l.tabs).some(t => t.id === 'agent:aaa-111')).toBe(false)
})
})
+33 -12
View File
@@ -1,5 +1,5 @@
/**
* Built-in registration tests: the plugin registers 7 tabs and 6 file
* Built-in registration tests: the plugin registers 6 tabs and 6 file
* viewers through the same service external plugins use (dogfooding);
* the catch-all `code` viewer, the NUL-sniffing `binary-download` viewer,
* and the html sandbox settings pin the registry's behavior. (Office
@@ -24,21 +24,24 @@ function setup(): { service: ReturnType<typeof createBetterSidebarService>; stor
}
describe('built-in tab registrations', () => {
it('registers the 7 built-in tabs', () => {
it('registers the 6 built-in tabs', () => {
const { service } = setup()
expect(service.getTabs().map(t => t.id).sort()).toEqual(
['browser', 'diff', 'editor', 'explorer', 'git', 'subagent', 'terminal'],
['browser', 'diff', 'editor', 'git', 'subagent', 'terminal'],
)
})
it('editor and diff are hidden from the + menu (opened by file-open / git view)', () => {
it('only diff is hidden from the + menu; editor is the visible files window (order 10)', () => {
const { service } = setup()
expect(service.getTabs().filter(t => t.hidden).map(t => t.id).sort()).toEqual(['diff', 'editor'])
expect(service.getTabs().filter(t => t.hidden).map(t => t.id)).toEqual(['diff'])
const editor = service.getTab('editor')
expect(editor?.hidden).toBe(false)
expect(editor?.order).toBe(10)
})
it('single-instance tabs use the single sugar', () => {
const { service } = setup()
for (const id of ['explorer', 'git', 'subagent']) {
for (const id of ['git', 'subagent']) {
expect(service.getTab(id)?.single).toBe(true)
}
})
@@ -49,6 +52,19 @@ describe('built-in tab registrations', () => {
expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent', 'autoOpenJobs'])
})
it('the editor tab declares its merged-mode (embedded file tree) setting', () => {
const { service } = setup()
const toggles = service.getTab('editor')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['editorExplorer'])
expect(toggles[0]?.title).toBeDefined()
expect(toggles[0]?.desc).toBeDefined()
// The merged mode is an iconed select (merged vs separate), not a switch.
expect(toggles[0]?.type).toBe('select')
const options = toggles[0]?.options ?? []
expect(options.map(o => o.value)).toEqual([true, false])
expect(options.every(o => o.icon !== undefined && o.title !== undefined)).toBe(true)
})
it('the terminal tab declares the model terminal-tools, auto-terminal and custom-font settings', () => {
const { service } = setup()
const toggles = service.getTab('terminal')?.settings?.toggles ?? []
@@ -67,14 +83,19 @@ describe('built-in tab registrations', () => {
expect(toggles[1]?.type ?? 'switch').toBe('switch')
})
it('the browser tab declares its sandbox and link-takeover related settings', () => {
it('the browser tab declares its sandbox, link-takeover master and per-protocol settings', () => {
const { service } = setup()
const toggles = service.getTab('browser')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['browserNoSandbox', 'browserInterceptLinks'])
expect(toggles[0]?.title).toBeDefined()
expect(toggles[0]?.desc).toBeDefined()
expect(toggles[1]?.title).toBeDefined()
expect(toggles[1]?.desc).toBeDefined()
expect(toggles.map(t => t.key)).toEqual([
'browserNoSandbox',
'browserInterceptLinks',
'browserInterceptHttp',
'browserInterceptHttps',
])
for (const toggle of toggles) {
expect(toggle.title).toBeDefined()
expect(toggle.desc).toBeDefined()
}
})
it('the browser createTab mints browser:<n> ids and bumps nextBrowser', () => {
+258
View File
@@ -0,0 +1,258 @@
/**
* Drag-layout lane: the width drag must track the app shell 1:1 — the
* regression test for issue #92 ("主会话框左右抖动").
*
* The layout push squeezes `#root` via `margin-right: var(--dsh-sidebar-width)`
* (layout.css), and layout.css disables that margin's transition while a drag
* is live via `body[data-dsh-sidebar-dragging]`. If the transition stays
* active during the drag (or the conversation lags the panel edge), the
* conversation visibly shakes at pointer cadence. This spec drives a real
* pointer drag on the width strip while a requestAnimationFrame sampler
* records, per frame:
*
* - the strip's x (the panel edge),
* - the conversation column's right edge (`#root`'s margin push lands
* exactly there),
* - whether `body[data-dsh-sidebar-dragging]` is set,
* - `#root`'s computed transition property/duration.
*
* Then it asserts the drag contract:
* 1. the dragging attribute is present during the drag;
* 2. `#root`'s transition is disabled (none) during the drag;
* 3. the conversation edge follows the panel edge monotonically (no
* oscillation) and 1:1 (total travel within a rounding epsilon).
*
* The server is booted by scripts/e2e-mount.sh; this spec only loads the
* page (same contract as mount.e2e.ts, using its own workspace so the two
* lanes never race on seeding).
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test, expect, request, type APIRequestContext } from '@playwright/test'
const BASE_URL = process.env.DSH_E2E_URL
if (!BASE_URL) {
throw new Error('DSH_E2E_URL is not set — boot a DSH web instance with the plugin mounted and point this lane at it (see scripts/e2e-mount.sh)')
}
/** This lane's own workspace (distinct from mount.e2e.ts's, lanes run serially
* but against the same server — never share seed paths). */
const WORKSPACE_PATH = process.env.DSH_E2E_DRAG_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-drag-workspace')
let api: APIRequestContext
/** Seed one workspace + one session through the host's unary RPC surface. */
async function seedSession(): Promise<void> {
mkdirSync(WORKSPACE_PATH, { recursive: true })
writeFileSync(join(WORKSPACE_PATH, 'seed.txt'), 'drag lane\n')
const workspace = await api.post(`${BASE_URL}/api/workspace.create`, {
data: { type: 'client-request', rpcId: 'e2e-drag-workspace', method: 'workspace.create', payload: { path: WORKSPACE_PATH } },
})
expect(workspace.ok(), `workspace.create: ${workspace.status()} ${await workspace.text()}`).toBe(true)
const workspaceBody = (await workspace.json()) as {
result: { ok: true; value: { workspace: { workspaceId: string } } } | { ok: false; error: unknown }
}
expect(workspaceBody.result.ok).toBe(true)
const workspaceId = (workspaceBody.result as { value: { workspace: { workspaceId: string } } }).value.workspace.workspaceId
const session = await api.post(`${BASE_URL}/api/session.create`, {
data: { type: 'client-request', rpcId: 'e2e-drag-session', method: 'session.create', payload: { workspaceId } },
})
expect(session.ok(), `session.create: ${session.status()} ${await session.text()}`).toBe(true)
}
test.beforeAll(async () => {
api = await request.newContext({ baseURL: BASE_URL })
await seedSession()
})
test.afterAll(async () => {
await api?.dispose()
})
interface FrameSample {
t: number
stripX: number
convoRight: number
dragging: boolean
transitionProperty: string
transitionDuration: string
}
test('width drag tracks the shell 1:1 with transitions disabled (issue #92)', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
const sidebar = page.locator('[data-dsh-better-sidebar]')
await expect(sidebar).toBeAttached({ timeout: 90_000 })
// Dismiss whatever onboarding takeover is present (same dance as the mount
// lane), so the pointer can reach the strip without a masking overlay.
try {
await expect
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
.toBeGreaterThan(0)
} catch {
console.warn('[e2e-drag] no onboarding takeover appeared; proceeding')
}
for (let round = 0; round < 8; round++) {
let dismissed = false
for (const name of ['Continue', 'Configure later']) {
const button = page.getByRole('button', { name, exact: true }).first()
if ((await button.count()) === 0) continue
try {
await button.click({ timeout: 4_000 })
dismissed = true
await page.waitForTimeout(1_000)
} catch {
// Masked by a takeover stacked above; retry in the next round.
}
}
if (!dismissed) break
}
// openByDefault defaults OFF: a fresh session's panel starts collapsed, and
// the collapsed layout push still writes `--dsh-sidebar-width: 0px` — so the
// geometry checks below are meaningless until the panel is expanded through
// the toggle cluster.
const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' })
await expect(expandButton, 'the collapsed toggle cluster must offer the expand button').toHaveCount(1)
await expandButton.click()
// The layout push variable becomes live (non-zero) once the panel opens
// (session activation lags the shell render).
await expect
.poll(async () => {
const value = await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
return value !== '' && value !== '0px'
}, { timeout: 90_000 })
.toBe(true)
// The width drag strip is the panel's left-edge hit strip. There is no
// dedicated hook (the skinning contract is token-driven), so locate it
// semantically among the sidebar's `cursor: col-resize` elements — the files
// window's tree-dock handle matches too, but the panel strip is always the
// LEFTMOST one (the dock sits at the panel's right edge).
const locateStrip = `(() => {
const host = document.querySelector('[data-dsh-better-sidebar]')
if (host === null) return null
const boxes = [...host.querySelectorAll('*')]
.filter(el => getComputedStyle(el).cursor === 'col-resize')
.map(el => el.getBoundingClientRect())
.filter(r => r.width > 0 && r.height > 0)
.sort((a, b) => a.x - b.x)
const r = boxes[0]
if (r === undefined) return null
const varWidth = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
return {
x: r.x, y: r.y, width: r.width, height: r.height,
varWidth: Number.isNaN(varWidth) ? 0 : varWidth,
innerWidth: window.innerWidth,
}
})()`
type StripBox = { x: number; y: number; width: number; height: number; varWidth: number; innerWidth: number }
// The panel SLIDES IN from the right on expand: locating the strip before
// the open transition settles captures a mid-animation box at the viewport
// edge, and the drag lands off-panel. Wait until the strip sits at the
// pushed layout edge (its right edge ≈ innerWidth - the push variable).
await expect
.poll(async () => {
const box = await page.evaluate<StripBox | null>(locateStrip)
if (box === null) return false
return Math.abs((box.x + box.width) - (box.innerWidth - box.varWidth)) <= 8
}, { timeout: 30_000 })
.toBe(true)
const stripBox = await page.evaluate<StripBox | null>(locateStrip)
expect(stripBox, 'the width drag strip must be present (cursor: col-resize)').not.toBeNull()
// Instrument a per-frame sampler BEFORE the drag begins.
await page.evaluate(() => {
type Sample = FrameSample
const samples: Sample[] = []
// Same rule as the strip locator above: the LEFTMOST col-resize element
// inside the sidebar host (the tree-dock handle is further right).
const host = document.querySelector('[data-dsh-better-sidebar]')
const strip = host === null
? null
: [...host.querySelectorAll<HTMLElement>('*')]
.filter(el => getComputedStyle(el).cursor === 'col-resize')
.sort((a, b) => a.getBoundingClientRect().x - b.getBoundingClientRect().x)[0]
// The conversation column: the grid item the layout push squeezes (the
// same nth-child(2) layout.css targets for the vertical push; its right
// edge is where the width push lands).
const center = document.querySelector('#root > div[data-slot="root"] > div > div:nth-child(2)')
const root = document.querySelector('#root') as HTMLElement
const loop = (): void => {
const s = strip?.getBoundingClientRect() ?? { left: 0 }
const c = center?.getBoundingClientRect() ?? { left: 0, right: 0 }
const cs = getComputedStyle(root)
samples.push({
t: performance.now(),
stripX: s.left,
convoRight: c.right,
dragging: document.body.hasAttribute('data-dsh-sidebar-dragging'),
transitionProperty: cs.transitionProperty,
transitionDuration: cs.transitionDuration,
})
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
;(window as unknown as { __dragSamples: Sample[] }).__dragSamples = samples
})
const startX = stripBox!.x + stripBox!.width / 2
const startY = stripBox!.y + Math.min(120, stripBox!.height / 2 + 60)
await page.mouse.move(startX, startY)
await page.mouse.down()
// Drag LEFT in steps (widens the panel): the conversation edge must move
// LEFT in lockstep with the panel edge, monotonically, no oscillation.
for (let i = 1; i <= 14; i++) {
await page.mouse.move(startX - i * 10, startY, { steps: 2 })
await page.waitForTimeout(40)
}
await page.mouse.up()
await page.waitForTimeout(400)
const samples = await page.evaluate(
() => (window as unknown as { __dragSamples: FrameSample[] }).__dragSamples,
)
expect(samples.length, 'the frame sampler must have collected frames').toBeGreaterThan(20)
// The drag must actually have moved the panel (sanity: the store committed).
const first = samples.find(s => s.dragging)
const last = [...samples].reverse().find(s => s.dragging)
expect(first, 'the dragging attribute must appear during the drag').toBeDefined()
expect(last!.stripX, 'the panel edge must have moved during the drag').toBeLessThan(first!.stripX - 40)
// The conversation-column selector must have matched (the push lands on it).
expect(last!.convoRight, 'the conversation edge must have moved with the drag').toBeLessThan(first!.convoRight - 40)
// Contract 1 + 2: while dragging, the body attribute is set and #root's
// margin transition is disabled (computed `transition: none` reads as
// transition-property "none" with 0s duration; the non-dragging rule would
// compute to "margin-right" with the theme duration).
const draggingSamples = samples.filter(s => s.dragging)
expect(draggingSamples.length).toBeGreaterThan(5)
for (const sample of draggingSamples) {
expect(sample.transitionProperty, 'the margin transition must be off while dragging').toBe('none')
expect(sample.transitionDuration, 'the margin transition must be off while dragging').toBe('0s')
}
// Contract 3: monotonic, 1:1 tracking. During a leftward drag both the
// strip x and the conversation right edge decrease; allow one frame of
// rAF-batching staleness (0 delta), never a reversal.
const tracked = draggingSamples.filter(s => s.t > first!.t)
for (let i = 1; i < tracked.length; i++) {
const stripDelta = tracked[i]!.stripX - tracked[i - 1]!.stripX
const convoDelta = tracked[i]!.convoRight - tracked[i - 1]!.convoRight
expect(stripDelta, 'the strip must move left during the drag').toBeLessThanOrEqual(2)
expect(
convoDelta,
`conversation edge reversed while dragging (jitter): strip ${stripDelta}px, conversation ${convoDelta}px`,
).toBeLessThanOrEqual(2)
}
// Total travel in lockstep (rounding + one-frame staleness tolerance).
const stripTravel = first!.stripX - last!.stripX
const convoTravel = first!.convoRight - last!.convoRight
expect(Math.abs(convoTravel - stripTravel), 'conversation must track the panel edge 1:1').toBeLessThanOrEqual(8)
})
+241
View File
@@ -0,0 +1,241 @@
/**
* Headless-render mount lane: prove the npm-packed plugin mounts into a real
* `dsh web` instance and renders without crashing the shell.
*
* The server is NOT started here — `scripts/e2e-mount.sh` boots `dsh web`
* (with the plugin mounted through the official `dsh plugin add` channel) and
* injects the base URL via `DSH_E2E_URL`. This spec:
*
* 1. seeds one workspace + one session through the host's own RPC surface
* (the same `workspace.create` / `session.create` calls the UI makes),
* so the sidebar has a real session to render;
* 2. loads the page in headless Chromium and asserts the shell and the
* plugin's `[data-dsh-better-sidebar]` host mount;
* 3. asserts the plugin's crash markers never appear (no RenderBoundary /
* fail() strips, no `pageerror`, no plugin-prefixed console errors);
* 4. expands the collapsed panel (openByDefault defaults off), sweeps every
* built-in tab (Files / Source Control / Tasks / Terminal / Browser) —
* including the lazily-fetched terminal chunk — and then opens a seeded
* file through the Files window's tree (in-place mode: the seeded home
* tab itself switches to the file, no new tab), while a response wait
* armed before goto proves the lazily-fetched editor chunk
* (client-editor.js) loaded.
*
* Deterministic by construction: every wait is on a DOM/network marker, the
* suite is serial (one server instance), and any crash trips the very next
* assertion.
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test, expect, request, type APIRequestContext } from '@playwright/test'
const BASE_URL = process.env.DSH_E2E_URL
if (!BASE_URL) {
throw new Error('DSH_E2E_URL is not set — boot a DSH web instance with the plugin mounted and point this lane at it (see scripts/e2e-mount.sh)')
}
/** Workspace the sidebar renders against (created by the lane's seeding). */
const WORKSPACE_PATH = process.env.DSH_E2E_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-workspace')
/** A file seeded into the workspace, opened through the Files window's tree to
* exercise the file-open path (editor chunk = client-editor.js). */
const SEEDED_FILE = 'hello.txt'
/**
* The plugin's crash markers. The client mounts inside an error boundary that
* renders a strip whose text starts with these prefixes instead of crashing
* (see src/client/index.tsx `fail()` and src/client/RenderBoundary.tsx).
*/
const CRASH_STRIP_PATTERNS = [/^dsh-better-sidebar:/, /^\[dsh-better-sidebar\]/]
/** Built-in tab titles the sweep drives (en-US copy; follows DSH locale). */
const BUILTIN_TABS = ['Files', 'Source Control', 'Tasks', 'Terminal', 'Browser']
let api: APIRequestContext
/** Seed one workspace + one session (plus a file for the editor-chunk probe)
* through the host's unary RPC surface. */
async function seedSession(): Promise<void> {
mkdirSync(WORKSPACE_PATH, { recursive: true })
writeFileSync(join(WORKSPACE_PATH, SEEDED_FILE), 'hello from the mount lane\n')
const workspace = await api.post(`${BASE_URL}/api/workspace.create`, {
data: { type: 'client-request', rpcId: 'e2e-workspace', method: 'workspace.create', payload: { path: WORKSPACE_PATH } },
})
expect(workspace.ok(), `workspace.create: ${workspace.status()} ${await workspace.text()}`).toBe(true)
const workspaceBody = (await workspace.json()) as {
result: { ok: true; value: { workspace: { workspaceId: string } } } | { ok: false; error: unknown }
}
expect(workspaceBody.result.ok).toBe(true)
const workspaceId = (workspaceBody.result as { value: { workspace: { workspaceId: string } } }).value.workspace.workspaceId
const session = await api.post(`${BASE_URL}/api/session.create`, {
data: { type: 'client-request', rpcId: 'e2e-session', method: 'session.create', payload: { workspaceId } },
})
expect(session.ok(), `session.create: ${session.status()} ${await session.text()}`).toBe(true)
}
test.beforeAll(async () => {
api = await request.newContext({ baseURL: BASE_URL })
await seedSession()
})
test.afterAll(async () => {
await api?.dispose()
})
test('plugin mounts into the DSH shell and survives a built-in tab sweep', async ({ page }) => {
const pageErrors: string[] = []
const consoleErrors: string[] = []
page.on('pageerror', (error) => pageErrors.push(String(error)))
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
// Load the shell. The app renders into #root; the plugin appends its own
// [data-dsh-better-sidebar] host once its client half activates.
//
// The editor chunk (client-editor.js) loads as soon as ANY files-window tab
// renders — the seeded home tab mounts the moment the panel expands, long
// before the tree click below — so the response wait must be armed BEFORE
// goto, or it misses the fetch and times out.
const editorChunk = page.waitForResponse(
(response) => response.url().includes('/sidebar/bundle/editor.js'),
{ timeout: 120_000 },
)
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
const sidebar = page.locator('[data-dsh-better-sidebar]')
await expect(sidebar).toBeAttached({ timeout: 90_000 })
// A keyless boot stacks onboarding takeovers that mask the whole shell: a
// versioned welcome notice ("Continue", persists its acknowledgement to
// settings) and, once that is acknowledged, a provider-config dialog
// ("Configure later", session-only — always present while no credential is
// configured). Both mount only after the settings join resolves. Wait
// (bounded) for one to appear; a DSH build without onboarding proceeds
// straight to the sweep.
try {
await expect
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
.toBeGreaterThan(0)
} catch {
console.warn('[e2e] no onboarding takeover appeared; proceeding without dismissal')
}
// Dismiss whatever takeover is present, in any stacking order, until none
// remain — a masked click is retried next round instead of failing.
for (let round = 0; round < 8; round++) {
let dismissed = false
for (const name of ['Continue', 'Configure later']) {
const button = page.getByRole('button', { name, exact: true }).first()
if ((await button.count()) === 0) continue
try {
await button.click({ timeout: 4_000 })
dismissed = true
await page.waitForTimeout(1_000)
} catch {
// Masked by the takeover stacked above it; the next round tries the
// other button first.
}
}
if (!dismissed) break
}
// The seeded session must give the sidebar a session scope: without it the
// shell renders a disabled toggle cluster and the tab sweep is impossible.
const tabBar = sidebar.locator('[title]')
await expect(tabBar.first()).toBeAttached({ timeout: 90_000 })
// openByDefault defaults OFF: a fresh session's panel starts collapsed.
// Expand it through the toggle cluster before the layout push can apply.
const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' })
await expect(expandButton, 'the collapsed toggle cluster must offer the expand button').toHaveCount(1)
await expandButton.click()
// The skinning contract is token-driven (AGENTS.md §8): the panels consume
// `--dsw-alias-bg-layer-1`, so switching a skin re-skins the sidebar with
// no per-skin code. The layout push variable must be live once the panel
// mounts (its absence would mean the panel never opened with the session).
await expect
.poll(async () => (
await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
), { timeout: 90_000 })
.not.toBe('')
// Crash-marker assertions shared by every step.
const assertNoCrash = async (): Promise<void> => {
await expect
.poll(async () => pageErrors, { timeout: 5_000 })
.toEqual([])
const strips = await sidebar.locator('div').evaluateAll(
(nodes, patterns) => nodes.filter((node) => {
const text = (node.textContent ?? '').trim()
return patterns.some((pattern) => pattern.test(text))
}).length,
CRASH_STRIP_PATTERNS,
)
expect(strips, 'a dsh-better-sidebar error strip is present in the sidebar').toBe(0)
}
// Sweep every built-in tab through the "+" menu (the sidebar's own open-tab
// affordance, reachable from any pane state). Each open may fetch a lazy
// chunk (/sidebar/bundle/client-terminal.js / client-editor.js) and mount a
// real viewer — the highest-risk crash surfaces. The pinned plugin must
// offer every listed built-in: a missing or renamed descriptor is a real
// regression and fails the lane loudly instead of silently narrowing the
// sweep. A failure anywhere surfaces as a pageerror or a console error,
// both of which the next assertion sees.
const newTabButton = sidebar.getByRole('button', { name: 'New tab' }).first()
for (const title of BUILTIN_TABS) {
await newTabButton.click()
const item = page.getByRole('menuitem', { name: title }).first()
await expect(item, `built-in tab "${title}" is not offered by the + menu — descriptor removed or its label changed`).toHaveCount(1)
await item.click()
// Let the activation commit (including any lazy-chunk fetch) before the
// crash assertions run.
await page.waitForTimeout(1_500)
await assertNoCrash()
}
// The editor chunk (client-editor.js) only loads when a files-window tab
// renders. Exercise the file-open path explicitly through the Files window's
// own tree: the seeded home tab ("Files") is already open with its tree
// panel pinned — activate it from the tab strip, open the seeded file, and
// require the chunk round-trip (armed before goto), so a missing/corrupt
// editor chunk fails the lane.
// Tab-strip tabs carry `draggable="true"`; the always-mounted (hidden)
// bottom panel's empty-pane welcome cards repeat the + menu labels with
// `title="Files"`, so a bare `[title="Files"]` match is ambiguous.
const filesTab = sidebar.locator('[title="Files"][draggable="true"]').first()
await expect(filesTab, 'the seeded files-window home tab must be in the tab strip').toHaveCount(1)
await filesTab.click()
// Inactive tabs stay mounted (display:none); only the ACTIVE files
// window's embedded tree is visible — match the visible row.
const fileRow = sidebar.locator(`[role="button"][title$="${SEEDED_FILE}"]:visible`)
await expect(fileRow, `the seeded "${SEEDED_FILE}" file must appear in the files window's tree`).toHaveCount(1, { timeout: 30_000 })
// Click near the row's LEFT edge: hovering reveals an @-reference button at
// the row's right end, and a center click on a narrow dock lands on it
// (referencing the file into the composer instead of opening it).
await fileRow.click({ position: { x: 8, y: 8 } })
await editorChunk
// In-place mode (editorExplorer default): the SAME tab switches to the
// file — its title is rewritten, no new tab appears. One "Files" tab
// remains: the second files window the sweep opened via the + menu.
await expect(
sidebar.locator('[title="Files"][draggable="true"]'),
'in-place mode rewrites the activated home tab instead of opening a new one',
).toHaveCount(1)
const pathInput = sidebar.locator('input[placeholder^="File path"]:visible')
await expect(pathInput, 'the files window header path input shows the opened file').toHaveValue(new RegExp(`${SEEDED_FILE}$`))
await page.waitForTimeout(1_500)
await assertNoCrash()
// The plugin's own console prefix must never appear in errors, and no
// unhandled rejection may escape the sweep.
const pluginErrors = consoleErrors.filter((text) => /dsh-better-sidebar|Unhandled/.test(text))
expect(pluginErrors, 'plugin-prefixed or unhandled console errors during the sweep').toEqual([])
expect(pageErrors, 'pageerrors during the sweep').toEqual([])
// Final screenshot: the rendered panel with a session is the lane's proof.
await page.screenshot({ path: 'test-results/mount-final.png' })
})
+286
View File
@@ -0,0 +1,286 @@
/**
* EditorHost (the files window): in merged (in-place) mode a path-less tab
* renders the empty-state hint with the tree dock open, and the header's
* tree toggle persists its flag through ctx.betterSidebar.updateTab
* (meta.treeOpen rides the tab's persisted layout). The editorExplorer pref
* controls FILE-OPEN behavior — in-place rewrites the current tab via
* updateTab, split opens a per-path dedupe tab via openSidebarFile — and in
* split mode a PATH-LESS window becomes the standalone explorer (tree panel
* only, no editor chrome); file tabs keep the full chrome in both modes.
*/
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { createElement, useEffect } from 'react'
import { createRoot } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import type { Context } from '../src/context-types.ts'
import { EditorHost } from '../src/client/EditorHost.tsx'
import { createBetterSidebarService } from '../src/client/service.ts'
import { allLeaves, createSidebarStore, type SidebarTab } from '../src/client/state.ts'
// The act() environment flag (React 18.2 reads it before flushing effects).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
/** A store with the seeded editor-home tab (default prefs: in-place mode). */
function setup(): {
store: ReturnType<typeof createSidebarStore>
ctx: Context
homeTab: () => SidebarTab
} {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
// The openTab path needs a registered editor descriptor (dedupe by path).
service.registerTab({ id: 'editor', title: 'Editor', dedupeKey: (tab) => tab.path, component: () => null })
store.setSession('editor-home-session')
const homeTab = (): SidebarTab =>
allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
.find(tab => tab.type === 'editor' && tab.path === undefined)!
// openSidebarFile reads the session cwd from ctx.sessions.
const sessionsSnapshot = { byId: { 'editor-home-session': { cwd: '/tmp' } }, current: 'editor-home-session' }
const ctx = {
betterSidebar: service,
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
} as unknown as Context
return { store, ctx, homeTab }
}
/** Mount the host for one tab; returns the container and an unmount helper. */
function mountHost(ctx: Context, store: ReturnType<typeof createSidebarStore>, tab: () => SidebarTab): {
container: HTMLDivElement
rerender: () => void
unmount: () => void
} {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
const render = (): void => {
root.render(createElement(EditorHost, {
ctx,
store,
scope: { sessionId: 'editor-home-session' },
tab: tab(),
expanded: [],
onToggleDir: () => {},
onReferenceFile: () => {},
}))
}
act(render)
return {
container,
// The real app re-renders the host with the fresh tab on every store
// change (Sidebar subscribes); mirror that after mutating the store.
rerender: () => { act(render) },
unmount: () => {
act(() => { root.unmount() })
container.remove()
},
}
}
/** Type into the controlled path input (native setter) and press Enter. */
function typeAndCommit(input: HTMLInputElement, value: string): void {
act(() => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
act(() => {
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
})
}
describe('EditorHost (files window)', () => {
it('a path-less tab renders the empty-state hint with the tree panel open', () => {
const { store, ctx, homeTab } = setup()
const { container, unmount } = mountHost(ctx, store, homeTab)
try {
const html = container.innerHTML
// The empty-state hint renders instead of the viewer loading flow.
expect(html).toContain('Pick a file from the tree panel')
expect(html).not.toContain('Loading…')
// The header carries the path input and the pressed tree toggle; the
// docked panel (search box) is open by default for path-less tabs.
expect(container.querySelector('input')).not.toBeNull()
const toggle = container.querySelector('button[aria-pressed]')
expect(toggle?.getAttribute('aria-pressed')).toBe('true')
// No cwd: the embedded tree renders its no-session placeholder
// instead of touching the network.
expect(html).toContain('Select a conversation')
} finally {
unmount()
}
})
it('the tree toggle persists meta.treeOpen through updateTab', () => {
const { store, ctx, homeTab } = setup()
expect(homeTab().meta).toEqual({ treeOpen: true })
const { container, rerender, unmount } = mountHost(ctx, store, homeTab)
try {
act(() => {
container.querySelector('button[aria-pressed]')!
.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(homeTab().meta).toEqual({ treeOpen: false })
// The store change re-renders the host with the fresh tab (Sidebar's
// subscription in the real app); the second click flips it back.
rerender()
expect(container.querySelector('button[aria-pressed]')?.getAttribute('aria-pressed')).toBe('false')
act(() => {
container.querySelector('button[aria-pressed]')!
.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(homeTab().meta).toEqual({ treeOpen: true })
} finally {
unmount()
}
})
it('in-place mode: the path input Enter switches the CURRENT tab (stable id, meta kept)', () => {
const { store, ctx, homeTab } = setup()
const { container, unmount } = mountHost(ctx, store, homeTab)
try {
const before = homeTab()
typeAndCommit(container.querySelector('input')!, '/tmp/a.ts')
// The same tab id now carries the file (homeTab's path-less finder no
// longer matches — look the tab up by id).
const after = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
.find(tab => tab.id === before.id)!
expect(after.id).toBe(before.id)
expect(after.path).toBe('/tmp/a.ts')
expect(after.title).toBe('a.ts')
expect(after.meta).toEqual({ treeOpen: true })
// No new tab landed.
expect(allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)).toHaveLength(1)
} finally {
unmount()
}
})
it('split mode: a file tab\'s path input Enter opens a NEW per-path tab; the source tab keeps its path', () => {
const { store, ctx } = setup()
store.setPrefs({ ...store.getPrefs(), editorExplorer: false })
ctx.betterSidebar.openTab({ type: 'editor', title: 'a.ts', path: '/tmp/a.ts', id: 'editor:/tmp/a.ts' })
const fileTab = (): SidebarTab =>
allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
.find(tab => tab.path === '/tmp/a.ts')!
const { container, unmount } = mountHost(ctx, store, fileTab)
try {
typeAndCommit(container.querySelector('input[placeholder^="File path"]')!, '/tmp/b.ts')
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
// home + a.ts + b.ts
expect(tabs).toHaveLength(3)
expect(fileTab().path).toBe('/tmp/a.ts')
const opened = tabs.find(tab => tab.path === '/tmp/b.ts')!
expect(opened.type).toBe('editor')
expect(opened.title).toBe('b.ts')
expect(opened.id).toBe('editor:/tmp/b.ts')
} finally {
unmount()
}
})
it('split mode: the path-less window is the standalone explorer (tree only, no chrome)', () => {
const { store, ctx, homeTab } = setup()
store.setPrefs({ ...store.getPrefs(), editorExplorer: false })
const { container, unmount } = mountHost(ctx, store, homeTab)
try {
// No editor chrome: no path input, no tree toggle, no resize handle.
expect(container.querySelector('input[placeholder^="File path"]')).toBeNull()
expect(container.querySelector('button[aria-pressed]')).toBeNull()
expect(container.querySelector('[role="separator"]')).toBeNull()
// The tree panel fills the whole window — its search box is the only
// input, and (no cwd) the tree shows its no-session placeholder.
expect(container.querySelector('input[placeholder^="Search files"]')).not.toBeNull()
expect(container.innerHTML).toContain('Select a conversation')
} finally {
unmount()
}
})
it('split mode: a file tab keeps the full chrome (path input + tree toggle + dock)', () => {
const { store, ctx } = setup()
store.setPrefs({ ...store.getPrefs(), editorExplorer: false })
ctx.betterSidebar.openTab({
type: 'editor', title: 'a.ts', path: '/tmp/a.ts', id: 'editor:/tmp/a.ts', meta: { treeOpen: true },
})
const fileTab = (): SidebarTab =>
allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
.find(tab => tab.path === '/tmp/a.ts')!
const { container, unmount } = mountHost(ctx, store, fileTab)
try {
expect(container.querySelector('input[placeholder^="File path"]')).not.toBeNull()
expect(container.querySelector('button[aria-pressed]')?.getAttribute('aria-pressed')).toBe('true')
expect(container.querySelector('[role="separator"]')).not.toBeNull()
} finally {
unmount()
}
})
it('dragging the panel edge resizes the dock and persists meta.treeWidth on release', () => {
const { store, ctx, homeTab } = setup()
const { container, unmount } = mountHost(ctx, store, homeTab)
try {
const handle = container.querySelector('[role="separator"]')!
expect(handle).not.toBeNull()
// The dock starts at the default width.
const dock = handle.parentElement!
expect(dock.style.width).toBe('240px')
// Drag the left edge LEFT by 100px → the right-docked panel widens.
// Pointer capture keeps move/up on the handle (jsdom: MouseEvent with
// pointer* type names; setPointerCapture is absent and skipped).
act(() => {
handle.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300 }))
handle.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 200 }))
})
expect(dock.style.width).toBe('340px')
// Release: the drag state clears and the width persists on the tab.
act(() => { handle.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 200 })) })
expect(homeTab().meta).toEqual({ treeOpen: true, treeWidth: 340 })
} finally {
unmount()
}
})
it('the header hosts the viewer toolbar (mode toggle / dirty dot / save)', () => {
const { store, ctx } = setup()
const service = ctx.betterSidebar
const calls: string[] = []
// A viewer with a hoisted toolbar (the TextEditor contract): register
// commands and report the state once on mount.
service.registerFileViewer({
id: 'test:fake',
exts: ['fake'],
fetchStrategy: 'none',
component: (viewerProps) => {
useEffect(() => {
viewerProps.onToolbarControls?.({
setMode: (next) => { calls.push(`mode:${next}`) },
save: () => { calls.push('save') },
})
viewerProps.onToolbarState?.({ modes: true, mode: 'preview', dirty: true, editable: true, saveState: 'idle' })
return () => { viewerProps.onToolbarControls?.(null) }
}, [])
return null
},
})
service.openTab({ type: 'editor', title: 'x.fake', path: '/tmp/x.fake', id: 'editor:/tmp/x.fake' })
const fileTab = (): SidebarTab =>
allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
.find(tab => tab.path === '/tmp/x.fake')!
const { container, unmount } = mountHost(ctx, store, fileTab)
try {
// Mode toggle + dirty dot + save button sit in the header row.
const header = container.querySelector('input')!.parentElement!
const buttons = [...header.querySelectorAll('button')]
expect(buttons.map(b => b.textContent)).toContain('Preview')
expect(buttons.map(b => b.textContent)).toContain('Edit')
expect(header.querySelector('button[aria-label="Save"]')).not.toBeNull()
expect(header.querySelector('[title="Unsaved"]')).not.toBeNull()
// The header commands reach the viewer's registered controls.
act(() => { buttons.find(b => b.textContent === 'Edit')!.click() })
act(() => { header.querySelector<HTMLButtonElement>('button[aria-label="Save"]')!.click() })
expect(calls).toEqual(['mode:edit', 'save'])
} finally {
unmount()
}
})
})
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { isImageExt } from '../src/client/image-types.ts'
import { extOf, languageKeyForExt } from '../src/client/lang.ts'
import { isPdfExt } from '../src/client/pdf-types.ts'
describe('editor language mapping', () => {
it('derives extensions from paths', () => {
expect(extOf('/a/b/main.tsx')).toBe('tsx')
expect(extOf('README.MD')).toBe('md')
expect(extOf('/a/b/.gitignore')).toBe('gitignore')
expect(extOf('noext')).toBe('')
})
it('maps common extensions to languages and falls back to plain text', () => {
expect(languageKeyForExt('tsx')).toBe('tsx')
expect(languageKeyForExt('js')).toBe('js')
expect(languageKeyForExt('py')).toBe('python')
expect(languageKeyForExt('yaml')).toBe('yaml')
expect(languageKeyForExt('sh')).toBe('shell')
expect(languageKeyForExt('md')).toBe('md')
expect(languageKeyForExt('cs')).toBe('csharp')
expect(languageKeyForExt('kt')).toBe('kotlin')
expect(languageKeyForExt('swift')).toBe('swift')
expect(languageKeyForExt('txt')).toBeNull()
expect(languageKeyForExt('log')).toBeNull()
expect(languageKeyForExt('')).toBeNull()
})
})
describe('pdf preview kind', () => {
it('routes only .pdf to the browser-native preview', () => {
expect(isPdfExt('.pdf')).toBe(true)
expect(isPdfExt('.PDF')).toBe(false)
expect(isPdfExt('.docx')).toBe(false)
expect(isPdfExt('')).toBe(false)
})
})
describe('image preview kind', () => {
it('routes supported image extensions before binary probing', () => {
expect(isImageExt('.png')).toBe(true)
expect(isImageExt('.jpg')).toBe(true)
expect(isImageExt('.svg')).toBe(true)
expect(isImageExt('.avif')).toBe(true)
expect(isImageExt('.pdf')).toBe(false)
expect(isImageExt('')).toBe(false)
})
})
+151
View File
@@ -0,0 +1,151 @@
/**
* fs-search: the host's recursive file-name search behind the editor side
* panel's search box. Matches are case-insensitive name substrings, reported
* RELATIVE to the root ('/'-separated); `.git` directories are skipped,
* symlinked directories are never descended (cycle safety), and the
* maxMatches/maxVisited budgets stop a runaway walk with `truncated: true`.
*/
import { describe, expect, it } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { searchFiles } from '../src/fs-search.ts'
/**
* Symlink creation needs extra privileges on Windows; the symlink case skips
* there rather than fails (mirror of the fs-tree symlink spec).
*/
const canSymlink = (() => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-probe-'))
try {
symlinkSync('target', join(dir, 'link'))
return true
} catch {
return false
} finally {
rmSync(dir, { recursive: true, force: true })
}
})()
/** A scratch tree: nested matches, a .git dir, and unrelated noise. */
function makeFixture(): string {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-'))
mkdirSync(join(dir, 'src'))
mkdirSync(join(dir, 'docs'))
mkdirSync(join(dir, '.git'))
mkdirSync(join(dir, '.git', 'objects'))
writeFileSync(join(dir, 'README.md'), 'readme')
writeFileSync(join(dir, 'src', 'Index.TS'), 'code')
writeFileSync(join(dir, 'src', 'util.ts'), 'code')
writeFileSync(join(dir, 'docs', 'guide.md'), 'doc')
writeFileSync(join(dir, '.git', 'config'), 'git-internal')
writeFileSync(join(dir, '.git', 'objects', 'readme-pack'), 'git-internal')
return dir
}
describe('fs-search', () => {
it('matches name substrings and reports root-relative /-separated paths', async () => {
const dir = makeFixture()
try {
const result = await searchFiles(dir, 'util')
expect(result).toEqual({ matches: ['src/util.ts'], truncated: false })
// A multi-level match list is sorted and relative (never absolute).
const md = await searchFiles(dir, '.md')
expect(md.truncated).toBe(false)
expect(md.matches).toEqual(['README.md', 'docs/guide.md'])
for (const match of md.matches) {
expect(match.startsWith(dir)).toBe(false)
expect(match).not.toContain('\\')
}
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('matches case-insensitively on the entry name', async () => {
const dir = makeFixture()
try {
expect((await searchFiles(dir, 'index.ts')).matches).toEqual(['src/Index.TS'])
expect((await searchFiles(dir, 'INDEX.TS')).matches).toEqual(['src/Index.TS'])
// Directory names match too (the client can hint where matches live).
expect((await searchFiles(dir, 'SRC')).matches).toEqual(['src'])
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('never descends into .git directories', async () => {
const dir = makeFixture()
try {
// 'readme' would hit .git/objects/readme-pack if the walk entered .git.
expect((await searchFiles(dir, 'readme')).matches).toEqual(['README.md'])
expect((await searchFiles(dir, 'config')).matches).toEqual([])
expect((await searchFiles(dir, '.git')).matches).toEqual([])
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('an empty (or whitespace) query matches nothing without walking', async () => {
const dir = makeFixture()
try {
expect(await searchFiles(dir, '')).toEqual({ matches: [], truncated: false })
expect(await searchFiles(dir, ' ')).toEqual({ matches: [], truncated: false })
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it.skipIf(!canSymlink)('does not descend into symlinked directories (cycle safety)', async () => {
const dir = makeFixture()
try {
// A link back to the root would loop forever if descended; a link to
// src would duplicate its matches. Neither must be entered.
symlinkSync(dir, join(dir, 'loop'))
symlinkSync(join(dir, 'src'), join(dir, 'src-link'))
const result = await searchFiles(dir, 'util')
expect(result).toEqual({ matches: ['src/util.ts'], truncated: false })
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('stops with truncated: true when the match budget is exceeded', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-cap-'))
try {
for (let index = 0; index < 5; index += 1) {
writeFileSync(join(dir, `match-${index}.txt`), 'x')
}
const result = await searchFiles(dir, 'match', { maxMatches: 2 })
expect(result.truncated).toBe(true)
expect(result.matches.length).toBe(2)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('stops with truncated: true when the visited budget is exceeded', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-visited-'))
try {
for (let index = 0; index < 5; index += 1) {
writeFileSync(join(dir, `file-${index}.txt`), 'x')
}
// The walk visits more entries than the budget allows and gives up.
const result = await searchFiles(dir, 'nomatch', { maxVisited: 3 })
expect(result.truncated).toBe(true)
expect(result.matches).toEqual([])
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('an unreadable root yields no matches instead of throwing', async () => {
const dir = makeFixture()
try {
const missing = join(dir, 'does-not-exist')
expect(await searchFiles(missing, 'x')).toEqual({ matches: [], truncated: false })
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})
+113
View File
@@ -0,0 +1,113 @@
/**
* fs-tree symlink listing: the explorer must show a symlink as what it
* points at (a symlink to a directory expands like a directory) and flag
* links whose target is missing. The host listing keeps the probe cheap:
* only entries that are actually symlinks are stat'ed.
*/
import { describe, expect, it } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { listDirectory } from '../src/fs-tree.ts'
/**
* Symlink creation needs extra privileges on Windows; skip the suite there
* rather than fail (mirror of the smoke spec's platform-tolerant fixtures).
*/
const canSymlink = (() => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-symlink-probe-'))
try {
symlinkSync('target', join(dir, 'link'))
return true
} catch {
return false
} finally {
rmSync(dir, { recursive: true, force: true })
}
})()
/** A scratch level with real entries plus three symlinks (dir/file/dangling). */
function makeFixture(): string {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-symlink-'))
mkdirSync(join(dir, 'real-dir'))
writeFileSync(join(dir, 'real-file.txt'), 'content')
symlinkSync(join(dir, 'real-dir'), join(dir, 'link-to-dir'))
symlinkSync(join(dir, 'real-file.txt'), join(dir, 'link-to-file'))
symlinkSync(join(dir, 'missing-target'), join(dir, 'broken-link'))
return dir
}
describe.skipIf(!canSymlink)('fs-tree symlink listing', () => {
it('reports a symlink to a directory as an expandable directory', async () => {
const dir = makeFixture()
try {
const listing = await listDirectory(dir)
const row = listing.entries.find(entry => entry.name === 'link-to-dir')
expect(row).toMatchObject({ isDir: true, isSymlink: true, broken: false })
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('reports a symlink to a file as a file with a link badge', async () => {
const dir = makeFixture()
try {
const listing = await listDirectory(dir)
const row = listing.entries.find(entry => entry.name === 'link-to-file')
expect(row).toMatchObject({ isDir: false, isSymlink: true, broken: false })
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('flags a dangling symlink as broken', async () => {
const dir = makeFixture()
try {
const listing = await listDirectory(dir)
const row = listing.entries.find(entry => entry.name === 'broken-link')
expect(row).toMatchObject({ isDir: false, isSymlink: true, broken: true })
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('leaves regular entries unmarked and sorts symlinked dirs with the directories', async () => {
const dir = makeFixture()
try {
const listing = await listDirectory(dir)
const realDir = listing.entries.find(entry => entry.name === 'real-dir')
const realFile = listing.entries.find(entry => entry.name === 'real-file.txt')
expect(realDir).toMatchObject({ isDir: true, isSymlink: false, broken: false })
expect(realFile).toMatchObject({ isDir: false, isSymlink: false, broken: false })
// Directory-first ordering counts a symlinked directory as a directory.
const dirs = listing.entries.filter(entry => entry.isDir).map(entry => entry.name)
expect(dirs).toEqual(['link-to-dir', 'real-dir'])
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('classifies every row correctly across a symlink-heavy level (bounded probe)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-symlink-many-'))
mkdirSync(join(dir, 'real-dir'))
writeFileSync(join(dir, 'real-file.txt'), 'content')
try {
// More links than the probe concurrency cap exercises the worker pool;
// every directory link must still classify as a directory and every
// file link as a file (order is already checked by the sort assertion).
for (let index = 0; index < 48; index += 1) {
symlinkSync(join(dir, 'real-dir'), join(dir, `dir-link-${index}`))
symlinkSync(join(dir, 'real-file.txt'), join(dir, `file-link-${index}`))
}
const listing = await listDirectory(dir)
for (let index = 0; index < 48; index += 1) {
expect(listing.entries.find(entry => entry.name === `dir-link-${index}`))
.toMatchObject({ isDir: true, isSymlink: true, broken: false })
expect(listing.entries.find(entry => entry.name === `file-link-${index}`))
.toMatchObject({ isDir: false, isSymlink: true, broken: false })
}
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import { resolve } from 'node:path'
import { compareEntries, isWithin, parentOf, requireAbsolute, rootLabel } from '../src/fs-tree.ts'
import { isWin32 } from './platform.ts'
describe('fs-tree', () => {
it('sorts directories first, then names case-insensitively', () => {
const rows = [
{ name: 'b.txt', path: '/x/b.txt', isDir: false, hidden: false, isSymlink: false, broken: false },
{ name: 'A', path: '/x/A', isDir: true, hidden: false, isSymlink: false, broken: false },
{ name: 'a.txt', path: '/x/a.txt', isDir: false, hidden: false, isSymlink: false, broken: false },
{ name: '.hidden', path: '/x/.hidden', isDir: false, hidden: true, isSymlink: false, broken: false },
]
expect(rows.sort(compareEntries).map(row => row.name)).toEqual(['A', '.hidden', 'a.txt', 'b.txt'])
})
it('derives root labels and parents (POSIX-style)', () => {
// POSIX-style inputs behave identically on both platforms (win32 parses '/'
// as a separator), so these assertions are platform-independent.
expect(rootLabel('/Users/me/code')).toBe('code')
expect(rootLabel('/')).toBe('/')
expect(parentOf('/Users/me/code')).toBe('/Users/me')
expect(parentOf('/')).toBeUndefined()
})
it.skipIf(!isWin32)('derives root labels and parents for Windows drives', () => {
expect(rootLabel('C:\\')).toBe('C:\\')
expect(parentOf('C:\\')).toBeUndefined()
expect(rootLabel('C:\\Users\\me')).toBe('me')
expect(parentOf('C:\\Users\\me')).toBe('C:\\Users')
})
it('accepts POSIX absolute paths and rejects relative ones', () => {
// resolve() is platform-native: '/a/b' roots to the current drive on win32.
expect(requireAbsolute('/a/b')).toBe(resolve('/a/b'))
expect(() => requireAbsolute('a/b')).toThrow(/not an absolute path/)
expect(() => requireAbsolute('../a')).toThrow(/not an absolute path/)
})
// Windows-only path semantics — skipped (not silently passing) on POSIX:
// drive letters and UNC shares are absolute on win32, drive-relative 'C:foo'
// is not.
describe.skipIf(!isWin32)('win32 path semantics', () => {
it('accepts drive letters and normalizes their separators', () => {
expect(requireAbsolute('C:/proj')).toBe('C:\\proj')
expect(requireAbsolute('C:\\proj')).toBe('C:\\proj')
// A drive path resolves against its own drive, never a bare root.
expect(resolve('/a/b')).toMatch(/^[A-Za-z]:/)
})
it('accepts UNC network shares in both separator styles', () => {
expect(requireAbsolute('\\\\server\\share\\proj')).toBe('\\\\server\\share\\proj')
expect(requireAbsolute('//server/share/proj')).toBe('\\\\server\\share\\proj')
})
it('rejects drive-relative paths', () => {
expect(() => requireAbsolute('C:proj')).toThrow(/not an absolute path/)
})
})
// POSIX-only path semantics — the reverse branch of the win32 suite: forms
// the host can never access must be refused loudly instead of mangled.
describe.skipIf(isWin32)('POSIX path semantics', () => {
it('rejects Windows drive paths', () => {
expect(() => requireAbsolute('C:/proj')).toThrow(/not an absolute path/)
expect(() => requireAbsolute('C:\\proj')).toThrow(/not an absolute path/)
})
it('rejects backslash UNC paths (not absolute on POSIX)', () => {
expect(() => requireAbsolute('\\\\server\\share\\proj')).toThrow(/not an absolute path/)
})
})
it('isWithin tolerates separators and (on win32) letter case', () => {
expect(isWithin('/work/proj', '/work/proj/src/a.ts')).toBe(true)
expect(isWithin('/work/proj', '/work/proj')).toBe(true)
expect(isWithin('/work/proj', '/work/proj2/a.ts')).toBe(false)
expect(isWithin('/work/proj', '/other/a.ts')).toBe(false)
// Mixed separators normalize on every platform.
expect(isWithin('C:\\Users\\me', 'C:/Users/me/src/a.ts')).toBe(true)
// Case sensitivity follows the platform's filesystem semantics (the
// platform parameter makes both branches assertable on any host).
expect(isWithin('C:\\Users\\Me', 'c:/users/me/file.png', 'win32')).toBe(true)
expect(isWithin('/Users/Me', '/users/me/file.png', 'win32')).toBe(true)
expect(isWithin('/Users/Me', '/users/me/file.png', 'linux')).toBe(false)
expect(isWithin('/Users/Me', '/users/me/file.png', 'darwin')).toBe(false)
// Windows drive-root containment.
expect(isWithin('C:\\', 'C:\\Users\\me\\a.png', 'win32')).toBe(true)
expect(isWithin('c:\\users', 'C:/USERS/me/b.png', 'win32')).toBe(true)
// UNC network-share containment: the '//' share prefix must not defeat
// the prefix test, and a sibling share must stay outside. The platform
// parameter is injected, so these win32-semantics assertions run on
// every host without any platform guard.
expect(isWithin('\\\\server\\share\\proj', '\\\\server\\share\\proj\\src\\a.ts', 'win32')).toBe(true)
expect(isWithin('\\\\server\\share\\proj', '\\\\server\\share\\proj2\\a.ts', 'win32')).toBe(false)
expect(isWithin('\\\\server\\share\\proj', '\\\\other\\share\\a.ts', 'win32')).toBe(false)
})
})
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest'
import { parseUnifiedDiff } from '../src/client/DiffView.tsx'
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
describe('git parsing', () => {
it('parses porcelain -z entries including renames', () => {
const output = ['M src/a.ts', ' M src/b.ts', '?? src/c.ts', 'R src/new.ts', 'src/old.ts', ''].join('\0')
const entries = parsePorcelainZ(output)
expect(entries).toEqual([
{ path: 'src/a.ts', xy: 'M ' },
{ path: 'src/b.ts', xy: ' M' },
{ path: 'src/c.ts', xy: '??' },
{ path: 'src/new.ts', xy: 'R ' },
])
})
it('parses log rows with unit separators (full hash + refs)', () => {
const rows = parseLogLines(
'abc1234\x1fFirst subject\x1fAlice\x1f2024-01-01 10:00:00 +0800\x1fabc1234def5678abc1234def5678abc1234def5678\x1fHEAD -> main, origin/main\n'
+ 'def5678\x1fSecond subject\x1fBob\x1f2024-01-02 10:00:00 +0800\x1fdef5678abc1234def5678abc1234def5678abc1234\x1f\n',
)
expect(rows).toEqual([
{
hash: 'abc1234',
subject: 'First subject',
author: 'Alice',
date: '2024-01-01 10:00:00 +0800',
hashFull: 'abc1234def5678abc1234def5678abc1234def5678',
refs: 'HEAD -> main, origin/main',
},
{
hash: 'def5678',
subject: 'Second subject',
author: 'Bob',
date: '2024-01-02 10:00:00 +0800',
hashFull: 'def5678abc1234def5678abc1234def5678abc1234',
refs: '',
},
])
})
it('parses a multi-file unified diff with aligned line numbers', () => {
const diff = [
'diff --git a/src/a.ts b/src/a.ts',
'index 1234567..89abcde 100644',
'--- a/src/a.ts',
'+++ b/src/a.ts',
'@@ -1,4 +1,5 @@ section with @@ inside',
' line1',
'-line2',
'+line2b',
' context',
'+trailing',
'diff --git a/README.md b/README.md',
'new file mode 100644',
'index 0000000..1234567',
'--- /dev/null',
'+++ b/README.md',
'@@ -0,0 +1,2 @@',
'+hello',
'+world',
'',
].join('\n')
const parsed = parseUnifiedDiff(diff)
expect(parsed.files).toHaveLength(2)
const first = parsed.files[0]!
expect(first.oldPath).toBe('a/src/a.ts')
expect(first.newPath).toBe('b/src/a.ts')
expect(first.binary).toBe(false)
expect(first.hunks).toHaveLength(1)
expect(first.hunks[0]!.oldStart).toBe(1)
expect(first.hunks[0]!.newStart).toBe(1)
expect(first.hunks[0]!.header).toBe(' section with @@ inside')
expect(first.hunks[0]!.lines).toEqual([
{ kind: 'ctx', text: 'line1', oldNum: 1, newNum: 1 },
{ kind: 'del', text: 'line2', oldNum: 2, newNum: null },
{ kind: 'add', text: 'line2b', oldNum: null, newNum: 2 },
{ kind: 'ctx', text: 'context', oldNum: 3, newNum: 3 },
{ kind: 'add', text: 'trailing', oldNum: null, newNum: 4 },
])
const second = parsed.files[1]!
expect(second.oldPath).toBe('/dev/null')
expect(second.hunks[0]!.lines[0]).toEqual({ kind: 'add', text: 'hello', oldNum: null, newNum: 1 })
expect(second.hunks[0]!.lines[1]).toEqual({ kind: 'add', text: 'world', oldNum: null, newNum: 2 })
})
it('parses binary, deletion and no-newline markers', () => {
const diff = [
'diff --git a/img.png b/img.png',
'index 111..222 100644',
'Binary files a/img.png and b/img.png differ',
'diff --git a/gone.ts b/gone.ts',
'deleted file mode 100644',
'--- a/gone.ts',
'+++ /dev/null',
'@@ -1,2 +0,0 @@',
'-one',
'-two',
'\\ No newline at end of file',
'',
].join('\n')
const parsed = parseUnifiedDiff(diff)
expect(parsed.files).toHaveLength(2)
expect(parsed.files[0]!.binary).toBe(true)
expect(parsed.files[0]!.hunks).toHaveLength(0)
const gone = parsed.files[1]!
expect(gone.newPath).toBe('/dev/null')
expect(gone.hunks[0]!.lines).toEqual([
{ kind: 'del', text: 'one', oldNum: 1, newNum: null },
{ kind: 'del', text: 'two', oldNum: 2, newNum: null },
{ kind: 'meta', text: ' No newline at end of file', oldNum: null, newNum: null },
])
})
it('keeps mode/rename-only sections hunkless', () => {
const parsed = parseUnifiedDiff([
'diff --git a/run.sh b/run.sh',
'old mode 100644',
'new mode 100755',
'diff --git a/old.ts b/new.ts',
'similarity index 90%',
'rename from old.ts',
'rename to new.ts',
'',
].join('\n'))
expect(parsed.files).toHaveLength(2)
expect(parsed.files[0]!.oldPath).toBe('')
expect(parsed.files[0]!.hunks).toHaveLength(0)
expect(parsed.files[1]!.hunks).toHaveLength(0)
})
it('parses an empty or junk diff into no files', () => {
expect(parseUnifiedDiff('').files).toEqual([])
expect(parseUnifiedDiff('no diff here\n').files).toEqual([])
})
})
+55 -3
View File
@@ -8,6 +8,7 @@
import { describe, expect, it } from 'vitest'
import { resolve } from 'node:path'
import { decodeHtmlUrl, encodeHtmlUrl } from '../src/html-route.ts'
import { isWin32 } from './platform.ts'
describe('encodeHtmlUrl', () => {
it('encodes a POSIX absolute path into route segments', () => {
@@ -20,6 +21,21 @@ describe('encodeHtmlUrl', () => {
.toBe('/sidebar/html/sess-1/C%3A/Users/me/a.html')
})
it('encodes a UNC path with the // marker (backslash and forward-slash forms)', () => {
expect(encodeHtmlUrl('sess-1', '\\\\server\\share\\proj\\a.html'))
.toBe('/sidebar/html/sess-1//server/share/proj/a.html')
expect(encodeHtmlUrl('sess-1', '//server/share/proj/a.html'))
.toBe('/sidebar/html/sess-1//server/share/proj/a.html')
})
it('keeps a POSIX // path marker-encoded (the marker is platform-neutral)', () => {
// The decoder rebuilds '//server/share/...', which node:path resolves to
// '/server/share/...' on POSIX and '\\server\share\...' on win32 — so the
// leading double slash round-trips without any platform signal.
expect(encodeHtmlUrl('s-1', '//server/share/a.html'))
.toBe('/sidebar/html/s-1//server/share/a.html')
})
it('percent-encodes special characters in segments', () => {
expect(encodeHtmlUrl('s-1', '/a b/中文/100%.html'))
.toBe('/sidebar/html/s-1/a%20b/%E4%B8%AD%E6%96%87/100%25.html')
@@ -45,13 +61,37 @@ describe('decodeHtmlUrl', () => {
ok: true,
ref: { sessionId: 'sess-1', path: 'C:/Users/me/a.html' },
})
})
it.skipIf(!isWin32)('decoded drive paths resolve back verbatim on win32', () => {
// The host runs requireAbsolute() over the decoded path: with the old
// '/C:/...' form node's resolve() produced 'C:\C:\Users\...' (drive
// doubled) on Windows and the isWithin(cwd) fence rejected every drive
// path. The slash-free form must resolve back to the original path.
if (process.platform === 'win32') {
expect(resolve('C:/Users/me/a.html')).toBe('C:\\Users\\me\\a.html')
}
expect(resolve('C:/Users/me/a.html')).toBe('C:\\Users\\me\\a.html')
})
it('decodes a UNC round-trip back to the platform-neutral forward-slash form', () => {
const url = encodeHtmlUrl('sess-1', '\\\\server\\share\\proj\\a.html')
expect(decodeHtmlUrl(url)).toEqual({
ok: true,
ref: { sessionId: 'sess-1', path: '//server/share/proj/a.html' },
})
// The host's requireAbsolute resolves that form per-platform:
// '\\server\share\proj\a.html' on win32, '/server/share/proj/a.html' on POSIX.
})
it('round-trips a POSIX // path through the same marker', () => {
const url = encodeHtmlUrl('s-1', '//server/share/a.html')
expect(decodeHtmlUrl(url)).toEqual({
ok: true,
ref: { sessionId: 's-1', path: '//server/share/a.html' },
})
})
it('refuses a marker-only UNC URL and stray double slashes (400)', () => {
expect(decodeHtmlUrl('/sidebar/html/s//').ok).toBe(false)
expect(decodeHtmlUrl('/sidebar/html/s//server//x.html').ok).toBe(false)
})
it('decodes a lowercase-drive Windows path without a leading slash', () => {
@@ -122,4 +162,16 @@ describe('relative asset resolution stays in-route', () => {
expect(new URL('img/x.png', doc).pathname).toBe('/sidebar/html/s/a/b/img/x.png')
expect(new URL('../c.css', doc).pathname).toBe('/sidebar/html/s/a/c.css')
})
it('relative assets of a UNC document stay inside the same route', () => {
// The WHATWG URL preserves the '//' marker during relative resolution,
// so ./style.css lands back on the route with the UNC prefix intact.
const doc = `http://h${encodeHtmlUrl('s', '\\\\server\\share\\proj\\index.html')}`
expect(new URL('./style.css', doc).pathname)
.toBe('/sidebar/html/s//server/share/proj/style.css')
expect(decodeHtmlUrl(new URL('./style.css', doc).pathname)).toEqual({
ok: true,
ref: { sessionId: 's', path: '//server/share/proj/style.css' },
})
})
})
+68 -3
View File
@@ -1,10 +1,12 @@
// @vitest-environment jsdom
/**
* External-link interception policy tests: the pure decision (which http(s)
* external links are taken over into the sidebar browser) and the click
* shape (plain left clicks only — modified clicks always bypass).
* external links are taken over into the sidebar browser), the click shape
* (plain left clicks only — modified clicks always bypass), and the
* registered click capture (the caller's `takeoverEnabled(url)` gate).
*/
import { describe, expect, it } from 'vitest'
import { isPlainLeftClick, shouldInterceptLink } from '../src/client/link-intercept.ts'
import { isPlainLeftClick, registerLinkInterception, shouldInterceptLink } from '../src/client/link-intercept.ts'
const SELF = 'http://127.0.0.1:3080'
@@ -55,3 +57,66 @@ describe('isPlainLeftClick', () => {
expect(isPlainLeftClick({ ...plain, button: 2 })).toBe(false)
})
})
describe('registerLinkInterception', () => {
/** Dispatch one click on an anchor with the given absolute href. */
const clickAnchor = (href: string, init: MouseEventInit = {}): void => {
const anchor = document.createElement('a')
anchor.href = href
document.body.appendChild(anchor)
anchor.dispatchEvent(new MouseEvent('click', {
bubbles: true,
cancelable: true,
button: 0,
...init,
}))
anchor.remove()
}
it('passes the parsed URL to takeoverEnabled and only takes over when it returns true', () => {
const seen: string[] = []
const opened: string[] = []
const dispose = registerLinkInterception({
takeoverEnabled: (url) => {
seen.push(url.protocol)
return url.protocol === 'http:'
},
openInSidebar: (url) => { opened.push(url) },
selfOrigin: SELF,
})
// https → the gate refuses (the sidebar-takeover defaults: https off).
clickAnchor('https://example.com/page')
// http → the gate passes, the click is taken over with the absolute href.
clickAnchor('http://example.com/page')
expect(seen).toEqual(['https:', 'http:'])
expect(opened).toEqual(['http://example.com/page'])
dispose()
})
it('never consults the gate for same-origin or non-http(s) links', () => {
let gateCalls = 0
const dispose = registerLinkInterception({
takeoverEnabled: () => { gateCalls++; return true },
openInSidebar: () => { throw new Error('must not open') },
selfOrigin: SELF,
})
clickAnchor(`${SELF}/settings`)
clickAnchor('mailto:a@b.c')
expect(gateCalls).toBe(0)
dispose()
})
it('bypasses the takeover on modified clicks even when the gate passes', () => {
let gateCalls = 0
const dispose = registerLinkInterception({
takeoverEnabled: () => { gateCalls++; return true },
openInSidebar: () => { throw new Error('must not open') },
selfOrigin: SELF,
})
clickAnchor('http://example.com/', { ctrlKey: true })
clickAnchor('http://example.com/', { metaKey: true })
clickAnchor('http://example.com/', { button: 1 })
expect(gateCalls).toBe(0)
dispose()
})
})
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest'
import { registerOpenPathInterception } from '../src/client/intercept.tsx'
import { wrapOpenPath, type OpenPathInterceptDeps, type OpenPathService } from '../src/client/openpath-intercept.ts'
import { createSidebarStore } from '../src/client/state.ts'
import type { Context } from '../src/context-types.ts'
describe('open-path interception', () => {
/** A minimal fake of the workspaces.openPath service method. */
const service = (): OpenPathService & { calls: string[]; opened: string[] } => {
const fake = {
calls: [] as string[],
opened: [] as string[],
async openPath(path: string): Promise<void> {
this.calls.push(path)
this.opened.push(path)
},
}
return fake
}
const deps = (overrides: Partial<OpenPathInterceptDeps> = {}): OpenPathInterceptDeps & {
sidebar: string[]
} => {
const sidebar: string[] = []
return {
sidebar,
takeoverEnabled: () => true,
currentSessionId: () => 's1',
openInSidebar: (path, sessionId) => { sidebar.push(`${sessionId}:${path}`) },
...overrides,
}
}
it('routes an intercepted open into the sidebar and resolves without the original call', async () => {
const ws = service()
const d = deps()
const restore = wrapOpenPath(ws, d)
await expect(ws.openPath('/abs/a.ts')).resolves.toBeUndefined()
expect(ws.calls).toEqual([])
expect(d.sidebar).toEqual(['s1:/abs/a.ts'])
restore()
})
it('falls through to the original when the takeover is disabled', async () => {
const ws = service()
const d = deps({ takeoverEnabled: () => false })
const restore = wrapOpenPath(ws, d)
await ws.openPath('/abs/a.ts')
expect(ws.opened).toEqual(['/abs/a.ts'])
expect(d.sidebar).toEqual([])
restore()
})
it('falls through when no session is current (nothing to scope the editor load to)', async () => {
const ws = service()
const d = deps({ currentSessionId: () => undefined })
const restore = wrapOpenPath(ws, d)
await ws.openPath('/abs/a.ts')
expect(ws.opened).toEqual(['/abs/a.ts'])
expect(d.sidebar).toEqual([])
restore()
})
it('passes the current session into the sidebar opener', async () => {
const ws = service()
let current = 's1'
const d = deps({ currentSessionId: () => current })
const restore = wrapOpenPath(ws, d)
await ws.openPath('/abs/a.ts')
current = 's2'
await ws.openPath('/abs/b.ts')
expect(d.sidebar).toEqual(['s1:/abs/a.ts', 's2:/abs/b.ts'])
restore()
})
it('restores the original method on dispose (HMR-safe)', async () => {
const ws = service()
const d = deps()
const original = ws.openPath
const restore = wrapOpenPath(ws, d)
expect(ws.openPath).not.toBe(original)
restore()
expect(ws.openPath).toBe(original)
await ws.openPath('/abs/a.ts')
expect(ws.opened).toEqual(['/abs/a.ts'])
})
it('treats a rejected promise like the original would (no swallowing)', async () => {
const failing: OpenPathService = {
async openPath() { throw new Error('host refused') },
}
const d = deps({ takeoverEnabled: () => false })
const restore = wrapOpenPath(failing, d)
await expect(failing.openPath('/abs/a.ts')).rejects.toThrow('host refused')
restore()
})
})
describe('open-path interception wiring', () => {
it('registerOpenPathInterception routes chat opens into the editor tab and restores on dispose', async () => {
// A realistic client-context fake: the sessions list feed (current + cwd),
// the workspaces funnel, and the sidebar service the editor goes through.
const opened: Array<Record<string, unknown>> = []
const funnel = { openPath: async (): Promise<void> => {} }
const ctx = {
sessions: {
list: { getSnapshot: () => ({ current: 's1', byId: { s1: { cwd: '/w' } } }) },
},
workspaces: funnel,
betterSidebar: { openTab: (seed: unknown) => { opened.push(seed as Record<string, unknown>) } },
} as unknown as Context
const store = createSidebarStore()
const original = ctx.workspaces.openPath
const restore = registerOpenPathInterception(ctx, store)
// Default prefs: the takeover routes the open into the sidebar editor
// with the session-scoped absolute path (chat already resolved it).
await ctx.workspaces.openPath('/w/src/a.ts')
expect(opened).toEqual([{
type: 'editor',
title: 'a.ts',
path: '/w/src/a.ts',
id: 'editor:/w/src/a.ts',
}])
// The interceptOpenPath pref off → the original funnel runs untouched.
store.setPrefs({ ...store.getPrefs(), interceptOpenPath: false })
const calls: string[] = []
ctx.workspaces.openPath = async (path: string) => { calls.push(path) }
await ctx.workspaces.openPath('/w/src/b.ts')
expect(calls).toEqual(['/w/src/b.ts'])
expect(opened).toHaveLength(1)
// The editor tab disabled → falls through too (an editor that cannot
// open must not swallow opens).
store.setPrefs({ ...store.getPrefs(), interceptOpenPath: true, tabsEnabled: { editor: false } })
await ctx.workspaces.openPath('/w/src/c.ts')
expect(calls).toEqual(['/w/src/b.ts', '/w/src/c.ts'])
// Disposal restores the raw original method (HMR-safe).
restore()
expect(ctx.workspaces.openPath).toBe(original)
})
})
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import { isAbsolutePath, relativeTo } from '../src/client/paths.ts'
import { resolveSidebarPath } from '../src/client/produced-files.ts'
import { htmlUrl } from '../src/client/api.ts'
describe('path helpers', () => {
it('derives relative paths under the cwd (and "." for the cwd itself)', () => {
expect(relativeTo('/Users/me/code', '/Users/me/code/src/main.ts')).toBe('src/main.ts')
expect(relativeTo('/Users/me/code', '/Users/me/code')).toBe('.')
expect(relativeTo('/Users/me/code/', '/Users/me/code/src/a/b.ts')).toBe('src/a/b.ts')
})
it('falls back to the path unchanged when it lies outside the cwd', () => {
expect(relativeTo('/Users/me/code', '/Users/other/x.ts')).toBe('/Users/other/x.ts')
expect(relativeTo('/Users/me/code', '/Users/me/codex/y.ts')).toBe('/Users/me/codex/y.ts')
})
it('handles windows roots and mixed separators', () => {
expect(relativeTo('C:\\Users\\me', 'C:\\Users\\me\\src\\a.ts')).toBe('src/a.ts')
expect(relativeTo('C:\\Users\\me', 'C:/Users/me/src/a.ts')).toBe('src/a.ts')
expect(relativeTo('C:\\Users\\me\\', 'C:\\Users\\me')).toBe('.')
})
it('containment is case-insensitive (windows/macOS case-insensitive volumes)', () => {
expect(relativeTo('C:\\Users\\Me', 'c:/users/me/src/a.ts')).toBe('src/a.ts')
expect(relativeTo('/Users/Me/code', '/users/me/code/src/main.ts')).toBe('src/main.ts')
// The returned relative text keeps the caller's own casing.
expect(relativeTo('C:\\Users\\me', 'C:\\Users\\Me\\SRC\\a.ts')).toBe('SRC/a.ts')
})
it('resolves produced paths against windows cwds', () => {
expect(resolveSidebarPath('C:\\work\\proj', 'src/a.ts')).toBe('C:\\work\\proj\\src/a.ts')
expect(resolveSidebarPath('C:\\work\\proj', 'C:\\abs\\x.ts')).toBe('C:\\abs\\x.ts')
expect(resolveSidebarPath('C:\\work\\proj\\', 'C:\\abs\\x.ts')).toBe('C:\\abs\\x.ts')
})
it('keeps UNC produced paths absolute instead of joining them onto the cwd', () => {
// Pure client function: UNC detection is platform-independent, so these
// assertions run on every host without a platform guard.
expect(resolveSidebarPath('C:\\work\\proj', '\\\\server\\share\\abs\\x.ts'))
.toBe('\\\\server\\share\\abs\\x.ts')
expect(resolveSidebarPath('C:\\work\\proj', '//server/share/abs/x.ts'))
.toBe('//server/share/abs/x.ts')
// A relative path under a UNC cwd joins with backslashes.
expect(resolveSidebarPath('\\\\server\\share\\proj', 'src/a.ts'))
.toBe('\\\\server\\share\\proj\\src/a.ts')
})
it('mirrors the host absolute-path notion without node:path', () => {
expect(isAbsolutePath('/abs/x.ts')).toBe(true)
expect(isAbsolutePath('C:\\abs\\x.ts')).toBe(true)
expect(isAbsolutePath('C:/abs/x.ts')).toBe(true)
expect(isAbsolutePath('\\\\server\\share\\x.ts')).toBe(true)
expect(isAbsolutePath('//server/share/x.ts')).toBe(true)
expect(isAbsolutePath('C:relative.ts')).toBe(false)
expect(isAbsolutePath('rel/x.ts')).toBe(false)
})
it('htmlUrl always marks UNC paths (platform-neutral marker)', () => {
// The marker is platform-neutral now: the host resolves the decoded
// '//server/share/...' form per-platform, so no cwd/OS signal is needed.
expect(htmlUrl({ sessionId: 's' }, '\\\\server\\share\\proj\\a.html'))
.toBe('/sidebar/html/s//server/share/proj/a.html')
expect(htmlUrl({ sessionId: 's', cwd: '/home/me' }, '//server/share/a.html'))
.toBe('/sidebar/html/s//server/share/a.html')
expect(htmlUrl({ sessionId: 's', cwd: '/home/me' }, '/home/me/index.html'))
.toBe('/sidebar/html/s/home/me/index.html')
})
})
+10
View File
@@ -0,0 +1,10 @@
/**
* Shared platform guards for tests. Use vitest's `describe.skipIf` /
* `it.skipIf` with these so platform-specific cases are REPORTED as skipped
* on other hosts instead of silently passing through an inline `if` — and
* so the opposite branch (e.g. POSIX rejecting drive paths) is always
* asserted on the matching host.
*/
/** True when running on Windows (drive letters / UNC path semantics apply). */
export const isWin32 = process.platform === 'win32'
+16 -3
View File
@@ -34,6 +34,13 @@ describe('dsh-better-sidebar plugin export shape', () => {
expect(resolved.listLimit).toBe(1000)
expect(resolved.terminalsPerSession).toBe(3)
expect(resolved.reconnectGraceMs).toBe(30_000)
// The terminal shell config defaults to auto-resolution (empty shell =
// the platform chain in defaultShell()).
expect(resolved.shell).toBe('')
const configured = (schema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown>
})({ shell: 'pwsh.exe' })
expect(configured.shell).toBe('pwsh.exe')
})
it('registers the side card preferences schema with the documented defaults', async () => {
@@ -42,8 +49,8 @@ describe('dsh-better-sidebar plugin export shape', () => {
const resolved = (PrefsSchema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown>
})(undefined)
expect(resolved.openByDefault).toBe(true)
expect(resolved.defaultWidthPercent).toBe(30)
expect(resolved.openByDefault).toBe(false)
expect(resolved.defaultWidthPercent).toBe(35)
expect(resolved.autoOpenSubagent).toBe(true)
// A new background job auto-opens the Jobs page too.
expect(resolved.autoOpenJobs).toBe(true)
@@ -54,15 +61,21 @@ 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.
expect(resolved.titleBarCompat).toBe(false)
expect(resolved.titleBarStripPx).toBe(40)
// The enable-switch maps resolve to {} (everything on) for old documents.
expect(resolved.tabsEnabled).toEqual({})
expect(resolved.viewersEnabled).toEqual({})
// The merged editor-explorer mode defaults ON.
expect(resolved.editorExplorer).toBe(true)
// A stored overridden value resolves through (the range contract is
// enforced by the settings service on write); the new pref keeps its
// default when the stored document predates it.
const overridden = (PrefsSchema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown>
})({ openByDefault: false, defaultWidthPercent: 45 })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
})
})
+297
View File
@@ -0,0 +1,297 @@
import { describe, expect, it } from 'vitest'
import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
import { allLeaves, createSidebarStore, defaultWidthFor, makeDefaultState } from '../src/client/state.ts'
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
describe('side card preferences', () => {
/** A fake settings wire face whose settingsGet resolves to one raw value. */
const wire = (value: unknown): SidebarSettingsClient => ({
settingsGet: async () => ({ value, revision: 1 }),
settingsUpdate: async () => ({ value, revision: 2 }),
})
const rejecting = (): SidebarSettingsClient => ({
settingsGet: async () => { throw new Error('route rejected') },
settingsUpdate: async () => { throw new Error('route rejected') },
})
it('falls back to the defaults when the settings route rejects', async () => {
expect(await loadPrefs(rejecting())).toEqual(SIDEBAR_PREFS_DEFAULTS)
})
it('falls back to the defaults when the value is absent or malformed', async () => {
expect(await loadPrefs(wire(undefined))).toEqual(SIDEBAR_PREFS_DEFAULTS)
expect(await loadPrefs(wire('garbage'))).toEqual(SIDEBAR_PREFS_DEFAULTS)
})
it('parses a valid value and clamps the percent into the contract range', async () => {
expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 80, autoOpenSubagent: false, agentTerminalTools: true })))
.toEqual({
openByDefault: false,
defaultWidthPercent: 60,
autoOpenSubagent: false,
autoOpenJobs: true,
agentTerminalTools: true,
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
interceptOpenPath: true,
editorExplorer: true,
titleBarCompat: false,
titleBarStripPx: 40,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
browserNoSandbox: false,
browserInterceptLinks: true,
browserInterceptHttp: true,
browserInterceptHttps: false,
tabsEnabled: {},
viewersEnabled: {},
pluginSettings: {},
})
})
it('falls back per-field when a stored field is malformed', async () => {
expect(await loadPrefs(wire({ openByDefault: 'yes', defaultWidthPercent: 33, autoOpenSubagent: 'no', agentTerminalTools: 'yes' })))
.toEqual({
openByDefault: false,
defaultWidthPercent: 33,
autoOpenSubagent: true,
autoOpenJobs: true,
agentTerminalTools: false,
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
interceptOpenPath: true,
editorExplorer: true,
titleBarCompat: false,
titleBarStripPx: 40,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
browserNoSandbox: false,
browserInterceptLinks: true,
browserInterceptHttp: true,
browserInterceptHttps: false,
tabsEnabled: {},
viewersEnabled: {},
pluginSettings: {},
})
})
it('defaults autoOpenSubagent to true and agentTerminalTools to false when the stored value is absent or malformed', async () => {
expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 40 })))
.toEqual({
openByDefault: false,
defaultWidthPercent: 40,
autoOpenSubagent: true,
autoOpenJobs: true,
agentTerminalTools: false,
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
interceptOpenPath: true,
editorExplorer: true,
titleBarCompat: false,
titleBarStripPx: 40,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
browserNoSandbox: false,
browserInterceptLinks: true,
browserInterceptHttp: true,
browserInterceptHttps: false,
tabsEnabled: {},
viewersEnabled: {},
pluginSettings: {},
})
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenSubagent: 1 }))).autoOpenSubagent)
.toBe(true)
// The terminal-tools feature is OFF by default; only an explicit true turns it on.
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40 }))).agentTerminalTools)
.toBe(false)
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: 1 }))).agentTerminalTools)
.toBe(false)
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: true }))).agentTerminalTools)
.toBe(true)
// The job auto-open is ON by default; only an explicit false turns it off.
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenJobs: 1 }))).autoOpenJobs)
.toBe(true)
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenJobs: false }))).autoOpenJobs)
.toBe(false)
})
it('defaults interceptOpenPath to true; only an explicit false turns the takeover off', async () => {
// Absent or malformed → on (the takeover is the safe default).
expect((await loadPrefs(wire({}))).interceptOpenPath).toBe(true)
expect((await loadPrefs(wire({ interceptOpenPath: 'yes' }))).interceptOpenPath).toBe(true)
expect((await loadPrefs(wire({ interceptOpenPath: 0 }))).interceptOpenPath).toBe(true)
// Explicit booleans survive verbatim.
expect((await loadPrefs(wire({ interceptOpenPath: false }))).interceptOpenPath).toBe(false)
expect((await loadPrefs(wire({ interceptOpenPath: true }))).interceptOpenPath).toBe(true)
})
it('defaults editorExplorer to true; only an explicit false restores the plain editor', async () => {
// Absent or malformed → on (the merged editor-explorer is the default).
expect((await loadPrefs(wire({}))).editorExplorer).toBe(true)
expect((await loadPrefs(wire({ editorExplorer: 'yes' }))).editorExplorer).toBe(true)
expect((await loadPrefs(wire({ editorExplorer: 0 }))).editorExplorer).toBe(true)
// Explicit booleans survive verbatim.
expect((await loadPrefs(wire({ editorExplorer: false }))).editorExplorer).toBe(false)
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 titleBarStripPx to 40 and clamps stored values into the contract range', async () => {
// Absent or malformed → 40 (the strip default).
expect((await loadPrefs(wire({}))).titleBarStripPx).toBe(40)
expect((await loadPrefs(wire({ titleBarStripPx: 'yes' }))).titleBarStripPx).toBe(40)
// Out-of-range numbers clamp into 0–120.
expect((await loadPrefs(wire({ titleBarStripPx: -5 }))).titleBarStripPx).toBe(0)
expect((await loadPrefs(wire({ titleBarStripPx: 200 }))).titleBarStripPx).toBe(120)
expect((await loadPrefs(wire({ titleBarStripPx: 47.6 }))).titleBarStripPx).toBe(48)
// In-range values survive verbatim.
expect((await loadPrefs(wire({ titleBarStripPx: 0 }))).titleBarStripPx).toBe(0)
expect((await loadPrefs(wire({ titleBarStripPx: 64 }))).titleBarStripPx).toBe(64)
})
it('defaults the link-takeover protocol flags: http on, https off, master on', async () => {
// Absent or malformed → the per-protocol defaults.
expect((await loadPrefs(wire({}))).browserInterceptLinks).toBe(true)
expect((await loadPrefs(wire({}))).browserInterceptHttp).toBe(true)
expect((await loadPrefs(wire({}))).browserInterceptHttps).toBe(false)
expect((await loadPrefs(wire({ browserInterceptHttp: 'yes' }))).browserInterceptHttp).toBe(true)
expect((await loadPrefs(wire({ browserInterceptHttps: 0 }))).browserInterceptHttps).toBe(false)
// Explicit booleans survive verbatim.
expect((await loadPrefs(wire({ browserInterceptHttp: false }))).browserInterceptHttp).toBe(false)
expect((await loadPrefs(wire({ browserInterceptHttps: true }))).browserInterceptHttps).toBe(true)
// The master is independent of the protocol flags (an explicit master
// false stays "never take over" regardless of the flags).
expect((await loadPrefs(wire({ browserInterceptLinks: false, browserInterceptHttp: true, browserInterceptHttps: true }))))
.toMatchObject({ browserInterceptLinks: false, browserInterceptHttp: true, browserInterceptHttps: true })
})
it('resolves the terminal font prefs (family passthrough, size clamp)', async () => {
// Absent → theme default (empty family) + default size.
expect((await loadPrefs(wire({}))).terminalFontFamily).toBe('')
expect((await loadPrefs(wire({}))).terminalFontSize).toBe(13)
// A custom family survives verbatim; a malformed one falls back.
expect((await loadPrefs(wire({ terminalFontFamily: '"JetBrains Mono", monospace' }))).terminalFontFamily)
.toBe('"JetBrains Mono", monospace')
expect((await loadPrefs(wire({ terminalFontFamily: 42 }))).terminalFontFamily).toBe('')
// The size clamps into 9–32 (rounded); non-numbers fall back.
expect((await loadPrefs(wire({ terminalFontSize: 5 }))).terminalFontSize).toBe(9)
expect((await loadPrefs(wire({ terminalFontSize: 40 }))).terminalFontSize).toBe(32)
expect((await loadPrefs(wire({ terminalFontSize: 15.6 }))).terminalFontSize).toBe(16)
expect((await loadPrefs(wire({ terminalFontSize: 'big' }))).terminalFontSize).toBe(13)
expect((await loadPrefs(wire({ terminalFontSize: 18 }))).terminalFontSize).toBe(18)
})
it('validates the per-tab / per-viewer enable maps (absent keys mean enabled)', async () => {
// A non-object map falls back to {} (everything enabled).
expect((await loadPrefs(wire({ tabsEnabled: 'nope' }))).tabsEnabled).toEqual({})
expect((await loadPrefs(wire({ viewersEnabled: [1, 2] }))).viewersEnabled).toEqual({})
// Non-boolean entries are dropped; boolean entries survive verbatim.
const parsed = await loadPrefs(wire({
tabsEnabled: { git: false, explorer: true, bad: 'yes' },
viewersEnabled: { image: false, code: 1 },
}))
expect(parsed.tabsEnabled).toEqual({ git: false, explorer: true })
expect(parsed.viewersEnabled).toEqual({ image: false })
})
it('seeds new-session defaults from the store prefs (open flag + width)', () => {
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, 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, 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)
expect(snapshot.state?.width).toBe(400)
// The default prefs keep the panel closed (openByDefault defaults off).
const openStore = createSidebarStore()
openStore.setSession('another-fresh')
expect(openStore.getSnapshot().state?.panelOpen).toBe(false)
})
it('seeds a brand-new session COLLAPSED on narrow viewports (the panel is a full-screen drawer there)', () => {
// Stub a narrow window (the file otherwise runs without one): only
// innerWidth is read while seeding a fresh session.
const original = (globalThis as Record<string, unknown>).window
;(globalThis as Record<string, unknown>).window = {
innerWidth: 390,
clearTimeout: () => {},
setTimeout: (_fn: () => void) => 0,
}
try {
const store = createSidebarStore()
// The narrow viewport keeps a fresh session collapsed for the FIRST
// seeding only (a later user expansion persists).
store.setSession('narrow-fresh')
expect(store.getSnapshot().state?.panelOpen).toBe(false)
// The width seeding still follows the window (clamped to the floor).
expect(store.getSnapshot().state?.width).toBe(280)
} finally {
if (original === undefined) delete (globalThis as Record<string, unknown>).window
else (globalThis as Record<string, unknown>).window = original
}
})
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, 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)
expect(tabs).toHaveLength(0)
expect(state.splits.kind).toBe('leaf')
// Re-enabling seeds the files window (editor home tab) again — in BOTH
// 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, 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'])
}
})
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, 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)
expect(tabs[0]!.type).toBe('editor')
expect(tabs[0]!.title).toBe('Files')
expect(tabs[0]!.path).toBeUndefined()
expect(tabs[0]!.meta).toEqual({ treeOpen: true })
}
})
it('derives the default width from the window percent with clamps', () => {
expect(defaultWidthFor(1440, 30)).toBe(432)
expect(defaultWidthFor(800, 30)).toBe(280) // the panel floor
expect(defaultWidthFor(1440, 100)).toBe(1440) // the viewport cap
})
it('makeDefaultState honors the open flag', () => {
expect(makeDefaultState().panelOpen).toBe(true)
expect(makeDefaultState(400, false).panelOpen).toBe(false)
expect(makeDefaultState(400, false).width).toBe(400)
// The 'none' seed starts with an empty pane (no default tab).
expect(makeDefaultState(400, true, 'none').splits.kind).toBe('leaf')
expect((makeDefaultState(400, true, 'none').splits as { tabs: unknown[] }).tabs).toHaveLength(0)
})
})
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { producedForClosing, resolveSidebarPath, selectProducedFiles } from '../src/client/produced-files.ts'
describe('produced-files derivation', () => {
const diffResult = (path: string) => ({
kind: 'tool-result', isError: false, callView: { card: 'diff', locations: [{ path }] },
})
const editResult = (path: string) => ({
kind: 'tool-result', isError: false, callView: { card: 'generic', kind: 'edit', locations: [{ path }] },
})
it('collects diff/edit locations of the closing turn, first-seen order', () => {
const nodes = [
{ kind: 'assistant', seq: 1, turn: 1 },
diffResult('a.ts'),
editResult('b.ts'),
diffResult('a.ts'),
{ kind: 'assistant', seq: 2, turn: 1 },
]
expect(producedForClosing(nodes, 2)).toEqual(['a.ts', 'b.ts'])
})
it('resets on user messages and turn changes', () => {
const nodes = [
{ kind: 'assistant', seq: 1, turn: 1 },
diffResult('old.ts'),
{ kind: 'user' },
{ kind: 'assistant', seq: 2, turn: 2 },
diffResult('new.ts'),
{ kind: 'assistant', seq: 3, turn: 2 },
]
expect(producedForClosing(nodes, 3)).toEqual(['new.ts'])
})
it('ignores reads, deletes, errors, and unknown cards', () => {
const nodes = [
{ kind: 'assistant', seq: 1, turn: 1 },
{ kind: 'tool-result', isError: true, callView: { card: 'diff', locations: [{ path: 'x.ts' }] } },
{ kind: 'tool-result', isError: false, callView: { card: 'read', locations: [{ path: 'r.ts' }] } },
{ kind: 'tool-result', isError: false, callView: { card: 'generic', kind: 'delete', locations: [{ path: 'd.ts' }] } },
]
expect(producedForClosing(nodes, 1)).toEqual([])
})
it('selector claims only when files exist', () => {
expect(selectProducedFiles({ nodes: [{ kind: 'assistant', seq: 1, turn: 1 }], seq: 1 })).toBeNull()
expect(selectProducedFiles({ nodes: [diffResult('a.ts'), { kind: 'assistant', seq: 1, turn: 1 }], seq: 1 })).toEqual(['a.ts'])
expect(selectProducedFiles(null)).toBeNull()
})
it('resolves relative paths against the session cwd', () => {
expect(resolveSidebarPath('/work/proj', 'src/a.ts')).toBe('/work/proj/src/a.ts')
expect(resolveSidebarPath('/work/proj', '/abs/x.ts')).toBe('/abs/x.ts')
expect(resolveSidebarPath(undefined, 'a.ts')).toBe('a.ts')
})
})
+205
View File
@@ -0,0 +1,205 @@
/**
* pty-deps unit tests (issue #140, plugin side): lazy node-pty loading,
* profile/plugin-root discovery, and the pasteable repair command — the
* degraded mode the terminal tab enters when node-pty cannot load, instead
* of the whole plugin (and `dsh web`) failing to boot.
*/
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, dirname } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
buildRepairCommand,
depsStatus,
DSH_NODE_PTY_RANGE,
findPluginRoot,
findProfileDir,
loadNodePty,
loadRequiredNodePty,
nodePtyLoadCause,
resetNodePtyCache,
} from '../src/pty-deps.ts'
import { SidebarError } from '../src/wire.ts'
/** A throw-only require: node-pty missing or its native binding broken. */
const failingRequire = (): never => { throw new Error('Cannot find package node-pty') }
describe('loadNodePty', () => {
beforeEach(() => { resetNodePtyCache() })
it('loads the real node-pty module in this repository', () => {
const mod = loadNodePty()
expect(mod).not.toBeNull()
expect(typeof mod?.spawn).toBe('function')
})
it('returns null and records the cause when the require throws', () => {
expect(loadNodePty(failingRequire)).toBeNull()
expect((nodePtyLoadCause() as Error).message).toBe('Cannot find package node-pty')
})
it('caches the first outcome (a later working require does not override)', () => {
expect(loadNodePty(failingRequire)).toBeNull()
expect(loadNodePty(() => ({ spawn: () => undefined }))).toBeNull()
resetNodePtyCache()
expect(loadNodePty(() => ({ spawn: () => undefined }))).not.toBeNull()
})
it('loadRequiredNodePty throws the canonical degraded-mode error', () => {
loadNodePty(failingRequire)
let thrown: unknown
try {
loadRequiredNodePty()
} catch (error) {
thrown = error
}
expect(thrown).toBeInstanceOf(SidebarError)
expect((thrown as SidebarError).code).toBe('pty-deps-missing')
expect((thrown as SidebarError).status).toBe(503)
})
})
describe('buildRepairCommand', () => {
let root: string
let pluginRoot: string
let profileDir: string
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'pty-deps-'))
pluginRoot = join(root, 'plugin')
profileDir = join(root, 'profiles', 'web')
mkdirSync(join(pluginRoot, 'scripts'), { recursive: true })
mkdirSync(profileDir, { recursive: true })
writeFileSync(join(pluginRoot, 'scripts', 'install.sh'), '#!/usr/bin/env bash\n')
writeFileSync(join(pluginRoot, 'scripts', 'install.ps1'), '# ps1\n')
})
afterEach(() => { rmSync(root, { recursive: true, force: true }) })
it('posix: bash installer with --repair and the detected profile', () => {
const { command, note } = buildRepairCommand({ pluginRoot, profileDir, platform: 'darwin' })
expect(command).toBe(`bash "${join(pluginRoot, 'scripts', 'install.sh')}" --repair --profile "web"`)
expect(note).toBeUndefined()
})
it('posix: omits --profile when the profile could not be detected', () => {
const { command } = buildRepairCommand({ pluginRoot, profileDir: null, platform: 'linux' })
expect(command).toBe(`bash "${join(pluginRoot, 'scripts', 'install.sh')}" --repair`)
})
it('win32: powershell -ExecutionPolicy Bypass with -Repair and -Profile', () => {
const { command } = buildRepairCommand({ pluginRoot, profileDir, platform: 'win32' })
const expected = join(pluginRoot, 'scripts', 'install.ps1')
expect(command).toBe(`powershell -ExecutionPolicy Bypass -File "${expected}" -Repair -Profile "web"`)
})
it('falls back to the dsh plugin command when the plugin scripts are unavailable', () => {
const bareRoot = join(root, 'bare')
mkdirSync(bareRoot)
const { command, note } = buildRepairCommand({ pluginRoot: bareRoot, profileDir, platform: 'darwin' })
expect(command).toBe('dsh plugin --profile "web" install')
expect(note).toContain('allowBuilds')
})
it('falls back to the dsh plugin command (default web) when nothing is detectable', () => {
const { command } = buildRepairCommand({ pluginRoot: null, profileDir: null, platform: 'darwin' })
expect(command).toBe('dsh plugin --profile "web" install')
})
})
describe('findProfileDir / findPluginRoot', () => {
let root: string
let moduleFile: string
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'pty-deps-'))
// A fake plugin module deep inside the profile's pnpm node_modules.
const pkgRoot = join(root, 'profiles', 'web', 'node_modules', '.pnpm', 'dsh-better-sidebar@0.0.0', 'node_modules', 'dsh-better-sidebar')
const libDir = join(pkgRoot, 'lib')
mkdirSync(libDir, { recursive: true })
writeFileSync(join(root, 'profiles', 'web', 'package.json'), JSON.stringify({ name: 'dsh-profile-web' }))
writeFileSync(join(root, 'profiles', 'web', 'pnpm-workspace.yaml'), '')
writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({ name: 'dsh-better-sidebar' }))
moduleFile = join(libDir, 'index.js')
writeFileSync(moduleFile, '')
})
afterEach(() => { rmSync(root, { recursive: true, force: true }) })
it('walks up from the plugin module to the profile root (pnpm-workspace.yaml)', () => {
expect(findProfileDir(moduleFile)).toBe(realpathSync(join(root, 'profiles', 'web')))
})
it('walks up to the plugin package root by package.json name', () => {
expect(findPluginRoot(moduleFile)).toBe(realpathSync(join(root, 'profiles', 'web', 'node_modules', '.pnpm', 'dsh-better-sidebar@0.0.0', 'node_modules', 'dsh-better-sidebar')))
})
it('falls back to $DSH_HOME/profiles/web when no ancestor looks like a profile', () => {
const home = join(root, 'home')
const web = join(home, 'profiles', 'web')
mkdirSync(web, { recursive: true })
writeFileSync(join(web, 'package.json'), JSON.stringify({ name: 'dsh-profile-web' }))
writeFileSync(join(web, 'pnpm-workspace.yaml'), '')
// A bare module path with no profile-like ancestor (the walk-up must miss).
const bareModule = join(root, 'elsewhere', 'lib', 'index.js')
mkdirSync(dirname(bareModule), { recursive: true })
writeFileSync(bareModule, '')
const previous = process.env.DSH_HOME
process.env.DSH_HOME = home
try {
expect(findProfileDir(bareModule)).toBe(realpathSync(web))
} finally {
if (previous === undefined) delete process.env.DSH_HOME
else process.env.DSH_HOME = previous
}
})
})
describe('depsStatus', () => {
let root: string
let moduleFile: string
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'pty-deps-'))
const pkgRoot = join(root, 'profiles', 'web', 'node_modules', '.pnpm', 'dsh-better-sidebar@0.0.0', 'node_modules', 'dsh-better-sidebar')
mkdirSync(join(pkgRoot, 'scripts'), { recursive: true })
mkdirSync(join(pkgRoot, 'lib'), { recursive: true })
writeFileSync(join(root, 'profiles', 'web', 'package.json'), JSON.stringify({ name: 'dsh-profile-web' }))
writeFileSync(join(root, 'profiles', 'web', 'pnpm-workspace.yaml'), '')
writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({ name: 'dsh-better-sidebar' }))
writeFileSync(join(pkgRoot, 'scripts', 'install.sh'), '#!/usr/bin/env bash\n')
moduleFile = join(pkgRoot, 'lib', 'index.js')
writeFileSync(moduleFile, '')
resetNodePtyCache()
})
afterEach(() => {
rmSync(root, { recursive: true, force: true })
resetNodePtyCache()
})
it('reports ok:true when node-pty loads', () => {
expect(depsStatus({ fromFile: moduleFile })).toEqual({ ok: true })
})
it('reports the cause, the repair command and the profile when the load fails', () => {
loadNodePty(failingRequire)
const status = depsStatus({ fromFile: moduleFile })
expect(status.ok).toBe(false)
if (status.ok) throw new Error('unreachable')
expect(status.cause).toBe('Cannot find package node-pty')
const script = realpathSync(join(root, 'profiles', 'web', 'node_modules', '.pnpm', 'dsh-better-sidebar@0.0.0', 'node_modules', 'dsh-better-sidebar', 'scripts', 'install.sh'))
expect(status.command).toBe(`bash "${script}" --repair --profile "web"`)
expect(status.profile).toBe('web')
})
})
describe('DSH sync contract', () => {
it('the declared node-pty range stays identical to DSH core (dsh-subprocess-local ^1.1.0)', () => {
expect(DSH_NODE_PTY_RANGE).toBe('^1.1.0')
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
dependencies: Record<string, string>
}
expect(pkg.dependencies['node-pty']).toBe(DSH_NODE_PTY_RANGE)
})
})
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it, vi } from 'vitest'
// Pin the passwd login shell to a non-bash value for the pty helpers tests:
// the CI runner's own login shell is /bin/bash, which the old hardcoded
// fallback would have satisfied by coincidence, so the fallback chain needs
// a mock passwd to be genuinely discriminating. No other test reads os.userInfo.
vi.mock('node:os', async (importOriginal) => {
const os = await importOriginal<typeof import('node:os')>()
return {
...os,
userInfo: () => ({ ...os.userInfo(), shell: '/usr/bin/zsh' }),
}
})
import { resolveSidebarConfig } from '../src/config.ts'
import { defaultShell, ensureSpawnHelper } from '../src/pty-manager.ts'
describe('pty helpers', () => {
it('prefers an explicit shell, then SHELL, then the account login shell on POSIX', () => {
// The platform is injected so this chain also runs (and is asserted) on
// non-POSIX developer machines. The userInfo mock at the top of the file
// pins the passwd login shell to /usr/bin/zsh.
expect(defaultShell({ platform: 'linux', env: { SHELL: '/explicit/zsh' } })).toBe('/explicit/zsh')
// Surrounding whitespace must not leak into the spawned executable path.
expect(defaultShell({ platform: 'linux', env: { SHELL: ' /explicit/zsh ' } })).toBe('/explicit/zsh')
expect(defaultShell({ platform: 'linux', env: { SHELL: ' ' } })).toBe('/usr/bin/zsh')
expect(defaultShell({ platform: 'linux', env: {} })).toBe('/usr/bin/zsh')
})
it('Windows: explicit shell wins, then DSH_SIDEBAR_SHELL, then a probed pwsh.exe, then powershell.exe 5.1', () => {
// The explicit shell (the `shell` config field) beats every automatic
// source, and whitespace-only values count as unset.
expect(defaultShell({ platform: 'win32', explicit: 'C:\\Tools\\pwsh.exe', env: { DSH_SIDEBAR_SHELL: 'pwsh.exe' } }))
.toBe('C:\\Tools\\pwsh.exe')
expect(defaultShell({ platform: 'win32', explicit: ' ', env: { DSH_SIDEBAR_SHELL: 'pwsh.exe' } }))
.toBe('pwsh.exe')
// PATH is probed entry by entry; the first directory containing pwsh.exe
// wins and the returned value is the full resolved path. The candidate is
// normalized to forward slashes before comparing so the assertion holds
// under BOTH join() implementations: on the ubuntu runners node:path is
// POSIX and joins 'C:\\other' + 'pwsh.exe' as 'C:\\other/pwsh.exe'.
const fromPath = defaultShell({
platform: 'win32',
env: { PATH: 'C:\\tools;C:\\other' },
exists: path => path.replaceAll('\\', '/') === 'C:/other/pwsh.exe',
})
expect(fromPath.replaceAll('\\', '/')).toBe('C:/other/pwsh.exe')
// PATH misses fall through to the known install directories. On 32-bit
// Node, ProgramW6432 is the real 64-bit Program Files and is preferred.
const fromProgramW6432 = defaultShell({
platform: 'win32',
env: { ProgramW6432: 'C:\\PF64' },
exists: path => path.replaceAll('\\', '/') === 'C:/PF64/PowerShell/7/pwsh.exe',
})
expect(fromProgramW6432.replaceAll('\\', '/')).toBe('C:/PF64/PowerShell/7/pwsh.exe')
const fromProgramFiles = defaultShell({
platform: 'win32',
env: { ProgramFiles: 'C:\\PF', LOCALAPPDATA: 'C:\\Users\\x\\AppData\\Local' },
exists: path => path.replaceAll('\\', '/') === 'C:/PF/PowerShell/7/pwsh.exe',
})
expect(fromProgramFiles.replaceAll('\\', '/')).toBe('C:/PF/PowerShell/7/pwsh.exe')
// Nothing installed: keep the inbox 5.1 fallback instead of breaking.
expect(defaultShell({ platform: 'win32', env: {}, exists: () => false })).toBe('powershell.exe')
})
it('trims the configured shell and defaults it to auto for old documents', () => {
expect(resolveSidebarConfig(undefined).shell).toBe('')
expect(resolveSidebarConfig({ shell: ' pwsh.exe ' }).shell).toBe('pwsh.exe')
})
it('restores the spawn-helper executable bit idempotently', () => {
// node-pty's spawn-helper is a macOS-only artifact: binding.gyp builds
// the executable only for OS=="mac", and no other platform ships one to
// restore (linux uses forkpty directly). Skip elsewhere.
if (process.platform !== 'darwin') return
ensureSpawnHelper()
ensureSpawnHelper()
const { existsSync, statSync } = require('node:fs') as typeof import('node:fs')
const { dirname, join } = require('node:path') as typeof import('node:path')
const { createRequire } = require('node:module') as typeof import('node:module')
const entry = createRequire(import.meta.url).resolve('node-pty')
const root = dirname(dirname(entry))
// Prebuilt (tarball) or node-gyp-compiled (build/Release): mirror
// ensureSpawnHelper's candidate list — either location is acceptable.
const candidates = [
join(root, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'),
join(root, 'build', 'Release', 'spawn-helper'),
]
const helper = candidates.find(existsSync)
expect(helper).toBeTruthy()
expect((statSync(helper!).mode & 0o111) !== 0).toBe(true)
})
})
+83 -10
View File
@@ -20,8 +20,8 @@ if (g.localStorage === undefined) {
}
}
import { createBetterSidebarService, SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from '../src/client/service.ts'
import { createSidebarStore, allLeaves, makeDefaultState, openDiffTab, sanitizeState } from '../src/client/state.ts'
import { createBetterSidebarService, matchUrlTarget, SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from '../src/client/service.ts'
import { createSidebarStore, allLeaves, makeDefaultState, openDiffTab, openTabInActivePane, sanitizeState } from '../src/client/state.ts'
describe('BetterSidebar service', () => {
it('registerTab adds to the registry and dispose removes it', () => {
@@ -108,8 +108,9 @@ describe('enable switches (declarative settings)', () => {
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { explorer: false } })
store.setSession('s1')
service.openTab({ type: 'explorer', title: 'Explorer' })
// The seeded files-window home tab stays; no EXPLORER tab landed.
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs)
expect(tabs).toHaveLength(0)
expect(tabs.filter(t => t.type === 'explorer')).toHaveLength(0)
})
it('matchFileViewer skips a disabled viewer (files fall through)', () => {
@@ -223,6 +224,55 @@ describe('matchFileViewer', () => {
})
})
describe('matchUrlTarget (v0.13.0)', () => {
/** A tab descriptor that claims a URL host. */
const claimingTab = (id: string, host: string) => ({
id,
title: id,
urlTarget: (url: URL): boolean => url.hostname === host,
component: () => null,
})
it('returns undefined when no tab declares urlTarget', () => {
const tabs = [
{ id: 'explorer', title: 'E', component: () => null },
{ id: 'git', title: 'G', component: () => null },
]
expect(matchUrlTarget(tabs, new URL('http://example.com/'))).toBeUndefined()
})
it('returns the first REGISTRATION-ORDER claim (first match wins)', () => {
const tabs = [
claimingTab('my:one', 'example.com'),
claimingTab('my:two', 'example.com'),
]
expect(matchUrlTarget(tabs, new URL('https://example.com/x'))?.id).toBe('my:one')
// A non-matching earlier tab yields to the next match.
const tabs2 = [
claimingTab('my:one', 'other.com'),
claimingTab('my:two', 'example.com'),
]
expect(matchUrlTarget(tabs2, new URL('https://example.com/x'))?.id).toBe('my:two')
})
it('skips a throwing predicate (the next claim still wins)', () => {
const tabs = [
{ id: 'my:broken', title: 'B', urlTarget: () => { throw new Error('boom') }, component: () => null },
claimingTab('my:ok', 'example.com'),
]
expect(matchUrlTarget(tabs, new URL('https://example.com/x'))?.id).toBe('my:ok')
})
it('never matches the built-in browser (no urlTarget declared — the caller falls back)', () => {
const tabs = [
{ id: 'browser', title: 'Browser', component: () => null },
{ id: 'my:ok', title: 'OK', urlTarget: () => true, component: () => null },
]
expect(matchUrlTarget(tabs, new URL('http://example.com/'))?.id).toBe('my:ok')
expect(matchUrlTarget([{ id: 'browser', title: 'Browser', component: () => null }], new URL('http://example.com/'))).toBeUndefined()
})
})
describe('service.openTab dedupe', () => {
it('dedupeKey focuses existing tab instead of duplicating', () => {
const store = createSidebarStore()
@@ -303,7 +353,8 @@ describe('service.openTab dedupe', () => {
store.setSession('s1')
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
const state = store.getSnapshot().state!
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor')
// Find by path: the seeded files-window home tab is an editor tab too.
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor' && t.path === '/p/main.ts')
expect(tab?.title).toBe('main.ts')
})
@@ -594,7 +645,7 @@ describe('service.openTab auto-expand for content opens', () => {
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
const state = store.getSnapshot().state!
expect(state.panelOpen).toBe(true)
expect(allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'editor')).toHaveLength(1)
expect(allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'editor' && t.path === '/p/main.ts')).toHaveLength(1)
})
})
@@ -622,7 +673,7 @@ describe('state subscription (v0.12.0)', () => {
const snapshot = service.getSnapshot()
expect(snapshot.sessionId).toBe('s1')
expect(snapshot.state).toBeDefined()
expect(snapshot.prefs.openByDefault).toBe(true)
expect(snapshot.prefs.openByDefault).toBe(false)
})
it('subscribeState fires on state changes but NOT on registry changes', () => {
@@ -690,7 +741,7 @@ describe('targeted openTab (v0.12.0)', () => {
service.registerTab({ id: 'notes', title: 'Notes', component: () => null })
store.setSession('s1')
service.openTab({ type: 'notes', title: 'Notes', id: 'notes:1' }, { sessionId: 's2' })
// The UI snapshot still shows s1, untouched (its default explorer tab
// The UI snapshot still shows s1, untouched (its default files-window tab
// is the only one — no notes tab landed there).
const snapshot = store.getSnapshot()
expect(snapshot.sessionId).toBe('s1')
@@ -734,7 +785,8 @@ describe('openFile (v0.12.0)', () => {
store.setSession('s1')
service.openFile({ sessionId: 's1', cwd: '/p' }, '/p/src/main.ts')
const state = store.getSnapshot().state!
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor')
// Find by path: the seeded files-window home tab is an editor tab too.
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor' && t.path !== undefined)
expect(tab?.title).toBe('main.ts')
expect(tab?.path).toBe('/p/src/main.ts')
// Windows separators are handled too.
@@ -843,8 +895,9 @@ describe('tab meta (v0.12.0)', () => {
})
it('older persisted tabs (no meta) sanitize unchanged', () => {
const state = makeDefaultState(400, true, true)
const sanitized = sanitizeState(JSON.parse(JSON.stringify(state)))
const state = makeDefaultState(400, true, 'none')
const withTab = openTabInActivePane(state, { id: 'tab:old', type: 'git', title: 'Git' })
const sanitized = sanitizeState(JSON.parse(JSON.stringify(withTab)))
const tabs = allLeaves(sanitized!.splits).flatMap(l => l.tabs)
expect(tabs[0]?.meta).toBeUndefined()
})
@@ -982,4 +1035,24 @@ describe('independent CR follow-up fixes', () => {
{ kind: 'close', cwd: '/work' },
])
})
it('SIDEBAR_FEATURES snapshot and service version checks', () => {
expect(SIDEBAR_FEATURES).toContain('urlTarget')
expect(SIDEBAR_FEATURES).toContain('pluginSettings')
expect(SIDEBAR_FEATURES).toContain('tabLifecycle')
expect(SIDEBAR_FEATURES).toContain('badge')
expect(SIDEBAR_FEATURES).toContain('updateTab')
expect(SIDEBAR_FEATURES).toContain('openFile')
expect(SIDEBAR_FEATURES).toContain('targetedOpen')
expect(SIDEBAR_FEATURES).toContain('stateSubscription')
expect(SIDEBAR_FEATURES).toContain('tabMeta')
expect(SIDEBAR_SERVICE_VERSION).toMatch(/^\d+\.\d+\.\d+/)
})
it('registerFileViewer throws on duplicate id', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerFileViewer({ id: 'csv', exts: ['csv'], fetchStrategy: 'custom', component: () => null })
expect(() => service.registerFileViewer({ id: 'csv', exts: ['csv'], fetchStrategy: 'custom', component: () => null })).toThrow(/already registered/)
})
})
+64
View File
@@ -0,0 +1,64 @@
// @vitest-environment jsdom
/** Settings-navigation icon marker tests (including delayed dialog mount). */
import { afterEach, describe, expect, it } from 'vitest'
import { registerSettingsNavIcon, SETTINGS_NAV_MARKER } from '../src/client/settings-nav-icon.ts'
function navButton(label: string): HTMLButtonElement {
const button = document.createElement('button')
button.innerHTML = `<svg data-fallback="gear"></svg><span>${label}</span>`
return button
}
function dialogWith(...buttons: HTMLButtonElement[]): HTMLElement {
const dialog = document.createElement('div')
dialog.setAttribute('role', 'dialog')
const nav = document.createElement('nav')
nav.append(...buttons)
dialog.append(nav)
return dialog
}
async function mutationTick(): Promise<void> {
await new Promise<void>(resolve => { setTimeout(resolve, 0) })
}
afterEach(() => {
document.body.innerHTML = ''
})
describe('registerSettingsNavIcon', () => {
it('marks only the matching settings row and preserves the shell icon DOM', () => {
const general = navButton('General')
const sideCard = navButton('Side card')
document.body.append(dialogWith(general, sideCard))
const dispose = registerSettingsNavIcon(() => 'Side card')
expect(general.hasAttribute(SETTINGS_NAV_MARKER)).toBe(false)
expect(sideCard.hasAttribute(SETTINGS_NAV_MARKER)).toBe(true)
expect(sideCard.querySelector('[data-fallback="gear"]')).not.toBeNull()
dispose()
expect(sideCard.hasAttribute(SETTINGS_NAV_MARKER)).toBe(false)
})
it('marks a dialog mounted later and follows a localized label change', async () => {
let label = 'Side card'
const dispose = registerSettingsNavIcon(() => label)
const english = navButton('Side card')
const chinese = navButton('侧边卡片')
const dialog = dialogWith(english, chinese)
document.body.append(dialog)
await mutationTick()
expect(english.hasAttribute(SETTINGS_NAV_MARKER)).toBe(true)
expect(chinese.hasAttribute(SETTINGS_NAV_MARKER)).toBe(false)
label = '侧边卡片'
chinese.querySelector('span')!.textContent = '侧边卡片 '
await mutationTick()
expect(english.hasAttribute(SETTINGS_NAV_MARKER)).toBe(false)
expect(chinese.hasAttribute(SETTINGS_NAV_MARKER)).toBe(true)
dispose()
})
})
+103 -5
View File
@@ -1,9 +1,11 @@
/**
* Interactive tests for the settings popup's text/number rows: the draft is
* local state committed on blur through the parent's onCommit; the parent's
* canonical return is adopted (clamped numbers, stored value for invalid
* input), and the row remounts when the committed pref value changes (a
* failed commit reverts prefs → the stored value reappears).
* Interactive tests for the settings popup's text/number/select rows: the
* draft is local state committed on blur through the parent's onCommit; the
* parent's canonical return is adopted (clamped numbers, stored value for
* invalid input), and the row remounts when the committed pref value changes
* (a failed commit reverts prefs → the stored value reappears). Select rows
* commit the picked option's value (single) or the picked values in options
* order (multi) through onSelectValue.
*
* Rendered with createRoot + act() in jsdom (the SSR specs stay in
* side-card-section.spec.tsx; this file exercises the event paths).
@@ -173,3 +175,99 @@ describe('FeatureSettingsRows typed rows (interactive)', () => {
unmount()
})
})
/** Open one select row's dropdown: click the anchor, the Menu portals its
* list into document.body (role="menuitem" rows). */
function openSelect(container: HTMLDivElement): HTMLElement[] {
const anchor = container.querySelector<HTMLButtonElement>('button[aria-haspopup="listbox"]')!
act(() => { anchor.click() })
return [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
}
describe('FeatureSettingsRows select rows (interactive)', () => {
const options: NonNullable<SidebarSettingToggle['options']> = [
{ value: true, icon: (size: number) => createElement('i', { 'data-size': size }), title: () => 'Merged', desc: () => 'Docked tree' },
{ value: false, icon: (size: number) => createElement('i', { 'data-size': size }), title: () => 'Split', desc: () => 'Two tabs' },
]
it('commits the picked option value and closes (iconed single select)', () => {
const commits: Array<[string, unknown]> = []
const toggle: SidebarSettingToggle = {
key: 'editorExplorer',
type: 'select',
title: () => 'Editor explorer',
options,
}
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs,
onToggle: () => {},
onSelectValue: (t, next) => { commits.push([t.key, next]) },
}))
// The closed anchor shows the selected option's title (icon variant).
expect(container.textContent).toContain('Merged')
const items = openSelect(container)
// Big-icon cards: title + desc per option.
expect(items.map(item => item.textContent)).toEqual(['MergedDocked tree', 'SplitTwo tabs'])
act(() => { items[1]!.click() })
expect(commits).toEqual([['editorExplorer', false]])
// Single-pick closes the dropdown.
expect(document.querySelectorAll('[role="menuitem"]')).toHaveLength(0)
unmount()
})
it('toggles membership and commits the picked values in options order (multi)', () => {
const commits: Array<[string, unknown]> = []
const plain: NonNullable<SidebarSettingToggle['options']> = [
{ value: 'a', title: () => 'Alpha' },
{ value: 'b', title: () => 'Beta' },
{ value: 'c', title: () => 'Gamma' },
]
const toggle: SidebarSettingToggle = {
key: 'pluginKey',
type: 'select',
multi: true,
title: () => 'Pick several',
options: plain,
}
const rows = (value: unknown) => createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs,
onToggle: () => {},
onSelectValue: (t, next) => { commits.push([t.key, next]) },
valueSource: () => value,
})
const { container, rerender, unmount } = mount(rows(['c', 'a']))
// The anchor shows the picked titles; options follow the declared order.
const items = openSelect(container)
expect(items.map(item => item.textContent)).toEqual(['Alpha', 'Beta', 'Gamma'])
// Deselect 'a' → ['c'] is normalized to options order… (['c'] here).
act(() => { items[0]!.click() })
expect(commits).toEqual([['pluginKey', ['c']]])
// The menu stays open under multi; re-render with the committed value.
rerender(rows(['c']))
const again = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
expect(again).toHaveLength(3)
// Select 'b' → committed in OPTIONS order, not pick order.
act(() => { again[1]!.click() })
expect(commits[1]).toEqual(['pluginKey', ['b', 'c']])
unmount()
})
it('shows an em dash when nothing is selected', () => {
const toggle: SidebarSettingToggle = {
key: 'editorExplorer',
type: 'select',
title: () => 'Editor explorer',
options,
}
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs,
onToggle: () => {},
valueSource: () => undefined,
}))
expect(container.querySelector('button[aria-haspopup="listbox"]')!.textContent).toContain('—')
unmount()
})
})
+54 -6
View File
@@ -80,13 +80,14 @@ describe('SideCardSection declarative inventory', () => {
expect(html).toContain('>explorer<')
expect(html).toContain('data-icon="subagent"')
expect(html).toContain('>Subagents<')
// Default prefs: openByDefault + interceptOpenPath switches checked, and
// both tabs + the image viewer cards pressed (3 aria-pressed cards).
// Default prefs: only the interceptOpenPath switch is checked (openByDefault
// now defaults off), and both tabs + the image viewer cards pressed
// (3 aria-pressed cards).
// The nested auto-open toggle is NOT an inline card (it lives in the popup).
expect(pressedCount(html, 'true')).toBe(3)
expect(pressedCount(html, 'false')).toBe(0)
// The general toggles are custom switches (real checkboxes, checked).
expect(html.match(/checked=""/g)?.length).toBe(2)
// The general toggles are custom switches (real checkboxes, one checked).
expect(html.match(/checked=""/g)?.length).toBe(1)
expect(html).not.toContain('Auto-open Subagents')
})
@@ -140,9 +141,9 @@ describe('SideCardSection declarative inventory', () => {
expect(html).toContain('>Subagents<')
expect(html).toContain('>Image<')
expect(pressedCount(html, 'false')).toBe(2)
// The explorer card stays pressed; the general switches stay checked.
// The explorer card stays pressed; the one default-on general switch stays checked.
expect(pressedCount(html, 'true')).toBe(1)
expect(html.match(/checked=""/g)?.length).toBe(2)
expect(html.match(/checked=""/g)?.length).toBe(1)
})
it('hides the gear of a disabled feature (its related settings are dormant)', () => {
@@ -151,6 +152,28 @@ describe('SideCardSection declarative inventory', () => {
const html = renderSection(store, service)
expect(html).not.toContain('Feature settings')
})
it('renders the position-compat mode general row: off by default, checked when the pref is on', () => {
const { store, service } = mount()
let html = renderSection(store, service)
// The general row renders its title and description.
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.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.
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 })
html = renderSection(store, service)
expect(html.match(/checked=""/g)?.length).toBe(2)
expect(html).toContain('aria-label="Position compatibility mode Feature settings"')
})
})
describe('FeatureSettingsRows (the secondary settings popup body)', () => {
@@ -229,6 +252,31 @@ describe('FeatureSettingsRows (the secondary settings popup body)', () => {
expect(html).toContain('px')
expect(html).not.toContain('type="checkbox"')
})
it('renders the title-bar strip row: the pref value, the 0–120 bounds and the px suffix', () => {
const html = renderToString(createElement(FeatureSettingsRows, {
toggles: [{
key: 'titleBarStripPx',
type: 'number',
title: () => 'Shift distance',
desc: () => 'Title-bar strip height in px',
min: 0,
max: 120,
unit: 'px',
}],
prefs: { ...prefs, titleBarStripPx: 64 },
onToggle: () => {},
onCommit: () => '64',
}))
expect(html).toContain('Shift distance')
expect(html).toContain('Title-bar strip height in px')
expect(html).toContain('type="number"')
expect(html).toContain('value="64"')
expect(html).toContain('min="0"')
expect(html).toContain('max="120"')
expect(html).toContain('px')
expect(html).not.toContain('type="checkbox"')
})
})
describe('mergePluginSetting (v0.12.0, codex review fix)', () => {
+2 -1
View File
@@ -53,7 +53,8 @@ function mountSidebar(): MountedSidebar {
document.body.append(container)
const store = createSidebarStore()
const service = createBetterSidebarService(store)
// Fresh-session seed: the panel starts OPEN (openByDefault default true).
// Fresh-session seed: open the panel explicitly (openByDefault defaults off).
store.setPrefs({ ...store.getPrefs(), openByDefault: true })
store.setSession('s1')
// useSyncExternalStore requires STABLE snapshots across calls (the real DSH
// services return stable objects) — a fresh object per call loops forever.
+66 -6
View File
@@ -12,7 +12,7 @@ import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-setti
import { apply, mediaTypeForPath } from '../src/index.ts'
import * as git from '../src/git.ts'
import { listDirectory } from '../src/fs-tree.ts'
import { defaultShell, PtyManager } from '../src/pty-manager.ts'
import { defaultShell, PtyManager, type SidebarPty } from '../src/pty-manager.ts'
import type { SidebarWebRoute, SidebarWebUpgradeRoute } from '../src/context-types.ts'
interface FakeContext {
@@ -31,6 +31,32 @@ interface FakeContext {
get: (key: string) => undefined
}
/**
* The login-shell test spawns a real pty whose bash may still be writing to
* the temp HOME (history files, etc.) when `disposeAll()` returns — `close()`
* only requests the kill and the process exit lands asynchronously in
* `onExit`. Deleting the directory immediately then races the shell and
* fails with ENOTEMPTY on CI. Wait for the spawned handle to report `exited`
* (bounded), then remove with a short retry loop as a belt-and-braces
* fallback for any straggler fd.
*/
async function rmTempDirAfterPtyExit(handle: { exited: boolean }, dir: string): Promise<void> {
const deadline = Date.now() + 2000
while (Date.now() < deadline && !handle.exited) {
await new Promise((resolve) => setTimeout(resolve, 50))
}
for (let attempt = 0; ; attempt++) {
try {
rmSync(dir, { recursive: true, force: true })
return
} catch (error) {
const busy = (error as NodeJS.ErrnoException).code === 'ENOTEMPTY' || (error as NodeJS.ErrnoException).code === 'EBUSY'
if (!busy || attempt >= 4) throw error
await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)))
}
}
}
describe('host plugin smoke', () => {
it('serves PDF with the browser-native content type', () => {
expect(mediaTypeForPath('/work/report.PDF')).toBe('application/pdf')
@@ -188,6 +214,35 @@ describe('host plugin smoke', () => {
}
})
it.skipIf(process.platform === 'win32')('spawns the shell as a login shell (loads ~/.profile)', async () => {
const home = mkdtempSync(join(tmpdir(), 'dsh-sidebar-login-'))
const previousHome = process.env.HOME
let handle: SidebarPty | undefined
try {
// A login bash reads ~/.profile (a non-login interactive bash reads
// ~/.bashrc instead), so this marker proves the spawn used a login
// argv — the terminal-emulator behavior the tab should match.
writeFileSync(join(home, '.profile'), 'export DSH_LOGIN_MARKER=loaded-from-profile\n')
process.env.HOME = home
const manager = new PtyManager('/bin/bash', 3)
try {
handle = manager.open('s5', 't1', process.cwd(), 80, 24)
handle.pty.write('echo $DSH_LOGIN_MARKER\r')
const deadline = Date.now() + 5000
while (!handle.transcript.includes('loaded-from-profile') && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 50))
}
expect(handle.transcript).toContain('loaded-from-profile')
} finally {
manager.disposeAll()
}
} finally {
if (previousHome === undefined) delete process.env.HOME
else process.env.HOME = previousHome
await rmTempDirAfterPtyExit(handle ?? { exited: true }, home)
}
})
it('lists the repository root level', async () => {
const listing = await listDirectory(process.cwd(), 1000)
expect(listing.entries.some(entry => entry.name === 'src' && entry.isDir)).toBe(true)
@@ -511,8 +566,8 @@ describe('side card settings routes', () => {
expect(read.ok).toBe(true)
expect(read.value).toEqual({
value: {
openByDefault: true,
defaultWidthPercent: 30,
openByDefault: false,
defaultWidthPercent: 35,
autoOpenSubagent: true,
autoOpenJobs: true,
agentTerminalTools: false,
@@ -520,10 +575,15 @@ describe('side card settings routes', () => {
terminalFontFamily: '',
terminalFontSize: 13,
interceptOpenPath: true,
editorExplorer: true,
titleBarCompat: false,
titleBarStripPx: 40,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
browserNoSandbox: false,
browserInterceptLinks: true,
browserInterceptHttp: true,
browserInterceptHttps: false,
// The enable-switch maps default to {} (everything on).
tabsEnabled: {},
viewersEnabled: {},
@@ -533,11 +593,11 @@ describe('side card settings routes', () => {
revision: 0,
})
const written = await invoke(route, 'settings.update', { patch: { openByDefault: false } })
const written = await invoke(route, 'settings.update', { patch: { openByDefault: true } })
expect(written.ok).toBe(true)
const view = written.value as { value: { openByDefault: boolean; defaultWidthPercent: number }; revision: number }
expect(view.value.openByDefault).toBe(false)
expect(view.value.defaultWidthPercent).toBe(30)
expect(view.value.openByDefault).toBe(true)
expect(view.value.defaultWidthPercent).toBe(35)
expect(view.revision).toBe(1)
})
+875
View File
@@ -0,0 +1,875 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
activateTab, allLeaves, BOTTOM_DEFAULT, BOTTOM_MIN, closeTab, createSidebarStore,
insertLeafAt, makeDefaultState, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab,
openTabInActivePane, patchTab, resizeSplit, resizeSplitIn, sanitizeState, setBottomHeight,
splitPane, tabOpenIn, toggleBottomPanel, toggleExpanded, togglePanel,
type SidebarState, type SidebarTab, type SplitNode,
} from '../src/client/state.ts'
describe('sidebar state', () => {
const state = (): SidebarState => makeDefaultState()
it('makeDefaultState seeds per the seed enum (editor-home / none)', () => {
// Default and explicit 'editor-home' seed an EMPTY editor tab (the files
// window) with the tree panel pinned open.
for (const s of [makeDefaultState(), makeDefaultState(400, true, 'editor-home')]) {
const leaf = s.splits as { tabs: SidebarTab[]; active: string | null }
expect(leaf.tabs).toHaveLength(1)
expect(leaf.tabs[0]!.type).toBe('editor')
expect(leaf.tabs[0]!.title).toBe('Files')
expect(leaf.tabs[0]!.path).toBeUndefined()
expect(leaf.tabs[0]!.meta).toEqual({ treeOpen: true })
expect(leaf.active).toBe(leaf.tabs[0]!.id)
}
// The seeded home tab survives the persist round-trip (meta intact).
const restored = sanitizeState(JSON.parse(JSON.stringify(makeDefaultState())))
const restoredLeaf = restored!.splits as { tabs: SidebarTab[] }
expect(restoredLeaf.tabs[0]!.meta).toEqual({ treeOpen: true })
// 'none' seeds an empty pane.
const bare = makeDefaultState(400, true, 'none')
const bareLeaf = bare.splits as { tabs: SidebarTab[]; active: string | null }
expect(bareLeaf.tabs).toHaveLength(0)
expect(bareLeaf.active).toBeNull()
})
it('sanitizeState migrates persisted explorer tabs to editor home tabs (both trees)', () => {
const valid = sanitizeState({
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: 'pane:1',
expanded: [],
splits: {
kind: 'leaf',
id: 'pane:1',
active: 'ex-right',
tabs: [{ id: 'ex-right', type: 'explorer', title: 'Explorer', meta: { treeWidth: 300 } }],
},
bottomSplits: {
kind: 'leaf',
id: 'pane:b',
active: 'ex-bottom',
tabs: [{ id: 'ex-bottom', type: 'explorer', title: 'Explorer' }],
},
})
const right = (valid?.splits as { tabs: SidebarTab[] }).tabs
expect(right).toHaveLength(1)
// Migrated: editor home tab (no path), tree pinned open, prior meta kept.
expect(right[0]).toMatchObject({ id: 'ex-right', type: 'editor', title: 'Files', meta: { treeOpen: true, treeWidth: 300 } })
expect(right[0]!.path).toBeUndefined()
const bottom = (valid?.bottomSplits as { tabs: SidebarTab[] }).tabs
expect(bottom).toHaveLength(1)
expect(bottom[0]).toMatchObject({ id: 'ex-bottom', type: 'editor', title: 'Files', meta: { treeOpen: true } })
})
it('opens tabs into the active pane and dedupes by id (safety net)', () => {
let s = state()
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
s = openTabInActivePane(s, gitTab)
expect(s.splits.kind).toBe('leaf')
expect((s.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
// Reopening with the SAME id focuses the existing tab instead of duplicating.
const after = openTabInActivePane(s, { id: 'git', type: 'git' as const, title: 'Git' })
expect((after.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
// A different id opens a new tab (type-level dedupe is the service's job).
const after2 = openTabInActivePane(s, { id: 'git2', type: 'git' as const, title: 'Git' })
expect((after2.splits as { tabs: unknown[] }).tabs).toHaveLength(3)
})
it('opens multiple editors with distinct ids (path-level dedupe is the service descriptor\'s job)', () => {
let s = state()
const firstId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id
s = openTabInActivePane(s, { id: 'e1', type: 'editor', title: 'a.ts', path: '/p/a.ts' })
const after = openTabInActivePane(s, { id: 'e2', type: 'editor', title: 'a.ts', path: '/p/a.ts' })
expect((after.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([firstId, 'e1', 'e2'])
})
const diffTab = (id: string): SidebarTab => ({
id,
type: 'diff',
title: id,
diff: { kind: 'worktree', path: 'src/a.ts', staged: false },
})
it('first diff splits the source pane vertically (diff below)', () => {
const s = state()
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
const withGit = openTabInActivePane(s, gitTab)
const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id
const after = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts'))
expect(after.splits.kind).toBe('split')
const split = after.splits as { dir: string; children: { kind: string; tabs?: SidebarTab[]; id: string }[] }
expect(split.dir).toBe('col')
expect(split.children).toHaveLength(2)
// The source stays on TOP (first child), the diff lands in the new bottom leaf.
expect(split.children[0]!.id).toBe(sourcePane)
expect(split.children[1]!.tabs?.map(tab => tab.id)).toEqual(['diff:w:u:src/a.ts'])
expect(after.activePane).toBe(split.children[1]!.id)
})
it('reopening the same diff focuses its existing tab', () => {
const s = state()
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
const withGit = openTabInActivePane(s, gitTab)
const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id
const first = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts'))
const second = openDiffTab(first, sourcePane, diffTab('diff:w:u:src/a.ts'))
// No new panes, no duplicate tabs.
expect(second.splits.kind).toBe('split')
const split = second.splits as { children: { kind: string; tabs?: SidebarTab[] }[] }
const allTabs = split.children.flatMap(child => child.tabs ?? [])
expect(allTabs.filter(tab => tab.type === 'diff')).toHaveLength(1)
})
it('subsequent diffs stack into the existing diff pane', () => {
const s = state()
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
const withGit = openTabInActivePane(s, gitTab)
const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id
const first = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts'))
const second = openDiffTab(first, sourcePane, diffTab('diff:c:abc1234def5678abc1234def5678abc1234def5678'))
// Still one split: the second diff joins the bottom leaf instead of splitting again.
expect(second.splits.kind).toBe('split')
const split = second.splits as { children: { kind: string; tabs?: SidebarTab[] }[] }
const diffLeaves = split.children.filter(child => child.tabs?.some(tab => tab.type === 'diff'))
expect(diffLeaves).toHaveLength(1)
expect(diffLeaves[0]!.tabs?.map(tab => tab.id)).toEqual([
'diff:w:u:src/a.ts',
'diff:c:abc1234def5678abc1234def5678abc1234def5678',
])
})
it('openDiffTab degrades to a regular open when the source pane is gone', () => {
const s = state()
const after = openDiffTab(s, 'pane:gone', diffTab('diff:w:u:src/a.ts'))
expect(after.splits.kind).toBe('leaf')
expect((after.splits as { tabs: SidebarTab[] }).tabs.map(tab => tab.id)).toContain('diff:w:u:src/a.ts')
})
it('sanitize drops diff tabs (ephemeral, like VSCode diff editors)', () => {
const valid = sanitizeState({
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: 'pane:1',
expanded: [],
splits: {
kind: 'leaf',
id: 'pane:1',
active: 'd1',
tabs: [
{ id: 'explorer-tab', type: 'explorer', title: 'Explorer' },
{ id: 'd1', type: 'diff', title: 'a.ts', diff: { kind: 'worktree', path: 'src/a.ts', staged: false } },
],
},
})
expect(valid?.splits.kind).toBe('leaf')
const tabs = (valid?.splits as { tabs: SidebarTab[] }).tabs
expect(tabs.map(tab => tab.id)).toEqual(['explorer-tab'])
// The dropped diff tab was the active one: the leaf falls back to a null
// active instead of resetting the whole state.
expect((valid?.splits as { active: string | null }).active).toBeNull()
// A leaf of ONLY diff tabs survives as an empty pane (welcome cards).
const onlyDiff = sanitizeState({
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: 'pane:1',
expanded: [],
splits: {
kind: 'leaf',
id: 'pane:1',
active: 'd1',
tabs: [{ id: 'd1', type: 'diff', title: 'a.ts' }],
},
})
expect(onlyDiff?.splits.kind).toBe('leaf')
expect((onlyDiff?.splits as { tabs: SidebarTab[] }).tabs).toEqual([])
})
it('dedupes the single-instance subagent tab (focuses instead of duplicating)', () => {
let s = state()
s = openTabInActivePane(s, { id: 'subagent', type: 'subagent', title: 'Subagents' })
expect((s.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
// Reopening (e.g. the auto-activation effect) focuses the existing tab.
const after = openTabInActivePane(s, { id: 'subagent', type: 'subagent', title: 'Subagents' })
expect((after.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
const tabs = (after.splits as { tabs: { type: string; id: string }[] }).tabs
expect(tabs.filter(tab => tab.type === 'subagent')).toHaveLength(1)
})
it('splits panes and moves tabs between them', () => {
let s = state()
s = splitPane(s, 'row')
expect(s.splits.kind).toBe('split')
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
expect(split.children).toHaveLength(2)
const explorerId = (split.children[0] as { id: string }).id
const otherId = (split.children[1] as { id: string }).id
expect((split.children[1] as { tabs: unknown[] }).tabs).toHaveLength(0)
const explorerTab = ((split.children[0] as { tabs: { id: string }[] }).tabs[0]!).id
s = moveTab(s, explorerId, explorerTab, otherId)
// The source pane emptied and was removed; the target leaf is promoted.
expect(s.splits.kind).toBe('leaf')
expect((s.splits as { id: string }).id).toBe(otherId)
expect((s.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([explorerTab])
})
it('dragging a tab to a pane edge splits the pane with the tab in a fresh leaf', () => {
let s = state()
s = splitPane(s, 'row')
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
const paneA = split.children[0] as { id: string; tabs: { id: string }[] }
const paneB = split.children[1] as { id: string; tabs: { id: string }[] }
const tabId = paneA.tabs[0]!.id
// 先给 paneB 一个 tab,然后拖 paneA 的 tab 到 paneB 的 right 边缘。
s = openTabInActivePane(s, { id: 't2', type: 'terminal', title: 'T2' })
s = moveTabToEdge(s, paneA.id, tabId, paneB.id, 'right')
const after = s.splits as Extract<SplitNode, { kind: 'split' }>
// paneB 现在是 split(row) [旧leaf, 新leaf(tabId)];其父 split 仍存在。
const bSplit = after.children.find(child => child.kind === 'split') as Extract<SplitNode, { kind: 'split' }> | undefined
expect(bSplit).toBeDefined()
expect(bSplit!.dir).toBe('row')
const newLeaf = bSplit!.children[1] as { tabs: { id: string }[] }
expect(newLeaf.tabs.map(t => t.id)).toContain(tabId)
})
it('dragging a tab to a pane center merges it into the pane', () => {
let s = state()
s = splitPane(s, 'col')
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
const paneA = split.children[0] as { id: string; tabs: { id: string }[] }
const paneB = split.children[1] as { id: string; tabs: { id: string }[] }
const tabId = paneA.tabs[0]!.id
s = moveTabToEdge(s, paneA.id, tabId, paneB.id, 'center')
// paneA 空了被移除,树退化为 paneB(含 tab)。
expect(s.splits.kind).toBe('leaf')
expect((s.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([tabId])
})
it('dragging a tab back onto its own pane center reorders it', () => {
let s = state()
s = openTabInActivePane(s, { id: 't2', type: 'terminal', title: 'T2' })
const leaf = s.splits as { id: string; tabs: { id: string }[] }
const first = leaf.tabs[0]!.id
s = moveTabToEdge(s, leaf.id, first, leaf.id, 'center')
const after = s.splits as { tabs: { id: string }[] }
expect(after.tabs[after.tabs.length - 1]!.id).toBe(first)
expect(after.tabs).toHaveLength(2)
})
it('closing the last tab removes the pane (promotes the sibling)', () => {
let s = state()
s = splitPane(s, 'col')
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
const paneA = split.children[0] as { id: string; tabs: { id: string }[] }
const paneB = split.children[1] as { id: string }
const explorerId = paneA.tabs[0]!.id
// paneA gets a terminal; the explorer moves to paneB; closing the
// terminal empties paneA, which is removed, promoting paneB.
s = openTabInActivePane(s, { id: 't', type: 'terminal', title: 'Terminal 1' })
s = moveTab(s, paneA.id, explorerId, paneB.id)
s = activateTab(s, paneA.id, 't')
s = closeTab(s, paneA.id, 't')
expect(s.splits.kind).toBe('leaf')
expect((s.splits as { id: string }).id).toBe(paneB.id)
})
it('resizes splits within the clamp range', () => {
let s = state()
s = splitPane(s, 'row')
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
const id = split.id
s = { ...s, splits: resizeSplit(s.splits, id, 0, 0.2) }
const after = s.splits as Extract<SplitNode, { kind: 'split' }>
expect(after.sizes[0]).toBeCloseTo(0.7)
expect(after.sizes[1]).toBeCloseTo(0.3)
})
it('tracks explorer expansion and tab activation', () => {
let s = state()
s = toggleExpanded(s, '/p/a')
s = toggleExpanded(s, '/p/b')
expect(s.expanded).toEqual(['/p/a', '/p/b'])
s = toggleExpanded(s, '/p/a')
expect(s.expanded).toEqual(['/p/b'])
const leaf = s.splits as { id: string; tabs: { id: string }[]; active: string | null }
const tabId = leaf.tabs[0]!.id
const after = activateTab(s, leaf.id, tabId)
expect((after.splits as { active: string | null }).active).toBe(tabId)
})
it('patchTab updates the title and path of one open tab (browser persistence)', () => {
let s = state()
const leaf = s.splits as { id: string; tabs: { id: string; type: string; title: string; path?: string }[] }
s = openTabInActivePane(s, { id: 'browser:1', type: 'browser', title: 'Browser' })
const browserId = 'browser:1'
s = patchTab(s, browserId, { path: 'https://example.com/', title: 'example.com' })
const tab = (s.splits as { tabs: { id: string; title: string; path?: string }[] }).tabs.find(t => t.id === browserId)
expect(tab).toMatchObject({ title: 'example.com', path: 'https://example.com/' })
// A partial patch leaves the other field untouched.
s = patchTab(s, browserId, { title: 'example.org' })
const again = (s.splits as { tabs: { id: string; title: string; path?: string }[] }).tabs.find(t => t.id === browserId)
expect(again).toMatchObject({ title: 'example.org', path: 'https://example.com/' })
// Other tabs are untouched.
expect(leaf.tabs[0]).toBeDefined()
})
it('patchTab is a no-op for a missing tab id', () => {
const s = state()
const after = patchTab(s, 'nope', { title: 'X', path: 'https://x/' })
expect(after).toBe(s)
})
it('sanitize accepts nextBrowser (defaulting a missing/malformed one to 1)', () => {
const base = {
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: 'pane:1',
expanded: [],
splits: {
kind: 'leaf',
id: 'pane:1',
active: null,
tabs: [{ id: 't', type: 'explorer', title: 'Explorer' }],
},
}
// Older persisted states lack the field: they must keep loading.
expect(sanitizeState(base)?.nextBrowser).toBe(1)
// A present valid value survives; a malformed one falls back to 1.
expect(sanitizeState({ ...base, nextBrowser: 7 })?.nextBrowser).toBe(7)
expect(sanitizeState({ ...base, nextBrowser: 'x' })?.nextBrowser).toBe(1)
expect(sanitizeState({ ...base, nextBrowser: 0 })?.nextBrowser).toBe(1)
// The default state seeds 1.
expect(makeDefaultState().nextBrowser).toBe(1)
})
it('tabOpenIn: a tab is open until it is truly closed, wherever it lives', () => {
let s = state()
const leaf = s.splits as { id: string; tabs: { id: string }[] }
const explorerId = leaf.tabs[0]!.id
expect(tabOpenIn(s, explorerId)).toBe(true)
// Moving the tab to another pane keeps it open.
s = splitPane(s, 'row')
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
const paneA = split.children[0] as { id: string; tabs: { id: string }[] }
const paneB = split.children[1] as { id: string }
s = moveTab(s, paneA.id, explorerId, paneB.id)
expect(tabOpenIn(s, explorerId)).toBe(true)
// Closing it removes it from the whole tree.
const target = s.splits as { id: string; tabs: { id: string }[] }
s = closeTab(s, target.id, explorerId)
expect(tabOpenIn(s, explorerId)).toBe(false)
// A terminal tab added later is open too.
s = openTabInActivePane(s, { id: 'terminal:9', type: 'terminal', title: 'Terminal 9' })
expect(tabOpenIn(s, 'terminal:9')).toBe(true)
})
// ── Bottom panel (the second, independent workbench) ───────────────────
it('toggleBottomPanel flips the bottom panel independently of the right panel', () => {
let s = state()
expect(s.bottomOpen).toBe(false)
s = toggleBottomPanel(s)
expect(s.bottomOpen).toBe(true)
// Collapsing the right panel leaves the bottom panel open (independent toggles).
s = togglePanel(s)
expect(s.panelOpen).toBe(false)
expect(s.bottomOpen).toBe(true)
})
it('setBottomHeight clamps to the contract range', () => {
expect(setBottomHeight(state(), 50).bottomHeight).toBe(BOTTOM_MIN)
const g = globalThis as Record<string, unknown>
const previous = g.window
g.window = { innerHeight: 800 }
try {
// The bottom panel must leave the center column at least PANEL_MIN
// tall (800 - 280), regardless of the right panel's open state.
expect(setBottomHeight(state(), 9999).bottomHeight).toBe(800 - 280)
expect(setBottomHeight({ ...state(), panelOpen: false }, 9999).bottomHeight).toBe(800 - 280)
} finally {
if (previous === undefined) delete g.window
else g.window = previous
}
})
// ── Narrow-viewport merge (bottom tabs thrown into the right sidebar) ──
it('migrateBottomTabs throws the bottom tree tabs into the right tree’s FIRST leaf', () => {
let s = state()
s = toggleBottomPanel(s)
const bottomPane = (s.bottomSplits as { id: string }).id
// Two bottom tabs in their own pane; the right pane holds explorer.
s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:1', type: 'terminal', title: 'T1' })
s = openTabInActivePane(s, { id: 'terminal:2', type: 'terminal', title: 'T2' })
const migrated = migrateBottomTabs(s)
// All tabs now live in the right tree's first leaf, bottom tabs appended.
expect((migrated.splits as { tabs: SidebarTab[] }).tabs.map(t => t.id))
.toEqual([expect.stringMatching(/^tab:/), 'terminal:1', 'terminal:2'])
// The bottom tree is emptied (structure stays), the panel closes, and
// new tabs land in the right tree.
expect((migrated.bottomSplits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
expect(migrated.bottomOpen).toBe(false)
expect(migrated.activePane).toBe((migrated.splits as { id: string }).id)
// The migrated tabs are fully functional: closing one works through the
// right tree.
expect(tabOpenIn(migrated, 'terminal:1')).toBe(true)
expect(tabOpenIn(closeTab(migrated, migrated.activePane!, 'terminal:1'), 'terminal:1')).toBe(false)
})
it('migrateBottomTabs appends into the FIRST leaf when the right tree is a split', () => {
let s = state()
s = splitPane(s, 'row') // splits the active pane into two leaves
s = toggleBottomPanel(s)
const bottomPane = (s.bottomSplits as { id: string }).id
s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:9', type: 'terminal', title: 'T9' })
const migrated = migrateBottomTabs(s)
// The first (leftmost) leaf carries the bottom tab; the second leaf
// keeps its own tabs untouched.
const leaves = allLeaves(migrated.splits)
expect(leaves[0]!.tabs.map(t => t.id)).toContain('terminal:9')
expect(allLeaves(migrated.bottomSplits).flatMap(l => l.tabs)).toHaveLength(0)
})
it('migrateBottomTabs is idempotent (same reference) once the bottom tree is empty and closed', () => {
const s = state()
expect(migrateBottomTabs(s)).toBe(s)
// With the panel open but no tabs, the migration only closes the panel.
const open = toggleBottomPanel(s)
const migrated = migrateBottomTabs(open)
expect(migrated).not.toBe(open)
expect(migrated.bottomOpen).toBe(false)
expect(migrateBottomTabs(migrated)).toBe(migrated)
})
it('migrateBottomTabs repoints an active pane that lives in the bottom tree', () => {
let s = state()
const bottomPane = (s.bottomSplits as { id: string }).id
s = { ...s, activePane: bottomPane } // empty bottom pane, panel closed
const migrated = migrateBottomTabs(s)
expect(migrated.activePane).toBe((migrated.splits as { id: string }).id)
// A tab opened after the migration lands in the VISIBLE right tree.
const landed = openTabInActivePane(migrated, { id: 'git', type: 'git' as const, title: 'Git' })
expect((landed.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toContain('git')
})
it('openTabInActivePane lands in the bottom tree when the active pane lives there', () => {
let s = state()
s = toggleBottomPanel(s)
const bottomPane = (s.bottomSplits as { id: string }).id
s = { ...s, activePane: bottomPane }
const tab = { id: 'git', type: 'git' as const, title: 'Git' }
s = openTabInActivePane(s, tab)
expect((s.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toContain('git')
// The right tree is untouched (its seeded files-window home tab stays).
expect((s.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toEqual(['editor'])
expect(s.activePane).toBe(bottomPane)
// The id safety net works across trees: reopening the same id focuses it.
const after = openTabInActivePane(s, tab)
expect((after.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toEqual(['git'])
})
it('openTabInActivePane falls back to the right tree when the active pane is stale', () => {
let s = state()
s = toggleBottomPanel(s)
s = { ...s, activePane: 'pane:gone' }
const after = openTabInActivePane(s, { id: 'git', type: 'git' as const, title: 'Git' })
expect((after.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toContain('git')
})
it('closeTab routes to the bottom tree', () => {
let s = state()
s = toggleBottomPanel(s)
const bottomPane = (s.bottomSplits as { id: string }).id
s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:1', type: 'terminal', title: 'T1' })
expect(tabOpenIn(s, 'terminal:1')).toBe(true)
s = closeTab(s, bottomPane, 'terminal:1')
expect(tabOpenIn(s, 'terminal:1')).toBe(false)
// The right tree is untouched.
expect(tabOpenIn(s, (s.splits as { tabs: { id: string }[] }).tabs[0]!.id)).toBe(true)
})
it('moveTabToEdge splits within the bottom tree', () => {
let s = state()
s = toggleBottomPanel(s)
const bottomPane = (s.bottomSplits as { id: string }).id
s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:1', type: 'terminal', title: 'T1' })
s = moveTabToEdge(s, bottomPane, 'terminal:1', bottomPane, 'right')
expect(s.bottomSplits.kind).toBe('split')
expect(s.splits.kind).toBe('leaf')
expect(tabOpenIn(s, 'terminal:1')).toBe(true)
// The fresh leaf (the drop's active pane) differs from the source pane.
expect(s.activePane).not.toBe(bottomPane)
})
it('resizeSplitIn routes a divider to its own tree', () => {
let s = state()
s = toggleBottomPanel(s)
const bottomPane = (s.bottomSplits as { id: string }).id
s = splitPane({ ...s, activePane: bottomPane }, 'row')
const split = s.bottomSplits as Extract<SplitNode, { kind: 'split' }>
s = resizeSplitIn(s, split.id, 0, 0.1)
const next = s.bottomSplits as Extract<SplitNode, { kind: 'split' }>
expect(next.sizes[0]).toBeCloseTo(0.6)
expect(s.splits.kind).toBe('leaf')
})
it('sanitize defaults the bottom fields for older persisted states and repairs a broken bottom tree', () => {
const base = {
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: 'pane:1',
expanded: [],
splits: {
kind: 'leaf',
id: 'pane:1',
active: null,
tabs: [{ id: 't', type: 'explorer', title: 'Explorer' }],
},
}
// Older persisted states lack the bottom fields: defaults, state kept.
const s = sanitizeState(base)
expect(s?.bottomOpen).toBe(false)
expect(s?.bottomHeight).toBe(BOTTOM_DEFAULT)
expect(s?.bottomSplits.kind).toBe('leaf')
expect((s?.bottomSplits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
// A malformed bottom tree is replaced with a fresh empty pane.
const broken = sanitizeState({ ...base, bottomSplits: 'junk' })
expect(broken?.splits).toBeDefined()
expect(broken?.bottomSplits.kind).toBe('leaf')
// A valid persisted bottom tree survives.
const withBottom = sanitizeState({
...base,
bottomOpen: true,
bottomHeight: 300,
bottomSplits: {
kind: 'leaf',
id: 'pane:9',
active: 'b1',
tabs: [{ id: 'b1', type: 'terminal', title: 'T' }],
},
})
expect(withBottom?.bottomOpen).toBe(true)
expect(withBottom?.bottomHeight).toBe(300)
expect((withBottom?.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toEqual(['b1'])
// Heights are clamped to the contract range.
expect(sanitizeState({ ...base, bottomHeight: 10 })?.bottomHeight).toBe(BOTTOM_MIN)
// A stale full-height bottom panel must not squeeze the center column
// (the agent output area) to zero: the cap leaves it at least PANEL_MIN
// tall, regardless of the right panel's open state.
const g = globalThis as Record<string, unknown>
const previous = g.window
g.window = { innerHeight: 800 }
try {
expect(sanitizeState({ ...base, panelOpen: true, bottomHeight: 9999 })?.bottomHeight).toBe(800 - 280)
expect(sanitizeState({ ...base, panelOpen: false, bottomHeight: 9999 })?.bottomHeight).toBe(800 - 280)
} finally {
if (previous === undefined) delete g.window
else g.window = previous
}
})
it('tabOpenIn and patchTab see tabs in the bottom tree', () => {
let s = state()
s = toggleBottomPanel(s)
const bottomPane = (s.bottomSplits as { id: string }).id
s = openTabInActivePane(
{ ...s, activePane: bottomPane },
{ id: 'browser:1', type: 'browser', title: 'example.com', path: 'https://example.com' },
)
expect(tabOpenIn(s, 'browser:1')).toBe(true)
s = patchTab(s, 'browser:1', { title: 'other.com', path: 'https://other.com' })
const tab = allLeaves(s.bottomSplits).flatMap(leaf => leaf.tabs).find(t => t.id === 'browser:1')
expect(tab?.title).toBe('other.com')
})
it('moves a tab across panels (center merge into the other tree)', () => {
let s = state()
s = toggleBottomPanel(s)
const rightPane = (s.splits as { id: string }).id
const bottomPane = (s.bottomSplits as { id: string }).id
const explorerId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id
// Drag the explorer tab from the right panel into the bottom panel (center).
s = moveTabToEdge(s, rightPane, explorerId, bottomPane, 'center')
expect((s.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toContain(explorerId)
expect((s.splits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
expect(s.activePane).toBe(bottomPane)
// And back, inserted at an index.
s = moveTab(s, bottomPane, explorerId, rightPane, 0)
expect((s.splits as { tabs: SidebarTab[] }).tabs[0]!.id).toBe(explorerId)
expect((s.bottomSplits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
})
it('moves a tab across panels by splitting the target pane (edge drop)', () => {
let s = state()
s = toggleBottomPanel(s)
const rightPane = (s.splits as { id: string }).id
const bottomPane = (s.bottomSplits as { id: string }).id
const explorerId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id
s = moveTabToEdge(s, rightPane, explorerId, bottomPane, 'right')
// The source tree empties back to a leaf; the target tree splits.
expect(s.splits.kind).toBe('leaf')
expect((s.splits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
expect(s.bottomSplits.kind).toBe('split')
expect(tabOpenIn(s, explorerId)).toBe(true)
const split = s.bottomSplits as Extract<SplitNode, { kind: 'split' }>
expect(split.children.some(
child => child.kind === 'leaf' && (child as { tabs: SidebarTab[] }).tabs.some(t => t.id === explorerId),
)).toBe(true)
// The fresh leaf (the drop's active pane) differs from the source pane.
expect(s.activePane).not.toBe(rightPane)
})
it('moveTab with a non-existent source or target pane is safe', () => {
const s = state()
const pane = (s.splits as { id: string }).id
// Missing source: returns unchanged state
expect(moveTab(s, 'pane:ghost', 'tab:1', pane)).toBe(s)
// Missing target: returns unchanged state
expect(moveTab(s, pane, 'tab:1', 'pane:ghost')).toBe(s)
})
it('closeTab with non-existent tab or pane returns equivalent state without throwing', () => {
const s = state()
const pane = (s.splits as { id: string }).id
expect(closeTab(s, 'pane:ghost', 'tab:1')).toEqual(s)
expect(closeTab(s, pane, 'tab:ghost')).toEqual(s)
})
it('moveTabToEdge with non-existent pane returns unchanged state', () => {
const s = state()
const pane = (s.splits as { id: string }).id
expect(moveTabToEdge(s, 'pane:ghost', 'tab:1', pane, 'right')).toBe(s)
expect(moveTabToEdge(s, pane, 'tab:1', 'pane:ghost', 'right')).toBe(s)
})
})
describe('persisted state sanitization', () => {
it('accepts a well-formed state unchanged (node environment: no width clamp)', () => {
const state = makeDefaultState(400)
const clean = sanitizeState(JSON.parse(JSON.stringify(state)))
expect(clean).toEqual(state)
})
it('accepts a subagent tab as a known type', () => {
const raw = JSON.parse(JSON.stringify(makeDefaultState(400)))
raw.splits.tabs.push({ id: 'tab:9', type: 'subagent', title: 'Subagents' })
raw.splits.active = 'tab:9'
const clean = sanitizeState(raw)
expect(clean).toBeDefined()
const tabs = (clean!.splits as { tabs: { type: string }[] }).tabs
expect(tabs.some(tab => tab.type === 'subagent')).toBe(true)
})
it('clamps undersized widths to the panel minimum', () => {
const state = { ...makeDefaultState(400), width: 10 }
const clean = sanitizeState(JSON.parse(JSON.stringify(state)))
expect(clean?.width).toBe(280)
})
it('rejects malformed shapes instead of crashing the panel', () => {
expect(sanitizeState(null)).toBeUndefined()
expect(sanitizeState('nope')).toBeUndefined()
expect(sanitizeState({})).toBeUndefined()
expect(sanitizeState({ ...makeDefaultState(400), width: 'wide' })).toBeUndefined()
expect(sanitizeState({ ...makeDefaultState(400), panelOpen: 1 })).toBeUndefined()
// A split whose sizes do not match its children is rejected.
const withSplit = JSON.parse(JSON.stringify(makeDefaultState(400)))
withSplit.splits = { kind: 'split', id: 's1', dir: 'row', sizes: [0.5], children: [] }
expect(sanitizeState(withSplit)).toBeUndefined()
// Unknown tab types (external plugins not yet loaded) are accepted —
// they render as <OrphanedTab/> at view time and recover if the plugin
// loads later. Only diff tabs are dropped (ephemeral).
const withExternalTab = JSON.parse(JSON.stringify(makeDefaultState(400)))
withExternalTab.splits.tabs[0].type = 'my-plugin:db'
const externalClean = sanitizeState(withExternalTab)
expect(externalClean).toBeDefined()
if (externalClean !== undefined && externalClean.splits.kind === 'leaf') {
expect(externalClean.splits.tabs[0]!.type).toBe('my-plugin:db')
}
// An active id that no tab carries is rejected.
const withBadActive = JSON.parse(JSON.stringify(makeDefaultState(400)))
withBadActive.splits.active = 'ghost-tab'
expect(sanitizeState(withBadActive)).toBeUndefined()
})
it('re-ids stale duplicate pane/split ids and follows the activePane rename', () => {
// The pre-seeding counter reset could mint a fresh "pane:1" beside the
// persisted "pane:1": mapLeaf then hit BOTH leaves and every open landed
// in both panes. Sanitize must give the repeat a fresh id.
const corrupted = JSON.parse(JSON.stringify(makeDefaultState(400)))
corrupted.activePane = 'pane:1'
corrupted.splits = {
kind: 'split',
id: 'split:1',
dir: 'col',
sizes: [0.5, 0.5],
children: [
{ kind: 'leaf', id: 'pane:1', tabs: [], active: null },
{ kind: 'leaf', id: 'pane:1', tabs: [{ id: 'tab:1', type: 'explorer', title: 'Explorer' }], active: 'tab:1' },
],
}
const clean = sanitizeState(corrupted)
expect(clean).toBeDefined()
const leaves = allLeaves(clean!.splits)
// The first occurrence keeps its id; the repeat gets a fresh unique one
// (exact suffix depends on the module-level uid counter, so assert shape).
expect(leaves[0]!.id).toBe('pane:1')
expect(new Set(leaves.map(leaf => leaf.id)).size).toBe(2)
expect(clean!.activePane).toBe(leaves[1]!.id)
// And an open must land in exactly one pane of the healed tree.
const opened = openTabInActivePane(clean!, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' })
const owners = allLeaves(opened.splits).filter(leaf => leaf.tabs.some(tab => tab.path === '/a.ts'))
expect(owners).toHaveLength(1)
})
it('falls back from a stale active pane instead of dropping the open', () => {
let s = makeDefaultState()
const paneA = allLeaves(s.splits)[0]!.id
const seededTab = allLeaves(s.splits)[0]!.tabs.find(tab => tab.type === 'editor')!.id
s = closeTab(s, paneA, seededTab)
s = openTabInActivePane(s, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' })
const split = insertLeafAt(s.splits, paneA, 'col', { id: 'terminal:1', type: 'terminal', title: 'Terminal 1' }, false)
s = { ...s, splits: split.node, activePane: paneA }
// Closing the editor empties paneA; the pane is removed but activePane
// still points at it. The next open must land in the surviving pane.
s = closeTab(s, paneA, 'editor:/a.ts')
s = openTabInActivePane(s, { id: 'editor:/b.ts', type: 'editor', title: 'b.ts', path: '/b.ts' })
const owners = allLeaves(s.splits).filter(leaf => leaf.tabs.some(tab => tab.path === '/b.ts'))
expect(owners).toHaveLength(1)
expect(owners[0]!.tabs.some(tab => tab.type === 'terminal')).toBe(true)
})
it('handles state with deeply corrupted split children gracefully', () => {
const corrupted = {
...makeDefaultState(400),
splits: {
kind: 'split',
id: 's1',
dir: 'row',
sizes: [0.5, 0.5],
children: [
{ kind: 'leaf', id: 'pane:1', tabs: null, active: null },
{ kind: 'leaf', id: 'pane:2', tabs: [{ id: 'tab:1', type: 'explorer', title: 'Explorer' }], active: 'tab:1' },
],
},
}
expect(sanitizeState(corrupted)).toBeUndefined()
})
})
describe('v0.12.0 store additions', () => {
// These blocks exercise store reduce/reduceFor (which schedule the
// localStorage persist through window timers) and sanitizeState (which
// reads window.innerHeight). Stub the browser globals ONLY inside this
// scope so the earlier describes keep their window-less environment.
beforeEach(() => {
const g = globalThis as Record<string, unknown>
g.window = { clearTimeout: () => {}, setTimeout: () => 0, innerWidth: 1024, innerHeight: 800 }
g.localStorage = { getItem: () => null, setItem: () => {} }
})
afterEach(() => {
const g = globalThis as Record<string, unknown>
delete g.window
delete g.localStorage
})
describe('store.reduceFor (targeted opens, v0.12.0)', () => {
it('mutates the target session, persists it, and leaves the active snapshot untouched', () => {
const store = createSidebarStore()
store.setSession('s1')
let calls = 0
store.subscribe(() => { calls++ })
store.reduceFor('s2', (state) => ({ ...state, expanded: ['/x'] }))
// No notify, no snapshot switch.
expect(calls).toBe(0)
expect(store.getSnapshot().sessionId).toBe('s1')
// The target session's state updated and loads back on switch.
store.setSession('s2')
expect(store.getSnapshot().state?.expanded).toEqual(['/x'])
})
it('loads a fresh state for a never-visited target session', () => {
const store = createSidebarStore()
store.setSession('s1')
store.reduceFor('brand-new', (state) => ({ ...state, panelOpen: false }))
store.setSession('brand-new')
expect(store.getSnapshot().state?.panelOpen).toBe(false)
expect(store.getSnapshot().state?.splits).toBeDefined()
})
it('reduceFor never lowers the shared uid counter below the active session needs (no pane-id collision)', () => {
const store = createSidebarStore()
// Session 'b' is cached FIRST with a LOW id range (pane:1 / tab:2).
store.setSession('b')
// Session 'a' then loads and its operations raise the shared counter
// well above b's max (default pane, plus fresh pane ids from splits).
store.setSession('a')
store.reduce(s => splitPane(s, 'row'))
const before = allLeaves(store.getSnapshot().state!.splits).map(leaf => leaf.id).sort()
// A targeted reduce into the OLD, low-id session must not lower the
// counter: the next split in the ACTIVE session would otherwise mint
// an id that already exists (mapLeaf visits both leaves → corruption).
store.reduceFor('b', (state) => state)
store.reduce(s => splitPane(s, 'row'))
const after = allLeaves(store.getSnapshot().state!.splits).map(leaf => leaf.id)
expect(new Set(after).size).toBe(after.length)
// The active session's pre-existing pane ids all survived untouched.
for (const id of before) expect(after).toContain(id)
})
it('persists each session independently (per-session debounce timers)', () => {
const g = globalThis as Record<string, unknown>
let seq = 0
const timers = new Map<number, () => void>()
const writes: string[] = []
g.window = {
clearTimeout: (id: number) => { timers.delete(id) },
setTimeout: (fn: () => void) => { const id = ++seq; timers.set(id, fn); return id },
innerWidth: 1024,
innerHeight: 800,
}
g.localStorage = {
getItem: () => null,
setItem: (key: string) => { writes.push(key) },
}
try {
const store = createSidebarStore()
store.setSession('a')
store.reduce(s => ({ ...s, expanded: ['/a'] })) // schedules persist(a)
store.reduceFor('b', s => ({ ...s, expanded: ['/b'] })) // schedules persist(b)
// A shared timer would have cancelled persist(a) — with per-session
// timers BOTH writes are pending and both land when they fire.
expect(timers.size).toBe(2)
for (const [, fn] of [...timers]) fn()
expect(writes).toEqual(['dsh-sidebar:v1:a', 'dsh-sidebar:v1:b'])
} finally {
delete g.window
delete g.localStorage
}
})
})
describe('tab meta persistence (v0.12.0)', () => {
it('sanitizeState carries plugin meta through a reload round-trip', () => {
const store = createSidebarStore()
store.setSession('s1')
store.reduce(s => ({
...s,
splits: {
kind: 'leaf' as const,
id: 'pane:1',
tabs: [{ id: 'tab:1', type: 'db', title: 'DB', meta: { q: [1, 2], n: 0 } }],
active: 'tab:1',
},
}))
const sanitized = sanitizeState(JSON.parse(JSON.stringify(store.getSnapshot().state!)))
const tabs = allLeaves(sanitized!.splits).flatMap(leaf => leaf.tabs)
expect(tabs[0]?.meta).toEqual({ q: [1, 2], n: 0 })
})
})
})
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { contentText, lastActivity } from '../src/client/subagent-activity.ts'
import type { SidebarHistoryEntry } from '../src/context-types.ts'
describe('subagent activity summary parser', () => {
/** One history entry from raw event fields. */
const entry = (type: string, data: Record<string, unknown>): SidebarHistoryEntry => ({
event: { type, seq: 0, time: 0, data },
})
it('extracts text blocks and skips non-text content', () => {
// Text blocks join as paragraphs (newline-separated).
expect(contentText([{ type: 'text', text: 'hello' }, { type: 'text', text: ' world' }])).toBe('hello\n world')
expect(contentText([{ type: 'tool_use', name: 'bash' }])).toBeUndefined()
expect(contentText(undefined)).toBeUndefined()
expect(contentText('nope')).toBeUndefined()
})
it('lastActivity returns the LAST text output and the LAST tool call', () => {
const live = lastActivity([
entry('turn/start', { turn: 1 }),
entry('user/message', { content: [{ type: 'text', text: '请检查代码' }] }),
entry('tool/call', { callId: 'c1', name: 'bash', arguments: '{"command":"ls -la"}' }),
entry('assistant/message', {
turn: 1, step: 1,
message: { content: [{ type: 'text', text: '检查完毕' }] },
}),
entry('tool/call', { callId: 'c2', name: 'read', arguments: '{"path":"a.ts"}' }),
entry('assistant/message', {
turn: 1, step: 2,
message: { content: [{ type: 'text', text: '再看一眼' }] },
}),
])
expect(live).toEqual({
text: '再看一眼',
tool: { name: 'read', args: '{"path":"a.ts"}' },
})
})
it('lastActivity keeps only the fields the tail actually has', () => {
expect(lastActivity([
entry('tool/call', { callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' }),
])).toEqual({ tool: { name: 'bash', args: '{"command":"ls"}' } })
expect(lastActivity([
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'ok' }] } }),
])).toEqual({ text: 'ok' })
})
it('lastActivity ignores lifecycle events, chunks, and text-less messages', () => {
const live = lastActivity([
entry('turn/end', { turn: 1, reason: 'success' }),
entry('step/start', { turn: 1, step: 1 }),
entry('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text', delta: 'x' } }),
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'tool_use', name: 'bash' }] } }),
])
expect(live).toEqual({})
expect(lastActivity([])).toEqual({})
})
it('lastActivity defaults a missing tool name and tolerates non-string arguments', () => {
const live = lastActivity([
entry('tool/call', { callId: 'c1' }),
entry('tool/call', { callId: 'c2', name: 'web', arguments: { url: 'x' } }),
])
expect(live.tool).toEqual({ name: 'web', args: '' })
})
})
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest'
import {
collectBranchIds, countSubagentDescendants, detectNewDirectSubagent,
directSubagentCount, rootAncestor,
} from '../src/client/subagent-detect.ts'
import type { SidebarSessionList, SidebarSubagentCatalog } from '../src/context-types.ts'
describe('subagent detection over the sessions list feed', () => {
/** A list snapshot carrying the given direct subagent children of `parent`. */
const list = (
parent: string,
childIds: string[],
running: string[] = [],
): SidebarSessionList => {
const byId: SidebarSessionList['byId'] = { [parent]: { id: parent, displayTitle: 'Parent' } }
for (const id of childIds) {
byId[id] = {
id,
displayTitle: `Child ${id}`,
origin: 'subagent',
parentId: parent,
running: running.includes(id),
}
}
return { current: parent, byId }
}
it('counts only the direct subagent children of the given session', () => {
const snapshot = list('p1', ['c1', 'c2'])
snapshot.byId['other'] = { id: 'other', displayTitle: 'Other', origin: 'subagent', parentId: 'p2' }
expect(directSubagentCount(snapshot.byId, 'p1')).toBe(2)
expect(directSubagentCount(snapshot.byId, 'p2')).toBe(1)
expect(directSubagentCount(snapshot.byId, 'p1-nobody')).toBe(0)
})
it('fires only on the 0 → N transition of the current session', () => {
const empty = list('p1', [])
const one = list('p1', ['c1'])
const two = list('p1', ['c1', 'c2'])
expect(detectNewDirectSubagent(empty, empty, 'p1')).toBe(false)
expect(detectNewDirectSubagent(empty, one, 'p1')).toBe(true)
// Already-present children never re-trigger (session switch, reload).
expect(detectNewDirectSubagent(one, two, 'p1')).toBe(false)
expect(detectNewDirectSubagent(two, one, 'p1')).toBe(false)
// A child arriving under ANOTHER session does not trigger this one.
expect(detectNewDirectSubagent(empty, list('p2', ['x']), 'p1')).toBe(false)
})
it('indexes descendants through uninterrupted subagent lineage', () => {
// p1 → c1 (subagent) → g1 (subagent child of c1).
const byId: SidebarSessionList['byId'] = {
p1: { id: 'p1', displayTitle: 'P1' },
c1: { id: 'c1', displayTitle: 'C1', origin: 'subagent', parentId: 'p1', running: true },
g1: { id: 'g1', displayTitle: 'G1', origin: 'subagent', parentId: 'c1' },
}
expect(countSubagentDescendants(byId, 'p1')).toEqual({ count: 2, runningCount: 1 })
expect(countSubagentDescendants(byId, 'c1')).toEqual({ count: 1, runningCount: 0 })
expect(countSubagentDescendants(byId, 'g1')).toEqual({ count: 0, runningCount: 0 })
// An ordinary fork between the parent and the subagent cuts that lineage:
// c2 is c1's sibling in origin but its parent `fork` is not a subagent,
// so it never reaches p1 (only c1 and g1 count under p1).
const forked: SidebarSessionList['byId'] = {
...byId,
fork: { id: 'fork', displayTitle: 'Fork', parentId: 'p1' },
c2: { id: 'c2', displayTitle: 'C2', origin: 'subagent', parentId: 'fork' },
}
expect(countSubagentDescendants(forked, 'p1')).toEqual({ count: 2, runningCount: 1 })
expect(countSubagentDescendants(forked, 'fork')).toEqual({ count: 1, runningCount: 0 })
// Cycles terminate (fail soft, never hang); both rows in a 2-cycle reach
// the queried node once each.
const cyclic: SidebarSessionList['byId'] = {
a: { id: 'a', displayTitle: 'A', origin: 'subagent', parentId: 'b' },
b: { id: 'b', displayTitle: 'B', origin: 'subagent', parentId: 'a' },
}
expect(countSubagentDescendants(cyclic, 'a')).toEqual({ count: 2, runningCount: 0 })
})
it('resolves the main-agent root of the current session tree', () => {
const byId: SidebarSessionList['byId'] = {
main: { id: 'main', displayTitle: 'Main' },
child: { id: 'child', displayTitle: 'Child', origin: 'subagent', parentId: 'main' },
grand: { id: 'grand', displayTitle: 'Grand', origin: 'subagent', parentId: 'child' },
}
// An ordinary session is its own root; a deep subagent walks up to the main agent.
expect(rootAncestor(byId, 'main')).toBe('main')
expect(rootAncestor(byId, 'child')).toBe('main')
expect(rootAncestor(byId, 'grand')).toBe('main')
// A broken chain (parent not in the mirror) degrades to the session itself.
expect(rootAncestor(byId, 'orphan')).toBe('orphan')
// A hydrating session row degrades to the session itself.
expect(rootAncestor(byId, 'not-listed')).toBe('not-listed')
expect(rootAncestor(byId, undefined)).toBeUndefined()
})
it('collects every catalog branch of the topology, cycle-safe', () => {
const child = (id: string, hasChildren: boolean): SidebarSubagentCatalog['entries'][number] => ({
kind: 'child', id, activity: 'inactive', hasChildren, mode: 'one-shot',
})
const catalogs: Record<string, SidebarSubagentCatalog> = {
root: { entries: [child('a', true), child('b', false)], parentAvailable: true, state: 'ready', error: null },
a: { entries: [child('c', false)], parentAvailable: true, state: 'ready', error: null },
}
expect(collectBranchIds(catalogs, 'root')).toEqual(['a'])
expect(collectBranchIds(catalogs, undefined)).toEqual([])
// A cycle terminates (each branch id collected at most once, no hang).
const cyclic: Record<string, SidebarSubagentCatalog> = {
root: { entries: [child('a', true)], parentAvailable: true, state: 'ready', error: null },
a: { entries: [child('root', true)], parentAvailable: true, state: 'ready', error: null },
}
expect(collectBranchIds(cyclic, 'root')).toEqual(['a', 'root'])
})
})
+82
View File
@@ -0,0 +1,82 @@
/**
* TerminalDepsBanner tests (issue #140): the degraded-mode banner the
* terminal tab shows when node-pty fails to load. It must render the
* failure title, the PASTEABLE repair command (bash / cmd / PowerShell),
* a copy button that writes the command to the clipboard with a transient
* "Copied" feedback, and a retry button.
*
* The banner is tested directly (like PluginListBody): it is a pure
* presentational component, so server-side renderToString covers the
* static content and createRoot + act() covers the copy click.
*/
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { renderToString } from 'react-dom/server'
import { createRoot } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
import { TerminalDepsBanner } from '../src/client/TerminalView.tsx'
// The act() environment flag (React 18.2 reads it before flushing effects).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
const deps = {
ok: false as const,
cause: 'Cannot find package node-pty',
command: 'bash "/Users/u/.dsh/profiles/web/node_modules/.pnpm/dsh-better-sidebar@0.0.0/node_modules/dsh-better-sidebar/scripts/install.sh" --repair --profile "web"',
profile: 'web',
}
describe('TerminalDepsBanner (render)', () => {
it('renders the failure title, the pasteable command and the actions', () => {
const html = renderToString(createElement(TerminalDepsBanner, { deps, onRetry: () => {} }))
// The jsdom locale is en-US, so the en dictionary applies.
expect(html).toContain('Terminal dependency node-pty failed to load')
// renderToString HTML-escapes the quotes inside the command block.
expect(html).toContain(deps.command.replaceAll('"', '&quot;'))
expect(html).toContain('Copy')
expect(html).toContain('Retry')
// The profile the command targets is shown next to the hint.
expect(html).toContain('detected profile: web')
})
})
describe('TerminalDepsBanner copy click (interactive)', () => {
it('clicking Copy writes the repair command and flashes "Copied"', async () => {
vi.spyOn(primitives, 'writeClipboard').mockResolvedValue(true)
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
const onRetry = vi.fn()
act(() => {
root.render(createElement(TerminalDepsBanner, { deps, onRetry }))
})
const copyButton = container.querySelector('button[aria-label="Copy"]') as HTMLButtonElement
expect(copyButton).not.toBeNull()
expect(copyButton.textContent).toBe('Copy')
await act(async () => { copyButton.click() })
expect(primitives.writeClipboard).toHaveBeenCalledWith(deps.command)
expect(copyButton.textContent).toBe('Copied')
act(() => { root.unmount() })
container.remove()
vi.restoreAllMocks()
})
it('clicking Retry fires the callback (the view reconnects)', async () => {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
const onRetry = vi.fn()
act(() => {
root.render(createElement(TerminalDepsBanner, { deps, onRetry }))
})
const buttons = [...container.querySelectorAll('button')] as HTMLButtonElement[]
const retry = buttons.find(button => button.textContent === 'Retry')
expect(retry).toBeDefined()
await act(async () => { retry!.click() })
expect(onRetry).toHaveBeenCalledTimes(1)
act(() => { root.unmount() })
container.remove()
})
})
+82
View File
@@ -0,0 +1,82 @@
/**
* 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 { 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('')
})
})
-1741
View File
File diff suppressed because it is too large Load Diff