diff --git a/tests/e2e/helpers/paired-client-runtime-environment.ts b/tests/e2e/helpers/paired-client-runtime-environment.ts index 2bb64d02974..771499a3ed8 100644 --- a/tests/e2e/helpers/paired-client-runtime-environment.ts +++ b/tests/e2e/helpers/paired-client-runtime-environment.ts @@ -1,4 +1,6 @@ import type { Page } from '@stablyai/playwright-test' +import type { PairedElectronClient, RuntimeDesktopPairingOffer } from './paired-electron-client' +import { revealPairedClientWindow } from './paired-client-window-reveal' /** * Points a freshly launched paired desktop client at the HUB runtime and makes it the active @@ -35,3 +37,71 @@ export async function selectPairedRuntimeEnvironment( return environmentId }, args) } + +export async function rePairPairedElectronClient( + client: PairedElectronClient, + offer: RuntimeDesktopPairingOffer, + name: string +): Promise { + await client.captureDirectSshAttempts() + const environmentId = await client.page.evaluate( + async ({ currentEnvironmentId, name, pairingUrl }) => { + const store = window.__store + if (!store) { + throw new Error('Paired desktop store is unavailable') + } + if (!(await store.getState().setActiveRuntimeEnvironmentPreference(null))) { + throw new Error('Paired desktop could not select local before replacing the HUB') + } + await window.api.runtimeEnvironments.remove({ selector: currentEnvironmentId }) + const result = await window.api.runtimeEnvironments.addFromPairingCode({ + name, + pairingCode: pairingUrl + }) + store.getState().setRuntimeEnvironments(await window.api.runtimeEnvironments.list()) + if (!(await store.getState().refreshRuntimeEnvironmentStatus(result.environment.id))) { + throw new Error('Re-paired desktop could not reach the HUB runtime') + } + if (!(await store.getState().setActiveRuntimeEnvironmentPreference(result.environment.id))) { + throw new Error('Re-paired desktop could not select the HUB runtime') + } + return result.environment.id + }, + { + currentEnvironmentId: client.environmentId, + name, + pairingUrl: offer.pairingUrl + } + ) + client.environmentId = environmentId + // Why: removing and re-adding the same HUB changes the environment identity; remount so no pane keeps the retired transport wrapper. + await client.page.reload() + // Xvfb needs a mapped window to resume actionability frames after reload. + if ( + process.env.GITHUB_ACTIONS === 'true' && + process.platform === 'linux' && + process.env.DISPLAY && + process.env.ORCA_BACKGROUND_LAUNCH !== '1' + ) { + await revealPairedClientWindow(client) + } + await client.page.waitForFunction( + () => window.__store?.getState().workspaceSessionReady === true, + null, + { timeout: 30_000, polling: 100 } + ) + await client.installDirectSshAttemptProbe() + const reachable = await client.page.evaluate(async (nextEnvironmentId) => { + const store = window.__store + if (!store) { + throw new Error('Re-paired desktop store is unavailable after reload') + } + if (!(await store.getState().refreshRuntimeEnvironmentStatus(nextEnvironmentId))) { + return false + } + return store.getState().setActiveRuntimeEnvironmentPreference(nextEnvironmentId) + }, environmentId) + if (!reachable) { + throw new Error('Re-paired desktop could not reach the HUB after reload') + } +} diff --git a/tests/e2e/helpers/paired-client-runtime-environment.unit.test.ts b/tests/e2e/helpers/paired-client-runtime-environment.unit.test.ts new file mode 100644 index 00000000000..3851a9184c1 --- /dev/null +++ b/tests/e2e/helpers/paired-client-runtime-environment.unit.test.ts @@ -0,0 +1,74 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { rePairPairedElectronClient } from './paired-client-runtime-environment' +import type { PairedElectronClient } from './paired-electron-client' + +afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() +}) + +function fixture(canSelectLocal: boolean) { + let selected: string | null = 'old-hub' + const remove = vi.fn(async () => { + if (selected !== null) { + throw new Error('Cannot remove the selected runtime') + } + }) + const state = { + setActiveRuntimeEnvironmentPreference: vi.fn(async (id: string | null) => { + if (id === null && !canSelectLocal) { + return false + } + selected = id + return true + }), + setRuntimeEnvironments: vi.fn(), + refreshRuntimeEnvironmentStatus: vi.fn(async () => true) + } + vi.stubGlobal('window', { + __store: { getState: () => state }, + api: { + runtimeEnvironments: { + remove, + addFromPairingCode: vi.fn(async () => ({ environment: { id: 'new-hub' } })), + list: vi.fn(async () => [{ id: 'new-hub' }]) + } + } + }) + const nativeEvaluate = vi.fn() + const reload = vi.fn(async () => undefined) + const client = { + environmentId: 'old-hub', + captureDirectSshAttempts: vi.fn(async () => undefined), + installDirectSshAttemptProbe: vi.fn(async () => undefined), + app: { evaluate: nativeEvaluate }, + page: { + evaluate: async (callback: (args: unknown) => unknown, args: unknown) => callback(args), + reload, + waitForFunction: vi.fn(async () => undefined) + } + } as unknown as PairedElectronClient + return { client, remove, reload, nativeEvaluate } +} + +it('keeps the old pairing when selecting local fails', async () => { + const { client, remove, reload } = fixture(false) + await expect(rePairPairedElectronClient(client, { pairingUrl: 'code' }, 'HUB')).rejects.toThrow( + 'could not select local' + ) + expect(remove).not.toHaveBeenCalled() + expect(reload).not.toHaveBeenCalled() + expect(client.environmentId).toBe('old-hub') +}) + +it('replaces the active pairing without touching native windows in background mode', async () => { + vi.stubEnv('ORCA_BACKGROUND_LAUNCH', '1') + vi.stubEnv('GITHUB_ACTIONS', 'true') + vi.stubEnv('DISPLAY', ':99') + const { client, remove, reload, nativeEvaluate } = fixture(true) + await rePairPairedElectronClient(client, { pairingUrl: 'code' }, 'HUB') + expect(remove).toHaveBeenCalledWith({ selector: 'old-hub' }) + expect(client.environmentId).toBe('new-hub') + expect(reload).toHaveBeenCalledOnce() + expect(nativeEvaluate).not.toHaveBeenCalled() +}) diff --git a/tests/e2e/helpers/paired-electron-client.ts b/tests/e2e/helpers/paired-electron-client.ts index d08aaebe5f9..a5947bc1228 100644 --- a/tests/e2e/helpers/paired-electron-client.ts +++ b/tests/e2e/helpers/paired-electron-client.ts @@ -25,6 +25,8 @@ import { import { createPairedWebClientUrl, type PairedWebClientOptions } from './paired-web-client-url' import { selectPairedRuntimeEnvironment } from './paired-client-runtime-environment' +export { rePairPairedElectronClient } from './paired-client-runtime-environment' + export type { SameIdPairingReplacement } from './nested-runtime-same-id-pairing' export type PairedElectronClient = { @@ -222,13 +224,13 @@ export async function launchPairedElectronClient( replacementOffer: RuntimeDesktopPairingOffer ): Promise => replaceRuntimePairingInPlace({ - environmentId, + environmentId: client.environmentId, page, pairingUrl: replacementOffer.pairingUrl, userDataDir }) - return { + const client: PairedElectronClient = { app, page, environmentId, @@ -247,6 +249,7 @@ export async function launchPairedElectronClient( replacePairingInPlace, userDataDir } + return client } catch (error) { await closeElectronAppForE2E(app) await cleanupE2EDaemons(userDataDir) @@ -254,59 +257,3 @@ export async function launchPairedElectronClient( throw error } } - -export async function rePairPairedElectronClient( - client: PairedElectronClient, - offer: RuntimeDesktopPairingOffer, - name: string -): Promise { - await client.captureDirectSshAttempts() - const environmentId = await client.page.evaluate( - async ({ currentEnvironmentId, name, pairingUrl }) => { - const store = window.__store - if (!store) { - throw new Error('Paired desktop store is unavailable') - } - await window.api.runtimeEnvironments.remove({ selector: currentEnvironmentId }) - const result = await window.api.runtimeEnvironments.addFromPairingCode({ - name, - pairingCode: pairingUrl - }) - store.getState().setRuntimeEnvironments(await window.api.runtimeEnvironments.list()) - if (!(await store.getState().refreshRuntimeEnvironmentStatus(result.environment.id))) { - throw new Error('Re-paired desktop could not reach the HUB runtime') - } - if (!(await store.getState().setActiveRuntimeEnvironmentPreference(result.environment.id))) { - throw new Error('Re-paired desktop could not select the HUB runtime') - } - return result.environment.id - }, - { - currentEnvironmentId: client.environmentId, - name, - pairingUrl: offer.pairingUrl - } - ) - client.environmentId = environmentId - // Why: removing and re-adding the same HUB changes the environment identity; remount so no pane keeps the retired transport wrapper. - await client.page.reload() - await client.page.waitForFunction( - () => window.__store?.getState().workspaceSessionReady === true, - null, - { timeout: 30_000 } - ) - await client.installDirectSshAttemptProbe() - const reachable = await client.page.evaluate(async (nextEnvironmentId) => { - const store = window.__store - if (!store) { - throw new Error('Re-paired desktop store is unavailable after reload') - } - if (!(await store.getState().refreshRuntimeEnvironmentStatus(nextEnvironmentId))) { - return false - } - return store.getState().setActiveRuntimeEnvironmentPreference(nextEnvironmentId) - }, environmentId) - if (!reachable) { - throw new Error('Re-paired desktop could not reach the HUB after reload') - } -} diff --git a/tests/e2e/nested-runtime-ssh-lifecycle.spec.ts b/tests/e2e/nested-runtime-ssh-lifecycle.spec.ts index 4b4eb7f21c6..2680d4c3204 100644 --- a/tests/e2e/nested-runtime-ssh-lifecycle.spec.ts +++ b/tests/e2e/nested-runtime-ssh-lifecycle.spec.ts @@ -722,7 +722,7 @@ test('restores a paired nested SSH route after the HUB restarts', async ({ if (!(await store.getState().refreshRuntimeEnvironmentStatus(environmentId))) { return false } - return store.getState().switchRuntimeEnvironment(environmentId) + return store.getState().setActiveRuntimeEnvironmentPreference(environmentId) }, preRestartEnvironmentId) expect(existingPairingRecovered).toBe(true) await reconnectDisconnectedDockerSshRelayTarget(hubLaunch.page, remote.targetId)