From c12c211804f0f5f87fb8a9e9d83f443d1e90dae6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:22:13 -0700 Subject: [PATCH] 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 Co-authored-by: OrcaWin --- config/scripts/ci-e2e-job-selection.test.mjs | 5 +- config/scripts/pr-e2e-source-routing.mjs | 2 +- ...-managed-server-converted-catalogs.test.ts | 23 +++++ .../ssh-managed-server-state-effects.ts | 8 ++ .../repos/converted-ssh-browser-pages.test.ts | 79 +++++++++++++++++ .../repos/converted-ssh-browser-pages.ts | 51 +++++++++++ .../browser-tab-creation-placement.test.ts | 18 ++++ .../slices/browser/browser-tab-actions.ts | 4 +- tests/e2e/ssh-orcad-browser-routing.spec.ts | 86 ++++++++++++++++++- .../ssh-orcad-browser-service-status.spec.ts | 65 ++++++++++---- 10 files changed, 316 insertions(+), 25 deletions(-) create mode 100644 src/renderer/src/store/repos/converted-ssh-browser-pages.test.ts create mode 100644 src/renderer/src/store/repos/converted-ssh-browser-pages.ts diff --git a/config/scripts/ci-e2e-job-selection.test.mjs b/config/scripts/ci-e2e-job-selection.test.mjs index f842d6b6cab..7c26e7af63d 100644 --- a/config/scripts/ci-e2e-job-selection.test.mjs +++ b/config/scripts/ci-e2e-job-selection.test.mjs @@ -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') }) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 1a5880aea14..c10042a4464 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -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 ) }, diff --git a/src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts b/src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts index 6c38b10f8bc..aa0089b9588 100644 --- a/src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts +++ b/src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts @@ -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', diff --git a/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts b/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts index 08963f8bbd3..4d5bcd4355f 100644 --- a/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts +++ b/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts @@ -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 { 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() diff --git a/src/renderer/src/store/repos/converted-ssh-browser-pages.test.ts b/src/renderer/src/store/repos/converted-ssh-browser-pages.test.ts new file mode 100644 index 00000000000..5451a6667ae --- /dev/null +++ b/src/renderer/src/store/repos/converted-ssh-browser-pages.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/store/repos/converted-ssh-browser-pages.ts b/src/renderer/src/store/repos/converted-ssh-browser-pages.ts new file mode 100644 index 00000000000..b9fc1848d3a --- /dev/null +++ b/src/renderer/src/store/repos/converted-ssh-browser-pages.ts @@ -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, + 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 +} diff --git a/src/renderer/src/store/slices/browser-tab-creation-placement.test.ts b/src/renderer/src/store/slices/browser-tab-creation-placement.test.ts index 37e44fd9cc5..b3dd75a3839 100644 --- a/src/renderer/src/store/slices/browser-tab-creation-placement.test.ts +++ b/src/renderer/src/store/slices/browser-tab-creation-placement.test.ts @@ -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 diff --git a/src/renderer/src/store/slices/browser/browser-tab-actions.ts b/src/renderer/src/store/slices/browser/browser-tab-actions.ts index 2eb8e560669..5d5ae045d96 100644 --- a/src/renderer/src/store/slices/browser/browser-tab-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-tab-actions.ts @@ -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 } diff --git a/tests/e2e/ssh-orcad-browser-routing.spec.ts b/tests/e2e/ssh-orcad-browser-routing.spec.ts index 3d53191b02a..94cc0af7943 100644 --- a/tests/e2e/ssh-orcad-browser-routing.spec.ts +++ b/tests/e2e/ssh-orcad-browser-routing.spec.ts @@ -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 { }, 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) diff --git a/tests/e2e/ssh-orcad-browser-service-status.spec.ts b/tests/e2e/ssh-orcad-browser-service-status.spec.ts index 3cfb16d87d5..ae751752b6d 100644 --- a/tests/e2e/ssh-orcad-browser-service-status.spec.ts +++ b/tests/e2e/ssh-orcad-browser-service-status.spec.ts @@ -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,