Distinguish remote browser failures from server connection failures (#26573)

* Distinguish remote browser service failures from server outages

* Use the shared browser_unavailable code in the stream failure classifier

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
Neil
2026-10-08 21:07:48 -07:00
committed by GitHub
co-authored by m4air
parent 8d3c922176
commit 8167ddaa7f
12 changed files with 357 additions and 47 deletions
+4 -3
View File
@@ -351,7 +351,8 @@ jobs:
. != "tests/e2e/ssh-orcad-auto-convert.spec.ts" and
. != "tests/e2e/windows-missing-appdata-startup.spec.ts" and
. != "tests/e2e/ssh-orcad-idle-exit.spec.ts" and
. != "tests/e2e/ssh-orcad-browser-capabilities.spec.ts"
. != "tests/e2e/ssh-orcad-browser-capabilities.spec.ts" and
. != "tests/e2e/ssh-orcad-browser-service-status.spec.ts"
)' <<<"$TEST_FILES_JSON" > "$RUNNER_TEMP/general-e2e-specs"
fi
mapfile -t TEST_FILES < "$RUNNER_TEMP/general-e2e-specs"
@@ -585,7 +586,7 @@ jobs:
orcad-auto-convert-docker:
name: ssh host auto-converts to managed orcad (Docker)
needs: [build, prepare-native-cache]
if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-auto-convert.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-idle-exit.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-capabilities.spec.ts')
if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-auto-convert.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-idle-exit.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-capabilities.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-service-status.spec.ts')
runs-on: ubuntu-latest
timeout-minutes: 45
env:
@@ -621,7 +622,7 @@ jobs:
ORCA_RELAY_PATH: ${{ github.workspace }}/out/relay
run: |
export ORCA_E2E_ORCAD_CONVERT_TEMPLATE="$RUNNER_TEMP/orcad-convert-template"
xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad-auto-convert.spec.ts tests/e2e/ssh-orcad-idle-exit.spec.ts tests/e2e/ssh-orcad-browser-capabilities.spec.ts --project=electron-headless --workers=1
xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad-auto-convert.spec.ts tests/e2e/ssh-orcad-idle-exit.spec.ts tests/e2e/ssh-orcad-browser-capabilities.spec.ts tests/e2e/ssh-orcad-browser-service-status.spec.ts --project=electron-headless --workers=1
- uses: actions/upload-artifact@v7
if: failure()
with:
+6 -2
View File
@@ -53,6 +53,8 @@ export const LAYOUT_ORACLE_HEADLESS_E2E_SPEC = 'tests/e2e/workspace-layout-oracl
export const ORCAD_IDLE_EXIT_E2E_SPEC = 'tests/e2e/ssh-orcad-idle-exit.spec.ts'
export const ORCAD_BROWSER_CAPABILITIES_E2E_SPEC =
'tests/e2e/ssh-orcad-browser-capabilities.spec.ts'
export const ORCAD_BROWSER_SERVICE_STATUS_E2E_SPEC =
'tests/e2e/ssh-orcad-browser-service-status.spec.ts'
export const DEDICATED_E2E_SPECS = [
...DOCKER_SSH_E2E_SPECS,
NODE_NETWORK_E2E_SPEC,
@@ -63,7 +65,8 @@ export const DEDICATED_E2E_SPECS = [
ORCAD_AUTO_CONVERT_E2E_SPEC,
WINDOWS_MISSING_APPDATA_E2E_SPEC,
ORCAD_IDLE_EXIT_E2E_SPEC,
ORCAD_BROWSER_CAPABILITIES_E2E_SPEC
ORCAD_BROWSER_CAPABILITIES_E2E_SPEC,
ORCAD_BROWSER_SERVICE_STATUS_E2E_SPEC
]
const dedicatedSpecs = new Set(DEDICATED_E2E_SPECS)
const dockerSpecs = new Set(DOCKER_SSH_E2E_SPECS)
@@ -106,7 +109,8 @@ export function classifyE2eJobs(input, sshSourceChanged = 'false') {
spec === LAYOUT_ORACLE_HEADLESS_E2E_SPEC ||
spec === ORCAD_AUTO_CONVERT_E2E_SPEC ||
spec === ORCAD_IDLE_EXIT_E2E_SPEC ||
spec === ORCAD_BROWSER_CAPABILITIES_E2E_SPEC
spec === ORCAD_BROWSER_CAPABILITIES_E2E_SPEC ||
spec === ORCAD_BROWSER_SERVICE_STATUS_E2E_SPEC
)
}
}
@@ -21,6 +21,17 @@ it.each([
expect(selectPrE2eSpecs([path])).toContain('tests/e2e/ssh-orcad-browser-capabilities.spec.ts')
})
it.each(['errors', 'status', 'lifecycle', 'restart-attempt'])(
'routes the real browser service status check from %s changes',
(name) => {
expect(
selectPrE2eSpecs([
`src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-${name}.ts`
])
).toContain('tests/e2e/ssh-orcad-browser-service-status.spec.ts')
}
)
const workflow = parse(readFileSync('.github/workflows/e2e.yml', 'utf8'))
const prWorkflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const classify = (specs, ssh = 'false') => classifyE2eJobs(JSON.stringify(specs), ssh)
+9
View File
@@ -55,6 +55,15 @@ export const PR_E2E_SOURCE_ROUTES = [
file
)
},
{
id: 'browser.orcad-service-status',
specs: ['tests/e2e/ssh-orcad-browser-service-status.spec.ts'],
matches: (file) =>
isProductSource(file) &&
/^src\/renderer\/src\/components\/browser-pane\/stream-remote\/remote-browser-stream-(?:errors|status|lifecycle|restart-attempt)\.ts$/.test(
file
)
},
{
id: 'ssh.orcad-idle-exit',
specs: ['tests/e2e/ssh-orcad-idle-exit.spec.ts'],
@@ -0,0 +1,62 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createHarness,
openStreamAndConfirmReady,
rpcError,
settle
} from './remote-browser-stream-lifecycle-test-harness'
const SERVICE_NOTICE = 'The remote browser is unavailable. Check its setup on the server.'
describe('browser service failures on a responding remote host', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('shows the browser setup failure on initial open and keeps manual recovery', async () => {
const harness = createHarness()
harness.failEverySubscribe(rpcError('browser_unavailable', 'unconfigured backend'))
harness.lifecycle.open()
await settle()
expect(harness.currentError).toBe(SERVICE_NOTICE)
expect(harness.currentStatusKind).toBe('stopped')
expect(harness.reconnectOffered).toBe(true)
})
it('keeps browser service recovery bounded and restores the stream once it returns', async () => {
const harness = createHarness()
await openStreamAndConfirmReady(harness)
harness.failEverySubscribe(rpcError('browser_unavailable', 'backend exited'))
harness.streams[0].emitEnd()
await vi.advanceTimersByTimeAsync(500)
expect(harness.currentError).toBe(SERVICE_NOTICE)
expect(harness.reconnectOffered).toBe(false)
harness.failEverySubscribe(null)
await vi.advanceTimersByTimeAsync(1_000)
harness.streams.at(-1)?.emitReady()
await settle()
expect(harness.currentStatusKind).toBe('live')
expect(harness.currentError).toBeNull()
})
it('offers manual recovery with the service notice after retry exhaustion', async () => {
const harness = createHarness()
await openStreamAndConfirmReady(harness)
harness.failEverySubscribe(rpcError('browser_unavailable', 'backend remains unavailable'))
harness.streams[0].emitEnd()
await vi.advanceTimersByTimeAsync(60_000)
expect(harness.currentError).toBe(SERVICE_NOTICE)
expect(harness.reconnectOffered).toBe(true)
expect(harness.subscribeAttempts).toBeLessThanOrEqual(6)
})
it('keeps the service failure specific when resizing replaces a live stream', async () => {
const harness = createHarness()
await openStreamAndConfirmReady(harness)
harness.setViewportSize({ width: 1_024, height: 768 })
harness.failEverySubscribe(rpcError('browser_unavailable', 'backend exited'))
harness.lifecycle.restartForViewport('page-1')
await settle()
expect(harness.currentError).toBe(SERVICE_NOTICE)
expect(harness.reconnectOffered).toBe(true)
})
})
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
isPermanentRemoteBrowserStreamFailure,
remoteBrowserStreamUnsupportedError,
resolveRemoteBrowserStreamRestartFailure
resolveRemoteBrowserStreamFailure
} from './remote-browser-stream-errors'
function rpcError(code: string, message = 'boom'): Error {
@@ -13,6 +13,28 @@ function rpcError(code: string, message = 'boom'): Error {
// was enforced by nothing — re-adding `selector_not_found` (the exact code 08260a54bf had to walk
// back out) left the whole suite green, as did deleting two of the three members.
describe('remote browser stream failure classification', () => {
it.each(['opening', 'restart'] as const)(
'distinguishes browser setup from transport during %s',
(phase) => {
const failure = resolveRemoteBrowserStreamFailure(
rpcError('browser_unavailable', 'unconfigured backend'),
phase
)
expect(failure.message).toBe(
'The remote browser is unavailable. Check its setup on the server.'
)
expect(failure.shouldRetry).toBe(true)
expect(failure.logRawError).toBe(true)
expect(isPermanentRemoteBrowserStreamFailure(rpcError('browser_unavailable'))).toBe(false)
}
)
it('keeps the initial transport failure distinct from a lost stream', () => {
expect(resolveRemoteBrowserStreamFailure(rpcError('runtime_timeout'), 'opening').message).toBe(
'Cannot reach the remote server.'
)
})
it('treats only codes that prove the target is gone as permanent', () => {
for (const code of [
'worktree_not_found_on_server',
@@ -30,9 +52,7 @@ describe('remote browser stream failure classification', () => {
// stopped retries on a blip and stranded the pane, which is the bug this area exists to prevent.
it('does not treat an unresolved selector as proof the target is gone', () => {
expect(isPermanentRemoteBrowserStreamFailure(rpcError('selector_not_found'))).toBe(false)
expect(
resolveRemoteBrowserStreamRestartFailure(rpcError('selector_not_found')).shouldRetry
).toBe(true)
expect(resolveRemoteBrowserStreamFailure(rpcError('selector_not_found')).shouldRetry).toBe(true)
})
it('keeps unknown and transport failures retryable', () => {
@@ -44,7 +64,7 @@ describe('remote browser stream failure classification', () => {
})
it('keeps a permanent failure specific and replaces only raw transient text', () => {
const permanent = resolveRemoteBrowserStreamRestartFailure(
const permanent = resolveRemoteBrowserStreamFailure(
rpcError('worktree_not_found_on_server', 'worktree is gone')
)
// Its own message says something true that a generic "lost connection" would not.
@@ -52,7 +72,7 @@ describe('remote browser stream failure classification', () => {
expect(permanent.shouldRetry).toBe(false)
expect(permanent.logRawError).toBe(false)
const transient = resolveRemoteBrowserStreamRestartFailure(
const transient = resolveRemoteBrowserStreamFailure(
rpcError('runtime_unavailable', 'Runtime environment is manually disconnected.')
)
expect(transient.message).toBe('Lost connection to the remote server.')
@@ -62,7 +82,7 @@ describe('remote browser stream failure classification', () => {
})
it('uses localized copy for permanent failures without an Error message', () => {
const failure = resolveRemoteBrowserStreamRestartFailure({
const failure = resolveRemoteBrowserStreamFailure({
code: 'worktree_not_found_on_server'
})
@@ -1,8 +1,11 @@
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-client'
import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../../../../shared/runtime-session-contracts'
import {
remoteBrowserStreamLostNotice,
remoteBrowserStreamRestartFailedNotice,
remoteBrowserStreamUnsupportedNotice
remoteBrowserStreamUnsupportedNotice,
remoteBrowserStreamUnreachableNotice,
remoteBrowserServiceUnavailableNotice
} from './remote-browser-stream-status'
// Why: a runtime lacking browser.screencast.v1 will not grow it while this connection lives, so
@@ -44,7 +47,7 @@ function readErrorCode(error: unknown): string | null {
if (!error || typeof error !== 'object' || !('code' in error)) {
return null
}
const code = (error as { code: unknown }).code
const code = error.code
return typeof code === 'string' ? code : null
}
@@ -68,7 +71,7 @@ export function isPermanentRemoteBrowserStreamFailure(error: unknown): boolean {
)
}
export type RemoteBrowserStreamRestartFailure = {
export type RemoteBrowserStreamFailure = {
/** What the pane shows. */
message: string
/** False once the failure is proven unrecoverable on this connection. */
@@ -89,9 +92,18 @@ export type RemoteBrowserStreamRestartFailure = {
// is what stranded the pane in the first place. `selector_not_found` already had to be walked back
// out of the permanent set once (08260a54bf) — proof this classification can be wrong — so a
// misjudgement here must stay recoverable by hand.
export function resolveRemoteBrowserStreamRestartFailure(
error: unknown
): RemoteBrowserStreamRestartFailure {
export function resolveRemoteBrowserStreamFailure(
error: unknown,
phase: 'opening' | 'restart' = 'restart'
): RemoteBrowserStreamFailure {
// Browser setup can recover without the server connection changing.
if (readErrorCode(error) === BROWSER_UNAVAILABLE_ERROR_CODE) {
return {
message: remoteBrowserServiceUnavailableNotice(),
shouldRetry: true,
logRawError: true
}
}
if (isPermanentRemoteBrowserStreamFailure(error)) {
return {
message: error instanceof Error ? error.message : remoteBrowserStreamRestartFailedNotice(),
@@ -99,5 +111,12 @@ export function resolveRemoteBrowserStreamRestartFailure(
logRawError: false
}
}
return { message: remoteBrowserStreamLostNotice(), shouldRetry: true, logRawError: true }
return {
message:
phase === 'opening'
? remoteBrowserStreamUnreachableNotice()
: remoteBrowserStreamLostNotice(),
shouldRetry: true,
logRawError: true
}
}
@@ -11,14 +11,14 @@ import {
remoteBrowserStreamLostNotice,
remoteBrowserStreamRetrying,
remoteBrowserStreamStopped,
remoteBrowserStreamUnreachableNotice,
REMOTE_BROWSER_STREAM_IDLE
REMOTE_BROWSER_STREAM_IDLE,
remoteBrowserStreamNotice,
type RemoteBrowserStreamStatus
} from './remote-browser-stream-status'
import {
isPermanentRemoteBrowserStreamFailure,
isRemoteBrowserPageMissingError,
remoteBrowserStreamUnsupportedError,
resolveRemoteBrowserStreamRestartFailure
resolveRemoteBrowserStreamFailure
} from './remote-browser-stream-errors'
import {
areRemoteViewportSizesNear,
@@ -40,6 +40,7 @@ export class RemoteBrowserStreamLifecycle {
private subscription: RemoteBrowserStreamSubscription | null = null
private streamViewportSize: RemoteBrowserViewportSize | null = null
private readonly liveness = new RemoteBrowserStreamLiveness()
private status: RemoteBrowserStreamStatus = REMOTE_BROWSER_STREAM_IDLE
constructor(private readonly deps: RemoteBrowserStreamLifecycleDeps) {
this.tokens = new RemoteBrowserOperationTokens(deps.identity)
@@ -48,10 +49,19 @@ export class RemoteBrowserStreamLifecycle {
// each restart subscribes fine, then the stream ends before 'ready', so the catch that normally
// reports never runs. 'stopped' carries its notice, so that case cannot go silent.
this.restartScheduler = new RemoteBrowserStreamRestartScheduler(undefined, () =>
deps.setStatus(remoteBrowserStreamStopped(remoteBrowserStreamLostNotice()))
this.setStatus(
remoteBrowserStreamStopped(
remoteBrowserStreamNotice(this.status) ?? remoteBrowserStreamLostNotice()
)
)
)
}
private setStatus(status: RemoteBrowserStreamStatus): void {
this.status = status
this.deps.setStatus(status)
}
forgetStreamViewportSize(): void {
this.streamViewportSize = null
}
@@ -62,11 +72,11 @@ export class RemoteBrowserStreamLifecycle {
const { tokens, deps } = this
// A reopen (tab switch, environment or worktree change, or Reconnect) starts a fresh budget, so
// 'opening' replaces whatever the previous attempt ended on — including a spent 'stopped'.
deps.setStatus(REMOTE_BROWSER_STREAM_OPENING)
this.setStatus(REMOTE_BROWSER_STREAM_OPENING)
this.retireInFlightWork()?.unsubscribe()
const operationToken = tokens.createOperationToken()
if (!operationToken) {
deps.setStatus(REMOTE_BROWSER_STREAM_IDLE)
this.setStatus(REMOTE_BROWSER_STREAM_IDLE)
return () => {}
}
void this.session
@@ -101,8 +111,8 @@ export class RemoteBrowserStreamLifecycle {
// Why classified here too: the same condition (a host that cannot stream) reaches both this
// path and the restart path, and it must not read as "unreachable" here and as its own
// specific message there.
const permanent = isPermanentRemoteBrowserStreamFailure(error)
if (!permanent) {
const failure = resolveRemoteBrowserStreamFailure(error, 'opening')
if (failure.logRawError) {
console.warn('[browser-pane] remote browser failed to open:', error)
}
// Why this can be the loser of a race: the stream token is claimed before subscribe is
@@ -113,15 +123,8 @@ export class RemoteBrowserStreamLifecycle {
if (this.restartScheduler.isScheduled) {
return
}
// "Unreachable" rather than "lost": nothing was ever established here. And this path never
// had a stream, so the retry budget never runs — 'stopped' is what hands the user a way back.
deps.setStatus(
remoteBrowserStreamStopped(
permanent && error instanceof Error
? error.message
: remoteBrowserStreamUnreachableNotice()
)
)
// An initial open has no retry budget; preserve manual recovery.
this.setStatus(remoteBrowserStreamStopped(failure.message))
})
return () => {
cancelled = true
@@ -151,7 +154,7 @@ export class RemoteBrowserStreamLifecycle {
// Why: without a token the .then/.catch below would mutate busy/remoteError on behalf of a
// restart that a newer operation has already replaced.
const restartToken = this.tokens.createOperationToken(pageId)
this.deps.setStatus(REMOTE_BROWSER_STREAM_OPENING)
this.setStatus(REMOTE_BROWSER_STREAM_OPENING)
current.unsubscribe()
void this.startStream(pageId)
.then((subscription) => {
@@ -177,12 +180,12 @@ export class RemoteBrowserStreamLifecycle {
// Why classified rather than forwarded: this was the one failure path still putting raw
// transport text in the UI ("Runtime environment pairing changed; refresh and try again"),
// which is written for logs and names our internals. The other two paths already classify.
const failure = resolveRemoteBrowserStreamRestartFailure(error)
const failure = resolveRemoteBrowserStreamFailure(error)
if (failure.logRawError) {
console.warn('[browser-pane] remote stream resize failed:', error)
}
// A resize during a blip tears down a live subscription and never reaches the budget.
this.deps.setStatus(remoteBrowserStreamStopped(failure.message))
this.setStatus(remoteBrowserStreamStopped(failure.message))
})
}
@@ -279,7 +282,7 @@ export class RemoteBrowserStreamLifecycle {
// 'live' carries no notice, so a toast the retry already healed cannot survive
// (STA-3483).
this.liveness.markReady()
deps.setStatus(REMOTE_BROWSER_STREAM_LIVE)
this.setStatus(REMOTE_BROWSER_STREAM_LIVE)
deps.applyTabInfo(event.tab)
void deps.syncViewport(event.browserPageId).catch(() => {})
},
@@ -288,7 +291,7 @@ export class RemoteBrowserStreamLifecycle {
// close below is deliberately non-restarting, so nothing else would hand the user a way
// back. Its message is the host's own, which is more specific than any substitute.
onFailed: (message) => {
deps.setStatus(remoteBrowserStreamStopped(message))
this.setStatus(remoteBrowserStreamStopped(message))
this.handleStreamClosed(token, false)
},
// Why 'stopped' and not 'retrying': a transport error is NOT guaranteed to be followed by
@@ -306,7 +309,7 @@ export class RemoteBrowserStreamLifecycle {
// does follow must still refill the budget for a stream that had been healthy.
onTransportError: () => {
this.liveness.stopWaitingForReady()
deps.setStatus(remoteBrowserStreamStopped(remoteBrowserStreamLostNotice()))
this.setStatus(remoteBrowserStreamStopped(remoteBrowserStreamLostNotice()))
},
onPageMissing: () => deps.closeMissingRemotePage(pageId),
onFrame: (bytes) => deps.handleFrameBytes(token, bytes),
@@ -335,7 +338,7 @@ export class RemoteBrowserStreamLifecycle {
// Why no notice yet: the budget exists to absorb a blip invisibly, so nothing is said until an
// attempt has actually failed. A non-restarting close leaves whatever the caller published.
if (restart) {
this.deps.setStatus(remoteBrowserStreamRetrying(null))
this.setStatus(remoteBrowserStreamRetrying(null))
}
const current = this.subscription
this.subscription = null
@@ -362,7 +365,7 @@ export class RemoteBrowserStreamLifecycle {
createRemoteBrowserStreamRestartAttempt(token, {
tokens: this.tokens,
session: this.session,
setStatus: (status) => this.deps.setStatus(status),
setStatus: (status) => this.setStatus(status),
applyTabInfo: (tab) => this.deps.applyTabInfo(tab),
closeMissingRemotePage: (remotePageId) => this.deps.closeMissingRemotePage(remotePageId),
startStream: (pageId) => this.startStream(pageId),
@@ -1,7 +1,7 @@
import type { RemoteBrowserPageSession } from './remote-browser-page-session'
import {
isRemoteBrowserPageMissingError,
resolveRemoteBrowserStreamRestartFailure
resolveRemoteBrowserStreamFailure
} from './remote-browser-stream-errors'
import {
remoteBrowserStreamRetrying,
@@ -63,7 +63,7 @@ export function createRemoteBrowserStreamRestartAttempt(
deps.closeMissingRemotePage(token.remotePageId)
return false
}
const failure = resolveRemoteBrowserStreamRestartFailure(error)
const failure = resolveRemoteBrowserStreamFailure(error)
if (failure.logRawError) {
// The raw text is transport-level and written for logs; keep it out of the UI but not out of
// reach, since nothing else records it.
@@ -80,6 +80,13 @@ export function remoteBrowserStreamUnreachableNotice(): string {
)
}
export function remoteBrowserServiceUnavailableNotice(): string {
return translate(
'auto.components.BrowserPane.streamBrowserUnavailable',
'The remote browser is unavailable. Check its setup on the server.'
)
}
export function remoteBrowserStreamRestartFailedNotice(): string {
return translate(
'auto.components.BrowserPane.streamRestartFailed',
+1
View File
@@ -17652,6 +17652,7 @@
"BrowserPane": {
"streamConnectionLost": "Lost connection to the remote server.",
"streamConnectionUnreachable": "Cannot reach the remote server.",
"streamBrowserUnavailable": "The remote browser is unavailable. Check its setup on the server.",
"streamRestartFailed": "Failed to restart remote browser stream.",
"streamCapabilityUnsupported": "The selected runtime does not support remote browser streaming."
},
@@ -0,0 +1,173 @@
import { cpSync, rmSync } from 'node:fs'
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 {
cleanupDockerSshRelayTarget,
startDockerSshRelayTarget
} from './helpers/docker-ssh-relay-target'
import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection'
import {
startSshRemoteOnlyBrowserFixture,
SSH_REMOTE_ONLY_ORIGIN,
SSH_REMOTE_ONLY_COOKIE_NAME,
SSH_REMOTE_ONLY_COOKIE_VALUE
} from './helpers/ssh-remote-only-browser-fixture'
import { createRetentionFixtureDirectory } from './helpers/host-created-terminal-retention-oracle'
import { managedServer, reconnect, serverCall } from './helpers/orcad-convert-flow'
import { navigateGuest } from './helpers/browser-split-guest-probes'
const TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE
test.skip(!TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', 'Needs Docker and server template')
async function guestMarker(page: Page, tabId: string): Promise<unknown> {
return page.evaluate(async (id) => {
const guest = document.querySelector<Electron.WebviewTag>(
`[data-browser-overlay-tab-id="${id}"] webview`
)
try {
return await guest?.executeJavaScript('document.querySelector("#marker")?.textContent')
} catch {
return null
}
}, tabId)
}
test('an unavailable browser on a responding managed host does not report a server outage', async (// oxlint-disable-next-line no-empty-pattern -- Owns app launch.
{}, testInfo) => {
test.setTimeout(5 * 60_000)
const target = startDockerSshRelayTarget(testInfo)
const scratch = createRetentionFixtureDirectory()
const template = path.join(scratch, 'template')
const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: template })
let app: ElectronApplication | null = null
try {
startSshRemoteOnlyBrowserFixture(target)
const launched = await session.launch()
app = launched.app
const page = launched.page
page.on('console', (message) => {
if (message.type() === 'warning' || message.type() === 'error') {
console.log('[browser-renderer]', message.text().slice(0, 1_000))
}
})
await waitForSessionReady(page)
const remote = await connectDockerSshRelayTarget(page, target, { seedInitialTab: false })
expect(await managedServer(page, remote.targetId)).toMatchObject({ kind: 'relay' })
const tabId = await page.evaluate(
({ worktreeId, url }) => {
const state = window.__store?.getState()
if (!state) {
throw new Error('Missing store')
}
const tab = state.createBrowserTab(worktreeId, url, {
title: 'Retained browser',
activate: true
})
for (const terminal of state.tabsByWorktree[worktreeId] ?? []) {
state.closeTab(terminal.id)
}
return tab.id
},
{ worktreeId: remote.worktreeId, url: `${SSH_REMOTE_ONLY_ORIGIN}/login` }
)
await expect.poll(() => guestMarker(page, tabId), { timeout: 60_000 }).toBe('login-marker')
await navigateGuest(page, tabId, `${SSH_REMOTE_ONLY_ORIGIN}/echo/before`)
const cookieMarker = `cookie:${SSH_REMOTE_ONLY_COOKIE_NAME}=${SSH_REMOTE_ONLY_COOKIE_VALUE}`
await expect.poll(() => guestMarker(page, tabId), { timeout: 30_000 }).toBe(cookieMarker)
await page.screenshot({ path: testInfo.outputPath('browser-before-conversion.png') })
await expect
.poll(
() =>
page.evaluate((id) => window.api.pty.listSessions({ connectionId: id }), remote.targetId),
{ timeout: 30_000 }
)
.toEqual([])
cpSync(TEMPLATE!, template, { recursive: true })
const server = await reconnect(page, remote.targetId)
console.log('[browser-conversion-connect]', server)
expect(JSON.parse(server)).toMatchObject({ kind: 'managed' })
const environments = await page.evaluate(() => window.api.runtimeEnvironments.list())
const environment = environments.find(
(entry) => entry.orcadDeployment?.sshTargetId === remote.targetId
)
if (!environment) {
throw new Error('Missing managed environment')
}
console.log('[browser-host-status]', await serverCall(page, environment.id, 'status.get'))
await expect
.poll(
() =>
page.evaluate((worktreeId) => {
const state = window.__store?.getState()
return (
state?.repos.find((repo) =>
(state.worktreesByRepo[repo.id] ?? []).some(
(worktree) => worktree.id === worktreeId
)
)?.executionHostId ?? null
)
}, remote.worktreeId),
{ 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)
}
if (tab?.activePageId) {
state?.focusBrowserTabInWorktree(worktreeId, tab.activePageId, { surfacePane: true })
}
return tab?.id ?? null
}, remote.worktreeId)
expect(retained).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') })
console.log('[browser-service-notice]', await notice.innerText())
const browserReply = await page.evaluate(
(environmentId) =>
window.api.runtimeEnvironments.call({
selector: environmentId,
method: 'browser.tabList',
params: {}
}),
environment.id
)
expect(browserReply).toMatchObject({ ok: false, error: { code: 'browser_unavailable' } })
console.log('[browser-service-refusal]', JSON.stringify(browserReply))
const catalog = JSON.parse(await serverCall(page, environment.id, 'repo.list'))
console.log('[browser-service-responsive-host]', JSON.stringify(catalog))
expect(catalog).toMatchObject({
ok: true,
result: { repos: expect.arrayContaining([expect.objectContaining({ id: remote.repoId })]) }
})
await expect(notice).toContainText(
'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.'
)
console.log('[browser-service-after-retry]', await notice.innerText())
expect(JSON.parse(await serverCall(page, environment.id, 'repo.list'))).toMatchObject({
ok: true,
result: { repos: expect.arrayContaining([expect.objectContaining({ id: remote.repoId })]) }
})
} finally {
if (app) {
await session.close(app)
}
await session.dispose()
cleanupDockerSshRelayTarget(target)
rmSync(scratch, { recursive: true, force: true })
}
})