mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
test: remove 170 low-value renderer tests (#37539)
This commit is contained in:
@@ -2321,10 +2321,7 @@
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/workspace-session-host-contention.test.ts",
|
||||
"assertions": [
|
||||
"keeps an SSH claimant out of the rotating runtime partition",
|
||||
"does not strand the runtime co-claimant when the SSH row is written"
|
||||
]
|
||||
"assertions": ["does not strand the runtime co-claimant when the SSH row is written"]
|
||||
},
|
||||
{
|
||||
"file": "src/shared/workspace-session-partition-owner.test.ts",
|
||||
@@ -5781,7 +5778,7 @@
|
||||
"providers": ["remote-runtime", "ssh"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "A headed macOS desktop host and separate paired Electron client exercise a real mirrored terminal link, owner-asserted host browser creation, exact host/client page identity, rendered DOM, explicit browser-tab activation, client external-browser suppression, exact host/client close convergence, and continued original-PTY liveness. Unit contracts cover OSC, WebLinksAddon, click fallback, explicit System Browser routing, failed creation, incapable runtimes, and unchanged direct SSH behavior.",
|
||||
"coverageNotes": "A headed macOS desktop host and separate paired Electron client exercise a real mirrored terminal link, owner-asserted host browser creation, exact host/client page identity, rendered DOM, explicit browser-tab activation, client external-browser suppression, exact host/client close convergence, and continued original-PTY liveness. Unit contracts cover OSC, WebLinksAddon, click fallback, explicit System Browser routing, failed creation, and unchanged direct SSH behavior. The test-value audit removed separate supplied owner/capability wrapper examples; their coverage loss is accepted.",
|
||||
"motivatingLinks": [
|
||||
"https://stablygroup.slack.com/archives/C0AK2T3JEF4/p1786567689897109",
|
||||
"https://github.com/stablyai/orca/issues/12418"
|
||||
@@ -5811,8 +5808,7 @@
|
||||
{
|
||||
"file": "src/renderer/src/lib/workspace-browser-tab-open.test.ts",
|
||||
"assertions": [
|
||||
"an asserted runtime cannot fall back to a client browser",
|
||||
"mismatched and incapable runtime owners fail closed"
|
||||
"false or rejected runtime creation does not fall back to a client browser"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10903,36 +10903,18 @@
|
||||
"src/renderer/src/lib/windows-terminal-capabilities-race.test.ts": 450,
|
||||
"src/renderer/src/lib/windows-terminal-capabilities.test.ts": 585,
|
||||
"src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts": 198,
|
||||
"src/renderer/src/lib/work-item-lookup-text.test.ts": 65,
|
||||
"src/renderer/src/lib/work-item-repo-row.test.ts": 43,
|
||||
"src/renderer/src/lib/worker-auto-close.test.ts": 387,
|
||||
"src/renderer/src/lib/workspace-activation-path-gate.test.ts": 2107,
|
||||
"src/renderer/src/lib/workspace-activation-terminal-focus.test.ts": 532,
|
||||
"src/renderer/src/lib/workspace-attachment-observed-origin.test.ts": 112,
|
||||
"src/renderer/src/lib/workspace-browser-tab-close.test.ts": 290,
|
||||
"src/renderer/src/lib/workspace-browser-tab-open-placement.test.ts": 1996,
|
||||
"src/renderer/src/lib/workspace-browser-tab-open.test.ts": 605,
|
||||
"src/renderer/src/lib/workspace-directory.test.ts": 303,
|
||||
"src/renderer/src/lib/workspace-doc-address-input.test.ts": 470,
|
||||
"src/renderer/src/lib/workspace-editor-ssh-target.test.ts": 337,
|
||||
"src/renderer/src/lib/workspace-emoji-picker-defaults.test.ts": 125,
|
||||
"src/renderer/src/lib/workspace-emoji-shortcodes.lazy.test.ts": 85,
|
||||
"src/renderer/src/lib/workspace-emoji-shortcodes.test.ts": 90,
|
||||
"src/renderer/src/lib/workspace-file-browser-open-target.test.ts": 340,
|
||||
"src/renderer/src/lib/workspace-file-drag-source-runtime.test.ts": 67,
|
||||
"src/renderer/src/lib/workspace-file-drag.test.ts": 70,
|
||||
"src/renderer/src/lib/workspace-ports/workspace-port-groups.test.ts": 46,
|
||||
"src/renderer/src/lib/workspace-ports/workspace-port-host-scoped-scan.test.ts": 529,
|
||||
"src/renderer/src/lib/workspace-ports/workspace-port-scan-debounce.test.ts": 57,
|
||||
"src/renderer/src/lib/workspace-ports/workspace-port-scan-publish.test.ts": 595,
|
||||
"src/renderer/src/lib/workspace-session-browser-history.test.ts": 419,
|
||||
"src/renderer/src/lib/workspace-session-editor-drafts.test.ts": 324,
|
||||
"src/renderer/src/lib/workspace-session-folder-host-twin.test.ts": 95,
|
||||
"src/renderer/src/lib/workspace-session-host-contention.test.ts": 265,
|
||||
"src/renderer/src/lib/workspace-session-host-emptied-fields.test.ts": 279,
|
||||
"src/renderer/src/lib/workspace-session-host-persistence.test.ts": 313,
|
||||
"src/renderer/src/lib/workspace-session-host-split.test.ts": 261,
|
||||
"src/renderer/src/lib/workspace-session-hydration-keys.test.ts": 269,
|
||||
"src/renderer/src/lib/workspace-session-liveness.test.ts": 298,
|
||||
"src/renderer/src/lib/workspace-session-patch.test.ts": 336,
|
||||
"src/renderer/src/lib/workspace-session-persistence-gate.test.ts": 314,
|
||||
|
||||
@@ -81,7 +81,6 @@ describe('a folder whose project group mixes local and SSH repos (layout-owner b
|
||||
})
|
||||
|
||||
describe('every folder kind routes where the window saves it (layout-owner build)', () => {
|
||||
// The window's side of each case runs in workspace-session-folder-host-twin.test.ts.
|
||||
it.each(FOLDER_HOST_CASES)('$name', async ({ folder, groups, expected }) => {
|
||||
const { runtime, applyLayoutCommand } = runtimeWithMixedFolder(folder, groups)
|
||||
await runtime.applyLayoutCommand(RATIOS)
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isWorkItemLookupText } from './work-item-lookup-text'
|
||||
|
||||
describe('isWorkItemLookupText', () => {
|
||||
it('detects GitHub PR and issue URLs', () => {
|
||||
expect(isWorkItemLookupText('https://github.com/stablyai/orca/pull/4900')).toBe(true)
|
||||
expect(isWorkItemLookupText('https://github.com/stablyai/orca/issues/123')).toBe(true)
|
||||
expect(isWorkItemLookupText(' https://www.github.com/stablyai/orca/pull/1 ')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects hash-number shorthand', () => {
|
||||
expect(isWorkItemLookupText('#4900')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects GitLab issue and MR URLs on any host', () => {
|
||||
expect(isWorkItemLookupText('https://gitlab.com/group/project/-/merge_requests/7')).toBe(true)
|
||||
expect(isWorkItemLookupText('https://gitlab.example.com/group/sub/project/-/issues/42')).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('detects modern /-/work_items/<iid> GitLab URLs', () => {
|
||||
expect(isWorkItemLookupText('https://gitlab.com/group/project/-/work_items/42')).toBe(true)
|
||||
expect(
|
||||
isWorkItemLookupText('https://gitlab.example.com:8443/group/sub/project/-/work_items/9')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('detects Linear issue URLs', () => {
|
||||
expect(isWorkItemLookupText('https://linear.app/acme/issue/STA-123/fix-the-bug')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects deliberate workspace names', () => {
|
||||
expect(isWorkItemLookupText('')).toBe(false)
|
||||
expect(isWorkItemLookupText(' ')).toBe(false)
|
||||
expect(isWorkItemLookupText('my-feature')).toBe(false)
|
||||
expect(isWorkItemLookupText('fix-2')).toBe(false)
|
||||
expect(isWorkItemLookupText('terminal scrollbar polish')).toBe(false)
|
||||
expect(isWorkItemLookupText('https://example.com/some/page')).toBe(false)
|
||||
expect(isWorkItemLookupText('https://linear.app/acme/project/mobile')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import { getWorkItemRepo } from './work-item-repo-row'
|
||||
|
||||
const localRow: Repo = {
|
||||
id: 'twin',
|
||||
path: '/laptop/twin',
|
||||
displayName: 'Twin',
|
||||
badgeColor: '#000',
|
||||
addedAt: 1
|
||||
}
|
||||
const sshRow: Repo = { ...localRow, path: '/remote/twin', connectionId: 'builder' }
|
||||
const runtimeRow: Repo = { ...localRow, executionHostId: 'runtime:env-1' }
|
||||
|
||||
describe('getWorkItemRepo (GitHub and GitLab items)', () => {
|
||||
it('picks the twin on the stamped host', () => {
|
||||
expect(getWorkItemRepo([sshRow, localRow], { repoId: 'twin', repoHostId: 'ssh:builder' })).toBe(
|
||||
sshRow
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a twin whose stamped host has no row', () => {
|
||||
expect(
|
||||
getWorkItemRepo([sshRow, localRow], { repoId: 'twin', repoHostId: 'runtime:gone' })
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a lone row even when the fetch stamped the source host', () => {
|
||||
expect(getWorkItemRepo([runtimeRow], { repoId: 'twin', repoHostId: 'local' })).toBe(runtimeRow)
|
||||
})
|
||||
|
||||
it('keeps the by-id row for an unstamped item', () => {
|
||||
expect(getWorkItemRepo([sshRow, localRow], { repoId: 'twin' })).toBe(localRow)
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import type { FolderWorkspace } from '../../../shared/folder-workspace-types'
|
||||
import type { ProjectGroup } from '../../../shared/project-group-types'
|
||||
import type { FolderWorkspacePathStatusReason } from '../../../shared/folder-workspace-path-status'
|
||||
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { getDefaultSettings } from '../../../shared/constants'
|
||||
import '../../../shared/constants'
|
||||
import { getFolderWorkspaceStatusRequestSnapshot } from '@/store/folder-workspaces/folder-path-status'
|
||||
import {
|
||||
makeGroup,
|
||||
@@ -99,15 +99,6 @@ describe('Cmd/Ctrl+1-9 folder-workspace path gate (#10716)', () => {
|
||||
expect(mocks.toastError).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('blocks the number shortcut when the SSH connection owning the folder is ambiguous', async () => {
|
||||
await seedFolderWorkspace({ exists: false, reason: 'ambiguous-connection' })
|
||||
|
||||
activateAndRevealWorkspace(folderWorkspaceKey(folderWorkspace.id))
|
||||
|
||||
expect(store.getState().activeWorkspaceKey).toBeNull()
|
||||
expect(mocks.toastError).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('still activates the number shortcut when the folder path is present', async () => {
|
||||
await seedFolderWorkspace({ exists: true })
|
||||
|
||||
@@ -117,58 +108,6 @@ describe('Cmd/Ctrl+1-9 folder-workspace path gate (#10716)', () => {
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['local', 'runtime:builder'] as const)(
|
||||
'keeps the selected %s host on a folder sidebar reveal',
|
||||
async (executionHostId) => {
|
||||
await seedFolderWorkspace({ exists: true })
|
||||
store.setState({
|
||||
folderWorkspaces: [
|
||||
{
|
||||
...folderWorkspace,
|
||||
executionHostId: executionHostId === 'local' ? 'runtime:builder' : 'local'
|
||||
},
|
||||
{ ...folderWorkspace, executionHostId }
|
||||
]
|
||||
})
|
||||
|
||||
activateAndRevealWorkspace(folderWorkspaceKey(folderWorkspace.id), {
|
||||
executionHostId,
|
||||
providesInitialSurface: true
|
||||
})
|
||||
|
||||
expect(store.getState().pendingRevealWorktree).toMatchObject({
|
||||
worktreeId: folderWorkspaceKey(folderWorkspace.id),
|
||||
executionHostId
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['local', null],
|
||||
['ssh:builder', 'builder']
|
||||
] as const)(
|
||||
'reveals the %s catalog owner when an unrelated server is focused',
|
||||
async (executionHostId, connectionId) => {
|
||||
await seedFolderWorkspace({ exists: true })
|
||||
store.setState({
|
||||
projectGroups: [{ ...projectGroup, connectionId }],
|
||||
settings: {
|
||||
...getDefaultSettings('/tmp'),
|
||||
activeRuntimeEnvironmentId: 'focused-server'
|
||||
}
|
||||
})
|
||||
|
||||
activateAndRevealWorkspace(folderWorkspaceKey(folderWorkspace.id), {
|
||||
providesInitialSurface: true
|
||||
})
|
||||
|
||||
expect(store.getState().pendingRevealWorktree).toMatchObject({
|
||||
worktreeId: folderWorkspaceKey(folderWorkspace.id),
|
||||
executionHostId
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['local', 'runtime:builder'] as const)(
|
||||
'checks the selected %s folder when the same id is active on another host',
|
||||
async (executionHostId) => {
|
||||
|
||||
@@ -229,9 +229,4 @@ describe('workspace activation focus', () => {
|
||||
flushFrame()
|
||||
expect(mocks.focus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('declines a failed activation', () => {
|
||||
expect(queueWorkspaceActivationTerminalFocus('wt-1', false)).toBe(false)
|
||||
expect(mocks.subscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { makeTerminalTab } from '../store/slices/worktrees-slice-test-fixtures'
|
||||
import { singlePaneLayoutSnapshot } from '../store/slices/terminal-helpers'
|
||||
import { makePaneKey } from '../../../shared/stable-pane-id'
|
||||
import {
|
||||
getWorkspaceAttachmentObservedOrigin,
|
||||
getWorkspaceAttachmentTerminalOrigin
|
||||
} from './workspace-attachment-observed-origin'
|
||||
|
||||
const workspaceId = 'repo::/feature'
|
||||
const leaf = '11111111-1111-4111-8111-111111111111'
|
||||
const otherLeaf = '22222222-2222-4222-8222-222222222222'
|
||||
const context = { tabId: 'tab', paneKey: makePaneKey('tab', leaf), ptyId: 'pty' }
|
||||
function state(): Pick<
|
||||
AppState,
|
||||
'tabsByWorktree' | 'terminalLayoutsByTabId' | 'agentStatusByPaneKey'
|
||||
> {
|
||||
return {
|
||||
tabsByWorktree: {
|
||||
[workspaceId]: [makeTerminalTab({ id: 'tab', worktreeId: workspaceId, ptyId: 'pty' })]
|
||||
},
|
||||
terminalLayoutsByTabId: { tab: singlePaneLayoutSnapshot(leaf, 'pty') },
|
||||
agentStatusByPaneKey: {
|
||||
[context.paneKey]: {
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
updatedAt: 1,
|
||||
stateStartedAt: 1,
|
||||
paneKey: context.paneKey,
|
||||
stateHistory: [],
|
||||
worktreeId: workspaceId,
|
||||
terminalHandle: 'pty',
|
||||
agentType: 'codex',
|
||||
providerSession: { id: 'provider-session', key: 'session_id' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
describe('reference evidence terminal identity', () => {
|
||||
it('records the currently bound provider session', () => {
|
||||
expect(getWorkspaceAttachmentTerminalOrigin(state(), workspaceId, context, 'local')).toEqual(
|
||||
expect.objectContaining({
|
||||
tabId: 'tab',
|
||||
paneKey: context.paneKey,
|
||||
hostId: 'local',
|
||||
sessionId: 'provider-session',
|
||||
agent: 'codex',
|
||||
kind: 'observed'
|
||||
})
|
||||
)
|
||||
})
|
||||
it('rejects removed leaves even if a stale PTY mapping is retained', () => {
|
||||
const current = state()
|
||||
current.terminalLayoutsByTabId.tab = {
|
||||
...singlePaneLayoutSnapshot(otherLeaf, 'another-pty'),
|
||||
ptyIdsByLeafId: { [leaf]: 'pty', [otherLeaf]: 'another-pty' }
|
||||
}
|
||||
expect(
|
||||
getWorkspaceAttachmentTerminalOrigin(current, workspaceId, context, 'local')
|
||||
).toBeUndefined()
|
||||
})
|
||||
it('rejects a PTY rebound in the same pane', () => {
|
||||
const current = state()
|
||||
current.terminalLayoutsByTabId.tab = singlePaneLayoutSnapshot(leaf, 'replacement-pty')
|
||||
expect(
|
||||
getWorkspaceAttachmentTerminalOrigin(current, workspaceId, context, 'local')
|
||||
).toBeUndefined()
|
||||
})
|
||||
it('retains terminal evidence without attributing a session from a different PTY', () => {
|
||||
const current = state()
|
||||
const entry = current.agentStatusByPaneKey[context.paneKey]
|
||||
if (!entry) {
|
||||
throw new Error('Expected source session')
|
||||
}
|
||||
entry.terminalHandle = 'previous-pty'
|
||||
const origin = getWorkspaceAttachmentTerminalOrigin(current, workspaceId, context, 'local')
|
||||
expect(origin).toMatchObject({ tabId: 'tab', kind: 'observed' })
|
||||
expect(origin?.sessionId).toBeUndefined()
|
||||
expect(origin?.agent).toBeUndefined()
|
||||
})
|
||||
it('does not record an observation before a PTY is running', () => {
|
||||
const current = state()
|
||||
current.terminalLayoutsByTabId.tab = singlePaneLayoutSnapshot(leaf)
|
||||
current.tabsByWorktree[workspaceId][0].ptyId = null
|
||||
current.agentStatusByPaneKey = {}
|
||||
expect(
|
||||
getWorkspaceAttachmentTerminalOrigin(
|
||||
current,
|
||||
workspaceId,
|
||||
{ tabId: 'tab', paneKey: context.paneKey },
|
||||
'local'
|
||||
)
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reference evidence native chat identity', () => {
|
||||
it('records the chat tab and its session without a terminal pane', () => {
|
||||
const chat = {
|
||||
...state(),
|
||||
unifiedTabsByWorktree: {
|
||||
[workspaceId]: [
|
||||
{
|
||||
id: 'chat-tab',
|
||||
entityId: 'chat-session',
|
||||
groupId: 'group',
|
||||
worktreeId: workspaceId,
|
||||
contentType: 'agent-session' as const,
|
||||
agentSessionAgent: 'claude' as const,
|
||||
label: 'Plan refactor',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
expect(
|
||||
getWorkspaceAttachmentObservedOrigin(
|
||||
chat,
|
||||
workspaceId,
|
||||
{ kind: 'agent-session', tabId: 'chat-tab' },
|
||||
'local'
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'observed',
|
||||
tabId: 'chat-tab',
|
||||
hostId: 'local',
|
||||
label: 'Plan refactor',
|
||||
agent: 'claude',
|
||||
sessionId: 'chat-session'
|
||||
})
|
||||
|
||||
const renamed = {
|
||||
...chat,
|
||||
unifiedTabsByWorktree: {
|
||||
[workspaceId]: chat.unifiedTabsByWorktree[workspaceId].map((tab) => ({
|
||||
...tab,
|
||||
customLabel: 'Pinned chat'
|
||||
}))
|
||||
}
|
||||
}
|
||||
expect(
|
||||
getWorkspaceAttachmentObservedOrigin(
|
||||
renamed,
|
||||
workspaceId,
|
||||
{ kind: 'agent-session', tabId: 'chat-tab' },
|
||||
'local'
|
||||
)
|
||||
).toMatchObject({ label: 'Pinned chat' })
|
||||
})
|
||||
})
|
||||
@@ -1,79 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state: Record<string, unknown> = {}
|
||||
return {
|
||||
state,
|
||||
closeOnHosts: vi.fn(),
|
||||
destroyWorkspaceWebviews: vi.fn(),
|
||||
closeBrowserTab: vi.fn(),
|
||||
closeUnifiedTab: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } }))
|
||||
vi.mock('@/store/slices/browser-webview-cleanup', () => ({
|
||||
destroyWorkspaceWebviews: mocks.destroyWorkspaceWebviews
|
||||
}))
|
||||
vi.mock('@/runtime/browser-workspace-tab-close', () => ({
|
||||
closeBrowserWorkspaceTabOnHosts: mocks.closeOnHosts
|
||||
}))
|
||||
|
||||
import { closeWorkspaceBrowserTab } from './workspace-browser-tab-close'
|
||||
|
||||
const WORKTREE_ID = 'repo::/w'
|
||||
type OwnerHost = 'local' | 'runtime:env-1'
|
||||
|
||||
function stateOwnedBy(hostIds: OwnerHost[], pages: { id: string }[] = []) {
|
||||
return {
|
||||
repos: hostIds.map((hostId) => ({ id: 'repo', executionHostId: hostId })),
|
||||
worktreesByRepo: {
|
||||
repo: hostIds.map((hostId) => ({
|
||||
id: WORKTREE_ID,
|
||||
repoId: 'repo',
|
||||
hostId,
|
||||
...(hostId === 'local' ? {} : { runtimeOwnerEnvironmentId: 'env-1' })
|
||||
}))
|
||||
},
|
||||
browserPagesByWorkspace: { 'browser-ws': pages },
|
||||
closeBrowserTab: mocks.closeBrowserTab,
|
||||
closeUnifiedTab: mocks.closeUnifiedTab
|
||||
}
|
||||
}
|
||||
|
||||
describe('closeWorkspaceBrowserTab owner', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.closeOnHosts.mockReturnValue({
|
||||
hostEnvironmentIds: ['env-1'],
|
||||
closesLocally: false,
|
||||
removesVisibleTab: true
|
||||
})
|
||||
})
|
||||
|
||||
it("closes a pageless mirror on its runtime owner's host", () => {
|
||||
mocks.state = stateOwnedBy(['runtime:env-1'])
|
||||
|
||||
closeWorkspaceBrowserTab(WORKTREE_ID, 'browser-ws', 'visible-tab')
|
||||
|
||||
expect(mocks.closeOnHosts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ focusedEnvironmentId: 'env-1', visibleTabId: 'visible-tab' })
|
||||
)
|
||||
expect(mocks.closeUnifiedTab).toHaveBeenCalledWith('visible-tab', undefined)
|
||||
})
|
||||
|
||||
it('refuses a pageless mirror of a local twin: no host close, no local teardown', () => {
|
||||
mocks.state = stateOwnedBy(['local', 'runtime:env-1'])
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const plan = closeWorkspaceBrowserTab(WORKTREE_ID, 'browser-ws', 'visible-tab')
|
||||
|
||||
expect(plan).toEqual({ hostEnvironmentIds: [], closesLocally: false, removesVisibleTab: false })
|
||||
expect(mocks.closeOnHosts).not.toHaveBeenCalled()
|
||||
expect(mocks.closeBrowserTab).not.toHaveBeenCalled()
|
||||
expect(mocks.closeUnifiedTab).not.toHaveBeenCalled()
|
||||
expect(mocks.destroyWorkspaceWebviews).not.toHaveBeenCalled()
|
||||
expect(warn.mock.calls[0]).toContain(WORKTREE_ID)
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -1,18 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
toRuntimeExecutionHostId,
|
||||
toSshExecutionHostId
|
||||
} from '../../../shared/execution-host'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
|
||||
import { toRuntimeExecutionHostId } from '../../../shared/execution-host'
|
||||
import '../../../shared/constants'
|
||||
import { BROWSER_SCREENCAST_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
|
||||
import { BROWSER_CLIENT_HOSTING_RUNTIME_CAPABILITIES } from '../../../shared/browser-client-hosting-eligibility'
|
||||
import * as clientCreationActionPolicy from './client-creation-action-policy'
|
||||
import {
|
||||
canOpenWorkspaceBrowserTabOnRuntime,
|
||||
canOpenWorkspaceBrowserTabOnSsh,
|
||||
openWorkspaceBrowserTab
|
||||
} from './workspace-browser-tab-open'
|
||||
import '../../../shared/browser-client-hosting-eligibility'
|
||||
import './client-creation-action-policy'
|
||||
import { openWorkspaceBrowserTab } from './workspace-browser-tab-open'
|
||||
|
||||
function createBrowserTabMock() {
|
||||
return vi.fn((_worktreeId: string, _url: string, _options: Record<string, unknown>) => ({
|
||||
@@ -75,345 +67,6 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('openWorkspaceBrowserTab', () => {
|
||||
it('opens client-owned searches with a safe title and the desktop default profile', async () => {
|
||||
const createBrowserTab = createBrowserTabMock()
|
||||
const sshHost = toSshExecutionHostId('ssh-target')
|
||||
mocks.state = {
|
||||
...ownerState(sshHost),
|
||||
createBrowserTab,
|
||||
settings: { browserDefaultProfileIdByHostId: { local: 'local-profile' } }
|
||||
}
|
||||
|
||||
expect(canOpenWorkspaceBrowserTabOnSsh(mocks.state as never, WORKSPACE_ID, 'ssh-target')).toBe(
|
||||
true
|
||||
)
|
||||
|
||||
await openWorkspaceBrowserTab({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
targetGroupId: 'group-1',
|
||||
url: 'https://www.google.com/search?q=private%20query',
|
||||
intent: { kind: 'search', engine: 'google' },
|
||||
expectedSshConnectionId: 'ssh-target'
|
||||
})
|
||||
|
||||
expect(createBrowserTab).toHaveBeenCalledWith(
|
||||
WORKSPACE_ID,
|
||||
'https://www.google.com/search?q=private%20query',
|
||||
{
|
||||
activate: true,
|
||||
browserRuntimeEnvironmentId: null,
|
||||
executionHostId: 'ssh:ssh-target',
|
||||
focusAddressBar: false,
|
||||
sessionProfileId: 'local-profile',
|
||||
targetGroupId: 'group-1',
|
||||
title: 'Search Google'
|
||||
}
|
||||
)
|
||||
expect(mocks.createRemote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates a client tab without activating it when requested', async () => {
|
||||
const createBrowserTab = createBrowserTabMock()
|
||||
const sshHost = toSshExecutionHostId('ssh-target')
|
||||
mocks.state = {
|
||||
...ownerState(sshHost),
|
||||
createBrowserTab
|
||||
}
|
||||
|
||||
await openWorkspaceBrowserTab({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
url: 'https://github.com/acme/orca/pull/456',
|
||||
afterTabId: 'source-unified-tab',
|
||||
intent: { kind: 'url' },
|
||||
focusOnCreate: false,
|
||||
selectWorktree: false
|
||||
})
|
||||
|
||||
expect(createBrowserTab).toHaveBeenCalledWith(
|
||||
WORKSPACE_ID,
|
||||
'https://github.com/acme/orca/pull/456',
|
||||
expect.objectContaining({ activate: false, afterTabId: 'source-unified-tab' })
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed when the asserted SSH browser route is opted out or belongs to another host', async () => {
|
||||
const sshHost = toSshExecutionHostId('ssh-target')
|
||||
mocks.state = {
|
||||
...ownerState(sshHost),
|
||||
settings: { browserSshWorkspaceRoutingDisabledTargetIds: ['ssh-target'] },
|
||||
createBrowserTab: createBrowserTabMock()
|
||||
}
|
||||
|
||||
expect(canOpenWorkspaceBrowserTabOnSsh(mocks.state as never, WORKSPACE_ID, 'ssh-target')).toBe(
|
||||
false
|
||||
)
|
||||
expect(canOpenWorkspaceBrowserTabOnSsh(mocks.state as never, WORKSPACE_ID, 'ssh-other')).toBe(
|
||||
false
|
||||
)
|
||||
await expect(
|
||||
openWorkspaceBrowserTab({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
url: 'http://0.0.0.0:8000/',
|
||||
intent: { kind: 'url' },
|
||||
expectedSshConnectionId: 'ssh-target'
|
||||
})
|
||||
).rejects.toThrow('Unable to open URL.')
|
||||
expect(mocks.state.createBrowserTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces the opening workspace and titles runtime-owned URL tabs by target', async () => {
|
||||
const createBrowserTab = createBrowserTabMock()
|
||||
mocks.state = {
|
||||
...ownerState(toRuntimeExecutionHostId('hub-a')),
|
||||
...browserCapableRuntime('hub-a'),
|
||||
createBrowserTab
|
||||
}
|
||||
|
||||
expect(canOpenWorkspaceBrowserTabOnRuntime(mocks.state as never, WORKSPACE_ID, 'hub-a')).toBe(
|
||||
true
|
||||
)
|
||||
|
||||
await openWorkspaceBrowserTab({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
url: 'https://example.com/docs?token=secret',
|
||||
targetGroupId: 'group-1',
|
||||
intent: { kind: 'url' }
|
||||
})
|
||||
|
||||
expect(mocks.createRemote).toHaveBeenCalledWith({
|
||||
worktreeId: WORKSPACE_ID,
|
||||
environmentId: 'hub-a',
|
||||
url: 'https://example.com/docs?token=secret',
|
||||
targetGroupId: 'group-1',
|
||||
clientTargetGroupId: 'group-1',
|
||||
selectWorktree: true,
|
||||
stagedTitle: 'example.com/docs',
|
||||
stagedFocusAddressBar: false,
|
||||
failureLogMode: 'operation-only'
|
||||
})
|
||||
expect(createBrowserTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stages a runtime tab without selecting the worktree or focusing the browser', async () => {
|
||||
mocks.state = {
|
||||
...ownerState(toRuntimeExecutionHostId('hub-a')),
|
||||
...browserCapableRuntime('hub-a'),
|
||||
createBrowserTab: createBrowserTabMock()
|
||||
}
|
||||
|
||||
await openWorkspaceBrowserTab({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
url: 'https://gitlab.com/acme/orca/-/merge_requests/77',
|
||||
afterTabId: 'source-unified-tab',
|
||||
intent: { kind: 'url' },
|
||||
focusOnCreate: false,
|
||||
selectWorktree: false
|
||||
})
|
||||
|
||||
expect(mocks.createRemote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
clientAfterTabId: 'source-unified-tab',
|
||||
focusOnCreate: false,
|
||||
selectWorktree: false,
|
||||
url: 'https://gitlab.com/acme/orca/-/merge_requests/77'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('waits for host registration before reconciling an asserted runtime link', async () => {
|
||||
const createBrowserTab = createBrowserTabMock()
|
||||
mocks.state = {
|
||||
...ownerState(toRuntimeExecutionHostId('hub-a')),
|
||||
...browserCapableRuntime('hub-a'),
|
||||
createBrowserTab
|
||||
}
|
||||
await openWorkspaceBrowserTab({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
url: 'https://example.com/pinned',
|
||||
intent: { kind: 'url' },
|
||||
expectedRuntimeEnvironmentId: 'hub-a'
|
||||
})
|
||||
|
||||
expect(mocks.createRemote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
environmentId: 'hub-a',
|
||||
waitForRegistration: true,
|
||||
worktreeId: WORKSPACE_ID
|
||||
})
|
||||
)
|
||||
expect(createBrowserTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an asserted runtime link on its owner when policy selects the local client', async () => {
|
||||
const createBrowserTab = createBrowserTabMock()
|
||||
mocks.state = {
|
||||
...ownerState(toRuntimeExecutionHostId('hub-a')),
|
||||
...browserCapableRuntime('hub-a'),
|
||||
createBrowserTab
|
||||
}
|
||||
const policySpy = vi
|
||||
.spyOn(clientCreationActionPolicy, 'getClientCreationActionPolicy')
|
||||
.mockReturnValue({
|
||||
'managed-browser': { state: 'enabled', provider: 'local-client' },
|
||||
'mobile-emulator': { state: 'enabled', provider: 'local-client' }
|
||||
})
|
||||
|
||||
try {
|
||||
await openWorkspaceBrowserTab({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
url: 'https://example.com/pinned',
|
||||
intent: { kind: 'url' },
|
||||
expectedRuntimeEnvironmentId: 'hub-a'
|
||||
})
|
||||
|
||||
expect(mocks.createRemote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
environmentId: 'hub-a',
|
||||
waitForRegistration: true,
|
||||
worktreeId: WORKSPACE_ID
|
||||
})
|
||||
)
|
||||
expect(createBrowserTab).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
policySpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('forwards an explicit server placement for owner-pinned remote panes', async () => {
|
||||
mocks.state = {
|
||||
...ownerState(toRuntimeExecutionHostId('hub-a')),
|
||||
...browserCapableRuntime('hub-a'),
|
||||
createBrowserTab: createBrowserTabMock()
|
||||
}
|
||||
|
||||
await openWorkspaceBrowserTab({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
url: 'https://example.com/pinned',
|
||||
intent: { kind: 'url' },
|
||||
expectedRuntimeEnvironmentId: 'hub-a',
|
||||
placementPreference: 'server'
|
||||
})
|
||||
|
||||
expect(mocks.createRemote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ placementPreference: 'server' })
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed when the workspace route swaps away from the pane runtime before opening', async () => {
|
||||
mocks.state = {
|
||||
...ownerState(toRuntimeExecutionHostId('hub-a')),
|
||||
...browserCapableRuntime('hub-a'),
|
||||
createBrowserTab: createBrowserTabMock()
|
||||
}
|
||||
const paneRuntimeEnvironmentId = 'hub-a'
|
||||
|
||||
mocks.state = {
|
||||
...ownerState(toRuntimeExecutionHostId('hub-b')),
|
||||
...browserCapableRuntime('hub-b'),
|
||||
createBrowserTab: createBrowserTabMock()
|
||||
}
|
||||
|
||||
await expect(
|
||||
openWorkspaceBrowserTab({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
url: 'https://example.com/',
|
||||
intent: { kind: 'url' },
|
||||
expectedRuntimeEnvironmentId: paneRuntimeEnvironmentId
|
||||
})
|
||||
).rejects.toThrow('Unable to open URL.')
|
||||
expect(mocks.createRemote).not.toHaveBeenCalled()
|
||||
expect(mocks.state.createBrowserTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed for a blank asserted runtime owner', async () => {
|
||||
mocks.state = {
|
||||
...ownerState(toRuntimeExecutionHostId('hub-a')),
|
||||
...browserCapableRuntime('hub-a'),
|
||||
createBrowserTab: createBrowserTabMock()
|
||||
}
|
||||
|
||||
await expect(
|
||||
openWorkspaceBrowserTab({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
url: 'https://example.com/',
|
||||
intent: { kind: 'url' },
|
||||
expectedRuntimeEnvironmentId: ' '
|
||||
})
|
||||
).rejects.toThrow('Unable to open URL.')
|
||||
expect(mocks.createRemote).not.toHaveBeenCalled()
|
||||
expect(mocks.state.createBrowserTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes an asserted paired-HUB SSH owner through that runtime', async () => {
|
||||
const sshHost = toSshExecutionHostId('ssh-target')
|
||||
mocks.state = {
|
||||
...ownerState(sshHost, 'hub-a'),
|
||||
...browserCapableRuntime('hub-a'),
|
||||
createBrowserTab: createBrowserTabMock()
|
||||
}
|
||||
|
||||
await openWorkspaceBrowserTab({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
url: 'http://localhost:3000/',
|
||||
intent: { kind: 'url' },
|
||||
expectedRuntimeEnvironmentId: 'hub-a'
|
||||
})
|
||||
|
||||
expect(mocks.createRemote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ environmentId: 'hub-a', worktreeId: WORKSPACE_ID })
|
||||
)
|
||||
expect(mocks.state.createBrowserTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fall back to a client browser for an incapable asserted runtime', async () => {
|
||||
mocks.state = {
|
||||
...ownerState(toRuntimeExecutionHostId('hub-a')),
|
||||
createBrowserTab: createBrowserTabMock()
|
||||
}
|
||||
|
||||
expect(canOpenWorkspaceBrowserTabOnRuntime(mocks.state as never, WORKSPACE_ID, 'hub-a')).toBe(
|
||||
false
|
||||
)
|
||||
await expect(
|
||||
openWorkspaceBrowserTab({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
url: 'https://example.com/',
|
||||
intent: { kind: 'url' },
|
||||
expectedRuntimeEnvironmentId: 'hub-a'
|
||||
})
|
||||
).rejects.toThrow('Unable to open URL.')
|
||||
expect(mocks.createRemote).not.toHaveBeenCalled()
|
||||
expect(mocks.state.createBrowserTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['runtime', 'ssh'] as const)(
|
||||
'rejects another owner for an opted-in %s preview client provider',
|
||||
async (kind) => {
|
||||
const hostId =
|
||||
kind === 'runtime' ? toRuntimeExecutionHostId('source') : toSshExecutionHostId('source')
|
||||
const createBrowserTab = createBrowserTabMock()
|
||||
mocks.state = {
|
||||
...ownerState(hostId),
|
||||
settings: { browserSshWorkspaceRoutingEnabled: false },
|
||||
createBrowserTab
|
||||
}
|
||||
const request = {
|
||||
workspaceId: WORKSPACE_ID,
|
||||
url: 'https://example.com/',
|
||||
intent: { kind: 'url' as const },
|
||||
allowClientBrowserFallback: true
|
||||
}
|
||||
const expectedOwner = (id: string) =>
|
||||
kind === 'runtime' ? { expectedRuntimeEnvironmentId: id } : { expectedSshConnectionId: id }
|
||||
|
||||
await expect(
|
||||
openWorkspaceBrowserTab({ ...request, ...expectedOwner('other') })
|
||||
).rejects.toThrow('Unable to open URL.')
|
||||
expect(createBrowserTab).not.toHaveBeenCalled()
|
||||
expect(mocks.createRemote).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([false, true])(
|
||||
'does not fall back to a client browser when asserted runtime creation fails (client provider opt-in %s)',
|
||||
async (allowClientBrowserFallback) => {
|
||||
@@ -440,178 +93,6 @@ describe('openWorkspaceBrowserTab', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('does not route an asserted floating-terminal source to either runtime or local browser', async () => {
|
||||
const createBrowserTab = createBrowserTabMock()
|
||||
mocks.state = {
|
||||
activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID,
|
||||
activeWorkspaceExecutionHostId: toRuntimeExecutionHostId('hub-a'),
|
||||
...browserCapableRuntime('hub-a'),
|
||||
createBrowserTab
|
||||
}
|
||||
|
||||
expect(
|
||||
canOpenWorkspaceBrowserTabOnRuntime(
|
||||
mocks.state as never,
|
||||
FLOATING_TERMINAL_WORKTREE_ID,
|
||||
'hub-a'
|
||||
)
|
||||
).toBe(false)
|
||||
|
||||
await expect(
|
||||
openWorkspaceBrowserTab({
|
||||
workspaceId: FLOATING_TERMINAL_WORKTREE_ID,
|
||||
url: 'http://localhost:3000/',
|
||||
intent: { kind: 'url' },
|
||||
expectedRuntimeEnvironmentId: 'hub-a'
|
||||
})
|
||||
).rejects.toThrow('Unable to open URL.')
|
||||
expect(mocks.createRemote).not.toHaveBeenCalled()
|
||||
expect(createBrowserTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps runtime browser actions unavailable in a paired-web floating workspace', async () => {
|
||||
mocks.pairedWeb = true
|
||||
mocks.state = {
|
||||
activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID,
|
||||
activeWorkspaceExecutionHostId: toRuntimeExecutionHostId('hub-a'),
|
||||
...browserCapableRuntime('hub-a'),
|
||||
createBrowserTab: createBrowserTabMock()
|
||||
}
|
||||
|
||||
expect(
|
||||
canOpenWorkspaceBrowserTabOnRuntime(
|
||||
mocks.state as never,
|
||||
FLOATING_TERMINAL_WORKTREE_ID,
|
||||
'hub-a'
|
||||
)
|
||||
).toBe(false)
|
||||
await expect(
|
||||
openWorkspaceBrowserTab({
|
||||
workspaceId: FLOATING_TERMINAL_WORKTREE_ID,
|
||||
url: 'http://localhost:3000/',
|
||||
intent: { kind: 'url' },
|
||||
expectedRuntimeEnvironmentId: 'hub-a'
|
||||
})
|
||||
).rejects.toThrow('Unable to open URL.')
|
||||
expect(mocks.createRemote).not.toHaveBeenCalled()
|
||||
expect(mocks.state.createBrowserTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Two dev servers on one host must not share a tab title.
|
||||
it('keeps the port in local-dev tab titles', async () => {
|
||||
const createBrowserTab = createBrowserTabMock()
|
||||
mocks.state = {
|
||||
...ownerState(LOCAL_EXECUTION_HOST_ID),
|
||||
createBrowserTab
|
||||
}
|
||||
|
||||
for (const url of ['http://localhost:3000/', 'http://localhost:5173/app']) {
|
||||
await openWorkspaceBrowserTab({ workspaceId: WORKSPACE_ID, url, intent: { kind: 'url' } })
|
||||
}
|
||||
|
||||
expect(createBrowserTab.mock.calls.map((call) => call[2].title)).toEqual([
|
||||
'localhost:3000',
|
||||
'localhost:5173/app'
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the desktop provider when the runtime cannot stream browsers', async () => {
|
||||
const createBrowserTab = createBrowserTabMock()
|
||||
const sshHost = toSshExecutionHostId('ssh-target')
|
||||
mocks.state = {
|
||||
...ownerState(sshHost, 'hub-a'),
|
||||
createBrowserTab,
|
||||
settings: { browserDefaultProfileIdByHostId: { local: 'local-profile' } }
|
||||
}
|
||||
mocks.createRemote.mockResolvedValue(false)
|
||||
|
||||
await openWorkspaceBrowserTab({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
url: 'https://www.google.com/search?q=hooks',
|
||||
intent: { kind: 'search', engine: 'google' }
|
||||
})
|
||||
|
||||
expect(mocks.createRemote).not.toHaveBeenCalled()
|
||||
expect(createBrowserTab).toHaveBeenCalledWith(
|
||||
WORKSPACE_ID,
|
||||
'https://www.google.com/search?q=hooks',
|
||||
expect.objectContaining({ sessionProfileId: 'local-profile', title: 'Search Google' })
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['desktop hosting is on', true],
|
||||
['the setting asks for server rendering', false]
|
||||
])(
|
||||
'opens an asserted runtime link through desktop hosting without screencast support when %s',
|
||||
async (_label, enabled) => {
|
||||
mocks.state = {
|
||||
...ownerState(toRuntimeExecutionHostId('hub-a')),
|
||||
settings: { browserClientHostedRemoteEnabled: enabled },
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
['hub-a', { status: { capabilities: [...BROWSER_CLIENT_HOSTING_RUNTIME_CAPABILITIES] } }]
|
||||
]),
|
||||
createBrowserTab: vi.fn()
|
||||
}
|
||||
|
||||
expect(canOpenWorkspaceBrowserTabOnRuntime(mocks.getState(), WORKSPACE_ID, 'hub-a')).toBe(
|
||||
true
|
||||
)
|
||||
await openWorkspaceBrowserTab({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
url: 'http://localhost:3000/',
|
||||
intent: { kind: 'url' },
|
||||
expectedRuntimeEnvironmentId: 'hub-a'
|
||||
})
|
||||
|
||||
expect(mocks.createRemote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ environmentId: 'hub-a', waitForRegistration: true })
|
||||
)
|
||||
expect(mocks.state.createBrowserTab).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['an older peer lacks capabilities', undefined, true, false],
|
||||
[
|
||||
'a peer lacks a hosting capability',
|
||||
BROWSER_CLIENT_HOSTING_RUNTIME_CAPABILITIES.slice(1),
|
||||
true,
|
||||
false
|
||||
],
|
||||
[
|
||||
'the viewing client is paired web',
|
||||
[...BROWSER_CLIENT_HOSTING_RUNTIME_CAPABILITIES],
|
||||
true,
|
||||
true
|
||||
]
|
||||
])(
|
||||
'refuses an asserted runtime link when %s',
|
||||
async (_label, capabilities, enabled, pairedWeb) => {
|
||||
mocks.pairedWeb = pairedWeb
|
||||
mocks.state = {
|
||||
...ownerState(toRuntimeExecutionHostId('hub-a')),
|
||||
settings: { browserClientHostedRemoteEnabled: enabled },
|
||||
runtimeStatusByEnvironmentId: new Map([['hub-a', { status: { capabilities } }]]),
|
||||
createBrowserTab: vi.fn()
|
||||
}
|
||||
|
||||
expect(canOpenWorkspaceBrowserTabOnRuntime(mocks.getState(), WORKSPACE_ID, 'hub-a')).toBe(
|
||||
false
|
||||
)
|
||||
await expect(
|
||||
openWorkspaceBrowserTab({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
url: 'http://localhost:3000/',
|
||||
intent: { kind: 'url' },
|
||||
expectedRuntimeEnvironmentId: 'hub-a'
|
||||
})
|
||||
).rejects.toThrow('Unable to open URL.')
|
||||
expect(mocks.createRemote).not.toHaveBeenCalled()
|
||||
expect(mocks.state.createBrowserTab).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not fall back locally when the runtime create outcome is unknown', async () => {
|
||||
const createBrowserTab = createBrowserTabMock()
|
||||
mocks.state = {
|
||||
@@ -675,114 +156,3 @@ describe('openWorkspaceBrowserTab', () => {
|
||||
expect(mocks.state.createBrowserTab).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('links from a managed server reached over SSH', () => {
|
||||
function sshDeployedRuntimeState(
|
||||
orcadDeployment: Record<string, unknown>,
|
||||
settings: Record<string, unknown> = {}
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...ownerState(toRuntimeExecutionHostId('orcad-env')),
|
||||
runtimeEnvironments: [{ id: 'orcad-env', orcadDeployment }],
|
||||
// Headless orcad advertises no screencast backend.
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
['orcad-env', { status: { capabilities: [] }, checkedAt: 1 }]
|
||||
]),
|
||||
settings,
|
||||
createBrowserTab: createBrowserTabMock()
|
||||
}
|
||||
}
|
||||
const sshDeployment = {
|
||||
kind: 'ssh',
|
||||
sshTargetId: 'ssh-target',
|
||||
sshTargetGeneration: 2,
|
||||
localPort: 41000,
|
||||
remotePort: 41001
|
||||
}
|
||||
|
||||
it('opens the link in an SSH-routed desktop tab the runtime workspace still owns', async () => {
|
||||
mocks.state = sshDeployedRuntimeState(sshDeployment)
|
||||
|
||||
expect(canOpenWorkspaceBrowserTabOnRuntime(mocks.getState(), WORKSPACE_ID, 'orcad-env')).toBe(
|
||||
true
|
||||
)
|
||||
await openWorkspaceBrowserTab({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
url: 'http://localhost:3000/',
|
||||
intent: { kind: 'url' },
|
||||
expectedRuntimeEnvironmentId: 'orcad-env'
|
||||
})
|
||||
|
||||
expect(mocks.state.createBrowserTab).toHaveBeenCalledWith(
|
||||
WORKSPACE_ID,
|
||||
'http://localhost:3000/',
|
||||
expect.objectContaining({
|
||||
browserRuntimeEnvironmentId: null,
|
||||
executionHostId: toRuntimeExecutionHostId('orcad-env')
|
||||
})
|
||||
)
|
||||
expect(mocks.createRemote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a screencast-capable deployment on its runtime browser', async () => {
|
||||
mocks.state = {
|
||||
...sshDeployedRuntimeState(sshDeployment),
|
||||
...browserCapableRuntime('orcad-env')
|
||||
}
|
||||
|
||||
await openWorkspaceBrowserTab({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
url: 'http://localhost:3000/',
|
||||
intent: { kind: 'url' },
|
||||
expectedRuntimeEnvironmentId: 'orcad-env'
|
||||
})
|
||||
|
||||
expect(mocks.createRemote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ environmentId: 'orcad-env', waitForRegistration: true })
|
||||
)
|
||||
expect(mocks.state.createBrowserTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['SSH browser routing is off', sshDeployment, { browserSshWorkspaceRoutingEnabled: false }],
|
||||
[
|
||||
'the user browses this host from this device',
|
||||
sshDeployment,
|
||||
{ browserSshWorkspaceRoutingDisabledTargetIds: ['ssh-target'] }
|
||||
],
|
||||
['the server is a WSL deployment', { kind: 'wsl', distro: 'Ubuntu' }, {}]
|
||||
])('offers only the system browser when %s', async (_label, deployment, settings) => {
|
||||
mocks.state = sshDeployedRuntimeState(deployment, settings)
|
||||
|
||||
expect(canOpenWorkspaceBrowserTabOnRuntime(mocks.getState(), WORKSPACE_ID, 'orcad-env')).toBe(
|
||||
false
|
||||
)
|
||||
await expect(
|
||||
openWorkspaceBrowserTab({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
url: 'http://localhost:3000/',
|
||||
intent: { kind: 'url' },
|
||||
expectedRuntimeEnvironmentId: 'orcad-env'
|
||||
})
|
||||
).rejects.toThrow('Unable to open URL.')
|
||||
expect(mocks.state.createBrowserTab).not.toHaveBeenCalled()
|
||||
expect(mocks.createRemote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not lend the SSH route to a link asserting another runtime', () => {
|
||||
mocks.state = sshDeployedRuntimeState(sshDeployment)
|
||||
|
||||
expect(canOpenWorkspaceBrowserTabOnRuntime(mocks.getState(), WORKSPACE_ID, 'other-env')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('offers no in-app browser from the paired web client', () => {
|
||||
mocks.pairedWeb = true
|
||||
mocks.state = sshDeployedRuntimeState(sshDeployment)
|
||||
|
||||
expect(canOpenWorkspaceBrowserTabOnRuntime(mocks.getState(), WORKSPACE_ID, 'orcad-env')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import {
|
||||
detectedListingFixture,
|
||||
worktreeFixture
|
||||
} from '../components/native-chat/native-chat-workspace-test-fixtures'
|
||||
import { makeFolderWorkspace } from '@/store/slices/worktrees-slice-test-fixtures'
|
||||
import { resolveWorkspaceDirectory, type WorkspaceDirectoryState } from './workspace-directory'
|
||||
|
||||
function catalog(overrides: Partial<WorkspaceDirectoryState> = {}): WorkspaceDirectoryState {
|
||||
return {
|
||||
detectedWorktreesByRepo: {},
|
||||
floatingWorkspacePath: null,
|
||||
folderWorkspaces: [],
|
||||
worktreesByRepo: { repo: [worktreeFixture('wt-1', '/visible', { hostId: 'local' })] },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveWorkspaceDirectory', () => {
|
||||
it('prefers a visible catalog row and resolves a detected-only row from the same snapshot', () => {
|
||||
const state = catalog({
|
||||
detectedWorktreesByRepo: {
|
||||
repo: detectedListingFixture([
|
||||
worktreeFixture('wt-1', '/detected-shadow', { hostId: 'local' }),
|
||||
worktreeFixture('wt-detected', '/detected', { hostId: 'ssh:box-1' })
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
expect(resolveWorkspaceDirectory(state, 'wt-1')).toBe('/visible')
|
||||
expect(resolveWorkspaceDirectory(state, 'wt-detected', 'ssh:box-1')).toBe('/detected')
|
||||
expect(resolveWorkspaceDirectory(state, 'wt-detected', 'local')).toBeNull()
|
||||
})
|
||||
|
||||
it('applies the same host rules when the caller passes no detected rows', () => {
|
||||
const folderKey = folderWorkspaceKey('folder-1')
|
||||
const state: WorkspaceDirectoryState = {
|
||||
floatingWorkspacePath: null,
|
||||
folderWorkspaces: [
|
||||
makeFolderWorkspace({ folderPath: '/workspace/remote', connectionId: 'box-1' })
|
||||
],
|
||||
worktreesByRepo: { repo: [worktreeFixture('wt-1', '/visible')] }
|
||||
}
|
||||
|
||||
// Why: an unhosted row is a local row, and a folder on another host is not this one.
|
||||
expect(resolveWorkspaceDirectory(state, 'wt-1', 'local')).toBe('/visible')
|
||||
expect(resolveWorkspaceDirectory(state, folderKey, 'local')).toBeNull()
|
||||
expect(resolveWorkspaceDirectory(state, folderKey, 'ssh:box-1')).toBe('/workspace/remote')
|
||||
})
|
||||
})
|
||||
@@ -59,167 +59,6 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('resolveWorkspaceDocAddressTarget', () => {
|
||||
it('resolves a file URL inside a sibling folder workspace on the same host', () => {
|
||||
expect(
|
||||
resolveWorkspaceDocAddressTarget(makeState(), CURRENT, 'file:///srv/site/report.html')
|
||||
).toMatchObject({
|
||||
status: 'workspace-doc',
|
||||
docLocation: {
|
||||
worktreeId: 'folder:folder-1',
|
||||
filePath: '/srv/site/report.html'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('does not cross SSH targets behind the same paired server', () => {
|
||||
places.set(CURRENT, 'ssh:first')
|
||||
places.set(OTHER, 'ssh:second')
|
||||
resolveWorkspaceDocAddressTarget(makeState(), CURRENT, 'file:///home/alice/wt2/report.html')
|
||||
expect(plan.calls).toEqual([{ worktreeId: CURRENT, filePath: '/home/alice/wt2/report.html' }])
|
||||
})
|
||||
it('routes a pasted remote file URL through its workspace preview', () => {
|
||||
expect(
|
||||
resolveWorkspaceDocAddressTarget(
|
||||
makeState(),
|
||||
CURRENT,
|
||||
' file:///home/alice/wt1/docs/report%20one.html '
|
||||
)
|
||||
).toMatchObject({
|
||||
status: 'workspace-doc',
|
||||
docLocation: {
|
||||
worktreeId: CURRENT,
|
||||
filePath: '/home/alice/wt1/docs/report one.html'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('does not choose a matching root on another host', () => {
|
||||
hosts.set(OTHER, 'server-2')
|
||||
plan.result = { status: 'unsupported', message: 'outside workspace', reason: 'no-channel' }
|
||||
expect(
|
||||
resolveWorkspaceDocAddressTarget(makeState(), CURRENT, 'file:///home/alice/wt2/report.html')
|
||||
).toEqual({ status: 'unsupported', message: 'outside workspace' })
|
||||
expect(plan.calls).toEqual([{ worktreeId: CURRENT, filePath: '/home/alice/wt2/report.html' }])
|
||||
})
|
||||
|
||||
it('skips ambiguous sibling hosts and uses the current host boundary', () => {
|
||||
hosts.set(OTHER, null)
|
||||
resolveWorkspaceDocAddressTarget(makeState(), CURRENT, 'file:///home/alice/wt2/report.html')
|
||||
expect(plan.calls).toEqual([{ worktreeId: CURRENT, filePath: '/home/alice/wt2/report.html' }])
|
||||
})
|
||||
|
||||
it('refuses a file URL when its current workspace host is ambiguous', () => {
|
||||
hosts.set(CURRENT, null)
|
||||
expect(
|
||||
resolveWorkspaceDocAddressTarget(makeState(), CURRENT, 'file:///home/alice/wt1/report.html')
|
||||
).toMatchObject({ status: 'unsupported' })
|
||||
expect(plan.calls).toEqual([])
|
||||
})
|
||||
|
||||
it('resolves an absolute path inside the current worktree to its document', () => {
|
||||
const target = resolveWorkspaceDocAddressTarget(
|
||||
makeState(),
|
||||
CURRENT,
|
||||
'/home/alice/wt1/docs/report.html'
|
||||
)
|
||||
expect(target).toEqual({
|
||||
status: 'workspace-doc',
|
||||
docLocation: {
|
||||
kind: 'workspace-doc',
|
||||
worktreeId: CURRENT,
|
||||
filePath: '/home/alice/wt1/docs/report.html'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to another known worktree only when the current one does not contain the path', () => {
|
||||
const target = resolveWorkspaceDocAddressTarget(
|
||||
makeState(),
|
||||
CURRENT,
|
||||
'/home/alice/wt2/index.html'
|
||||
)
|
||||
expect(target).toMatchObject({
|
||||
status: 'workspace-doc',
|
||||
docLocation: { worktreeId: OTHER, filePath: '/home/alice/wt2/index.html' }
|
||||
})
|
||||
})
|
||||
|
||||
// Prefer nested roots over outer containment so grants select the correct workspace/host owner.
|
||||
it('attributes a file in a nested workspace to the nested root, not the outer one', () => {
|
||||
const state = makeState()
|
||||
const stateWithNested = {
|
||||
...state,
|
||||
allWorktrees: () => [...state.allWorktrees(), { id: 'repo2::/srv', path: '/srv' }]
|
||||
} as typeof state
|
||||
|
||||
const target = resolveWorkspaceDocAddressTarget(
|
||||
stateWithNested,
|
||||
CURRENT,
|
||||
'/srv/site/index.html'
|
||||
)
|
||||
|
||||
expect(target).toMatchObject({
|
||||
status: 'workspace-doc',
|
||||
docLocation: { worktreeId: 'folder:folder-1' }
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves a folder workspace root under its folder key', () => {
|
||||
const target = resolveWorkspaceDocAddressTarget(makeState(), CURRENT, '/srv/site/index.html')
|
||||
expect(target).toMatchObject({
|
||||
status: 'workspace-doc',
|
||||
docLocation: { worktreeId: 'folder:folder-1', filePath: '/srv/site/index.html' }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a path outside every known workspace on the current host', () => {
|
||||
expect(resolveWorkspaceDocAddressTarget(makeState(), CURRENT, '/etc/motd.html')).toEqual({
|
||||
status: 'workspace-doc',
|
||||
docLocation: { kind: 'workspace-doc', worktreeId: CURRENT, filePath: '/etc/motd.html' }
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves a ./ relative path against the current worktree', () => {
|
||||
const target = resolveWorkspaceDocAddressTarget(makeState(), CURRENT, './docs/report.html')
|
||||
expect(target).toMatchObject({
|
||||
status: 'workspace-doc',
|
||||
docLocation: { worktreeId: CURRENT, filePath: '/home/alice/wt1/docs/report.html' }
|
||||
})
|
||||
})
|
||||
|
||||
// Containment is lexical, so dot segments are refused rather than resolved.
|
||||
it('refuses dot segments in both the relative and the absolute form', () => {
|
||||
expect(resolveWorkspaceDocAddressTarget(makeState(), CURRENT, './a/../../etc/x.html')).toEqual({
|
||||
status: 'not-a-workspace-doc'
|
||||
})
|
||||
expect(
|
||||
resolveWorkspaceDocAddressTarget(makeState(), CURRENT, '/home/alice/wt1/../wt9/x.html')
|
||||
).toEqual({ status: 'not-a-workspace-doc' })
|
||||
})
|
||||
|
||||
// "docs/report.html" is indistinguishable from a hostname with a path.
|
||||
it('leaves bare relative text and non-previewable extensions to the URL pipeline', () => {
|
||||
expect(resolveWorkspaceDocAddressTarget(makeState(), CURRENT, 'docs/report.html')).toEqual({
|
||||
status: 'not-a-workspace-doc'
|
||||
})
|
||||
expect(
|
||||
resolveWorkspaceDocAddressTarget(makeState(), CURRENT, '/home/alice/wt1/notes.md')
|
||||
).toEqual({ status: 'not-a-workspace-doc' })
|
||||
expect(plan.calls).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a local file on the file:// pipeline and surfaces an unsupported plan as its message', () => {
|
||||
plan.result = { status: 'browser-tab', url: 'file:///x', title: 'x' }
|
||||
expect(
|
||||
resolveWorkspaceDocAddressTarget(makeState(), CURRENT, '/home/alice/wt1/x.html')
|
||||
).toEqual({ status: 'not-a-workspace-doc' })
|
||||
|
||||
plan.result = { status: 'unsupported', message: 'no channel', reason: 'no-channel' }
|
||||
expect(
|
||||
resolveWorkspaceDocAddressTarget(makeState(), CURRENT, '/home/alice/wt1/x.html')
|
||||
).toEqual({ status: 'unsupported', message: 'no channel' })
|
||||
})
|
||||
|
||||
it('resolves a current-workspace address without enumerating unrelated workspace roots', () => {
|
||||
const state = makeState()
|
||||
state.allWorktrees = vi.fn(() => {
|
||||
@@ -231,26 +70,6 @@ describe('resolveWorkspaceDocAddressTarget', () => {
|
||||
expect(state.allWorktrees).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The current worktree outranks every other root, including a more specific one nested inside it.
|
||||
it('keeps the current worktree ahead of a workspace nested inside it', () => {
|
||||
const state = makeState()
|
||||
const nestedInsideCurrent = {
|
||||
...state,
|
||||
allWorktrees: () => [
|
||||
...state.allWorktrees(),
|
||||
{ id: 'repo3::/home/alice/wt1/vendor', path: '/home/alice/wt1/vendor' }
|
||||
]
|
||||
} as typeof state
|
||||
|
||||
expect(
|
||||
resolveWorkspaceDocAddressTarget(
|
||||
nestedInsideCurrent,
|
||||
CURRENT,
|
||||
'/home/alice/wt1/vendor/index.html'
|
||||
)
|
||||
).toMatchObject({ status: 'workspace-doc', docLocation: { worktreeId: CURRENT } })
|
||||
})
|
||||
|
||||
it('short-circuits for a folder workspace that is the current workspace', () => {
|
||||
const state = makeState()
|
||||
const folderCurrent = {
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getWorkspaceEditorSshHost } from './workspace-editor-ssh-target'
|
||||
|
||||
const link = { sshTargetId: 'ssh-1', sshTargetGeneration: 1, localPort: 41000, remotePort: 6768 }
|
||||
const WORKTREE_ID = 'repo-1::/home/ada/project'
|
||||
|
||||
function stateWith(
|
||||
environment: Record<string, unknown>,
|
||||
worktreeHostId: 'runtime:env-1' | 'ssh:box' = 'runtime:env-1'
|
||||
): Parameters<typeof getWorkspaceEditorSshHost>[0] {
|
||||
return {
|
||||
runtimeEnvironments: [{ id: 'env-1', ...environment }],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:env-1' }],
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
{
|
||||
id: WORKTREE_ID,
|
||||
repoId: 'repo-1',
|
||||
hostId: worktreeHostId,
|
||||
runtimeOwnerEnvironmentId: 'env-1'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('getWorkspaceEditorSshHost', () => {
|
||||
it('names the SSH host a managed server runs on for a workspace on that machine', () => {
|
||||
expect(
|
||||
getWorkspaceEditorSshHost(stateWith({ orcadDeployment: link }), {
|
||||
workspaceId: WORKTREE_ID,
|
||||
hostId: 'runtime:env-1'
|
||||
})
|
||||
).toEqual({ environmentId: 'env-1', sshTargetId: 'ssh-1' })
|
||||
})
|
||||
|
||||
it('names the SSH host of a paired server reached over SSH access', () => {
|
||||
const sshAccess = { ...link, endpointId: 'tunnel', previousPreferredEndpointId: 'lan' }
|
||||
expect(
|
||||
getWorkspaceEditorSshHost(stateWith({ sshAccess }), { workspaceId: WORKTREE_ID })
|
||||
).toEqual({ environmentId: 'env-1', sshTargetId: 'ssh-1' })
|
||||
})
|
||||
|
||||
it('refuses a workspace the server reaches through its own SSH host', () => {
|
||||
expect(
|
||||
getWorkspaceEditorSshHost(stateWith({ orcadDeployment: link }, 'ssh:box'), {
|
||||
workspaceId: WORKTREE_ID,
|
||||
hostId: 'ssh:box'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('places a folder workspace by its own SSH connection, not only its server stamp', () => {
|
||||
const folderState = (
|
||||
connectionId: string | null
|
||||
): Parameters<typeof getWorkspaceEditorSshHost>[0] => ({
|
||||
runtimeEnvironments: [{ id: 'env-1', orcadDeployment: link }],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
folderWorkspaces: [
|
||||
{ id: 'fw-1', projectGroupId: 'g-1', connectionId, executionHostId: 'runtime:env-1' }
|
||||
],
|
||||
projectGroups: [{ id: 'g-1', executionHostId: 'runtime:env-1' }]
|
||||
})
|
||||
const ref = { workspaceId: 'folder:fw-1', hostId: 'runtime:env-1' as const }
|
||||
expect(getWorkspaceEditorSshHost(folderState(null), ref)).toEqual({
|
||||
environmentId: 'env-1',
|
||||
sshTargetId: 'ssh-1'
|
||||
})
|
||||
expect(getWorkspaceEditorSshHost(folderState('nested'), ref)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a network-paired server', {}],
|
||||
['an ephemeral VM', { orcadDeployment: { ...link, sshTargetId: 'runtime-ssh-vm-1' } }]
|
||||
])('refuses %s', (_label, environment) => {
|
||||
expect(
|
||||
getWorkspaceEditorSshHost(stateWith(environment), { workspaceId: WORKTREE_ID })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses a local or missing workspace', () => {
|
||||
expect(getWorkspaceEditorSshHost({}, { workspaceId: 'local-repo::/tmp/x' })).toBeNull()
|
||||
expect(getWorkspaceEditorSshHost(stateWith({ orcadDeployment: link }), null)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,14 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getWorkspaceEmojiPickerDefaults } from './workspace-emoji-picker-defaults'
|
||||
import { searchWorkspaceEmojiShortcodes } from './workspace-emoji-shortcodes'
|
||||
|
||||
describe('getWorkspaceEmojiPickerDefaults', () => {
|
||||
it('matches the shortcode catalog exactly and lists distinct emoji', () => {
|
||||
const defaults = getWorkspaceEmojiPickerDefaults()
|
||||
expect(defaults).toHaveLength(48)
|
||||
expect(new Set(defaults.map((entry) => entry.emoji)).size).toBe(48)
|
||||
for (const entry of defaults) {
|
||||
expect(searchWorkspaceEmojiShortcodes(entry.shortcode, 1)[0]).toEqual(entry)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import type { WorkspaceEmojiSuggestion } from './workspace-emoji-shortcodes'
|
||||
// Lives apart from the shortcode index so only the lazily loaded picker panel ships it.
|
||||
// Shown before the user searches: emoji that read well as workspace markers.
|
||||
// Only emoji-presentation code points: text-default ones (gear, warning, sun) render as monochrome glyphs.
|
||||
// Literal pairs so opening the picker never builds the full shortcode catalog; a test keeps them in sync.
|
||||
// Literal pairs so opening the picker never builds the full shortcode catalog.
|
||||
const WORKSPACE_EMOJI_PICKER_DEFAULTS: readonly WorkspaceEmojiSuggestion[] = [
|
||||
{ emoji: '🚀', shortcode: 'rocket' },
|
||||
{ emoji: '🐛', shortcode: 'bug' },
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('workspace emoji shortcode index laziness', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('does not build the shared catalog when the renderer index is imported', async () => {
|
||||
const shortcodeIndex = await import('./workspace-emoji-shortcodes')
|
||||
const catalog = await import('../../../shared/emoji-shortcode-catalog')
|
||||
|
||||
expect(catalog.isEmojiShortcodeCatalogBuiltForTest()).toBe(false)
|
||||
|
||||
// Cursor/regex-only paths must stay off the catalog too.
|
||||
expect(shortcodeIndex.getActiveWorkspaceEmojiShortcode('hi :tad', 7)).not.toBeNull()
|
||||
expect(catalog.isEmojiShortcodeCatalogBuiltForTest()).toBe(false)
|
||||
|
||||
expect(shortcodeIndex.searchWorkspaceEmojiShortcodes('tada')[0]?.emoji).toBe('🎉')
|
||||
expect(catalog.isEmojiShortcodeCatalogBuiltForTest()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,71 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyWorkspaceEmojiSuggestion,
|
||||
getActiveWorkspaceEmojiShortcode,
|
||||
insertWorkspaceEmojiAtSelection,
|
||||
replaceCompletedWorkspaceEmojiShortcode
|
||||
} from './workspace-emoji-shortcodes'
|
||||
|
||||
describe('workspace emoji shortcodes', () => {
|
||||
it('detects a shortcode at the caret without matching URLs or mid-word colons', () => {
|
||||
expect(getActiveWorkspaceEmojiShortcode('Ship :win', 9)).toEqual({
|
||||
start: 5,
|
||||
end: 9,
|
||||
query: 'win'
|
||||
})
|
||||
expect(getActiveWorkspaceEmojiShortcode('https://example.com', 19)).toBeNull()
|
||||
expect(getActiveWorkspaceEmojiShortcode('fix:win', 7)).toBeNull()
|
||||
})
|
||||
|
||||
it('replaces a completed shortcode and preserves text after the caret', () => {
|
||||
expect(replaceCompletedWorkspaceEmojiShortcode('Ship :wink: today', 11)).toEqual({
|
||||
value: 'Ship 😉 today',
|
||||
cursor: 7
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces a completed flag shortcode', () => {
|
||||
expect(replaceCompletedWorkspaceEmojiShortcode(':flag_kr:', 9)).toEqual({
|
||||
value: '🇰🇷',
|
||||
cursor: 4
|
||||
})
|
||||
})
|
||||
|
||||
it('applies a selected suggestion to the active shortcode range', () => {
|
||||
expect(
|
||||
applyWorkspaceEmojiSuggestion(
|
||||
'Ship :win today',
|
||||
{ start: 5, end: 9, query: 'win' },
|
||||
{ emoji: '😉', shortcode: 'wink' }
|
||||
)
|
||||
).toEqual({
|
||||
value: 'Ship 😉 today',
|
||||
cursor: 8
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('insertWorkspaceEmojiAtSelection', () => {
|
||||
it('pads the emoji so it never fuses with neighbouring text', () => {
|
||||
expect(insertWorkspaceEmojiAtSelection('user-support', 0, 0, '🚀')).toEqual({
|
||||
value: '🚀 user-support',
|
||||
cursor: 3
|
||||
})
|
||||
expect(insertWorkspaceEmojiAtSelection('user-support', 12, 12, '🚀')).toEqual({
|
||||
value: 'user-support 🚀 ',
|
||||
cursor: 16
|
||||
})
|
||||
expect(insertWorkspaceEmojiAtSelection('ship it', 4, 4, '🚀')).toEqual({
|
||||
value: 'ship 🚀 it',
|
||||
cursor: 7
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces the selected range and clamps out-of-range selections', () => {
|
||||
expect(insertWorkspaceEmojiAtSelection('ship bug fix', 5, 8, '🐛')).toEqual({
|
||||
value: 'ship 🐛 fix',
|
||||
cursor: 7
|
||||
})
|
||||
expect(insertWorkspaceEmojiAtSelection('', 4, 9, '✨')).toEqual({ value: '✨ ', cursor: 2 })
|
||||
})
|
||||
})
|
||||
@@ -1,90 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getWorkspaceFileBrowserOpenTarget,
|
||||
REMOTE_FILE_BROWSER_UNSUPPORTED_MESSAGE,
|
||||
TWIN_FILE_BROWSER_UNSUPPORTED_MESSAGE
|
||||
} from './workspace-file-browser-open-target'
|
||||
import { localAndSshTwinRows } from './workspace-twin-rows.test-fixture'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
environmentId: null as string | null,
|
||||
connectionId: null as string | null,
|
||||
twin: false
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-runtime-owner', () => ({
|
||||
runtimeEnvironmentIdOrLocalUnlessTwin: () => mocks.environmentId
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () =>
|
||||
mocks.twin
|
||||
? localAndSshTwinRows('repo-1', 'wt-1')
|
||||
: {
|
||||
repos: [{ id: 'repo-1', connectionId: mocks.connectionId }],
|
||||
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] }
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.environmentId = null
|
||||
mocks.connectionId = null
|
||||
mocks.twin = false
|
||||
})
|
||||
|
||||
describe('getWorkspaceFileBrowserOpenTarget', () => {
|
||||
it('returns a reusable browser navigation target for local files', () => {
|
||||
expect(
|
||||
getWorkspaceFileBrowserOpenTarget({
|
||||
filePath: 'C:\\repo\\demo page.html',
|
||||
worktreeId: 'wt-1'
|
||||
})
|
||||
).toEqual({
|
||||
status: 'ready',
|
||||
url: 'file:///C:/repo/demo%20page.html',
|
||||
title: 'demo page.html'
|
||||
})
|
||||
})
|
||||
|
||||
it('still refuses remote files, which have no local file URL', () => {
|
||||
mocks.connectionId = 'ssh-1'
|
||||
|
||||
expect(
|
||||
getWorkspaceFileBrowserOpenTarget({
|
||||
filePath: '/home/alice/report.html',
|
||||
worktreeId: 'wt-1'
|
||||
})
|
||||
).toEqual({
|
||||
status: 'unsupported',
|
||||
reason: 'remote-worktree',
|
||||
message: REMOTE_FILE_BROWSER_UNSUPPORTED_MESSAGE
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a file:// URL for a project two hosts publish, naming why', () => {
|
||||
mocks.twin = true
|
||||
expect(
|
||||
getWorkspaceFileBrowserOpenTarget({ filePath: '/srv/repo/report.html', worktreeId: 'wt-1' })
|
||||
).toEqual({
|
||||
status: 'unsupported',
|
||||
reason: 'remote-worktree',
|
||||
message: TWIN_FILE_BROWSER_UNSUPPORTED_MESSAGE
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses paired files even when their connection ID is null', () => {
|
||||
mocks.environmentId = 'managed-host'
|
||||
expect(
|
||||
getWorkspaceFileBrowserOpenTarget({
|
||||
filePath: '/srv/repo/report.html',
|
||||
worktreeId: 'wt-1'
|
||||
})
|
||||
).toEqual({
|
||||
status: 'unsupported',
|
||||
reason: 'remote-worktree',
|
||||
message: REMOTE_FILE_BROWSER_UNSUPPORTED_MESSAGE
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,49 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { readWorkspaceFileDragSource } from './workspace-file-drag'
|
||||
import { writeWorkspaceFileDragSourceForWorkspace } from './workspace-file-drag-source'
|
||||
|
||||
const mocks = vi.hoisted((): { host: ExecutionHostId | null; environmentId: string | null } => ({
|
||||
host: 'ssh:target-a',
|
||||
environmentId: null
|
||||
}))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: { getState: () => ({ settings: { activeRuntimeEnvironmentId: 'foreign-env' } }) }
|
||||
}))
|
||||
vi.mock('./worktree-runtime-owner', () => ({
|
||||
getKnownExecutionHostIdForWorktree: () => mocks.host
|
||||
}))
|
||||
vi.mock('./resolve-owner', () => ({
|
||||
runtimeEnvironmentIdForWorkspaceOwner: () => ({
|
||||
kind: 'resolved',
|
||||
environmentId: mocks.environmentId
|
||||
})
|
||||
}))
|
||||
|
||||
describe('workspace drag source server namespace', () => {
|
||||
beforeEach(() => {
|
||||
mocks.host = 'ssh:target-a'
|
||||
mocks.environmentId = null
|
||||
})
|
||||
|
||||
it.each([null, 'env-a'])('stamps SSH ownership from its workspace: %s', (environmentId) => {
|
||||
mocks.environmentId = environmentId
|
||||
const payload = new Map<string, string>()
|
||||
writeWorkspaceFileDragSourceForWorkspace(
|
||||
{ setData: (type, value) => payload.set(type, value) },
|
||||
'folder:source'
|
||||
)
|
||||
expect(readWorkspaceFileDragSource({ getData: (type) => payload.get(type) ?? '' })).toEqual({
|
||||
executionHostId: 'ssh:target-a',
|
||||
workspaceId: 'folder:source',
|
||||
...(environmentId ? { runtimeEnvironmentId: environmentId } : {})
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves an unknown workspace unstamped despite the focused server', () => {
|
||||
mocks.host = null
|
||||
const setData = vi.fn()
|
||||
writeWorkspaceFileDragSourceForWorkspace({ setData }, 'folder:missing')
|
||||
expect(setData).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -5,9 +5,7 @@ import {
|
||||
WORKSPACE_FILE_PATHS_MIME,
|
||||
WORKSPACE_FILE_PATH_MIME,
|
||||
encodeWorkspaceFilePaths,
|
||||
readWorkspaceFileDragPaths,
|
||||
readWorkspaceFileDragSource,
|
||||
writeWorkspaceFileDragSource
|
||||
readWorkspaceFileDragPaths
|
||||
} from './workspace-file-drag'
|
||||
|
||||
vi.mock('../../../shared/cross-platform-path', async (importOriginal) => {
|
||||
@@ -31,74 +29,6 @@ class FakeDataTransfer {
|
||||
}
|
||||
|
||||
describe('workspace file drag payloads', () => {
|
||||
it('rejects missing and malformed workspace ownership', () => {
|
||||
const transfer = new FakeDataTransfer()
|
||||
expect(readWorkspaceFileDragSource(transfer)).toBeNull()
|
||||
transfer.setData('application/x-orca-workspace-file-source', '{"workspaceId":"worktree-1"}')
|
||||
expect(readWorkspaceFileDragSource(transfer)).toBeNull()
|
||||
})
|
||||
|
||||
it('round-trips the server namespace of a nested SSH target', () => {
|
||||
const transfer = new FakeDataTransfer()
|
||||
const source = {
|
||||
executionHostId: 'ssh:remote-1',
|
||||
workspaceId: 'folder:docs',
|
||||
runtimeEnvironmentId: 'env-1'
|
||||
} as const
|
||||
writeWorkspaceFileDragSource(transfer, source)
|
||||
expect(readWorkspaceFileDragSource(transfer)).toEqual(source)
|
||||
})
|
||||
|
||||
it.each([null, 123, '', ' '])('rejects a malformed server namespace %s', (value) => {
|
||||
const transfer = new FakeDataTransfer()
|
||||
transfer.setData(
|
||||
'application/x-orca-workspace-file-source',
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
executionHostId: 'ssh:remote-1',
|
||||
workspaceId: 'folder:docs',
|
||||
runtimeEnvironmentId: value
|
||||
})
|
||||
)
|
||||
expect(readWorkspaceFileDragSource(transfer)).toBeNull()
|
||||
})
|
||||
|
||||
it('round-trips bounded multi-path payloads and removes nested duplicates', () => {
|
||||
const transfer = new FakeDataTransfer()
|
||||
transfer.setData(
|
||||
WORKSPACE_FILE_PATHS_MIME,
|
||||
encodeWorkspaceFilePaths(['/repo/src', '/repo/src/index.ts', '/repo/README.md'])
|
||||
)
|
||||
|
||||
expect(readWorkspaceFileDragPaths(transfer)).toEqual({
|
||||
byteLength: 42,
|
||||
pathCount: 2,
|
||||
paths: ['/repo/src', '/repo/README.md'],
|
||||
status: 'accepted'
|
||||
})
|
||||
const selected = readWorkspaceFileDragPaths(transfer, { collapseDescendantsForMove: false })
|
||||
if (selected.status !== 'accepted') {
|
||||
throw new Error('Expected selected resources to be accepted')
|
||||
}
|
||||
expect(selected.paths).toEqual(['/repo/src', '/repo/src/index.ts', '/repo/README.md'])
|
||||
})
|
||||
|
||||
it('keeps sibling paths whose names only share a prefix', () => {
|
||||
const transfer = new FakeDataTransfer()
|
||||
transfer.setData(
|
||||
WORKSPACE_FILE_PATHS_MIME,
|
||||
encodeWorkspaceFilePaths(['/repo/src', '/repo/src-other', '/repo/src-other/index.ts'])
|
||||
)
|
||||
|
||||
const result = readWorkspaceFileDragPaths(transfer)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
pathCount: 2,
|
||||
paths: ['/repo/src', '/repo/src-other'],
|
||||
status: 'accepted'
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes each accepted path once while pruning Windows-style nested duplicates', () => {
|
||||
const normalizePathForComparison = vi.mocked(
|
||||
crossPlatformPath.normalizeRuntimePathForComparison
|
||||
@@ -124,19 +54,6 @@ describe('workspace file drag payloads', () => {
|
||||
expect(normalizePathForComparison).toHaveBeenCalledTimes(paths.length)
|
||||
})
|
||||
|
||||
it('rejects oversized raw multi-path data before JSON parsing', () => {
|
||||
const transfer = new FakeDataTransfer()
|
||||
const oversizedPrefix = '😀'.repeat(3)
|
||||
transfer.setData(WORKSPACE_FILE_PATHS_MIME, `${oversizedPrefix}not-json`)
|
||||
|
||||
expect(readWorkspaceFileDragPaths(transfer, { maxPathBytes: 5 })).toEqual({
|
||||
byteLength: 8,
|
||||
pathCount: 0,
|
||||
reason: 'paths-too-large',
|
||||
status: 'rejected'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects decoded path lists by path count without falling back to a single path', () => {
|
||||
const transfer = new FakeDataTransfer()
|
||||
transfer.setData(WORKSPACE_FILE_PATHS_MIME, JSON.stringify(['/repo/a', '/repo/b']))
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getExternalWorkspacePorts,
|
||||
getWorkspacePortGroups,
|
||||
getWorkspacePortsByWorktreeId
|
||||
} from './workspace-port-groups'
|
||||
|
||||
describe('workspace port group caches', () => {
|
||||
it('returns stable empty references when no scan result exists', () => {
|
||||
expect(getWorkspacePortsByWorktreeId(null).size).toBe(0)
|
||||
expect(getWorkspacePortGroups(null)).toEqual([])
|
||||
expect(getExternalWorkspacePorts(null)).toEqual([])
|
||||
|
||||
expect(getWorkspacePortsByWorktreeId(null)).toBe(getWorkspacePortsByWorktreeId(undefined))
|
||||
expect(getWorkspacePortGroups(null)).toBe(getWorkspacePortGroups(undefined))
|
||||
expect(getExternalWorkspacePorts(null)).toBe(getExternalWorkspacePorts(undefined))
|
||||
})
|
||||
})
|
||||
@@ -1,173 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { callRuntimeRpc, supportsCapability } = vi.hoisted(() => ({
|
||||
callRuntimeRpc: vi.fn(),
|
||||
supportsCapability: vi.fn()
|
||||
}))
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
callRuntimeRpc,
|
||||
runtimeEnvironmentSupportsCapability: supportsCapability
|
||||
}))
|
||||
|
||||
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
|
||||
import {
|
||||
killWorkspacePortOnExecutionHost,
|
||||
scanWorkspacePortsOnExecutionHost,
|
||||
WORKSPACE_PORTS_SERVER_SSH_UPDATE_REASON,
|
||||
type HostScopedPortHost
|
||||
} from './workspace-port-scan-client'
|
||||
import { WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY } from '../../../../shared/workspace-ports-runtime-capabilities'
|
||||
|
||||
const host: HostScopedPortHost = {
|
||||
route: { target: { kind: 'environment', environmentId: 'env-e' }, at: 'ssh:t' },
|
||||
executionHostId: 'ssh:t',
|
||||
worktreeId: 'repo-1::/srv/w'
|
||||
}
|
||||
|
||||
const rowOnT = {
|
||||
id: '0.0.0.0:3020:4242',
|
||||
kind: 'external',
|
||||
bindHost: '0.0.0.0',
|
||||
connectHost: '127.0.0.1',
|
||||
port: 3020,
|
||||
pid: 4242,
|
||||
protocol: 'unknown'
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
callRuntimeRpc.mockReset()
|
||||
supportsCapability.mockReset()
|
||||
supportsCapability.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
describe('scanWorkspacePortsOnExecutionHost', () => {
|
||||
it('asks the server to scan the workspace host and keeps the host it names', async () => {
|
||||
callRuntimeRpc.mockResolvedValue({
|
||||
executionHostId: 'ssh:t',
|
||||
platform: 'linux',
|
||||
scannedAt: 1,
|
||||
ports: [rowOnT]
|
||||
})
|
||||
|
||||
const scan = await scanWorkspacePortsOnExecutionHost(host)
|
||||
|
||||
expect(supportsCapability).toHaveBeenCalledWith(
|
||||
'env-e',
|
||||
WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY
|
||||
)
|
||||
expect(callRuntimeRpc).toHaveBeenCalledWith(
|
||||
host.route.target,
|
||||
'workspacePorts.scanHost',
|
||||
{ worktree: 'id:repo-1::/srv/w' },
|
||||
expect.objectContaining({ timeoutMs: 15_000 })
|
||||
)
|
||||
expect(scan).toMatchObject({ executionHostId: 'ssh:t', ports: [rowOnT] })
|
||||
expect(scan.unavailableReason).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never asks an older server, whose scan would show the server instead of its SSH host', async () => {
|
||||
supportsCapability.mockResolvedValue(false)
|
||||
|
||||
const scan = await scanWorkspacePortsOnExecutionHost(host)
|
||||
|
||||
expect(callRuntimeRpc).not.toHaveBeenCalled()
|
||||
expect(scan).toMatchObject({
|
||||
executionHostId: 'ssh:t',
|
||||
ports: [],
|
||||
unavailableReason: WORKSPACE_PORTS_SERVER_SSH_UPDATE_REASON
|
||||
})
|
||||
})
|
||||
|
||||
it('says "update server" when the server lacks the method despite advertising it', async () => {
|
||||
callRuntimeRpc.mockRejectedValue(
|
||||
new RuntimeRpcCallError({
|
||||
id: 'r',
|
||||
ok: false,
|
||||
error: { code: 'method_not_found', message: 'Unknown method' },
|
||||
_meta: { runtimeId: 'runtime-e' }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(scanWorkspacePortsOnExecutionHost(host)).resolves.toMatchObject({
|
||||
ports: [],
|
||||
unavailableReason: WORKSPACE_PORTS_SERVER_SSH_UPDATE_REASON
|
||||
})
|
||||
})
|
||||
|
||||
it("scans a recipe VM through this app's own runtime without a capability probe", async () => {
|
||||
const recipeVm: HostScopedPortHost = {
|
||||
route: { target: { kind: 'local' }, at: 'ssh:runtime-ssh-vm1' },
|
||||
executionHostId: 'ssh:runtime-ssh-vm1',
|
||||
worktreeId: 'repo-1::/srv/w'
|
||||
}
|
||||
callRuntimeRpc.mockResolvedValue({
|
||||
executionHostId: 'ssh:runtime-ssh-vm1',
|
||||
platform: 'unknown',
|
||||
scannedAt: 1,
|
||||
ports: [],
|
||||
unavailableReason: 'The SSH host is not connected.'
|
||||
})
|
||||
|
||||
const scan = await scanWorkspacePortsOnExecutionHost(recipeVm)
|
||||
|
||||
expect(supportsCapability).not.toHaveBeenCalled()
|
||||
expect(callRuntimeRpc).toHaveBeenCalledWith(
|
||||
{ kind: 'local' },
|
||||
'workspacePorts.scanHost',
|
||||
{ worktree: 'id:repo-1::/srv/w' },
|
||||
expect.anything()
|
||||
)
|
||||
// An unreachable VM stays "could not look", never an empty "no ports".
|
||||
expect(scan.unavailableReason).toBe('The SSH host is not connected.')
|
||||
})
|
||||
|
||||
it("drops rows the server scanned on a host other than the workspace's", async () => {
|
||||
callRuntimeRpc.mockResolvedValue({
|
||||
executionHostId: 'local',
|
||||
platform: 'linux',
|
||||
scannedAt: 1,
|
||||
ports: [rowOnT]
|
||||
})
|
||||
|
||||
const scan = await scanWorkspacePortsOnExecutionHost(host)
|
||||
|
||||
expect(scan.ports).toEqual([])
|
||||
expect(scan.unavailableReason).toMatch(/different host/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('killWorkspacePortOnExecutionHost', () => {
|
||||
it('stops a row only through the server, naming the host the row was scanned on', async () => {
|
||||
callRuntimeRpc.mockResolvedValue({ ok: true })
|
||||
|
||||
await expect(
|
||||
killWorkspacePortOnExecutionHost(host, { scannedHostId: 'ssh:t', pid: 4242, port: 3020 })
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
// Equal server/SSH pid+port values are unrelated processes; never fall back to repo-scoped server kills.
|
||||
expect(callRuntimeRpc).toHaveBeenCalledOnce()
|
||||
expect(callRuntimeRpc).toHaveBeenCalledWith(
|
||||
host.route.target,
|
||||
'workspacePorts.killHost',
|
||||
{ worktree: 'id:repo-1::/srv/w', executionHostId: 'ssh:t', pid: 4242, port: 3020 },
|
||||
expect.objectContaining({ timeoutMs: 15_000 })
|
||||
)
|
||||
})
|
||||
|
||||
it('reports "update server" instead of falling back to the server-local kill', async () => {
|
||||
callRuntimeRpc.mockRejectedValue(
|
||||
new RuntimeRpcCallError({
|
||||
id: 'r',
|
||||
ok: false,
|
||||
error: { code: 'method_not_found', message: 'Unknown method' },
|
||||
_meta: { runtimeId: 'runtime-e' }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
killWorkspacePortOnExecutionHost(host, { scannedHostId: 'ssh:t', pid: 4242, port: 3020 })
|
||||
).resolves.toEqual({ ok: false, reason: WORKSPACE_PORTS_SERVER_SSH_UPDATE_REASON })
|
||||
expect(callRuntimeRpc).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -21,15 +21,6 @@ function good(portIds: string[]): WorkspacePortScanResult {
|
||||
}
|
||||
}
|
||||
|
||||
function unavailable(): WorkspacePortScanResult {
|
||||
return { platform: 'unknown', scannedAt: 1, ports: [], unavailableReason: 'scan failed' }
|
||||
}
|
||||
|
||||
/** What the ports popover publishes when its own scan fails: reason + last-good ports. */
|
||||
function unavailableWithRetainedPorts(portIds: string[]): WorkspacePortScanResult {
|
||||
return { ...good(portIds), unavailableReason: 'scan failed' }
|
||||
}
|
||||
|
||||
const FAILURE_THRESHOLD = 2
|
||||
|
||||
function createHarness(): {
|
||||
@@ -58,46 +49,6 @@ function createHarness(): {
|
||||
}
|
||||
|
||||
describe('reconcileTransientPortScanFailures', () => {
|
||||
it('keeps the live indicator solid through a single transient failure', () => {
|
||||
const { apply } = createHarness()
|
||||
|
||||
const first = apply([{ key: 'host:all', result: good(['tcp:3000']) }])
|
||||
expect(first[0].result.ports).toHaveLength(1)
|
||||
|
||||
const second = apply([{ key: 'host:all', result: unavailable() }])
|
||||
expect(second[0].result.ports).toHaveLength(1)
|
||||
expect(second[0].result.unavailableReason).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops ports only after failures reach the tolerance', () => {
|
||||
const { apply } = createHarness()
|
||||
apply([{ key: 'h:all', result: good(['tcp:3000']) }])
|
||||
apply([{ key: 'h:all', result: unavailable() }])
|
||||
|
||||
const third = apply([{ key: 'h:all', result: unavailable() }])
|
||||
expect(third[0].result.ports).toHaveLength(0)
|
||||
expect(third[0].result.unavailableReason).toBe('scan failed')
|
||||
})
|
||||
|
||||
it('clears immediately when a reachable host reports zero ports', () => {
|
||||
const { apply } = createHarness()
|
||||
apply([{ key: 'h:all', result: good(['tcp:3000']) }])
|
||||
|
||||
const next = apply([{ key: 'h:all', result: good([]) }])
|
||||
expect(next[0].result.ports).toHaveLength(0)
|
||||
expect(next[0].result.unavailableReason).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resets the failure streak after a successful scan', () => {
|
||||
const { apply } = createHarness()
|
||||
apply([{ key: 'h:all', result: good(['tcp:3000']) }])
|
||||
apply([{ key: 'h:all', result: unavailable() }])
|
||||
apply([{ key: 'h:all', result: good(['tcp:3000']) }])
|
||||
|
||||
const afterRecovery = apply([{ key: 'h:all', result: unavailable() }])
|
||||
expect(afterRecovery[0].result.ports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('prunes state for hosts that disappear', () => {
|
||||
const { apply, state } = createHarness()
|
||||
apply([{ key: 'gone:all', result: good(['tcp:3000']) }])
|
||||
@@ -105,52 +56,4 @@ describe('reconcileTransientPortScanFailures', () => {
|
||||
expect(state.has('gone:all')).toBe(false)
|
||||
expect(state.has('other:all')).toBe(true)
|
||||
})
|
||||
|
||||
// Popover failures already publish last-good ports; do not count that as spent grace or the next poll drops them immediately.
|
||||
it('still grants the grace period after a failure that retained its ports', () => {
|
||||
const { apply, publish } = createHarness()
|
||||
apply([{ key: 'h:all', result: good(['tcp:3000']) }])
|
||||
const popoverResult = unavailableWithRetainedPorts(['tcp:3000'])
|
||||
publish('h:all', popoverResult)
|
||||
|
||||
const next = apply([{ key: 'h:all', result: unavailable() }])
|
||||
|
||||
expect(next[0].result).toBe(popoverResult)
|
||||
expect(next[0].result.ports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('still drops retained ports once failures reach the tolerance', () => {
|
||||
const { apply, publish } = createHarness()
|
||||
apply([{ key: 'h:all', result: good(['tcp:3000']) }])
|
||||
publish('h:all', unavailableWithRetainedPorts(['tcp:3000']))
|
||||
apply([{ key: 'h:all', result: unavailable() }])
|
||||
|
||||
const next = apply([{ key: 'h:all', result: unavailable() }])
|
||||
|
||||
expect(next[0].result.ports).toHaveLength(0)
|
||||
expect(next[0].result.unavailableReason).toBe('scan failed')
|
||||
})
|
||||
|
||||
it('uses a newer manual result instead of resurrecting stale ports', () => {
|
||||
const { apply, publish } = createHarness()
|
||||
apply([{ key: 'h:all', result: good(['tcp:3000']) }])
|
||||
publish('h:all', good([]))
|
||||
|
||||
const next = apply([{ key: 'h:all', result: unavailable() }])
|
||||
|
||||
expect(next[0].result.ports).toHaveLength(0)
|
||||
expect(next[0].result.unavailableReason).toBeUndefined()
|
||||
})
|
||||
|
||||
it('starts a fresh failure streak after a newer manual result', () => {
|
||||
const { apply, publish } = createHarness()
|
||||
apply([{ key: 'h:all', result: good(['tcp:3000']) }])
|
||||
apply([{ key: 'h:all', result: unavailable() }])
|
||||
const manualResult = good([])
|
||||
publish('h:all', manualResult)
|
||||
|
||||
const next = apply([{ key: 'h:all', result: unavailable() }])
|
||||
|
||||
expect(next[0].result).toBe(manualResult)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports'
|
||||
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
callRuntimeRpc: vi.fn(),
|
||||
assertRuntimeEnvironmentCapability: vi.fn()
|
||||
}))
|
||||
vi.mock('@/runtime/runtime-rpc-result', () => ({
|
||||
RuntimeRpcCallError: class RuntimeRpcCallError extends Error {
|
||||
code?: string
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./workspace-port-scan-client', () => ({
|
||||
runWorkspacePortScanForTarget: vi.fn()
|
||||
}))
|
||||
|
||||
const { publishWorkspacePortScanForHost, WORKSPACE_PORT_ALL_HOSTS_SCAN_KEY } =
|
||||
await import('./workspace-port-actions')
|
||||
type WorkspacePortScanPublisher = Parameters<typeof publishWorkspacePortScanForHost>[0]
|
||||
|
||||
function scanWithPort(port: number, scannedAt: number): WorkspacePortScanResult {
|
||||
return {
|
||||
platform: 'linux',
|
||||
scannedAt,
|
||||
ports: [
|
||||
{
|
||||
id: `tcp:${port}`,
|
||||
bindHost: '0.0.0.0',
|
||||
connectHost: '127.0.0.1',
|
||||
port,
|
||||
pid: 100 + port,
|
||||
processName: 'node',
|
||||
protocol: 'http',
|
||||
kind: 'external'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirrors the store's replaceWorkspacePortScans semantics: one atomic update. */
|
||||
function makeStoreHarness(initial: Record<string, WorkspacePortScanResult> = {}): {
|
||||
scansByKey: Record<string, WorkspacePortScanResult>
|
||||
projections: { key: string; result: WorkspacePortScanResult }[]
|
||||
publisher: Omit<WorkspacePortScanPublisher, 'scanKey' | 'scan'>
|
||||
} {
|
||||
let scansByKey: Record<string, WorkspacePortScanResult> = { ...initial }
|
||||
const projections: { key: string; result: WorkspacePortScanResult }[] = []
|
||||
return {
|
||||
get scansByKey() {
|
||||
return scansByKey
|
||||
},
|
||||
projections,
|
||||
publisher: {
|
||||
replaceWorkspacePortScans: (
|
||||
nextScansByKey: Record<string, WorkspacePortScanResult>,
|
||||
projection: { key: string; result: WorkspacePortScanResult } | null
|
||||
) => {
|
||||
scansByKey = nextScansByKey
|
||||
if (projection) {
|
||||
projections.push(projection)
|
||||
}
|
||||
},
|
||||
getWorkspacePortScansByKey: () => scansByKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('publishWorkspacePortScanForHost', () => {
|
||||
let localScan: WorkspacePortScanResult
|
||||
let remoteScan: WorkspacePortScanResult
|
||||
|
||||
beforeEach(() => {
|
||||
localScan = scanWithPort(5173, 10)
|
||||
remoteScan = scanWithPort(3000, 20)
|
||||
})
|
||||
|
||||
it('keeps the other host in the projection when one host refreshes', () => {
|
||||
const harness = makeStoreHarness({ 'local:all': localScan })
|
||||
|
||||
publishWorkspacePortScanForHost({
|
||||
...harness.publisher,
|
||||
scanKey: 'environment:env-1:all',
|
||||
scan: remoteScan
|
||||
})
|
||||
|
||||
const projection = harness.projections.at(-1)
|
||||
expect(projection?.key).toBe(WORKSPACE_PORT_ALL_HOSTS_SCAN_KEY)
|
||||
expect(projection?.result.ports.map((port) => port.port).sort()).toEqual([3000, 5173])
|
||||
expect(Object.keys(harness.scansByKey).sort()).toEqual(['environment:env-1:all', 'local:all'])
|
||||
})
|
||||
|
||||
it('publishes map and projection in a single store update', () => {
|
||||
const harness = makeStoreHarness({ 'local:all': localScan })
|
||||
const replaceSpy = vi.spyOn(harness.publisher, 'replaceWorkspacePortScans')
|
||||
|
||||
publishWorkspacePortScanForHost({
|
||||
...harness.publisher,
|
||||
scanKey: 'environment:env-1:all',
|
||||
scan: remoteScan
|
||||
})
|
||||
|
||||
// Atomic replacement must notify once and keep map/projection coherent.
|
||||
expect(replaceSpy).toHaveBeenCalledTimes(1)
|
||||
const [nextScans, projection] = replaceSpy.mock.calls[0]
|
||||
expect(Object.keys(nextScans).sort()).toEqual(['environment:env-1:all', 'local:all'])
|
||||
expect(projection?.key).toBe(WORKSPACE_PORT_ALL_HOSTS_SCAN_KEY)
|
||||
})
|
||||
|
||||
it('does not accumulate duplicate rows across repeated publishes', () => {
|
||||
const harness = makeStoreHarness({ 'local:all': localScan })
|
||||
|
||||
publishWorkspacePortScanForHost({
|
||||
...harness.publisher,
|
||||
scanKey: 'environment:env-1:all',
|
||||
scan: remoteScan
|
||||
})
|
||||
publishWorkspacePortScanForHost({
|
||||
...harness.publisher,
|
||||
scanKey: 'environment:env-1:all',
|
||||
scan: { ...remoteScan, scannedAt: 30 }
|
||||
})
|
||||
|
||||
// Never store aggregate in per-host state or the next merge multiplies its own rows.
|
||||
expect(harness.scansByKey[WORKSPACE_PORT_ALL_HOSTS_SCAN_KEY]).toBeUndefined()
|
||||
expect(
|
||||
harness.projections
|
||||
.at(-1)
|
||||
?.result.ports.map((port) => port.port)
|
||||
.sort()
|
||||
).toEqual([3000, 5173])
|
||||
})
|
||||
})
|
||||
@@ -1,210 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { WorkspaceSessionSnapshot } from './workspace-session'
|
||||
import { buildWorkspaceSessionPayload } from './workspace-session'
|
||||
|
||||
function createSnapshot(
|
||||
overrides: Partial<WorkspaceSessionSnapshot> = {}
|
||||
): WorkspaceSessionSnapshot {
|
||||
return {
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorkspaceKey: 'worktree:wt-1',
|
||||
activeWorktreeId: 'wt-1',
|
||||
activeTabId: 'tab-1',
|
||||
tabsByWorktree: {},
|
||||
ptyIdsByTabId: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
localOnlyScrollbackByTabId: {},
|
||||
activeTabIdByWorktree: {},
|
||||
openFiles: [],
|
||||
editorDrafts: {},
|
||||
markdownFrontmatterVisible: {},
|
||||
activeFileIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {},
|
||||
browserTabsByWorktree: {},
|
||||
browserPagesByWorkspace: {},
|
||||
remoteBrowserPageHandlesByPageId: {},
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
browserUrlHistory: [],
|
||||
workspaceDocHistory: [],
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
layoutByWorktree: {},
|
||||
activeGroupIdByWorktree: {},
|
||||
sshConnectionStates: new Map(),
|
||||
repos: [],
|
||||
worktreesByRepo: {},
|
||||
lastKnownRelayPtyIdByTabId: {},
|
||||
lastVisitedAtByWorktreeId: {},
|
||||
defaultTerminalTabsAppliedByWorktreeId: {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('workspace session editor drafts', () => {
|
||||
it('persists dirty editor drafts without saving clean file content', () => {
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
openFiles: [
|
||||
{
|
||||
id: '/tmp/dirty.md',
|
||||
filePath: '/tmp/dirty.md',
|
||||
relativePath: 'dirty.md',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
mode: 'edit',
|
||||
isDirty: true
|
||||
} as never,
|
||||
{
|
||||
id: '/tmp/clean.md',
|
||||
filePath: '/tmp/clean.md',
|
||||
relativePath: 'clean.md',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
mode: 'edit',
|
||||
isDirty: false
|
||||
} as never
|
||||
],
|
||||
editorDrafts: {
|
||||
'/tmp/dirty.md': '',
|
||||
'/tmp/clean.md': 'clean draft should not persist'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(payload.openFilesByWorktree?.['wt-1']).toEqual([
|
||||
expect.objectContaining({
|
||||
filePath: '/tmp/dirty.md',
|
||||
dirtyDraftContent: ''
|
||||
}),
|
||||
expect.not.objectContaining({
|
||||
dirtyDraftContent: expect.any(String)
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('persists the SSH target that owns an external host file', () => {
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
openFiles: [
|
||||
{
|
||||
id: '/tmp/ssh-preview.png',
|
||||
filePath: '/tmp/ssh-preview.png',
|
||||
relativePath: '/tmp/ssh-preview.png',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'png',
|
||||
mode: 'edit',
|
||||
isDirty: false,
|
||||
externalSshTargetId: 'ssh-1'
|
||||
} as never
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
expect(payload.openFilesByWorktree?.['wt-1']?.[0]).toEqual(
|
||||
expect.objectContaining({ externalSshTargetId: 'ssh-1' })
|
||||
)
|
||||
})
|
||||
|
||||
it('persists the disk baseline signature only alongside a dirty draft', () => {
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
openFiles: [
|
||||
{
|
||||
id: '/tmp/dirty.md',
|
||||
filePath: '/tmp/dirty.md',
|
||||
relativePath: 'dirty.md',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
mode: 'edit',
|
||||
isDirty: true,
|
||||
lastKnownDiskSignature: 'abc123'
|
||||
} as never,
|
||||
{
|
||||
id: '/tmp/clean.md',
|
||||
filePath: '/tmp/clean.md',
|
||||
relativePath: 'clean.md',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
mode: 'edit',
|
||||
isDirty: false,
|
||||
lastKnownDiskSignature: 'def456'
|
||||
} as never
|
||||
],
|
||||
editorDrafts: {
|
||||
'/tmp/dirty.md': 'unsaved edits'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(payload.openFilesByWorktree?.['wt-1']).toEqual([
|
||||
expect.objectContaining({
|
||||
filePath: '/tmp/dirty.md',
|
||||
dirtyDraftContent: 'unsaved edits',
|
||||
lastKnownDiskSignature: 'abc123'
|
||||
}),
|
||||
// Clean tabs need no restore-conflict signature; persisting it bloats every write.
|
||||
expect.not.objectContaining({
|
||||
lastKnownDiskSignature: expect.any(String)
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('persists readOnly:true and never emits a dirty draft for read-only tabs', () => {
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
openFiles: [
|
||||
{
|
||||
id: '/home/user/.claude/log.jsonl',
|
||||
filePath: '/home/user/.claude/log.jsonl',
|
||||
relativePath: '/home/user/.claude/log.jsonl',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'jsonl',
|
||||
mode: 'edit',
|
||||
// Why: even if isDirty is somehow set, a read-only tab must not
|
||||
// persist a draft that a restore could write back to disk.
|
||||
isDirty: true,
|
||||
readOnly: true,
|
||||
liveTail: true
|
||||
} as never
|
||||
],
|
||||
editorDrafts: {
|
||||
'/home/user/.claude/log.jsonl': 'stray draft that must not persist'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const persisted = payload.openFilesByWorktree?.['wt-1']?.[0]
|
||||
expect(persisted).toEqual(
|
||||
expect.objectContaining({
|
||||
filePath: '/home/user/.claude/log.jsonl',
|
||||
readOnly: true,
|
||||
liveTail: true
|
||||
})
|
||||
)
|
||||
expect(persisted).toEqual(
|
||||
expect.not.objectContaining({ dirtyDraftContent: expect.any(String) })
|
||||
)
|
||||
})
|
||||
|
||||
it('omits readOnly for ordinary writable tabs (writable is the default)', () => {
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
openFiles: [
|
||||
{
|
||||
id: '/tmp/clean.md',
|
||||
filePath: '/tmp/clean.md',
|
||||
relativePath: 'clean.md',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
mode: 'edit',
|
||||
isDirty: false
|
||||
} as never
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const persisted = payload.openFilesByWorktree?.['wt-1']?.[0]
|
||||
expect(persisted).toEqual(expect.objectContaining({ filePath: '/tmp/clean.md' }))
|
||||
expect(persisted).not.toHaveProperty('readOnly')
|
||||
})
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getFolderWorkspaceHostId } from '../../../shared/folder-workspace-execution-host'
|
||||
import { FOLDER_HOST_CASES } from '../../../shared/folder-workspace-host-routing.test-fixture'
|
||||
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { adoptFromEndpoint } from '../store/adopt-from-endpoint'
|
||||
import { buildHostIdByWorktreeId } from './workspace-session-host-persistence'
|
||||
|
||||
// Main's router is checked against the same table in folder-workspace-mixed-host-routing.test.ts.
|
||||
describe("the window's save partition for each folder kind local main lists", () => {
|
||||
it.each(FOLDER_HOST_CASES)('$name', ({ folder, groups, expected }) => {
|
||||
const local = { kind: 'local' } as const
|
||||
const stampedGroups = groups.map((row) =>
|
||||
adoptFromEndpoint(local, { kind: 'projectGroup', row })
|
||||
)
|
||||
const stampedFolder = adoptFromEndpoint(local, {
|
||||
kind: 'folderWorkspace',
|
||||
row: folder,
|
||||
// As the window's catalog resolver does, over the groups it already stamped.
|
||||
resolveOwnHostId: (row) => getFolderWorkspaceHostId(row, stampedGroups)
|
||||
})
|
||||
const hostIdOf = buildHostIdByWorktreeId({
|
||||
repos: [],
|
||||
projectGroups: stampedGroups,
|
||||
folderWorkspaces: [stampedFolder],
|
||||
worktreesByRepo: {}
|
||||
})
|
||||
expect(hostIdOf(folderWorkspaceKey(folder.id))).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -5,14 +5,10 @@ import type { TerminalTab } from '../../../shared/terminal-tab-types'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import {
|
||||
indexWorktreeHostClaims,
|
||||
mergeWorkspaceSessionsWithHostShadow
|
||||
} from './workspace-session-host-contention'
|
||||
import { pickPrimaryHostForClaims } from '../../../shared/workspace-session-contested-primary-host'
|
||||
import './workspace-session-host-contention'
|
||||
import '../../../shared/workspace-session-contested-primary-host'
|
||||
import { fetchWorkspaceSessionWithRuntimeHostOwners } from './workspace-session-host-hydration'
|
||||
import {
|
||||
buildHostIdByWorktreeId,
|
||||
patchWorkspaceSessionByHost,
|
||||
persistWorkspaceSessionByHost,
|
||||
type HostPersistenceState
|
||||
@@ -58,86 +54,6 @@ function contestedState(overrides: Partial<HostPersistenceState> = {}): HostPers
|
||||
}
|
||||
}
|
||||
|
||||
describe('indexWorktreeHostClaims', () => {
|
||||
it('records every host publishing the same workspace id', () => {
|
||||
const claims = indexWorktreeHostClaims(contestedState().worktreesByRepo, new Map())
|
||||
|
||||
expect([...(claims.get(SHARED_ID) ?? [])].sort()).toEqual(['local', SSH_HOST])
|
||||
})
|
||||
|
||||
it('attributes an unqualified row through its repo when the repo names one host', () => {
|
||||
const claims = indexWorktreeHostClaims(
|
||||
{ 'repo-a': [{ id: 'repo-a::/work/a', repoId: 'repo-a' }] },
|
||||
new Map([['repo-a', SSH_HOST]])
|
||||
)
|
||||
|
||||
expect([...(claims.get('repo-a::/work/a') ?? [])]).toEqual([SSH_HOST])
|
||||
})
|
||||
|
||||
it('leaves an unqualified row unattributed when its repo id is itself ambiguous', () => {
|
||||
const claims = indexWorktreeHostClaims(
|
||||
{ 'repo-a': [{ id: 'repo-a::/work/a', repoId: 'repo-a' }] },
|
||||
new Map([['repo-a', null]])
|
||||
)
|
||||
|
||||
expect(claims.has('repo-a::/work/a')).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers local as primary, else the lowest host id', () => {
|
||||
expect(pickPrimaryHostForClaims([SSH_HOST, 'local'])).toBe('local')
|
||||
expect(pickPrimaryHostForClaims(['runtime:b', 'runtime:a'])).toBe('runtime:a')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildHostIdByWorktreeId for a contested workspace id', () => {
|
||||
it('routes a local/SSH collision to one deterministic primary', () => {
|
||||
expect(buildHostIdByWorktreeId(contestedState())(SHARED_ID)).toBe('local')
|
||||
})
|
||||
|
||||
it('gives two runtime claimants a runtime primary instead of folding them into local', () => {
|
||||
const owner = buildHostIdByWorktreeId({
|
||||
repos: [],
|
||||
worktreesByRepo: {
|
||||
'repo-shared': [
|
||||
{ id: SHARED_ID, repoId: 'repo-shared', hostId: 'runtime:env-b' },
|
||||
{ id: SHARED_ID, repoId: 'repo-shared', hostId: 'runtime:env-a' }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(owner(SHARED_ID)).toBe('runtime:env-a')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeWorkspaceSessionsWithHostShadow', () => {
|
||||
it('parks a co-claimant partition entry instead of letting it win the shared key', () => {
|
||||
const merged = mergeWorkspaceSessionsWithHostShadow({
|
||||
local: sessionWithTabs({ [SHARED_ID]: [tab('local-tab')] }),
|
||||
[SSH_HOST]: sessionWithTabs({ [SHARED_ID]: [tab('ssh-tab')] })
|
||||
})
|
||||
|
||||
expect(merged.session.tabsByWorktree[SHARED_ID]?.map((entry) => entry.id)).toEqual([
|
||||
'local-tab'
|
||||
])
|
||||
expect(
|
||||
(merged.shadow[SSH_HOST]?.tabsByWorktree?.[SHARED_ID] ?? []).map((entry) => entry.id)
|
||||
).toEqual(['ssh-tab'])
|
||||
expect(merged.shadow.local).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves uncontested partitions untouched', () => {
|
||||
const merged = mergeWorkspaceSessionsWithHostShadow({
|
||||
local: sessionWithTabs({ 'repo-a::/a': [tab('a', 'repo-a::/a')] }),
|
||||
'runtime:env-1': sessionWithTabs({
|
||||
'repo-b::/b': [tab('b', 'repo-b::/b')]
|
||||
})
|
||||
})
|
||||
|
||||
expect(Object.keys(merged.session.tabsByWorktree).sort()).toEqual(['repo-a::/a', 'repo-b::/b'])
|
||||
expect(merged.shadow).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
type SessionWriteMock = Mock<
|
||||
(session: WorkspaceSessionState, hostId?: ExecutionHostId) => Promise<void>
|
||||
>
|
||||
@@ -318,11 +234,6 @@ describe('read-time primary is the one the write path honours', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it('keeps an SSH claimant out of the rotating runtime partition', () => {
|
||||
// Claim sorting favors runtime over ssh, which must not move SSH rows out of their own partition.
|
||||
expect(buildHostIdByWorktreeId(sshVersusRuntimeState())(SHARED_ID)).toBe(SSH_HOST)
|
||||
})
|
||||
|
||||
it('does not strand the runtime co-claimant when the SSH row is written', async () => {
|
||||
const set: SessionWriteMock = vi.fn(async () => {})
|
||||
await persistWorkspaceSessionByHost(
|
||||
@@ -371,33 +282,6 @@ describe('read-time primary is the one the write path honours', () => {
|
||||
expect(localWrite?.tabsByWorktree[SHARED_ID]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('still migrates a workspace the catalog has re-attributed to another partition', () => {
|
||||
const owner = buildHostIdByWorktreeId({
|
||||
repos: [],
|
||||
worktreesByRepo: {
|
||||
'repo-shared': [{ id: SHARED_ID, repoId: 'repo-shared', hostId: RUNTIME_HOST }]
|
||||
},
|
||||
contestedPrimaryHostBySessionKey: { [SHARED_ID]: 'local' }
|
||||
})
|
||||
|
||||
expect(owner(SHARED_ID)).toBe(RUNTIME_HOST)
|
||||
})
|
||||
|
||||
it('records the partition every restored key came from, contested or not', () => {
|
||||
const merged = mergeWorkspaceSessionsWithHostShadow({
|
||||
local: sessionWithTabs({ [SHARED_ID]: [tab('local-tab')] }),
|
||||
[RUNTIME_HOST]: sessionWithTabs({
|
||||
[SHARED_ID]: [tab('runtime-tab')],
|
||||
[RUNTIME_ONLY_ID]: [tab('runtime-only-tab', RUNTIME_ONLY_ID)]
|
||||
})
|
||||
})
|
||||
|
||||
expect(merged.primaryHostBySessionKey).toEqual({
|
||||
[SHARED_ID]: 'local',
|
||||
[RUNTIME_ONLY_ID]: RUNTIME_HOST
|
||||
})
|
||||
})
|
||||
|
||||
it('does not name a runtime owner for a key the local partition kept', async () => {
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
{
|
||||
|
||||
@@ -2,18 +2,12 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../../shared/constants'
|
||||
import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import {
|
||||
buildHostIdByWorktreeId,
|
||||
buildWorkspaceSessionHostSnapshots,
|
||||
patchWorkspaceSessionByHost,
|
||||
persistWorkspaceSessionByHost,
|
||||
type HostPersistenceState
|
||||
} from './workspace-session-host-persistence'
|
||||
import {
|
||||
fetchWorkspaceSessionFromHosts,
|
||||
fetchWorkspaceSessionWithRuntimeHostOwners
|
||||
} from './workspace-session-host-hydration'
|
||||
import { fetchWorkspaceSessionFromHosts } from './workspace-session-host-hydration'
|
||||
|
||||
describe('fetchWorkspaceSessionFromHosts', () => {
|
||||
it('reads saved runtime host partitions before runtime repos are loaded', async () => {
|
||||
@@ -51,140 +45,6 @@ describe('fetchWorkspaceSessionFromHosts', () => {
|
||||
expect(session.tabsByWorktree[worktreeId]).toEqual(remoteSession.tabsByWorktree[worktreeId])
|
||||
})
|
||||
|
||||
it('returns runtime owners for worktrees loaded from runtime host partitions', async () => {
|
||||
const worktreeId = 'remote-repo::/srv/remote-wt'
|
||||
const get = vi.fn(async (hostId?: string): Promise<WorkspaceSessionState> => {
|
||||
if (hostId === 'runtime:env-1') {
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [
|
||||
{
|
||||
id: 'remote-tab',
|
||||
ptyId: null,
|
||||
worktreeId,
|
||||
title: 'Remote',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
return getDefaultWorkspaceSession()
|
||||
})
|
||||
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, [], ['runtime:env-1'])
|
||||
|
||||
expect(read.session.tabsByWorktree[worktreeId]).toHaveLength(1)
|
||||
expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({
|
||||
[worktreeId]: 'runtime:env-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes canonical worktree session keys in runtime owner maps', async () => {
|
||||
const worktreeId = 'remote-repo::/srv/remote-wt'
|
||||
const workspaceKey = worktreeWorkspaceKey(worktreeId)
|
||||
const get = vi.fn(async (hostId?: string): Promise<WorkspaceSessionState> => {
|
||||
if (hostId === 'runtime:env-1') {
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
[workspaceKey]: [
|
||||
{
|
||||
id: 'remote-tab',
|
||||
ptyId: null,
|
||||
worktreeId,
|
||||
title: 'Remote',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
return getDefaultWorkspaceSession()
|
||||
})
|
||||
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, [], ['runtime:env-1'])
|
||||
|
||||
expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({
|
||||
[worktreeId]: 'runtime:env-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns runtime owners for folder workspace session keys', async () => {
|
||||
const folderKey = folderWorkspaceKey('folder-1')
|
||||
const get = vi.fn(async (hostId?: string): Promise<WorkspaceSessionState> => {
|
||||
if (hostId === 'runtime:env-1') {
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeWorkspaceKey: folderKey,
|
||||
tabsByWorktree: {
|
||||
[folderKey]: [
|
||||
{
|
||||
id: 'remote-folder-tab',
|
||||
ptyId: null,
|
||||
worktreeId: folderKey,
|
||||
title: 'Remote folder',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
return getDefaultWorkspaceSession()
|
||||
})
|
||||
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, [], ['runtime:env-1'])
|
||||
|
||||
expect(read.session.tabsByWorktree[folderKey]).toHaveLength(1)
|
||||
expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({
|
||||
[folderKey]: 'runtime:env-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns runtime owners for sleeping-agent-only runtime worktrees', async () => {
|
||||
const worktreeId = 'remote-repo::/srv/sleeping-wt'
|
||||
const get = vi.fn(async (hostId?: string): Promise<WorkspaceSessionState> => {
|
||||
if (hostId === 'runtime:env-1') {
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
'remote-tab:leaf-1': {
|
||||
paneKey: 'remote-tab:leaf-1',
|
||||
tabId: 'remote-tab',
|
||||
worktreeId,
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'codex-session-1' },
|
||||
prompt: 'finish the task',
|
||||
state: 'working',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return getDefaultWorkspaceSession()
|
||||
})
|
||||
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, [], ['runtime:env-1'])
|
||||
|
||||
expect(read.session.sleepingAgentSessionsByPaneKey?.['remote-tab:leaf-1']?.worktreeId).toBe(
|
||||
worktreeId
|
||||
)
|
||||
expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({
|
||||
[worktreeId]: 'runtime:env-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes restored runtime folder workspace patches back to the runtime host', async () => {
|
||||
const folderKey = folderWorkspaceKey('folder-1')
|
||||
const patch = vi.fn().mockResolvedValue(undefined)
|
||||
@@ -391,88 +251,6 @@ describe('fetchWorkspaceSessionFromHosts', () => {
|
||||
'runtime:env-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults duplicate repo ids to local when the worktree has no host metadata', () => {
|
||||
const owner = buildHostIdByWorktreeId({
|
||||
repos: [
|
||||
{ id: 'same-repo', connectionId: null, executionHostId: 'local' },
|
||||
{
|
||||
id: 'same-repo',
|
||||
connectionId: null,
|
||||
executionHostId: 'runtime:env-1'
|
||||
}
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'same-repo': [{ id: 'same-repo::/local-only', repoId: 'same-repo' }]
|
||||
}
|
||||
})
|
||||
|
||||
expect(owner('same-repo::/local-only')).toBe('local')
|
||||
})
|
||||
|
||||
it('routes canonical worktree keys using their raw worktree owner', () => {
|
||||
const worktreeId = 'remote-repo::/srv/remote-wt'
|
||||
const owner = buildHostIdByWorktreeId({
|
||||
repos: [],
|
||||
worktreesByRepo: {
|
||||
'remote-repo': [{ id: worktreeId, repoId: 'remote-repo', hostId: 'runtime:env-1' }]
|
||||
}
|
||||
})
|
||||
|
||||
expect(owner(worktreeWorkspaceKey(worktreeId))).toBe('runtime:env-1')
|
||||
})
|
||||
|
||||
it('builds local-first host snapshots', () => {
|
||||
const localWorktreeId = 'local-repo::C:\\src\\local'
|
||||
const remoteWorktreeId = 'remote-repo::/srv/remote'
|
||||
const makeTab = (id: string, worktreeId: string) => ({
|
||||
id,
|
||||
ptyId: null,
|
||||
worktreeId,
|
||||
title: id,
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
})
|
||||
const payload: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
[localWorktreeId]: [makeTab('local-tab', localWorktreeId)],
|
||||
[remoteWorktreeId]: [makeTab('remote-tab', remoteWorktreeId)]
|
||||
}
|
||||
}
|
||||
const state = {
|
||||
repos: [
|
||||
{ id: 'local-repo', connectionId: null, executionHostId: 'local' },
|
||||
{
|
||||
id: 'remote-repo',
|
||||
connectionId: null,
|
||||
executionHostId: 'runtime:env-1'
|
||||
}
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'local-repo': [{ id: localWorktreeId, repoId: 'local-repo' }],
|
||||
'remote-repo': [
|
||||
{
|
||||
id: remoteWorktreeId,
|
||||
repoId: 'remote-repo',
|
||||
hostId: 'runtime:env-1'
|
||||
}
|
||||
]
|
||||
}
|
||||
} satisfies HostPersistenceState
|
||||
|
||||
const snapshots = buildWorkspaceSessionHostSnapshots(payload, state)
|
||||
|
||||
expect(snapshots.map((snapshot) => snapshot.hostId)).toEqual([undefined, 'runtime:env-1'])
|
||||
expect(snapshots[0].state.tabsByWorktree).toEqual({
|
||||
[localWorktreeId]: [expect.objectContaining({ id: 'local-tab' })]
|
||||
})
|
||||
expect(snapshots[1].state.tabsByWorktree).toEqual({
|
||||
[remoteWorktreeId]: [expect.objectContaining({ id: 'remote-tab' })]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('patchWorkspaceSessionByHost write outcome', () => {
|
||||
@@ -600,105 +378,3 @@ describe('patchWorkspaceSessionByHost tab-keyed routing', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildHostIdByWorktreeId nested ownership', () => {
|
||||
it('persists an SSH worktree in its paired HUB session partition', () => {
|
||||
const worktreeId = 'nested-repo::/srv/remote-wt'
|
||||
const owner = buildHostIdByWorktreeId({
|
||||
repos: [
|
||||
{
|
||||
id: 'nested-repo',
|
||||
connectionId: 'hub-private-ssh',
|
||||
executionHostId: 'runtime:owner-hub'
|
||||
}
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'nested-repo': [
|
||||
{
|
||||
id: worktreeId,
|
||||
repoId: 'nested-repo',
|
||||
hostId: 'ssh:hub-private-ssh',
|
||||
runtimeOwnerEnvironmentId: 'owner-hub'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(owner(worktreeId)).toBe('runtime:owner-hub')
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistWorkspaceSessionByHost', () => {
|
||||
it('awaits every host write before crossing the durable flush boundary', async () => {
|
||||
const localWorktreeId = 'local-repo::/src/local'
|
||||
const remoteWorktreeId = 'remote-repo::/srv/remote'
|
||||
const payload: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
[localWorktreeId]: [
|
||||
{
|
||||
id: 'local-tab',
|
||||
ptyId: null,
|
||||
worktreeId: localWorktreeId,
|
||||
title: 'Local',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
],
|
||||
[remoteWorktreeId]: [
|
||||
{
|
||||
id: 'remote-tab',
|
||||
ptyId: null,
|
||||
worktreeId: remoteWorktreeId,
|
||||
title: 'Remote',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
const set = vi.fn().mockResolvedValue(undefined)
|
||||
const flush = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await persistWorkspaceSessionByHost({ get: vi.fn(), set, patch: vi.fn(), flush }, payload, {
|
||||
repos: [
|
||||
{ id: 'local-repo', connectionId: null, executionHostId: 'local' },
|
||||
{
|
||||
id: 'remote-repo',
|
||||
connectionId: null,
|
||||
executionHostId: 'runtime:env-1'
|
||||
}
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'local-repo': [{ id: localWorktreeId, repoId: 'local-repo' }],
|
||||
'remote-repo': [
|
||||
{
|
||||
id: remoteWorktreeId,
|
||||
repoId: 'remote-repo',
|
||||
hostId: 'runtime:env-1'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(set).toHaveBeenCalledTimes(2)
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tabsByWorktree: { [localWorktreeId]: expect.any(Array) }
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tabsByWorktree: { [remoteWorktreeId]: expect.any(Array) }
|
||||
}),
|
||||
'runtime:env-1'
|
||||
)
|
||||
expect(flush).toHaveBeenCalledTimes(1)
|
||||
expect(set.mock.invocationCallOrder.at(-1)).toBeLessThan(flush.mock.invocationCallOrder[0]!)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,500 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
splitWorkspaceSessionByHost,
|
||||
mergeWorkspaceSessionsFromHosts,
|
||||
type HostIdByWorktreeId
|
||||
} from './workspace-session-host-split'
|
||||
import { getDefaultWorkspaceSession } from '../../../shared/constants'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS } from '../../../shared/workspace-session-host-field-ownership'
|
||||
import type { BrowserPage } from '../../../shared/browser-workspace-types'
|
||||
import type { Tab } from '../../../shared/tab-types'
|
||||
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
|
||||
const RUNTIME_A: ExecutionHostId = 'runtime:env-a'
|
||||
const RUNTIME_B: ExecutionHostId = 'runtime:env-b'
|
||||
|
||||
function makeTab(id: string, worktreeId: string): TerminalTab {
|
||||
return {
|
||||
id,
|
||||
ptyId: null,
|
||||
worktreeId,
|
||||
title: id,
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function makeUnifiedTab(id: string, worktreeId: string): Tab {
|
||||
return {
|
||||
id,
|
||||
entityId: id,
|
||||
groupId: `group-${worktreeId}`,
|
||||
worktreeId,
|
||||
contentType: 'terminal',
|
||||
label: id,
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function makeLayout(): TerminalLayoutSnapshot {
|
||||
return { root: { type: 'leaf', leafId: 'leaf-1' }, activeLeafId: 'leaf-1', expandedLeafId: null }
|
||||
}
|
||||
|
||||
function makeBrowserPage(id: string, workspaceId: string, worktreeId: string): BrowserPage {
|
||||
return {
|
||||
id,
|
||||
workspaceId,
|
||||
worktreeId,
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
/** worktree id convention in these tests: `<host>-wt-...`, except local ones. */
|
||||
function ownerByPrefix(): HostIdByWorktreeId {
|
||||
return (worktreeId: string) => {
|
||||
if (worktreeId.startsWith('a-')) {
|
||||
return RUNTIME_A
|
||||
}
|
||||
if (worktreeId.startsWith('b-')) {
|
||||
return RUNTIME_B
|
||||
}
|
||||
return LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
}
|
||||
|
||||
describe('splitWorkspaceSessionByHost', () => {
|
||||
it('keeps global fields only on the local slice', () => {
|
||||
const state: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorktreeId: 'local-wt',
|
||||
activeTabId: 'tab-1',
|
||||
browserUrlHistory: [
|
||||
{ url: 'u', normalizedUrl: 'u', title: 't', lastVisitedAt: 1, visitCount: 1 }
|
||||
],
|
||||
activeConnectionIdsAtShutdown: ['ssh-target']
|
||||
}
|
||||
|
||||
const slices = splitWorkspaceSessionByHost(state, ownerByPrefix())
|
||||
|
||||
expect(slices[LOCAL_EXECUTION_HOST_ID]?.activeRepoId).toBe('repo-1')
|
||||
expect(slices[LOCAL_EXECUTION_HOST_ID]?.activeConnectionIdsAtShutdown).toEqual(['ssh-target'])
|
||||
// No runtime slice is created when nothing is worktree-owned by it.
|
||||
expect(slices[RUNTIME_A]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never replicates a local-owned global onto a non-local slice', () => {
|
||||
// One global template copied local browserUrlHistory into every runtime, undoing #18161 on the next save (~66KB/host at 200 entries).
|
||||
const state: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
browserUrlHistory: [
|
||||
{ url: 'u', normalizedUrl: 'u', title: 't', lastVisitedAt: 1, visitCount: 1 }
|
||||
],
|
||||
workspaceDocHistory: [
|
||||
{
|
||||
docLocation: { kind: 'workspace-doc', worktreeId: 'local-wt', filePath: 'a.md' },
|
||||
title: 'a.md',
|
||||
lastVisitedAt: 2,
|
||||
visitCount: 1
|
||||
}
|
||||
],
|
||||
tabsByWorktree: {
|
||||
'local-wt': [makeTab('t-local', 'local-wt')],
|
||||
'a-wt': [makeTab('t-a', 'a-wt')],
|
||||
'b-wt': [makeTab('t-b', 'b-wt')]
|
||||
}
|
||||
}
|
||||
|
||||
const slices = splitWorkspaceSessionByHost(state, ownerByPrefix())
|
||||
|
||||
expect(Object.keys(slices).sort()).toEqual([LOCAL_EXECUTION_HOST_ID, RUNTIME_A, RUNTIME_B])
|
||||
for (const field of HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS) {
|
||||
expect(slices[LOCAL_EXECUTION_HOST_ID]?.[field]).toEqual(state[field])
|
||||
expect(Object.hasOwn(slices[RUNTIME_A] ?? {}, field)).toBe(false)
|
||||
expect(Object.hasOwn(slices[RUNTIME_B] ?? {}, field)).toBe(false)
|
||||
}
|
||||
// Local already supplies globals, so merge never falls back to remote copies.
|
||||
const merged = mergeWorkspaceSessionsFromHosts(slices)
|
||||
for (const field of HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS) {
|
||||
expect(merged[field]).toEqual(state[field])
|
||||
}
|
||||
})
|
||||
|
||||
it('routes worktree-keyed maps to their owner host', () => {
|
||||
const state: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
'local-wt': [makeTab('t-local', 'local-wt')],
|
||||
'a-wt': [makeTab('t-a', 'a-wt')],
|
||||
'b-wt': [makeTab('t-b', 'b-wt')]
|
||||
}
|
||||
}
|
||||
|
||||
const slices = splitWorkspaceSessionByHost(state, ownerByPrefix())
|
||||
|
||||
expect(Object.keys(slices[LOCAL_EXECUTION_HOST_ID]?.tabsByWorktree ?? {})).toEqual(['local-wt'])
|
||||
expect(Object.keys(slices[RUNTIME_A]?.tabsByWorktree ?? {})).toEqual(['a-wt'])
|
||||
expect(Object.keys(slices[RUNTIME_B]?.tabsByWorktree ?? {})).toEqual(['b-wt'])
|
||||
})
|
||||
|
||||
it('routes host-qualified visit recency to the partition the key names', () => {
|
||||
const state: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
lastVisitedAtByWorktreeId: {
|
||||
'local-wt': 1,
|
||||
'a-wt': 2,
|
||||
'ssh:builder|ssh-wt': 3,
|
||||
'runtime:env-a|a-wt': 4
|
||||
}
|
||||
}
|
||||
|
||||
const slices = splitWorkspaceSessionByHost(state, ownerByPrefix())
|
||||
|
||||
// Host-qualified recency must share its workspace’s partition or adoption records visits without corresponding tabs (#12721).
|
||||
expect(slices[LOCAL_EXECUTION_HOST_ID]?.lastVisitedAtByWorktreeId).toEqual({ 'local-wt': 1 })
|
||||
expect(slices[RUNTIME_A]?.lastVisitedAtByWorktreeId).toEqual({
|
||||
'a-wt': 2,
|
||||
'runtime:env-a|a-wt': 4
|
||||
})
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the literal is a well-formed ssh: host id; ExecutionHostId is a template-literal type a plain string cannot satisfy.
|
||||
expect(slices['ssh:builder' as ExecutionHostId]?.lastVisitedAtByWorktreeId).toEqual({
|
||||
'ssh:builder|ssh-wt': 3
|
||||
})
|
||||
})
|
||||
|
||||
it('routes tab-keyed maps via the owning tab worktree (legacy + unified)', () => {
|
||||
const state: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: { 'a-wt': [makeTab('t-a', 'a-wt')] },
|
||||
unifiedTabs: { 'b-wt': [makeUnifiedTab('t-b', 'b-wt')] },
|
||||
terminalLayoutsByTabId: { 't-a': makeLayout(), 't-b': makeLayout() },
|
||||
remoteSessionIdsByTabId: { 't-a': 'sess-a', 't-b': 'sess-b' }
|
||||
}
|
||||
|
||||
const slices = splitWorkspaceSessionByHost(state, ownerByPrefix())
|
||||
|
||||
expect(slices[RUNTIME_A]?.terminalLayoutsByTabId).toHaveProperty('t-a')
|
||||
expect(slices[RUNTIME_B]?.terminalLayoutsByTabId).toHaveProperty('t-b')
|
||||
expect(slices[RUNTIME_A]?.remoteSessionIdsByTabId).toEqual({ 't-a': 'sess-a' })
|
||||
expect(slices[RUNTIME_B]?.remoteSessionIdsByTabId).toEqual({ 't-b': 'sess-b' })
|
||||
})
|
||||
|
||||
it('keeps orphan tab layouts (unknown worktree) in the local slice', () => {
|
||||
const state: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
terminalLayoutsByTabId: { orphan: makeLayout() }
|
||||
}
|
||||
|
||||
const slices = splitWorkspaceSessionByHost(state, ownerByPrefix())
|
||||
|
||||
expect(slices[LOCAL_EXECUTION_HOST_ID]?.terminalLayoutsByTabId).toHaveProperty('orphan')
|
||||
expect(slices[RUNTIME_A]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('routes tab-keyed rows through a caller-supplied tab index when the payload has no tab rows', () => {
|
||||
// Layouts/PTY-only patches omit tabsByWorktree; use the live tab index for ownership.
|
||||
const state: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
terminalLayoutsByTabId: { 't-a': makeLayout() },
|
||||
remoteSessionIdsByTabId: { 't-a': 'sess-a' },
|
||||
terminalPtyIncarnationsByPaneKey: { 't-a:leaf-1': 'inc-3' }
|
||||
}
|
||||
const slices = splitWorkspaceSessionByHost(state, ownerByPrefix(), {
|
||||
worktreeIdByTabId: new Map([['t-a', 'a-wt-1']])
|
||||
})
|
||||
expect(slices[RUNTIME_A]?.terminalLayoutsByTabId).toHaveProperty('t-a')
|
||||
expect(slices[RUNTIME_A]?.remoteSessionIdsByTabId).toEqual({ 't-a': 'sess-a' })
|
||||
expect(slices[RUNTIME_A]?.terminalPtyIncarnationsByPaneKey).toEqual({ 't-a:leaf-1': 'inc-3' })
|
||||
expect(slices[LOCAL_EXECUTION_HOST_ID]?.terminalLayoutsByTabId).toEqual({})
|
||||
expect(slices[LOCAL_EXECUTION_HOST_ID]?.remoteSessionIdsByTabId).toEqual({})
|
||||
})
|
||||
|
||||
it('keeps a tab the supplied index does not name in the local slice', () => {
|
||||
const state: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
terminalLayoutsByTabId: { orphan: makeLayout() }
|
||||
}
|
||||
const slices = splitWorkspaceSessionByHost(state, ownerByPrefix(), {
|
||||
worktreeIdByTabId: new Map([['t-a', 'a-wt-1']])
|
||||
})
|
||||
expect(slices[LOCAL_EXECUTION_HOST_ID]?.terminalLayoutsByTabId).toHaveProperty('orphan')
|
||||
expect(slices[RUNTIME_A]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('routes browser pages via their record worktreeId', () => {
|
||||
const state: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
browserPagesByWorkspace: {
|
||||
'ws-a': [makeBrowserPage('p-a', 'ws-a', 'a-wt')],
|
||||
'ws-local': [makeBrowserPage('p-local', 'ws-local', 'local-wt')]
|
||||
}
|
||||
}
|
||||
|
||||
const slices = splitWorkspaceSessionByHost(state, ownerByPrefix())
|
||||
|
||||
expect(slices[RUNTIME_A]?.browserPagesByWorkspace).toHaveProperty('ws-a')
|
||||
expect(slices[LOCAL_EXECUTION_HOST_ID]?.browserPagesByWorkspace).toHaveProperty('ws-local')
|
||||
})
|
||||
|
||||
it('routes markdown frontmatter visibility via the open file worktree', () => {
|
||||
const state: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
openFilesByWorktree: {
|
||||
'a-wt': [
|
||||
{
|
||||
filePath: '/a/file.md',
|
||||
relativePath: 'file.md',
|
||||
worktreeId: 'a-wt',
|
||||
language: 'markdown'
|
||||
}
|
||||
]
|
||||
},
|
||||
markdownFrontmatterVisible: { '/a/file.md': true, '/unknown.md': true }
|
||||
}
|
||||
|
||||
const slices = splitWorkspaceSessionByHost(state, ownerByPrefix())
|
||||
|
||||
expect(slices[RUNTIME_A]?.markdownFrontmatterVisible).toEqual({ '/a/file.md': true })
|
||||
// Unknown file id has no owner → stays local.
|
||||
expect(slices[LOCAL_EXECUTION_HOST_ID]?.markdownFrontmatterVisible).toEqual({
|
||||
'/unknown.md': true
|
||||
})
|
||||
})
|
||||
|
||||
it('partitions activeWorktreeIdsOnShutdown by owner', () => {
|
||||
const state: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeWorktreeIdsOnShutdown: ['a-wt', 'b-wt', 'local-wt']
|
||||
}
|
||||
|
||||
const slices = splitWorkspaceSessionByHost(state, ownerByPrefix())
|
||||
|
||||
expect(slices[RUNTIME_A]?.activeWorktreeIdsOnShutdown).toEqual(['a-wt'])
|
||||
expect(slices[RUNTIME_B]?.activeWorktreeIdsOnShutdown).toEqual(['b-wt'])
|
||||
expect(slices[LOCAL_EXECUTION_HOST_ID]?.activeWorktreeIdsOnShutdown).toEqual(['local-wt'])
|
||||
})
|
||||
|
||||
it('routes sleeping agent records via their worktreeId', () => {
|
||||
const state: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
'pane-a': {
|
||||
paneKey: 'pane-a',
|
||||
worktreeId: 'a-wt',
|
||||
agent: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'x' },
|
||||
prompt: 'p',
|
||||
state: 'done',
|
||||
capturedAt: 1,
|
||||
updatedAt: 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const slices = splitWorkspaceSessionByHost(state, ownerByPrefix())
|
||||
|
||||
expect(slices[RUNTIME_A]?.sleepingAgentSessionsByPaneKey).toHaveProperty('pane-a')
|
||||
})
|
||||
|
||||
it('routes terminal incarnation authority with its owning surface', () => {
|
||||
const state: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: { 'a-wt': [makeTab('tab-a', 'a-wt')] },
|
||||
terminalPtyIncarnationsByPaneKey: { 'tab-a:leaf-a': 'inc-a' },
|
||||
terminalSurfaceTombstonesByPaneKey: {
|
||||
'tab-b:leaf-b': {
|
||||
worktreeId: 'b-wt',
|
||||
parentTabId: 'tab-b',
|
||||
leafId: 'leaf-b',
|
||||
ptyId: 'pty-b',
|
||||
incarnationId: 'inc-b',
|
||||
retiredAt: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const slices = splitWorkspaceSessionByHost(state, ownerByPrefix())
|
||||
|
||||
expect(slices[RUNTIME_A]?.terminalPtyIncarnationsByPaneKey).toEqual({
|
||||
'tab-a:leaf-a': 'inc-a'
|
||||
})
|
||||
expect(slices[RUNTIME_B]?.terminalSurfaceTombstonesByPaneKey).toHaveProperty('tab-b:leaf-b')
|
||||
})
|
||||
|
||||
it('does not send host-private topology authority through renderer partitions', () => {
|
||||
const state: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: { 'a-wt': [makeTab('tab-a', 'a-wt')] },
|
||||
terminalTopologyRevisionByRepoId: { 'a-repo': 7 }
|
||||
}
|
||||
|
||||
const slices = splitWorkspaceSessionByHost(state, ownerByPrefix())
|
||||
|
||||
expect(slices[LOCAL_EXECUTION_HOST_ID]?.terminalTopologyRevisionByRepoId).toBeUndefined()
|
||||
expect(slices[RUNTIME_A]?.terminalTopologyRevisionByRepoId).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeWorkspaceSessionsFromHosts', () => {
|
||||
it('takes global fields from the local slice', () => {
|
||||
const merged = mergeWorkspaceSessionsFromHosts({
|
||||
[LOCAL_EXECUTION_HOST_ID]: {
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeRepoId: 'local-repo',
|
||||
activeWorktreeId: 'local-wt'
|
||||
},
|
||||
[RUNTIME_A]: {
|
||||
...getDefaultWorkspaceSession(),
|
||||
// A non-local slice's global fields must lose to local.
|
||||
activeRepoId: 'runtime-repo',
|
||||
tabsByWorktree: { 'a-wt': [makeTab('t-a', 'a-wt')] }
|
||||
}
|
||||
})
|
||||
|
||||
expect(merged.activeRepoId).toBe('local-repo')
|
||||
expect(merged.tabsByWorktree).toHaveProperty('a-wt')
|
||||
})
|
||||
|
||||
it('falls back to a non-local slice for globals when local is absent', () => {
|
||||
const merged = mergeWorkspaceSessionsFromHosts({
|
||||
[RUNTIME_A]: {
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeRepoId: 'runtime-repo'
|
||||
}
|
||||
})
|
||||
expect(merged.activeRepoId).toBe('runtime-repo')
|
||||
})
|
||||
|
||||
it('tolerates missing and empty slices', () => {
|
||||
const merged = mergeWorkspaceSessionsFromHosts({})
|
||||
expect(merged.tabsByWorktree).toBeUndefined()
|
||||
expect(() => mergeWorkspaceSessionsFromHosts({ [RUNTIME_A]: undefined })).not.toThrow()
|
||||
})
|
||||
|
||||
it('does not merge host-private topology authority into unified renderer state', () => {
|
||||
const merged = mergeWorkspaceSessionsFromHosts({
|
||||
[LOCAL_EXECUTION_HOST_ID]: {
|
||||
...getDefaultWorkspaceSession(),
|
||||
terminalTopologyRevisionByRepoId: { duplicate: 3 }
|
||||
},
|
||||
[RUNTIME_A]: {
|
||||
...getDefaultWorkspaceSession(),
|
||||
terminalTopologyRevisionByRepoId: { duplicate: 9 }
|
||||
}
|
||||
})
|
||||
|
||||
expect(merged.terminalTopologyRevisionByRepoId).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('split → merge round trip', () => {
|
||||
function roundTrip(state: WorkspaceSessionState): WorkspaceSessionState {
|
||||
return mergeWorkspaceSessionsFromHosts(splitWorkspaceSessionByHost(state, ownerByPrefix()))
|
||||
}
|
||||
|
||||
it('preserves a representative multi-host state', () => {
|
||||
const state: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorktreeId: 'a-wt',
|
||||
activeTabId: 't-a',
|
||||
tabsByWorktree: {
|
||||
'local-wt': [makeTab('t-local', 'local-wt')],
|
||||
'a-wt': [makeTab('t-a', 'a-wt')],
|
||||
'b-wt': [makeTab('t-b', 'b-wt')]
|
||||
},
|
||||
unifiedTabs: { 'b-wt': [makeUnifiedTab('t-b', 'b-wt')] },
|
||||
terminalLayoutsByTabId: { 't-local': makeLayout(), 't-a': makeLayout(), 't-b': makeLayout() },
|
||||
remoteSessionIdsByTabId: { 't-a': 'sess-a' },
|
||||
activeTabIdByWorktree: { 'local-wt': 't-local', 'a-wt': 't-a' },
|
||||
activeWorktreeIdsOnShutdown: ['a-wt', 'b-wt'],
|
||||
lastVisitedAtByWorktreeId: { 'a-wt': 10, 'local-wt': 5 },
|
||||
defaultTerminalTabsAppliedByWorktreeId: { 'a-wt': true },
|
||||
browserTabsByWorktree: {},
|
||||
browserPagesByWorkspace: {},
|
||||
browserUrlHistory: [
|
||||
{ url: 'u', normalizedUrl: 'u', title: 't', lastVisitedAt: 1, visitCount: 1 }
|
||||
]
|
||||
}
|
||||
|
||||
expect(roundTrip(state)).toEqual(state)
|
||||
})
|
||||
|
||||
it('preserves the default (empty) session', () => {
|
||||
const state = getDefaultWorkspaceSession()
|
||||
expect(roundTrip(state)).toEqual(state)
|
||||
})
|
||||
|
||||
it('keeps orphan-owned entries in local and preserves them', () => {
|
||||
const state: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
terminalLayoutsByTabId: { orphan: makeLayout() },
|
||||
remoteSessionIdsByTabId: { orphan: 'sess' }
|
||||
}
|
||||
const result = roundTrip(state)
|
||||
expect(result.terminalLayoutsByTabId).toEqual(state.terminalLayoutsByTabId)
|
||||
expect(result.remoteSessionIdsByTabId).toEqual(state.remoteSessionIdsByTabId)
|
||||
})
|
||||
|
||||
it('handles a host with only runtime-owned worktrees (empty local maps)', () => {
|
||||
const state: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: { 'a-wt': [makeTab('t-a', 'a-wt')] },
|
||||
terminalLayoutsByTabId: { 't-a': makeLayout() }
|
||||
}
|
||||
expect(roundTrip(state)).toEqual(state)
|
||||
})
|
||||
})
|
||||
|
||||
/** Pin both local-precedence rules used by main’s nonlocal-global pruning or it could discard values renderer merge would read. */
|
||||
describe('mergeWorkspaceSessionsFromHosts global-field precedence', () => {
|
||||
const localEntry = {
|
||||
url: 'local',
|
||||
normalizedUrl: 'local',
|
||||
title: 'l',
|
||||
lastVisitedAt: 2,
|
||||
visitCount: 1
|
||||
}
|
||||
const hostEntry = {
|
||||
url: 'host',
|
||||
normalizedUrl: 'host',
|
||||
title: 'h',
|
||||
lastVisitedAt: 1,
|
||||
visitCount: 1
|
||||
}
|
||||
|
||||
it("takes a global field from 'local' whenever local has one, ignoring every other slice", () => {
|
||||
const merged = mergeWorkspaceSessionsFromHosts({
|
||||
[LOCAL_EXECUTION_HOST_ID]: {
|
||||
...getDefaultWorkspaceSession(),
|
||||
browserUrlHistory: [localEntry]
|
||||
},
|
||||
[RUNTIME_A]: { ...getDefaultWorkspaceSession(), browserUrlHistory: [hostEntry] }
|
||||
})
|
||||
expect(merged.browserUrlHistory).toEqual([localEntry])
|
||||
})
|
||||
|
||||
it('falls back to another slice only when local does not have the field', () => {
|
||||
const local = getDefaultWorkspaceSession()
|
||||
delete local.browserUrlHistory
|
||||
const merged = mergeWorkspaceSessionsFromHosts({
|
||||
[LOCAL_EXECUTION_HOST_ID]: local,
|
||||
[RUNTIME_A]: { ...getDefaultWorkspaceSession(), browserUrlHistory: [hostEntry] }
|
||||
})
|
||||
expect(merged.browserUrlHistory).toEqual([hostEntry])
|
||||
})
|
||||
})
|
||||
@@ -1,208 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import {
|
||||
collectFolderWorkspaceKeysFromSession,
|
||||
collectWorktreeHydrationRepoIdsFromSession
|
||||
} from './workspace-session-hydration-keys'
|
||||
|
||||
describe('collectFolderWorkspaceKeysFromSession', () => {
|
||||
it('keeps folder workspace selection markers valid without scheduling a Git scan', () => {
|
||||
const session = {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {},
|
||||
activeTabTypeByWorktree: { 'folder:folder-1': 'terminal' }
|
||||
} as unknown as WorkspaceSessionState
|
||||
|
||||
expect(collectFolderWorkspaceKeysFromSession(session)).toEqual(['folder:folder-1'])
|
||||
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectWorktreeHydrationRepoIdsFromSession', () => {
|
||||
it('includes persisted terminal tabs and ignores folder workspaces', () => {
|
||||
const session = {
|
||||
activeWorktreeIdsOnShutdown: [
|
||||
'repo-a::/worktree-a',
|
||||
'repo-b::/worktree-b',
|
||||
'folder:folder-1'
|
||||
],
|
||||
tabsByWorktree: {
|
||||
'repo-a::/worktree-a': [{ ptyId: 'pty-a' }],
|
||||
'repo-b::/worktree-b': [{ ptyId: null }],
|
||||
'folder:folder-1': [{ ptyId: 'pty-folder' }]
|
||||
}
|
||||
} as unknown as WorkspaceSessionState
|
||||
|
||||
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual(['repo-a', 'repo-b'])
|
||||
})
|
||||
|
||||
it('recognizes split-pane and remote persisted sessions', () => {
|
||||
const session = {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-a': { ptyIdsByLeafId: { 'pane:1': 'pty-a' } }
|
||||
},
|
||||
tabsByWorktree: {
|
||||
'repo-a::/worktree-a': [{ id: 'tab-a', ptyId: null }],
|
||||
'repo-b::/worktree-b': [{ id: 'tab-b', ptyId: null }]
|
||||
},
|
||||
remoteSessionIdsByTabId: { 'tab-b': 'remote-session' }
|
||||
} as unknown as WorkspaceSessionState
|
||||
|
||||
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual(['repo-a', 'repo-b'])
|
||||
})
|
||||
|
||||
it('matches canonical session keys against raw shutdown worktree IDs', () => {
|
||||
const rawWorktreeId = 'repo-a::/worktree-a'
|
||||
const session = {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
activeWorktreeIdsOnShutdown: [rawWorktreeId],
|
||||
tabsByWorktree: {
|
||||
[`worktree:${rawWorktreeId}`]: [{ ptyId: 'pty-a' }]
|
||||
}
|
||||
} as unknown as WorkspaceSessionState
|
||||
|
||||
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual(['repo-a'])
|
||||
})
|
||||
|
||||
it('excludes runtime-owned session worktrees for raw and canonical owner keys', () => {
|
||||
const rawWorktreeId = 'repo-a::/remote/worktree'
|
||||
const canonicalWorktreeKey = `worktree:${rawWorktreeId}`
|
||||
const session = {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {
|
||||
[canonicalWorktreeKey]: [{ ptyId: 'pty-a' }]
|
||||
}
|
||||
} as unknown as WorkspaceSessionState
|
||||
|
||||
expect(
|
||||
collectWorktreeHydrationRepoIdsFromSession(session, {
|
||||
[rawWorktreeId]: 'runtime:env-1'
|
||||
})
|
||||
).toEqual([])
|
||||
expect(
|
||||
collectWorktreeHydrationRepoIdsFromSession(session, {
|
||||
[canonicalWorktreeKey]: 'runtime:env-1'
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps SSH-owned session worktrees eligible for local recovery routing', () => {
|
||||
const rawWorktreeId = 'repo-a::/ssh/worktree'
|
||||
const session = {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {
|
||||
[rawWorktreeId]: [{ ptyId: 'ssh:ssh-target@@pty-a' }]
|
||||
}
|
||||
} as unknown as WorkspaceSessionState
|
||||
|
||||
expect(
|
||||
collectWorktreeHydrationRepoIdsFromSession(session, {
|
||||
[rawWorktreeId]: 'ssh:ssh-target'
|
||||
})
|
||||
).toEqual(['repo-a'])
|
||||
})
|
||||
|
||||
it('returns repository IDs in deterministic order', () => {
|
||||
const session = {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {
|
||||
'repo-b::/worktree-b': [{ ptyId: 'pty-b' }],
|
||||
'repo-a::/worktree-a': [{ ptyId: 'pty-a' }]
|
||||
}
|
||||
} as unknown as WorkspaceSessionState
|
||||
|
||||
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual(['repo-a', 'repo-b'])
|
||||
})
|
||||
|
||||
it('excludes repositories referenced only by unbounded history maps (visit recency, default tabs)', () => {
|
||||
const session = {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: { 'repo-chrome::/wt': [{ ptyId: 'pty-a' }] },
|
||||
lastVisitedAtByWorktreeId: {
|
||||
'repo-chrome::/wt': 10,
|
||||
'repo-history-only::/visited': 20
|
||||
},
|
||||
defaultTerminalTabsAppliedByWorktreeId: {
|
||||
'repo-default-only::/applied': true
|
||||
}
|
||||
} as unknown as WorkspaceSessionState
|
||||
|
||||
// Why: history-only repos hydrate their maps unfiltered and would bloat the selective fetch.
|
||||
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual(['repo-chrome'])
|
||||
})
|
||||
|
||||
it('does not scan repositories retained only by empty chrome maps or selection markers', () => {
|
||||
const emptyTabsByWorktree = Object.fromEntries(
|
||||
Array.from({ length: 326 }, (_, index) => [`repo-empty-${index}::/wt`, []])
|
||||
)
|
||||
const staleActiveTabTypes = Object.fromEntries(
|
||||
Array.from({ length: 326 }, (_, index) => [`repo-empty-${index}::/wt`, 'terminal'])
|
||||
)
|
||||
const session = {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {
|
||||
...emptyTabsByWorktree,
|
||||
'repo-live::/wt': [{ ptyId: 'pty-a' }]
|
||||
},
|
||||
openFilesByWorktree: { 'repo-empty-editor::/wt': [] },
|
||||
browserTabsByWorktree: { 'repo-empty-browser::/wt': [] },
|
||||
activeFileIdByWorktree: { 'repo-empty-active-file::/wt': '/stale/file.ts' },
|
||||
activeBrowserTabIdByWorktree: { 'repo-empty-active-browser::/wt': 'stale-browser' },
|
||||
activeTabIdByWorktree: { 'repo-empty-active-terminal::/wt': 'stale-terminal' },
|
||||
activeGroupIdByWorktree: { 'repo-empty-active-group::/wt': 'stale-group' },
|
||||
activeTabTypeByWorktree: staleActiveTabTypes
|
||||
} as unknown as WorkspaceSessionState
|
||||
|
||||
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual(['repo-live'])
|
||||
})
|
||||
|
||||
it('includes a repository referenced only by activeRepoId (no active worktree, no tabs)', () => {
|
||||
const session = {
|
||||
activeRepoId: 'repo-active-only',
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {}
|
||||
} as unknown as WorkspaceSessionState
|
||||
|
||||
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual(['repo-active-only'])
|
||||
})
|
||||
|
||||
it('includes repositories referenced only by active, editor, browser, or sleeping-agent state', () => {
|
||||
const session = {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: 'repo-active::/active',
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
openFilesByWorktree: { 'repo-editor::/editor': [{ filePath: '/editor/file.ts' }] },
|
||||
browserTabsByWorktree: { 'repo-browser::/browser': [{ id: 'browser-a' }] },
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
'tab:leaf': { worktreeId: 'repo-agent::/agent' }
|
||||
}
|
||||
} as unknown as WorkspaceSessionState
|
||||
|
||||
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual([
|
||||
'repo-active',
|
||||
'repo-agent',
|
||||
'repo-browser',
|
||||
'repo-editor'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -57,11 +57,6 @@ export function getStandardEmojiShortcodeEntries(): readonly StandardEmojiShortc
|
||||
return loadCatalog().entries
|
||||
}
|
||||
|
||||
/** Test-only probe for the lazy-boundary guard; never branch on this in product code. */
|
||||
export function isEmojiShortcodeCatalogBuiltForTest(): boolean {
|
||||
return catalog !== null
|
||||
}
|
||||
|
||||
/** Prefer spelled-out names over symbolic +1/-1, cryptic no/v and flag_xx namespaces for branch/directory labels. */
|
||||
function primaryShortcode(shortcodes: readonly string[]): string {
|
||||
const named = shortcodes.filter((candidate) => /^[a-z]/i.test(candidate))
|
||||
|
||||
Reference in New Issue
Block a user