diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e000cdab4ba..2b5f939b20a 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -229,6 +229,7 @@ jobs: . != "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" @@ -375,3 +376,65 @@ jobs: 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 + 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" + ssh-keygen -q -t ed25519 -N '' -f "$fixture/host_key" + ssh-keygen -q -t ed25519 -N '' -f "$fixture/client_key" + cat > "$fixture/sshd_config" <> "$GITHUB_ENV" + echo "ORCA_E2E_SSH_USER=$(id -un)" >> "$GITHUB_ENV" + echo "ORCA_E2E_SSH_IDENTITY_FILE=$fixture/client_key" >> "$GITHUB_ENV" + - name: Run localhost SSH terminal and hook journey + 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 + - uses: actions/upload-artifact@v7 + if: failure() + with: + name: localhost-ssh-traces + path: test-results/ + retention-days: 7 + if-no-files-found: ignore diff --git a/config/scripts/release-cut-token-permissions.test.mjs b/config/scripts/release-cut-token-permissions.test.mjs index 85f36dea3a0..0fc1e5f8448 100644 --- a/config/scripts/release-cut-token-permissions.test.mjs +++ b/config/scripts/release-cut-token-permissions.test.mjs @@ -13,6 +13,7 @@ const EXPECTED_MATRIX = { '.github/workflows/e2e.yml#e2e': { contents: 'read' }, '.github/workflows/e2e.yml#prepare-native-cache': { contents: 'read' }, '.github/workflows/e2e.yml#ssh-browser-network-route': { contents: 'read' }, + '.github/workflows/e2e.yml#ssh-localhost': { contents: 'read' }, '.github/workflows/e2e.yml#ssh-docker-watcher-isolation': { contents: 'read' }, '.github/workflows/homebrew-bump.yml#bump-cask': { contents: 'read' }, '.github/workflows/release-mac-build.yml#build-mac': { contents: 'write' }, diff --git a/config/scripts/run-ssh-docker-e2e.mjs b/config/scripts/run-ssh-docker-e2e.mjs index 4b9d51137f1..b88bde609bb 100644 --- a/config/scripts/run-ssh-docker-e2e.mjs +++ b/config/scripts/run-ssh-docker-e2e.mjs @@ -48,10 +48,9 @@ if (runtime.status !== 0) { // - E2E does not gate merges: `verify.needs` in pr.yml omits `e2e` while the suite is red on // main. Nothing in this lane blocks a PR yet. pr.yml's Require-successful-checks comment // has the exact wiring to flip it, and the gate contract asserts the current state. -// - Three specs are gated on env vars no workflow sets, so they run nowhere +// - Two specs are gated on env vars no workflow sets, so they run nowhere // and are not Docker-gated, which puts them outside this file's contract: // nested-runtime-ssh-lifecycle, nested-runtime-ssh-routing (ORCA_E2E_NESTED_RUNTIME_SSH) -// ssh-localhost (ORCA_E2E_SSH_LOCALHOST) // The nested-runtime runner remains unused by CI. const result = spawnSync( pnpm, diff --git a/config/scripts/ssh-localhost-e2e-routing.test.mjs b/config/scripts/ssh-localhost-e2e-routing.test.mjs new file mode 100644 index 00000000000..abab627dfb5 --- /dev/null +++ b/config/scripts/ssh-localhost-e2e-routing.test.mjs @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { parse } from 'yaml' +import { expect, it } from 'vitest' + +const workflow = parse( + readFileSync(resolve(import.meta.dirname, '../../.github/workflows/e2e.yml'), 'utf8') +) + +it('gives the localhost SSH journey its same-filesystem server and agent prerequisite', () => { + const spec = 'tests/e2e/ssh-localhost.spec.ts' + const job = workflow.jobs['ssh-localhost'] + expect(job.if).toContain("inputs.test_files == ''") + expect(job.if).toContain(spec) + expect(job['runs-on']).toBe('ubuntu-latest') + expect(job.needs).toEqual(['build', 'prepare-native-cache']) + const setup = job.steps.find((step) => step.name === 'Start isolated localhost SSH server') + expect(setup.run).toContain('ListenAddress 127.0.0.1') + expect(setup.run).toContain('PasswordAuthentication no') + expect(setup.run).toContain('UsePAM yes') + expect(setup.run).toContain('mkdir -p "$HOME/.pi/agent"') + for (const key of ['ORCA_E2E_SSH_PORT', 'ORCA_E2E_SSH_USER', 'ORCA_E2E_SSH_IDENTITY_FILE']) { + expect(setup.run).toContain(key) + } + const run = job.steps.find((step) => step.name === 'Run localhost SSH terminal and hook journey') + expect(run.env.ORCA_E2E_SSH_LOCALHOST).toBe('1') + expect(run.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS).toBe('1') + expect(run.run).toContain(spec) + expect(run.run).toContain('--project=electron-headless') + expect(run.run).not.toContain('--retries') + expect(run['continue-on-error']).toBeUndefined() + expect( + workflow.jobs['changed-e2e'].steps.find((step) => step.name === 'Run changed E2E specs').run + ).toContain(`. != "${spec}"`) +})