Files
apes-sidebar/tests/smoke.spec.ts
T
Menghuan1918 223b2545d9 refactor(shell): retire the plugin's own right panel and free windows
DSH 0.1.5 owns the right column (its native Sidebar hosts every tab type
this plugin registers), so the plugin's own right panel, its width drag /
new-session seed prefs, and the free-window feature are gone. The bottom
workbench is the only plugin-owned surface left, and its expand/collapse
control is registered into DSH's session-header utilities list slot (the
header's corner seat belongs to the native sidebar's own control).

- state: one `bottomSplits` tree; `makeDefaultState()` takes no arguments;
  `toggleBottomPanel` / `openTabInBottomPane` are the only landing
  reducers; `setBottomHeight` and `sanitizeState` share the
  `CONVERSATION_MIN` cap (a persisted tall height no longer snaps on the
  first drag).
- service: every landing in the workbench (creation or focus) expands it;
  `SIDEBAR_FEATURES` drops `'floatWindows'`; the open/close/activate tab
  pools cover the single tree.
- shell: `Sidebar.tsx` renders only the bottom workbench; the toggle lives
  in `sidebar/bottom-toggle.tsx`; layout-push pushes height only (no width,
  no `--dsh-sidebar-width`).
- prefs: `openByDefault` / `defaultWidthPercent` / `changesDiffFloat`
  removed from the schema, defaults, client parser, settings page, and all
  20 dictionaries; the retired keys in stored documents are ignored.
- tests: free-window specs deleted, panel/float assertions dropped,
  remaining suites adapted (124 files / 1287 tests); the mount lane asserts
  the header toggle and sweeps the native sidebar's guide page.
- docs: README / README_EN / guide §0,§11 / AGENTS test map.
2026-09-09 15:18:06 +08:00

1262 lines
53 KiB
TypeScript

