From bb3b40ff15afd4d24c8de2598fafc2e2051c0d69 Mon Sep 17 00:00:00 2001 From: taurus Date: Sun, 16 Aug 2026 23:06:44 +0800 Subject: [PATCH] refactor(tests): decentralize monolithic unit.spec.ts and add state boundary tests (#141) Co-authored-by: yukun.wu --- tests/agent-terminal-reconcile.spec.ts | 72 + tests/file-types.spec.ts | 48 + tests/fs-tree.spec.ts | 58 + tests/git.spec.ts | 136 ++ tests/openpath-intercept.spec.ts | 144 ++ tests/paths.spec.ts | 35 + tests/prefs.spec.ts | 267 ++++ tests/produced-files.spec.ts | 56 + tests/pty-helpers.spec.ts | 96 ++ tests/service.spec.ts | 20 + tests/state.spec.ts | 822 +++++++++++ tests/subagent-activity.spec.ts | 67 + tests/subagent-detect.spec.ts | 112 ++ tests/unit.spec.ts | 1851 ------------------------ 14 files changed, 1933 insertions(+), 1851 deletions(-) create mode 100644 tests/agent-terminal-reconcile.spec.ts create mode 100644 tests/file-types.spec.ts create mode 100644 tests/fs-tree.spec.ts create mode 100644 tests/git.spec.ts create mode 100644 tests/openpath-intercept.spec.ts create mode 100644 tests/paths.spec.ts create mode 100644 tests/prefs.spec.ts create mode 100644 tests/produced-files.spec.ts create mode 100644 tests/pty-helpers.spec.ts create mode 100644 tests/state.spec.ts create mode 100644 tests/subagent-activity.spec.ts create mode 100644 tests/subagent-detect.spec.ts delete mode 100644 tests/unit.spec.ts diff --git a/tests/agent-terminal-reconcile.spec.ts b/tests/agent-terminal-reconcile.spec.ts new file mode 100644 index 0000000..8f9cda7 --- /dev/null +++ b/tests/agent-terminal-reconcile.spec.ts @@ -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) + }) +}) diff --git a/tests/file-types.spec.ts b/tests/file-types.spec.ts new file mode 100644 index 0000000..66b36c0 --- /dev/null +++ b/tests/file-types.spec.ts @@ -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) + }) +}) diff --git a/tests/fs-tree.spec.ts b/tests/fs-tree.spec.ts new file mode 100644 index 0000000..4e19eb4 --- /dev/null +++ b/tests/fs-tree.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { compareEntries, isWithin, parentOf, requireAbsolute, rootLabel } from '../src/fs-tree.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 }, + { name: 'A', path: '/x/A', isDir: true, hidden: false }, + { name: 'a.txt', path: '/x/a.txt', isDir: false, hidden: false }, + { name: '.hidden', path: '/x/.hidden', isDir: false, hidden: true }, + ] + expect(rows.sort(compareEntries).map(row => row.name)).toEqual(['A', '.hidden', 'a.txt', 'b.txt']) + }) + + it('derives root labels and parents', () => { + // 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() + // Windows-drive roots and segments, asserted only where win32 semantics apply. + if (process.platform === 'win32') { + 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 absolute paths and rejects relative ones', () => { + // resolve() is platform-native: '/a/b' roots to the current drive on win32. + expect(requireAbsolute('/a/b')).toBe(process.platform === 'win32' ? '\\a\\b' : '/a/b') + if (process.platform === 'win32') { + expect(requireAbsolute('C:/proj')).toBe('C:\\proj') + } + expect(() => requireAbsolute('a/b')).toThrow(/not an absolute path/) + expect(() => requireAbsolute('../a')).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) + }) +}) diff --git a/tests/git.spec.ts b/tests/git.spec.ts new file mode 100644 index 0000000..cee7996 --- /dev/null +++ b/tests/git.spec.ts @@ -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([]) + }) +}) diff --git a/tests/openpath-intercept.spec.ts b/tests/openpath-intercept.spec.ts new file mode 100644 index 0000000..763003f --- /dev/null +++ b/tests/openpath-intercept.spec.ts @@ -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 { + this.calls.push(path) + this.opened.push(path) + }, + } + return fake + } + + const deps = (overrides: Partial = {}): 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> = [] + const funnel = { openPath: async (): Promise => {} } + const ctx = { + sessions: { + list: { getSnapshot: () => ({ current: 's1', byId: { s1: { cwd: '/w' } } }) }, + }, + workspaces: funnel, + betterSidebar: { openTab: (seed: unknown) => { opened.push(seed as Record) } }, + } 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) + }) +}) diff --git a/tests/paths.spec.ts b/tests/paths.spec.ts new file mode 100644 index 0000000..55fb596 --- /dev/null +++ b/tests/paths.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { relativeTo } from '../src/client/paths.ts' +import { resolveSidebarPath } from '../src/client/produced-files.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') + }) +}) diff --git a/tests/prefs.spec.ts b/tests/prefs.spec.ts new file mode 100644 index 0000000..75ea78b --- /dev/null +++ b/tests/prefs.spec.ts @@ -0,0 +1,267 @@ +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, + 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: true, + defaultWidthPercent: 33, + autoOpenSubagent: true, + autoOpenJobs: true, + agentTerminalTools: false, + bottomPanelAutoTerminal: true, + terminalFontFamily: '', + terminalFontSize: 13, + interceptOpenPath: 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, + 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 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, 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, 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 open. + const openStore = createSidebarStore() + openStore.setSession('another-fresh') + expect(openStore.getSnapshot().state?.panelOpen).toBe(true) + }) + + 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).window + ;(globalThis as Record).window = { + innerWidth: 390, + clearTimeout: () => {}, + setTimeout: (_fn: () => void) => 0, + } + try { + const store = createSidebarStore() + // Default prefs say openByDefault: true — the narrow viewport overrides + // it 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).window + else (globalThis as Record).window = original + } + }) + + it('skips the default explorer tab when the explorer 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, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: { explorer: false }, viewersEnabled: {}, pluginSettings: {} }) + store.setSession('no-explorer') + 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 explorer tab again. + const openStore = createSidebarStore() + openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + openStore.setSession('with-explorer') + const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) + expect(openTabs.map(tab => tab.type)).toEqual(['explorer']) + }) + + 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 seedExplorer flag controls the default explorer tab. + expect(makeDefaultState(400, true, false).splits.kind).toBe('leaf') + expect((makeDefaultState(400, true, false).splits as { tabs: unknown[] }).tabs).toHaveLength(0) + }) +}) diff --git a/tests/produced-files.spec.ts b/tests/produced-files.spec.ts new file mode 100644 index 0000000..0afb9c5 --- /dev/null +++ b/tests/produced-files.spec.ts @@ -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') + }) +}) diff --git a/tests/pty-helpers.spec.ts b/tests/pty-helpers.spec.ts new file mode 100644 index 0000000..0cbdf63 --- /dev/null +++ b/tests/pty-helpers.spec.ts @@ -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() + 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) + }) +}) diff --git a/tests/service.spec.ts b/tests/service.spec.ts index be18dde..0ae19dc 100644 --- a/tests/service.spec.ts +++ b/tests/service.spec.ts @@ -1031,4 +1031,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/) + }) }) diff --git a/tests/state.spec.ts b/tests/state.spec.ts new file mode 100644 index 0000000..7678d70 --- /dev/null +++ b/tests/state.spec.ts @@ -0,0 +1,822 @@ +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('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 + 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 + 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 + // paneB 现在是 split(row) [旧leaf, 新leaf(tabId)];其父 split 仍存在。 + const bSplit = after.children.find(child => child.kind === 'split') as Extract | 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 + 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 + 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 + const id = split.id + s = { ...s, splits: resizeSplit(s.splits, id, 0, 0.2) } + const after = s.splits as Extract + 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 + 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 + 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. + expect((s.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toEqual(['explorer']) + 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 + s = resizeSplitIn(s, split.id, 0, 0.1) + const next = s.bottomSplits as Extract + 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 + 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 + 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 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 explorerTab = allLeaves(s.splits)[0]!.tabs.find(tab => tab.type === 'explorer')!.id + s = closeTab(s, paneA, explorerTab) + 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 + g.window = { clearTimeout: () => {}, setTimeout: () => 0, innerWidth: 1024, innerHeight: 800 } + g.localStorage = { getItem: () => null, setItem: () => {} } + }) + afterEach(() => { + const g = globalThis as Record + 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 + let seq = 0 + const timers = new Map 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 }) + }) + }) +}) diff --git a/tests/subagent-activity.spec.ts b/tests/subagent-activity.spec.ts new file mode 100644 index 0000000..fe016de --- /dev/null +++ b/tests/subagent-activity.spec.ts @@ -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): 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: '' }) + }) +}) diff --git a/tests/subagent-detect.spec.ts b/tests/subagent-detect.spec.ts new file mode 100644 index 0000000..202c2c6 --- /dev/null +++ b/tests/subagent-detect.spec.ts @@ -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 = { + 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 = { + 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']) + }) +}) diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts deleted file mode 100644 index 6833715..0000000 --- a/tests/unit.spec.ts +++ /dev/null @@ -1,1851 +0,0 @@ -import { afterEach, beforeEach, 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 in this file -// reads os.userInfo. -vi.mock('node:os', async (importOriginal) => { - const os = await importOriginal() - return { - ...os, - userInfo: () => ({ ...os.userInfo(), shell: '/usr/bin/zsh' }), - } -}) -import { compareEntries, isWithin, parentOf, rootLabel, requireAbsolute } from '../src/fs-tree.ts' -import { parseLogLines, parsePorcelainZ } from '../src/git.ts' -import { parseUnifiedDiff } from '../src/client/DiffView.tsx' -import { - activateTab, allLeaves, BOTTOM_DEFAULT, BOTTOM_MIN, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState, - migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, patchTab, reconcileAgentTerminals, resizeSplit, resizeSplitIn, sanitizeState, setBottomHeight, splitPane, tabOpenIn, toggleBottomPanel, toggleExpanded, togglePanel, - type SidebarState, type SidebarTab, type SplitNode, -} from '../src/client/state.ts' -import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts' -import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts' -import { extOf, languageKeyForExt } from '../src/client/lang.ts' -import { isPdfExt } from '../src/client/pdf-types.ts' -import { isImageExt } from '../src/client/image-types.ts' -import { relativeTo } from '../src/client/paths.ts' -import { producedForClosing, resolveSidebarPath, selectProducedFiles } from '../src/client/produced-files.ts' -import { wrapOpenPath, type OpenPathInterceptDeps, type OpenPathService } from '../src/client/openpath-intercept.ts' -import { registerOpenPathInterception } from '../src/client/intercept.tsx' -import type { Context } from '../src/context-types.ts' -import { defaultShell, ensureSpawnHelper } from '../src/pty-manager.ts' -import { resolveSidebarConfig } from '../src/config.ts' -import { - collectBranchIds, countSubagentDescendants, detectNewDirectSubagent, directSubagentCount, rootAncestor, -} from '../src/client/subagent-detect.ts' -import { contentText, lastActivity } from '../src/client/subagent-activity.ts' -import type { SidebarHistoryEntry, SidebarSessionList, SidebarSubagentCatalog } from '../src/context-types.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 }, - { name: 'A', path: '/x/A', isDir: true, hidden: false }, - { name: 'a.txt', path: '/x/a.txt', isDir: false, hidden: false }, - { name: '.hidden', path: '/x/.hidden', isDir: false, hidden: true }, - ] - expect(rows.sort(compareEntries).map(row => row.name)).toEqual(['A', '.hidden', 'a.txt', 'b.txt']) - }) - - it('derives root labels and parents', () => { - // 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() - // Windows-drive roots and segments, asserted only where win32 semantics apply. - if (process.platform === 'win32') { - 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 absolute paths and rejects relative ones', () => { - // resolve() is platform-native: '/a/b' roots to the current drive on win32. - expect(requireAbsolute('/a/b')).toBe(process.platform === 'win32' ? '\\a\\b' : '/a/b') - if (process.platform === 'win32') { - expect(requireAbsolute('C:/proj')).toBe('C:\\proj') - } - expect(() => requireAbsolute('a/b')).toThrow(/not an absolute path/) - expect(() => requireAbsolute('../a')).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) - }) -}) - -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([]) - }) -}) - -describe('sidebar state', () => { - const state = (): SidebarState => makeDefaultState() - - 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 - 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 - 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 - // paneB 现在是 split(row) [旧leaf, 新leaf(tabId)];其父 split 仍存在。 - const bSplit = after.children.find(child => child.kind === 'split') as Extract | 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 - 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 - 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 - const id = split.id - s = { ...s, splits: resizeSplit(s.splits, id, 0, 0.2) } - const after = s.splits as Extract - 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 - 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 - 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. - expect((s.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toEqual(['explorer']) - 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 - s = resizeSplitIn(s, split.id, 0, 0.1) - const next = s.bottomSplits as Extract - 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 - 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 - 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) - }) -}) - -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) - }) -}) - -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) - }) -}) - -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') - }) -}) - -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 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 explorerTab = allLeaves(s.splits)[0]!.tabs.find(tab => tab.type === 'explorer')!.id - s = closeTab(s, paneA, explorerTab) - 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) - }) -}) - -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') - }) -}) - -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) - }) -}) - -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, - 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: true, - defaultWidthPercent: 33, - autoOpenSubagent: true, - autoOpenJobs: true, - agentTerminalTools: false, - bottomPanelAutoTerminal: true, - terminalFontFamily: '', - terminalFontSize: 13, - interceptOpenPath: 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, - 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 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, 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, 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 open. - const openStore = createSidebarStore() - openStore.setSession('another-fresh') - expect(openStore.getSnapshot().state?.panelOpen).toBe(true) - }) - - 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).window - ;(globalThis as Record).window = { - innerWidth: 390, - clearTimeout: () => {}, - setTimeout: (_fn: () => void) => 0, - } - try { - const store = createSidebarStore() - // Default prefs say openByDefault: true — the narrow viewport overrides - // it 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).window - else (globalThis as Record).window = original - } - }) - - it('skips the default explorer tab when the explorer 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, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: { explorer: false }, viewersEnabled: {}, pluginSettings: {} }) - store.setSession('no-explorer') - 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 explorer tab again. - const openStore = createSidebarStore() - openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) - openStore.setSession('with-explorer') - const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) - expect(openTabs.map(tab => tab.type)).toEqual(['explorer']) - }) - - 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 seedExplorer flag controls the default explorer tab. - expect(makeDefaultState(400, true, false).splits.kind).toBe('leaf') - expect((makeDefaultState(400, true, false).splits as { tabs: unknown[] }).tabs).toHaveLength(0) - }) -}) - -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 = { - 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 = { - 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']) - }) -}) - -describe('subagent activity summary parser', () => { - /** One history entry from raw event fields. */ - const entry = (type: string, data: Record): 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: '' }) - }) -}) - -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 { - this.calls.push(path) - this.opened.push(path) - }, - } - return fake - } - - const deps = (overrides: Partial = {}): 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> = [] - const funnel = { openPath: async (): Promise => {} } - const ctx = { - sessions: { - list: { getSnapshot: () => ({ current: 's1', byId: { s1: { cwd: '/w' } } }) }, - }, - workspaces: funnel, - betterSidebar: { openTab: (seed: unknown) => { opened.push(seed as Record) } }, - } 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) - }) -}) - -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 - g.window = { clearTimeout: () => {}, setTimeout: () => 0, innerWidth: 1024, innerHeight: 800 } - g.localStorage = { getItem: () => null, setItem: () => {} } - }) - afterEach(() => { - const g = globalThis as Record - 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 - let seq = 0 - const timers = new Map 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 }) - }) -}) -})