Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion src/web-ui/src/features/ssh-remote/SSHRemoteProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -392,14 +392,16 @@ describe('SSHRemoteProvider startup restore', () => {
});
});

describe('SSHRemoteProvider peer device mode', () => {
describe('SSHRemoteProvider workspace connection state', () => {
let container: HTMLDivElement;
let root: Root;
let latestStatuses: Record<string, ConnectionStatus>;
let statusHistory: Array<Record<string, ConnectionStatus>>;

function StatusProbe() {
const ctx = React.useContext(SSHContext);
latestStatuses = ctx?.workspaceStatuses ?? {};
statusHistory.push(latestStatuses);
return null;
}

Expand All @@ -424,6 +426,7 @@ describe('SSHRemoteProvider peer device mode', () => {
vi.clearAllMocks();
peerModeFlagMock.active = false;
latestStatuses = {};
statusHistory = [];
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
Expand Down Expand Up @@ -477,6 +480,59 @@ describe('SSHRemoteProvider peer device mode', () => {
});
}

it.each([true, false])('keeps an established connection green during a session-switch probe (saved profile: %s)', async (hasSavedProfile) => {
const workspace = createRemoteWorkspace();
workspaceManagerMock.getState.mockReturnValue({
loading: false, openedWorkspaces: new Map([[workspace.id, workspace]]), activeWorkspaceId: workspace.id,
});
sshApiMock.listSavedConnections.mockResolvedValue(hasSavedProfile ? [{
id: 'conn-1', name: 'dev-box', host: 'example.com', port: 22, username: 'dev',
authType: { type: 'PrivateKey', keyPath: '/tmp/key' },
}] : []);
sshApiMock.isConnected.mockResolvedValue(true);
await renderProvider();
expect(latestStatuses['conn-1']).toBe('connected');
statusHistory = [];

let finishProbe!: (connected: boolean) => void;
sshApiMock.isConnected.mockImplementationOnce(() => new Promise(resolve => { finishProbe = resolve; }));
emitWorkspaceManagerEvent({ type: 'workspace:switched', workspace });
await act(async () => { await Promise.resolve(); });
expect(finishProbe).toBeTypeOf('function');
expect(latestStatuses['conn-1']).toBe('connected');
await act(async () => { finishProbe(true); });

expect(statusHistory.every(statuses => statuses['conn-1'] === 'connected')).toBe(true);
expect(sshApiMock.connect).not.toHaveBeenCalled();
});

it('shows connecting after a probe confirms a real disconnect and clears it when reconnection finishes', async () => {
const workspace = createRemoteWorkspace();
workspaceManagerMock.getState.mockReturnValue({
loading: false, openedWorkspaces: new Map([[workspace.id, workspace]]), activeWorkspaceId: workspace.id,
});
sshApiMock.listSavedConnections.mockResolvedValue([{
id: 'conn-1', name: 'dev-box', host: 'example.com', port: 22, username: 'dev',
authType: { type: 'PrivateKey', keyPath: '/tmp/key' },
}]);
sshApiMock.isConnected.mockResolvedValue(true);
await renderProvider();
expect(latestStatuses['conn-1']).toBe('connected');

let finishProbe!: (connected: boolean) => void;
let finishReconnect!: (result: { success: boolean; connectionId: string }) => void;
sshApiMock.isConnected.mockImplementationOnce(() => new Promise(resolve => { finishProbe = resolve; }));
sshApiMock.connect.mockImplementationOnce(() => new Promise(resolve => { finishReconnect = resolve; }));
emitWorkspaceManagerEvent({ type: 'workspace:switched', workspace });
await act(async () => { await Promise.resolve(); });
expect(latestStatuses['conn-1']).toBe('connected');
await act(async () => { finishProbe(false); });
expect(sshApiMock.connect).toHaveBeenCalledTimes(1);
expect(latestStatuses['conn-1']).toBe('connecting');
await act(async () => { finishReconnect({ success: true, connectionId: 'conn-1' }); });
expect(latestStatuses['conn-1']).toBe('connected');
});

it('mirrors peer-owned remote workspaces as connected without starting the reconnect timeout', async () => {
vi.useFakeTimers();
peerModeFlagMock.active = true;
Expand Down
6 changes: 5 additions & 1 deletion src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,10 @@ export const SSHRemoteProvider: React.FC<SSHRemoteProviderProps> = ({ children }
? 'error'
: 'connecting';
}
setWorkspaceStatuses(prev => ({ ...prev, ...initialStatuses }));
// A background check is not a reconnect. Keep the last observed state
// while the probe is pending, including when session selection checks
// every opened workspace again.
setWorkspaceStatuses(prev => ({ ...initialStatuses, ...prev }));

type ConnectedEntry = { workspace: RemoteWorkspace; connectionId: string };
const results = await Promise.all(
Expand Down Expand Up @@ -617,6 +620,7 @@ export const SSHRemoteProvider: React.FC<SSHRemoteProviderProps> = ({ children }
connectionId: workspace.connectionId,
remotePath: workspace.remotePath,
});
setWorkspaceStatuses(prev => ({ ...prev, [workspace.connectionId]: 'connecting' }));
const result = await tryReconnectWithRetry(workspace);

if (result !== false) {
Expand Down
Loading