mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 01:27:35 +00:00
* fix(ssh): a relay-era desktop can join the server another desktop on the same login runs A desktop that still had relay-era projects on a host whose managed server another Orca version ran could never connect: its migration skipped the running-version adoption, so it deferred on a terminal count only a paired server can give. Once the versions matched it failed instead because the server already held the same repository path, added by the other desktop. The migration now pairs with the running server like a fresh desktop does, before any upload. Projects the server already holds at the same host path are linked to the server's project instead of imported twice: the journal is re-cut without them, under the same migration id, only after the server proves it holds nothing of the earlier manifest. A conflict the server will keep refusing now says what conflicts and how to get past it. * fix(ssh): never link a project a bridged relay terminal still runs in The bridge reattaches a relay pane only into its own tab, which a linked project never sends to the server, so linking would leave that terminal running out of sight. Refuse the link instead, naming the project and how to end the terminals. * test(ssh): run the link test on Node's SQLite runtime and read the host's activation record * test(ssh): establish desktop B's profile before seeding its relay-era state * test(ssh): connect desktop B's host explicitly in the shared-login spec * fix(ssh): say what a linked project left behind, and never blame it for an unowned relay terminal A project this desktop moved that the host's server already held is linked to the server's copy, so its tabs and automations from this desktop stay behind. The connect now carries those linked projects and the SSH host card says so, with the automation count, instead of dropping them silently. A bridged relay terminal of unknown or removed project still blocks a link, but its guidance no longer names the linked project as where it runs. Both terminal messages now offer removing the project from this desktop as the way out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
145 lines
5.2 KiB
TypeScript
145 lines
5.2 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||
|
||
const mocks = await vi.hoisted(async () => {
|
||
const { createSshIpcMocks } = await import('./ssh-ipc-module-mocks')
|
||
return createSshIpcMocks()
|
||
})
|
||
|
||
vi.mock('../ssh/ssh-config-host-picker', () => mocks.sshConfigHostPicker)
|
||
vi.mock('electron', () => mocks.electron)
|
||
vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore)
|
||
vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect)
|
||
vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager)
|
||
vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer)
|
||
vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward)
|
||
vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner)
|
||
|
||
import type { SshConnectionState, SshTarget } from '../../shared/ssh-types'
|
||
import { createSshIpcHarness } from './ssh-ipc-test-harness'
|
||
import {
|
||
decideHostServer,
|
||
publishManagedServerConnect,
|
||
publishUnservedHostServer
|
||
} from './ssh-host-server-connect'
|
||
import { beginSshHostCensus, recordSshConnectionOpened } from '../ssh/ssh-connection-attribution'
|
||
import type { SshConnection } from '../ssh/ssh-connection'
|
||
|
||
const { mockSshStore, mockConnectionManager } = mocks
|
||
|
||
const target: SshTarget = {
|
||
id: 'ssh-1',
|
||
label: 'Server',
|
||
host: 'example.com',
|
||
port: 22,
|
||
username: 'deploy'
|
||
}
|
||
|
||
function asTransport(value: { id: string }): SshConnection {
|
||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: attribution keys on identity only.
|
||
return value as unknown as SshConnection
|
||
}
|
||
|
||
describe('SSH IPC handlers', () => {
|
||
const harness = createSshIpcHarness(mocks)
|
||
const { handlers, mockWindow } = harness
|
||
|
||
beforeEach(harness.reset)
|
||
|
||
it('ssh:connect throws for unknown targetId', async () => {
|
||
mockSshStore.getTarget.mockReturnValue(undefined)
|
||
|
||
await expect(handlers.get('ssh:connect')!(null, { targetId: 'unknown' })).rejects.toThrow(
|
||
'SSH target "unknown" not found'
|
||
)
|
||
})
|
||
|
||
it('publishes a managed connect without opening a relay', async () => {
|
||
mockSshStore.getTarget.mockReturnValue(target)
|
||
|
||
await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toMatchObject({
|
||
status: 'connected'
|
||
})
|
||
expect(publishManagedServerConnect).toHaveBeenCalledWith(
|
||
'ssh-1',
|
||
'env-test',
|
||
undefined,
|
||
undefined,
|
||
undefined
|
||
)
|
||
expect(mockConnectionManager.connect).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('fails a host the managed server does not serve, with no fallback', async () => {
|
||
const decision = {
|
||
route: 'unserved' as const,
|
||
reason: 'orcad_unavailable' as const,
|
||
detail: 'unsupported_host'
|
||
}
|
||
const opened = { id: 'decision-transport' }
|
||
mockSshStore.getTarget.mockReturnValue(target)
|
||
vi.mocked(decideHostServer).mockImplementationOnce(async () => {
|
||
recordSshConnectionOpened(asTransport(opened))
|
||
mockConnectionManager.getConnection.mockReturnValue(opened)
|
||
return decision
|
||
})
|
||
|
||
await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).rejects.toThrow(
|
||
'This SSH host isn’t supported'
|
||
)
|
||
expect(publishUnservedHostServer).toHaveBeenCalledWith(target, decision)
|
||
expect(mockConnectionManager.disconnectConnection).toHaveBeenCalledWith('ssh-1', opened)
|
||
expect(mockConnectionManager.connect).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('holds a raw connected the server decision causes', async () => {
|
||
const conn = {}
|
||
mockSshStore.getTarget.mockReturnValue(target)
|
||
mockConnectionManager.getConnection.mockReturnValue(conn)
|
||
let broadcastsDuringDecision: unknown[] = []
|
||
vi.mocked(decideHostServer).mockImplementationOnce(async () => {
|
||
await Promise.resolve()
|
||
const callbacks = mockConnectionManager.callbacksRef.current as {
|
||
onStateChange: (targetId: string, state: SshConnectionState) => void
|
||
}
|
||
callbacks.onStateChange('ssh-1', {
|
||
targetId: 'ssh-1',
|
||
status: 'connected',
|
||
error: null,
|
||
reconnectAttempt: 0
|
||
})
|
||
broadcastsDuringDecision = mockWindow.webContents.send.mock.calls
|
||
.filter(([channel]) => channel === 'ssh:state-changed')
|
||
.map(([, payload]) => payload)
|
||
return { route: 'managed', environmentId: 'env-test' }
|
||
})
|
||
|
||
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
|
||
expect(broadcastsDuringDecision).not.toContainEqual(
|
||
expect.objectContaining({ state: expect.objectContaining({ status: 'connected' }) })
|
||
)
|
||
expect(broadcastsDuringDecision.at(-1)).toMatchObject({ state: { status: 'connecting' } })
|
||
})
|
||
|
||
it("holds a census's raw 'connected' outside any connect", () => {
|
||
const end = beginSshHostCensus('ssh-1')
|
||
try {
|
||
const callbacks = mockConnectionManager.callbacksRef.current as {
|
||
onStateChange: (targetId: string, state: SshConnectionState) => void
|
||
}
|
||
mockWindow.webContents.send.mockClear()
|
||
callbacks.onStateChange('ssh-1', {
|
||
targetId: 'ssh-1',
|
||
status: 'connected',
|
||
error: null,
|
||
reconnectAttempt: 0
|
||
})
|
||
expect(mockWindow.webContents.send).toHaveBeenCalledWith(
|
||
'ssh:state-changed',
|
||
expect.objectContaining({ state: expect.objectContaining({ status: 'connecting' }) })
|
||
)
|
||
} finally {
|
||
end()
|
||
}
|
||
})
|
||
})
|