diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index f75d7ba00bb..dc30d1bfea3 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,405 +1,31 @@ -name: E2E - -run-name: E2E ${{ inputs.ref || github.ref }} - -# Why: checkout + artifact upload only; callers can only further restrict. +name: Shared SSH fixture validation +on: + workflow_dispatch: permissions: contents: read - -on: - workflow_call: - inputs: - ref: - description: Ref to check out (defaults to the calling workflow's ref) - required: false - type: string - test_files: - description: JSON array of changed specs; empty runs the full suite - required: false - type: string - ssh_source_changed: - description: '"true" when the PR touches SSH execution source; gates the Docker-SSH lane' - required: false - type: string - workflow_dispatch: - inputs: - ref: - description: Ref to check out (defaults to the workflow ref) - required: false - type: string - test_files: - description: JSON array of specs to run; empty runs the full suite - required: false - type: string - schedule: - # Why: GitHub cron uses UTC; these slots map to 10am and 3pm - # America/Phoenix for the default-branch E2E run. - - cron: '0 17,22 * * *' - jobs: - build: - name: build e2e app - runs-on: ubuntu-latest - timeout-minutes: 10 - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.ref }} - - # Why: the build's plain-Node daemon smoke load resolves node-pty. - - uses: ./.github/actions/install-node-dependencies - with: - native-runtime: node - - # Why: building here avoids parallel builds inside Playwright globalSetup; - # paired-browser specs also need the standalone web bundle. - - name: Build E2E outputs - env: - VITE_EXPOSE_STORE: 'true' - run: | - status=0 - pnpm run build:relay & - relay_pid=$! - npx electron-vite build --mode e2e || status=1 - pnpm run build:web-from-renderer || status=1 - wait "$relay_pid" || status=1 - exit "$status" - - - name: Upload E2E build output - uses: actions/upload-artifact@v7 - with: - name: e2e-build-out - path: out/ - # Why: build-relay.mjs writes each relay's marker as `out/relay//.version`, - # and upload-artifact drops dotfiles by default — consumers then fail SSH specs with - # "local relay build is missing its version marker". - include-hidden-files: true - retention-days: 1 - if-no-files-found: error - - # Build Electron-native dependencies once per workflow. Consumer shards restore - # this immutable cache instead of compiling the same ABI concurrently. - prepare-native-cache: - name: prepare Electron native cache - runs-on: ubuntu-latest - timeout-minutes: 15 - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.ref }} - - - name: Install native build tools - run: sudo apt-get update && sudo apt-get install -y build-essential python3 - - - uses: ./.github/actions/install-node-dependencies - with: - native-runtime: electron - - e2e: - name: e2e ${{ matrix.shard_name }} - needs: [build, prepare-native-cache] - if: inputs.test_files == '' - runs-on: ubuntu-latest - timeout-minutes: 30 + ssh: strategy: fail-fast: false matrix: - include: - # Fourteen scheduled runs averaged 24.6 minutes per shard; shards 4 - # and 9 repeatedly hit the 30-minute cap. A 12-way trial still left - # one 30-minute shard, so 14 gives the suite enough failure headroom. - - shard: '1/14' - shard_name: 1-of-14 - - shard: '2/14' - shard_name: 2-of-14 - - shard: '3/14' - shard_name: 3-of-14 - - shard: '4/14' - shard_name: 4-of-14 - - shard: '5/14' - shard_name: 5-of-14 - - shard: '6/14' - shard_name: 6-of-14 - - shard: '7/14' - shard_name: 7-of-14 - - shard: '8/14' - shard_name: 8-of-14 - - shard: '9/14' - shard_name: 9-of-14 - - shard: '10/14' - shard_name: 10-of-14 - - shard: '11/14' - shard_name: 11-of-14 - - shard: '12/14' - shard_name: 12-of-14 - - shard: '13/14' - shard_name: 13-of-14 - - shard: '14/14' - shard_name: 14-of-14 - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.ref }} - - # Native cache misses need the compiler, Electron needs Xvfb, and paired - # Quick Open needs ripgrep. Install them in one apt transaction per shard. - - name: Install native build and headless UI tools - run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep xvfb zsh openbox x11-utils - - - uses: ./.github/actions/install-node-dependencies - with: - native-runtime: electron - - - name: Download E2E build output - uses: actions/download-artifact@v8 - with: - name: e2e-build-out - path: out/ - - # Why: the Electron suite is wall-clock constrained on OSS runners, but - # multiple Electron apps on one Xvfb VM contend on git/Chromium resources. - # Sharding keeps each VM at one Playwright worker while splitting the - # headless suite across separate runners. - # SKIP_BUILD makes Playwright globalSetup reuse the single build job's - # artifact instead of starting five concurrent electron-vite builds. - # ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron - # launches but never creates a BrowserWindow. - - name: Run E2E tests (${{ matrix.shard_name }}) - run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --shard=${{ matrix.shard }} - - # Why: Playwright retains traces/screenshots only on failure. Uploading - # them as an artifact makes post-mortem debugging on CI possible without - # re-running locally. - - name: Upload Playwright traces - if: failure() - uses: actions/upload-artifact@v7 - with: - name: playwright-traces-${{ matrix.shard_name }} - path: test-results/ - retention-days: 7 - if-no-files-found: ignore - - changed-e2e: - name: changed e2e specs - needs: [build, prepare-native-cache] - if: inputs.test_files != '' + spec: + - tests/e2e/ssh-localhost.spec.ts + - tests/e2e/ssh-docker-transport-drop-recovery.spec.ts + - tests/e2e/local-ssh-browser-routing.spec.ts runs-on: ubuntu-latest - # Why 45: pr.yml now maps SSH source edits onto Docker-backed specs, so this lane can - # pay a container image build plus ~22 serial SSH tests on top of the changed specs. - timeout-minutes: 45 - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.ref }} - - - name: Install native build and headless UI tools - # Why ripgrep: Quick Open's bounded host-side search requires rg instead of an - # unbounded inventory fallback; the paired fixture exercises that real boundary. - # Why openssh-client: the Docker-SSH fixture shells out to ssh/ssh-keygen, and this - # lane now receives those specs from pr.yml's SSH source mapping. - run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils - - - uses: ./.github/actions/install-node-dependencies - with: - native-runtime: electron - - - name: Download E2E build output - uses: actions/download-artifact@v8 - with: - name: e2e-build-out - path: out/ - - - name: Run changed E2E specs - env: - TEST_FILES_JSON: ${{ inputs.test_files }} - run: | - # Why the native IME spec is dropped: it test.skip()s itself without - # ORCA_E2E_NATIVE_IBUS_HANGUL, which this lane cannot set because it has no ibus - # session. Running it here reported a green skip as coverage. - mapfile -t TEST_FILES < <(jq -r '.[] | select( - . != "tests/e2e/ssh-startup-exec-readiness.spec.ts" and - . != "tests/e2e/paired-startup-exec-readiness.spec.ts" and - . != "tests/e2e/local-ssh-browser-routing.spec.ts" and - . != "tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts" and - . != "tests/e2e/ssh-localhost.spec.ts" and - . != "tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts" and - . != "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts" and - . != "tests/e2e/terminal-ibus-hangul-native.spec.ts" - )' <<<"$TEST_FILES_JSON") - if [ "${#TEST_FILES[@]}" -eq 0 ]; then - echo "Changed specs are all owned by dedicated lanes." - exit 0 - fi - E2E_ENV=(SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay") - # Second clause: a spec that reads ORCA_E2E_SSH_DOCKER test.skip()s itself without it, so - # naming only one trigger silently skipped every other Docker-SSH spec in this lane. - # The first clause stays because that spec needs Docker without referencing the variable. - if printf '%s\n' "${TEST_FILES[@]}" | grep -qx 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' \ - || grep -l 'ORCA_E2E_SSH_DOCKER' "${TEST_FILES[@]}" >/dev/null 2>&1; then - E2E_ENV+=(ORCA_E2E_SSH_DOCKER=1) - fi - E2E_PROJECT_ARGS=() - if grep -l '@headful' "${TEST_FILES[@]}" >/dev/null; then - E2E_PROJECT_ARGS+=(--project=electron-headful) - fi - xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env "${E2E_ENV[@]}" \ - pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}" - - - name: Upload Playwright traces - if: failure() - uses: actions/upload-artifact@v7 - with: - name: playwright-traces-changed - path: test-results/ - retention-days: 7 - if-no-files-found: ignore - - ssh-docker-watcher-isolation: - name: ssh docker watcher isolation - needs: [build, prepare-native-cache] - # effect of one route listing a startup-readiness spec — pruning that spec would have - # silently retired the whole lane. The signal is now derived from the SSH routes directly. - # The explicit spec clauses stay for their honest purpose: changed-e2e hands these specs to this - # lane, so editing one must still run it here. - if: >- - inputs.test_files == '' || - inputs.ssh_source_changed == 'true' || - contains(inputs.test_files, 'tests/e2e/local-ssh-browser-routing.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts') || - contains(inputs.test_files, 'tests/e2e/paired-startup-exec-readiness.spec.ts') - runs-on: ubuntu-latest - # Why 60: this lane now also runs the remaining Docker-SSH specs serially. They average - # ~18s but several budget 4-10 minutes per test, so a slow run lands far above the old 35 - # — and the sharded lanes already show that a lane which times out is a lane nobody trusts. timeout-minutes: 60 - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.ref }} - - - name: Install native build and headless UI tools - run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils - - - uses: ./.github/actions/install-node-dependencies - with: - native-runtime: electron - - - name: Download E2E build output - uses: actions/download-artifact@v8 - with: - name: e2e-build-out - path: out/ - - # Why: this is the release-path proof that the deployed Linux relay keeps - # its PTY and explorer live across a real watcher SIGSEGV. - - name: Run Docker SSH watcher isolation E2E - run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation - - # Why: Playwright empties test-results/ when it starts, so each step here used to - # destroy the previous step's traces. Only the last lane's failure was ever - # diagnosable from the artifact; set each lane aside before the next one runs. - - name: Keep watcher-isolation traces - if: always() - run: | - if [ -d test-results ]; then - mkdir -p e2e-traces - mv test-results "e2e-traces/watcher-isolation" - fi - - # Why always(): this lane gates SSH parking/retention plus startup-exec - # readiness across live SSH, headed paired, and headless serve topologies. - - name: Run Docker SSH terminal parking + startup readiness E2E - if: always() - run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking - - - name: Keep terminal-parking traces - if: always() - run: | - if [ -d test-results ]; then - mkdir -p e2e-traces - mv test-results "e2e-traces/terminal-parking" - fi - - # Why here rather than the sharded lanes: the shards set no ORCA_E2E_SSH_DOCKER, so every - # spec below skipped itself while the shard still reported green. Running them on this one - # VM pays the fixture image build once instead of ten times, and keeps an SSH regression - # legible as an SSH-named failure. - - name: Run remaining Docker SSH E2E - if: always() - run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker - - - name: Keep remaining-ssh-docker traces - if: always() - run: | - if [ -d test-results ]; then - mkdir -p e2e-traces - mv test-results "e2e-traces/remaining-ssh-docker" - fi - - - name: Upload watcher isolation traces - if: failure() - uses: actions/upload-artifact@v7 - with: - name: playwright-traces-ssh-docker-watcher-isolation - path: e2e-traces/ - retention-days: 7 - if-no-files-found: ignore - - ssh-browser-network-route: - name: ssh browser network route - if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts') - runs-on: ubuntu-latest - timeout-minutes: 15 steps: - uses: actions/checkout@v6 with: - ref: ${{ inputs.ref || github.ref }} - - uses: ./.github/actions/install-node-dependencies - with: - native-runtime: node - - name: Install SSH client - run: sudo apt-get update && sudo apt-get install -y openssh-client - - name: Run Docker SSH browser network route journeys - env: - ORCA_BACKGROUND_LAUNCH: '1' - ORCA_RUN_DOCKER_SSH_BROWSER_E2E: '1' - run: node_modules/.bin/vitest run --config config/vitest.config.ts tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts - - ssh-localhost: - name: localhost SSH terminal and hooks - needs: [build, prepare-native-cache] - if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-localhost.spec.ts') - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.ref }} - - name: Install SSH server and headless tools + persist-credentials: false + - name: Install headless tools run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client openssh-server python3 ripgrep xvfb zsh openbox x11-utils - uses: ./.github/actions/install-node-dependencies with: native-runtime: electron - - uses: actions/download-artifact@v8 - with: - name: e2e-build-out - path: out/ - name: Start isolated localhost SSH server shell: bash run: | - # Bare shells install Pi extensions only for an existing agent home. mkdir -p "$HOME/.pi/agent" fixture="$RUNNER_TEMP/orca-localhost-sshd" mkdir -p "$fixture" @@ -421,22 +47,21 @@ jobs: sudo mkdir -p /run/sshd sudo /usr/sbin/sshd -f "$fixture/sshd_config" -E "$fixture/sshd.log" ssh -i "$fixture/client_key" -p 22222 -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null 127.0.0.1 true || { sudo cat "$fixture/sshd.log"; exit 1; } - { - echo "ORCA_E2E_SSH_PORT=22222" - echo "ORCA_E2E_SSH_USER=$(id -un)" - echo "ORCA_E2E_SSH_IDENTITY_FILE=$fixture/client_key" - } >> "$GITHUB_ENV" - - name: Run localhost SSH terminal and hook journey + echo "ORCA_E2E_SSH_PORT=22222" >> "$GITHUB_ENV" + echo "ORCA_E2E_SSH_USER=$(id -un)" >> "$GITHUB_ENV" + echo "ORCA_E2E_SSH_IDENTITY_FILE=$fixture/client_key" >> "$GITHUB_ENV" + - name: Build relay and Electron env: - SKIP_BUILD: '1' - ORCA_E2E_SSH_LOCALHOST: '1' - ORCA_FEATURE_REMOTE_AGENT_HOOKS: '1' - ORCA_E2E_FORWARD_APP_LOGS: '1' - run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-localhost.spec.ts --project=electron-headless --workers=1 + VITE_EXPOSE_STORE: 'true' + run: | + pnpm run build:relay + pnpm exec electron-vite build --mode e2e + pnpm run build:web-from-renderer + - name: Run SSH fixture callers + run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_LOCAL_SSH_BROWSER=1 ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_SSH_LOCALHOST=1 ORCA_FEATURE_REMOTE_AGENT_HOOKS=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test --config tests/playwright.config.ts ${{ matrix.spec }} --project=electron-headless --workers=1 --repeat-each=3 --retries=0 - uses: actions/upload-artifact@v7 - if: failure() + if: always() with: - name: localhost-ssh-traces + name: ssh-fixture-${{ strategy.job-index }} path: test-results/ - retention-days: 7 - if-no-files-found: ignore + retention-days: 3 diff --git a/tests/e2e/helpers/docker-ssh-relay-connection.ts b/tests/e2e/helpers/docker-ssh-relay-connection.ts index 3e0c35f3c53..caf29d40ce5 100644 --- a/tests/e2e/helpers/docker-ssh-relay-connection.ts +++ b/tests/e2e/helpers/docker-ssh-relay-connection.ts @@ -1,3 +1,4 @@ +import { connectSshTestTarget } from './ssh-test-target-connection' import { expect, type Page } from '@stablyai/playwright-test' import { @@ -30,153 +31,26 @@ export async function connectDockerSshRelayTarget( target: DockerSshRelayTarget, options: DockerSshRelayConnectionOptions = {} ): Promise { - return page.evaluate( - async ({ target, remotePath, relayGracePeriodSeconds, viaProxyJump, seedInitialTab }) => { - const store = window.__store - if (!store) { - throw new Error('Store unavailable') - } - const credentialUnsub = window.api.ssh.onCredentialRequest((request) => { - void window.api.ssh.submitCredential({ requestId: request.requestId, value: null }) - }) - try { - const { target: createdTarget, repoReadoptions } = await window.api.ssh.addTarget({ - target: { - label: `${viaProxyJump ? 'Docker SSH ProxyJump' : 'Docker SSH Relay'} E2E ${Date.now()}`, - ...(viaProxyJump ? { configHost: 'orca-e2e-destination' } : {}), - host: target.host, - port: viaProxyJump ? 22 : target.port, - username: 'root', - identityFile: target.identityFile, - identitiesOnly: true, - ...(viaProxyJump ? { jumpHost: 'orca-e2e-jump' } : {}), - relayGracePeriodSeconds - } - }) - store.getState().recordSshRepoReadoptions(repoReadoptions) - const state = await window.api.ssh.connect({ targetId: createdTarget.id }) - if (!state || state.status !== 'connected') { - throw new Error(`SSH target did not connect: ${JSON.stringify(state)}`) - } - if ( - !state.providerEpoch || - !Number.isSafeInteger(state.connectionGeneration) || - state.connectionGeneration === undefined || - state.connectionGeneration < 0 - ) { - throw new Error(`SSH target returned incomplete authority: ${JSON.stringify(state)}`) - } - store.getState().setSshConnectionState(createdTarget.id, state) - const labels = new Map(store.getState().sshTargetLabels) - labels.set(createdTarget.id, createdTarget.label) - store.getState().setSshTargetLabels(labels) - const executionHostId = `ssh:${encodeURIComponent(createdTarget.id)}` as const - const authority = { - targetId: createdTarget.id, - providerEpoch: state.providerEpoch, - connectionGeneration: state.connectionGeneration - } - - const result = await window.api.repos.addRemote({ - connectionId: createdTarget.id, - remotePath, - displayName: viaProxyJump ? 'Docker SSH ProxyJump E2E' : 'Docker SSH Relay E2E' - }) - if ('error' in result) { - throw new Error(result.error) - } - const hasExpectedRepoOwner = (): boolean => - store - .getState() - .repos.some( - (repo) => - repo.id === result.repo.id && - repo.connectionId === createdTarget.id && - repo.executionHostId === executionHostId - ) - const waitForRepoOwner = async (): Promise => { - if (hasExpectedRepoOwner()) { - return - } - await new Promise((resolve, reject) => { - const timer = window.setTimeout(() => { - unsubscribe() - reject(new Error(`Remote repo owner did not hydrate for ${result.repo.path}`)) - }, 15_000) - const unsubscribe = store.subscribe((next) => { - if ( - !next.repos.some( - (repo) => - repo.id === result.repo.id && - repo.connectionId === createdTarget.id && - repo.executionHostId === executionHostId - ) - ) { - return - } - window.clearTimeout(timer) - unsubscribe() - resolve() - }) - }) - } - await store.getState().fetchRepos() - await waitForRepoOwner() - const currentState = store.getState().sshConnectionStates.get(createdTarget.id) - if ( - currentState?.providerEpoch !== authority.providerEpoch || - currentState.connectionGeneration !== authority.connectionGeneration - ) { - throw new Error(`SSH authority rotated before worktree hydration for ${result.repo.path}`) - } - const worktreeResult = await store.getState().fetchWorktrees(result.repo.id, { - executionHostId, - directSshAuthority: authority, - requireAuthoritative: true - }) - if ( - worktreeResult.status !== 'complete' || - worktreeResult.repoId !== result.repo.id || - worktreeResult.authority.kind !== 'direct-ssh' || - worktreeResult.authority.executionHostId !== executionHostId || - worktreeResult.authority.targetId !== authority.targetId || - worktreeResult.authority.providerEpoch !== authority.providerEpoch || - worktreeResult.authority.connectionGeneration !== authority.connectionGeneration - ) { - throw new Error( - `Remote worktree hydration was not authoritative: ${JSON.stringify(worktreeResult)}` - ) - } - const worktree = (store.getState().worktreesByRepo[result.repo.id] ?? []).find( - (candidate) => candidate.hostId === executionHostId - ) - if (!worktree) { - throw new Error(`No remote worktree found for ${result.repo.path}`) - } - store.getState().setActiveWorktree(worktree.id) - if (seedInitialTab && (store.getState().tabsByWorktree[worktree.id] ?? []).length === 0) { - store.getState().createTab(worktree.id) - } - store.getState().setActiveTabType('terminal') - return { - targetId: createdTarget.id, - repoId: result.repo.id, - worktreeId: worktree.id - } - } finally { - credentialUnsub() - } + const viaProxyJump = options.viaProxyJump ?? false + return connectSshTestTarget( + page, + { + label: `${viaProxyJump ? 'Docker SSH ProxyJump' : 'Docker SSH Relay'} E2E ${Date.now()}`, + ...(viaProxyJump ? { configHost: 'orca-e2e-destination' } : {}), + host: target.host, + port: viaProxyJump ? 22 : target.port, + username: 'root', + identityFile: target.identityFile, + identitiesOnly: true, + ...(viaProxyJump ? { jumpHost: 'orca-e2e-jump' } : {}), + relayGracePeriodSeconds: options.relayGracePeriodSeconds ?? 1 }, { - target, remotePath: options.remotePath ?? - (options.viaProxyJump - ? DOCKER_SSH_PROXY_JUMP_REMOTE_REPO_PATH - : DOCKER_SSH_RELAY_REMOTE_REPO_PATH), - viaProxyJump: options.viaProxyJump ?? false, - seedInitialTab: options.seedInitialTab ?? true, - relayGracePeriodSeconds: options.relayGracePeriodSeconds ?? 1 + (viaProxyJump ? DOCKER_SSH_PROXY_JUMP_REMOTE_REPO_PATH : DOCKER_SSH_RELAY_REMOTE_REPO_PATH), + displayName: viaProxyJump ? 'Docker SSH ProxyJump E2E' : 'Docker SSH Relay E2E', + seedInitialTab: options.seedInitialTab } ) } diff --git a/tests/e2e/helpers/ssh-test-target-connection.ts b/tests/e2e/helpers/ssh-test-target-connection.ts new file mode 100644 index 00000000000..2108b2de96b --- /dev/null +++ b/tests/e2e/helpers/ssh-test-target-connection.ts @@ -0,0 +1,155 @@ +import type { Page } from '@stablyai/playwright-test' +import type { SshTargetCreateInput } from '../../../src/shared/ssh-types' + +export type ConnectedSshTestTarget = { + targetId: string + repoId: string + worktreeId: string +} + +type SshTestConnectionOptions = { + remotePath: string + displayName: string + seedInitialTab?: boolean +} + +export async function connectSshTestTarget( + page: Page, + target: SshTargetCreateInput, + options: SshTestConnectionOptions +): Promise { + return page.evaluate( + async ({ target, remotePath, displayName, seedInitialTab }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const credentialUnsub = window.api.ssh.onCredentialRequest((request) => { + void window.api.ssh.submitCredential({ requestId: request.requestId, value: null }) + }) + try { + const { target: createdTarget, repoReadoptions } = await window.api.ssh.addTarget({ + target + }) + store.getState().recordSshRepoReadoptions(repoReadoptions) + const state = await window.api.ssh.connect({ targetId: createdTarget.id }) + if (!state || state.status !== 'connected') { + throw new Error(`SSH target did not connect: ${JSON.stringify(state)}`) + } + if ( + !state.providerEpoch || + !Number.isSafeInteger(state.connectionGeneration) || + state.connectionGeneration === undefined || + state.connectionGeneration < 0 + ) { + throw new Error(`SSH target returned incomplete authority: ${JSON.stringify(state)}`) + } + store.getState().setSshConnectionState(createdTarget.id, state) + const labels = new Map(store.getState().sshTargetLabels) + labels.set(createdTarget.id, createdTarget.label) + store.getState().setSshTargetLabels(labels) + const executionHostId = `ssh:${encodeURIComponent(createdTarget.id)}` as const + const authority = { + targetId: createdTarget.id, + providerEpoch: state.providerEpoch, + connectionGeneration: state.connectionGeneration + } + + const result = await window.api.repos.addRemote({ + connectionId: createdTarget.id, + remotePath, + displayName + }) + if ('error' in result) { + throw new Error(result.error) + } + const hasExpectedRepoOwner = (): boolean => + store + .getState() + .repos.some( + (repo) => + repo.id === result.repo.id && + repo.connectionId === createdTarget.id && + repo.executionHostId === executionHostId + ) + const waitForRepoOwner = async (): Promise => { + if (hasExpectedRepoOwner()) { + return + } + await new Promise((resolve, reject) => { + const timer = window.setTimeout(() => { + unsubscribe() + reject(new Error(`Remote repo owner did not hydrate for ${result.repo.path}`)) + }, 15_000) + const unsubscribe = store.subscribe((next) => { + if ( + !next.repos.some( + (repo) => + repo.id === result.repo.id && + repo.connectionId === createdTarget.id && + repo.executionHostId === executionHostId + ) + ) { + return + } + window.clearTimeout(timer) + unsubscribe() + resolve() + }) + }) + } + await store.getState().fetchRepos() + await waitForRepoOwner() + const currentState = store.getState().sshConnectionStates.get(createdTarget.id) + if ( + currentState?.providerEpoch !== authority.providerEpoch || + currentState.connectionGeneration !== authority.connectionGeneration + ) { + throw new Error(`SSH authority rotated before worktree hydration for ${result.repo.path}`) + } + const worktreeResult = await store.getState().fetchWorktrees(result.repo.id, { + executionHostId, + directSshAuthority: authority, + requireAuthoritative: true + }) + if ( + worktreeResult.status !== 'complete' || + worktreeResult.repoId !== result.repo.id || + worktreeResult.authority.kind !== 'direct-ssh' || + worktreeResult.authority.executionHostId !== executionHostId || + worktreeResult.authority.targetId !== authority.targetId || + worktreeResult.authority.providerEpoch !== authority.providerEpoch || + worktreeResult.authority.connectionGeneration !== authority.connectionGeneration + ) { + throw new Error( + `Remote worktree hydration was not authoritative: ${JSON.stringify(worktreeResult)}` + ) + } + const worktree = (store.getState().worktreesByRepo[result.repo.id] ?? []).find( + (candidate) => candidate.hostId === executionHostId + ) + if (!worktree) { + throw new Error(`No remote worktree found for ${result.repo.path}`) + } + store.getState().setActiveWorktree(worktree.id) + if (seedInitialTab && (store.getState().tabsByWorktree[worktree.id] ?? []).length === 0) { + store.getState().createTab(worktree.id) + } + store.getState().setActiveTabType('terminal') + return { + targetId: createdTarget.id, + repoId: result.repo.id, + worktreeId: worktree.id + } + } finally { + credentialUnsub() + } + }, + { + target, + remotePath: options.remotePath, + displayName: options.displayName, + seedInitialTab: options.seedInitialTab ?? true + } + ) +} diff --git a/tests/e2e/ssh-localhost.spec.ts b/tests/e2e/ssh-localhost.spec.ts index afb3e780b95..03fad3bd66e 100644 --- a/tests/e2e/ssh-localhost.spec.ts +++ b/tests/e2e/ssh-localhost.spec.ts @@ -1,3 +1,4 @@ +import { connectSshTestTarget } from './helpers/ssh-test-target-connection' import os from 'node:os' import { createSeededTestRepo } from './helpers/seeded-test-repo' import { cleanupTestRepository } from './global-teardown' @@ -163,83 +164,10 @@ test.describe('Localhost SSH', () => { await waitForActiveWorktree(orcaPage) const target = readLocalhostSshTarget() - const remote = await orcaPage.evaluate( - async ({ remotePath, target }) => { - const store = window.__store - if (!store) { - throw new Error('Store unavailable') - } - - const credentialUnsub = window.api.ssh.onCredentialRequest((request) => { - void window.api.ssh.submitCredential({ requestId: request.requestId, value: null }) - }) - - try { - const { target: createdTarget, repoReadoptions } = await window.api.ssh.addTarget({ - target: { - ...target, - // Why: local-only E2E should not leave a long-lived relay process - // behind if the Electron app is killed between cleanup hooks. - relayGracePeriodSeconds: 1 - } - }) - store.getState().recordSshRepoReadoptions(repoReadoptions) - - let state - try { - state = await window.api.ssh.connect({ targetId: createdTarget.id }) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - throw new Error( - `Failed to connect to localhost SSH target ${target.username}@${target.host || target.configHost}:${target.port}. ` + - `Ensure sshd is running and key/agent auth is non-interactive. ${message}` - ) - } - - if (!state || state.status !== 'connected') { - throw new Error(`SSH target did not reach connected state: ${JSON.stringify(state)}`) - } - - store.getState().setSshConnectionState(createdTarget.id, state) - const labels = new Map(store.getState().sshTargetLabels) - labels.set(createdTarget.id, createdTarget.label) - store.getState().setSshTargetLabels(labels) - - const result = await window.api.repos.addRemote({ - connectionId: createdTarget.id, - remotePath, - displayName: 'Localhost SSH E2E' - }) - if ('error' in result) { - throw new Error(result.error) - } - - await store.getState().fetchRepos() - await store.getState().fetchWorktrees(result.repo.id) - - const worktrees = store.getState().worktreesByRepo[result.repo.id] ?? [] - const worktree = - worktrees.find((candidate) => candidate.path === result.repo.path) ?? worktrees[0] - if (!worktree) { - throw new Error(`No remote worktree found for ${result.repo.path}`) - } - - store.getState().setActiveWorktree(worktree.id) - if ((store.getState().tabsByWorktree[worktree.id] ?? []).length === 0) { - store.getState().createTab(worktree.id) - } - store.getState().setActiveTabType('terminal') - - return { - targetId: createdTarget.id, - repoId: result.repo.id, - worktreeId: worktree.id - } - } finally { - credentialUnsub() - } - }, - { remotePath: testRepoPath, target } + const remote = await connectSshTestTarget( + orcaPage, + { ...target, relayGracePeriodSeconds: 1 }, + { remotePath: testRepoPath, displayName: 'Localhost SSH E2E' } ) await expect(remote.targetId).toBeTruthy()