test: remove 368 low-value cases and 4 suites (#34157)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-26 18:11:04 -07:00
committed by GitHub
co-authored by OrcaWin
parent 53c426f6b4
commit 0cee6cbd86
32 changed files with 6 additions and 3232 deletions
-1
View File
@@ -1192,7 +1192,6 @@
2 test-no-wall-clock:src/renderer/src/store/slices/runtime-environment-ssh.test.ts
1 test-no-wall-clock:src/renderer/src/store/slices/store-sleep-agent-status-retention.test.ts
2 test-no-wall-clock:src/renderer/src/store/slices/store-sleep-exact-runtime-stop.test.ts
1 test-no-wall-clock:src/renderer/src/store/slices/tab-group-state.test.ts
7 test-no-wall-clock:src/renderer/src/store/slices/tabs-unread-and-focus.test.ts
1 test-no-wall-clock:src/renderer/src/store/slices/terminal-helpers.test.ts
1 test-no-wall-clock:src/renderer/src/store/slices/ui-hydration-view-layout.test.ts
-4
View File
@@ -7839,7 +7839,6 @@
"src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.test.ts": 64,
"src/renderer/src/components/floating-terminal/floating-terminal-panel-inputs.test.ts": 106,
"src/renderer/src/components/floating-terminal/floating-terminal-panel-view-state.test.ts": 153,
"src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.test.ts": 50,
"src/renderer/src/components/floating-terminal/floating-workspace-surface-model.test.ts": 172,
"src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts": 2600,
"src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.test.tsx": 1337,
@@ -8783,7 +8782,6 @@
"src/renderer/src/components/settings/PluginMarketplaceSourceDialog.test.tsx": 871,
"src/renderer/src/components/settings/PluginSettingsRow.test.tsx": 589,
"src/renderer/src/components/settings/PluginsSettingsSection.lifecycle.test.tsx": 889,
"src/renderer/src/components/settings/PrivacyPane.test.ts": 1589,
"src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.test.tsx": 729,
"src/renderer/src/components/settings/QuickCommandsList.test.tsx": 617,
"src/renderer/src/components/settings/QuickCommandsPane.test.ts": 604,
@@ -11087,7 +11085,6 @@
"src/renderer/src/lib/syntax-highlighting/textmate-core.test.ts": 46,
"src/renderer/src/lib/tab-agent-identity-decision-table.test.ts": 462,
"src/renderer/src/lib/tab-agent-status-index.test.ts": 137,
"src/renderer/src/lib/tab-agent.test.ts": 102,
"src/renderer/src/lib/tab-number-shortcuts.test.ts": 1378,
"src/renderer/src/lib/task-source-runtime-target.test.ts": 54,
"src/renderer/src/lib/telemetry-track-result.test.ts": 32,
@@ -11938,7 +11935,6 @@
"src/renderer/src/store/slices/store-worktree-removal-cascade.test.ts": 1503,
"src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts": 56,
"src/renderer/src/store/slices/tab-group-reference-repair.test.ts": 59,
"src/renderer/src/store/slices/tab-group-state.test.ts": 54,
"src/renderer/src/store/slices/tab-view-mode.test.ts": 1440,
"src/renderer/src/store/slices/tab-worktree-orphan-map-purge-leak.test.ts": 1305,
"src/renderer/src/store/slices/tabs-close-dangling-group.test.ts": 1683,
@@ -3,29 +3,10 @@ import {
MAX_AUTOMATIC_DIFF_CHANGED_LINES,
collectCountedCombinedDiffPasses,
getCombinedDiffCountingPassKey,
isCombinedDiffSizeUnknown,
shouldLoadCombinedDiffOnDemand
} from './combined-diff-on-demand-load'
describe('combined diff on-demand loading', () => {
it('defers diffs above the automatic changed-line limit', () => {
expect(
shouldLoadCombinedDiffOnDemand({
added: MAX_AUTOMATIC_DIFF_CHANGED_LINES,
removed: 1
})
).toBe(true)
})
it('automatically loads diffs at the limit', () => {
expect(
shouldLoadCombinedDiffOnDemand({
added: MAX_AUTOMATIC_DIFF_CHANGED_LINES - 500,
removed: 500
})
).toBe(false)
})
it('defers uncounted tracked text files, whose size is unknown', () => {
// Entry caps or failed numstat leave tracked rows uncounted; eager loading can freeze Monaco.
expect(shouldLoadCombinedDiffOnDemand({ path: 'src/generated/schema.ts' })).toBe(true)
@@ -43,26 +24,6 @@ describe('combined diff on-demand loading', () => {
expect(shouldLoadCombinedDiffOnDemand({ path: 'data/dump.json', area: 'untracked' })).toBe(true)
})
it('automatically loads tracked binaries numstat left uncounted, whatever the extension', () => {
// Numstat marks binaries with dashes regardless of extension, including extensionless files.
for (const path of ['resources/build/icon.icns', 'assets/logo.tiff', 'bin/orca-helper']) {
expect(
shouldLoadCombinedDiffOnDemand({ path, area: 'unstaged', hasCountedSiblings: true })
).toBe(false)
}
})
it('automatically loads submodule rows whose only change is untracked content inside', () => {
// Submodule-only untracked changes produce porcelain status but no numstat row.
expect(
shouldLoadCombinedDiffOnDemand({
path: 'vendor/sub',
area: 'unstaged',
submodule: { commitChanged: false, trackedChanges: false, untrackedChanges: true }
})
).toBe(false)
})
it('defers uncounted rows when the whole pass skipped counting', () => {
// Without counted siblings, a small binary is indistinguishable from unbounded text.
expect(
@@ -70,32 +31,6 @@ describe('combined diff on-demand loading', () => {
).toBe(true)
})
it('defers uncounted svgs, which render as text rather than a preview', () => {
expect(shouldLoadCombinedDiffOnDemand({ path: 'assets/map.svg' })).toBe(true)
})
it('automatically loads uncounted images, which render as a preview', () => {
expect(shouldLoadCombinedDiffOnDemand({ path: 'docs/Shot.PNG' })).toBe(false)
})
it('automatically loads uncounted non-image binaries of any size', () => {
expect(shouldLoadCombinedDiffOnDemand({ path: 'fixtures/sample.zip' })).toBe(false)
expect(shouldLoadCombinedDiffOnDemand({ path: 'fonts/Inter.woff2' })).toBe(false)
expect(shouldLoadCombinedDiffOnDemand({ path: 'bun.lockb' })).toBe(false)
})
it('defers diffs when only additions are reported', () => {
expect(shouldLoadCombinedDiffOnDemand({ added: MAX_AUTOMATIC_DIFF_CHANGED_LINES + 1 })).toBe(
true
)
})
it('defers diffs when only removals are reported', () => {
expect(shouldLoadCombinedDiffOnDemand({ removed: MAX_AUTOMATIC_DIFF_CHANGED_LINES + 1 })).toBe(
true
)
})
it('credits a counted row only to its own counting pass', () => {
// Staged, unstaged and compare counting calls fail independently; all mode must not pool their evidence.
const countedPasses = collectCountedCombinedDiffPasses([
@@ -118,21 +53,6 @@ describe('combined diff on-demand loading', () => {
).toBe(false)
})
it('separates the two deferral reasons the prompt has to explain', () => {
const overLimit = { added: MAX_AUTOMATIC_DIFF_CHANGED_LINES + 1, path: 'src/schema.ts' }
const uncounted = {
path: 'resources/build/icon.icns',
area: 'unstaged' as const,
added: undefined,
removed: undefined
}
expect(shouldLoadCombinedDiffOnDemand(overLimit)).toBe(true)
expect(shouldLoadCombinedDiffOnDemand(uncounted)).toBe(true)
expect(isCombinedDiffSizeUnknown(overLimit)).toBe(false)
expect(isCombinedDiffSizeUnknown(uncounted)).toBe(true)
expect(isCombinedDiffSizeUnknown({ added: 0, removed: 0 })).toBe(false)
})
it('keeps counted binary-extension rows on the line-count rule', () => {
expect(shouldLoadCombinedDiffOnDemand({ added: 3, path: 'fixtures/sample.zip' })).toBe(false)
expect(
@@ -56,33 +56,6 @@ describe('editor self-write registry', () => {
expect(hasRecentSelfWrite('/repo/a.md', 'env-1')).toBe(false)
})
it('trims runtime owner ids when matching stamps', () => {
recordSelfWrite('/repo/a.md', 'runtime save', ' env-1 ')
expect(hasRecentSelfWrite('/repo/a.md', 'env-1')).toBe(true)
})
it('prunes expired stamps when recording later writes', () => {
recordSelfWrite('/repo/old.md')
vi.advanceTimersByTime(751)
recordSelfWrite('/repo/new.md')
expect(__getSelfWriteRegistrySizeForTests()).toBe(1)
expect(hasRecentSelfWrite('/repo/old.md')).toBe(false)
expect(hasRecentSelfWrite('/repo/new.md')).toBe(true)
})
it('caps retained stamps', () => {
for (let i = 0; i < 260; i++) {
recordSelfWrite(`/repo/${i}.md`)
}
expect(__getSelfWriteRegistrySizeForTests()).toBe(256)
expect(hasRecentSelfWrite('/repo/0.md')).toBe(false)
expect(hasRecentSelfWrite('/repo/259.md')).toBe(true)
})
it('keeps remote-TTL stamps alive past the local window', () => {
// Remote watcher echoes can arrive after the local TTL expires.
recordSelfWrite('/repo/remote.md', 'content', 'env-1', SELF_WRITE_REMOTE_TTL_MS)
@@ -153,13 +126,6 @@ describe('editor self-write registry', () => {
expect(vi.getTimerCount()).toBe(0)
})
it('clears the shared timer when the registry is reset', () => {
recordSelfWrite('/repo/reset.json', 'saved content')
__clearSelfWriteRegistryForTests()
expect(__getSelfWriteRegistrySizeForTests()).toBe(0)
expect(vi.getTimerCount()).toBe(0)
})
it('bounds the timer delay after a large backward clock change', () => {
const timeout = vi.spyOn(globalThis, 'setTimeout')
recordSelfWrite('/repo/clock.json', 'saved content')
@@ -7,15 +7,6 @@ const SOURCE = '/repo/docs/note.md'
const ROOT = '/repo'
describe('resolveMarkdownLinkTarget', () => {
it('classifies relative .md inside worktree as markdown', () => {
const r = resolveMarkdownLinkTarget('./guide.md', SOURCE, ROOT)
expect(r).toEqual({
kind: 'markdown',
absolutePath: '/repo/docs/guide.md',
relativePath: 'docs/guide.md'
})
})
it('classifies relative .md inside a UNC worktree as markdown', () => {
const r = resolveMarkdownLinkTarget(
'./guide.md',
@@ -29,29 +20,6 @@ describe('resolveMarkdownLinkTarget', () => {
})
})
it('classifies relative .md outside worktree as file', () => {
const r = resolveMarkdownLinkTarget('../../other/guide.md', SOURCE, ROOT)
expect(r?.kind).toBe('file')
})
it('classifies absolute .md inside worktree as markdown', () => {
const r = resolveMarkdownLinkTarget('/repo/docs/guide.md', SOURCE, ROOT)
expect(r?.kind).toBe('markdown')
})
it('classifies Windows drive-letter absolute .md links inside worktree as markdown', () => {
const r = resolveMarkdownLinkTarget(
'C:\\repo\\docs\\guide.md',
'C:\\repo\\docs\\note.md',
'C:\\repo'
)
expect(r).toEqual({
kind: 'markdown',
absolutePath: 'C:/repo/docs/guide.md',
relativePath: 'docs/guide.md'
})
})
it('extracts hash line anchors from Windows drive-letter absolute .md links', () => {
const r = resolveMarkdownLinkTarget(
'C:\\repo\\docs\\guide.md#L10',
@@ -66,21 +34,6 @@ describe('resolveMarkdownLinkTarget', () => {
})
})
it('extracts line from #L10', () => {
const r = resolveMarkdownLinkTarget('./guide.md#L10', SOURCE, ROOT)
expect(r).toMatchObject({ kind: 'markdown', line: 10, column: undefined })
})
it('extracts line+col from #L10C5', () => {
const r = resolveMarkdownLinkTarget('./guide.md#L10C5', SOURCE, ROOT)
expect(r).toMatchObject({ kind: 'markdown', line: 10, column: 5 })
})
it('extracts line+col from trailing :10:5 syntax', () => {
const r = resolveMarkdownLinkTarget('./guide.md:10:5', SOURCE, ROOT)
expect(r).toMatchObject({ kind: 'markdown', line: 10, column: 5 })
})
it('extracts line+col from non-markdown file links', () => {
const r = resolveMarkdownLinkTarget('../src/PdfViewer.tsx:142:7', SOURCE, ROOT)
expect(r).toMatchObject({
@@ -92,11 +45,6 @@ describe('resolveMarkdownLinkTarget', () => {
})
})
it('ignores non-line-col hashes', () => {
const r = resolveMarkdownLinkTarget('./guide.md#intro', SOURCE, ROOT)
expect(r).toMatchObject({ kind: 'markdown', line: undefined, column: undefined })
})
it('does not treat :note in a filename as a line anchor', () => {
const r = resolveMarkdownLinkTarget('./my:note.md', SOURCE, ROOT)
expect(r).toMatchObject({ kind: 'markdown', line: undefined })
@@ -108,35 +56,6 @@ describe('resolveMarkdownLinkTarget', () => {
expect(r).toMatchObject({ kind: 'markdown', line: undefined })
})
it('classifies http(s) as external', () => {
const r = resolveMarkdownLinkTarget('https://example.com', SOURCE, ROOT)
expect(r).toEqual({ kind: 'external', url: 'https://example.com/' })
})
it('classifies bare anchor as anchor', () => {
const r = resolveMarkdownLinkTarget('#heading-only', SOURCE, ROOT)
expect(r).toEqual({ kind: 'anchor' })
})
it('classifies non-markdown local file as file', () => {
const r = resolveMarkdownLinkTarget('./image.png', SOURCE, ROOT)
expect(r).toMatchObject({
kind: 'file',
absolutePath: '/repo/docs/image.png',
relativePath: 'docs/image.png'
})
})
it('classifies explicit file URLs inside the worktree with a relative path', () => {
const r = resolveMarkdownLinkTarget('file:///repo/docs/image.png', SOURCE, ROOT)
expect(r).toMatchObject({
kind: 'file',
uri: 'file:///repo/docs/image.png',
absolutePath: '/repo/docs/image.png',
relativePath: 'docs/image.png'
})
})
it('classifies explicit UNC file URLs without losing the server name', () => {
const r = resolveMarkdownLinkTarget(
'file://server/share/repo/docs/image.png',
@@ -166,20 +85,7 @@ describe('resolveMarkdownLinkTarget', () => {
expect(r?.kind).toBe('file')
})
it('decodes URL-encoded spaces in the path', () => {
const r = resolveMarkdownLinkTarget('./my%20note.md', SOURCE, ROOT)
expect(r).toMatchObject({
kind: 'markdown',
absolutePath: '/repo/docs/my note.md'
})
})
it('returns null for malformed percent-encoded file URL paths', () => {
expect(resolveMarkdownLinkTarget('file:///repo/docs/%zz.md', SOURCE, ROOT)).toBeNull()
})
it('returns null for empty href', () => {
expect(resolveMarkdownLinkTarget('', SOURCE, ROOT)).toBeNull()
expect(resolveMarkdownLinkTarget(undefined, SOURCE, ROOT)).toBeNull()
})
})
@@ -2,143 +2,10 @@ import { describe, expect, it } from 'vitest'
import {
MARKDOWN_PREVIEW_SEARCH_QUERY_MAX_BYTES,
findTextMatchRanges,
isMarkdownPreviewFindShortcut,
isMarkdownPreviewReplaceShortcut,
isMarkdownPreviewSearchQueryTooLarge
} from './markdown-preview-search'
describe('isMarkdownPreviewFindShortcut', () => {
it('uses Cmd on macOS', () => {
expect(
isMarkdownPreviewFindShortcut(
{
key: 'f',
code: 'KeyF',
metaKey: true,
ctrlKey: false,
altKey: false,
shiftKey: false
},
'darwin'
)
).toBe(true)
expect(
isMarkdownPreviewFindShortcut(
{
key: 'f',
code: 'KeyF',
metaKey: false,
ctrlKey: true,
altKey: false,
shiftKey: false
},
'darwin'
)
).toBe(false)
})
it('uses Ctrl on non-macOS platforms', () => {
expect(
isMarkdownPreviewFindShortcut(
{
key: 'f',
code: 'KeyF',
metaKey: false,
ctrlKey: true,
altKey: false,
shiftKey: false
},
'linux'
)
).toBe(true)
expect(
isMarkdownPreviewFindShortcut(
{
key: 'f',
code: 'KeyF',
metaKey: true,
ctrlKey: false,
altKey: false,
shiftKey: false
},
'linux'
)
).toBe(false)
})
})
describe('isMarkdownPreviewReplaceShortcut', () => {
it('uses Cmd+Option+F on macOS', () => {
expect(
isMarkdownPreviewReplaceShortcut(
{
key: 'f',
code: 'KeyF',
metaKey: true,
ctrlKey: false,
altKey: true,
shiftKey: false
},
'darwin'
)
).toBe(true)
expect(
isMarkdownPreviewReplaceShortcut(
{
key: 'h',
code: 'KeyH',
metaKey: false,
ctrlKey: true,
altKey: false,
shiftKey: false
},
'darwin'
)
).toBe(false)
})
it('uses Ctrl+H on non-macOS platforms', () => {
expect(
isMarkdownPreviewReplaceShortcut(
{
key: 'h',
code: 'KeyH',
metaKey: false,
ctrlKey: true,
altKey: false,
shiftKey: false
},
'linux'
)
).toBe(true)
expect(
isMarkdownPreviewReplaceShortcut(
{
key: 'f',
code: 'KeyF',
metaKey: true,
ctrlKey: false,
altKey: true,
shiftKey: false
},
'linux'
)
).toBe(false)
})
})
describe('findTextMatchRanges', () => {
it('finds case-insensitive literal matches', () => {
expect(findTextMatchRanges('Alpha beta ALPHA', 'alpha')).toEqual([
{ start: 0, end: 5 },
{ start: 11, end: 16 }
])
})
it('skips overlapping matches so highlights remain stable per text node', () => {
expect(findTextMatchRanges('ababa', 'aba')).toEqual([{ start: 0, end: 3 }])
})
it('preserves code-point folding and offsets for Unicode around ASCII matches', () => {
expect(findTextMatchRanges('ΟΣ οσ ος', 'οσ')).toEqual([
{ start: 0, end: 2 },
@@ -166,42 +33,12 @@ describe('findTextMatchRanges', () => {
expect(ranges.map((range) => 'İstanbul'.slice(range.start, range.end))).toEqual(['stan'])
})
it('returns no matches for an empty query', () => {
expect(findTextMatchRanges('Alpha beta', '')).toEqual([])
})
it('matches only same-case occurrences when matchCase is set', () => {
expect(findTextMatchRanges('Alpha beta ALPHA alpha', 'alpha', { matchCase: true })).toEqual([
{ start: 17, end: 22 }
])
})
it('restricts matches to whole words when wholeWord is set', () => {
expect(findTextMatchRanges('cat category scatter cat', 'cat', { wholeWord: true })).toEqual([
{ start: 0, end: 3 },
{ start: 21, end: 24 }
])
})
it('treats underscore and digits as word characters for whole-word matching', () => {
expect(findTextMatchRanges('id id_2 (id)', 'id', { wholeWord: true })).toEqual([
{ start: 0, end: 2 },
{ start: 9, end: 11 }
])
})
it('checks whole-word boundaries around astral letters by code point', () => {
const text = '𐐀cat cat𐐀 cat'
expect(findTextMatchRanges(text, 'cat', { wholeWord: true })).toEqual([{ start: 12, end: 15 }])
})
it('combines matchCase and wholeWord constraints', () => {
expect(
findTextMatchRanges('Cat cat catalog', 'cat', { matchCase: true, wholeWord: true })
).toEqual([{ start: 4, end: 7 }])
})
it('rejects oversized pasted queries before indexing preview text', () => {
const oversizedQuery = 'secret-preview-search'.repeat(MARKDOWN_PREVIEW_SEARCH_QUERY_MAX_BYTES)
const throwingText = {
@@ -1,237 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
FLOATING_TERMINAL_TRIGGER_POSITION_STORAGE_KEY,
anchorFloatingTerminalTriggerPosition,
clampFloatingTerminalTriggerPosition,
getDefaultFloatingTerminalTriggerCommittedPosition,
getDefaultFloatingTerminalTriggerPosition,
hasUsableFloatingTerminalTriggerViewport,
parseFloatingTerminalTriggerPosition,
persistFloatingTerminalTriggerPosition,
readPersistedFloatingTerminalTriggerPosition,
resolveFloatingTerminalTriggerCommittedPosition,
resolveFloatingTerminalTriggerPosition,
shouldReconcileFloatingTerminalTriggerPosition
} from './floating-terminal-trigger-position'
function stubViewport(width: number, height: number, localStorage?: Partial<Storage>): void {
vi.stubGlobal('window', { innerWidth: width, innerHeight: height, localStorage })
}
describe('floating terminal trigger position', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('defaults to the bottom right of the viewport', () => {
stubViewport(1200, 800)
expect(getDefaultFloatingTerminalTriggerCommittedPosition()).toEqual({
anchorX: 'right',
anchorY: 'bottom',
offsetX: 24,
offsetY: 72
})
expect(getDefaultFloatingTerminalTriggerPosition()).toEqual({
left: 1140,
top: 692
})
})
it('converts each anchored position into viewport coordinates', () => {
stubViewport(1200, 800)
expect(
resolveFloatingTerminalTriggerCommittedPosition({
anchorX: 'left',
anchorY: 'top',
offsetX: 40,
offsetY: 48
})
).toEqual({ left: 40, top: 48 })
expect(
resolveFloatingTerminalTriggerCommittedPosition({
anchorX: 'right',
anchorY: 'bottom',
offsetX: 24,
offsetY: 72
})
).toEqual({ left: 1140, top: 692 })
})
it('clamps parked positions into the viewport', () => {
stubViewport(640, 480)
expect(clampFloatingTerminalTriggerPosition({ left: 900, top: -20 })).toEqual({
left: 596,
top: 36
})
})
it('re-anchors default positions when startup viewport dimensions change', () => {
stubViewport(0, 0)
const initialPosition = getDefaultFloatingTerminalTriggerPosition()
stubViewport(1200, 800)
expect(resolveFloatingTerminalTriggerPosition(initialPosition, 'default')).toEqual({
left: 1140,
top: 692
})
})
it('clamps user-placed positions instead of re-anchoring them on resize', () => {
stubViewport(1200, 800)
expect(resolveFloatingTerminalTriggerPosition({ left: 900, top: 500 }, 'user')).toEqual({
left: 900,
top: 500
})
})
it('preserves right-bottom anchored placements across skinny viewport clamps', () => {
const committed = {
anchorX: 'right' as const,
anchorY: 'bottom' as const,
offsetX: 48,
offsetY: 80
}
stubViewport(260, 220)
expect(resolveFloatingTerminalTriggerPosition(committed, 'user')).toEqual({
left: 176,
top: 104
})
stubViewport(1200, 800)
expect(resolveFloatingTerminalTriggerPosition(committed, 'user')).toEqual({
left: 1116,
top: 684
})
})
it('preserves left-top anchored placements across resize', () => {
const committed = anchorFloatingTerminalTriggerPosition({ left: 32, top: 44 })
expect(committed).toEqual({
anchorX: 'left',
anchorY: 'top',
offsetX: 32,
offsetY: 44
})
stubViewport(1200, 800)
expect(resolveFloatingTerminalTriggerPosition(committed!, 'user')).toEqual({
left: 32,
top: 44
})
})
it('ignores malformed persisted positions', () => {
stubViewport(640, 480)
expect(parseFloatingTerminalTriggerPosition('not-json')).toBeNull()
expect(parseFloatingTerminalTriggerPosition('{"left":"1","top":2}')).toBeNull()
expect(
parseFloatingTerminalTriggerPosition(
'{"anchorX":"center","anchorY":"bottom","offsetX":24,"offsetY":72}'
)
).toBeNull()
expect(
parseFloatingTerminalTriggerPosition(
'{"anchorX":"right","anchorY":"bottom","offsetX":24,"offsetY":null}'
)
).toBeNull()
})
it('parses anchored and legacy persisted positions', () => {
expect(
parseFloatingTerminalTriggerPosition(
'{"anchorX":"right","anchorY":"bottom","offsetX":24,"offsetY":72}'
)
).toEqual({
anchorX: 'right',
anchorY: 'bottom',
offsetX: 24,
offsetY: 72
})
expect(parseFloatingTerminalTriggerPosition('{"left":900,"top":500}')).toEqual({
left: 900,
top: 500
})
})
it('does not clamp parsed positions before the viewport is settled', () => {
stubViewport(0, 0)
expect(parseFloatingTerminalTriggerPosition('{"left":900,"top":500}')).toEqual({
left: 900,
top: 500
})
})
it('detects whether the viewport is usable for persisted user-position clamps', () => {
stubViewport(0, 0)
expect(hasUsableFloatingTerminalTriggerViewport()).toBe(false)
stubViewport(1200, 800)
expect(hasUsableFloatingTerminalTriggerViewport()).toBe(true)
})
it('does not derive anchors from a viewport smaller than the trigger', () => {
stubViewport(40, 40)
expect(anchorFloatingTerminalTriggerPosition({ left: 8, top: 36 })).toBeNull()
})
it('defers user-position reconciliation until the viewport is usable', () => {
stubViewport(0, 0)
expect(shouldReconcileFloatingTerminalTriggerPosition('default')).toBe(true)
expect(shouldReconcileFloatingTerminalTriggerPosition('user')).toBe(false)
stubViewport(1200, 800)
expect(shouldReconcileFloatingTerminalTriggerPosition('user')).toBe(true)
})
it('reads and writes persisted positions without throwing when storage is unavailable', () => {
const localStorage = {
getItem: vi.fn(() => {
throw new Error('blocked')
}),
setItem: vi.fn(() => {
throw new Error('blocked')
})
}
stubViewport(1200, 800, localStorage)
expect(readPersistedFloatingTerminalTriggerPosition()).toBeNull()
expect(() =>
persistFloatingTerminalTriggerPosition({
anchorX: 'right',
anchorY: 'bottom',
offsetX: 24,
offsetY: 72
})
).not.toThrow()
})
it('persists through the trigger-specific storage key', () => {
const localStorage = {
getItem: vi.fn(() => null),
setItem: vi.fn()
}
stubViewport(1200, 800, localStorage)
persistFloatingTerminalTriggerPosition({
anchorX: 'right',
anchorY: 'bottom',
offsetX: 24,
offsetY: 72
})
expect(localStorage.setItem).toHaveBeenCalledWith(
FLOATING_TERMINAL_TRIGGER_POSITION_STORAGE_KEY,
'{"anchorX":"right","anchorY":"bottom","offsetX":24,"offsetY":72}'
)
})
})
@@ -1,18 +1,12 @@
import { describe, it, expect } from 'vitest'
import {
distanceFromBottom,
isNearBottom,
nextFollowingEnd,
shouldShowJumpToLatest,
NATIVE_CHAT_BOTTOM_THRESHOLD_PX,
NATIVE_CHAT_FOLLOW_REARM_PX,
readerGestureLeavesEnd
NATIVE_CHAT_FOLLOW_REARM_PX
} from './native-chat-autoscroll'
const atBottom = { scrollTop: 952, scrollHeight: 1000, clientHeight: 48 }
const scrolledUp = { scrollTop: 0, scrollHeight: 1000, clientHeight: 48 }
const noOverflow = { scrollTop: 0, scrollHeight: 48, clientHeight: 48 }
/** A view parked exactly `distance` px above the end of the same document. */
function parkedAbove(distance: number): {
scrollTop: number
@@ -22,39 +16,6 @@ function parkedAbove(distance: number): {
return { scrollTop: 952 - distance, scrollHeight: 1000, clientHeight: 48 }
}
describe('distanceFromBottom', () => {
it('is zero at the exact bottom and never negative', () => {
expect(distanceFromBottom(atBottom)).toBe(0)
expect(distanceFromBottom({ scrollTop: 5000, scrollHeight: 1000, clientHeight: 48 })).toBe(0)
})
})
describe('isNearBottom', () => {
it('sticks within the threshold and detaches beyond it', () => {
expect(isNearBottom(atBottom)).toBe(true)
expect(
isNearBottom({
scrollTop: 952 - NATIVE_CHAT_BOTTOM_THRESHOLD_PX,
scrollHeight: 1000,
clientHeight: 48
})
).toBe(true)
expect(isNearBottom(scrolledUp)).toBe(false)
})
})
describe('shouldShowJumpToLatest', () => {
it('shows only when detached with content below', () => {
expect(shouldShowJumpToLatest(false, scrolledUp)).toBe(true)
})
it('hides while stuck to bottom', () => {
expect(shouldShowJumpToLatest(true, scrolledUp)).toBe(false)
})
it('hides when there is nothing to scroll', () => {
expect(shouldShowJumpToLatest(false, noOverflow)).toBe(false)
})
})
// Application writes emit ordinary browser scroll events; explicit marks distinguish delayed echoes from reader movement.
describe('nextFollowingEnd', () => {
const wellAway = parkedAbove(400)
@@ -66,19 +27,11 @@ describe('nextFollowingEnd', () => {
settling: false
}
it('follows when the reader reaches the end', () => {
expect(nextFollowingEnd(following)).toBe(true)
})
// Growth after application pinning is not reader detachment, even if the scroll event arrives late.
it('keeps following when a delayed application scroll arrives after growth', () => {
expect(nextFollowingEnd({ ...following, programmatic: true, geometry: wellAway })).toBe(true)
})
it('keeps following through unmarked passive layout offsets', () => {
expect(nextFollowingEnd({ ...following, geometry: wellAway })).toBe(true)
})
it.each([0, NATIVE_CHAT_FOLLOW_REARM_PX, 400])(
'does not reattach a detached reader from an application write %i px from the end',
(distance) => {
@@ -103,21 +56,6 @@ describe('nextFollowingEnd', () => {
expect(shouldShowJumpToLatest(false, parked)).toBe(false)
})
it('re-arms at the band and not one pixel past it', () => {
const detached = {
following: false,
programmatic: false,
previousDistanceFromEnd: 400,
settling: false
}
expect(
nextFollowingEnd({ ...detached, geometry: parkedAbove(NATIVE_CHAT_FOLLOW_REARM_PX) })
).toBe(true)
expect(
nextFollowingEnd({ ...detached, geometry: parkedAbove(NATIVE_CHAT_FOLLOW_REARM_PX + 1) })
).toBe(false)
})
// Early smooth-scroll frames may be unmarked; movement away from the end must not rearm follow.
it('does not reattach a detached reader who is moving away from the end', () => {
const leaving = {
@@ -145,29 +83,4 @@ describe('nextFollowingEnd', () => {
expect(nextFollowingEnd({ ...clamped, settling: true })).toBe(false)
expect(nextFollowingEnd({ ...clamped, settling: false })).toBe(true)
})
it('keeps a following reader through a small move up inside the band', () => {
expect(
nextFollowingEnd({ ...following, previousDistanceFromEnd: 0, geometry: parkedAbove(2) })
).toBe(true)
})
// Sub-pixel and zoom rounding put the true end a fraction short of exact.
it('holds follow through rounding noise at the end', () => {
expect(nextFollowingEnd({ ...following, geometry: parkedAbove(1.5) })).toBe(true)
})
})
it('only detaches for gestures that can move away from the tail', () => {
expect(readerGestureLeavesEnd({ kind: 'wheel', deltaY: -10, zoom: false }, atBottom)).toBe(true)
expect(readerGestureLeavesEnd({ kind: 'wheel', deltaY: 10, zoom: false }, atBottom)).toBe(false)
expect(readerGestureLeavesEnd({ kind: 'wheel', deltaY: -10, zoom: true }, atBottom)).toBe(false)
expect(readerGestureLeavesEnd({ kind: 'key', key: 'Home' }, atBottom)).toBe(true)
expect(readerGestureLeavesEnd({ kind: 'key', key: 'End' }, atBottom)).toBe(false)
expect(readerGestureLeavesEnd({ kind: 'scrollbar-press' }, atBottom)).toBe(true)
expect(readerGestureLeavesEnd({ kind: 'touch-drag' }, parkedAbove(60))).toBe(true)
expect(readerGestureLeavesEnd({ kind: 'touch-drag' }, atBottom)).toBe(false)
expect(readerGestureLeavesEnd({ kind: 'wheel', deltaY: -10, zoom: false }, noOverflow)).toBe(
false
)
})
@@ -3,85 +3,6 @@ import { canToggleNativeChat } from './native-chat-availability'
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
describe('canToggleNativeChat', () => {
it('allows a terminal launched with a supported coding agent', () => {
expect(
canToggleNativeChat({
contentType: 'terminal',
launchAgent: 'claude'
})
).toBe(true)
})
it('allows a terminal with a live detected supported agent but no launchAgent', () => {
expect(
canToggleNativeChat({
contentType: 'terminal',
launchAgent: null,
detectedAgent: 'codex'
})
).toBe(true)
})
it('allows a terminal with a resolved title/foreground supported agent before hooks arrive', () => {
expect(
canToggleNativeChat({
contentType: 'terminal',
launchAgent: null,
resolvedAgent: 'claude'
})
).toBe(true)
})
it('allows the OpenClaude variant', () => {
expect(
canToggleNativeChat({
contentType: 'terminal',
launchAgent: 'openclaude'
})
).toBe(true)
})
it('allows an existing chat view to toggle back after live signals are gone', () => {
expect(
canToggleNativeChat({
contentType: 'terminal',
launchAgent: null,
isChatViewMode: true
})
).toBe(true)
})
it('accepts local Grok once native chat can parse its transcript', () => {
expect(
canToggleNativeChat({
contentType: 'terminal',
launchAgent: 'grok',
nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(null)
})
).toBe(true)
})
it('accepts runtime-owned Grok because Model B reads the transcript locally', () => {
expect(
canToggleNativeChat({
contentType: 'terminal',
launchAgent: 'grok',
nativeChatTranscriptIsLocalReadable:
isNativeChatTranscriptLocalReadable('runtime-ssh-env-1')
})
).toBe(true)
})
it('rejects Model-A SSH Grok when its transcript is remote-only', () => {
expect(
canToggleNativeChat({
contentType: 'terminal',
launchAgent: 'grok',
nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable('ssh-target-1')
})
).toBe(false)
})
// omp exposes no hook transcript path; its session file requires readable agent storage.
it('rejects Model-A SSH omp but accepts it local and runtime-owned', () => {
const forConnection = (connectionId: string | null): boolean =>
@@ -106,27 +27,6 @@ describe('canToggleNativeChat', () => {
).toBe(true)
})
it.each(['gemini'] as const)('rejects unsupported agent %s detected live', (agent) => {
expect(
canToggleNativeChat({
contentType: 'terminal',
launchAgent: null,
detectedAgent: agent
})
).toBe(false)
})
it('accepts Grok when resolved from the title', () => {
expect(
canToggleNativeChat({
contentType: 'terminal',
launchAgent: null,
resolvedAgent: 'grok',
nativeChatTranscriptIsLocalReadable: true
})
).toBe(true)
})
it('rejects a stale supported title when live detection found an unsupported agent', () => {
expect(
canToggleNativeChat({
@@ -157,37 +57,4 @@ describe('canToggleNativeChat', () => {
})
).toBe(false)
})
it('rejects a plain shell terminal with no agent', () => {
expect(
canToggleNativeChat({
contentType: 'terminal',
launchAgent: null,
detectedAgent: null
})
).toBe(false)
})
it('rejects a plain shell terminal with everything omitted', () => {
expect(canToggleNativeChat({ contentType: 'terminal' })).toBe(false)
})
it('rejects an editor tab even if a supported agent hint were somehow present', () => {
expect(
canToggleNativeChat({
contentType: 'editor',
launchAgent: 'codex',
detectedAgent: 'codex'
})
).toBe(false)
})
it('rejects a browser tab', () => {
expect(
canToggleNativeChat({
contentType: 'browser',
detectedAgent: 'claude'
})
).toBe(false)
})
})
@@ -29,27 +29,11 @@ function interactiveTarget(): EventTarget {
}
describe('shouldRedirectNativeChatTyping', () => {
it('redirects printable typing from the native chat pane', () => {
expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'x' }), true)).toBe(true)
})
it('does not redirect shortcuts or non-printable keys', () => {
expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'Enter' }), true)).toBe(false)
expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'a', metaKey: true }), true)).toBe(false)
expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'a', ctrlKey: true }), true)).toBe(false)
})
it('does not redirect IME composition or already-handled events', () => {
expect(shouldRedirectNativeChatTyping(keyEvent({ isComposing: true }), true)).toBe(false)
expect(shouldRedirectNativeChatTyping(keyEvent({ defaultPrevented: true }), true)).toBe(false)
})
it('leaves interactive targets alone', () => {
expect(shouldRedirectNativeChatTyping(keyEvent({ target: interactiveTarget() }), true)).toBe(
false
)
})
it('passes typed characters but not Space through a focused button or link', () => {
const button = document.createElement('button')
const link = document.createElement('a')
@@ -97,11 +81,6 @@ describe('shouldRedirectNativeChatTyping', () => {
})
describe('shouldFocusNativeChatComposerFromEditingKey', () => {
it('focuses the composer on Backspace/Delete from the pane', () => {
expect(shouldFocusNativeChatComposerFromEditingKey(keyEvent({ key: 'Backspace' }))).toBe(true)
expect(shouldFocusNativeChatComposerFromEditingKey(keyEvent({ key: 'Delete' }))).toBe(true)
})
it('ignores other keys, shortcut/editing modifiers, IME composition, and handled events', () => {
expect(shouldFocusNativeChatComposerFromEditingKey(keyEvent({ key: 'a' }))).toBe(false)
expect(
@@ -126,36 +105,9 @@ describe('shouldFocusNativeChatComposerFromEditingKey', () => {
shouldFocusNativeChatComposerFromEditingKey(keyEvent({ key: 'Backspace', isComposing: true }))
).toBe(false)
})
it('leaves interactive targets (the textarea itself) alone', () => {
expect(
shouldFocusNativeChatComposerFromEditingKey(
keyEvent({ key: 'Backspace', target: interactiveTarget() })
)
).toBe(false)
})
})
describe('shouldFocusNativeChatPaneFromPointerTarget', () => {
it('focuses the pane for non-interactive clicks', () => {
expect(shouldFocusNativeChatPaneFromPointerTarget(inertTarget())).toBe(true)
})
it('does not steal focus from controls', () => {
expect(shouldFocusNativeChatPaneFromPointerTarget(interactiveTarget())).toBe(false)
})
})
describe('shouldFocusNativeChatInputForPaste', () => {
it.each([
[true, { key: 'v', metaKey: true }],
[false, { key: 'v', ctrlKey: true }],
[false, { key: 'V', ctrlKey: true, shiftKey: true }],
[false, { key: 'Insert', shiftKey: true }]
])('takes the platform paste chord (mac=%s) outside an input', (isMac, keys) => {
expect(shouldFocusNativeChatInputForPaste(keyEvent(keys), isMac)).toBe(true)
})
it('ignores the other platform chord, Alt chords, IME, handled events and inputs', () => {
expect(shouldFocusNativeChatInputForPaste(keyEvent({ key: 'v', ctrlKey: true }), true)).toBe(
false
@@ -1,9 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import {
aiVaultSessionCwdMatchesWorkspace,
resolveAiVaultSessionResumeInChatEligibility
} from './ai-vault-session-resume-in-chat'
import { resolveAiVaultSessionResumeInChatEligibility } from './ai-vault-session-resume-in-chat'
type ResumeInChatSession = Parameters<
typeof resolveAiVaultSessionResumeInChatEligibility
@@ -36,30 +33,6 @@ function eligibility(
}
describe('resolveAiVaultSessionResumeInChatEligibility', () => {
it('offers the chat for a local Claude row in its own workspace', () => {
expect(eligibility()).toEqual({ available: true, workspaceId: 'repo-1::/repo/orca' })
})
it.each<AiVaultSession['agent']>(['grok', 'omp', 'opencode', 'opencode2', 'pi'])(
'offers the chat for a local %s row in its own workspace',
(agent) => {
expect(eligibility({ session: session({ agent }) })).toEqual({
available: true,
workspaceId: 'repo-1::/repo/orca'
})
}
)
it.each<AiVaultSession['agent']>(['hermes', 'gemini'])(
'refuses %s, whose chats cannot take over a CLI conversation',
(agent) => {
expect(eligibility({ session: session({ agent }) })).toEqual({
available: false,
reason: 'agent'
})
}
)
it('refuses a row already adopted into a chat before any other check', () => {
expect(
eligibility({
@@ -101,47 +74,12 @@ describe('resolveAiVaultSessionResumeInChatEligibility', () => {
).toEqual({ available: false, reason: 'remote' })
})
it('refuses a row whose transcript is stored inside WSL', () => {
expect(
eligibility({
session: session({
filePath: '//wsl.localhost/Ubuntu-22.04/home/dev/.claude/projects/p/session-1.jsonl'
})
})
).toEqual({ available: false, reason: 'remote' })
})
it('refuses a transcript that holds no conversation', () => {
expect(eligibility({ session: session({ messageCount: 0, previewMessages: [] }) })).toEqual({
available: false,
reason: 'empty'
})
})
it('offers a zero-count row whose preview proves the turns exist', () => {
expect(
eligibility({
session: session({
messageCount: 0,
previewMessages: [{ role: 'user', text: 'hello', timestamp: null }]
})
})
).toMatchObject({ available: true })
})
it('refuses when the same pair could not take the structured route for a fresh chat', () => {
expect(eligibility({ structuredRouteAvailable: false })).toEqual({
available: false,
reason: 'workspace'
})
})
it('refuses when there is no target workspace at all', () => {
expect(eligibility({ targetWorkspaceId: null })).toEqual({
available: false,
reason: 'workspace'
})
})
})
describe('workspace matching, which binds the agents that store conversations per folder', () => {
@@ -184,21 +122,3 @@ describe('workspace matching, which binds the agents that store conversations pe
).toMatchObject({ available: true })
})
})
describe('aiVaultSessionCwdMatchesWorkspace', () => {
it('ignores separator, case, and a trailing slash', () => {
expect(aiVaultSessionCwdMatchesWorkspace('C:\\repo\\Orca', 'c:/repo/orca')).toBe(true)
expect(aiVaultSessionCwdMatchesWorkspace('/repo/orca/', '/repo/orca')).toBe(true)
expect(aiVaultSessionCwdMatchesWorkspace(' /repo/orca ', '/repo/orca')).toBe(true)
})
it('never calls a missing path a match', () => {
expect(aiVaultSessionCwdMatchesWorkspace(null, '/repo/orca')).toBe(false)
expect(aiVaultSessionCwdMatchesWorkspace('/repo/orca', null)).toBe(false)
expect(aiVaultSessionCwdMatchesWorkspace('', '')).toBe(false)
})
it('does not treat a sibling directory as the same workspace', () => {
expect(aiVaultSessionCwdMatchesWorkspace('/repo/orca-2', '/repo/orca')).toBe(false)
})
})
@@ -13,20 +13,6 @@ function node(path: string, isDirectory = false): TreeNode {
}
describe('selectDeletionRoots', () => {
it('normalizes each selected path once instead of once per possible parent', () => {
let reads = 0
const nodes = Array.from({ length: 1000 }, (_, i) => ({
...node(`/repo/directory-${i}`, true),
get path() {
reads++
return `/repo/directory-${i}`
}
}))
const selected = selectDeletionRoots(nodes)
expect(reads).toBe(2000)
selected.forEach((entry, index) => expect(entry).toBe(nodes[index]))
})
it('preserves WSL case-sensitive paths while accepting UNC aliases', () => {
const parent = node('//wsl.localhost/Ubuntu/Repo', true)
const child = node('//wsl$/ubuntu/Repo/file')
@@ -34,46 +20,6 @@ describe('selectDeletionRoots', () => {
expect(selectDeletionRoots([parent, child, outside])).toEqual([parent, outside])
})
it('keeps unrelated files and directories', () => {
const nodes = [node('/repo/a.ts'), node('/repo/b.ts'), node('/repo/docs', true)]
expect(selectDeletionRoots(nodes)).toEqual(nodes)
})
it('drops children of a selected directory', () => {
const dir = node('/repo/docs', true)
const child = node('/repo/docs/readme.md')
const nested = node('/repo/docs/guides/intro.md')
const outside = node('/repo/a.ts')
expect(selectDeletionRoots([dir, child, nested, outside])).toEqual([dir, outside])
})
it('keeps siblings whose paths merely share a prefix', () => {
const dir = node('/repo/docs', true)
const lookalike = node('/repo/docs-old/readme.md')
expect(selectDeletionRoots([dir, lookalike])).toEqual([dir, lookalike])
})
it('does not treat selected files as containers', () => {
const file = node('/repo/a.ts')
const other = node('/repo/a.ts/impossible-child')
expect(selectDeletionRoots([file, other])).toEqual([file, other])
})
it('reads directory membership once per selected node, including file-only selections', () => {
let directoryReads = 0
const nodes = Array.from({ length: 1_000 }, (_, index) => ({
...node(`/repo/file-${index}.ts`),
get isDirectory() {
directoryReads += 1
return false
}
}))
const selected = selectDeletionRoots(nodes)
expect(directoryReads).toBe(nodes.length)
expect(selected).not.toBe(nodes)
selected.forEach((entry, index) => expect(entry).toBe(nodes[index]))
})
it('preserves order, node identity and host ownership when children precede parents', () => {
const child = node('/repo/docs/guide.md')
const first = { ...node('/repo/first.ts'), operationOwner: { kind: 'local' as const } }
@@ -94,14 +40,6 @@ describe('selectDeletionRoots', () => {
expect(nodes).toEqual([child, first, parent, last])
})
it('keeps repeated references but excludes distinct directories with the same path', () => {
const dir = node('/repo/docs', true)
expect(selectDeletionRoots([dir, dir])).toEqual([dir, dir])
expect(selectDeletionRoots([dir, { ...dir }])).toEqual([])
const file = node('/repo/docs')
expect(selectDeletionRoots([file, dir])).toEqual([dir])
})
it.each([
['/repo/docs/', '/repo/docs/guide.md', '/repo/docs-other/guide.md'],
['C:\\Repo\\Docs\\', 'c:/repo/docs/guide.md', 'C:/Repo/Docs-other/guide.md'],
@@ -157,20 +95,6 @@ describe('runBatchDeletion', () => {
expect(deleteNode).not.toHaveBeenCalled()
})
it('skips confirmation when none is needed', async () => {
const confirmBatch = vi.fn()
const deleted = await runBatchDeletion({
roots,
needsConfirmation: false,
confirmBatch,
deleteNode: vi.fn(async () => true)
})
expect(confirmBatch).not.toHaveBeenCalled()
expect(deleted).toEqual(roots)
})
it('excludes failed deletes from the result but continues the batch', async () => {
const deleteNode = vi.fn(async (n: TreeNode) => n.path !== '/repo/b.ts')
@@ -8,16 +8,6 @@ import {
import type { AppState } from '@/store/types'
describe('getExternalFileChangeRelativePath', () => {
it('returns a worktree-relative file path for external file updates', () => {
expect(getExternalFileChangeRelativePath('/repo', '/repo/config/settings.json', false)).toBe(
'config/settings.json'
)
})
it('ignores directory events so only file tabs reload', () => {
expect(getExternalFileChangeRelativePath('/repo', '/repo/config', true)).toBeNull()
})
it('treats isDirectory=undefined as a file so delete events still notify', () => {
// Deleted paths cannot report isDirectory; undefined must still invalidate editor contents.
expect(
@@ -25,53 +15,9 @@ describe('getExternalFileChangeRelativePath', () => {
).toBe('config/settings.json')
})
it('normalizes Windows separators before deriving the relative path', () => {
expect(
getExternalFileChangeRelativePath('C:\\repo', 'C:\\repo\\config\\settings.json', false)
).toBe('config/settings.json')
})
it('matches Windows paths case-insensitively before deriving the relative path', () => {
expect(
getExternalFileChangeRelativePath('C:\\Repo', 'c:\\repo\\config\\settings.json', false)
).toBe('config/settings.json')
})
it('preserves UNC roots when deriving the relative path', () => {
expect(
getExternalFileChangeRelativePath(
'//Server/Share/Repo',
'//server/share/repo/config/settings.json',
false
)
).toBe('config/settings.json')
})
it('ignores paths outside the active worktree', () => {
expect(
getExternalFileChangeRelativePath('/repo', '/other/config/settings.json', false)
).toBeNull()
})
it('rejects sibling worktrees whose path merely shares a prefix', () => {
expect(getExternalFileChangeRelativePath('/repo', '/repo-other/file.ts', false)).toBeNull()
})
it('returns null when the changed path is the worktree root itself', () => {
expect(getExternalFileChangeRelativePath('/repo', '/repo', false)).toBeNull()
})
it('tolerates a trailing slash on the worktree path', () => {
expect(getExternalFileChangeRelativePath('/repo/', '/repo/src/index.ts', false)).toBe(
'src/index.ts'
)
})
it('preserves nested segments in the returned relative path', () => {
expect(getExternalFileChangeRelativePath('/repo', '/repo/a/b/c/deep.ts', false)).toBe(
'a/b/c/deep.ts'
)
})
})
describe('canonicalizeFileExplorerWatchPath', () => {
@@ -98,23 +44,10 @@ describe('canonicalizeFileExplorerWatchPath', () => {
})
describe('resolveCachedDirPath', () => {
it('returns the exact cache key when present', () => {
const cache = { '/repo/src': { children: [] } }
expect(resolveCachedDirPath(cache, '/repo/src')).toBe('/repo/src')
})
it('matches Windows cache keys case-insensitively (#10264)', () => {
const cache = { 'C:\\Repo\\src': { children: [] } }
expect(resolveCachedDirPath(cache, 'c:\\repo\\src')).toBe('C:\\Repo\\src')
})
it('falls back to the worktree root path when the root is not yet cached', () => {
expect(resolveCachedDirPath({}, 'C:\\Repo', 'C:\\Repo')).toBe('C:\\Repo')
})
it('returns null when the directory is not cached and is not the worktree root', () => {
expect(resolveCachedDirPath({ '/repo': { children: [] } }, '/repo/src', '/repo')).toBeNull()
})
})
describe('getFileExplorerWatchRuntimeEnvironmentId', () => {
@@ -1,197 +0,0 @@
// Use pure predicates for consent gates because static SSR never runs the consent-loading effect.
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { TelemetryConsentState } from '../../../../shared/telemetry-consent-types'
const { getConsentStateMock, setOptInMock } = vi.hoisted(() => ({
getConsentStateMock: vi.fn<() => Promise<TelemetryConsentState>>(),
setOptInMock: vi.fn<(optedIn: boolean) => Promise<void>>()
}))
vi.mock('../../lib/telemetry', () => ({
getConsentState: getConsentStateMock,
setOptIn: setOptInMock
}))
import { PrivacyPane, computeBlockedReason, envVarNameForReason, isEnvBlocked } from './PrivacyPane'
function buildSettings(
telemetry: Partial<NonNullable<GlobalSettings['telemetry']>> = {}
): GlobalSettings {
return {
telemetry: {
installId: 'test-install-id',
existedBeforeTelemetryRelease: false,
optedIn: true,
...telemetry
}
} as unknown as GlobalSettings
}
describe('PrivacyPane — toggle markup reflects stored preference', () => {
beforeEach(() => {
getConsentStateMock.mockReset()
getConsentStateMock.mockResolvedValue({ effective: 'enabled' })
})
afterEach(() => {
vi.restoreAllMocks()
})
it('renders the toggle in its checked state when optedIn is true', () => {
const markup = renderToStaticMarkup(
React.createElement(PrivacyPane, {
settings: buildSettings({ optedIn: true })
})
)
expect(markup).toContain('aria-checked="true"')
})
it('renders the toggle unchecked when optedIn is false', () => {
const markup = renderToStaticMarkup(
React.createElement(PrivacyPane, {
settings: buildSettings({ optedIn: false })
})
)
expect(markup).toContain('aria-checked="false"')
})
it('renders the toggle unchecked when optedIn is null (existing user pre-banner)', () => {
// Banner-pending users transmit nothing; Settings opt-in uses its own attribution and resolves the notice.
const markup = renderToStaticMarkup(
React.createElement(PrivacyPane, {
settings: buildSettings({
existedBeforeTelemetryRelease: true,
optedIn: null
})
})
)
expect(markup).toContain('aria-checked="false"')
})
})
describe('PrivacyPane — isEnvBlocked', () => {
it('returns true for DO_NOT_TRACK', () => {
expect(isEnvBlocked({ effective: 'disabled', reason: 'do_not_track' })).toBe(true)
})
it('returns true for ORCA_TELEMETRY_DISABLED', () => {
expect(isEnvBlocked({ effective: 'disabled', reason: 'orca_disabled' })).toBe(true)
})
it('returns true for CI', () => {
expect(isEnvBlocked({ effective: 'disabled', reason: 'ci' })).toBe(true)
})
it('returns false for user_opt_out (not env-blocked — the toggle remains actionable)', () => {
// User opt-out is reversible preference, so it must not disable the toggle like an environment constraint.
expect(isEnvBlocked({ effective: 'disabled', reason: 'user_opt_out' })).toBe(false)
})
it('returns false for pending_banner', () => {
expect(isEnvBlocked({ effective: 'pending_banner' })).toBe(false)
})
it('returns false for enabled', () => {
expect(isEnvBlocked({ effective: 'enabled' })).toBe(false)
})
it('returns false for null (consent not yet fetched)', () => {
expect(isEnvBlocked(null)).toBe(false)
})
})
describe('PrivacyPane — envVarNameForReason', () => {
it('maps do_not_track to DO_NOT_TRACK', () => {
expect(envVarNameForReason('do_not_track')).toBe('DO_NOT_TRACK')
})
it('maps orca_disabled to ORCA_TELEMETRY_DISABLED', () => {
expect(envVarNameForReason('orca_disabled')).toBe('ORCA_TELEMETRY_DISABLED')
})
it('maps ci to CI', () => {
expect(envVarNameForReason('ci')).toBe('CI')
})
})
describe('PrivacyPane — computeBlockedReason', () => {
it('returns null when consent is enabled', () => {
expect(computeBlockedReason({ effective: 'enabled' })).toBeNull()
})
it('returns null for a user_opt_out state (the toggle must remain actionable)', () => {
expect(computeBlockedReason({ effective: 'disabled', reason: 'user_opt_out' })).toBeNull()
})
it('returns null for pending_banner (banner-pending does NOT gate the toggle)', () => {
// Settings opt-in clears optedIn=null and dismisses the first-launch notice.
expect(computeBlockedReason({ effective: 'pending_banner' })).toBeNull()
})
it('names DO_NOT_TRACK as the env reason when set', () => {
const result = computeBlockedReason({ effective: 'disabled', reason: 'do_not_track' })
expect(result).toEqual({ kind: 'env', reason: 'do_not_track' })
})
it('names ORCA_TELEMETRY_DISABLED as the env reason when set', () => {
const result = computeBlockedReason({ effective: 'disabled', reason: 'orca_disabled' })
expect(result).toEqual({ kind: 'env', reason: 'orca_disabled' })
})
it('names CI as the env reason when set', () => {
const result = computeBlockedReason({ effective: 'disabled', reason: 'ci' })
expect(result).toEqual({ kind: 'env', reason: 'ci' })
})
})
describe('PrivacyPane — markup respects blocked state', () => {
const switchTag = (markup: string): string =>
markup.match(/<button[^>]*role="switch"[^>]*>/)?.[0] ?? ''
// Static SSR keeps consent null; predicate tests cover blocked reasons, while these cases verify actionable cohorts.
beforeEach(() => {
getConsentStateMock.mockReset()
getConsentStateMock.mockResolvedValue({ effective: 'enabled' })
})
afterEach(() => {
vi.restoreAllMocks()
})
it('leaves the toggle enabled for a new user', () => {
const markup = renderToStaticMarkup(
React.createElement(PrivacyPane, {
settings: buildSettings({
existedBeforeTelemetryRelease: false,
optedIn: true
})
})
)
expect(switchTag(markup)).not.toContain(' disabled=""')
})
it('leaves the toggle enabled for an existing user awaiting the banner (flip dismisses the banner)', () => {
const markup = renderToStaticMarkup(
React.createElement(PrivacyPane, {
settings: buildSettings({
existedBeforeTelemetryRelease: true,
optedIn: null
})
})
)
expect(switchTag(markup)).not.toContain(' disabled=""')
})
it('leaves the toggle enabled once the existing-user banner is resolved', () => {
const markup = renderToStaticMarkup(
React.createElement(PrivacyPane, {
settings: buildSettings({
existedBeforeTelemetryRelease: true,
optedIn: true
})
})
)
expect(switchTag(markup)).not.toContain(' disabled=""')
})
})
@@ -8,25 +8,6 @@ function renderChat(content: string): string {
}
describe('CommentMarkdown unknown HTML as text', () => {
it('keeps an unknown inline tag in native chat prose', () => {
const markup = renderChat('use\na <se-tabs>')
expect(markup).toContain('a &lt;se-tabs&gt;')
})
it('keeps unknown opening and closing tags around their content', () => {
const markup = renderChat('wrap <se-tabs>x</se-tabs> here')
expect(markup).toContain('wrap &lt;se-tabs&gt;x&lt;/se-tabs&gt; here')
})
it('keeps a component-style tag that starts a block', () => {
const markup = renderChat('<Button variant="ghost" />\n\nnext')
expect(markup).toContain('&lt;Button variant=&quot;ghost&quot; /&gt;')
expect(markup).toContain('next')
})
it('shows script tags as text instead of dropping them', () => {
const markup = renderChat('see <script>alert(1)</script>')
@@ -34,31 +15,12 @@ describe('CommentMarkdown unknown HTML as text', () => {
expect(markup).not.toContain('<script>')
})
it('still renders allowed HTML in native chat', () => {
const markup = renderChat('H<sub>2</sub>O and <kbd>Ctrl</kbd>')
expect(markup).toContain('<sub>2</sub>')
expect(markup).toContain('<kbd>Ctrl</kbd>')
})
it('leaves inline code untouched', () => {
const markup = renderChat('use `<se-tabs>`')
expect(markup).toContain('&lt;se-tabs&gt;</code>')
})
it('keeps sanitize dropping unknown tags on other surfaces', () => {
const markup = renderToStaticMarkup(<CommentMarkdown content="a <se-tabs> b" />)
expect(markup).not.toContain('se-tabs')
})
it('keeps allowed HTML when an attribute mentions an unknown tag', () => {
const markup = renderChat('<kbd title="use <Button>">Ctrl</kbd>')
expect(markup).toContain('<kbd title="use &lt;Button&gt;">Ctrl</kbd>')
})
it('does not reveal HTML comments that mention unknown tags', () => {
const markup = renderChat('before <!-- <Button /> --> after')
@@ -66,43 +28,6 @@ describe('CommentMarkdown unknown HTML as text', () => {
expect(markup).toContain('before after')
})
it('preserves the existing bare URL punctuation boundary in chat', () => {
const markup = renderChat('See https://example.com/a。Next')
expect(markup).toContain('href="https://example.com/a"')
expect(markup).not.toContain('href="https://example.com/a%E3%80%82Next"')
})
it('keeps multiline XML literal and preserves its line breaks', () => {
const markup = renderChat('<project>\n<name>orca</name>\n</project>')
expect(markup).toContain('&lt;project&gt;<br/>')
expect(markup).toContain('&lt;name&gt;orca&lt;/name&gt;<br/>')
expect(markup).toContain('&lt;/project&gt;')
expect(markup).not.toContain('<project>')
})
it('keeps literal tags in a growing reply', () => {
const markup = renderToStaticMarkup(
<NativeChatMarkdown content="use a <se-tabs>" growing streaming />
)
expect(markup).toContain('a &lt;se-tabs&gt;')
})
it('leaves fenced code untouched', () => {
const markup = renderChat('```tsx\n<Button />\n```')
expect(markup).toContain('&lt;Button /&gt;\n</code></pre>')
})
it('leaves angle-bracket URL and email autolinks intact', () => {
const markup = renderChat('<https://example.com> and <orca@example.com>')
expect(markup).toContain('href="https://example.com"')
expect(markup).toContain('href="mailto:orca@example.com"')
})
it('still sanitizes unsafe attributes and URLs on allowed HTML', () => {
const markup = renderChat('<a href="javascript:alert(1)" onclick="alert(2)">link</a>')
@@ -140,12 +65,4 @@ describe('CommentMarkdown unknown HTML as text', () => {
expect(markup).not.toContain('<iframe')
expect(markup).not.toContain('<script')
})
it('still renders an allowed disclosure', () => {
const markup = renderChat('<details><summary>More</summary>\n\nbody\n\n</details>')
expect(markup).toContain('<details>')
expect(markup).toContain('<summary>More</summary>')
expect(markup).toContain('body')
})
})
@@ -28,59 +28,7 @@ const gitLabReview: HostedReviewInfo = {
mergeable: 'MERGEABLE'
}
const bitbucketReview: HostedReviewInfo = {
provider: 'bitbucket',
number: 789,
title: 'Ready Bitbucket PR',
state: 'open',
url: 'https://bitbucket.org/stablyai/orca/pull-requests/789',
status: 'success',
updatedAt: '2026-05-13T00:00:00.000Z',
mergeable: 'MERGEABLE'
}
describe('getWorktreeCardPrDisplay', () => {
it('uses cached PR details when available', () => {
expect(getWorktreeCardPrDisplay(pr, 123)).toBe(pr)
})
it('falls back to linkedPR while PR details load', () => {
expect(getWorktreeCardPrDisplay(undefined, 456)).toEqual({
provider: 'github',
number: 456,
title: 'Loading PR...'
})
})
it('keeps linkedPR visible when PR details are unavailable', () => {
expect(getWorktreeCardPrDisplay(null, 456)).toEqual({
provider: 'github',
number: 456,
title: 'PR details unavailable'
})
})
it('does not show a PR row for unlinked worktrees', () => {
expect(getWorktreeCardPrDisplay(undefined, null)).toBeNull()
})
it('ignores linked-lookup PR details when the worktree is unlinked', () => {
expect(
getWorktreeCardPrDisplay(pr, null, null, null, null, null, {
reviewHintKey: 'github:123'
})
).toBeNull()
})
it('keeps an unlinked GitHub PR visible when branch provenance names the same PR', () => {
expect(
getWorktreeCardPrDisplay(pr, null, null, null, null, null, {
reviewHintKey: 'github:123',
branchLookupGitHubPRNumber: 123
})
).toBe(pr)
})
it('still suppresses unlinked linked-lookup details when branch provenance names a different PR', () => {
expect(
getWorktreeCardPrDisplay(pr, null, null, null, null, null, {
@@ -108,44 +56,6 @@ describe('getWorktreeCardPrDisplay', () => {
).toBeNull()
})
it('shows branch-discovered GitHub PR details when the worktree is unlinked', () => {
expect(
getWorktreeCardPrDisplay(pr, null, null, null, null, null, {
reviewHintKey: ''
})
).toBe(pr)
})
it('hides a matching suppressed branch-discovered GitHub PR', () => {
expect(
getWorktreeCardPrDisplay(pr, null, null, null, null, null, {
reviewHintKey: '',
suppressedGitHubPR: 123
})
).toBeNull()
})
it('lets an explicit GitHub link override stale suppression metadata', () => {
expect(
getWorktreeCardPrDisplay(pr, 123, null, null, null, null, {
suppressedGitHubPR: 123
})
).toBe(pr)
})
it('keeps a different branch-discovered GitHub PR visible', () => {
expect(
getWorktreeCardPrDisplay(pr, null, null, null, null, null, {
reviewHintKey: '',
suppressedGitHubPR: 999
})
).toBe(pr)
})
it('treats missing cache hints as unsafe for unlinked GitHub PR details', () => {
expect(getWorktreeCardPrDisplay(pr, null)).toBeNull()
})
it('keeps the linked PR number visible when cached details belong to a different PR', () => {
expect(getWorktreeCardPrDisplay(pr, 456)).toEqual({
provider: 'github',
@@ -154,10 +64,6 @@ describe('getWorktreeCardPrDisplay', () => {
})
})
it('uses cached GitLab MR details when linked metadata matches', () => {
expect(getWorktreeCardPrDisplay(gitLabReview, null, 321)).toBe(gitLabReview)
})
it('keeps the linked GitLab MR number visible when cached details belong to a different MR', () => {
expect(getWorktreeCardPrDisplay(gitLabReview, null, 654)).toEqual({
provider: 'gitlab',
@@ -165,14 +71,6 @@ describe('getWorktreeCardPrDisplay', () => {
title: 'Loading MR...'
})
})
it('preserves branch-discovered hosted reviews for providers without worktree metadata', () => {
expect(
getWorktreeCardPrDisplay(bitbucketReview, null, null, null, null, null, {
suppressedGitHubPR: bitbucketReview.number
})
).toBe(bitbucketReview)
})
})
describe('isCachedMergedBranchPRCurrentForWorktree', () => {
@@ -187,10 +85,6 @@ describe('isCachedMergedBranchPRCurrentForWorktree', () => {
headSha: 'final-head'
}
it('matches when the worktree sits exactly on the merged head', () => {
expect(isCachedMergedBranchPRCurrentForWorktree(mergedPR, { head: 'final-head' })).toBe(true)
})
it('matches when the worktree head is a confirmed commit of the merged PR', () => {
expect(
isCachedMergedBranchPRCurrentForWorktree(
@@ -1,8 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
isNewIssueDraftContentful,
resolveNewIssueOpenSeed,
resolveUserRepoSwitchReset,
resolveVanishedNewIssueRepoReset
} from './task-page-new-issue-draft'
import type { NewIssueDraft } from '@/store/slices/new-issue-draft'
@@ -14,65 +12,7 @@ function draft(overrides: Partial<NewIssueDraft> = {}): NewIssueDraft {
return { title: '', body: '', labels: [], assignees: [], repoId: null, ...overrides }
}
describe('isNewIssueDraftContentful', () => {
it('is false for null and an all-empty draft', () => {
expect(isNewIssueDraftContentful(null)).toBe(false)
expect(isNewIssueDraftContentful(draft())).toBe(false)
})
it('is false for whitespace-only title/body', () => {
expect(isNewIssueDraftContentful(draft({ title: ' ', body: '\n\t' }))).toBe(false)
})
it('is false when only a repoId is set (repoId is not content)', () => {
expect(isNewIssueDraftContentful(draft({ repoId: 'repo-a' }))).toBe(false)
})
it('is true once a title, body, label, or assignee is present', () => {
expect(isNewIssueDraftContentful(draft({ title: 'Bug' }))).toBe(true)
expect(isNewIssueDraftContentful(draft({ body: 'details' }))).toBe(true)
expect(isNewIssueDraftContentful(draft({ labels: ['p1'] }))).toBe(true)
expect(isNewIssueDraftContentful(draft({ assignees: [assignee] }))).toBe(true)
})
})
describe('resolveNewIssueOpenSeed', () => {
it('takes the empty-default branch for no draft, targeting the first selected repo', () => {
expect(resolveNewIssueOpenSeed({ draft: null, selectedRepoIds: ['repo-a', 'repo-b'] })).toEqual(
{ title: '', body: '', labels: [], assignees: [], repoId: 'repo-a' }
)
})
it('takes the default branch for a draft carrying only a repoId', () => {
expect(
resolveNewIssueOpenSeed({
draft: draft({ repoId: 'repo-b' }),
selectedRepoIds: ['repo-a', 'repo-b']
})
).toEqual({ title: '', body: '', labels: [], assignees: [], repoId: 'repo-a' })
})
it('restores every field when the draft repo is still selected', () => {
expect(
resolveNewIssueOpenSeed({
draft: draft({
title: 'Bug',
body: 'details',
labels: ['p1'],
assignees: [assignee],
repoId: 'repo-b'
}),
selectedRepoIds: ['repo-a', 'repo-b']
})
).toEqual({
title: 'Bug',
body: 'details',
labels: ['p1'],
assignees: [assignee],
repoId: 'repo-b'
})
})
it('drops repo-scoped labels/assignees and falls back when the draft repo vanished', () => {
expect(
resolveNewIssueOpenSeed({
@@ -93,12 +33,6 @@ describe('resolveNewIssueOpenSeed', () => {
repoId: 'repo-a'
})
})
it('resolves repoId to null only when nothing is selected', () => {
expect(
resolveNewIssueOpenSeed({ draft: draft({ title: 'Bug' }), selectedRepoIds: [] })
).toEqual({ title: 'Bug', body: '', labels: [], assignees: [], repoId: null })
})
})
describe('store-retention P1 guard (restore of an in-selection non-fallback repo)', () => {
@@ -128,29 +62,3 @@ describe('store-retention P1 guard (restore of an in-selection non-fallback repo
expect(seed.assignees).toEqual(original.assignees)
})
})
describe('resolveUserRepoSwitchReset', () => {
it('clears both repo-scoped labels and assignees on a genuine user repo switch', () => {
expect(resolveUserRepoSwitchReset()).toEqual({ labels: [], assignees: [] })
})
})
describe('resolveVanishedNewIssueRepoReset', () => {
it('returns null when the chosen repo is still selected', () => {
expect(resolveVanishedNewIssueRepoReset('repo-b', ['repo-a', 'repo-b'])).toBeNull()
})
it('returns null when no repo is chosen', () => {
expect(resolveVanishedNewIssueRepoReset(null, ['repo-a'])).toBeNull()
})
it('resets to the first selected repo when the chosen repo vanished', () => {
expect(resolveVanishedNewIssueRepoReset('removed', ['repo-a', 'repo-b'])).toEqual({
repoId: 'repo-a'
})
})
it('resets to null when the chosen repo vanished and nothing remains selected', () => {
expect(resolveVanishedNewIssueRepoReset('removed', [])).toEqual({ repoId: null })
})
})
@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest'
import {
appendPaneTerminalError,
appendTerminalErrorMessage,
boundTerminalErrorSurface,
clearPaneTerminalError,
mapPaneTerminalErrors,
MAX_TERMINAL_ERROR_CHARS,
@@ -14,30 +13,6 @@ import { stripSshReconnectOwnedErrorLines } from './TerminalErrorToast'
const MULTILINE_ERROR = 'Remote terminal write failed.\nThe remote runtime rejected the request.'
describe('appendTerminalErrorMessage', () => {
it('starts the surface with the first message', () => {
expect(appendTerminalErrorMessage(null, 'Paste failed.')).toBe('Paste failed.')
})
it('appends distinct messages as newline-joined entries', () => {
const accumulated = appendTerminalErrorMessage(
appendTerminalErrorMessage(null, 'Paste failed.'),
'Remote terminal was closed.'
)
expect(accumulated).toBe('Paste failed.\nRemote terminal was closed.')
})
it('keeps the first occurrence of a repeated single-line message', () => {
const accumulated = appendTerminalErrorMessage(null, 'Paste failed.')
expect(appendTerminalErrorMessage(accumulated, 'Paste failed.')).toBe(accumulated)
})
it('does not re-append a repeated multi-line message', () => {
let accumulated = appendTerminalErrorMessage(null, MULTILINE_ERROR)
accumulated = appendTerminalErrorMessage(accumulated, MULTILINE_ERROR)
accumulated = appendTerminalErrorMessage(accumulated, MULTILINE_ERROR)
expect(accumulated).toBe(MULTILINE_ERROR)
})
it('detects a repeated multi-line message in any position of the surface', () => {
const leading = appendTerminalErrorMessage(
appendTerminalErrorMessage(null, MULTILINE_ERROR),
@@ -55,28 +30,6 @@ describe('appendTerminalErrorMessage', () => {
expect(appendTerminalErrorMessage(middle, MULTILINE_ERROR)).toBe(middle)
})
it('keeps per-line dedup for a single-line message already present as a line', () => {
const accumulated = appendTerminalErrorMessage(null, MULTILINE_ERROR)
expect(appendTerminalErrorMessage(accumulated, 'Remote terminal write failed.')).toBe(
accumulated
)
})
it('appends a message that is only a substring of an existing line', () => {
const accumulated = appendTerminalErrorMessage(null, MULTILINE_ERROR)
expect(appendTerminalErrorMessage(accumulated, 'terminal write failed.')).toBe(
`${MULTILINE_ERROR}\nterminal write failed.`
)
})
it('stays a newline-joined string the toast can still filter per line', () => {
const accumulated = appendTerminalErrorMessage(
appendTerminalErrorMessage(null, 'SSH connection failed: host unreachable'),
MULTILINE_ERROR
)
expect(stripSshReconnectOwnedErrorLines(accumulated)).toBe(MULTILINE_ERROR)
})
it('caps a distinct error storm to the newest lines', () => {
let accumulated: string | null = null
for (let index = 0; index < MAX_TERMINAL_ERROR_LINES + 12; index += 1) {
@@ -88,25 +41,9 @@ describe('appendTerminalErrorMessage', () => {
expect(lines[0]).toBe('timeout #12')
expect(lines.at(-1)).toBe(`timeout #${MAX_TERMINAL_ERROR_LINES + 11}`)
})
it('drops a clipped leading line under the character budget', () => {
const latestLine = 'SSH connection failed: host unreachable'
const huge = `${'x'.repeat(MAX_TERMINAL_ERROR_CHARS + 500)}\n${latestLine}`
expect(boundTerminalErrorSurface(huge)).toBe(latestLine)
})
})
describe('pane terminal errors', () => {
it('shows only the active pane errors beside a tab-wide error', () => {
let errors = appendPaneTerminalError({}, 1, 'Pane one failed.')
errors = appendPaneTerminalError(errors, 2, 'Pane two failed.')
expect(terminalErrorForPane('Paste failed.', errors, 1)).toBe('Paste failed.\nPane one failed.')
expect(terminalErrorForPane(null, errors, 2)).toBe('Pane two failed.')
expect(terminalErrorForPane(null, errors, 3)).toBeNull()
})
it('clears only the recovered message for the matching pane', () => {
let errors = appendPaneTerminalError({}, 1, 'Remote terminal was closed.')
errors = appendPaneTerminalError(errors, 1, 'Paste failed.')
@@ -129,17 +66,6 @@ describe('pane terminal errors', () => {
expect(terminalErrorForPane(null, mapped, 2)).toBe('Paste failed.')
})
it('bounds distinct errors retained for one pane', () => {
let errors = {}
for (let index = 0; index < 20; index += 1) {
errors = appendPaneTerminalError(errors, 1, `Failure ${index}`)
}
expect(terminalErrorForPane(null, errors, 1)?.split('\n')).toEqual(
Array.from({ length: 8 }, (_, index) => `Failure ${index + 12}`)
)
})
it('bounds individual pane errors and their joined display', () => {
let errors = appendPaneTerminalError(
{},
@@ -44,12 +44,6 @@ function installTracker(): TrackerHarness {
}
describe('installTerminalImeCompositionTracker', () => {
it('activates on compositionstart', () => {
const harness = installTracker()
harness.composition('compositionstart', '')
expect(harness.tracker.isActive()).toBe(true)
})
it('keeps composition active through Sogou-style empty compositionupdate', () => {
const harness = installTracker()
harness.composition('compositionstart', '')
@@ -58,51 +52,7 @@ describe('installTerminalImeCompositionTracker', () => {
expect(harness.tracker.isActive()).toBe(true)
})
it('clears on compositionend', () => {
const harness = installTracker()
harness.composition('compositionstart', '')
harness.composition('compositionupdate', '你')
harness.composition('compositionend', '你')
expect(harness.tracker.isActive()).toBe(false)
})
it('still clears on non-composition input', () => {
const harness = installTracker()
harness.composition('compositionstart', '')
harness.input('insertText')
expect(harness.tracker.isActive()).toBe(false)
})
it('does not clear on insertCompositionText input', () => {
const harness = installTracker()
harness.composition('compositionstart', '')
harness.input('insertCompositionText')
expect(harness.tracker.isActive()).toBe(true)
})
it('clears on blur', () => {
const harness = installTracker()
harness.composition('compositionstart', '')
harness.blur()
expect(harness.tracker.isActive()).toBe(false)
expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false)
})
describe('candidate key guard', () => {
it('is active while a composition is live', () => {
const harness = installTracker()
expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false)
harness.composition('compositionstart', '')
expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true)
})
it('stays active through empty compositionupdate events', () => {
const harness = installTracker()
harness.composition('compositionstart', '')
harness.composition('compositionupdate', '')
expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true)
})
it('expires after prolonged composition-event silence so stale IME state cannot keep terminal keys dead', () => {
const harness = installTracker()
harness.composition('compositionstart', '')
@@ -154,13 +104,6 @@ describe('installTerminalImeCompositionTracker', () => {
})
})
it('handles a missing terminal element', () => {
const tracker = installTerminalImeCompositionTracker(null)
expect(tracker.isActive()).toBe(false)
expect(tracker.isCandidateKeyGuardActive()).toBe(false)
expect(() => tracker.dispose()).not.toThrow()
})
it('stops tracking after dispose', () => {
const harness = installTracker()
harness.tracker.dispose()
@@ -3,11 +3,7 @@ import type {
TerminalLayoutSnapshot,
TerminalPaneLayoutNode
} from '../../../../shared/terminal-tab-types'
import {
normalizeTerminalLayoutSnapshot,
resolvePtyBoundActiveLeafId,
resolveTerminalLayoutActiveLeafId
} from './terminal-layout-leaf-ids'
import { normalizeTerminalLayoutSnapshot } from './terminal-layout-leaf-ids'
const LEAF_1 = '11111111-1111-4111-8111-111111111111'
const LEAF_2 = '22222222-2222-4222-8222-222222222222'
@@ -46,60 +42,7 @@ function splitLayout(): TerminalLayoutSnapshot {
}
}
describe('resolveTerminalLayoutActiveLeafId', () => {
it('keeps the active leaf when it is still PTY-bound', () => {
expect(
resolveTerminalLayoutActiveLeafId({
root: split(LEAF_1, LEAF_2),
activeLeafId: LEAF_2,
ptyIdsByLeafId: { [LEAF_1]: 'pty-1', [LEAF_2]: 'pty-2' }
})
).toBe(LEAF_2)
})
it('repairs a stale active leaf to the first PTY-bound leaf in layout order', () => {
expect(
resolveTerminalLayoutActiveLeafId({
root: split(LEAF_1, LEAF_2),
activeLeafId: LEAF_1,
ptyIdsByLeafId: { [LEAF_2]: 'pty-2' }
})
).toBe(LEAF_2)
})
it('ignores PTY bindings for leaves outside the layout root', () => {
expect(
resolveTerminalLayoutActiveLeafId({
root: split(LEAF_1, LEAF_2),
activeLeafId: LEAF_1,
ptyIdsByLeafId: { [LEAF_3]: 'stale-pty' }
})
).toBe(LEAF_1)
})
it('falls back to a valid visual leaf when no PTY-bound leaf remains', () => {
expect(
resolveTerminalLayoutActiveLeafId({
root: split(LEAF_1, LEAF_2),
activeLeafId: LEAF_2,
ptyIdsByLeafId: {}
})
).toBe(LEAF_2)
})
})
describe('normalizeTerminalLayoutSnapshot active leaf repair', () => {
it('preserves a rootless active leaf before terminal replay', () => {
const result = normalizeTerminalLayoutSnapshot({
root: null,
activeLeafId: LEAF_1,
expandedLeafId: null
})
expect(result.changed).toBe(false)
expect(result.snapshot.activeLeafId).toBe(LEAF_1)
})
it('prefers a rootless active leaf over multiple retained PTY bindings', () => {
const result = normalizeTerminalLayoutSnapshot({
root: null,
@@ -115,20 +58,6 @@ describe('normalizeTerminalLayoutSnapshot active leaf repair', () => {
expect(result.snapshot.activeLeafId).toBe(LEAF_3)
})
it('uses a sole rootless PTY binding when no active leaf remains', () => {
const result = normalizeTerminalLayoutSnapshot({
root: null,
activeLeafId: null,
expandedLeafId: null,
ptyIdsByLeafId: {
[LEAF_2]: 'pty-2'
}
})
expect(result.changed).toBe(true)
expect(result.snapshot.activeLeafId).toBe(LEAF_2)
})
it('does not pick an arbitrary rootless PTY binding when multiple remain', () => {
const result = normalizeTerminalLayoutSnapshot({
root: null,
@@ -179,65 +108,4 @@ describe('normalizeTerminalLayoutSnapshot active leaf repair', () => {
expect(normalized.snapshot.activeLeafId).toBe(LEAF_2)
expect(normalized.snapshot.expandedLeafId).toBeNull()
})
it('preserves valid active and expanded leaf ids', () => {
const layout = splitLayout()
const normalized = normalizeTerminalLayoutSnapshot(layout)
expect(normalized.changed).toBe(false)
expect(normalized.snapshot.activeLeafId).toBe(LEAF_2)
expect(normalized.snapshot.expandedLeafId).toBe(LEAF_3)
})
})
describe('resolvePtyBoundActiveLeafId', () => {
it('preserves the active leaf when it still has a PTY binding', () => {
const layout = splitLayout()
const activeLeafId = resolvePtyBoundActiveLeafId({
root: layout.root,
activeLeafId: LEAF_2,
ptyIdsByLeafId: layout.ptyIdsByLeafId
})
expect(activeLeafId).toBe(LEAF_2)
})
it('moves active selection to the first bound layout leaf when the active PTY is gone', () => {
const layout = splitLayout()
const activeLeafId = resolvePtyBoundActiveLeafId({
root: layout.root,
activeLeafId: LEAF_2,
ptyIdsByLeafId: {
[LEAF_1]: 'pty-1',
[LEAF_3]: 'pty-3'
}
})
expect(activeLeafId).toBe(LEAF_1)
})
it('falls back to the current active leaf when no PTY bindings exist', () => {
const layout = splitLayout()
const activeLeafId = resolvePtyBoundActiveLeafId({
root: layout.root,
activeLeafId: LEAF_2,
ptyIdsByLeafId: undefined
})
expect(activeLeafId).toBe(LEAF_2)
})
it('uses a binding key when there is no root layout to inspect', () => {
const activeLeafId = resolvePtyBoundActiveLeafId({
root: null,
activeLeafId: null,
ptyIdsByLeafId: { [LEAF_3]: 'pty-3' }
})
expect(activeLeafId).toBe(LEAF_3)
})
})
@@ -24,66 +24,11 @@ afterEach(() => {
})
describe('agent picker search', () => {
it.each(['oh-my-pi', 'oh my pi', 'OH-MY-PI'])('finds OMP by its project name: %s', (query) => {
expect(searchAgentPickerEntries(AGENT_CATALOG, query).map((agent) => agent.id)).toEqual(['omp'])
})
it('does not offer unavailable OMP through a search alias', () => {
const available = AGENT_CATALOG.filter((agent) => agent.id !== 'omp')
expect(searchAgentPickerEntries(available, 'oh-my-pi')).toEqual([])
})
it('keeps catalog order for an empty query', () => {
expect(searchAgentPickerEntries(agents, '').map((agent) => agent.id)).toEqual(
agents.map((agent) => agent.id)
)
})
it('prefers label matches over command and id aliases', () => {
expect(
searchAgentPickerEntries(agents, 'cod')
.map((agent) => agent.id)
.slice(0, 3)
).toEqual(['codex', 'opencode', 'qwen-code'])
})
it('matches multi-word agents by initials and ordered shorthand', () => {
expect(searchAgentPickerEntries(agents, 'gc')[0]?.id).toBe('copilot')
expect(searchAgentPickerEntries(agents, 'mv')[0]?.id).toBe('mistral-vibe')
expect(searchAgentPickerEntries(agents, 'qc')[0]?.id).toBe('qwen-code')
})
it('matches command aliases that do not appear in the display label', () => {
expect(searchAgentPickerEntries(agents, 'agy')[0]?.id).toBe('antigravity')
expect(searchAgentPickerEntries(agents, 'cursor-agent')[0]?.id).toBe('cursor')
})
it('normalizes accepted pasted whitespace without regex replacement', () => {
const replaceSpy = vi.spyOn(String.prototype, 'replace')
expect(searchAgentPickerEntries(agents, ' qwen\n\tcode ')[0]?.id).toBe('qwen-code')
expect(replaceSpy).not.toHaveBeenCalled()
})
it('resolves catalog commands to the first entry owning that command', () => {
for (const agent of AGENT_CATALOG) {
const owner = AGENT_CATALOG.find((candidate) => candidate.cmd === agent.cmd)
expect(searchAgentPickerEntries(AGENT_CATALOG, agent.cmd)[0]?.id).toBe(owner?.id)
}
})
it.each([
['qoder', 'qoder'],
['qodercli', 'qoder'],
['qoder-cn', 'qoder-cn'],
['qoderclicn', 'qoder-cn'],
['Qoder CLI China', 'qoder-cn'],
['qwen', 'qwen-code']
] as const)('resolves explicit identity or command %s to %s', (query, expected) => {
expect(searchAgentPickerEntries(AGENT_CATALOG, query)[0]?.id).toBe(expected)
})
it('keeps a stable default for a shared binary while explicit CN remains selectable', () => {
const shared = [
entry('qoder', 'Qoder CLI', 'qodercli'),
@@ -103,10 +48,6 @@ describe('agent picker search', () => {
).toBe('qoder-cn')
})
it('returns no entries for unrelated text', () => {
expect(searchAgentPickerEntries(agents, 'not-an-agent')).toEqual([])
})
it('rejects oversized pasted queries before scoring agent candidates', () => {
const oversizedQuery = 'secret-agent-picker'.repeat(AGENT_PICKER_QUERY_MAX_BYTES)
const throwingAgents = [
@@ -145,45 +86,6 @@ describe('agent picker search', () => {
false
)
})
it('matches the blank terminal option by terminal, shell, and shorthand queries', () => {
expect(agentPickerBlankTerminalMatches('term')).toBe(true)
expect(agentPickerBlankTerminalMatches('shell')).toBe(true)
expect(agentPickerBlankTerminalMatches('bt')).toBe(true)
expect(agentPickerBlankTerminalMatches('agent')).toBe(false)
})
it('highlights the current value until a search should choose the first visible result', () => {
const filteredAgents = searchAgentPickerEntries(agents, 'gc')
expect(
getAgentPickerCommandValue({
blankValue: '__none__',
blankMatchesQuery: false,
currentValue: 'claude',
filteredAgents: agents,
rawQuery: ''
})
).toBe('claude')
expect(
getAgentPickerCommandValue({
blankValue: '__none__',
blankMatchesQuery: false,
currentValue: 'claude',
filteredAgents,
rawQuery: 'gc'
})
).toBe('copilot')
expect(
getAgentPickerCommandValue({
blankValue: '__none__',
blankMatchesQuery: true,
currentValue: 'claude',
filteredAgents: [],
rawQuery: 'bt'
})
).toBe('__none__')
})
})
function entry(id: AgentCatalogEntry['id'], label: string, cmd: string): AgentCatalogEntry {
@@ -15,18 +15,6 @@ function wireStatus(reason: unknown): FolderWorkspacePathStatus {
}
describe('getFolderWorkspacePathStatusTitle', () => {
it('keeps the declared reasons on their own copy', () => {
expect(getFolderWorkspacePathStatusTitle(wireStatus('missing'))).toBe('Folder not found')
expect(getFolderWorkspacePathStatusTitle(wireStatus('not-directory'))).toBe(
'Path is not a folder'
)
expect(getFolderWorkspacePathStatusTitle(wireStatus('ambiguous-connection'))).toBe(
'Cannot determine connection'
)
expect(getFolderWorkspacePathStatusTitle(wireStatus('unavailable'))).toBe('Cannot check folder')
expect(getFolderWorkspacePathStatusTitle(wireStatus(undefined))).toBe('Cannot check folder')
})
it('still titles a broken folder when a newer host sends an undeclared reason', () => {
const title = getFolderWorkspacePathStatusTitle(wireStatus('permission-denied'))
@@ -42,23 +30,9 @@ describe('getFolderWorkspacePathStatusTitle', () => {
expect(title).not.toBe('')
expect(title).not.toBe('Folder not found')
})
it('stays silent for a healthy or absent status', () => {
expect(getFolderWorkspacePathStatusTitle(null)).toBeNull()
expect(getFolderWorkspacePathStatusTitle({ path: '/srv/scans', exists: true })).toBeNull()
})
})
describe('getFolderWorkspacePathStatusDescription', () => {
it('keeps the declared reasons on their own copy', () => {
expect(getFolderWorkspacePathStatusDescription(wireStatus('missing'))).toBe(
'Orca cannot find /srv/scans. Remove and re-import this folder workspace.'
)
expect(getFolderWorkspacePathStatusDescription(wireStatus(undefined))).toBe(
'Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.'
)
})
it('still describes a broken folder when a newer host sends an undeclared reason', () => {
const description = getFolderWorkspacePathStatusDescription(wireStatus('permission-denied'))
@@ -79,32 +53,6 @@ describe('getFolderWorkspacePathStatusDescription', () => {
})
describe('getFolderWorkspacePathErrorCopy', () => {
it('maps each main-process path error code to its own copy', () => {
expect(getFolderWorkspacePathErrorCopy('folder_workspace_path_missing:/srv/scans')).toEqual({
title: 'Folder not found',
description: 'Orca cannot find /srv/scans. Remove and re-import the folder.'
})
expect(
getFolderWorkspacePathErrorCopy('folder_workspace_path_not_directory:/srv/scans')?.title
).toBe('Path is not a folder')
expect(
getFolderWorkspacePathErrorCopy('folder_workspace_connection_ambiguous:/srv/scans')?.title
).toBe('Cannot determine connection')
expect(
getFolderWorkspacePathErrorCopy('folder_workspace_path_unavailable:/srv/scans')?.title
).toBe('Cannot check folder')
})
it('finds the code behind an Electron IPC prefix and keeps paths with spaces', () => {
const copy = getFolderWorkspacePathErrorCopy(
"Error invoking remote method 'pty:spawn': Error: folder_workspace_path_missing:/Users/me/My Project"
)
expect(copy?.description).toBe(
'Orca cannot find /Users/me/My Project. Remove and re-import the folder.'
)
})
it('maps the ambiguous-connection code the runtime throws without a path', () => {
expect(
getFolderWorkspacePathErrorCopy(
@@ -112,24 +60,6 @@ describe('getFolderWorkspacePathErrorCopy', () => {
)?.title
).toBe('Cannot determine connection')
})
it('returns null for unrelated errors', () => {
expect(getFolderWorkspacePathErrorCopy('folder_workspace_not_found')).toBeNull()
expect(getFolderWorkspacePathErrorCopy('folder_workspace_path_missing_extra:/x')).toBeNull()
expect(getFolderWorkspacePathErrorCopy('Failed to spawn shell')).toBeNull()
})
})
describe('formatFolderWorkspaceCreateError', () => {
it('uses the path copy for path codes and the raw message otherwise', () => {
expect(
formatFolderWorkspaceCreateError(new Error('folder_workspace_path_missing:/srv/app')).title
).toBe('Folder not found')
expect(formatFolderWorkspaceCreateError(new Error('disk full'))).toEqual({
title: 'Failed to create folder workspace',
description: 'disk full'
})
})
})
it.each(['/srv/project: folder', 'C:\\work\\My Project:backup', '/tmp/$& folder'])(
+1 -128
View File
@@ -1,87 +1,8 @@
import { describe, expect, it } from 'vitest'
import {
normalizeGitLabLinkQuery,
parseGitLabIssueOrMRLink,
parseGitLabIssueOrMRNumber
} from './gitlab-links'
import { normalizeGitLabLinkQuery, parseGitLabIssueOrMRLink } from './gitlab-links'
import { WORK_ITEM_LINK_QUERY_MAX_BYTES } from './work-item-link-query-bounds'
describe('parseGitLabIssueOrMRNumber', () => {
it('parses bare numbers, # prefix, and ! prefix', () => {
expect(parseGitLabIssueOrMRNumber('42')).toBe(42)
expect(parseGitLabIssueOrMRNumber('#42')).toBe(42)
expect(parseGitLabIssueOrMRNumber('!42')).toBe(42)
})
it('parses gitlab.com issue and MR URLs', () => {
expect(parseGitLabIssueOrMRNumber('https://gitlab.com/stablyai/orca/-/issues/923')).toBe(923)
expect(
parseGitLabIssueOrMRNumber('https://gitlab.com/stablyai/orca/-/merge_requests/123')
).toBe(123)
})
it('parses URLs from self-hosted GitLab instances', () => {
expect(parseGitLabIssueOrMRNumber('https://gitlab.example.com/team/api/-/issues/7')).toBe(7)
})
it('parses modern /-/work_items/<iid> issue URLs', () => {
expect(parseGitLabIssueOrMRNumber('https://gitlab.com/stablyai/orca/-/work_items/923')).toBe(
923
)
expect(
parseGitLabIssueOrMRNumber('https://gitlab.example.com:8443/team/api/-/work_items/7')
).toBe(7)
expect(parseGitLabIssueOrMRNumber('https://gitlab.com/g/p/-/work_items/923/designs')).toBe(923)
})
it('parses URLs with nested group paths', () => {
expect(
parseGitLabIssueOrMRNumber('https://gitlab.com/group/subgroup/project/-/merge_requests/55')
).toBe(55)
})
it('parses issue and MR URLs with trailing page segments', () => {
expect(parseGitLabIssueOrMRNumber('https://gitlab.com/g/p/-/merge_requests/77/diffs')).toBe(77)
expect(
parseGitLabIssueOrMRNumber('https://gitlab.com/g/p/-/merge_requests/77/diffs.diff?diff_id=1')
).toBe(77)
expect(parseGitLabIssueOrMRNumber('https://gitlab.com/g/p/-/issues/923/designs')).toBe(923)
})
it('rejects GitHub URLs (no /-/ separator)', () => {
expect(parseGitLabIssueOrMRNumber('https://github.com/stablyai/orca/issues/923')).toBeNull()
expect(parseGitLabIssueOrMRNumber('https://github.com/stablyai/orca/pull/123')).toBeNull()
})
it('rejects unparseable input', () => {
expect(parseGitLabIssueOrMRNumber('')).toBeNull()
expect(parseGitLabIssueOrMRNumber(' ')).toBeNull()
expect(parseGitLabIssueOrMRNumber('not-a-url')).toBeNull()
})
})
describe('parseGitLabIssueOrMRLink', () => {
it('extracts slug + number + type for issues and MRs', () => {
expect(parseGitLabIssueOrMRLink('https://gitlab.com/stablyai/orca/-/issues/923')).toEqual({
slug: { host: 'gitlab.com', path: 'stablyai/orca' },
number: 923,
type: 'issue'
})
expect(
parseGitLabIssueOrMRLink('https://gitlab.com/stablyai/orca/-/merge_requests/77')
).toEqual({ slug: { host: 'gitlab.com', path: 'stablyai/orca' }, number: 77, type: 'mr' })
})
it('preserves self-hosted GitLab hosts in the slug', () => {
expect(parseGitLabIssueOrMRLink('https://gitlab.internal/team/api/-/merge_requests/8')).toEqual(
{
slug: { host: 'gitlab.internal', path: 'team/api' },
number: 8,
type: 'mr'
}
)
})
it('preserves explicit ports on self-hosted GitLab hosts', () => {
expect(parseGitLabIssueOrMRLink('https://gitlab.example.com:8443/team/api/-/issues/7')).toEqual(
{
@@ -92,14 +13,6 @@ describe('parseGitLabIssueOrMRLink', () => {
)
})
it('preserves full nested group paths in the slug', () => {
expect(parseGitLabIssueOrMRLink('https://gitlab.com/g/sub/proj/-/issues/1')).toEqual({
slug: { host: 'gitlab.com', path: 'g/sub/proj' },
number: 1,
type: 'issue'
})
})
it('treats /-/work_items/<iid> as an issue work item', () => {
expect(parseGitLabIssueOrMRLink('https://gitlab.com/stablyai/orca/-/work_items/923')).toEqual({
slug: { host: 'gitlab.com', path: 'stablyai/orca' },
@@ -134,49 +47,9 @@ describe('parseGitLabIssueOrMRLink', () => {
type: 'issue'
})
})
it('returns null for single-segment paths (no project)', () => {
expect(parseGitLabIssueOrMRLink('https://gitlab.com/foo/-/issues/1')).toBeNull()
})
it('returns null for non-GitLab URL shapes', () => {
expect(parseGitLabIssueOrMRLink('https://gitlab.com/stablyai/orca/issues/123')).toBeNull()
expect(parseGitLabIssueOrMRLink('https://gitlab.com/stablyai/orca/-/issues/123abc')).toBeNull()
})
})
describe('normalizeGitLabLinkQuery', () => {
it('routes a bare number to directNumber', () => {
expect(normalizeGitLabLinkQuery('42')).toEqual({ query: '42', directNumber: 42 })
})
it('routes a full URL to query + directNumber', () => {
expect(normalizeGitLabLinkQuery('https://gitlab.com/stablyai/orca/-/issues/923')).toEqual({
query: 'https://gitlab.com/stablyai/orca/-/issues/923',
directNumber: 923
})
})
it('routes full URLs with trailing page segments to directNumber', () => {
expect(
normalizeGitLabLinkQuery('https://gitlab.com/stablyai/orca/-/merge_requests/77/diffs')
).toEqual({
query: 'https://gitlab.com/stablyai/orca/-/merge_requests/77/diffs',
directNumber: 77
})
})
it('returns the query alone for non-numeric, non-URL input', () => {
expect(normalizeGitLabLinkQuery('search me')).toEqual({
query: 'search me',
directNumber: null
})
})
it('returns empty for empty input', () => {
expect(normalizeGitLabLinkQuery(' ')).toEqual({ query: '', directNumber: null })
})
it('rejects oversized pasted link queries without echoing their content', () => {
const secret = 'gitlab-link-secret'
const result = normalizeGitLabLinkQuery(secret + 'x'.repeat(WORK_ITEM_LINK_QUERY_MAX_BYTES))
@@ -1,7 +1,6 @@
import { describe, it, expect } from 'vitest'
import {
detectOptionAsAltFromLayoutMap,
detectedCategoryToDefault,
effectiveMacOptionAsAlt,
type LayoutMapLike
} from './detect-option-as-alt'
@@ -24,30 +23,6 @@ const US: Record<string, string> = {
}
describe('detectOptionAsAltFromLayoutMap', () => {
it('classifies US Standard as us', () => {
expect(detectOptionAsAltFromLayoutMap(mapOf(US))).toBe('us')
})
it('classifies UK as non-us (Backquote → §)', () => {
expect(detectOptionAsAltFromLayoutMap(mapOf({ ...US, Backquote: '§' }))).toBe('non-us')
})
it('classifies German QWERTZ as non-us (KeyZ → y, Semicolon → ö)', () => {
expect(
detectOptionAsAltFromLayoutMap(
mapOf({
...US,
KeyZ: 'y',
Semicolon: 'ö',
Quote: 'ä',
Backquote: '^',
BracketLeft: 'ü',
BracketRight: '+'
})
)
).toBe('non-us')
})
it('classifies Turkish Q as non-us (the #903 repro)', () => {
expect(
detectOptionAsAltFromLayoutMap(
@@ -63,95 +38,6 @@ describe('detectOptionAsAltFromLayoutMap', () => {
).toBe('non-us')
})
it('classifies Turkish F as non-us (KeyQ → f, every letter swapped)', () => {
expect(
detectOptionAsAltFromLayoutMap(
mapOf({
KeyQ: 'f',
KeyW: 'g',
KeyA: 'u',
KeyZ: 'j',
Semicolon: 's',
Quote: 'y',
Backquote: '+',
BracketLeft: 'ğ',
BracketRight: 'ü'
})
)
).toBe('non-us')
})
it('classifies French AZERTY as non-us (KeyQ → a)', () => {
expect(
detectOptionAsAltFromLayoutMap(
mapOf({
KeyQ: 'a',
KeyW: 'z',
KeyA: 'q',
KeyZ: 'w',
Semicolon: 'm',
Quote: 'ù',
Backquote: '@',
BracketLeft: ')',
BracketRight: '='
})
)
).toBe('non-us')
})
it('classifies Spanish ISO as non-us (Semicolon → ñ)', () => {
expect(
detectOptionAsAltFromLayoutMap(mapOf({ ...US, Semicolon: 'ñ', Quote: '´', Backquote: 'º' }))
).toBe('non-us')
})
it('classifies Swedish as non-us (BracketLeft → å)', () => {
expect(
detectOptionAsAltFromLayoutMap(
mapOf({
...US,
Semicolon: 'ö',
Quote: 'ä',
Backquote: '§',
BracketLeft: 'å',
BracketRight: '¨'
})
)
).toBe('non-us')
})
it('classifies Dvorak as non-us (KeyQ → apostrophe)', () => {
expect(
detectOptionAsAltFromLayoutMap(
mapOf({
KeyQ: "'",
KeyW: ',',
KeyA: 'a',
KeyZ: ';',
Semicolon: 's',
Quote: '-',
Backquote: '`',
BracketLeft: '/',
BracketRight: '='
})
)
).toBe('non-us')
})
it('classifies Colemak as non-us (Semicolon → o)', () => {
// Colemak is a US-variant but maps Semicolon → o. Matches Ghostty:
// com.apple.keylayout.Colemak is not whitelisted there either.
expect(detectOptionAsAltFromLayoutMap(mapOf({ ...US, Semicolon: 'o' }))).toBe('non-us')
})
it('returns unknown for null', () => {
expect(detectOptionAsAltFromLayoutMap(null)).toBe('unknown')
})
it('returns unknown for empty map', () => {
expect(detectOptionAsAltFromLayoutMap(mapOf({}))).toBe('unknown')
})
it('returns unknown when fingerprint is incomplete (missing KeyQ)', () => {
const partial: Record<string, string> = { ...US }
delete partial.KeyQ
@@ -159,38 +45,8 @@ describe('detectOptionAsAltFromLayoutMap', () => {
})
})
describe('detectedCategoryToDefault', () => {
it('us → true', () => {
expect(detectedCategoryToDefault('us')).toBe('true')
})
it('non-us → false', () => {
expect(detectedCategoryToDefault('non-us')).toBe('false')
})
it('unknown → false (conservative)', () => {
expect(detectedCategoryToDefault('unknown')).toBe('false')
})
})
describe('effectiveMacOptionAsAlt', () => {
it('auto + us → true', () => {
expect(effectiveMacOptionAsAlt('auto', 'us')).toBe('true')
})
it('auto + non-us → false', () => {
expect(effectiveMacOptionAsAlt('auto', 'non-us')).toBe('false')
})
it('auto + unknown → false', () => {
expect(effectiveMacOptionAsAlt('auto', 'unknown')).toBe('false')
})
it('explicit true wins over detected non-us', () => {
expect(effectiveMacOptionAsAlt('true', 'non-us')).toBe('true')
})
it('explicit false wins over detected us', () => {
expect(effectiveMacOptionAsAlt('false', 'us')).toBe('false')
})
it('explicit left passes through', () => {
expect(effectiveMacOptionAsAlt('left', 'us')).toBe('left')
})
it('explicit right passes through', () => {
expect(effectiveMacOptionAsAlt('right', 'non-us')).toBe('right')
})
})
@@ -31,43 +31,6 @@ beforeEach(() => {
})
describe('client-hosted browser row state', () => {
it('holds the rows a push delivers for a worktree', () => {
applyClientHostedBrowserRows({ worktreeId: 'wt-1', rows: [makeRow()] })
expect(getClientHostedBrowserRows('wt-1')).toEqual([makeRow()])
expect(getClientHostedBrowserRows('wt-2')).toEqual([])
})
it('replaces a worktree wholesale on each push', () => {
applyClientHostedBrowserRows({ worktreeId: 'wt-1', rows: [makeRow()] })
applyClientHostedBrowserRows({
worktreeId: 'wt-1',
rows: [makeRow({ browserPageId: 'page-2' })]
})
expect(getClientHostedBrowserRows('wt-1').map((row) => row.browserPageId)).toEqual(['page-2'])
})
it('drops a worktree an empty push retracts', () => {
applyClientHostedBrowserRows({ worktreeId: 'wt-1', rows: [makeRow()] })
applyClientHostedBrowserRows({ worktreeId: 'wt-1', rows: [] })
expect(getClientHostedBrowserRows('wt-1')).toEqual([])
})
it('live-updates a row title and its host-absent state', () => {
applyClientHostedBrowserRows({ worktreeId: 'wt-1', rows: [makeRow()] })
applyClientHostedBrowserRows({
worktreeId: 'wt-1',
rows: [makeRow({ title: 'Renamed', hostAbsent: true })]
})
expect(getClientHostedBrowserRows('wt-1')[0]).toMatchObject({
title: 'Renamed',
hostAbsent: true
})
})
it('replaces every worktree on hydration', () => {
applyClientHostedBrowserRows({ worktreeId: 'wt-1', rows: [makeRow()] })
@@ -78,10 +41,6 @@ describe('client-hosted browser row state', () => {
expect(getClientHostedBrowserRows('wt-1')).toEqual([])
expect(getClientHostedBrowserRows('wt-2')).toHaveLength(1)
})
it('keeps a stable empty array so a snapshot read cannot loop a render', () => {
expect(getClientHostedBrowserRows('wt-1')).toBe(getClientHostedBrowserRows('wt-2'))
})
})
describe('client-hosted browser row selection', () => {
@@ -92,14 +51,6 @@ describe('client-hosted browser row selection', () => {
groupActiveTabIdAtSelection: 'tab-1'
}
it('holds and clears a selection', () => {
selectClientHostedBrowserRow(selection)
expect(getClientHostedBrowserRowSelection()).toEqual(selection)
clearClientHostedBrowserRowSelection()
expect(getClientHostedBrowserRowSelection()).toBeNull()
})
it('drops a selection whose row is retracted', () => {
applyClientHostedBrowserRows({ worktreeId: 'wt-1', rows: [makeRow()] })
selectClientHostedBrowserRow(selection)
@@ -109,18 +60,6 @@ describe('client-hosted browser row selection', () => {
expect(getClientHostedBrowserRowSelection()).toBeNull()
})
it('keeps a selection while its row survives a push', () => {
applyClientHostedBrowserRows({ worktreeId: 'wt-1', rows: [makeRow()] })
selectClientHostedBrowserRow(selection)
applyClientHostedBrowserRows({
worktreeId: 'wt-1',
rows: [makeRow({ title: 'Renamed' }), makeRow({ browserPageId: 'page-2' })]
})
expect(getClientHostedBrowserRowSelection()).toEqual(selection)
})
it('drops a selection hydration no longer knows about', () => {
applyClientHostedBrowserRows({ worktreeId: 'wt-1', rows: [makeRow()] })
selectClientHostedBrowserRow(selection)
@@ -130,40 +69,11 @@ describe('client-hosted browser row selection', () => {
expect(getClientHostedBrowserRowSelection()).toBeNull()
})
it('stays live while its group still shows the tab it was picked over', () => {
expect(
isClientHostedBrowserRowSelectionLive(selection, [{ id: 'group-1', activeTabId: 'tab-1' }])
).toBe(true)
})
it('dies when the group activates something else', () => {
expect(
isClientHostedBrowserRowSelectionLive(selection, [{ id: 'group-1', activeTabId: 'tab-2' }])
).toBe(false)
})
it('dies when its group is gone', () => {
expect(
isClientHostedBrowserRowSelectionLive(selection, [{ id: 'group-2', activeTabId: 'tab-1' }])
).toBe(false)
})
it('treats a group with no active tab as the same empty selection context', () => {
const overEmptyGroup = { ...selection, groupActiveTabIdAtSelection: null }
expect(isClientHostedBrowserRowSelectionLive(overEmptyGroup, [{ id: 'group-1' }])).toBe(true)
expect(
isClientHostedBrowserRowSelectionLive(overEmptyGroup, [
{ id: 'group-1', activeTabId: 'tab-1' }
])
).toBe(false)
})
it('is never live with nothing selected', () => {
expect(
isClientHostedBrowserRowSelectionLive(null, [{ id: 'group-1', activeTabId: null }])
).toBe(false)
})
})
/**
@@ -179,14 +89,6 @@ describe('active client-hosted row for a strip', () => {
}
const scope = { worktreeId: 'wt-1', groupId: 'group-1', groupActiveTabId: 'tab-1' }
it('names the picked row', () => {
expect(resolveActiveClientHostedBrowserRowId(selection, scope)).toBe('page-1')
})
it('names nothing with no selection', () => {
expect(resolveActiveClientHostedBrowserRowId(null, scope)).toBeNull()
})
it('leaves other strips alone', () => {
expect(
resolveActiveClientHostedBrowserRowId(selection, { ...scope, groupId: 'group-2' })
-188
View File
@@ -1,188 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
resolveFocusedCompletedTabAgent,
resolveFocusedRetainedTabAgent,
resolveFocusedTabAgent,
resolveSiblingCompletedTabAgent,
resolveSiblingRetainedTabAgent,
resolveSiblingTabAgent
} from './tab-agent'
import type { AgentStatusEntry, AgentType } from '../../../shared/agent-status-types'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types'
import type { TerminalAgent } from '../../../shared/terminal-agent'
import type { RetainedAgentEntry } from '@/store/slices/agent-status/agent-status-contract'
// Compose focused then sibling resolution exactly as useTabAgent does.
function resolveTabAgent(
map: Record<string, AgentStatusEntry>,
layout: TerminalLayoutSnapshot | undefined,
tabId: string
): TerminalAgent | null {
return resolveFocusedTabAgent(map, layout, tabId) ?? resolveSiblingTabAgent(map, layout, tabId)
}
const LEAF_A = '11111111-1111-4111-8111-111111111111'
const LEAF_B = '22222222-2222-4222-8222-222222222222'
function entry(paneKey: string, agentType: AgentType | undefined): AgentStatusEntry {
return {
state: 'working',
prompt: '',
updatedAt: 0,
stateStartedAt: 0,
paneKey,
stateHistory: [],
...(agentType ? { agentType } : {})
}
}
function layout(activeLeafId: string | null): TerminalLayoutSnapshot {
return { root: null, activeLeafId, expandedLeafId: null }
}
function retainedEntry(paneKey: string, agentType: AgentType): RetainedAgentEntry {
const tabId = paneKey.slice(0, paneKey.indexOf(':'))
const tab: TerminalTab = {
id: tabId,
ptyId: null,
worktreeId: 'wt-1',
title: 'Terminal 1',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0
}
return {
entry: { ...entry(paneKey, agentType), state: 'done' },
worktreeId: tab.worktreeId,
tab,
agentType,
startedAt: 0
}
}
describe('resolveTabAgent', () => {
it('returns null for a plain terminal (no agent entries)', () => {
expect(resolveTabAgent({}, layout(LEAF_A), 'tab-1')).toBeNull()
})
it('returns the agent in the focused pane (single-pane tab)', () => {
const map = { [`tab-1:${LEAF_A}`]: entry(`tab-1:${LEAF_A}`, 'claude') }
expect(resolveTabAgent(map, layout(LEAF_A), 'tab-1')).toBe('claude')
})
it('prefers the focused pane when multiple panes run agents', () => {
const map = {
[`tab-1:${LEAF_A}`]: entry(`tab-1:${LEAF_A}`, 'claude'),
[`tab-1:${LEAF_B}`]: entry(`tab-1:${LEAF_B}`, 'codex')
}
expect(resolveTabAgent(map, layout(LEAF_B), 'tab-1')).toBe('codex')
})
it('exposes focused and sibling hook identity separately', () => {
const map = {
[`tab-1:${LEAF_A}`]: entry(`tab-1:${LEAF_A}`, 'claude'),
[`tab-1:${LEAF_B}`]: entry(`tab-1:${LEAF_B}`, 'codex')
}
expect(resolveFocusedTabAgent(map, layout(LEAF_A), 'tab-1')).toBe('claude')
expect(resolveSiblingTabAgent(map, layout(LEAF_A), 'tab-1')).toBe('codex')
})
it('falls back to any agent pane when the focused pane is a plain terminal', () => {
const map = { [`tab-1:${LEAF_B}`]: entry(`tab-1:${LEAF_B}`, 'codex') }
expect(resolveTabAgent(map, layout(LEAF_A), 'tab-1')).toBe('codex')
})
it('resolves via the prefix scan when the layout is missing', () => {
const map = { [`tab-1:${LEAF_A}`]: entry(`tab-1:${LEAF_A}`, 'droid') }
expect(resolveTabAgent(map, undefined, 'tab-1')).toBe('droid')
})
it('treats same-tab hook identity as focused when the layout is missing', () => {
const map = { [`tab-1:${LEAF_A}`]: entry(`tab-1:${LEAF_A}`, 'codex') }
expect(resolveFocusedTabAgent(map, undefined, 'tab-1')).toBe('codex')
expect(resolveSiblingTabAgent(map, undefined, 'tab-1')).toBeNull()
})
it("keeps the terminal glyph for an agent that didn't identify itself", () => {
const map = { [`tab-1:${LEAF_A}`]: entry(`tab-1:${LEAF_A}`, 'unknown') }
expect(resolveTabAgent(map, layout(LEAF_A), 'tab-1')).toBeNull()
})
it('does not keep a hook-only icon for a completed agent turn', () => {
const map = {
[`tab-1:${LEAF_A}`]: {
...entry(`tab-1:${LEAF_A}`, 'claude'),
state: 'done' as const
}
}
expect(resolveTabAgent(map, layout(LEAF_A), 'tab-1')).toBeNull()
})
it('exposes the completed hook agent for title disambiguation', () => {
const map = {
[`tab-1:${LEAF_A}`]: {
...entry(`tab-1:${LEAF_A}`, 'openclaude'),
state: 'done' as const
}
}
expect(resolveFocusedCompletedTabAgent(map, undefined, 'tab-1')).toBe('openclaude')
expect(resolveSiblingCompletedTabAgent(map, undefined, 'tab-1')).toBeNull()
})
it('exposes focused and sibling completed hook identity separately', () => {
const map = {
[`tab-1:${LEAF_A}`]: {
...entry(`tab-1:${LEAF_A}`, 'claude'),
state: 'done' as const
},
[`tab-1:${LEAF_B}`]: {
...entry(`tab-1:${LEAF_B}`, 'codex'),
state: 'done' as const
}
}
expect(resolveFocusedCompletedTabAgent(map, layout(LEAF_A), 'tab-1')).toBe('claude')
expect(resolveSiblingCompletedTabAgent(map, layout(LEAF_A), 'tab-1')).toBe('codex')
})
it('resolves retained completion identity for the focused pane and siblings separately', () => {
const retained = {
[`tab-1:${LEAF_A}`]: retainedEntry(`tab-1:${LEAF_A}`, 'codex'),
[`tab-1:${LEAF_B}`]: retainedEntry(`tab-1:${LEAF_B}`, 'claude')
}
expect(resolveFocusedRetainedTabAgent(retained, layout(LEAF_A), 'tab-1')).toBe('codex')
expect(resolveSiblingRetainedTabAgent(retained, layout(LEAF_A), 'tab-1')).toBe('claude')
})
it('treats a same-tab retained completion as focused while layout is unavailable', () => {
const retained = {
[`tab-1:${LEAF_A}`]: retainedEntry(`tab-1:${LEAF_A}`, 'codex')
}
expect(resolveFocusedRetainedTabAgent(retained, undefined, 'tab-1')).toBe('codex')
expect(resolveSiblingRetainedTabAgent(retained, undefined, 'tab-1')).toBeNull()
})
it('does not leak retained identity from another tab', () => {
const retained = {
[`tab-2:${LEAF_A}`]: retainedEntry(`tab-2:${LEAF_A}`, 'codex')
}
expect(resolveFocusedRetainedTabAgent(retained, layout(LEAF_A), 'tab-1')).toBeNull()
expect(resolveSiblingRetainedTabAgent(retained, layout(LEAF_A), 'tab-1')).toBeNull()
})
it('keeps the terminal glyph for an agent Orca has no icon for', () => {
const map = { [`tab-1:${LEAF_A}`]: entry(`tab-1:${LEAF_A}`, 'totally-custom-agent') }
expect(resolveTabAgent(map, layout(LEAF_A), 'tab-1')).toBeNull()
})
it('does not leak an agent from a tab whose id shares a prefix', () => {
// 'tab-1' must not match 'tab-10' — the `${tabId}:` delimiter prevents it.
const map = { [`tab-10:${LEAF_A}`]: entry(`tab-10:${LEAF_A}`, 'claude') }
expect(resolveTabAgent(map, layout(null), 'tab-1')).toBeNull()
})
})
@@ -20,39 +20,6 @@ function state(partial: {
}
describe('resolveTerminalTabIdForPtyId', () => {
it('matches a tab by its own ptyId', () => {
const s = state({
tabs: {
wt: [
{ id: 'tab-a', ptyId: 'wt@@1' },
{ id: 'tab-b', ptyId: 'wt@@2' }
]
}
})
expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@2')).toBe('tab-b')
})
it('matches a tab by a split leaf ptyId in its saved layout', () => {
const s = state({
tabs: { wt: [{ id: 'tab-a', ptyId: null }] },
layouts: { 'tab-a': { ptyIdsByLeafId: { leaf1: 'wt@@1', leaf2: 'wt@@9' } } }
})
expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@9')).toBe('tab-a')
})
it('matches a tab by a live pty binding with no saved layout yet', () => {
const s = state({
tabs: { wt: [{ id: 'tab-a', ptyId: null }] },
livePtyIds: { 'tab-a': ['wt@@9'] }
})
expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@9')).toBe('tab-a')
})
it('returns null when no tab owns the ptyId', () => {
const s = state({ tabs: { wt: [{ id: 'tab-a', ptyId: 'wt@@1' }] } })
expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@nope')).toBeNull()
})
it('returns null when stale persistence binds the ptyId to multiple tabs', () => {
const s = state({
tabs: {
@@ -65,11 +32,6 @@ describe('resolveTerminalTabIdForPtyId', () => {
})
expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@1')).toBeNull()
})
it('returns null for an unknown worktree', () => {
const s = state({ tabs: { wt: [{ id: 'tab-a', ptyId: 'wt@@1' }] } })
expect(resolveTerminalTabIdForPtyId(s, 'other', 'wt@@1')).toBeNull()
})
})
describe('resolveTerminalTabPtyOwnership', () => {
@@ -84,13 +46,6 @@ describe('resolveTerminalTabPtyOwnership', () => {
livePtyIds: { 'tab-mounted': ['wt@@1'] }
})
it('prefers a mounted pane over a stale layout row in another tab', () => {
expect(resolveTerminalTabPtyOwnership(staleLayoutBesideMountedTab, 'wt', 'wt@@1')).toEqual({
kind: 'owned',
tabId: 'tab-mounted'
})
})
it('keeps the mounted pane even when the hint names the stale tab', () => {
expect(
resolveTerminalTabPtyOwnership(staleLayoutBesideMountedTab, 'wt', 'wt@@1', {
@@ -143,17 +98,6 @@ describe('resolveTerminalTabPtyOwnership', () => {
)
})
it('ignores a hint that names no existing tab', () => {
const s = state({
tabs: { wt: [{ id: 'tab-a', ptyId: null }] },
layouts: { 'tab-a': { ptyIdsByLeafId: { leaf1: 'wt@@1' } } }
})
expect(resolveTerminalTabPtyOwnership(s, 'wt', 'wt@@1', { preferTabId: 'tab-gone' })).toEqual({
kind: 'owned',
tabId: 'tab-a'
})
})
it('reports ambiguity when only recorded bindings claim the ptyId', () => {
const s = state({
tabs: {
@@ -199,9 +143,4 @@ describe('resolveTerminalTabPtyOwnership', () => {
tabId: 'tab-b'
})
})
it('reports no owner when nothing binds the ptyId', () => {
const s = state({ tabs: { wt: [{ id: 'tab-a', ptyId: 'wt@@2' }] } })
expect(resolveTerminalTabPtyOwnership(s, 'wt', 'wt@@1')).toEqual({ kind: 'none' })
})
})
@@ -1,9 +1,5 @@
import { describe, expect, it } from 'vitest'
import {
getLiveAgentStatusByWorktreeId,
getWorktreeIdsWithLiveAgent,
isInactiveWorkspace
} from './worktree-activity-state'
import { getWorktreeIdsWithLiveAgent, isInactiveWorkspace } from './worktree-activity-state'
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
@@ -27,65 +23,6 @@ function makeAgentEntry(
}
describe('worktree activity state', () => {
it('treats a never-opened workspace as inactive', () => {
expect(isInactiveWorkspace('wt-1', {}, {}, {}, new Set())).toBe(true)
})
it('treats live terminal workspaces as active', () => {
const tabsByWorktree = { 'wt-1': [makeTab('tab-1')] }
const ptyIdsByTabId = { 'tab-1': ['pty-1'] }
expect(isInactiveWorkspace('wt-1', tabsByWorktree, ptyIdsByTabId, {}, new Set())).toBe(false)
})
it('treats browser workspaces as active', () => {
expect(
isInactiveWorkspace(
'wt-1',
{ 'wt-1': [makeTab('tab-1')] },
{ 'tab-1': [] },
{ 'wt-1': [{ id: 'browser-1' }] },
new Set()
)
).toBe(false)
})
it('treats pending paired web host terminal mirrors as inactive without a live pty', () => {
expect(
isInactiveWorkspace(
'wt-1',
{ 'wt-1': [makeTab('web-terminal-host-tab-1')] },
{},
{},
new Set()
)
).toBe(true)
})
it('treats ready paired web host terminal mirrors as active with a live pty', () => {
expect(
isInactiveWorkspace(
'wt-1',
{ 'wt-1': [makeTab('web-terminal-host-tab-1')] },
{ 'web-terminal-host-tab-1': ['pty-1'] },
{},
new Set()
)
).toBe(false)
})
it('keeps browser-only workspaces active when mirrored terminals are pending', () => {
expect(
isInactiveWorkspace(
'wt-1',
{ 'wt-1': [makeTab('web-terminal-host-tab-1')] },
{},
{ 'wt-1': [{ id: 'browser-1' }] },
new Set()
)
).toBe(false)
})
it('keeps a workspace with a running agent active even without a live pty (#7197)', () => {
const worktreeIdsWithLiveAgent = new Set(['wt-1'])
expect(
@@ -98,27 +35,9 @@ describe('worktree activity state', () => {
)
).toBe(false)
})
it('still hides a slept workspace with no live agent entry', () => {
expect(
isInactiveWorkspace('wt-1', { 'wt-1': [makeTab('tab-1')] }, { 'tab-1': [] }, {}, new Set())
).toBe(true)
})
})
describe('getWorktreeIdsWithLiveAgent', () => {
it('returns an empty set when there are no agent entries', () => {
expect(getWorktreeIdsWithLiveAgent({}, {}, NOW)).toEqual(new Set())
expect(getWorktreeIdsWithLiveAgent(null, null, NOW)).toEqual(new Set())
})
it('attributes an entry by its main-stamped worktreeId', () => {
const entries = {
'tab-1:leaf-1': makeAgentEntry({ paneKey: 'tab-1:leaf-1', worktreeId: 'wt-1' })
}
expect(getWorktreeIdsWithLiveAgent(entries, {}, NOW)).toEqual(new Set(['wt-1']))
})
it('falls back to the paneKey tabId when worktreeId is absent', () => {
const entries = {
'tab-1:00000000-0000-4000-8000-000000000000': makeAgentEntry({
@@ -130,15 +49,6 @@ describe('getWorktreeIdsWithLiveAgent', () => {
)
})
it('ignores entries that cannot be attributed to any worktree', () => {
const entries = {
'orphan:00000000-0000-4000-8000-000000000000': makeAgentEntry({
paneKey: 'orphan:00000000-0000-4000-8000-000000000000'
})
}
expect(getWorktreeIdsWithLiveAgent(entries, {}, NOW)).toEqual(new Set())
})
it('ignores completed headless agents without an open session', () => {
const entries = {
'tab-1:leaf-1': makeAgentEntry({
@@ -177,68 +87,4 @@ describe('getWorktreeIdsWithLiveAgent', () => {
expect(getWorktreeIdsWithLiveAgent(entries, {}, NOW)).toEqual(new Set(['wt-1']))
}
)
it('reports working and permission states with permission taking priority', () => {
const entries = {
'tab-1:leaf-1': makeAgentEntry({
paneKey: 'tab-1:leaf-1',
worktreeId: 'wt-1',
state: 'working'
}),
'tab-2:leaf-2': makeAgentEntry({
paneKey: 'tab-2:leaf-2',
worktreeId: 'wt-2',
state: 'working'
}),
'tab-3:leaf-3': makeAgentEntry({
paneKey: 'tab-3:leaf-3',
worktreeId: 'wt-2',
state: 'blocked'
})
}
expect(getLiveAgentStatusByWorktreeId(entries, {}, NOW)).toEqual(
new Map([
['wt-1', 'working'],
['wt-2', 'permission']
])
)
})
it('reports monitoring below active working and permission', () => {
const entries = {
'tab-1:leaf-1': makeAgentEntry({
paneKey: 'tab-1:leaf-1',
worktreeId: 'wt-1',
workingMode: 'monitoring'
}),
'tab-2:leaf-2': makeAgentEntry({
paneKey: 'tab-2:leaf-2',
worktreeId: 'wt-2',
workingMode: 'monitoring'
}),
'tab-3:leaf-3': makeAgentEntry({
paneKey: 'tab-3:leaf-3',
worktreeId: 'wt-2'
}),
'tab-4:leaf-4': makeAgentEntry({
paneKey: 'tab-4:leaf-4',
worktreeId: 'wt-3',
workingMode: 'monitoring'
}),
'tab-5:leaf-5': makeAgentEntry({
paneKey: 'tab-5:leaf-5',
worktreeId: 'wt-3',
state: 'waiting'
})
}
expect(getLiveAgentStatusByWorktreeId(entries, {}, NOW)).toEqual(
new Map([
['wt-1', 'monitoring'],
['wt-2', 'working'],
['wt-3', 'permission']
])
)
})
})
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types'
import { layoutCoversLeaves, resolveTerminalLayoutRoot } from './remote-terminal-layout-resolution'
import { resolveTerminalLayoutRoot } from './remote-terminal-layout-resolution'
const verticalSplit: TerminalPaneLayoutNode = {
type: 'split',
@@ -9,21 +9,6 @@ const verticalSplit: TerminalPaneLayoutNode = {
second: { type: 'leaf', leafId: 'b' }
}
describe('layoutCoversLeaves', () => {
it('is true when the tree has exactly the leaves', () => {
expect(layoutCoversLeaves(verticalSplit, ['a', 'b'])).toBe(true)
})
it('is false when a leaf is missing from the tree', () => {
expect(layoutCoversLeaves({ type: 'leaf', leafId: 'a' }, ['a', 'b'])).toBe(false)
})
it('is false when the tree has an extra leaf', () => {
expect(layoutCoversLeaves(verticalSplit, ['a'])).toBe(false)
})
it('is false for a null tree', () => {
expect(layoutCoversLeaves(null, ['a'])).toBe(false)
})
})
describe('resolveTerminalLayoutRoot', () => {
it('uses the authoritative tree verbatim — direction is preserved', () => {
// Host split direction must win; re-derivation once turned Split Right into down.
@@ -45,28 +30,6 @@ describe('resolveTerminalLayoutRoot', () => {
expect(root).toBe(verticalSplit)
})
it('never invents a split direction: synthesis only fires (and is reported) when no tree covers the leaves', () => {
const onSynthesize = vi.fn()
resolveTerminalLayoutRoot({
authoritativeRoot: undefined,
existingRoot: undefined,
leafIds: ['a', 'b'],
onSynthesize
})
expect(onSynthesize).toHaveBeenCalledWith(2)
})
it('does not report synthesis for a single leaf (direction is irrelevant)', () => {
const onSynthesize = vi.fn()
const root = resolveTerminalLayoutRoot({ leafIds: ['a'], onSynthesize })
expect(root).toEqual({ type: 'leaf', leafId: 'a' })
expect(onSynthesize).not.toHaveBeenCalled()
})
it('returns null for no leaves', () => {
expect(resolveTerminalLayoutRoot({ leafIds: [] })).toBeNull()
})
it('prunes a superset tree to the live leaves instead of re-guessing its directions', () => {
// Extra stale leaves must not make exact-cover failure replace the whole known tree with guessed directions.
const onSynthesize = vi.fn()
@@ -138,22 +101,6 @@ describe('resolveTerminalLayoutRoot', () => {
})
})
it('still degenerates when no known tree places any of the leaves', () => {
const onSynthesize = vi.fn()
const root = resolveTerminalLayoutRoot({
authoritativeRoot: verticalSplit,
leafIds: ['x', 'y'],
onSynthesize
})
expect(root).toEqual({
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: 'x' },
second: { type: 'leaf', leafId: 'y' }
})
expect(onSynthesize).toHaveBeenCalledWith(2)
})
it('prefers authoritative over an also-covering existing tree', () => {
const horizontalSplit: TerminalPaneLayoutNode = {
type: 'split',
@@ -1,8 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX,
RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX,
boundRecentlyClosedAgentStatusTabIds,
boundRecentlyRetiredAgentStatusPaneKeys,
removePaneKeys
} from './agent-status-pane-keyed-records'
@@ -20,53 +18,6 @@ function fullRecord(max: number, prefix: string): Record<string, true> {
}
describe('boundRecentlyRetiredAgentStatusPaneKeys', () => {
it('returns the existing record when there is nothing to add', () => {
const existing = keyRecord(['a', 'b'])
expect(boundRecentlyRetiredAgentStatusPaneKeys(existing, [])).toBe(existing)
const empty = keyRecord([])
expect(boundRecentlyRetiredAgentStatusPaneKeys(empty, [])).toBe(empty)
})
it('returns the existing record when the additions already form its tail in order', () => {
const existing = keyRecord(['a', 'b', 'c'])
expect(boundRecentlyRetiredAgentStatusPaneKeys(existing, ['c'])).toBe(existing)
expect(boundRecentlyRetiredAgentStatusPaneKeys(existing, ['b', 'c'])).toBe(existing)
expect(boundRecentlyRetiredAgentStatusPaneKeys(existing, ['a', 'b', 'c'])).toBe(existing)
})
// LRU readds move keys to tail; equal key sets alone cannot prove no-op.
it('re-retiring an existing non-tail key changes identity and moves it to the tail', () => {
const existing = keyRecord(['a', 'b', 'c'])
const next = boundRecentlyRetiredAgentStatusPaneKeys(existing, ['a'])
expect(next).not.toBe(existing)
expect(Object.keys(next)).toEqual(['b', 'c', 'a'])
expect(Object.keys(existing)).toEqual(['a', 'b', 'c'])
})
it('tail keys re-added in a different relative order are rebuilt in the new order', () => {
const existing = keyRecord(['a', 'b', 'c'])
const next = boundRecentlyRetiredAgentStatusPaneKeys(existing, ['c', 'b'])
expect(next).not.toBe(existing)
expect(Object.keys(next)).toEqual(['a', 'c', 'b'])
})
it('appends new keys after the existing ones', () => {
const existing = keyRecord(['a'])
const next = boundRecentlyRetiredAgentStatusPaneKeys(existing, ['b', 'c'])
expect(next).not.toBe(existing)
expect(Object.keys(next)).toEqual(['a', 'b', 'c'])
})
it('evicts the oldest keys once the cap is exceeded', () => {
const full = fullRecord(RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX, 'k')
const next = boundRecentlyRetiredAgentStatusPaneKeys(full, ['fresh'])
const keys = Object.keys(next)
expect(keys).toHaveLength(RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX)
expect(keys[0]).toBe('k1')
expect(keys.at(-1)).toBe('fresh')
expect(next.k0).toBeUndefined()
})
it('re-retiring the oldest key at the cap keeps it fenced and evicts the next oldest', () => {
const full = fullRecord(RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX, 'k')
const bumped = boundRecentlyRetiredAgentStatusPaneKeys(full, ['k0'])
@@ -87,28 +38,6 @@ describe('boundRecentlyRetiredAgentStatusPaneKeys', () => {
})
})
describe('boundRecentlyClosedAgentStatusTabIds', () => {
it('returns the existing record when the tab is already the most recent', () => {
const existing = keyRecord(['t1', 't2'])
expect(boundRecentlyClosedAgentStatusTabIds(existing, 't2')).toBe(existing)
})
it('moves a re-closed tab to the tail', () => {
const existing = keyRecord(['t1', 't2'])
const next = boundRecentlyClosedAgentStatusTabIds(existing, 't1')
expect(next).not.toBe(existing)
expect(Object.keys(next)).toEqual(['t2', 't1'])
})
it('evicts the oldest tab once the cap is exceeded', () => {
const full = fullRecord(RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX, 't')
const next = boundRecentlyClosedAgentStatusTabIds(full, 'fresh')
expect(Object.keys(next)).toHaveLength(RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX)
expect(next.t0).toBeUndefined()
expect(next.fresh).toBe(true)
})
})
it('does not enumerate unrelated pane records when removing absent keys', () => {
let enumerations = 0
const record = new Proxy(
@@ -132,24 +61,3 @@ it('removes enumerable undefined values without removing inherited or hidden key
expect(removePaneKeys(record, new Set(['hidden', 'toString']))).toBe(record)
expect(Object.hasOwn(removePaneKeys(record, new Set(['visible'])), 'visible')).toBe(false)
})
// Probe equivalence must cover keys missing in both directions and preserve identity on no-op.
it('drops every requested key that is present and keeps the reference when none are', () => {
const base = { a: 1, b: 2, c: 3 }
expect(removePaneKeys({ ...base }, new Set(['a', 'b', 'c', 'd', 'e']))).toEqual({})
expect(removePaneKeys({ ...base }, new Set(['b', 'missing']))).toEqual({ a: 1, c: 3 })
const disjoint = { ...base }
expect(removePaneKeys(disjoint, new Set(['x', 'y']))).toBe(disjoint)
const noKeys = { ...base }
expect(removePaneKeys(noKeys, new Set())).toBe(noKeys)
const empty = {}
expect(removePaneKeys(empty, new Set(['a']))).toBe(empty)
})
it('leaves surviving keys in their original order and does not pollute the prototype', () => {
const record: Record<string, number> = { z: 1, y: 2, x: 3, w: 4 }
expect(Object.keys(removePaneKeys(record, new Set(['x', 'z'])))).toEqual(['y', 'w'])
const plain = { safe: 1 }
expect(removePaneKeys(plain, new Set(['__proto__', 'constructor']))).toBe(plain)
expect(Object.getPrototypeOf(plain)).toBe(Object.prototype)
})
@@ -7,12 +7,6 @@ function makeRepo(id: string, overrides: Partial<Repo> = {}): Repo {
}
describe('reconcileFetchedRepos', () => {
it('returns the previous array when the fetched list is field-identical', () => {
const previous = [makeRepo('a'), makeRepo('b')]
const next = [makeRepo('a'), makeRepo('b')]
expect(reconcileFetchedRepos(previous, next)).toBe(previous)
})
it('reconciles repos whose nested records were rebuilt by hydration and IPC', () => {
// IPC clones nested records and main rebuilds hookSettings; reference comparison alone falsely changes all repos.
const nested = (): Partial<Repo> => ({
@@ -30,51 +24,6 @@ describe('reconcileFetchedRepos', () => {
expect(reconcileFetchedRepos(previous, next)).toBe(previous)
})
it('treats a changed nested field as a real change', () => {
const previous = [makeRepo('a', { importedExternalWorktreePaths: ['/a'] })]
const next = [makeRepo('a', { importedExternalWorktreePaths: ['/b'] })]
const result = reconcileFetchedRepos(previous, next)
expect(result).not.toBe(previous)
expect(result[0].importedExternalWorktreePaths).toEqual(['/b'])
})
it('treats a nested field gaining a key as a real change', () => {
const previous = [
makeRepo('a', { hookSettings: { mode: 'auto', scripts: { setup: '', archive: '' } } })
]
const next = [
makeRepo('a', {
hookSettings: {
mode: 'auto',
setupRunPolicy: 'run-by-default',
scripts: { setup: '', archive: '' }
}
})
]
expect(reconcileFetchedRepos(previous, next)).not.toBe(previous)
})
it('reuses unchanged repo objects while reflecting a reorder', () => {
const previous = [makeRepo('a'), makeRepo('b')]
const next = [makeRepo('b'), makeRepo('a')]
const result = reconcileFetchedRepos(previous, next)
expect(result).not.toBe(previous)
expect(result.map((r) => r.id)).toEqual(['b', 'a'])
// Identity preserved so memos keyed on repo objects don't churn.
expect(result[0]).toBe(previous[1])
expect(result[1]).toBe(previous[0])
})
it('keeps a new object only for the repo whose fields changed', () => {
const previous = [makeRepo('a'), makeRepo('b')]
const next = [makeRepo('a'), makeRepo('b', { displayName: 'renamed' })]
const result = reconcileFetchedRepos(previous, next)
expect(result[0]).toBe(previous[0])
expect(result[1]).toBe(next[1])
})
it('keeps fetched data when optional repo keys differ with the same key count', () => {
const previous = [makeRepo('a', { projectGroupId: undefined })]
const next = [makeRepo('a', { projectGroupOrder: 2 })]
@@ -82,15 +31,6 @@ describe('reconcileFetchedRepos', () => {
expect(result[0]).toBe(next[0])
})
it('returns a rebuilt array when repos are added or removed', () => {
const previous = [makeRepo('a')]
const next = [makeRepo('a'), makeRepo('b')]
const result = reconcileFetchedRepos(previous, next)
expect(result).not.toBe(previous)
expect(result[0]).toBe(previous[0])
expect(result.map((r) => r.id)).toEqual(['a', 'b'])
})
it('preserves same-id repo identity only within the matching host', () => {
const previous = [
makeRepo('same', { executionHostId: 'local', displayName: 'Local' }),
@@ -106,18 +46,6 @@ describe('reconcileFetchedRepos', () => {
expect(result[0]).toBe(previous[1])
expect(result[1]).toBe(previous[0])
})
it('uses map-style host identity lookups for large duplicate refreshes', () => {
const previous = Array.from({ length: 1000 }, (_, index) =>
makeRepo(`repo-${index}`, { executionHostId: index % 2 === 0 ? 'local' : 'runtime:env-1' })
)
const next = previous.map((repo) => ({ ...repo }))
const result = reconcileFetchedRepos(previous, next)
expect(result).toBe(previous)
expect(result.every((repo, index) => repo === previous[index])).toBe(true)
})
})
describe('reuseEqualRecordMap', () => {
@@ -126,11 +54,6 @@ describe('reuseEqualRecordMap', () => {
nested: { tags: [value, 'shared'] }
})
it('returns the previous map when every key holds an equal value', () => {
const previous = { a: row('a'), b: row('b') }
expect(reuseEqualRecordMap(previous, { a: row('a'), b: row('b') })).toBe(previous)
})
it('reuses the previous entry for structurally equal nested values', () => {
const previous = { a: row('a'), b: row('b') }
const result = reuseEqualRecordMap(previous, { a: row('a'), b: row('changed') })
@@ -139,28 +62,6 @@ describe('reuseEqualRecordMap', () => {
expect(result.b).toEqual(row('changed'))
})
it('returns a new map when a value changed', () => {
const previous = { a: row('a') }
const result = reuseEqualRecordMap(previous, { a: row('changed') })
expect(result).not.toBe(previous)
expect(result.a).toEqual(row('changed'))
})
it('returns a new map when a key is added', () => {
const previous = { a: row('a') }
const result = reuseEqualRecordMap(previous, { a: row('a'), b: row('b') })
expect(result).not.toBe(previous)
expect(result.a).toBe(previous.a)
expect(Object.keys(result)).toEqual(['a', 'b'])
})
it('returns a new map when a key is removed', () => {
const previous = { a: row('a'), b: row('b') }
const result = reuseEqualRecordMap(previous, { a: row('a') })
expect(result).not.toBe(previous)
expect(Object.keys(result)).toEqual(['a'])
})
it('returns a new map when a key is swapped for a same-count replacement', () => {
// Same counts can hide replaced keys; next-key misses detect removals without another previous-key pass.
const previous = { a: row('a'), b: row('b') }
@@ -1,197 +0,0 @@
import { describe, it, expect } from 'vitest'
import type { Tab, TabGroup } from '../../../../shared/tab-types'
import {
findTabAndWorktree,
findGroupForTab,
ensureGroup,
pickNeighbor,
pickNextActiveTab,
pushRecentTabId,
sanitizeRecentTabIds,
updateGroup,
patchTab
} from './tab-group-state'
function makeTab(overrides: Partial<Tab> & { id: string; worktreeId: string }): Tab {
return {
entityId: overrides.id,
groupId: 'g1',
contentType: 'terminal',
label: overrides.id,
customLabel: null,
color: null,
sortOrder: 0,
createdAt: Date.now(),
...overrides
}
}
describe('findTabAndWorktree', () => {
it('finds a tab across worktrees', () => {
const tabs: Record<string, Tab[]> = {
w1: [makeTab({ id: 't1', worktreeId: 'w1' })],
w2: [makeTab({ id: 't2', worktreeId: 'w2' })]
}
const result = findTabAndWorktree(tabs, 't2')
expect(result).not.toBeNull()
expect(result!.worktreeId).toBe('w2')
expect(result!.tab.id).toBe('t2')
})
it('returns null for unknown tab', () => {
expect(findTabAndWorktree({}, 'missing')).toBeNull()
})
})
describe('findGroupForTab', () => {
it('finds a group by worktree and groupId', () => {
const group: TabGroup = { id: 'g1', worktreeId: 'w1', activeTabId: null, tabOrder: [] }
const groups: Record<string, TabGroup[]> = { w1: [group] }
expect(findGroupForTab(groups, 'w1', 'g1')).toBe(group)
})
it('returns null for missing group', () => {
expect(findGroupForTab({}, 'w1', 'g1')).toBeNull()
})
})
describe('ensureGroup', () => {
it('returns existing group if one exists', () => {
const group: TabGroup = { id: 'g1', worktreeId: 'w1', activeTabId: null, tabOrder: [] }
const groups = { w1: [group] }
const active = { w1: 'g1' }
const result = ensureGroup(groups, active, 'w1')
expect(result.group).toBe(group)
expect(result.groupsByWorktree).toBe(groups)
})
it('creates a new group for a new worktree', () => {
const result = ensureGroup({}, {}, 'w1')
expect(result.group.worktreeId).toBe('w1')
expect(result.group.tabOrder).toEqual([])
expect(result.groupsByWorktree.w1).toHaveLength(1)
expect(result.activeGroupIdByWorktree.w1).toBe(result.group.id)
})
})
describe('pickNeighbor', () => {
it('picks right neighbor first', () => {
expect(pickNeighbor(['a', 'b', 'c'], 'b')).toBe('c')
})
it('falls back to left neighbor when closing rightmost', () => {
expect(pickNeighbor(['a', 'b', 'c'], 'c')).toBe('b')
})
it('returns null for single item', () => {
expect(pickNeighbor(['a'], 'a')).toBeNull()
})
it('returns null for missing item', () => {
expect(pickNeighbor(['a', 'b'], 'x')).toBeNull()
})
})
describe('pushRecentTabId', () => {
it('appends a new id to the tail', () => {
expect(pushRecentTabId(['a', 'b'], 'c')).toEqual(['a', 'b', 'c'])
})
it('moves an existing id to the tail', () => {
expect(pushRecentTabId(['a', 'b', 'c'], 'b')).toEqual(['a', 'c', 'b'])
})
it('is a no-op when the id is already at the tail', () => {
const input = ['a', 'b']
expect(pushRecentTabId(input, 'b')).toBe(input)
})
it('handles undefined as empty', () => {
expect(pushRecentTabId(undefined, 'a')).toEqual(['a'])
})
})
describe('sanitizeRecentTabIds', () => {
it('drops ids not present in tabOrder', () => {
expect(sanitizeRecentTabIds(['a', 'b', 'c'], ['a', 'c'])).toEqual(['a', 'c'])
})
it('keeps only the last occurrence of duplicates', () => {
expect(sanitizeRecentTabIds(['a', 'b', 'a', 'c', 'b'], ['a', 'b', 'c'])).toEqual([
'a',
'c',
'b'
])
})
it('returns empty for undefined or empty input', () => {
expect(sanitizeRecentTabIds(undefined, ['a'])).toEqual([])
expect(sanitizeRecentTabIds([], ['a'])).toEqual([])
})
})
describe('pickNextActiveTab', () => {
it('returns the most-recent non-closing id', () => {
expect(pickNextActiveTab(['a', 'b', 'c'], ['a', 'c', 'b'], 'b')).toBe('c')
})
it('skips the closing id if it appears in MRU', () => {
expect(pickNextActiveTab(['a', 'b', 'c'], ['a', 'b', 'c'], 'c')).toBe('b')
})
it('falls back to visual neighbor when MRU is empty or has only the closing id', () => {
expect(pickNextActiveTab(['a', 'b', 'c'], [], 'b')).toBe('c')
expect(pickNextActiveTab(['a', 'b', 'c'], ['b'], 'b')).toBe('c')
})
it('falls back to left neighbor when closing the rightmost and MRU is empty', () => {
expect(pickNextActiveTab(['a', 'b', 'c'], undefined, 'c')).toBe('b')
})
})
describe('updateGroup', () => {
it('replaces the matching group', () => {
const g1: TabGroup = { id: 'g1', worktreeId: 'w1', activeTabId: null, tabOrder: [] }
const g2: TabGroup = { id: 'g2', worktreeId: 'w1', activeTabId: null, tabOrder: [] }
const updated: TabGroup = { ...g1, activeTabId: 't1' }
const result = updateGroup([g1, g2], updated)
expect(result[0].activeTabId).toBe('t1')
expect(result[1]).toBe(g2)
})
})
describe('patchTab', () => {
it('updates a single tab property', () => {
const tabs: Record<string, Tab[]> = {
w1: [makeTab({ id: 't1', worktreeId: 'w1', label: 'old' })]
}
const result = patchTab(tabs, 't1', { label: 'new' })
expect(result).not.toBeNull()
expect(result!.unifiedTabsByWorktree.w1[0].label).toBe('new')
})
it('updates multiple properties at once', () => {
const tabs: Record<string, Tab[]> = {
w1: [makeTab({ id: 't1', worktreeId: 'w1' })]
}
const result = patchTab(tabs, 't1', { isPinned: true, isPreview: false })
expect(result).not.toBeNull()
expect(result!.unifiedTabsByWorktree.w1[0].isPinned).toBe(true)
expect(result!.unifiedTabsByWorktree.w1[0].isPreview).toBe(false)
})
it('returns null for unknown tab', () => {
expect(patchTab({}, 'missing', { label: 'x' })).toBeNull()
})
it('does not modify other tabs', () => {
const tabs: Record<string, Tab[]> = {
w1: [
makeTab({ id: 't1', worktreeId: 'w1', label: 'a' }),
makeTab({ id: 't2', worktreeId: 'w1', label: 'b' })
]
}
const result = patchTab(tabs, 't1', { label: 'changed' })
expect(result!.unifiedTabsByWorktree.w1[1].label).toBe('b')
})
})