Files
orca/src/shared/cross-platform-path.test.ts
T
Jinjing 17eefef502 [Tabs] Preserve host routing and reduce search churn (#13114)
* fix tab search host routing and churn

* Fix open-tab search to resolve hosts from worktree when active host unkn

- Use worktree.hostId to resolve execution host instead of defaulting to LOCAL_EXECUTION_HOST_ID
- Correctly populate search results for remote-only worktrees when activeWorkspaceExecutionHostId is null
- Remove automatic focus of terminal tabs after search activation

* Prevent stale tab results when user keeps typing ahead of deferred searc

- useOpenTabSearch now returns {query, results} to track which query the results describe
- Gate tab results on query match so stale results don't appear on user's screen
- Add live region (role=status) for accessibility of tab switch error messages
- Distinguish missing-worktree from missing-page errors in browser page activation
- Improve host resolution to prefer active host when worktree and repo don't specify one

* Re-pin entry to deferred tab results that rank higher

Track whether selection auto-follows the top-ranked result or was
manually positioned. Re-pin entry to tabs when they rank higher,
but preserve manual selection.

* Consolidate browser focus requests and simplify selection state

- Extract requestBrowserFocus to handle queueing + event dispatch atomically
- Simplify omnibox selection tracking with single pinnedOptionId state
- Optimize host resolution in tab search to compute once per query

* Report dead browser workspaces correctly and fold dedupe case by host

Two readiness-checklist fixes for open-tab search:

- Browser page activation checked page/workspace before the worktree, but
  deleting a worktree purges its browser workspaces and pages too, so a dead
  workspace surfaced as "Browser page no longer exists". Check the worktree
  first; routing already maps missing-worktree to the workspace wording.

- Editor-tab/file dedupe compared paths with separator normalization only, so
  a Windows worktree offered both "Switch to tab" and "Open file" for the same
  path in different case. Fold by the worktree path's syntax via the new
  isCaseInsensitiveRuntimeRoot, keeping WSL, POSIX and SSH roots case-sensitive,
  and add NFC so a macOS NFD listing matches an editor's composed path.

* Fix tab deduplication and resolve worktree host collisions

- Only editor tabs should suppress file entries; check contentType instead
  of relying on path being empty for non-editor tabs.
- Add executionHostId to simulator search results to disambiguate when
  the same worktree id exists on multiple execution hosts.
2026-08-08 17:46:11 -07:00

164 lines
7.0 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
isCaseInsensitiveRuntimeRoot,
isPathInsideOrEqual,
isRuntimePathAbsolute,
normalizeRuntimePathForComparison,
relativePathInsideRoot,
resolveRuntimePath
} from './cross-platform-path'
describe('isCaseInsensitiveRuntimeRoot', () => {
it('folds Windows drive and plain UNC roots', () => {
expect(isCaseInsensitiveRuntimeRoot('C:\\repos\\app')).toBe(true)
expect(isCaseInsensitiveRuntimeRoot('c:/repos/app')).toBe(true)
expect(isCaseInsensitiveRuntimeRoot('\\\\server\\share\\app')).toBe(true)
})
it('keeps WSL UNC and POSIX roots case-sensitive', () => {
expect(isCaseInsensitiveRuntimeRoot('\\\\wsl.localhost\\Ubuntu\\home\\ada\\app')).toBe(false)
expect(isCaseInsensitiveRuntimeRoot('//wsl$/Ubuntu/home/ada/app')).toBe(false)
expect(isCaseInsensitiveRuntimeRoot('/home/ada/app')).toBe(false)
expect(isCaseInsensitiveRuntimeRoot('/Users/ada/app')).toBe(false)
})
})
describe('cross-platform path containment', () => {
it('keeps POSIX sibling prefixes outside the root', () => {
expect(isPathInsideOrEqual('/repo/app', '/repo/app')).toBe(true)
expect(isPathInsideOrEqual('/repo/app', '/repo/app/src/index.ts')).toBe(true)
expect(isPathInsideOrEqual('/repo/app', '/repo/application/src/index.ts')).toBe(false)
expect(relativePathInsideRoot('/repo/app/', '/repo/app/src/index.ts')).toBe('src/index.ts')
})
it('keeps literal POSIX backslashes distinct from separators', () => {
expect(normalizeRuntimePathForComparison('/srv/team\\repo')).toBe('/srv/team\\repo')
expect(normalizeRuntimePathForComparison('/srv/team/repo')).toBe('/srv/team/repo')
expect(isPathInsideOrEqual('/srv/team\\repo', '/srv/team/repo/file.ts')).toBe(false)
expect(relativePathInsideRoot('/srv/repo', '/srv/repo/a\\b.txt')).toBe('a\\b.txt')
})
it('handles Windows drive roots and sibling drives case-insensitively', () => {
expect(isPathInsideOrEqual('C:\\Repo', 'c:\\repo\\src\\index.ts')).toBe(true)
expect(relativePathInsideRoot('C:\\Repo', 'c:\\repo\\src\\index.ts')).toBe('src/index.ts')
expect(isPathInsideOrEqual('C:\\Repo', 'D:\\Repo\\src\\index.ts')).toBe(false)
expect(relativePathInsideRoot('C:\\', 'c:\\repo\\src\\index.ts')).toBe('repo/src/index.ts')
})
it('handles UNC roots, trailing slashes, mixed separators, and case', () => {
expect(isPathInsideOrEqual('\\\\Server\\Share\\Repo\\', '//server/share/repo/src')).toBe(true)
expect(relativePathInsideRoot('\\\\Server\\Share\\Repo\\', '//server/share/repo/src')).toBe(
'src'
)
expect(isPathInsideOrEqual('\\\\Server\\Share\\Repo', '\\\\server\\share\\repo2')).toBe(false)
})
it('treats WSL UNC aliases as the same case-sensitive filesystem', () => {
expect(
isPathInsideOrEqual(
'\\\\wsl$\\Ubuntu\\home\\Alice\\repo',
'\\\\wsl.localhost\\ubuntu\\home\\Alice\\repo\\src'
)
).toBe(true)
expect(
relativePathInsideRoot(
'\\\\wsl$\\Ubuntu\\home\\Alice\\repo',
'\\\\wsl.localhost\\ubuntu\\home\\Alice\\repo\\Src'
)
).toBe('Src')
expect(
isPathInsideOrEqual(
'\\\\wsl$\\Ubuntu\\home\\Alice\\repo',
'\\\\wsl.localhost\\ubuntu\\home\\alice\\repo\\src'
)
).toBe(false)
expect(
relativePathInsideRoot(
'\\\\wsl$\\Ubuntu\\home\\Alice\\repo',
'\\\\wsl.localhost\\ubuntu\\home\\alice\\repo\\src'
)
).toBeNull()
expect(
relativePathInsideRoot(
'\\\\wsl$\\Ubuntu\\home\\Alice\\repo',
'\\\\wsl.localhost\\ubuntu\\home\\Alice\\repo\\line\nbreak'
)
).toBe('line\nbreak')
})
it('matches macOS NFD paths against agent-recorded NFC paths', () => {
// Regression for #10832: macOS file pickers hand Orca decomposed (NFD) paths
// while Claude Code records cwd and names its project dirs in NFC, so a
// non-ASCII workspace never matched its own sessions.
const nfc = '/Users/ada/내 드라이브/프로젝트'
const nfd = nfc.normalize('NFD')
expect(nfd).not.toBe(nfc)
expect(normalizeRuntimePathForComparison(nfd)).toBe(normalizeRuntimePathForComparison(nfc))
expect(isPathInsideOrEqual(nfd, `${nfc}/src`)).toBe(true)
expect(isPathInsideOrEqual(nfc, `${nfd}/src`)).toBe(true)
// WSL UNC keys return before the trailing fold, so they need NFC too.
expect(
normalizeRuntimePathForComparison(
`\\\\wsl$\\Ubuntu\\home\\ada\\${'프로젝트'.normalize('NFD')}`
)
).toBe(normalizeRuntimePathForComparison(`\\\\wsl.localhost\\Ubuntu\\home\\ada\\프로젝트`))
})
it('returns a byte-exact suffix when comparison folding changes length', () => {
// Comparison folding (NFC, case) is not length-preserving, so slicing the raw
// candidate by the folded root's length would cut mid-character and fabricate
// a path — callers rejoin this suffix and hit the filesystem with it.
const nfc = '/Users/ada/프로젝트'
const nfd = nfc.normalize('NFD')
for (const root of [nfc, nfd]) {
for (const candidate of [nfc, nfd]) {
expect(relativePathInsideRoot(root, `${candidate}/src/index.ts`)).toBe('src/index.ts')
}
}
// Pre-existing over-slice: toLowerCase expands U+0130 to two UTF-16 units.
expect(relativePathInsideRoot('C:\\İş', 'C:\\İş\\src\\a.ts')).toBe('src/a.ts')
// U+212A KELVIN SIGN folds to 'K', so the root and candidate must agree on
// Windows-ness or their segment counts desync and the suffix comes back ''.
expect(relativePathInsideRoot('\u212A:/a\\b', '\u212A:/a\\b/c')).toBe(
relativePathInsideRoot('K:/a\\b', 'K:/a\\b/c')
)
// Astral characters must not be cut mid-surrogate-pair.
expect(relativePathInsideRoot('/repo/🚀app', '/repo/🚀app/src/🎉file.ts')).toBe('src/🎉file.ts')
// A UNC-shaped candidate under POSIX root '/' used to yield a leading slash,
// which is not a relative path.
expect(relativePathInsideRoot('/', '//server/share/x')).toBe('server/share/x')
// WSL suffixes must stay decomposed: they name files on a Linux filesystem,
// where NFD and NFC are distinct entries.
const decomposed = '프로젝트'.normalize('NFD')
expect(
relativePathInsideRoot(
'\\\\wsl$\\Ubuntu\\home\\ada\\repo',
`\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo\\${decomposed}\\a.ts`
)
).toBe(`${decomposed}/a.ts`)
})
it('resolves POSIX relative paths without using the process cwd', () => {
expect(resolveRuntimePath('/repos/app/repo', '../worktrees/feature')).toBe(
'/repos/app/worktrees/feature'
)
expect(resolveRuntimePath('/repos/app/repo', '/custom/worktrees')).toBe('/custom/worktrees')
expect(isRuntimePathAbsolute('../worktrees')).toBe(false)
})
it('resolves Windows relative paths with Windows semantics', () => {
expect(resolveRuntimePath('C:\\Repos\\app\\repo', '..\\worktrees\\feature')).toBe(
'C:/Repos/app/worktrees/feature'
)
expect(resolveRuntimePath('C:\\Repos\\app\\repo', 'D:\\worktrees')).toBe('D:/worktrees')
expect(isRuntimePathAbsolute('/remote/worktrees', 'windows')).toBe(true)
})
})