mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
test: validate shared SSH connection readiness on merged main
This commit is contained in:
+24
-399
@@ -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/<platform>/.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
|
||||
|
||||
@@ -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<ConnectedDockerSshRelayTarget> {
|
||||
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<void> => {
|
||||
if (hasExpectedRepoOwner()) {
|
||||
return
|
||||
}
|
||||
await new Promise<void>((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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<ConnectedSshTestTarget> {
|
||||
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<void> => {
|
||||
if (hasExpectedRepoOwner()) {
|
||||
return
|
||||
}
|
||||
await new Promise<void>((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
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user