Preserve retained desktop browser pages during SSH conversion (#26572)

* Preserve desktop browser page placement when SSH workspaces convert

* Place the browser-service-status page on the managed host explicitly

Converted SSH workspaces now keep retained desktop pages local, so the
spec opens its remote-placed page on the managed environment instead of
relying on the retained page moving there.

* test(e2e): refocus the managed browser page before retrying its Reconnect

The managed host can seed a terminal for the converted workspace and the
client mirrors it with focus, hiding the browser notice mid-test.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: OrcaWin <alpha-eng@stably.ai>
This commit is contained in:
Neil
2026-10-09 04:22:13 -07:00
committed by GitHub
co-authored by m4air OrcaWin
parent 049bf808ab
commit c12c211804
10 changed files with 316 additions and 25 deletions
+4 -1
View File
@@ -35,7 +35,10 @@ it.each(['errors', 'status', 'lifecycle', 'restart-attempt'])(
it.each([
'src/renderer/src/lib/ssh-workspace-browser-route-eligibility.ts',
'src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.ts',
'src/main/browser/local-ssh-browser-route.ts'
'src/main/browser/local-ssh-browser-route.ts',
'src/renderer/src/store/repos/converted-ssh-browser-pages.ts',
'src/renderer/src/store/slices/browser/browser-tab-actions.ts',
'src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts'
])('routes the managed browser routing oracle from %s', (path) => {
expect(selectPrE2eSpecs([path])).toContain('tests/e2e/ssh-orcad-browser-routing.spec.ts')
})
+1 -1
View File
@@ -105,7 +105,7 @@ export const PR_E2E_SOURCE_ROUTES = [
specs: ['tests/e2e/ssh-orcad-browser-routing.spec.ts'],
matches: (file) =>
isProductSource(file) &&
/^src\/(?:main\/browser\/local-ssh-browser|main\/ipc\/browser\.ts$|renderer\/src\/(?:lib\/(?:ssh-workspace-browser-route-eligibility|worktree-host-connection-phase)|components\/browser-pane\/use-ssh-workspace-browser-route))/.test(
/^src\/(?:main\/browser\/local-ssh-browser|main\/ipc\/browser\.ts$|renderer\/src\/(?:lib\/(?:ssh-workspace-browser-route-eligibility|worktree-host-connection-phase)|components\/browser-pane\/use-ssh-workspace-browser-route|store\/repos\/converted-ssh-browser-pages|store\/slices\/browser\/browser-tab-actions|hooks\/ipc-events\/ssh-managed-server-state-effects))/.test(
file
)
},
@@ -8,6 +8,7 @@ import {
} from '../../runtime/runtime-compatibility-test-fixture'
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
import { createTestStore, makeWorktree } from '../../store/slices/store-test-helpers'
import { buildBrowserPage } from '../../store/slices/browser-page-records'
const store = createTestStore()
vi.mock('../../store', () => ({ useAppStore: store }))
@@ -102,6 +103,28 @@ beforeEach(() => {
})
describe('a host that just moved to its managed server', () => {
it('pins existing desktop pages before refreshing their workspace ownership', async () => {
const page = buildBrowserPage('browser', worktreeId, 'https://private.invalid/')
store.setState({ browserPagesByWorkspace: { browser: [page] } })
applySshManagedServerTransition('ssh-1', undefined, {
kind: 'managed',
environmentId: 'env-browser'
})
expect(store.getState().browserPagesByWorkspace.browser?.[0]).toEqual({
...page,
browserRuntimeEnvironmentId: null
})
await vi.waitFor(() =>
expect(store.getState().repos.map((entry) => entry.executionHostId)).toEqual([
'runtime:env-browser'
])
)
expect(
store.getState().browserPagesByWorkspace.browser?.[0]?.browserRuntimeEnvironmentId
).toBeNull()
})
it('shows the server copies only, under the server name', async () => {
applySshManagedServerTransition('ssh-1', undefined, {
kind: 'managed',
@@ -14,6 +14,7 @@ import {
} from '@/ssh/ssh-managed-server-move'
import { useAppStore } from '../../store'
import { withoutConvertedSshHostRows } from '../../store/repos/converted-ssh-host-rows'
import { preserveConvertedSshBrowserPages } from '../../store/repos/converted-ssh-browser-pages'
type ManagedServerStatus = SshConnectionState['managedServer']
@@ -93,6 +94,13 @@ function startManagedServerCatalogLoad(targetId: string, environmentId: string):
async function loadManagedServerCatalogs(targetId: string, load: CatalogLoad): Promise<void> {
const { environmentId } = load
const isCurrent = (): boolean => catalogLoadByTarget.get(targetId) === load
// Pin existing desktop pages before the catalogs change their workspace owner.
useAppStore.setState((state) => {
const browserPagesByWorkspace = preserveConvertedSshBrowserPages(state, targetId)
return browserPagesByWorkspace === state.browserPagesByWorkspace
? state
: { browserPagesByWorkspace }
})
const store = useAppStore.getState()
// Why: host badges read server names from this catalog, which a conversion does not refresh.
const environments = await window.api.runtimeEnvironments.list()
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import type { BrowserPage } from '../../../../shared/browser-workspace-types'
import type { WorktreeRuntimeOwnerState } from '../../lib/worktree-runtime-owner'
import { buildBrowserPage } from '../slices/browser-page-records'
import { preserveConvertedSshBrowserPages } from './converted-ssh-browser-pages'
const owners: WorktreeRuntimeOwnerState = {
repos: [{ id: 'repo', connectionId: 'target', executionHostId: 'ssh:target' }],
worktreesByRepo: { repo: [{ id: 'worktree', repoId: 'repo', hostId: 'ssh:target' }] },
folderWorkspaces: [{ id: 'folder', projectGroupId: 'group' }],
projectGroups: [{ id: 'group', connectionId: 'target' }]
}
function page(worktreeId = 'worktree'): BrowserPage {
return buildBrowserPage('browser', worktreeId, 'https://private.invalid/', 'Private')
}
describe('desktop browser placement during SSH conversion', () => {
it('pins all inherited pages to desktop without changing their saved browser state', () => {
const pages = [page(), page()]
const before = { ...owners, browserPagesByWorkspace: { browser: pages } }
const after = preserveConvertedSshBrowserPages(before, 'target')
expect(after.browser).toEqual(
pages.map((original) => ({ ...original, browserRuntimeEnvironmentId: null }))
)
expect(pages.every((original) => original.browserRuntimeEnvironmentId === undefined)).toBe(true)
expect(
preserveConvertedSshBrowserPages({ ...before, browserPagesByWorkspace: after }, 'target')
).toBe(after)
})
it('also preserves pages in SSH-owned folder workspaces', () => {
const original = page('folder:folder')
const after = preserveConvertedSshBrowserPages(
{ ...owners, browserPagesByWorkspace: { browser: [original] } },
'target'
)
expect(after.browser?.[0]?.browserRuntimeEnvironmentId).toBeNull()
})
it('keeps explicit remote placement and pages on another host unchanged', () => {
const remote = { ...page(), browserRuntimeEnvironmentId: 'env' }
const local = { ...page(), browserRuntimeEnvironmentId: null }
const other = page('unknown')
const browserPagesByWorkspace = { browser: [remote, local, other] }
expect(preserveConvertedSshBrowserPages({ ...owners, browserPagesByWorkspace }, 'target')).toBe(
browserPagesByWorkspace
)
expect(preserveConvertedSshBrowserPages({ ...owners, browserPagesByWorkspace }, 'other')).toBe(
browserPagesByWorkspace
)
})
it('preserves local URL history across document conversion without changing document ownership', () => {
const doc = {
...page(),
docLocation: {
kind: 'workspace-doc' as const,
worktreeId: 'worktree',
filePath: '/notes.md'
},
convertedFrom: { kind: 'url' as const, url: 'https://private.invalid/' },
convertedTo: {
kind: 'url' as const,
url: 'https://remote.invalid/',
browserRuntimeEnvironmentId: 'env'
}
}
const after = preserveConvertedSshBrowserPages(
{ ...owners, browserPagesByWorkspace: { browser: [doc] } },
'target'
)
expect(after.browser?.[0]).toEqual({
...doc,
convertedFrom: { ...doc.convertedFrom, browserRuntimeEnvironmentId: null }
})
expect(after.browser?.[0]?.docLocation).toBe(doc.docLocation)
})
})
@@ -0,0 +1,51 @@
import type { BrowserPageConversionOrigin } from '../../../../shared/browser-workspace-types'
import { toSshExecutionHostId } from '../../../../shared/execution-host'
import {
getKnownExecutionHostIdForWorktree,
type WorktreeRuntimeOwnerState
} from '../../lib/worktree-runtime-owner'
import type { AppState } from '../types'
function preserveLocalUrlOrigin(
origin: BrowserPageConversionOrigin | null | undefined
): BrowserPageConversionOrigin | null | undefined {
return origin?.kind === 'url' && origin.browserRuntimeEnvironmentId === undefined
? { ...origin, browserRuntimeEnvironmentId: null }
: origin
}
export function preserveConvertedSshBrowserPages(
state: WorktreeRuntimeOwnerState & Pick<AppState, 'browserPagesByWorkspace'>,
targetId: string
): AppState['browserPagesByWorkspace'] {
const hostId = toSshExecutionHostId(targetId)
let result = state.browserPagesByWorkspace
for (const [workspaceId, pages] of Object.entries(state.browserPagesByWorkspace)) {
let changed = false
const next = pages.map((page) => {
if (getKnownExecutionHostIdForWorktree(state, page.worktreeId) !== hostId) {
return page
}
const localPage = !page.docLocation && page.browserRuntimeEnvironmentId === undefined
const convertedFrom = preserveLocalUrlOrigin(page.convertedFrom)
const convertedTo = preserveLocalUrlOrigin(page.convertedTo)
if (!localPage && convertedFrom === page.convertedFrom && convertedTo === page.convertedTo) {
return page
}
changed = true
return {
...page,
...(localPage ? { browserRuntimeEnvironmentId: null } : {}),
...(convertedFrom !== page.convertedFrom ? { convertedFrom } : {}),
...(convertedTo !== page.convertedTo ? { convertedTo } : {})
}
})
if (changed) {
if (result === state.browserPagesByWorkspace) {
result = { ...result }
}
result[workspaceId] = next
}
}
return result
}
@@ -129,6 +129,24 @@ describe('browser tab creation placement', () => {
expect(store.getState().unifiedTabsByWorktree['repo1::/elsewhere']).toBeUndefined()
})
it('records desktop placement for both normal and profile tabs on an SSH workspace', async () => {
seedSplit(store)
store.setState({
worktreesByRepo: {
repo1: [makeWorktree({ id: WT, repoId: 'repo1', hostId: 'ssh:target' })]
}
})
await store.getState().openNewBrowserTabInActiveWorkspace(G1)
await store.getState().openBrowserProfileTabInActiveWorkspace('https://example.com', null)
const workspaces = store.getState().browserTabsByWorktree[WT] ?? []
expect(workspaces).toHaveLength(2)
for (const workspace of workspaces) {
expect(
store.getState().browserPagesByWorkspace[workspace.id]?.[0]?.browserRuntimeEnvironmentId
).toBeNull()
}
})
it('places a duplicate directly after its source', () => {
seedSplit(store)
const workspace = store
@@ -225,7 +225,7 @@ export function createBrowserTabActions(
get().createBrowserTab(worktreeId, defaultUrl, {
title: translate('auto.store.slices.browser.d175274b6d', 'New Browser Tab'),
focusAddressBar: true,
...(runtimeEnvironmentId ? { browserRuntimeEnvironmentId: null } : {}),
browserRuntimeEnvironmentId: null,
targetGroupId: groupId
})
get().recordFeatureInteraction('browser-tab-created')
@@ -268,7 +268,7 @@ export function createBrowserTabActions(
get().createBrowserTab(worktreeId, url, {
activate: true,
sessionProfileId: profileId,
...(runtimeEnvironmentId ? { browserRuntimeEnvironmentId: null } : {})
browserRuntimeEnvironmentId: null
})
return true
}
+83 -3
View File
@@ -3,7 +3,7 @@ import path from 'node:path'
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import { createRestartSession } from './helpers/orca-restart'
import { waitForSessionReady } from './helpers/store'
import { waitForSessionReady, waitForStartupWorktreeRefresh } from './helpers/store'
import {
cleanupDockerSshRelayTarget,
startDockerSshRelayTarget
@@ -36,7 +36,7 @@ async function guestMarker(page: Page, tabId: string): Promise<unknown> {
}, tabId)
}
test('new browser tabs keep SSH routing and login cookies after managed conversion', async (// oxlint-disable-next-line no-empty-pattern -- Owns app launch.
test('retained and new browser tabs keep SSH routing and login cookies after managed conversion', async (// oxlint-disable-next-line no-empty-pattern -- Owns app launch.
{}, testInfo) => {
test.setTimeout(5 * 60_000)
const target = startDockerSshRelayTarget(testInfo)
@@ -127,6 +127,26 @@ test('new browser tabs keep SSH routing and login cookies after managed conversi
return tab?.id ?? null
}, remote.worktreeId)
expect(retained).toBe(tabId)
await expect.poll(() => guestMarker(page, tabId), { timeout: 30_000 }).toBe(cookieMarker)
await navigateGuest(page, tabId, `${SSH_REMOTE_ONLY_ORIGIN}/echo/retained`)
await expect.poll(() => guestMarker(page, tabId), { timeout: 30_000 }).toBe(cookieMarker)
expect(readSshRemoteOnlyRequests(target)).toContainEqual({
path: '/echo/retained',
cookie: `${SSH_REMOTE_ONLY_COOKIE_NAME}=${SSH_REMOTE_ONLY_COOKIE_VALUE}`
})
expect(
await page
.locator(`[data-browser-overlay-tab-id="${tabId}"] webview`)
.getAttribute('partition')
).toBe(partitionBefore)
await page.keyboard.press('Escape')
await page.evaluate(() => {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
})
await expect(page.locator('[data-slot="popover-content"]')).toHaveCount(0)
await page.screenshot({ path: testInfo.outputPath('browser-retained-after-conversion.png') })
await page.evaluate(
async ({ worktreeId, url }) => {
const state = window.__store?.getState()
@@ -190,6 +210,24 @@ test('new browser tabs keep SSH routing and login cookies after managed conversi
path: '/echo/reconnect',
cookie: `${SSH_REMOTE_ONLY_COOKIE_NAME}=${SSH_REMOTE_ONLY_COOKIE_VALUE}`
})
await page.evaluate(
({ worktreeId, tabId }) => {
const state = window.__store?.getState()
const pageId = state?.browserTabsByWorktree[worktreeId]?.find(
(tab) => tab.id === tabId
)?.activePageId
if (pageId) {
state?.focusBrowserTabInWorktree(worktreeId, pageId, { surfacePane: true })
}
},
{ worktreeId: remote.worktreeId, tabId }
)
await navigateGuest(page, tabId, `${SSH_REMOTE_ONLY_ORIGIN}/echo/retained-reconnect`)
await expect.poll(() => guestMarker(page, tabId), { timeout: 30_000 }).toBe(cookieMarker)
expect(readSshRemoteOnlyRequests(target)).toContainEqual({
path: '/echo/retained-reconnect',
cookie: `${SSH_REMOTE_ONLY_COOKIE_NAME}=${SSH_REMOTE_ONLY_COOKIE_VALUE}`
})
console.log(
'[managed-browser-route]',
JSON.stringify({
@@ -205,7 +243,49 @@ test('new browser tabs keep SSH routing and login cookies after managed conversi
}
})
await expect(page.locator('[data-slot="popover-content"]')).toHaveCount(0)
await page.screenshot({ path: testInfo.outputPath('browser-new-tab-after.png') })
await page.screenshot({ path: testInfo.outputPath('browser-retained-after-reconnect.png') })
await session.close(app)
app = null
const restarted = await session.launch()
app = restarted.app
await waitForSessionReady(restarted.page)
await waitForStartupWorktreeRefresh(restarted.page)
console.log(
'[retained-browser-restored]',
JSON.stringify(
await restarted.page.evaluate(() => {
const state = window.__store?.getState()
return {
tabs: state?.browserTabsByWorktree,
pages: state?.browserPagesByWorkspace,
active: state?.activeWorktreeId,
host: state?.activeWorkspaceExecutionHostId
}
})
)
)
await restarted.page.evaluate(
({ worktreeId, environmentId, tabId }) => {
const state = window.__store?.getState()
state?.setActiveWorktree(worktreeId, `runtime:${environmentId}`)
const pageId = state?.browserTabsByWorktree[worktreeId]?.find(
(tab) => tab.id === tabId
)?.activePageId
if (pageId) {
state?.focusBrowserTabInWorktree(worktreeId, pageId, { surfacePane: true })
}
},
{ worktreeId: remote.worktreeId, environmentId: environment.id, tabId }
)
await expect
.poll(() => guestMarker(restarted.page, tabId), { timeout: 60_000 })
.toBe(cookieMarker)
await navigateGuest(restarted.page, tabId, `${SSH_REMOTE_ONLY_ORIGIN}/echo/retained-restart`)
expect(readSshRemoteOnlyRequests(target)).toContainEqual({
path: '/echo/retained-restart',
cookie: `${SSH_REMOTE_ONLY_COOKIE_NAME}=${SSH_REMOTE_ONLY_COOKIE_VALUE}`
})
console.log('[retained-browser-restart]', JSON.stringify(readSshRemoteOnlyRequests(target)))
} finally {
if (app) {
await session.close(app)
@@ -113,21 +113,34 @@ test('an unavailable browser on a responding managed host does not report a serv
{ timeout: 60_000 }
)
.toMatch(/^runtime:/)
const retained = await page.evaluate((worktreeId) => {
const state = window.__store?.getState()
const tab = state?.browserTabsByWorktree[worktreeId]?.[0]
const repo = state?.repos.find((repo) =>
(state.worktreesByRepo[repo.id] ?? []).some((worktree) => worktree.id === worktreeId)
)
if (repo?.executionHostId) {
state?.setActiveWorktree(worktreeId, repo.executionHostId)
// Retained desktop pages stay local after conversion; place this one on the managed host.
const remoteTabId = await page.evaluate(
({ worktreeId, environmentId, url }) => {
const state = window.__store?.getState()
const repo = state?.repos.find((repo) =>
(state.worktreesByRepo[repo.id] ?? []).some((worktree) => worktree.id === worktreeId)
)
if (!state || !repo?.executionHostId) {
throw new Error('Missing converted workspace')
}
state.setActiveWorktree(worktreeId, repo.executionHostId)
const tab = state.createBrowserTab(worktreeId, url, {
title: 'Managed host browser',
activate: true,
browserRuntimeEnvironmentId: environmentId
})
if (tab.activePageId) {
state.focusBrowserTabInWorktree(worktreeId, tab.activePageId, { surfacePane: true })
}
return tab.id
},
{
worktreeId: remote.worktreeId,
environmentId: environment.id,
url: `${SSH_REMOTE_ONLY_ORIGIN}/login`
}
if (tab?.activePageId) {
state?.focusBrowserTabInWorktree(worktreeId, tab.activePageId, { surfacePane: true })
}
return tab?.id ?? null
}, remote.worktreeId)
expect(retained).toBe(tabId)
)
expect(remoteTabId).not.toBe(tabId)
const notice = page.getByTestId('remote-browser-stream-error')
await expect(notice).toBeVisible({ timeout: 30_000 })
await page.screenshot({ path: testInfo.outputPath('browser-service-status.png') })
@@ -153,10 +166,26 @@ test('an unavailable browser on a responding managed host does not report a serv
'The remote browser is unavailable. Check its setup on the server.'
)
await expect(notice).not.toContainText('Cannot reach the remote server.')
await notice.getByRole('button', { name: 'Reconnect', exact: true }).click()
await expect(notice).toContainText(
'The remote browser is unavailable. Check its setup on the server.'
)
// Why: the managed host can mirror a terminal it seeds for this workspace, taking focus.
await expect(async () => {
await page.evaluate(
({ worktreeId, tabId }) => {
const state = window.__store?.getState()
const pageId = state?.browserTabsByWorktree[worktreeId]?.find(
(tab) => tab.id === tabId
)?.activePageId
if (pageId) {
state?.focusBrowserTabInWorktree(worktreeId, pageId, { surfacePane: true })
}
},
{ worktreeId: remote.worktreeId, tabId: remoteTabId }
)
await notice.getByRole('button', { name: 'Reconnect', exact: true }).click({ timeout: 2_000 })
await expect(notice).toContainText(
'The remote browser is unavailable. Check its setup on the server.',
{ timeout: 2_000 }
)
}).toPass({ timeout: 30_000 })
console.log('[browser-service-after-retry]', await notice.innerText())
expect(JSON.parse(await serverCall(page, environment.id, 'repo.list'))).toMatchObject({
ok: true,