fix(settings): keep local WSL settings scoped to the desktop host (#9635)
* fix(settings): scope local WSL settings to the desktop host * fix(settings): verify local WSL capability ownership * fix(settings): respect capability host ownership * fix(settings): isolate paired host capabilities * fix(settings): key web capabilities to paired host --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
parent
9150ac65cb
commit
3e4abef089
|
|
@ -0,0 +1,247 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, createElement, type ComponentProps, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import type { AgentsPane } from '@/components/settings/AgentsPane'
|
||||
import { resetWindowsTerminalCapabilitiesForTests } from '@/lib/windows-terminal-capabilities'
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
settings: null as GlobalSettings | null,
|
||||
updateSettings: vi.fn(),
|
||||
agentsPaneProps: null as ComponentProps<typeof AgentsPane> | null,
|
||||
isWebClient: false,
|
||||
runtimeEnvironments: [] as { id: string; createdAt: number; pairingRevision?: number }[]
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: object) => unknown) =>
|
||||
selector({
|
||||
settings: testState.settings,
|
||||
updateSettings: testState.updateSettings,
|
||||
runtimeEnvironments: testState.runtimeEnvironments,
|
||||
runtimeStatusByEnvironmentId: new Map()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/settings/AgentsPane', () => ({
|
||||
AgentsPane: (props: ComponentProps<typeof AgentsPane>) => {
|
||||
testState.agentsPaneProps = props
|
||||
return null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
Dialog: ({ children }: { children: ReactNode }) => children,
|
||||
DialogContent: ({ children }: { children: ReactNode }) => children,
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => children,
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => children,
|
||||
DialogTitle: ({ children }: { children: ReactNode }) => children
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/web-client-location', () => ({
|
||||
isWebClientLocation: () => testState.isWebClient
|
||||
}))
|
||||
|
||||
import AgentSettingsDialog from './AgentSettingsDialog'
|
||||
|
||||
function installCapabilityTransports(localHostPlatform: NodeJS.Platform = 'win32'): {
|
||||
localWslAvailable: ReturnType<typeof vi.fn>
|
||||
localWslDistros: ReturnType<typeof vi.fn>
|
||||
runtimeGetStatus: ReturnType<typeof vi.fn>
|
||||
runtimeEnvironmentCall: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const localWslAvailable = vi.fn().mockResolvedValue(true)
|
||||
const localWslDistros = vi.fn().mockResolvedValue(['Ubuntu'])
|
||||
const runtimeGetStatus = vi.fn().mockResolvedValue({ hostPlatform: localHostPlatform })
|
||||
const runtimeEnvironmentCall = vi.fn(async (args: { method: string }) => ({
|
||||
id: args.method,
|
||||
ok: true,
|
||||
result:
|
||||
args.method === 'status.get'
|
||||
? {
|
||||
hostPlatform: 'linux',
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2
|
||||
}
|
||||
: args.method === 'host.wsl.listDistros'
|
||||
? []
|
||||
: false
|
||||
}))
|
||||
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
wsl: {
|
||||
isAvailable: localWslAvailable,
|
||||
listDistros: localWslDistros
|
||||
},
|
||||
pwsh: { isAvailable: vi.fn().mockResolvedValue(true) },
|
||||
gitBash: { isAvailable: vi.fn().mockResolvedValue(false) },
|
||||
runtime: { getStatus: runtimeGetStatus },
|
||||
runtimeEnvironments: { call: runtimeEnvironmentCall }
|
||||
} as unknown as Window['api']
|
||||
})
|
||||
return { localWslAvailable, localWslDistros, runtimeGetStatus, runtimeEnvironmentCall }
|
||||
}
|
||||
|
||||
describe('AgentSettingsDialog', () => {
|
||||
let root: Root
|
||||
|
||||
beforeEach(() => {
|
||||
testState.settings = {
|
||||
...getDefaultSettings('/tmp'),
|
||||
activeRuntimeEnvironmentId: 'remote-linux'
|
||||
}
|
||||
testState.updateSettings.mockReset()
|
||||
testState.agentsPaneProps = null
|
||||
testState.isWebClient = false
|
||||
testState.runtimeEnvironments = []
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
resetWindowsTerminalCapabilitiesForTests()
|
||||
document.body.replaceChildren()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('reads desktop WSL capabilities instead of the active Linux runtime', async () => {
|
||||
vi.stubGlobal('navigator', { userAgent: 'Windows' })
|
||||
const { localWslAvailable, runtimeEnvironmentCall } = installCapabilityTransports()
|
||||
|
||||
await act(async () => {
|
||||
root.render(createElement(AgentSettingsDialog, { open: true, onOpenChange: vi.fn() }))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(localWslAvailable).toHaveBeenCalledTimes(1)
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
expect(testState.agentsPaneProps).toMatchObject({
|
||||
wslSupportedPlatform: true,
|
||||
wslAvailable: true,
|
||||
wslDistros: ['Ubuntu'],
|
||||
wslCapabilitiesLoading: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not expose the desktop runtime setting on a non-Windows desktop', async () => {
|
||||
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
|
||||
const { localWslAvailable, runtimeEnvironmentCall } = installCapabilityTransports()
|
||||
|
||||
await act(async () => {
|
||||
root.render(createElement(AgentSettingsDialog, { open: true, onOpenChange: vi.fn() }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(localWslAvailable).not.toHaveBeenCalled()
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
expect(testState.agentsPaneProps).toMatchObject({ wslSupportedPlatform: false })
|
||||
})
|
||||
|
||||
it('uses the paired server capability transport for a web client', async () => {
|
||||
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
|
||||
testState.isWebClient = true
|
||||
const { localWslAvailable, runtimeEnvironmentCall } = installCapabilityTransports()
|
||||
|
||||
await act(async () => {
|
||||
root.render(createElement(AgentSettingsDialog, { open: true, onOpenChange: vi.fn() }))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(localWslAvailable).toHaveBeenCalledTimes(1)
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
expect(testState.agentsPaneProps).toMatchObject({
|
||||
wslSupportedPlatform: true,
|
||||
wslAvailable: true,
|
||||
wslDistros: ['Ubuntu']
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores a Windows browser user agent when the paired server is Linux', async () => {
|
||||
vi.stubGlobal('navigator', { userAgent: 'Windows' })
|
||||
testState.isWebClient = true
|
||||
const { localWslAvailable, runtimeEnvironmentCall } = installCapabilityTransports('linux')
|
||||
|
||||
await act(async () => {
|
||||
root.render(createElement(AgentSettingsDialog, { open: true, onOpenChange: vi.fn() }))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(localWslAvailable).toHaveBeenCalledTimes(1)
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
expect(testState.agentsPaneProps).toMatchObject({ wslSupportedPlatform: false })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'Windows to Linux',
|
||||
firstPlatform: 'win32' as const,
|
||||
firstAvailable: true,
|
||||
firstDistros: ['Ubuntu'],
|
||||
secondPlatform: 'linux' as const,
|
||||
secondAvailable: false,
|
||||
secondDistros: []
|
||||
},
|
||||
{
|
||||
name: 'Linux to Windows',
|
||||
firstPlatform: 'linux' as const,
|
||||
firstAvailable: false,
|
||||
firstDistros: [],
|
||||
secondPlatform: 'win32' as const,
|
||||
secondAvailable: true,
|
||||
secondDistros: ['Debian']
|
||||
}
|
||||
])('refreshes paired-server capabilities after re-pairing: $name', async (args) => {
|
||||
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
|
||||
testState.isWebClient = true
|
||||
testState.settings = { ...testState.settings!, activeRuntimeEnvironmentId: null }
|
||||
testState.runtimeEnvironments = [{ id: 'paired-a', createdAt: 1 }]
|
||||
const { localWslAvailable, localWslDistros, runtimeGetStatus } = installCapabilityTransports(
|
||||
args.firstPlatform
|
||||
)
|
||||
localWslAvailable.mockResolvedValue(args.firstAvailable)
|
||||
localWslDistros.mockResolvedValue(args.firstDistros)
|
||||
|
||||
await act(async () => {
|
||||
root.render(createElement(AgentSettingsDialog, { open: true, onOpenChange: vi.fn() }))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(testState.agentsPaneProps).toMatchObject({
|
||||
wslSupportedPlatform: args.firstPlatform === 'win32',
|
||||
wslAvailable: args.firstAvailable,
|
||||
wslDistros: args.firstDistros
|
||||
})
|
||||
|
||||
testState.settings = {
|
||||
...testState.settings!,
|
||||
activeRuntimeEnvironmentId: null
|
||||
}
|
||||
testState.runtimeEnvironments = [{ id: 'paired-b', createdAt: 2 }]
|
||||
localWslAvailable.mockResolvedValue(args.secondAvailable)
|
||||
localWslDistros.mockResolvedValue(args.secondDistros)
|
||||
runtimeGetStatus.mockResolvedValue({ hostPlatform: args.secondPlatform })
|
||||
await act(async () => {
|
||||
root.render(createElement(AgentSettingsDialog, { open: true, onOpenChange: vi.fn() }))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(testState.agentsPaneProps).toMatchObject({
|
||||
wslSupportedPlatform: args.secondPlatform === 'win32',
|
||||
wslAvailable: args.secondAvailable,
|
||||
wslDistros: args.secondDistros
|
||||
})
|
||||
expect(localWslAvailable).toHaveBeenCalledTimes(2)
|
||||
expect(runtimeGetStatus).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
|
@ -10,11 +10,11 @@ import { AgentsPane } from '@/components/settings/AgentsPane'
|
|||
import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
getWindowsTerminalCapabilityOwnerKey,
|
||||
useWindowsTerminalCapabilities
|
||||
isWindowsTerminalCapabilityHost,
|
||||
useLocalWindowsTerminalCapabilities
|
||||
} from '@/lib/windows-terminal-capabilities'
|
||||
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { isWebClientLocation } from '@/lib/web-client-location'
|
||||
import { useWindowsTerminalCapabilityOwnerKey } from '@/hooks/useWindowsTerminalCapabilityOwnerKey'
|
||||
|
||||
type AgentSettingsDialogProps = {
|
||||
open: boolean
|
||||
|
|
@ -27,20 +27,24 @@ export default function AgentSettingsDialog({
|
|||
}: AgentSettingsDialogProps): React.JSX.Element | null {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
const runtimeTarget = getActiveRuntimeTarget(settings)
|
||||
const runtimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() || null
|
||||
const capabilitiesOwnerKey = getWindowsTerminalCapabilityOwnerKey(runtimeEnvironmentId)
|
||||
const isWindowsRenderer =
|
||||
typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows')
|
||||
const isWebClient = isWebClientLocation()
|
||||
const windowsTerminalCapabilities = useWindowsTerminalCapabilities(
|
||||
open && (isWindowsRenderer || isWebClient || runtimeTarget.kind === 'environment'),
|
||||
false,
|
||||
capabilitiesOwnerKey,
|
||||
runtimeTarget
|
||||
const runtimeCapabilityOwnerKey = useWindowsTerminalCapabilityOwnerKey(
|
||||
settings?.activeRuntimeEnvironmentId
|
||||
)
|
||||
const wslSupportedPlatform =
|
||||
isWindowsRenderer || windowsTerminalCapabilities.hostPlatform === 'win32'
|
||||
const localCapabilityOwnerKey = isWebClient ? runtimeCapabilityOwnerKey : 'local'
|
||||
const localWindowsTerminalCapabilities = useLocalWindowsTerminalCapabilities(
|
||||
open && (isWindowsRenderer || isWebClient),
|
||||
false,
|
||||
localCapabilityOwnerKey
|
||||
)
|
||||
const wslSupportedPlatform = isWindowsTerminalCapabilityHost({
|
||||
isWindowsRenderer,
|
||||
isWebClient,
|
||||
target: { kind: 'local' },
|
||||
hostPlatform: localWindowsTerminalCapabilities.hostPlatform
|
||||
})
|
||||
|
||||
if (!settings) {
|
||||
return null
|
||||
|
|
@ -69,9 +73,9 @@ export default function AgentSettingsDialog({
|
|||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
wslSupportedPlatform={wslSupportedPlatform}
|
||||
wslAvailable={windowsTerminalCapabilities.wslAvailable}
|
||||
wslDistros={windowsTerminalCapabilities.wslDistros}
|
||||
wslCapabilitiesLoading={windowsTerminalCapabilities.isLoading}
|
||||
wslAvailable={localWindowsTerminalCapabilities.wslAvailable}
|
||||
wslDistros={localWindowsTerminalCapabilities.wslDistros}
|
||||
wslCapabilitiesLoading={localWindowsTerminalCapabilities.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -77,9 +77,11 @@ import { isIntentionalAppRestartInProgress } from '@/lib/updater-beforeunload'
|
|||
import { registerWindowCloseGuard } from '../window-close-request-coordinator'
|
||||
import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client'
|
||||
import {
|
||||
getWindowsTerminalCapabilityOwnerKey,
|
||||
isWindowsTerminalCapabilityHost,
|
||||
useLocalWindowsTerminalCapabilities,
|
||||
useWindowsTerminalCapabilities
|
||||
} from '@/lib/windows-terminal-capabilities'
|
||||
import { useWindowsTerminalCapabilityOwnerKey } from '@/hooks/useWindowsTerminalCapabilityOwnerKey'
|
||||
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import { keybindingMatchesAction } from '../../../../shared/keybindings'
|
||||
|
|
@ -834,32 +836,59 @@ function Settings(): React.JSX.Element {
|
|||
}),
|
||||
[activeSectionId, mountedSectionIds, navSections, settingsSearchQuery, visibleSectionIds]
|
||||
)
|
||||
const windowsTerminalCapabilityOwnerKey = getWindowsTerminalCapabilityOwnerKey(
|
||||
const windowsTerminalCapabilityOwnerKey = useWindowsTerminalCapabilityOwnerKey(
|
||||
settings?.activeRuntimeEnvironmentId
|
||||
)
|
||||
const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings])
|
||||
const capabilityLoadTarget = useMemo(
|
||||
() => (isWebClient ? { kind: 'local' as const } : runtimeTarget),
|
||||
[isWebClient, runtimeTarget]
|
||||
)
|
||||
const hasActiveRuntimeEnvironment = Boolean(settings?.activeRuntimeEnvironmentId?.trim())
|
||||
const needsRepoWindowsRuntimeCapabilities = [...neededSectionIds].some((sectionId) =>
|
||||
sectionId.startsWith('repo-')
|
||||
)
|
||||
const needsLocalWindowsRuntimeCapabilities =
|
||||
(isWindows || isWebClient) &&
|
||||
(neededSectionIds.has('agents') || neededSectionIds.has('general'))
|
||||
const shouldLoadWindowsTerminalCapabilities =
|
||||
hasActiveRuntimeEnvironment ||
|
||||
((isWindows || isWebClient) &&
|
||||
(neededSectionIds.has('terminal') ||
|
||||
neededSectionIds.has('general') ||
|
||||
neededSectionIds.has('accounts') ||
|
||||
neededSectionIds.has('agents') ||
|
||||
needsRepoWindowsRuntimeCapabilities))
|
||||
// Why: General owns the Orca CLI controls, including WSL skill-location setup.
|
||||
needsRepoWindowsRuntimeCapabilities ||
|
||||
(runtimeTarget.kind === 'local' && needsLocalWindowsRuntimeCapabilities)))
|
||||
// Why: terminal, account, and repository settings describe the active execution host.
|
||||
const windowsTerminalCapabilities = useWindowsTerminalCapabilities(
|
||||
shouldLoadWindowsTerminalCapabilities,
|
||||
true,
|
||||
windowsTerminalCapabilityOwnerKey,
|
||||
runtimeTarget
|
||||
capabilityLoadTarget
|
||||
)
|
||||
// Why: global agent and project defaults belong to the desktop, not its active remote.
|
||||
const remoteViewLocalWindowsRuntimeCapabilities = useLocalWindowsTerminalCapabilities(
|
||||
needsLocalWindowsRuntimeCapabilities && runtimeTarget.kind === 'environment' && !isWebClient,
|
||||
true,
|
||||
'local'
|
||||
)
|
||||
const localWindowsRuntimeCapabilities =
|
||||
runtimeTarget.kind === 'local' || isWebClient
|
||||
? windowsTerminalCapabilities
|
||||
: remoteViewLocalWindowsRuntimeCapabilities
|
||||
// Why: only supported-but-unavailable WSL (Windows) should render disabled controls, not unsupported WSL (macOS/Linux).
|
||||
const wslSupportedPlatform = isWindows || windowsTerminalCapabilities.hostPlatform === 'win32'
|
||||
const isWindowsTerminalHost = isWindows || windowsTerminalCapabilities.hostPlatform === 'win32'
|
||||
const runtimeWslSupportedPlatform = isWindowsTerminalCapabilityHost({
|
||||
isWindowsRenderer: isWindows,
|
||||
isWebClient,
|
||||
target: runtimeTarget,
|
||||
hostPlatform: windowsTerminalCapabilities.hostPlatform
|
||||
})
|
||||
const localWslSupportedPlatform = isWindowsTerminalCapabilityHost({
|
||||
isWindowsRenderer: isWindows,
|
||||
isWebClient,
|
||||
target: { kind: 'local' },
|
||||
hostPlatform: localWindowsRuntimeCapabilities.hostPlatform
|
||||
})
|
||||
const isWindowsTerminalHost = runtimeWslSupportedPlatform
|
||||
|
||||
if ([...neededSectionIds].some((id) => !mountedSectionIds.has(id))) {
|
||||
// Why: record newly needed sections during render so panes don't wait for a follow-up Effect.
|
||||
|
|
@ -1183,10 +1212,10 @@ function Settings(): React.JSX.Element {
|
|||
<AgentsPane
|
||||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
wslSupportedPlatform={wslSupportedPlatform}
|
||||
wslAvailable={windowsTerminalCapabilities.wslAvailable}
|
||||
wslDistros={windowsTerminalCapabilities.wslDistros}
|
||||
wslCapabilitiesLoading={windowsTerminalCapabilities.isLoading}
|
||||
wslSupportedPlatform={localWslSupportedPlatform}
|
||||
wslAvailable={localWindowsRuntimeCapabilities.wslAvailable}
|
||||
wslDistros={localWindowsRuntimeCapabilities.wslDistros}
|
||||
wslCapabilitiesLoading={localWindowsRuntimeCapabilities.isLoading}
|
||||
/>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
|
|
@ -1211,7 +1240,7 @@ function Settings(): React.JSX.Element {
|
|||
<AccountsPane
|
||||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
wslSupportedPlatform={wslSupportedPlatform}
|
||||
wslSupportedPlatform={runtimeWslSupportedPlatform}
|
||||
wslAvailable={windowsTerminalCapabilities.wslAvailable}
|
||||
wslDistros={windowsTerminalCapabilities.wslDistros}
|
||||
wslCapabilitiesLoading={windowsTerminalCapabilities.isLoading}
|
||||
|
|
@ -1310,10 +1339,10 @@ function Settings(): React.JSX.Element {
|
|||
updateSettings={updateSettings}
|
||||
fontSuggestions={terminalFontSuggestions}
|
||||
onRequestFontSuggestions={requestFontSuggestions}
|
||||
wslSupportedPlatform={wslSupportedPlatform}
|
||||
wslAvailable={windowsTerminalCapabilities.wslAvailable}
|
||||
wslDistros={windowsTerminalCapabilities.wslDistros}
|
||||
wslCapabilitiesLoading={windowsTerminalCapabilities.isLoading}
|
||||
wslSupportedPlatform={localWslSupportedPlatform}
|
||||
wslAvailable={localWindowsRuntimeCapabilities.wslAvailable}
|
||||
wslDistros={localWindowsRuntimeCapabilities.wslDistros}
|
||||
wslCapabilitiesLoading={localWindowsRuntimeCapabilities.isLoading}
|
||||
/>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../shared/constants'
|
||||
import type { GlobalSettings } from '../../../shared/types'
|
||||
import type { SettingsNavSection } from '@/lib/settings-navigation-types'
|
||||
import { resetWindowsTerminalCapabilitiesForTests } from '@/lib/windows-terminal-capabilities'
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
settings: null as GlobalSettings | null,
|
||||
sections: null as SettingsNavSection[] | null,
|
||||
runtimeEnvironments: [] as { id: string; createdAt: number; pairingRevision?: number }[]
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: object) => unknown) =>
|
||||
selector({
|
||||
settings: testState.settings,
|
||||
repos: [],
|
||||
runtimeEnvironments: testState.runtimeEnvironments,
|
||||
runtimeStatusByEnvironmentId: new Map()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useLinearProviderConnected', () => ({
|
||||
useLinearProviderConnected: () => false
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/web-client-location', () => ({
|
||||
isWebClientLocation: () => true
|
||||
}))
|
||||
|
||||
import { useSettingsNavigationMetadata } from './useSettingsNavigationMetadata'
|
||||
|
||||
function Probe(): null {
|
||||
testState.sections = useSettingsNavigationMetadata()
|
||||
return null
|
||||
}
|
||||
|
||||
function hasAgentRuntimeEntry(): boolean {
|
||||
return (
|
||||
testState.sections
|
||||
?.find((section) => section.id === 'agents')
|
||||
?.searchEntries.some((entry) => entry.title === 'Agent Runtime') ?? false
|
||||
)
|
||||
}
|
||||
|
||||
describe('settings navigation capability ownership', () => {
|
||||
let root: Root
|
||||
|
||||
beforeEach(() => {
|
||||
testState.settings = {
|
||||
...getDefaultSettings('/tmp'),
|
||||
activeRuntimeEnvironmentId: null
|
||||
}
|
||||
testState.runtimeEnvironments = [{ id: 'paired-a', createdAt: 1 }]
|
||||
testState.sections = null
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
resetWindowsTerminalCapabilitiesForTests()
|
||||
document.body.replaceChildren()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'Windows to Linux',
|
||||
firstPlatform: 'win32' as const,
|
||||
secondPlatform: 'linux' as const,
|
||||
firstVisible: true,
|
||||
secondVisible: false
|
||||
},
|
||||
{
|
||||
name: 'Linux to Windows',
|
||||
firstPlatform: 'linux' as const,
|
||||
secondPlatform: 'win32' as const,
|
||||
firstVisible: false,
|
||||
secondVisible: true
|
||||
}
|
||||
])('rebuilds web metadata from the new paired host: $name', async (args) => {
|
||||
const wslIsAvailable = vi.fn().mockResolvedValue(false)
|
||||
const wslListDistros = vi.fn().mockResolvedValue([])
|
||||
const pwshIsAvailable = vi.fn().mockResolvedValue(false)
|
||||
const gitBashIsAvailable = vi.fn().mockResolvedValue(false)
|
||||
const runtimeGetStatus = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ hostPlatform: args.firstPlatform })
|
||||
.mockResolvedValueOnce({ hostPlatform: args.secondPlatform })
|
||||
const runtimeEnvironmentCall = vi.fn()
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
wsl: { isAvailable: wslIsAvailable, listDistros: wslListDistros },
|
||||
pwsh: { isAvailable: pwshIsAvailable },
|
||||
gitBash: { isAvailable: gitBashIsAvailable },
|
||||
runtime: { getStatus: runtimeGetStatus },
|
||||
runtimeEnvironments: { call: runtimeEnvironmentCall }
|
||||
} as unknown as Window['api']
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
root.render(createElement(Probe))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(hasAgentRuntimeEntry()).toBe(args.firstVisible)
|
||||
|
||||
testState.settings = {
|
||||
...testState.settings!,
|
||||
activeRuntimeEnvironmentId: null
|
||||
}
|
||||
testState.runtimeEnvironments = [{ id: 'paired-b', createdAt: 2 }]
|
||||
await act(async () => {
|
||||
root.render(createElement(Probe))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(hasAgentRuntimeEntry()).toBe(args.secondVisible)
|
||||
expect(wslIsAvailable).toHaveBeenCalledTimes(2)
|
||||
expect(wslListDistros).toHaveBeenCalledTimes(2)
|
||||
expect(pwshIsAvailable).toHaveBeenCalledTimes(2)
|
||||
expect(gitBashIsAvailable).toHaveBeenCalledTimes(2)
|
||||
expect(runtimeGetStatus).toHaveBeenCalledTimes(2)
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -204,6 +204,54 @@ describe('settings navigation metadata', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('does not expose local runtime settings from a remote Windows host', () => {
|
||||
const sections = buildSettingsNavigationMetadata({
|
||||
isMac: false,
|
||||
isWindows: false,
|
||||
isLocalWindowsHost: false,
|
||||
isWindowsTerminalHost: true,
|
||||
isWebClient: false,
|
||||
repos: [repo]
|
||||
})
|
||||
|
||||
const agents = sections.find((section) => section.id === 'agents')
|
||||
const general = sections.find((section) => section.id === 'general')
|
||||
const terminal = sections.find((section) => section.id === 'terminal')
|
||||
const repoSection = sections.find((section) => section.id === 'repo-repo-1')
|
||||
|
||||
expect(agents?.searchEntries.some((entry) => entry.title === 'Agent Runtime')).toBe(false)
|
||||
expect(general?.searchEntries.some((entry) => entry.title === 'Default Project Runtime')).toBe(
|
||||
false
|
||||
)
|
||||
expect(terminal?.searchEntries.some((entry) => entry.title === 'Default Shell')).toBe(true)
|
||||
expect(repoSection?.searchEntries.some((entry) => entry.title === 'Project Runtime')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps local runtime settings but hides remote Linux entries on a Windows desktop', () => {
|
||||
const sections = buildSettingsNavigationMetadata({
|
||||
isMac: false,
|
||||
isWindows: true,
|
||||
isLocalWindowsHost: true,
|
||||
isWindowsTerminalHost: false,
|
||||
isWebClient: false,
|
||||
repos: [repo]
|
||||
})
|
||||
|
||||
const agents = sections.find((section) => section.id === 'agents')
|
||||
const general = sections.find((section) => section.id === 'general')
|
||||
const terminal = sections.find((section) => section.id === 'terminal')
|
||||
const repoSection = sections.find((section) => section.id === 'repo-repo-1')
|
||||
|
||||
expect(agents?.searchEntries.some((entry) => entry.title === 'Agent Runtime')).toBe(true)
|
||||
expect(general?.searchEntries.some((entry) => entry.title === 'Default Project Runtime')).toBe(
|
||||
true
|
||||
)
|
||||
expect(terminal?.searchEntries.some((entry) => entry.title === 'Default Shell')).toBe(false)
|
||||
expect(repoSection?.searchEntries.some((entry) => entry.title === 'Project Runtime')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('places Advanced near the bottom on desktop without putting it under Experimental', () => {
|
||||
const desktopIds = ids()
|
||||
|
||||
|
|
|
|||
|
|
@ -77,9 +77,10 @@ import { getRepositoryPaneSearchEntries } from '@/components/settings/repository
|
|||
import { buildSettingsProjectList } from '@/components/settings/settings-project-list'
|
||||
import { isWebClientLocation } from '@/lib/web-client-location'
|
||||
import {
|
||||
getWindowsTerminalCapabilityOwnerKey,
|
||||
isWindowsTerminalCapabilityHost,
|
||||
useWindowsTerminalCapabilities
|
||||
} from '@/lib/windows-terminal-capabilities'
|
||||
import { useWindowsTerminalCapabilityOwnerKey } from './useWindowsTerminalCapabilityOwnerKey'
|
||||
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { useLinearProviderConnected } from '@/hooks/useLinearProviderConnected'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
|
@ -114,6 +115,7 @@ function getDevToolsPaneSearchEntries(): SettingsNavSection['searchEntries'] {
|
|||
export function buildSettingsNavigationMetadata({
|
||||
isMac,
|
||||
isWindows,
|
||||
isLocalWindowsHost = isWindows,
|
||||
isWindowsTerminalHost = isWindows,
|
||||
isWebClient,
|
||||
isDev = import.meta.env.DEV,
|
||||
|
|
@ -122,6 +124,7 @@ export function buildSettingsNavigationMetadata({
|
|||
}: {
|
||||
isMac: boolean
|
||||
isWindows: boolean
|
||||
isLocalWindowsHost?: boolean
|
||||
isWindowsTerminalHost?: boolean
|
||||
isWebClient: boolean
|
||||
isDev?: boolean
|
||||
|
|
@ -156,7 +159,7 @@ export function buildSettingsNavigationMetadata({
|
|||
'Manage AI agents, set a default, and customize commands.'
|
||||
),
|
||||
icon: Bot,
|
||||
searchEntries: getAgentsPaneSearchEntries({ includeAgentRuntime: isWindowsTerminalHost }),
|
||||
searchEntries: getAgentsPaneSearchEntries({ includeAgentRuntime: isLocalWindowsHost }),
|
||||
group: 'capabilities'
|
||||
},
|
||||
{
|
||||
|
|
@ -269,7 +272,7 @@ export function buildSettingsNavigationMetadata({
|
|||
'Workspace defaults, app setup, and maintenance.'
|
||||
),
|
||||
icon: SlidersHorizontal,
|
||||
searchEntries: getGeneralPaneSearchEntries({ includeProjectRuntime: isWindowsTerminalHost }),
|
||||
searchEntries: getGeneralPaneSearchEntries({ includeProjectRuntime: isLocalWindowsHost }),
|
||||
group: 'setup'
|
||||
},
|
||||
{
|
||||
|
|
@ -623,17 +626,32 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] {
|
|||
const isWindows = isWindowsUserAgent()
|
||||
const isWebClient = isWebClientLocation()
|
||||
const isLinearConnected = useLinearProviderConnected()
|
||||
const windowsTerminalCapabilityOwnerKey = getWindowsTerminalCapabilityOwnerKey(
|
||||
const windowsTerminalCapabilityOwnerKey = useWindowsTerminalCapabilityOwnerKey(
|
||||
settings?.activeRuntimeEnvironmentId
|
||||
)
|
||||
const runtimeTarget = getActiveRuntimeTarget(settings)
|
||||
const capabilityLoadTarget = isWebClient ? { kind: 'local' as const } : runtimeTarget
|
||||
const windowsTerminalCapabilities = useWindowsTerminalCapabilities(
|
||||
isWindows || isWebClient || runtimeTarget.kind === 'environment',
|
||||
false,
|
||||
windowsTerminalCapabilityOwnerKey,
|
||||
runtimeTarget
|
||||
capabilityLoadTarget
|
||||
)
|
||||
const isWindowsTerminalHost = isWindows || windowsTerminalCapabilities.hostPlatform === 'win32'
|
||||
const isLocalWindowsHost = isWindowsTerminalCapabilityHost({
|
||||
isWindowsRenderer: isWindows,
|
||||
isWebClient,
|
||||
target: { kind: 'local' },
|
||||
hostPlatform:
|
||||
isWebClient || runtimeTarget.kind === 'local'
|
||||
? windowsTerminalCapabilities.hostPlatform
|
||||
: null
|
||||
})
|
||||
const isWindowsTerminalHost = isWindowsTerminalCapabilityHost({
|
||||
isWindowsRenderer: isWindows,
|
||||
isWebClient,
|
||||
target: runtimeTarget,
|
||||
hostPlatform: windowsTerminalCapabilities.hostPlatform
|
||||
})
|
||||
|
||||
// Why: Settings and Cmd+J share this metadata so platform/runtime visibility
|
||||
// and search entries cannot drift. Keep this hook free of Settings pane UI
|
||||
|
|
@ -643,6 +661,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] {
|
|||
buildSettingsNavigationMetadata({
|
||||
isMac,
|
||||
isWindows,
|
||||
isLocalWindowsHost,
|
||||
isWindowsTerminalHost,
|
||||
isWebClient,
|
||||
isDev: import.meta.env.DEV,
|
||||
|
|
@ -650,6 +669,15 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] {
|
|||
repos
|
||||
}),
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- activeLocale is read implicitly by the translate() calls inside buildSettingsNavigationMetadata; without it the memo keeps the previous language's sections.
|
||||
[isMac, isWindows, isWindowsTerminalHost, isWebClient, isLinearConnected, repos, activeLocale]
|
||||
[
|
||||
isMac,
|
||||
isWindows,
|
||||
isLocalWindowsHost,
|
||||
isWindowsTerminalHost,
|
||||
isWebClient,
|
||||
isLinearConnected,
|
||||
repos,
|
||||
activeLocale
|
||||
]
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
resetWindowsTerminalCapabilitiesForTests,
|
||||
useLocalWindowsTerminalCapabilities,
|
||||
type WindowsTerminalCapabilities
|
||||
} from '@/lib/windows-terminal-capabilities'
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
runtimeEnvironments: [] as { id: string; createdAt: number; pairingRevision?: number }[],
|
||||
runtimeStatusByEnvironmentId: new Map<string, { connectionGeneration?: number }>()
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: object) => unknown) => selector(testState)
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/web-client-location', () => ({
|
||||
isWebClientLocation: () => true
|
||||
}))
|
||||
|
||||
import {
|
||||
resolveWindowsTerminalCapabilityOwnerKey,
|
||||
useWindowsTerminalCapabilityOwnerKey
|
||||
} from './useWindowsTerminalCapabilityOwnerKey'
|
||||
|
||||
describe('Windows terminal capability owner key', () => {
|
||||
let root: Root
|
||||
let latest: WindowsTerminalCapabilities | null
|
||||
|
||||
beforeEach(() => {
|
||||
testState.runtimeEnvironments = [{ id: 'paired-a', createdAt: 1 }]
|
||||
testState.runtimeStatusByEnvironmentId = new Map()
|
||||
latest = null
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
resetWindowsTerminalCapabilitiesForTests()
|
||||
document.body.replaceChildren()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'Windows to Linux',
|
||||
firstPlatform: 'win32' as const,
|
||||
firstAvailable: true,
|
||||
firstDistros: ['Ubuntu'],
|
||||
secondPlatform: 'linux' as const,
|
||||
secondAvailable: false,
|
||||
secondDistros: []
|
||||
},
|
||||
{
|
||||
name: 'Linux to Windows',
|
||||
firstPlatform: 'linux' as const,
|
||||
firstAvailable: false,
|
||||
firstDistros: [],
|
||||
secondPlatform: 'win32' as const,
|
||||
secondAvailable: true,
|
||||
secondDistros: ['Debian']
|
||||
}
|
||||
])('re-probes the actual paired host with a null preference: $name', async (args) => {
|
||||
const wslIsAvailable = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(args.firstAvailable)
|
||||
.mockResolvedValueOnce(args.secondAvailable)
|
||||
const wslListDistros = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(args.firstDistros)
|
||||
.mockResolvedValueOnce(args.secondDistros)
|
||||
const runtimeGetStatus = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ hostPlatform: args.firstPlatform })
|
||||
.mockResolvedValueOnce({ hostPlatform: args.secondPlatform })
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
wsl: { isAvailable: wslIsAvailable, listDistros: wslListDistros },
|
||||
pwsh: { isAvailable: vi.fn().mockResolvedValue(false) },
|
||||
gitBash: { isAvailable: vi.fn().mockResolvedValue(false) },
|
||||
runtime: { getStatus: runtimeGetStatus }
|
||||
}
|
||||
})
|
||||
|
||||
function Probe(): null {
|
||||
const ownerKey = useWindowsTerminalCapabilityOwnerKey(null)
|
||||
latest = useLocalWindowsTerminalCapabilities(true, false, ownerKey)
|
||||
return null
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.render(createElement(Probe))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(latest).toMatchObject({
|
||||
hostPlatform: args.firstPlatform,
|
||||
wslAvailable: args.firstAvailable,
|
||||
wslDistros: args.firstDistros
|
||||
})
|
||||
|
||||
testState.runtimeEnvironments = [{ id: 'paired-b', createdAt: 2 }]
|
||||
await act(async () => {
|
||||
root.render(createElement(Probe))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(latest).toMatchObject({
|
||||
hostPlatform: args.secondPlatform,
|
||||
wslAvailable: args.secondAvailable,
|
||||
wslDistros: args.secondDistros
|
||||
})
|
||||
expect(wslIsAvailable).toHaveBeenCalledTimes(2)
|
||||
expect(runtimeGetStatus).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('changes ownership for same-id re-pair and reconnect generations', () => {
|
||||
const environment = { id: 'paired-a', createdAt: 1, pairingRevision: 2 }
|
||||
const base = {
|
||||
activeRuntimeEnvironmentId: null,
|
||||
isWebClient: true,
|
||||
runtimeEnvironments: [environment]
|
||||
}
|
||||
const first = resolveWindowsTerminalCapabilityOwnerKey({
|
||||
...base,
|
||||
runtimeStatusByEnvironmentId: new Map([['paired-a', { connectionGeneration: 1 }]])
|
||||
})
|
||||
const repaired = resolveWindowsTerminalCapabilityOwnerKey({
|
||||
...base,
|
||||
runtimeEnvironments: [{ ...environment, pairingRevision: 3 }],
|
||||
runtimeStatusByEnvironmentId: new Map([['paired-a', { connectionGeneration: 1 }]])
|
||||
})
|
||||
const reconnected = resolveWindowsTerminalCapabilityOwnerKey({
|
||||
...base,
|
||||
runtimeStatusByEnvironmentId: new Map([['paired-a', { connectionGeneration: 2 }]])
|
||||
})
|
||||
|
||||
expect(new Set([first, repaired, reconnected])).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('preserves desktop-local and desktop-remote owner keys', () => {
|
||||
const runtimeEnvironments = [{ id: 'remote-a', createdAt: 1, pairingRevision: 2 }]
|
||||
|
||||
expect(
|
||||
resolveWindowsTerminalCapabilityOwnerKey({
|
||||
activeRuntimeEnvironmentId: null,
|
||||
isWebClient: false,
|
||||
runtimeEnvironments
|
||||
})
|
||||
).toBe('local')
|
||||
expect(
|
||||
resolveWindowsTerminalCapabilityOwnerKey({
|
||||
activeRuntimeEnvironmentId: 'remote-a',
|
||||
isWebClient: false,
|
||||
runtimeEnvironments
|
||||
})
|
||||
).toBe('runtime:remote-a')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getWindowsTerminalCapabilityOwnerKey } from '@/lib/windows-terminal-capabilities'
|
||||
import { isWebClientLocation } from '@/lib/web-client-location'
|
||||
|
||||
type RuntimeCapabilityOwnerStatus = {
|
||||
connectionGeneration?: number
|
||||
}
|
||||
|
||||
type RuntimeCapabilityOwnerEnvironment = Pick<
|
||||
PublicKnownRuntimeEnvironment,
|
||||
'id' | 'createdAt' | 'pairingRevision'
|
||||
>
|
||||
|
||||
export function resolveWindowsTerminalCapabilityOwnerKey(args: {
|
||||
activeRuntimeEnvironmentId?: string | null
|
||||
isWebClient: boolean
|
||||
runtimeEnvironments: readonly RuntimeCapabilityOwnerEnvironment[]
|
||||
runtimeStatusByEnvironmentId?: ReadonlyMap<string, RuntimeCapabilityOwnerStatus>
|
||||
sshConnectionId?: string | null
|
||||
}): string {
|
||||
const activeEnvironmentId = args.activeRuntimeEnvironmentId?.trim() || null
|
||||
const environment = args.isWebClient ? (args.runtimeEnvironments[0] ?? null) : null
|
||||
const ownerKey = getWindowsTerminalCapabilityOwnerKey(
|
||||
environment?.id ?? activeEnvironmentId,
|
||||
args.sshConnectionId
|
||||
)
|
||||
if (!environment) {
|
||||
return ownerKey
|
||||
}
|
||||
const pairingRevision = environment.pairingRevision ?? environment.createdAt
|
||||
const connectionGeneration =
|
||||
args.runtimeStatusByEnvironmentId?.get(environment.id)?.connectionGeneration ?? 0
|
||||
return `${ownerKey}:pairing:${pairingRevision}:connection:${connectionGeneration}`
|
||||
}
|
||||
|
||||
export function useWindowsTerminalCapabilityOwnerKey(
|
||||
activeRuntimeEnvironmentId?: string | null,
|
||||
sshConnectionId?: string | null
|
||||
): string {
|
||||
const isWebClient = isWebClientLocation()
|
||||
return useAppStore((state) =>
|
||||
resolveWindowsTerminalCapabilityOwnerKey({
|
||||
activeRuntimeEnvironmentId,
|
||||
isWebClient,
|
||||
runtimeEnvironments: state.runtimeEnvironments ?? [],
|
||||
runtimeStatusByEnvironmentId: state.runtimeStatusByEnvironmentId,
|
||||
sshConnectionId
|
||||
})
|
||||
)
|
||||
}
|
||||
|
|
@ -7,13 +7,62 @@ import {
|
|||
getCachedWindowsTerminalCapabilities,
|
||||
getWindowsTerminalCapabilityOwnerKey,
|
||||
hasCachedWindowsTerminalCapabilities,
|
||||
isWindowsTerminalCapabilityHost,
|
||||
loadWindowsTerminalCapabilities,
|
||||
refreshWindowsTerminalCapabilities,
|
||||
resetWindowsTerminalCapabilitiesForTests,
|
||||
selectWindowsTerminalCapabilitiesForOwner,
|
||||
useLocalWindowsTerminalCapabilities,
|
||||
useWindowsTerminalCapabilities
|
||||
} from './windows-terminal-capabilities'
|
||||
|
||||
describe('Windows terminal capability host ownership', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'local Windows desktop while the platform probe loads',
|
||||
isWindowsRenderer: true,
|
||||
isWebClient: false,
|
||||
target: { kind: 'local' } as const,
|
||||
hostPlatform: null,
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
name: 'Windows desktop attached to remote Linux',
|
||||
isWindowsRenderer: true,
|
||||
isWebClient: false,
|
||||
target: { kind: 'environment', environmentId: 'linux' } as const,
|
||||
hostPlatform: 'linux' as const,
|
||||
expected: false
|
||||
},
|
||||
{
|
||||
name: 'Windows browser paired to a Linux server',
|
||||
isWindowsRenderer: true,
|
||||
isWebClient: true,
|
||||
target: { kind: 'local' } as const,
|
||||
hostPlatform: 'linux' as const,
|
||||
expected: false
|
||||
},
|
||||
{
|
||||
name: 'non-Windows browser paired to a Windows server',
|
||||
isWindowsRenderer: false,
|
||||
isWebClient: true,
|
||||
target: { kind: 'local' } as const,
|
||||
hostPlatform: 'win32' as const,
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
name: 'non-Windows desktop attached to remote Windows',
|
||||
isWindowsRenderer: false,
|
||||
isWebClient: false,
|
||||
target: { kind: 'environment', environmentId: 'windows' } as const,
|
||||
hostPlatform: 'win32' as const,
|
||||
expected: true
|
||||
}
|
||||
])('$name', ({ expected, ...args }) => {
|
||||
expect(isWindowsTerminalCapabilityHost(args)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
function stubTerminalCapabilityApi(args: {
|
||||
wslAvailable: boolean
|
||||
pwshAvailable: boolean
|
||||
|
|
@ -509,6 +558,92 @@ describe('windows terminal capabilities', () => {
|
|||
expect(detectRemoteWindowsTerminalCapabilities).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'Windows to Linux',
|
||||
firstPlatform: 'win32' as const,
|
||||
firstAvailable: true,
|
||||
firstDistros: ['Ubuntu'],
|
||||
secondPlatform: 'linux' as const,
|
||||
secondAvailable: false,
|
||||
secondDistros: []
|
||||
},
|
||||
{
|
||||
name: 'Linux to Windows',
|
||||
firstPlatform: 'linux' as const,
|
||||
firstAvailable: false,
|
||||
firstDistros: [],
|
||||
secondPlatform: 'win32' as const,
|
||||
secondAvailable: true,
|
||||
secondDistros: ['Debian']
|
||||
}
|
||||
])('re-probes the local transport when the paired owner changes: $name', async (args) => {
|
||||
const wslIsAvailable = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(args.firstAvailable)
|
||||
.mockResolvedValueOnce(args.secondAvailable)
|
||||
const wslListDistros = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(args.firstDistros)
|
||||
.mockResolvedValueOnce(args.secondDistros)
|
||||
const runtimeGetStatus = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ hostPlatform: args.firstPlatform })
|
||||
.mockResolvedValueOnce({ hostPlatform: args.secondPlatform })
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
wsl: { isAvailable: wslIsAvailable, listDistros: wslListDistros },
|
||||
pwsh: { isAvailable: vi.fn().mockResolvedValue(false) },
|
||||
gitBash: { isAvailable: vi.fn().mockResolvedValue(false) },
|
||||
runtime: { getStatus: runtimeGetStatus }
|
||||
}
|
||||
})
|
||||
let ownerKey = 'runtime:paired-a'
|
||||
let latest: ReturnType<typeof useLocalWindowsTerminalCapabilities> | null = null
|
||||
|
||||
function HookProbe(): null {
|
||||
latest = useLocalWindowsTerminalCapabilities(true, false, ownerKey)
|
||||
return null
|
||||
}
|
||||
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
hookRoots.push(root)
|
||||
|
||||
await act(async () => {
|
||||
root.render(createElement(HookProbe))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(latest).toMatchObject({
|
||||
hostPlatform: args.firstPlatform,
|
||||
wslAvailable: args.firstAvailable,
|
||||
wslDistros: args.firstDistros
|
||||
})
|
||||
|
||||
ownerKey = 'runtime:paired-b'
|
||||
await act(async () => {
|
||||
root.render(createElement(HookProbe))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(latest).toMatchObject({
|
||||
hostPlatform: args.secondPlatform,
|
||||
wslAvailable: args.secondAvailable,
|
||||
wslDistros: args.secondDistros
|
||||
})
|
||||
expect(wslIsAvailable).toHaveBeenCalledTimes(2)
|
||||
expect(runtimeGetStatus).toHaveBeenCalledTimes(2)
|
||||
expect(getCachedWindowsTerminalCapabilities('runtime:paired-a')).toMatchObject({
|
||||
hostPlatform: args.firstPlatform
|
||||
})
|
||||
expect(getCachedWindowsTerminalCapabilities('runtime:paired-b')).toMatchObject({
|
||||
hostPlatform: args.secondPlatform
|
||||
})
|
||||
})
|
||||
|
||||
it('prunes expired runtime owner capability caches', async () => {
|
||||
stubTerminalCapabilityApi({
|
||||
wslAvailable: false,
|
||||
|
|
|
|||
|
|
@ -263,6 +263,27 @@ export function useWindowsTerminalCapabilities(
|
|||
return selectWindowsTerminalCapabilitiesForOwner(state, enabled, resolvedOwnerKey)
|
||||
}
|
||||
|
||||
export function isWindowsTerminalCapabilityHost(args: {
|
||||
isWindowsRenderer: boolean
|
||||
isWebClient: boolean
|
||||
target: WindowsTerminalCapabilityLoadTarget
|
||||
hostPlatform: NodeJS.Platform | null
|
||||
}): boolean {
|
||||
return (
|
||||
args.hostPlatform === 'win32' ||
|
||||
(!args.isWebClient && args.target.kind === 'local' && args.isWindowsRenderer)
|
||||
)
|
||||
}
|
||||
|
||||
export function useLocalWindowsTerminalCapabilities(
|
||||
enabled: boolean,
|
||||
forceRefreshOnMount = false,
|
||||
ownerKey = 'local'
|
||||
): WindowsTerminalCapabilities {
|
||||
// Why: desktop-owned defaults must not follow the active remote environment.
|
||||
return useWindowsTerminalCapabilities(enabled, forceRefreshOnMount, ownerKey, { kind: 'local' })
|
||||
}
|
||||
|
||||
export function resetWindowsTerminalCapabilitiesForTests(): void {
|
||||
cachedCapabilitiesByOwnerKey.clear()
|
||||
pendingCapabilitiesByOwnerKey.clear()
|
||||
|
|
|
|||
Loading…
Reference in New Issue