/**
* Smoke spec: mounts the host plugin against a minimal fake context and
* exercises the real integrations — route registration, git against the
* actual repository, and a real directory listing. Runs with `pnpm test`.
*/
import { describe, expect, it, vi } from 'vitest'
import { spawnSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve as resolvePath } from 'node:path'
import { SettingsConflictError, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { apply, mediaTypeForPath, wsCloseReasonOf } from '../src/index.ts'
import { SidebarError } from '../src/wire.ts'
import { encodeHtmlUrl } from '../src/html-route.ts'
import * as git from '../src/git.ts'
import { listDirectory } from '../src/fs-tree.ts'
import { defaultShell, PtyManager, type SidebarPty } from '../src/pty-manager.ts'
import type { SidebarWebRoute, SidebarWebUpgradeRoute } from '../src/context-types.ts'
/** Symlink creation may require elevated privileges on Windows. */
const canCreateSymlink = (() => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-security-probe-'))
try {
mkdirSync(join(dir, 'target'))
symlinkSync(join(dir, 'target'), join(dir, 'link'))
return true
} catch {
return false
} finally {
rmSync(dir, { recursive: true, force: true })
}
})()
interface FakeContext {
webRuntime: { trustedHosts: readonly string[] }
webServer: {
register: (route: SidebarWebRoute) => () => void
registerUpgrade: (route: SidebarWebUpgradeRoute) => () => void
}
sessions: { get: (id: string) => { header: { cwd?: string } } | undefined }
tools: { register: (tool: unknown) => () => void }
effect: (fn: () => void | (() => void), label?: string) => void
/** The session/agent event feeds: nothing emits in these tests. */
on: (event: string, listener: (payload: never) => void) => () => void
/** The settings service never appears in the smoke context: the inject
* callback must never run (mirror of cordis' service-less inject). */
inject: (deps: readonly string[], callback: (sctx: never) => void) => () => void
/** Optional services (jobs/agents) are read lazily; absent → undefined. */
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')
expect(mediaTypeForPath('/work/archive.bin')).toBe('application/octet-stream')
})
it('mounts the fenced routes', () => {
const routes: SidebarWebRoute[] = []
const upgrades: SidebarWebUpgradeRoute[] = []
const effects: Array<() => void | (() => void)> = []
const ctx: FakeContext = {
webRuntime: { trustedHosts: [] },
webServer: {
register: (route) => { routes.push(route); return () => {} },
registerUpgrade: (route) => { upgrades.push(route); return () => {} },
},
sessions: { get: () => undefined },
tools: { register: () => () => {} },
// The DSH-vendored cordis runs the registration effect immediately and
// keeps its cleanup for disposal.
effect: (fn) => {
const cleanup = fn()
if (typeof cleanup === 'function') effects.push(cleanup)
},
// No settings service in the smoke context: the registration callback
// never runs (cordis' service-less inject behaves the same).
inject: () => () => {},
on: () => () => {},
// No jobs/agents services: the jobs routes degrade to a 503.
get: () => undefined,
}
apply(ctx as never)
expect(routes.map(route => route.path)).toEqual([
'/sidebar/api',
'/sidebar/upload',
'/sidebar/bundle',
'/sidebar/file',
'/sidebar/html',
])
expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/terminal', '/sidebar/ws/agent-terminals', '/sidebar/ws/agent-opens'])
// Teardown runs without throwing (pty manager has nothing open).
for (const cleanup of effects) cleanup()
})
it('serves HTML previews as UTF-8 without changing the file bytes', async () => {
const directory = mkdtempSync(join(tmpdir(), 'dsh-sidebar-html-utf8-'))
const path = join(directory, 'fragment.html')
const source = Buffer.from('<div>排序算法可视化</div>', 'utf8')
writeFileSync(path, source)
const routes: SidebarWebRoute[] = []
const effects: Array<() => void | (() => void)> = []
const ctx: FakeContext = {
webRuntime: { trustedHosts: [] },
webServer: {
register: (route) => { routes.push(route); return () => {} },
registerUpgrade: () => () => {},
},
sessions: { get: () => ({ header: { cwd: directory } }) },
tools: { register: () => () => {} },
effect: (fn) => {
const cleanup = fn()
if (typeof cleanup === 'function') effects.push(cleanup)
},
inject: () => () => {},
on: () => () => {},
get: () => undefined,
}
try {
apply(ctx as never)
const route = routes.find(candidate => candidate.path === '/sidebar/html')!
const req = {
method: 'GET',
url: encodeHtmlUrl('s-html', path),
headers: { host: '127.0.0.1:3080' },
} as never
const response: { status?: number; headers?: Record<string, string>; chunks: Buffer[] } = { chunks: [] }
const res = {
writeHead: (status: number, headers?: Record<string, string>) => {
response.status = status
response.headers = headers
},
end: (chunk?: string | Buffer) => {
if (chunk !== undefined) response.chunks.push(Buffer.from(chunk))
},
} as never
await route.handler(req, res)
expect(response.status).toBe(200)
expect(Buffer.concat(response.chunks)).toEqual(source)
expect(response.headers?.['content-type']).toBe('text/html; charset=utf-8')
} finally {
for (const cleanup of effects) cleanup()
rmSync(directory, { recursive: true, force: true })
}
})
it('runs git status/log/branches against this repository', async () => {
const cwd = process.cwd()
const status = await git.status(cwd)
expect(status.isRepo).toBe(true)
expect(typeof status.branch).toBe('string')
expect(Array.isArray(status.entries)).toBe(true)
const log = await git.log(cwd)
expect(log.length).toBeGreaterThan(0)
expect(log[0]!.hash).toMatch(/^[0-9a-f]{7,}$/)
const branches = await git.branches(cwd)
expect(branches.names).toContain(branches.current)
})
it('enriches the log (full hash + refs) and renders commit diffs', async () => {
const cwd = process.cwd()
const log = await git.log(cwd)
const first = log[0]!
expect(first.hashFull).toMatch(/^[0-9a-f]{40}$/)
expect(typeof first.refs).toBe('string')
const patch = await git.commitDiff(cwd, first.hashFull)
expect(patch).toContain('diff --git')
})
it('pages the log lazily with skip/count', async () => {
const cwd = process.cwd()
const first = await git.log(cwd, 5, 0)
expect(first).toHaveLength(5)
const second = await git.log(cwd, 5, 5)
expect(second).toHaveLength(5)
// The pages are disjoint windows over the same ordered history.
expect(first[0]!.hashFull).not.toBe(second[0]!.hashFull)
const all = await git.log(cwd, 10, 0)
expect(all.slice(0, 5)).toEqual(first)
expect(all.slice(5)).toEqual(second)
// A skip past the end returns an empty page (the lazy loader's stop sign).
expect(await git.log(cwd, 5, 10_000)).toEqual([])
})
it('pty manager releases the quota on close and respawns after exit', async () => {
const manager = new PtyManager(defaultShell(), 3)
try {
const first = manager.open('s1', 't1', process.cwd(), 80, 24)
expect(manager.keysOf('s1')).toHaveLength(1)
// Tab-close semantics (close frame): quota released immediately.
manager.scheduleClose(first.key, 0)
await new Promise(resolve => setTimeout(resolve, 50))
expect(manager.keysOf('s1')).toHaveLength(0)
// Reopen spawns a fresh process.
const second = manager.open('s1', 't1', process.cwd(), 80, 24)
expect(second).not.toBe(first)
expect(manager.keysOf('s1')).toHaveLength(1)
// After the shell exits, a reconnect respawns instead of reusing the dead handle.
second.pty.write('exit\r')
const deadline = Date.now() + 5000
while (!second.exited && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 100))
}
expect(second.exited).toBe(true)
const third = manager.open('s1', 't1', process.cwd(), 80, 24)
expect(third.exited).toBe(false)
expect(third).not.toBe(second)
} finally {
manager.disposeAll()
}
})
it('pty manager: exited zombie handles do not consume the quota', async () => {
const manager = new PtyManager(defaultShell(), 1)
try {
const first = manager.open('s3', 't1', process.cwd(), 80, 24)
first.pty.write('exit\r')
const deadline = Date.now() + 5000
while (!first.exited && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 100))
}
expect(first.exited).toBe(true)
// Quota is 1; the exited handle is swept, so a NEW tab can still spawn.
const second = manager.open('s3', 't2', process.cwd(), 80, 24)
expect(second.exited).toBe(false)
expect(manager.keysOf('s3')).toHaveLength(1)
} finally {
manager.disposeAll()
}
})
it('pty manager: a reconnect within the grace period cancels the pending close', async () => {
const manager = new PtyManager(defaultShell(), 3)
try {
const handle = manager.open('s2', 't1', process.cwd(), 80, 24)
manager.scheduleClose(handle.key, 200)
manager.open('s2', 't1', process.cwd(), 80, 24)
await new Promise(resolve => setTimeout(resolve, 400))
expect(manager.get(handle.key)).toBeDefined()
} finally {
manager.disposeAll()
}
})
it('pty manager: a parked pty survives past the reconnect grace (session switch)', async () => {
const manager = new PtyManager(defaultShell(), 3)
try {
const handle = manager.open('s2', 't1', process.cwd(), 80, 24)
manager.park(handle.key)
expect(manager.isParked(handle.key)).toBe(true)
// A parked pty does NOT enter the grace countdown — it stays alive
// well past any realistic reconnectGraceMs.
await new Promise(resolve => setTimeout(resolve, 300))
expect(manager.get(handle.key)).toBeDefined()
expect(manager.isParked(handle.key)).toBe(true)
} finally {
manager.disposeAll()
}
})
it('pty manager: a reconnecting view clears the parked state (switch back)', () => {
const manager = new PtyManager(defaultShell(), 3)
try {
const handle = manager.open('s2', 't1', process.cwd(), 80, 24)
manager.park(handle.key)
expect(manager.isParked(handle.key)).toBe(true)
// open() calls cancelClose(), which clears the parked state — the
// user switched back to the session and the view reattached.
manager.open('s2', 't1', process.cwd(), 80, 24)
expect(manager.isParked(handle.key)).toBe(false)
expect(manager.get(handle.key)).toBeDefined()
} finally {
manager.disposeAll()
}
})
it('pty manager: an explicit close frame on a parked pty still kills it', async () => {
const manager = new PtyManager(defaultShell(), 3)
try {
const handle = manager.open('s2', 't1', process.cwd(), 80, 24)
manager.park(handle.key)
// The user switched back and closed the tab — scheduleClose (the
// close-frame handler) clears the parked state and kills the pty.
manager.scheduleClose(handle.key, 0)
expect(manager.isParked(handle.key)).toBe(false)
await new Promise(resolve => setTimeout(resolve, 50))
expect(manager.get(handle.key)).toBeUndefined()
} finally {
manager.disposeAll()
}
})
it('pty manager: park on an unknown key is a no-op', () => {
const manager = new PtyManager(defaultShell(), 3)
expect(() => manager.park('s2:nonexistent')).not.toThrow()
expect(manager.isParked('s2:nonexistent')).toBe(false)
})
it('pty manager: reopening with a different cwd respawns in the new directory', async () => {
const manager = new PtyManager(defaultShell(), 3)
// A real second directory: os.tmpdir() exists on every platform ('/tmp'
// does not exist on Windows).
const other = tmpdir()
try {
const first = manager.open('s4', 't1', process.cwd(), 80, 24)
// The hydrate race: the first connect fell back to the process cwd,
// the reconnect carries the session's real cwd — the shell must move.
const second = manager.open('s4', 't1', other, 80, 24)
expect(second).not.toBe(first)
expect(second.cwd).toBe(other)
expect(manager.keysOf('s4')).toHaveLength(1)
// A same-cwd reconnect reattaches without respawning.
const third = manager.open('s4', 't1', other, 80, 24)
expect(third).toBe(second)
expect(manager.keysOf('s4')).toHaveLength(1)
} finally {
manager.disposeAll()
}
})
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)
expect(listing.entries.some(entry => entry.name === 'package.json' && !entry.isDir)).toBe(true)
expect(listing.truncated).toBe(false)
})
})
/**
* Destructive git operations (discard / revert / cherry-pick) run against a
* throwaway repository under the OS temp dir — never the plugin repo. The
* fixture's commit identity comes from the GIT_AUTHOR / GIT_COMMITTER
* environment variables, confined to the fixture process: no git config is
* touched anywhere (the plugin never sets an identity, and neither does its
* test fixture).
*/
describe('git destructive operations (scratch repository)', () => {
const FIXTURE_IDENTITY = {
GIT_AUTHOR_NAME: 'dsh-better-sidebar-test',
GIT_AUTHOR_EMAIL: 'test@dsh.invalid',
GIT_COMMITTER_NAME: 'dsh-better-sidebar-test',
GIT_COMMITTER_EMAIL: 'test@dsh.invalid',
}
const gitRun = (cwd: string, args: string[]): string => {
const result = spawnSync('git', ['-C', cwd, '--no-pager', '-c', 'color.ui=false', ...args], {
encoding: 'utf8',
env: { ...process.env, ...FIXTURE_IDENTITY },
})
if (result.status !== 0) {
throw new Error(result.stderr || `git ${args[0] ?? ''} exited with ${String(result.status)}`)
}
return result.stdout
}
/** A fresh repo on branch `main` with one committed file `a.txt`. */
const makeScratchRepo = (): string => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-git-'))
gitRun(dir, ['init', '-q'])
// Pin the eol policy: Git for Windows defaults to core.autocrlf=true
// (system gitconfig on the CI runner, and many dev machines), which
// smudges LF→CRLF on every index restore and breaks the byte-exact
// assertions below. The destructive-op behavior under test is orthogonal
// to the machine's eol policy.
gitRun(dir, ['config', 'core.autocrlf', 'false'])
gitRun(dir, ['checkout', '-q', '-b', 'main'])
writeFileSync(join(dir, 'a.txt'), 'one\ntwo\nthree\n')
gitRun(dir, ['add', '-A'])
gitRun(dir, ['commit', '-q', '-m', 'base'])
return dir
}
it('discard restores the worktree file from the index (staged changes kept)', async () => {
const dir = makeScratchRepo()
try {
// Unstaged-only changes: fully reverts to the committed content.
writeFileSync(join(dir, 'a.txt'), 'one\nCHANGED\nthree\n')
await git.discard(dir, 'a.txt')
expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('one\ntwo\nthree\n')
// Staged changes: the worktree snaps back to the STAGED content and
// the index is untouched (`git checkout -- <path>` restores from the
// index — VSCode's "Discard Changes" semantics).
writeFileSync(join(dir, 'a.txt'), 'one\nCHANGED\nthree\n')
gitRun(dir, ['add', '-A'])
await git.discard(dir, 'a.txt')
expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('one\nCHANGED\nthree\n')
const staged = await git.diff(dir, 'a.txt', true)
expect(staged).toContain('-two')
expect(staged).toContain('+CHANGED')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('revert creates a revert commit', async () => {
const dir = makeScratchRepo()
try {
writeFileSync(join(dir, 'a.txt'), 'one\nTWO\nthree\n')
gitRun(dir, ['add', '-A'])
gitRun(dir, ['commit', '-q', '-m', 'change'])
const featureHash = (await git.log(dir))[0]!.hashFull
await git.revert(dir, featureHash)
expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('one\ntwo\nthree\n')
expect((await git.log(dir))[0]!.subject).toBe('Revert "change"')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('cherry-pick applies a commit from another branch', async () => {
const dir = makeScratchRepo()
try {
gitRun(dir, ['checkout', '-q', '-b', 'feature'])
writeFileSync(join(dir, 'b.txt'), 'feature work\n')
gitRun(dir, ['add', '-A'])
gitRun(dir, ['commit', '-q', '-m', 'feature work'])
const featureHash = (await git.log(dir))[0]!.hashFull
gitRun(dir, ['checkout', '-q', 'main'])
await git.cherryPick(dir, featureHash)
expect(readFileSync(join(dir, 'b.txt'), 'utf8')).toBe('feature work\n')
expect((await git.log(dir))[0]!.subject).toBe('feature work')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('reports a failing destructive operation as a GitCommandError', async () => {
const dir = makeScratchRepo()
try {
// An unknown revision fails before touching anything.
await expect(git.revert(dir, 'deadbeef00000000000000000000000000000000')).rejects.toThrow()
await expect(git.cherryPick(dir, 'deadbeef00000000000000000000000000000000')).rejects.toThrow()
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})
describe('session cwd resolution over the API route', () => {
interface CtxOverrides {
sessions?: { get: (id: string) => { header: { cwd?: string } } | undefined }
sessionPersistence?: { open: (id: string, access: 'read' | 'write') => Promise<{ header: { cwd?: string }; read: () => Promise<{ events: never[] }>; close: () => Promise<void> }> }
}
const mountAll = (overrides: CtxOverrides = {}): SidebarWebRoute[] => {
const routes: SidebarWebRoute[] = []
const ctx = {
webRuntime: { trustedHosts: [] },
webServer: {
register: (route: SidebarWebRoute) => { routes.push(route); return () => {} },
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
},
sessions: overrides.sessions ?? { get: () => undefined },
tools: { register: () => () => {} },
// The vendored cordis runs registration effects immediately.
effect: (fn: () => void | (() => void)) => { fn() },
// No settings service: the namespace registration never runs.
inject: () => () => {},
// The session/agent event feeds: nothing emits in these tests.
on: () => () => {},
// No jobs/agents services in the smoke context: the routes degrade.
get: (key: string) => key === 'sessionPersistence' ? overrides.sessionPersistence : undefined,
}
apply(ctx as never)
return routes
}
const mount = (overrides: CtxOverrides = {}): SidebarWebRoute => mountAll(overrides).find(route => route.path === '/sidebar/api')!
const invoke = async (
route: SidebarWebRoute,
method: string,
payload: unknown,
): Promise<{ ok: boolean; status: number; value?: { cwd: string }; error?: { code?: string; message: string } }> => {
const body = Buffer.from(JSON.stringify(payload))
const req = {
method: 'POST',
url: `/sidebar/api/${method}`,
headers: { host: '127.0.0.1:3080' },
[Symbol.asyncIterator]: async function* () { yield body },
} as never
const out: { status: number; body: string } = { status: 200, body: '' }
const res = {
writeHead: (status: number) => { out.status = status },
end: (chunk: unknown) => { out.body += String(chunk ?? '') },
} as never
await route.handler(req, res)
return { ...JSON.parse(out.body) as { ok: boolean; value?: { cwd: string }; error?: { code?: string; message: string } }, status: out.status }
}
const invokeGet = async (route: SidebarWebRoute, url: string): Promise<{ status: number; body: string }> => {
const out: { status: number; body: string } = { status: 200, body: '' }
const req = { method: 'GET', url, headers: { host: '127.0.0.1:3080' } } as never
const res = {
writeHead: (status: number) => { out.status = status },
end: (chunk: unknown) => { out.body += String(chunk ?? '') },
} as never
await route.handler(req, res)
return out
}
it('uses the client summary cwd while the session is detached', async () => {
const route = mount()
const result = await invoke(route, 'session.cwd', { sessionId: 's-detached', cwd: '/tmp/summary-cwd' })
expect(result.ok).toBe(true)
// The summary cwd passes through requireAbsolute (platform resolve), so
// the expectation follows the platform's own normalization.
expect(result.value?.cwd).toBe(resolvePath('/tmp/summary-cwd'))
})
it('falls back to the process cwd with no summary cwd', async () => {
const route = mount()
const result = await invoke(route, 'session.cwd', { sessionId: 's-unknown' })
expect(result.ok).toBe(true)
expect(result.value?.cwd).toBe(process.cwd())
})
it('resolves a cold (detached) session cwd through the persistence index', async () => {
// Regression: a detached first request (session not yet attached, no
// client cwd) must resolve the cwd from the session-persistence index
// instead of the host process cwd. On Windows the host process cwd is
// the DSH source root (dsh.cmd's `pushd`), so every user-project path
// was misclassified as "outside workspace" by the realpath guard.
const coldCwd = resolvePath('/cold-project-cwd')
const route = mount({
sessionPersistence: {
open: async (id) => ({
header: id === 's-cold' ? { cwd: coldCwd } : {},
read: async () => ({ events: [] }),
close: async () => {},
}),
},
})
const result = await invoke(route, 'session.cwd', { sessionId: 's-cold' })
expect(result.ok).toBe(true)
expect(result.value?.cwd).toBe(coldCwd)
})
it('rejects a relative cwd from the persistence index', async () => {
// A buggy / corrupt persistence layer that stored a relative cwd must
// be rejected by requireAbsolute instead of flowing into the workspace
// guard, where it would be resolved against the host process cwd and
// potentially recreate the original "outside workspace" misclassification.
const route = mount({
sessionPersistence: {
open: async () => ({ header: { cwd: 'relative/path' }, read: async () => ({ events: [] }), close: async () => {} }),
},
})
const result = await invoke(route, 'session.cwd', { sessionId: 's-bad' })
expect(result.ok).toBe(false)
expect(result.error?.message).toMatch(/invalid working directory/)
})
it('falls back to the process cwd when persistence has no cwd for the session', async () => {
const route = mount({
sessionPersistence: {
open: async () => ({ header: {}, read: async () => ({ events: [] }), close: async () => {} }),
},
})
const result = await invoke(route, 'session.cwd', { sessionId: 's-blank' })
expect(result.ok).toBe(true)
expect(result.value?.cwd).toBe(process.cwd())
})
it('prefers the attached session header over the client summary', async () => {
const route = mount({
sessions: {
get: (id) => id === 's-attached' ? { header: { cwd: '/attached-cwd' } } : undefined,
},
})
const result = await invoke(route, 'session.cwd', { sessionId: 's-attached', cwd: '/tmp/summary-cwd' })
expect(result.ok).toBe(true)
expect(result.value?.cwd).toBe('/attached-cwd')
})
it('rejects a non-absolute client cwd', async () => {
const route = mount()
const result = await invoke(route, 'session.cwd', { sessionId: 's-detached', cwd: 'relative/path' })
expect(result.ok).toBe(false)
expect(result.error?.message).toMatch(/invalid working directory/)
})
it('pty.close releases a terminal key (and rejects a missing tab)', async () => {
const route = mount()
const result = await invoke(route, 'pty.close', { sessionId: 's-pty', tab: 't1' })
expect(result.ok).toBe(true)
const missing = await invoke(route, 'pty.close', { sessionId: 's-pty' })
expect(missing.ok).toBe(false)
})
it('git.diff resolves repo-relative paths (session in a subdirectory)', async () => {
// The plugin repo's status paths are relative to the repo top level
// (e.g. `src/git.ts`); a session whose cwd sits inside the repo must
// still load per-file diffs instead of failing with "not an absolute
// path". The session header points INTO the repository.
const route = mount({
sessions: {
get: () => ({ header: { cwd: join(process.cwd(), 'src') } }),
},
})
const result = await invoke(route, 'git.diff', { sessionId: 's-sub', path: 'src/git.ts', staged: false })
expect(result.ok).toBe(true)
const value = result as unknown as { ok: boolean; value?: { diff: string } }
expect(typeof value.value?.diff).toBe('string')
})
it('fs.read resolves repo-relative paths (untracked diff fallback)', async () => {
const route = mount({
sessions: {
get: () => ({ header: { cwd: join(process.cwd(), 'src') } }),
},
})
const result = await invoke(route, 'fs.read', { sessionId: 's-sub', path: 'src/git.ts' })
expect(result.ok).toBe(true)
const value = result as unknown as { ok: boolean; value?: { kind: string; content: string } }
expect(value.value?.kind).toBe('text')
expect(value.value?.content).toContain('runGit')
})
it('rejects repo-root-relative fs.read paths outside a nested session workspace', async () => {
const route = mount({
sessions: {
get: () => ({ header: { cwd: join(process.cwd(), 'src') } }),
},
})
const result = await invoke(route, 'fs.read', { sessionId: 's-sub', path: 'package.json' })
expect(result).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } })
})
it('rejects fs.tree paths outside the session workspace', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-security-'))
const workspace = join(root, 'workspace')
const outside = join(root, 'outside')
mkdirSync(workspace)
mkdirSync(outside)
const outsideFile = join(outside, 'secret.txt')
writeFileSync(outsideFile, 'secret')
try {
const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } })
const tree = await invoke(route, 'fs.tree', { sessionId: 'security', path: outside })
expect(tree).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } })
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('rejects fs.read paths outside the session workspace', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-security-'))
const workspace = join(root, 'workspace')
const outside = join(root, 'outside')
mkdirSync(workspace)
mkdirSync(outside)
const outsideFile = join(outside, 'secret.txt')
writeFileSync(outsideFile, 'secret')
try {
const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } })
const read = await invoke(route, 'fs.read', { sessionId: 'security', path: outsideFile })
expect(read).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } })
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('rejects fs.write paths outside the session workspace', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-security-'))
const workspace = join(root, 'workspace')
const outside = join(root, 'outside')
mkdirSync(workspace)
mkdirSync(outside)
try {
const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } })
const write = await invoke(route, 'fs.write', { sessionId: 'security', path: join(outside, 'written.txt'), content: 'hack' })
expect(write).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } })
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('rejects media and HTML reads through a workspace symlink', async () => {
if (!canCreateSymlink) return
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-route-symlink-security-'))
const workspace = join(root, 'workspace')
const outside = join(root, 'outside')
mkdirSync(workspace)
mkdirSync(outside)
const mediaPath = join(outside, 'secret.png')
const htmlPath = join(outside, 'secret.html')
writeFileSync(mediaPath, 'not an image')
writeFileSync(htmlPath, '<p>secret</p>')
try {
symlinkSync(outside, join(workspace, 'link'))
const routes = mountAll({ sessions: { get: () => ({ header: { cwd: workspace } }) } })
const media = routes.find(route => route.path === '/sidebar/file')!
const html = routes.find(route => route.path === '/sidebar/html')!
const mediaResult = await invokeGet(media, `/sidebar/file?sessionId=security&path=${encodeURIComponent(join(workspace, 'link', 'secret.png'))}`)
// Use the production encoder so the URL is well-formed on every
// platform (a Windows drive path needs the leading slash separator
// that a naive join-without-separator drops).
const htmlResult = await invokeGet(html, encodeHtmlUrl('security', join(workspace, 'link', 'secret.html')))
expect(mediaResult).toMatchObject({ status: 403 })
expect(JSON.parse(mediaResult.body)).toMatchObject({ ok: false, error: { code: 'forbidden' } })
expect(htmlResult).toMatchObject({ status: 403 })
expect(JSON.parse(htmlResult.body)).toMatchObject({ ok: false, error: { code: 'forbidden' } })
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('keeps fs.tree missing-path failures as fs errors', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-security-'))
const workspace = join(root, 'workspace')
mkdirSync(workspace)
try {
const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } })
const tree = await invoke(route, 'fs.tree', { sessionId: 'security', path: join(workspace, 'missing') })
expect(tree).toMatchObject({ ok: false, status: 400, error: { code: 'fs-error' } })
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it.skipIf(!canCreateSymlink)('rejects workspace symlinks that resolve outside the workspace', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-symlink-security-'))
const workspace = join(root, 'workspace')
const outside = join(root, 'outside')
mkdirSync(workspace)
mkdirSync(outside)
writeFileSync(join(outside, 'secret.txt'), 'secret')
try {
symlinkSync(outside, join(workspace, 'link'))
const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } })
const tree = await invoke(route, 'fs.tree', { sessionId: 'security', path: join(workspace, 'link') })
expect(tree).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } })
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it.skipIf(!canCreateSymlink)('rejects fs.read through a workspace symlink', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-symlink-security-'))
const workspace = join(root, 'workspace')
const outside = join(root, 'outside')
mkdirSync(workspace)
mkdirSync(outside)
writeFileSync(join(outside, 'secret.txt'), 'secret')
try {
symlinkSync(outside, join(workspace, 'link'))
const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } })
const read = await invoke(route, 'fs.read', { sessionId: 'security', path: join(workspace, 'link', 'secret.txt') })
expect(read).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } })
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it.skipIf(!canCreateSymlink)('rejects fs.write through a workspace symlink', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-symlink-security-'))
const workspace = join(root, 'workspace')
const outside = join(root, 'outside')
mkdirSync(workspace)
mkdirSync(outside)
try {
symlinkSync(outside, join(workspace, 'link'))
const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } })
const write = await invoke(route, 'fs.write', { sessionId: 'security', path: join(workspace, 'link', 'new.txt'), content: 'hack' })
expect(write).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } })
} finally {
rmSync(root, { recursive: true, force: true })
}
})
})
describe('side card settings routes', () => {
/** A minimal settings seam: register/describe/update with the revision guard. */
const createFakeSettings = (pre?: Record<string, Record<string, unknown>>) => {
const namespaces = new Map<string, {
schema: unknown
value: Record<string, unknown> | undefined
revision: number
}>()
for (const [ns, value] of Object.entries(pre ?? {})) {
namespaces.set(ns, { schema: (input: unknown) => input, value, revision: 0 })
}
const resolve = (entry: { schema: unknown; value: Record<string, unknown> | undefined }): unknown => {
const schema = entry.schema as (input: unknown) => unknown
return entry.value === undefined ? schema(undefined) : schema(entry.value)
}
return {
register(ns: string, schema: unknown) {
// Preserve a pre-seeded value: tests stage prefs through the `pre`
// map before the plugin mounts and registers the same namespace.
const existing = namespaces.get(ns)
namespaces.set(ns, { schema, value: existing?.value ?? undefined, revision: 0 })
return { get: () => ({}), watch: () => () => {}, update: async () => {}, replace: async () => {} }
},
describe() {
return [...namespaces.entries()].map(([ns, entry]) => ({
ns,
value: resolve(entry),
applies: 'live' as const,
revision: entry.revision,
}))
},
async update(ns: string, patch: Record<string, unknown>, expectedRevision?: number) {
const entry = namespaces.get(ns)
if (entry === undefined) throw new Error(`settings namespace "${ns}" is not registered`)
if (expectedRevision !== undefined && expectedRevision !== entry.revision) {
throw new SettingsConflictError(ns as SettingsNamespace, expectedRevision, entry.revision)
}
entry.value = { ...entry.value, ...patch }
entry.revision += 1
},
}
}
const mountWithSettings = (settings?: unknown): SidebarWebRoute => {
const routes: SidebarWebRoute[] = []
const ctx = {
webRuntime: { trustedHosts: [] },
webServer: {
register: (route: SidebarWebRoute) => { routes.push(route); return () => {} },
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
},
sessions: { get: () => undefined },
tools: { register: () => () => {} },
effect: (fn: () => void | (() => void)) => { fn() },
inject: (deps: string[], callback: (sctx: { settings: unknown }) => void) => {
if (deps.includes('settings') && settings !== undefined) callback({ settings })
return () => {}
},
// The session/agent event feeds: nothing emits in these tests.
on: () => () => {},
// No jobs/agents services: the jobs routes degrade to a 503.
get: () => undefined,
}
apply(ctx as never)
return routes.find(route => route.path === '/sidebar/api')!
}
const invoke = async (route: SidebarWebRoute, method: string, payload: unknown): Promise<{
ok: boolean
value?: unknown
error?: { code?: string; message: string }
}> => {
const body = Buffer.from(JSON.stringify(payload))
const req = {
method: 'POST',
url: `/sidebar/api/${method}`,
headers: { host: '127.0.0.1:3080' },
[Symbol.asyncIterator]: async function* () { yield body },
} as never
const out: { status: number; body: string } = { status: 200, body: '' }
const res = {
writeHead: (status: number) => { out.status = status },
end: (chunk: unknown) => { out.body += String(chunk ?? '') },
} as never
await route.handler(req, res)
return JSON.parse(out.body) as { ok: boolean; value?: unknown; error?: { code?: string; message: string } }
}
it('serves the schema defaults when the settings service is absent', async () => {
const route = mountWithSettings(undefined)
const result = await invoke(route, 'settings.get', {})
expect(result.ok).toBe(true)
expect(result.value).toEqual({ value: undefined, revision: undefined, externalDisable: false })
})
it('reports externalDisable false when the aionui namespace is absent', async () => {
const route = mountWithSettings(createFakeSettings())
const result = await invoke(route, 'settings.get', {})
expect(result.ok).toBe(true)
expect((result.value as { externalDisable?: boolean }).externalDisable).toBe(false)
})
it('reports externalDisable true while the aionui provider is selected', async () => {
const route = mountWithSettings(createFakeSettings({ 'aionui-panel': { rightPanel: 'aionui-panel' } }))
const result = await invoke(route, 'settings.get', {})
expect(result.ok).toBe(true)
expect((result.value as { externalDisable?: boolean }).externalDisable).toBe(true)
})
it('serves the effective terminal shell and its display name', async () => {
const route = mountWithSettings(undefined)
const result = await invoke(route, 'shell.get', {})
expect(result.ok).toBe(true)
expect(result.value).toMatchObject({
shell: expect.any(String),
name: expect.any(String),
})
expect(String((result.value as { name: unknown }).name).length).toBeGreaterThan(0)
})
it('shell.get reflects the settings-page override with the quotes stripped', async () => {
const route = mountWithSettings(createFakeSettings({
'dsh-better-sidebar': {
terminalShell: '"C:\\Program Files\\PowerShell\\7\\pwsh.exe"',
terminalShellArgs: '-NoLogo',
},
}))
const result = await invoke(route, 'shell.get', {})
expect(result.ok).toBe(true)
expect(result.value).toMatchObject({
shell: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
name: 'pwsh',
})
})
it('maps a shell-not-found failure to the machine-readable close reason', async () => {
expect(wsCloseReasonOf(new SidebarError(
'shell-not-found',
'shell executable not found: "C:\\Program Files\\PowerShell\\7\\pwsh.exe"',
400,
{ shell: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' },
))).toBe('shell-not-found:pwsh')
expect(wsCloseReasonOf(new Error('boom'))).toBe('boom')
expect(wsCloseReasonOf('plain')).toBe('plain')
})
it('caps the close reason by UTF-8 bytes so the ws 123-byte limit holds', () => {
// 200 CJK characters are ~600 bytes: a character-count slice would still
// overflow the cap `ws` enforces with Buffer.byteLength.
const reason = wsCloseReasonOf(new SidebarError(
'shell-not-found',
'shell executable not found',
400,
{ shell: `/bin/${'终'.repeat(200)}` },
))
expect(reason.startsWith('shell-not-found:')).toBe(true)
expect(Buffer.byteLength(reason)).toBeLessThanOrEqual(123)
})
it('reads the resolved prefs and writes a patch through the seam', async () => {
const route = mountWithSettings(createFakeSettings())
const read = await invoke(route, 'settings.get', {})
expect(read.ok).toBe(true)
expect(read.value).toEqual({
value: {
autoOpenSubagent: true,
autoOpenJobs: true,
agentTerminalTools: false, agentOpenTools: false,
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
editorExplorer: false,
workspaceFence: true,
terminalShell: '',
terminalShellArgs: '',
titleBarCompat: false,
titleBarStripPx: 40,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
browserNoSandbox: false,
browserInterceptLinks: true,
browserInterceptHttp: true,
browserInterceptHttps: false,
browserAllowedLoopback: '',
// The enable-switch maps default to {} (everything on).
tabsEnabled: {},
viewersEnabled: {},
// The plugin-owned settings map defaults to {} too.
pluginSettings: {},
},
revision: 0,
externalDisable: false,
})
const written = await invoke(route, 'settings.update', { patch: { agentOpenTools: true } })
expect(written.ok).toBe(true)
const view = written.value as { value: { agentOpenTools: boolean; terminalFontSize: number }; revision: number }
expect(view.value.agentOpenTools).toBe(true)
expect(view.value.terminalFontSize).toBe(13)
expect(view.revision).toBe(1)
})
it('disarms the workspace fence for the fs routes when the pref is off', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fence-off-'))
const workspace = join(root, 'workspace')
const outside = join(root, 'outside')
mkdirSync(workspace)
mkdirSync(outside)
writeFileSync(join(outside, 'secret.txt'), 'global instructions')
try {
const route = mountWithSettings(createFakeSettings())
// Default (fence on): the outside read is refused as usual…
const refused = await invoke(route, 'fs.read', { sessionId: 'fence', cwd: workspace, path: join(outside, 'secret.txt') })
expect(refused).toMatchObject({ ok: false, error: { code: 'forbidden' } })
// …then the settings-page switch (or the fence notice's one-click off)
// disarms every fs route for paths outside the workspace.
const off = await invoke(route, 'settings.update', { patch: { workspaceFence: false } })
expect(off.ok).toBe(true)
const read = await invoke(route, 'fs.read', { sessionId: 'fence', cwd: workspace, path: join(outside, 'secret.txt') })
expect(read).toMatchObject({ ok: true, value: { kind: 'text', content: 'global instructions' } })
const tree = await invoke(route, 'fs.tree', { sessionId: 'fence', cwd: workspace, path: outside })
expect(tree).toMatchObject({ ok: true })
const write = await invoke(route, 'fs.write', { sessionId: 'fence', cwd: workspace, path: join(outside, 'written.txt'), content: 'ok' })
expect(write).toMatchObject({ ok: true })
expect(readFileSync(join(outside, 'written.txt'), 'utf8')).toBe('ok')
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('refuses a stale write with settings-conflict (409)', async () => {
const route = mountWithSettings(createFakeSettings())
await invoke(route, 'settings.update', { patch: { agentOpenTools: false } })
// The second write carries the pre-write revision: the seam refuses it.
const stale = await invoke(route, 'settings.update', {
patch: { terminalFontSize: 15 },
expectedRevision: 0,
})
expect(stale.ok).toBe(false)
expect(stale.error?.code).toBe('settings-conflict')
expect(stale.error?.message).toMatch(/changed since it was read/)
})
it('rejects a non-object patch as bad-request', async () => {
const route = mountWithSettings(createFakeSettings())
const result = await invoke(route, 'settings.update', { patch: 'nope' })
expect(result.ok).toBe(false)
expect(result.error?.message).toMatch(/plain object/)
})
/** Fake fetch responses shaped like what the route consumes. */
const respond = (status: number, headers: Record<string, string>): Response =>
({ status, url: 'https://site.example/', headers: new Headers(headers) }) as unknown as Response
it('reports X-Frame-Options and frame-ancestors from the target headers', async () => {
const route = mountWithSettings(undefined)
vi.stubGlobal('fetch', vi.fn(async () => respond(200, {
'x-frame-options': 'SAMEORIGIN',
'content-security-policy': "default-src 'self'; frame-ancestors 'none'",
})))
try {
const result = await invoke(route, 'browser.probe', { url: 'https://arxiv.org/' })
expect(result.ok).toBe(true)
expect(result.value).toEqual({
reachable: true,
url: 'https://site.example/',
status: 200,
xFrameOptions: 'SAMEORIGIN',
frameAncestors: ["'none'"],
})
} finally {
vi.unstubAllGlobals()
}
})
it('retries a 405 HEAD as GET', async () => {
const route = mountWithSettings(undefined)
const fetchMock = vi.fn()
.mockResolvedValueOnce(respond(405, {}))
.mockResolvedValueOnce(respond(200, {}))
vi.stubGlobal('fetch', fetchMock)
try {
const result = await invoke(route, 'browser.probe', { url: 'https://example.com/' })
expect(result.ok).toBe(true)
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(result.value).toMatchObject({ reachable: true, status: 200 })
} finally {
vi.unstubAllGlobals()
}
})
it('reports an unreachable target as reachable:false', async () => {
const route = mountWithSettings(undefined)
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('ENOTFOUND') }))
try {
const result = await invoke(route, 'browser.probe', { url: 'https://example.com/' })
expect(result.ok).toBe(true)
expect(result.value).toEqual({ reachable: false })
} finally {
vi.unstubAllGlobals()
}
})
it('refuses non-http(s) and loopback URLs', async () => {
const route = mountWithSettings(undefined)
for (const url of ['javascript:alert(1)', 'file:///etc/passwd', 'http://127.0.0.1:8080/', 'http://localhost/']) {
const result = await invoke(route, 'browser.probe', { url })
expect(result.ok, url).toBe(false)
expect(result.error?.code, url).toBe('bad-request')
}
})
})
describe('agent terminal tool gating', () => {
it('injects the eight tools only when the side-card setting is enabled (default off)', () => {
let registered = 0
let disposed = 0
// The tools currently registered (registered minus disposed).
const live = (): number => registered - disposed
// A ref container: the watch callback is only assigned inside a closure,
// which TypeScript's control-flow analysis ignores (the bare variable
// would narrow to null and refuse the optional call).
const watcherRef: { current: (() => void) | null } = { current: null }
let enabled = false
const settings = {
register() {
return {
get: () => ({ agentTerminalTools: enabled }),
watch: (callback: () => void) => { watcherRef.current = callback; return () => {} },
update: async () => {},
replace: async () => {},
}
},
describe: () => [],
async update() {},
}
const ctx = {
webRuntime: { trustedHosts: [] },
webServer: {
register: (route: SidebarWebRoute) => { void route; return () => {} },
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
},
sessions: { get: () => undefined },
tools: { register: () => { registered += 1; return () => { disposed += 1 } } },
effect: (fn: () => void | (() => void)) => { fn() },
inject: (deps: readonly string[], callback: (sctx: { settings: unknown }) => void) => {
if (deps.includes('settings')) callback({ settings })
return () => {}
},
// The session/agent event feeds: nothing emits in these tests.
on: () => () => {},
// No jobs/agents services: the jobs routes degrade to a 503.
get: () => undefined,
}
apply(ctx as never)
// Default off: no tools are registered even though the settings service is mounted.
expect(live()).toBe(0)
// Flipping the setting on registers all eight tools.
enabled = true
watcherRef.current?.()
expect(live()).toBe(8)
expect(disposed).toBe(0)
// Flipping it back off unregisters them (and releases any agent terminals).
enabled = false
watcherRef.current?.()
expect(live()).toBe(0)
expect(disposed).toBe(8)
// And a redundant toggle registers them fresh (no double-registration per
// flip: the guard only skips when the tools are already live).
enabled = true
watcherRef.current?.()
expect(live()).toBe(8)
expect(registered).toBe(16)
})
})
describe('agent sidebar-open tool gating', () => {
it('injects the one open tool only when the side-card setting is enabled (default off)', () => {
let registered = 0
let disposed = 0
const live = (): number => registered - disposed
const watcherRef: { current: (() => void) | null } = { current: null }
let enabled = false
const settings = {
register() {
return {
get: () => ({ agentOpenTools: enabled, tabsEnabled: {} }),
watch: (callback: () => void) => { watcherRef.current = callback; return () => {} },
update: async () => {},
replace: async () => {},
}
},
describe: () => [],
async update() {},
}
const ctx = {
webRuntime: { trustedHosts: [] },
webServer: {
register: (route: SidebarWebRoute) => { void route; return () => {} },
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
},
sessions: { get: () => undefined },
tools: { register: () => { registered += 1; return () => { disposed += 1 } } },
effect: (fn: () => void | (() => void)) => { fn() },
inject: (deps: readonly string[], callback: (sctx: { settings: unknown }) => void) => {
if (deps.includes('settings')) callback({ settings })
return () => {}
},
// The session/agent event feeds: nothing emits in these tests.
on: () => () => {},
get: () => undefined,
}
apply(ctx as never)
// Default off: no open tool is registered even though the settings service is mounted.
expect(live()).toBe(0)
// Flipping the setting on registers the single sidebar_open tool.
enabled = true
watcherRef.current?.()
expect(live()).toBe(1)
expect(disposed).toBe(0)
// Flipping it back off unregisters it (and drains the undelivered queue).
enabled = false
watcherRef.current?.()
expect(live()).toBe(0)
expect(disposed).toBe(1)
// And a redundant toggle registers it fresh (no double-registration).
enabled = true
watcherRef.current?.()
expect(live()).toBe(1)
expect(registered).toBe(2)
})
})