mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(browser): managed SSH terminal links open in the Orca Browser again (#29215)
* fix(browser): managed SSH terminal links open in the Orca Browser again A managed server reached over SSH has no browser backend of its own, so links from its panes were offered only the system browser. They now open in a desktop tab whose page egresses through the SSH host, the same route new browser tabs in that workspace already use. Fixes #29077, #27349 * test(e2e): keep the managed workspace host selected for the Cmd/Ctrl-click step * refactor(browser): keep owner-route checks private; split tab presentation instead Moving the owner predicates to their own module made their callers count as new focus reads. Keep them in workspace-browser-tab-open and move the title/error presentation helpers out to stay under the line limit. The SSH deployment check also reads the workspace's owner route instead of the focus-falling-back host lookup. * test(e2e): assert the managed SSH link page renders in the visible browser tab * fix(browser): keep a desktop-hosted link tab owned by its runtime workspace --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
@@ -42,6 +42,14 @@ it.each([
|
||||
expect(selectPrE2eSpecs([path])).toContain('tests/e2e/ssh-orcad-browser-routing.spec.ts')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'src/renderer/src/lib/workspace-browser-tab-open.ts',
|
||||
'src/renderer/src/lib/http-link-destinations.ts',
|
||||
'src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.ts'
|
||||
])('routes the managed terminal link oracle from %s', (path) => {
|
||||
expect(selectPrE2eSpecs([path])).toContain('tests/e2e/ssh-orcad-terminal-browser-link.spec.ts')
|
||||
})
|
||||
|
||||
const workflow = parse(readFileSync('.github/workflows/e2e.yml', 'utf8'))
|
||||
const prWorkflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const classify = (specs) => classifyE2eJobs(JSON.stringify(specs))
|
||||
|
||||
@@ -170,6 +170,15 @@ export const PR_E2E_SOURCE_ROUTES = [
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'ssh.orcad-terminal-browser-link',
|
||||
specs: ['tests/e2e/ssh-orcad-terminal-browser-link.spec.ts'],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^src\/renderer\/src\/(?:lib\/(?:workspace-browser-tab-open|http-link-(?:destinations|routing)|ssh-workspace-browser-route-eligibility)|components\/terminal-pane\/terminal-http-link-source-owner)\.ts$/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'ssh.orcad-browser-drop-owner',
|
||||
specs: ['tests/e2e/ssh-orcad-browser-drop-owner.spec.ts'],
|
||||
|
||||
@@ -599,3 +599,116 @@ describe('openWorkspaceBrowserTab', () => {
|
||||
expect(mocks.state.createBrowserTab).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('links from a managed server reached over SSH', () => {
|
||||
function sshDeployedRuntimeState(
|
||||
orcadDeployment: Record<string, unknown>,
|
||||
settings: Record<string, unknown> = {}
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...ownerState(toRuntimeExecutionHostId('orcad-env')),
|
||||
runtimeEnvironments: [{ id: 'orcad-env', orcadDeployment }],
|
||||
// Headless orcad advertises no screencast backend.
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
['orcad-env', { status: { capabilities: [] }, checkedAt: 1 }]
|
||||
]),
|
||||
settings,
|
||||
createBrowserTab: vi.fn(),
|
||||
defaultBrowserSessionProfileId: 'client-profile',
|
||||
defaultBrowserSessionProfileIdByHostId: {}
|
||||
}
|
||||
}
|
||||
const sshDeployment = {
|
||||
kind: 'ssh',
|
||||
sshTargetId: 'ssh-target',
|
||||
sshTargetGeneration: 2,
|
||||
localPort: 41000,
|
||||
remotePort: 41001
|
||||
}
|
||||
|
||||
it('opens the link in an SSH-routed desktop tab the runtime workspace still owns', async () => {
|
||||
mocks.state = sshDeployedRuntimeState(sshDeployment)
|
||||
|
||||
expect(canOpenWorkspaceBrowserTabOnRuntime(mocks.getState(), WORKSPACE_ID, 'orcad-env')).toBe(
|
||||
true
|
||||
)
|
||||
await openWorkspaceBrowserTab({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
url: 'http://localhost:3000/',
|
||||
intent: { kind: 'url' },
|
||||
expectedRuntimeEnvironmentId: 'orcad-env'
|
||||
})
|
||||
|
||||
expect(mocks.state.createBrowserTab).toHaveBeenCalledWith(
|
||||
WORKSPACE_ID,
|
||||
'http://localhost:3000/',
|
||||
expect.objectContaining({
|
||||
browserRuntimeEnvironmentId: null,
|
||||
executionHostId: toRuntimeExecutionHostId('orcad-env')
|
||||
})
|
||||
)
|
||||
expect(mocks.createRemote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a screencast-capable deployment on its runtime browser', async () => {
|
||||
mocks.state = {
|
||||
...sshDeployedRuntimeState(sshDeployment),
|
||||
...browserCapableRuntime('orcad-env')
|
||||
}
|
||||
|
||||
await openWorkspaceBrowserTab({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
url: 'http://localhost:3000/',
|
||||
intent: { kind: 'url' },
|
||||
expectedRuntimeEnvironmentId: 'orcad-env'
|
||||
})
|
||||
|
||||
expect(mocks.createRemote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ environmentId: 'orcad-env', waitForRegistration: true })
|
||||
)
|
||||
expect(mocks.state.createBrowserTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['SSH browser routing is off', sshDeployment, { browserSshWorkspaceRoutingEnabled: false }],
|
||||
[
|
||||
'the user browses this host from this device',
|
||||
sshDeployment,
|
||||
{ browserSshWorkspaceRoutingDisabledTargetIds: ['ssh-target'] }
|
||||
],
|
||||
['the server is a WSL deployment', { kind: 'wsl', distro: 'Ubuntu' }, {}]
|
||||
])('offers only the system browser when %s', async (_label, deployment, settings) => {
|
||||
mocks.state = sshDeployedRuntimeState(deployment, settings)
|
||||
|
||||
expect(canOpenWorkspaceBrowserTabOnRuntime(mocks.getState(), WORKSPACE_ID, 'orcad-env')).toBe(
|
||||
false
|
||||
)
|
||||
await expect(
|
||||
openWorkspaceBrowserTab({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
url: 'http://localhost:3000/',
|
||||
intent: { kind: 'url' },
|
||||
expectedRuntimeEnvironmentId: 'orcad-env'
|
||||
})
|
||||
).rejects.toThrow('Unable to open URL.')
|
||||
expect(mocks.state.createBrowserTab).not.toHaveBeenCalled()
|
||||
expect(mocks.createRemote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not lend the SSH route to a link asserting another runtime', () => {
|
||||
mocks.state = sshDeployedRuntimeState(sshDeployment)
|
||||
|
||||
expect(canOpenWorkspaceBrowserTabOnRuntime(mocks.getState(), WORKSPACE_ID, 'other-env')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('offers no in-app browser from the paired web client', () => {
|
||||
mocks.pairedWeb = true
|
||||
mocks.state = sshDeployedRuntimeState(sshDeployment)
|
||||
|
||||
expect(canOpenWorkspaceBrowserTabOnRuntime(mocks.getState(), WORKSPACE_ID, 'orcad-env')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { createWebRuntimeSessionBrowserTab } from '@/runtime/web-runtime-browser-creation'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
@@ -7,15 +6,20 @@ import {
|
||||
parseExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../shared/execution-host'
|
||||
import { SEARCH_ENGINE_LABELS, type SearchEngine } from '../../../shared/browser-url'
|
||||
import type { BrowserClientHostPlacementPreference } from '../../../shared/browser-client-host-placement'
|
||||
import type { SearchEngine } from '../../../shared/browser-url'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
|
||||
import { BROWSER_SCREENCAST_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
|
||||
import type { BrowserClientHostPlacementPreference } from '../../../shared/browser-client-host-placement'
|
||||
import {
|
||||
getClientCreationActionPolicy,
|
||||
type ClientCreationActionAvailability
|
||||
} from './client-creation-action-policy'
|
||||
import { resolveWorktreeOperationRoute } from './worktree-operation-route'
|
||||
import {
|
||||
intentPresentation,
|
||||
openFailure,
|
||||
validateTarget
|
||||
} from './workspace-browser-tab-presentation'
|
||||
import { getExecutionHostIdForWorktree } from './worktree-runtime-owner'
|
||||
import { resolveSshWorkspaceBrowserRouteEligibility } from './ssh-workspace-browser-route-eligibility'
|
||||
|
||||
@@ -65,6 +69,39 @@ function isExpectedRuntimeBrowserRoute(
|
||||
)
|
||||
}
|
||||
|
||||
// Why: a managed server reached over SSH has no browser of its own, so its links open on this
|
||||
// desktop, whose page the SSH-routed browser gate sends through that host (as new tabs do).
|
||||
function isSshDeploymentClientBrowserRoute(
|
||||
state: AppState,
|
||||
availability: ClientCreationActionAvailability,
|
||||
route: ReturnType<typeof resolveWorktreeOperationRoute>,
|
||||
workspaceId: string,
|
||||
expectedRuntimeEnvironmentId: string
|
||||
): boolean {
|
||||
if (
|
||||
availability.state !== 'enabled' ||
|
||||
availability.provider !== 'local-client' ||
|
||||
workspaceId === FLOATING_TERMINAL_WORKTREE_ID ||
|
||||
!route
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const expectedEnvironmentId = expectedRuntimeEnvironmentId.trim()
|
||||
const host = parseExecutionHostId(route.executionHostId)
|
||||
const eligibility = resolveSshWorkspaceBrowserRouteEligibility(
|
||||
route.executionHostId,
|
||||
state.settings,
|
||||
state.runtimeEnvironments
|
||||
)
|
||||
return (
|
||||
Boolean(expectedEnvironmentId) &&
|
||||
route.runtimeEnvironmentId?.trim() === expectedEnvironmentId &&
|
||||
host?.kind === 'runtime' &&
|
||||
host.environmentId === expectedEnvironmentId &&
|
||||
eligibility?.eligible === true
|
||||
)
|
||||
}
|
||||
|
||||
export function canOpenWorkspaceBrowserTabOnRuntime(
|
||||
state: AppState,
|
||||
workspaceId: string,
|
||||
@@ -72,12 +109,21 @@ export function canOpenWorkspaceBrowserTabOnRuntime(
|
||||
): boolean {
|
||||
const availability = getClientCreationActionPolicy(state, workspaceId)['managed-browser']
|
||||
const route = resolveWorktreeOperationRoute(state, workspaceId)
|
||||
return isExpectedRuntimeBrowserRoute(
|
||||
state,
|
||||
availability,
|
||||
route,
|
||||
workspaceId,
|
||||
expectedRuntimeEnvironmentId
|
||||
return (
|
||||
isExpectedRuntimeBrowserRoute(
|
||||
state,
|
||||
availability,
|
||||
route,
|
||||
workspaceId,
|
||||
expectedRuntimeEnvironmentId
|
||||
) ||
|
||||
isSshDeploymentClientBrowserRoute(
|
||||
state,
|
||||
availability,
|
||||
route,
|
||||
workspaceId,
|
||||
expectedRuntimeEnvironmentId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -117,82 +163,21 @@ export function canOpenWorkspaceBrowserTabOnSsh(
|
||||
return isExpectedSshBrowserRoute(state, availability, route, workspaceId, expectedSshConnectionId)
|
||||
}
|
||||
|
||||
// Why: concurrent URL tabs are indistinguishable under a shared "Open URL"
|
||||
// label until the page title loads; the query string stays out so a typed URL
|
||||
// does not park credentials or tokens in persisted tab state.
|
||||
function urlTabTitle(url: string): string | null {
|
||||
try {
|
||||
// host, not hostname: localhost:3000 and localhost:5173 are different tabs.
|
||||
const parsed = new URL(url)
|
||||
return `${parsed.host}${parsed.pathname === '/' ? '' : parsed.pathname}`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function intentPresentation(
|
||||
intent: WorkspaceBrowserTabIntent,
|
||||
url: string
|
||||
): {
|
||||
error: string
|
||||
title: string
|
||||
} {
|
||||
if (intent.kind === 'url') {
|
||||
return {
|
||||
error: translate('auto.lib.workspace.browser.tab.open.urlFailed', 'Unable to open URL.'),
|
||||
title:
|
||||
urlTabTitle(url) ??
|
||||
translate('auto.components.tab.bar.TabBarCreateEntry.7cdf8ee0c8', 'Open URL')
|
||||
}
|
||||
}
|
||||
const engine = SEARCH_ENGINE_LABELS[intent.engine]
|
||||
return {
|
||||
error: translate(
|
||||
'auto.lib.workspace.browser.tab.open.searchFailed',
|
||||
'Unable to search with {{value0}}.',
|
||||
{ value0: engine }
|
||||
),
|
||||
title: translate(
|
||||
'auto.components.tab.bar.TabBarCreateEntry.searchProvider',
|
||||
'Search {{value0}}',
|
||||
{ value0: engine }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the UI string stays friendly and query-free, but the diagnosable reason
|
||||
// rides along as `cause` so a failed open is not indistinguishable in logs.
|
||||
function openFailure(message: string, reason: string, cause?: unknown): Error {
|
||||
// Why: callers only surface `message`, so log the reason here or a failed open
|
||||
// leaves no trace at all. The reason alone — never `cause` — keeps the typed
|
||||
// URL and search query out of the console.
|
||||
console.warn(`[workspace-browser-tab-open] ${reason}`)
|
||||
return new Error(message, {
|
||||
cause: new Error(reason, cause === undefined ? undefined : { cause })
|
||||
})
|
||||
}
|
||||
|
||||
function validateTarget(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && !!parsed.hostname
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function createClientBrowserTab(
|
||||
state: AppState,
|
||||
request: OpenWorkspaceBrowserTabRequest,
|
||||
hostId: ExecutionHostId,
|
||||
presentation: { error: string; title: string }
|
||||
presentation: { error: string; title: string },
|
||||
// Why: a desktop-hosted page in a runtime workspace stays owned by that runtime, or the
|
||||
// unified tab is disowned from its worktree (palette and activation skip it).
|
||||
ownerHostId: ExecutionHostId = hostId
|
||||
): void {
|
||||
try {
|
||||
state.createBrowserTab(request.workspaceId, request.url, {
|
||||
activate: request.focusOnCreate !== false,
|
||||
browserRuntimeEnvironmentId: null,
|
||||
focusAddressBar: false,
|
||||
executionHostId: hostId,
|
||||
executionHostId: ownerHostId,
|
||||
sessionProfileId:
|
||||
state.defaultBrowserSessionProfileIdByHostId[hostId] ??
|
||||
state.defaultBrowserSessionProfileId,
|
||||
@@ -238,16 +223,26 @@ export async function openWorkspaceBrowserTab(
|
||||
if (expectedEnvironmentId !== null && expectedSshConnectionId !== null) {
|
||||
throw openFailure(presentation.error, 'browser owner assertion is ambiguous')
|
||||
}
|
||||
if (
|
||||
const runtimeOwnerRoute =
|
||||
expectedEnvironmentId !== null &&
|
||||
!isExpectedRuntimeBrowserRoute(
|
||||
isExpectedRuntimeBrowserRoute(
|
||||
state,
|
||||
availability,
|
||||
route,
|
||||
request.workspaceId,
|
||||
expectedEnvironmentId
|
||||
)
|
||||
) {
|
||||
const sshDeploymentClientRoute =
|
||||
expectedEnvironmentId !== null &&
|
||||
!runtimeOwnerRoute &&
|
||||
isSshDeploymentClientBrowserRoute(
|
||||
state,
|
||||
availability,
|
||||
route,
|
||||
request.workspaceId,
|
||||
expectedEnvironmentId
|
||||
)
|
||||
if (expectedEnvironmentId !== null && !runtimeOwnerRoute && !sshDeploymentClientRoute) {
|
||||
throw openFailure(presentation.error, 'asserted runtime cannot provide this managed browser')
|
||||
}
|
||||
if (
|
||||
@@ -280,10 +275,16 @@ export async function openWorkspaceBrowserTab(
|
||||
)
|
||||
}
|
||||
// An asserted runtime owns links opened from remote panes; provider policy may describe the
|
||||
// viewing client's generic browser surface rather than that pane's execution host.
|
||||
if (expectedEnvironmentId === null && availability.provider === 'local-client') {
|
||||
// viewing client's generic browser surface rather than that pane's execution host. The one
|
||||
// exception is an SSH-deployed server, whose links this desktop hosts on the SSH route.
|
||||
if (
|
||||
(expectedEnvironmentId === null || sshDeploymentClientRoute) &&
|
||||
availability.provider === 'local-client'
|
||||
) {
|
||||
const localHostId = host && host.kind !== 'runtime' ? host.id : LOCAL_EXECUTION_HOST_ID
|
||||
createClientBrowserTab(state, request, localHostId, presentation)
|
||||
const ownerHostId =
|
||||
host?.kind === 'runtime' && route.executionHostId ? route.executionHostId : localHostId
|
||||
createClientBrowserTab(state, request, localHostId, presentation, ownerHostId)
|
||||
return
|
||||
}
|
||||
let created = false
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { SEARCH_ENGINE_LABELS } from '../../../shared/browser-url'
|
||||
import type { WorkspaceBrowserTabIntent } from './workspace-browser-tab-open'
|
||||
|
||||
// Why: concurrent URL tabs are indistinguishable under a shared "Open URL"
|
||||
// label until the page title loads; the query string stays out so a typed URL
|
||||
// does not park credentials or tokens in persisted tab state.
|
||||
function urlTabTitle(url: string): string | null {
|
||||
try {
|
||||
// host, not hostname: localhost:3000 and localhost:5173 are different tabs.
|
||||
const parsed = new URL(url)
|
||||
return `${parsed.host}${parsed.pathname === '/' ? '' : parsed.pathname}`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function intentPresentation(
|
||||
intent: WorkspaceBrowserTabIntent,
|
||||
url: string
|
||||
): {
|
||||
error: string
|
||||
title: string
|
||||
} {
|
||||
if (intent.kind === 'url') {
|
||||
return {
|
||||
error: translate('auto.lib.workspace.browser.tab.open.urlFailed', 'Unable to open URL.'),
|
||||
title:
|
||||
urlTabTitle(url) ??
|
||||
translate('auto.components.tab.bar.TabBarCreateEntry.7cdf8ee0c8', 'Open URL')
|
||||
}
|
||||
}
|
||||
const engine = SEARCH_ENGINE_LABELS[intent.engine]
|
||||
return {
|
||||
error: translate(
|
||||
'auto.lib.workspace.browser.tab.open.searchFailed',
|
||||
'Unable to search with {{value0}}.',
|
||||
{ value0: engine }
|
||||
),
|
||||
title: translate(
|
||||
'auto.components.tab.bar.TabBarCreateEntry.searchProvider',
|
||||
'Search {{value0}}',
|
||||
{ value0: engine }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the UI string stays friendly and query-free, but the diagnosable reason
|
||||
// rides along as `cause` so a failed open is not indistinguishable in logs.
|
||||
export function openFailure(message: string, reason: string, cause?: unknown): Error {
|
||||
// Why: callers only surface `message`, so log the reason here or a failed open
|
||||
// leaves no trace at all. The reason alone — never `cause` — keeps the typed
|
||||
// URL and search query out of the console.
|
||||
console.warn(`[workspace-browser-tab-open] ${reason}`)
|
||||
return new Error(message, {
|
||||
cause: new Error(reason, cause === undefined ? undefined : { cause })
|
||||
})
|
||||
}
|
||||
|
||||
export function validateTarget(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && !!parsed.hostname
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
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 {
|
||||
cleanupDockerSshHostTarget,
|
||||
DOCKER_SSH_HOST_REMOTE_REPO_PATH,
|
||||
startDockerSshHostTarget
|
||||
} from './helpers/docker-ssh-host-target'
|
||||
import { seedRelayEraProfile } from './helpers/orcad-upgrade-profile'
|
||||
import {
|
||||
readSshRemoteOnlyRequests,
|
||||
SSH_REMOTE_ONLY_ORIGIN,
|
||||
SSH_REMOTE_ONLY_PORT,
|
||||
startSshRemoteOnlyBrowserFixture
|
||||
} from './helpers/ssh-remote-only-browser-fixture'
|
||||
import { convertAndRetain } from './helpers/orcad-convert-flow'
|
||||
import {
|
||||
activateManagedWorkspace,
|
||||
expectPaneText,
|
||||
managedEnvironmentId,
|
||||
openTerminalTab,
|
||||
ORCAD_TEMPLATE,
|
||||
skipUnlessManagedDockerHost,
|
||||
typeInTerminal
|
||||
} from './helpers/orcad-managed-workspace'
|
||||
|
||||
skipUnlessManagedDockerHost()
|
||||
|
||||
async function terminalLinkPoint(
|
||||
page: Page,
|
||||
tabId: string,
|
||||
link: string
|
||||
): Promise<{ x: number; y: number }> {
|
||||
return page.evaluate(
|
||||
({ tabId, link }) => {
|
||||
const pane = window.__paneManagers?.get(tabId)?.getActivePane?.()
|
||||
const screen = pane?.terminal.element?.querySelector<HTMLElement>('.xterm-screen')
|
||||
const cell = pane?.terminal.dimensions?.css.cell
|
||||
if (!pane || !screen || !cell?.width || !cell.height) {
|
||||
throw new Error('terminal screen unavailable')
|
||||
}
|
||||
const buffer = pane.terminal.buffer.active
|
||||
for (let row = pane.terminal.rows - 1; row >= 0; row -= 1) {
|
||||
const text = buffer.getLine(buffer.viewportY + row)?.translateToString(false) ?? ''
|
||||
const column = text.indexOf(link)
|
||||
if (column !== -1) {
|
||||
const rect = screen.getBoundingClientRect()
|
||||
return {
|
||||
x: rect.left + (column + link.length / 2) * cell.width,
|
||||
y: rect.top + (row + 0.5) * cell.height
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`terminal link ${link} not on screen`)
|
||||
},
|
||||
{ tabId, link }
|
||||
)
|
||||
}
|
||||
|
||||
/** Prints origin+path as one link; split apart so the typed command never shows the whole URL. */
|
||||
async function printLink(page: Page, tabId: string, origin: string, path: string): Promise<void> {
|
||||
await typeInTerminal(page, tabId, `printf '%s%s\\n' '${origin}' '${path}'`)
|
||||
await expectPaneText(page, tabId, `${origin}${path}`)
|
||||
}
|
||||
|
||||
function browserTabUrls(page: Page, worktreeId: string): Promise<string[]> {
|
||||
return page.evaluate(
|
||||
(id) => (window.__store?.getState().browserTabsByWorktree[id] ?? []).map((tab) => tab.url),
|
||||
worktreeId
|
||||
)
|
||||
}
|
||||
|
||||
function browserTabIdForUrl(page: Page, worktreeId: string, url: string): Promise<string | null> {
|
||||
return page.evaluate(
|
||||
({ id, url }) =>
|
||||
(window.__store?.getState().browserTabsByWorktree[id] ?? []).find((tab) => tab.url === url)
|
||||
?.id ?? null,
|
||||
{ id: worktreeId, url }
|
||||
)
|
||||
}
|
||||
|
||||
/** The opened page must be the visible browser surface and render the host's response. */
|
||||
async function expectRenderedLink(page: Page, worktreeId: string, url: string): Promise<void> {
|
||||
await expect.poll(() => browserTabIdForUrl(page, worktreeId, url)).not.toBeNull()
|
||||
const tabId = await browserTabIdForUrl(page, worktreeId, url)
|
||||
const webview = page.locator(`[data-browser-overlay-tab-id="${tabId}"] webview`)
|
||||
await expect(webview).toBeVisible({ timeout: 30_000 })
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
webview.evaluate(async (guest: Electron.WebviewTag) => {
|
||||
try {
|
||||
return await guest.executeJavaScript('document.querySelector("#marker")?.textContent')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}),
|
||||
{ timeout: 60_000, message: 'the opened page never rendered the host response' }
|
||||
)
|
||||
.toMatch(/^cookie:/)
|
||||
}
|
||||
|
||||
test('a managed SSH terminal link opens in the Orca Browser through the SSH host', async (// oxlint-disable-next-line no-empty-pattern -- Owns app launch.
|
||||
{}, testInfo) => {
|
||||
test.setTimeout(5 * 60_000)
|
||||
const target = startDockerSshHostTarget(testInfo)
|
||||
const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: ORCAD_TEMPLATE! })
|
||||
let app: ElectronApplication | null = null
|
||||
try {
|
||||
startSshRemoteOnlyBrowserFixture(target)
|
||||
// The first launch only creates the profile the SSH host is saved into.
|
||||
const first = await session.launch()
|
||||
app = first.app
|
||||
await waitForSessionReady(first.page)
|
||||
await session.close(first.app)
|
||||
app = null
|
||||
const seeded = seedRelayEraProfile(
|
||||
session.userDataDir,
|
||||
{
|
||||
label: `orcad terminal link E2E ${Date.now()}`,
|
||||
host: target.host,
|
||||
port: target.port,
|
||||
username: 'root',
|
||||
identityFile: target.identityFile,
|
||||
identitiesOnly: true,
|
||||
relayGracePeriodSeconds: 1
|
||||
},
|
||||
{ repoPath: DOCKER_SSH_HOST_REMOTE_REPO_PATH, folderPath: '/tmp' }
|
||||
)
|
||||
const launched = await session.launch()
|
||||
app = launched.app
|
||||
const page = launched.page
|
||||
await waitForSessionReady(page)
|
||||
await convertAndRetain(page, session.userDataDir, seeded)
|
||||
const environmentId = await managedEnvironmentId(page, seeded.targetId)
|
||||
await activateManagedWorkspace(page, seeded.worktreeId, environmentId)
|
||||
await page.evaluate(() =>
|
||||
window.__store?.getState().updateSettings({
|
||||
openLinksInApp: true,
|
||||
terminalLinkClickBehavior: 'actions'
|
||||
})
|
||||
)
|
||||
const tabId = await openTerminalTab(page, seeded.worktreeId)
|
||||
|
||||
// The link action popover offers the Orca Browser for a managed server's pane.
|
||||
const popoverUrl = `${SSH_REMOTE_ONLY_ORIGIN}/echo/popover`
|
||||
await printLink(page, tabId, SSH_REMOTE_ONLY_ORIGIN, '/echo/popover')
|
||||
const popoverPoint = await terminalLinkPoint(page, tabId, popoverUrl)
|
||||
await page.mouse.move(popoverPoint.x, popoverPoint.y)
|
||||
await page.mouse.click(popoverPoint.x, popoverPoint.y)
|
||||
const popover = page.locator('[data-terminal-link-action-popover]')
|
||||
await expect(popover).toBeVisible()
|
||||
await expect(popover.getByRole('button').filter({ hasText: 'System Browser' })).toBeVisible()
|
||||
const orcaBrowser = popover.getByRole('button').filter({ hasText: 'Orca Browser' })
|
||||
await expect(orcaBrowser).toBeVisible()
|
||||
await page.screenshot({ path: testInfo.outputPath('managed-terminal-link-popover.png') })
|
||||
await orcaBrowser.click()
|
||||
await expect
|
||||
.poll(() => browserTabUrls(page, seeded.worktreeId), { timeout: 30_000 })
|
||||
.toContain(popoverUrl)
|
||||
// Only the SSH host resolves this origin, so the request proves the page egressed there.
|
||||
await expect
|
||||
.poll(() => readSshRemoteOnlyRequests(target).map((request) => request.path), {
|
||||
timeout: 60_000,
|
||||
message: 'the Orca Browser page never reached the SSH host'
|
||||
})
|
||||
.toContain('/echo/popover')
|
||||
await expectRenderedLink(page, seeded.worktreeId, popoverUrl)
|
||||
|
||||
// Cmd/Ctrl-click opens a remote localhost dev-server URL straight into the Orca Browser.
|
||||
const modifierUrl = `http://localhost:${SSH_REMOTE_ONLY_PORT}/echo/modifier`
|
||||
await printLink(page, tabId, `http://localhost:${SSH_REMOTE_ONLY_PORT}`, '/echo/modifier')
|
||||
const modifierPoint = await terminalLinkPoint(page, tabId, modifierUrl)
|
||||
await page.mouse.move(modifierPoint.x, modifierPoint.y)
|
||||
await page.keyboard.down('ControlOrMeta')
|
||||
await page.mouse.click(modifierPoint.x, modifierPoint.y)
|
||||
await page.keyboard.up('ControlOrMeta')
|
||||
await expect
|
||||
.poll(() => browserTabUrls(page, seeded.worktreeId), { timeout: 30_000 })
|
||||
.toContain(modifierUrl)
|
||||
await expect
|
||||
.poll(() => readSshRemoteOnlyRequests(target).map((request) => request.path), {
|
||||
timeout: 60_000,
|
||||
message: 'the Cmd/Ctrl-clicked localhost link never reached the SSH host'
|
||||
})
|
||||
.toContain('/echo/modifier')
|
||||
await expectRenderedLink(page, seeded.worktreeId, modifierUrl)
|
||||
await page.screenshot({ path: testInfo.outputPath('managed-terminal-link-opened.png') })
|
||||
} finally {
|
||||
try {
|
||||
if (app) {
|
||||
await session.close(app)
|
||||
}
|
||||
await session.dispose()
|
||||
} finally {
|
||||
cleanupDockerSshHostTarget(target)
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user