Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,8 @@ jobs:
. != "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" and
. != "tests/e2e/ssh-orcad-browser-service-status.spec.ts"
. != "tests/e2e/ssh-orcad-browser-service-status.spec.ts" and
. != "tests/e2e/ssh-orcad-browser-routing.spec.ts"
)' <<<"$TEST_FILES_JSON" > "$RUNNER_TEMP/general-e2e-specs"
fi
mapfile -t TEST_FILES < "$RUNNER_TEMP/general-e2e-specs"
Expand Down Expand Up @@ -586,7 +587,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') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-service-status.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') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-routing.spec.ts')
runs-on: ubuntu-latest
timeout-minutes: 45
env:
Expand Down Expand Up @@ -622,7 +623,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 tests/e2e/ssh-orcad-browser-service-status.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 tests/e2e/ssh-orcad-browser-routing.spec.ts --project=electron-headless --workers=1
- uses: actions/upload-artifact@v7
if: failure()
with:
Expand Down
7 changes: 5 additions & 2 deletions config/scripts/ci-e2e-job-selection.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ 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 ORCAD_BROWSER_ROUTING_E2E_SPEC = 'tests/e2e/ssh-orcad-browser-routing.spec.ts'
export const DEDICATED_E2E_SPECS = [
...DOCKER_SSH_E2E_SPECS,
NODE_NETWORK_E2E_SPEC,
Expand All @@ -66,7 +67,8 @@ export const DEDICATED_E2E_SPECS = [
WINDOWS_MISSING_APPDATA_E2E_SPEC,
ORCAD_IDLE_EXIT_E2E_SPEC,
ORCAD_BROWSER_CAPABILITIES_E2E_SPEC,
ORCAD_BROWSER_SERVICE_STATUS_E2E_SPEC
ORCAD_BROWSER_SERVICE_STATUS_E2E_SPEC,
ORCAD_BROWSER_ROUTING_E2E_SPEC
]
const dedicatedSpecs = new Set(DEDICATED_E2E_SPECS)
const dockerSpecs = new Set(DOCKER_SSH_E2E_SPECS)
Expand Down Expand Up @@ -110,7 +112,8 @@ export function classifyE2eJobs(input, sshSourceChanged = 'false') {
spec === ORCAD_AUTO_CONVERT_E2E_SPEC ||
spec === ORCAD_IDLE_EXIT_E2E_SPEC ||
spec === ORCAD_BROWSER_CAPABILITIES_E2E_SPEC ||
spec === ORCAD_BROWSER_SERVICE_STATUS_E2E_SPEC
spec === ORCAD_BROWSER_SERVICE_STATUS_E2E_SPEC ||
spec === ORCAD_BROWSER_ROUTING_E2E_SPEC
)
}
}
Expand Down
8 changes: 8 additions & 0 deletions config/scripts/ci-e2e-job-selection.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ 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'
])('routes the managed browser routing oracle from %s', (path) => {
expect(selectPrE2eSpecs([path])).toContain('tests/e2e/ssh-orcad-browser-routing.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)
Expand Down
9 changes: 9 additions & 0 deletions config/scripts/pr-e2e-source-routing.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ export const PR_E2E_SOURCE_ROUTES = [
file
)
},
{
id: 'ssh.orcad-browser-routing',
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(
file
)
},
{
id: 'ssh.orcad-idle-exit',
specs: ['tests/e2e/ssh-orcad-idle-exit.spec.ts'],
Expand Down
29 changes: 29 additions & 0 deletions src/main/browser/local-ssh-browser-route-target.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { requireLocalSshBrowserRouteTarget } from './local-ssh-browser-route'

const targets = [{ id: 'target-a', generation: 4 }]

describe('local SSH browser target registration', () => {
it('accepts the current managed deployment registration', () => {
expect(() => requireLocalSshBrowserRouteTarget(targets, 'target-a', 4)).not.toThrow()
})

it('preserves direct SSH callers that do not supply a deployment fence', () => {
expect(() => requireLocalSshBrowserRouteTarget(targets, 'target-a')).not.toThrow()
})

it.each([3, 5, '4', null])(
'refuses a stale or invalid deployment registration: %j',
(generation) => {
expect(() => requireLocalSshBrowserRouteTarget(targets, 'target-a', generation)).toThrow(
'browser_local_route_target_stale'
)
}
)

it('refuses a removed target instead of borrowing another route', () => {
expect(() => requireLocalSshBrowserRouteTarget(targets, 'target-b', 4)).toThrow(
'browser_local_route_target_invalid'
)
})
})
18 changes: 18 additions & 0 deletions src/main/browser/local-ssh-browser-route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@
import type { Duplex } from 'node:stream'
import type { BrowserNetworkExecutionRouteResolver } from './browser-network-execution-route'
import type { BrowserNetworkTunnelOpen } from '../../shared/browser-network-tunnel-protocol'
import type { SshTargetSummary } from '../../shared/ssh-types'
import { openExecutionRouteSocketAsDuplex } from './execution-route-socket-duplex'
import { RemoteBrowserSocksServer } from './remote-browser-socks-server'

export function requireLocalSshBrowserRouteTarget(
targets: readonly Pick<SshTargetSummary, 'id' | 'generation'>[] | undefined,
targetId: string,
expectedGeneration?: unknown
): void {
const target = targets?.find((entry) => entry.id === targetId)
if (!target) {
throw new Error('browser_local_route_target_invalid')
}
if (
expectedGeneration !== undefined &&
(typeof expectedGeneration !== 'number' || target.generation !== expectedGeneration)
) {
throw new Error('browser_local_route_target_stale')
}
}

export type LocalSshBrowserRouteDependencies = {
resolveExecutionRoute: BrowserNetworkExecutionRouteResolver
getAuthority: (targetId: string) => { providerEpoch: string; connectionGeneration: number }
Expand Down
20 changes: 13 additions & 7 deletions src/main/ipc/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,12 @@ export function registerBrowserHandlers(): void {
'browser:prepareSshWorkspacePartition',
async (
event,
args: { targetId?: unknown; browserProfileId?: unknown; skipProbe?: unknown }
args: {
targetId?: unknown
browserProfileId?: unknown
skipProbe?: unknown
expectedSshTargetGeneration?: unknown
}
) => {
// Why (review P1-2): preparing mints bindings whose LRU eviction destroys
// cookie jars; only the trusted renderer naming a REGISTERED target may.
Expand All @@ -114,12 +119,13 @@ export function registerBrowserHandlers(): void {
throw new Error('browser_local_route_target_invalid')
}
const { getSshConnectionStore } = await import('./ssh')
const registered = getSshConnectionStore()
?.listTargets()
.some((target) => target.id === args.targetId)
if (!registered) {
throw new Error('browser_local_route_target_invalid')
}
const { requireLocalSshBrowserRouteTarget } =
await import('../browser/local-ssh-browser-route')
requireLocalSshBrowserRouteTarget(
getSshConnectionStore()?.listTargets(),
args.targetId,
args.expectedSshTargetGeneration
)
const { prepareLocalSshBrowserPartition } =
await import('../browser/local-ssh-browser-partitions')
return prepareLocalSshBrowserPartition({
Expand Down
1 change: 1 addition & 0 deletions src/preload/api/browser-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export type BrowserApi = {
/** Resolves once the SSH workspace's partition is bound and proxy-verified; the webview must wait for it. */
prepareSshWorkspacePartition: (args: {
targetId: string
expectedSshTargetGeneration?: number
browserProfileId?: string
skipProbe?: boolean
}) => Promise<{ partition: string }>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export const browserPageInteractionAndSessionsApi = {
sessionListProfiles: () => ipcRenderer.invoke('browser:session:listProfiles'),
prepareSshWorkspacePartition: (args: {
targetId: string
expectedSshTargetGeneration?: number
browserProfileId?: string
skipProbe?: boolean
}): Promise<{ partition: string }> =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createStore, type StoreApi } from 'zustand'
import type { BrowserPage, BrowserWorkspace } from '../../../../../shared/browser-workspace-types'
import type { Tab, TabGroup } from '../../../../../shared/tab-types'
import type { SshConnectionState } from '../../../../../shared/ssh-types'

type MockState = {
browserTabsByWorktree: Record<string, BrowserWorkspace[]>
Expand All @@ -12,6 +13,7 @@ type MockState = {
groupsByWorktree: Record<string, TabGroup[]>
activeGroupIdByWorktree: Record<string, string>
remoteBrowserPageHandlesByPageId: Record<string, never>
sshConnectionStates: Map<string, SshConnectionState>
focusGroup: () => void
updateBrowserPageState: () => void
setBrowserPageUrl: () => void
Expand Down Expand Up @@ -104,6 +106,18 @@ function createState(): MockState {
},
activeGroupIdByWorktree: { 'wt-1': 'group-1' },
remoteBrowserPageHandlesByPageId: {},
sshConnectionStates: new Map([
[
'target-a',
{
targetId: 'target-a',
status: 'connected',
error: null,
reconnectAttempt: 0,
connectionGeneration: 1
}
]
]),
focusGroup: () => {},
updateBrowserPageState: () => {},
setBrowserPageUrl: () => {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ vi.mock('../host-guest/webview-registry', () => ({
import { SshRoutedBrowserPageGate } from './ssh-routed-browser-page-gate'

const PAGE_IDS = ['page-1', 'page-2'] as const
const initialHostState = useAppStore.getState()

const settle = () =>
act(async () => {
Expand All @@ -28,14 +29,25 @@ const settle = () =>

describe('SshRoutedBrowserPageGate', () => {
beforeEach(() => {
useAppStore.setState({
sshConnectionStates: new Map([
[
'target-a',
{ targetId: 'target-a', status: 'connected', error: null, reconnectAttempt: 0 }
]
])
})
mocks.prepare.mockReset().mockResolvedValue({ partition: 'persist:orca-browser-v1-routed' })
mocks.destroyPersistentWebview.mockReset()
Object.defineProperty(window, 'api', {
configurable: true,
value: { browser: { prepareSshWorkspacePartition: mocks.prepare } }
})
})
afterEach(() => cleanup())
afterEach(() => {
cleanup()
useAppStore.setState({ sshConnectionStates: initialHostState.sshConnectionStates })
})

it('renders children with no override for non-SSH workspaces without any prepare call', async () => {
mocks.executionHostId = 'local'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export type SshWorkspaceBrowserRouteErrorKind = 'forwarding-blocked' | 'ssh-unav
export type SshWorkspaceBrowserRouteState =
| { kind: 'unrouted' }
| { kind: 'preparing' }
| { kind: 'ready'; partition: string; targetId: string }
| { kind: 'ready'; partition: string; targetId: string; expectedSshTargetGeneration?: number }
| { kind: 'error'; errorKind: SshWorkspaceBrowserRouteErrorKind; message: string }

export function classifySshWorkspaceBrowserRouteError(
Expand Down Expand Up @@ -47,16 +47,19 @@ export function useSshWorkspaceBrowserRoute(
} {
const executionHostId = useAppStore((s) => getExecutionHostIdForWorktree(s, worktreeId))
const browserRoutingSettings = useAppStore((s) => s.settings)
const environments = useAppStore((s) => s.runtimeEnvironments)
const probeSkippedTargetIds = useAppStore(
(s) => s.settings?.browserSshWorkspaceRoutingProbeSkippedTargetIds
)
const updateSettings = useAppStore((s) => s.updateSettings)
const routeEligibility = resolveSshWorkspaceBrowserRouteEligibility(
executionHostId,
browserRoutingSettings
browserRoutingSettings,
environments
)
const sshTargetId = routeEligibility?.targetId ?? null
const targetId = routeEligibility?.eligible === true ? routeEligibility.targetId : null
const expectedSshTargetGeneration = routeEligibility?.expectedSshTargetGeneration
const browserProfileId = sessionProfileId ?? 'default'
const [attempt, setAttempt] = useState<{ count: number; skipProbe: boolean }>({
count: 0,
Expand All @@ -65,13 +68,16 @@ export function useSshWorkspaceBrowserRoute(
const [state, setState] = useState<SshWorkspaceBrowserRouteState>(
targetId ? { kind: 'preparing' } : { kind: 'unrouted' }
)
const hostConnection = useWorktreeHostConnection(worktreeId)
const hostConnection = useWorktreeHostConnection(worktreeId, targetId)
const routeHost =
targetId !== null && hostConnection.targetId === targetId ? hostConnection : null
// Why only an unsettled route waits: a dial is a transient, so it must not unmount a ready
// page or swap a failed route's card for "preparing". A connect (below) re-derives a failed one.
// Unrouted and another target's page are unsettled too: neither answers for this target.
const routeReady = state.kind === 'ready' && state.targetId === targetId
const routeReady =
state.kind === 'ready' &&
state.targetId === targetId &&
state.expectedSshTargetGeneration === expectedSshTargetGeneration
const awaitingHost = routeHost?.phase === 'connecting' && state.kind !== 'error' && !routeReady
const connectedHostEpoch = routeHost?.connectedEpoch ?? null
const [seenConnectedHostEpoch, setSeenConnectedHostEpoch] = useState(connectedHostEpoch)
Expand Down Expand Up @@ -104,11 +110,17 @@ export function useSshWorkspaceBrowserRoute(
.prepareSshWorkspacePartition({
targetId,
browserProfileId,
...(expectedSshTargetGeneration !== undefined ? { expectedSshTargetGeneration } : {}),
...(skipProbe ? { skipProbe: true } : {})
})
.then((result) => {
if (!cancelled) {
setState({ kind: 'ready', partition: result.partition, targetId })
setState({
kind: 'ready',
partition: result.partition,
targetId,
...(expectedSshTargetGeneration !== undefined ? { expectedSshTargetGeneration } : {})
})
}
})
.catch((error: unknown) => {
Expand All @@ -124,7 +136,7 @@ export function useSshWorkspaceBrowserRoute(
return () => {
cancelled = true
}
}, [targetId, browserProfileId, attempt, skipProbe, awaitingHost])
}, [targetId, expectedSshTargetGeneration, browserProfileId, attempt, skipProbe, awaitingHost])

// Why (review P1-1): `state` lags one commit behind a targetId transition on
// an already-mounted instance; returning stale 'unrouted' (or a stale
Expand All @@ -133,9 +145,7 @@ export function useSshWorkspaceBrowserRoute(
// routed/unrouted decision must be derived from targetId in-render.
const effectiveState: SshWorkspaceBrowserRouteState = !targetId
? { kind: 'unrouted' }
: awaitingHost ||
state.kind === 'unrouted' ||
(state.kind === 'ready' && state.targetId !== targetId)
: awaitingHost || state.kind === 'unrouted' || (state.kind === 'ready' && !routeReady)
? { kind: 'preparing' }
: state
const rederive = (nextSkipProbe: boolean): void => {
Expand Down Expand Up @@ -183,13 +193,17 @@ export function useSshWorkspaceBrowserRoute(
export function useSshWorkspaceProbeSkipRecheck(worktreeId: string): (() => void) | null {
const executionHostId = useAppStore((s) => getExecutionHostIdForWorktree(s, worktreeId))
const browserRoutingSettings = useAppStore((s) => s.settings)
const environments = useAppStore((s) => s.runtimeEnvironments)
const probeSkippedTargetIds = useAppStore(
(s) => s.settings?.browserSshWorkspaceRoutingProbeSkippedTargetIds
)
const updateSettings = useAppStore((s) => s.updateSettings)
const targetId =
resolveSshWorkspaceBrowserRouteEligibility(executionHostId, browserRoutingSettings)?.targetId ??
null
resolveSshWorkspaceBrowserRouteEligibility(
executionHostId,
browserRoutingSettings,
environments
)?.targetId ?? null
if (!targetId || probeSkippedTargetIds?.includes(targetId) !== true) {
return null
}
Expand Down
Loading
Loading