diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 77ad10095f3e..62aa82b73f7f 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -341,7 +341,8 @@ jobs: . != "tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts" and . != "tests/e2e/ssh-localhost.spec.ts" and . != "tests/e2e/terminal-ibus-hangul-native.spec.ts" and - . != "tests/e2e/orcad-serve-mode-switch.spec.ts" + . != "tests/e2e/orcad-serve-mode-switch.spec.ts" and + . != "tests/e2e/ssh-orcad-auto-convert.spec.ts" )' <<<"$TEST_FILES_JSON" > "$RUNNER_TEMP/general-e2e-specs" fi mapfile -t TEST_FILES < "$RUNNER_TEMP/general-e2e-specs" @@ -551,6 +552,56 @@ jobs: retention-days: 7 if-no-files-found: ignore + # #24979 on a real host: a relay-era Docker host converts to managed orcad on connect. Needs the + # orcad template for the fixture's target (Debian, linux-x64-glibc), which only this job builds. + orcad-auto-convert-docker: + name: ssh host auto-converts to managed orcad (Docker) + needs: [build, prepare-native-cache] + if: inputs.test_files == '' || inputs.ssh_source_changed == 'true' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-auto-convert.spec.ts') + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref || github.ref }} + persist-credentials: false + - name: Install headless tools + run: sudo apt-get update && sudo apt-get install -y build-essential ripgrep xvfb 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/ + # Built to a path the app does not look at by default, so the relay phase has no template. + - name: Build the linux-x64-glibc orcad template + run: | + pnpm build:orcad-prebuilds + pnpm build:orcad-prebuilds --require-slots linux-x64-glibc + pnpm build:orcad + node config/scripts/build-orcad-template.mjs --targets linux-x64-glibc + mv out/orcad-template "$RUNNER_TEMP/orcad-convert-template" + - name: Convert a relay-era Docker host + env: + SKIP_BUILD: '1' + ORCA_E2E_SSH_DOCKER: '1' + ORCA_E2E_ORCAD_CONVERT_HOST: docker + ORCA_E2E_FORWARD_APP_LOGS: '1' + 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 --project=electron-headless --workers=1 + - uses: actions/upload-artifact@v7 + if: failure() + with: + name: orcad-auto-convert-docker-traces + path: test-results/ + retention-days: 7 + if-no-files-found: ignore + ssh-localhost: name: localhost SSH terminal and hooks needs: [build, prepare-native-cache] diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index 10c68dec02cb..e02644dc6eb3 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -35,6 +35,11 @@ on: - '!src/**/*.test.ts' - 'src/main/ssh/ssh-relay-windows-host-lane.test.ts' - 'src/main/ssh/orcad-windows-host-lane.test.ts' + - 'src/main/ipc/ssh-host-server-*' + - 'tests/e2e/ssh-orcad-auto-convert.spec.ts' + - 'tests/e2e/helpers/orcad-convert-host.ts' + - 'tests/e2e/helpers/orcad-convert-flow.ts' + - 'tests/e2e/helpers/orcad-upgrade-profile.ts' - 'config/ci/windows-ssh-provider/**' - '.github/workflows/ssh-windows-hosts.yml' - '.github/actions/prepare-orcad-prebuilds/**' @@ -150,6 +155,10 @@ jobs: New-Item -ItemType Directory -Force -Path $receipts | Out-Null $cells=@($env:CELLS -split ',' | ForEach-Object {$_.Trim()} | Where-Object {$_}) if(-not $cells.Count){$cells=@('pinned-cmd','pinned-powershell','legacy-opt-out','orcad-cmd','orcad-powershell')} + # One Windows host runs the app-level conversion; it is last because it switches native modules. + if(-not $env:CELLS -and '${{ matrix.arch }}' -eq 'x64' -and '${{ matrix.server }}' -eq 'inbox'){$cells+='orcad-convert'} + # The managed server is reached through an SSH local forward, so only the convert cell's account may open one. + $forwarding=if($cells[-1] -eq 'orcad-convert'){1}else{0} $archive='' $preparation='' if('${{ matrix.server }}' -eq 'inbox' -and '${{ matrix.arch }}' -eq 'arm64'){$preparation=Join-Path $receipts 'inbox-capability-preparation.json'} @@ -162,7 +171,7 @@ jobs: $callback={param($context) & (Join-Path $tools 'invoke-pinned-relay-cells.ps1') -SourceRoot $sourceRoot -Context $context -Target 'win32-${{ matrix.arch }}' -ReceiptRoot $receipts -Cells $cells }.GetNewClosure() - & (Join-Path $tools 'preview-ssh/prove-preview-openssh.ps1') -Archive $archive -Arch '${{ matrix.arch }}' -Server '${{ matrix.server }}' -Receipt (Join-Path $receipts 'provider-server.json') -InboxPreparationReceipt $preparation -Accounts $cells.Count -HiddenTools @('npm','npx','node-gyp','gcc','g++','cc','c++','make','cl','clang','clang++','msbuild','cmake') -HostCellProbe $callback 2>&1 | Tee-Object (Join-Path $receipts 'provision.log') + & (Join-Path $tools 'preview-ssh/prove-preview-openssh.ps1') -Archive $archive -Arch '${{ matrix.arch }}' -Server '${{ matrix.server }}' -Receipt (Join-Path $receipts 'provider-server.json') -InboxPreparationReceipt $preparation -Accounts $cells.Count -ForwardingAccounts $forwarding -HiddenTools @('npm','npx','node-gyp','gcc','g++','cc','c++','make','cl','clang','clang++','msbuild','cmake') -HostCellProbe $callback 2>&1 | Tee-Object (Join-Path $receipts 'provision.log') - uses: actions/upload-artifact@v7 if: always() with: diff --git a/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 b/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 index 91ef23cc6da3..230e59d264ea 100644 --- a/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 +++ b/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 @@ -6,11 +6,11 @@ param( [Parameter(Mandatory=$true)][hashtable]$Context, [Parameter(Mandatory=$true)][ValidateSet('win32-arm64','win32-x64')][string]$Target, [Parameter(Mandatory=$true)][string]$ReceiptRoot, - [ValidateSet('pinned-cmd','pinned-powershell','legacy-opt-out','orcad-cmd','orcad-powershell')][string[]]$Cells=@('pinned-cmd','pinned-powershell','legacy-opt-out','orcad-cmd','orcad-powershell') + [ValidateSet('pinned-cmd','pinned-powershell','legacy-opt-out','orcad-cmd','orcad-powershell','orcad-convert')][string[]]$Cells=@('pinned-cmd','pinned-powershell','legacy-opt-out','orcad-cmd','orcad-powershell') ) $ErrorActionPreference='Stop' if($env:GITHUB_ACTIONS -ne 'true' -or $env:ORCA_ISOLATED_SSH_CI -ne '1'){throw 'Disposable CI only'} -$shells=@{'pinned-cmd'='cmd';'pinned-powershell'='powershell';'legacy-opt-out'='cmd';'orcad-cmd'='cmd';'orcad-powershell'='powershell'} +$shells=@{'pinned-cmd'='cmd';'pinned-powershell'='powershell';'legacy-opt-out'='cmd';'orcad-cmd'='cmd';'orcad-powershell'='powershell';'orcad-convert'='cmd'} if($Context.accounts.Count -lt $Cells.Count){throw 'Each cell needs its own private account'} if(-not $Context.forbiddenToolLog){throw 'Run the provisioning with -HiddenTools so toolchain calls are logged'} $openSshKey='HKLM:\SOFTWARE\OpenSSH' @@ -53,6 +53,28 @@ function Test-PrivateWmiLaunch([string]$Account) { if($match.Success){return $match.Groups[1].Value}else{return 'no-output'} } +# The app converts a relay-era host on connect (tests/e2e/ssh-orcad-auto-convert.spec.ts). Last in +# the run: it switches native modules to Electron's ABI, which the vitest cells cannot load. +function Invoke-ConvertCell($Account,[string]$Descriptor,[string]$Log) { + $ready=Invoke-PrivateSsh $Account.name 'git init -q orca-convert-repo && git -C orca-convert-repo -c user.name=orca -c user.email=orca@example.invalid commit -q --allow-empty -m init && echo ORCA_REPO_READY' + if($ready -notmatch 'ORCA_REPO_READY'){throw 'Could not create the convert cell repository as the account'} + # Out of the app's default lookup, so the relay phase runs without a template. + $template=Join-Path $env:RUNNER_TEMP 'orcad-convert-template' + Copy-Item -LiteralPath 'out\orcad-template' -Destination $template -Recurse -Force + Rename-Item -LiteralPath 'out\orcad-template' -NewName 'orcad-template.convert-hidden' + try { + & node config/scripts/ensure-native-runtime.mjs --runtime=electron 2>&1 | Tee-Object -FilePath $Log | Out-Host + & pnpm exec electron-vite build --mode e2e 2>&1 | Tee-Object -FilePath $Log -Append | Out-Host + $env:ORCA_E2E_ORCAD_CONVERT_HOST=$Descriptor;$env:ORCA_E2E_ORCAD_CONVERT_TEMPLATE=$template;$env:SKIP_BUILD='1' + & pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad-auto-convert.spec.ts --project=electron-headless --workers=1 2>&1 | Tee-Object -FilePath $Log -Append | Out-Host + # Functions return uncaptured output, so only the exit code may reach the caller. + return $global:LASTEXITCODE + } finally { + Remove-Item Env:ORCA_E2E_ORCAD_CONVERT_HOST,Env:ORCA_E2E_ORCAD_CONVERT_TEMPLATE,Env:SKIP_BUILD -ErrorAction SilentlyContinue + Rename-Item -LiteralPath 'out\orcad-template.convert-hidden' -NewName 'orcad-template' + } +} + New-Item -ItemType Directory -Force -Path $ReceiptRoot | Out-Null Push-Location $SourceRoot try { @@ -78,11 +100,15 @@ try { @{cell=$cell;target=$Target;host='127.0.0.1';port=[int]$Context.port;username=$account.name;identityFile=$Context.identityFile;home=$account.home;forbiddenToolLog=$Context.forbiddenToolLog;receipt=(Join-Path $ReceiptRoot "$cell.json")} | ConvertTo-Json | Set-Content -LiteralPath $descriptor -Encoding utf8NoBOM $env:ORCA_RUN_SSH_WINDOWS_HOST='1';$env:ORCA_SSH_WINDOWS_HOST_CELL=$descriptor Write-Host "Windows host cell $cell ($Target, DefaultShell $shell, account $($account.name))" - # orcad cells deploy managed orcad instead of the relay; same account and descriptor shape. - $lane=if($cell.StartsWith('orcad-')){'src/main/ssh/orcad-windows-host-lane.test.ts'}else{'src/main/ssh/ssh-relay-windows-host-lane.test.ts'} - & node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts $lane --reporter=verbose 2>&1 | Tee-Object -FilePath (Join-Path $ReceiptRoot "$cell.log") - # Why global: under the workflow's GetNewClosure callback, bare $LASTEXITCODE reads a stale captured copy. - $code=$global:LASTEXITCODE + if($cell -eq 'orcad-convert'){ + $code=Invoke-ConvertCell $account $descriptor (Join-Path $ReceiptRoot "$cell.log") + } else { + # orcad cells deploy managed orcad instead of the relay; same account and descriptor shape. + $lane=if($cell.StartsWith('orcad-')){'src/main/ssh/orcad-windows-host-lane.test.ts'}else{'src/main/ssh/ssh-relay-windows-host-lane.test.ts'} + & node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts $lane --reporter=verbose 2>&1 | Tee-Object -FilePath (Join-Path $ReceiptRoot "$cell.log") + # Why global: under the workflow's GetNewClosure callback, bare $LASTEXITCODE reads a stale captured copy. + $code=$global:LASTEXITCODE + } # The relay's own log is the only record of why it closed a client. foreach($log in @(Get-ChildItem -Path (Join-Path $account.home '.orca-remote\relay-*\relay*.log'),(Join-Path $account.home '.orca-remote\orcad-*\orcad.log') -File -ErrorAction SilentlyContinue)){Copy-Item -LiteralPath $log.FullName -Destination (Join-Path $ReceiptRoot "$cell.$($log.Directory.Name).$($log.Name)")} if(Test-Path -LiteralPath $Context.forbiddenToolLog){Copy-Item -LiteralPath $Context.forbiddenToolLog -Destination (Join-Path $ReceiptRoot "$cell.forbidden-tool-calls.log")} diff --git a/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 b/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 index 4ea2fb2231d3..5b86447c1097 100644 --- a/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 +++ b/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 @@ -2,7 +2,7 @@ # -HiddenTools: the private accounts are denied every machine PATH directory holding one of these # executables, and their own PATH carries logging shims for them, so SSH sessions have no host toolchain. # -HostCellProbe receives a context hashtable (accounts, port, keys, shim log) once provisioning passes. -param([Parameter(Mandatory=$true)][string]$Receipt,[string]$Archive,[Parameter(Mandatory=$true)][ValidateSet('arm64','x64')][string]$Arch,[ValidateSet('preview','inbox')][string]$Server='preview',[scriptblock]$ProductionRouteProbe,[ValidateRange(1,5)][int]$Accounts=1,[string[]]$HiddenTools=@(),[scriptblock]$HostCellProbe,[string]$InboxPreparationReceipt) +param([Parameter(Mandatory=$true)][string]$Receipt,[string]$Archive,[Parameter(Mandatory=$true)][ValidateSet('arm64','x64')][string]$Arch,[ValidateSet('preview','inbox')][string]$Server='preview',[scriptblock]$ProductionRouteProbe,[ValidateRange(1,6)][int]$Accounts=1,[ValidateRange(0,6)][int]$ForwardingAccounts=0,[string[]]$HiddenTools=@(),[scriptblock]$HostCellProbe,[string]$InboxPreparationReceipt) $ErrorActionPreference = 'Stop' . (Join-Path $PSScriptRoot 'windows-ssh-capability.ps1') $target=@{arm64=@{os='Arm64';folder='OpenSSH-ARM64';machine='0xAA64';archive='698c6aec31c1dd0fb996206e8741f4531a97355686b5431ef347d531b07fcd42'};x64=@{os='X64';folder='OpenSSH-Win64';machine='0x8664';archive='23f50f3458c4c5d0b12217c6a5ddfde0137210a30fa870e98b29827f7b43aba5'}}[$Arch] @@ -246,6 +246,7 @@ PermitTunnel no PermitTTY no Subsystem sftp "$sftpServerPosix" LogLevel DEBUG1 +$(@($accountNames | Select-Object -Last $ForwardingAccounts | ForEach-Object {"Match User $_`n AllowTcpForwarding local"}) -join "`n") "@ | Set-Content -LiteralPath $config -Encoding ascii Write-Stage 'server-config-validate-start' Invoke-Bounded $sshd @('-t','-f',$config) | Out-Null diff --git a/config/scripts/ci-e2e-job-selection.mjs b/config/scripts/ci-e2e-job-selection.mjs index 69887908ce1e..644fc6a5bc58 100644 --- a/config/scripts/ci-e2e-job-selection.mjs +++ b/config/scripts/ci-e2e-job-selection.mjs @@ -39,12 +39,15 @@ export const LOCALHOST_SSH_E2E_SPEC = 'tests/e2e/ssh-localhost.spec.ts' export const NATIVE_IME_E2E_SPEC = 'tests/e2e/terminal-ibus-hangul-native.spec.ts' // Needs the packaged orcad slot, which only its own job builds. export const ORCAD_SERVE_MODE_SWITCH_E2E_SPEC = 'tests/e2e/orcad-serve-mode-switch.spec.ts' +// Needs the orcad template for its host's target, which only its own job builds. +export const ORCAD_AUTO_CONVERT_E2E_SPEC = 'tests/e2e/ssh-orcad-auto-convert.spec.ts' export const DEDICATED_E2E_SPECS = [ ...DOCKER_SSH_E2E_SPECS, NODE_NETWORK_E2E_SPEC, LOCALHOST_SSH_E2E_SPEC, NATIVE_IME_E2E_SPEC, - ORCAD_SERVE_MODE_SWITCH_E2E_SPEC + ORCAD_SERVE_MODE_SWITCH_E2E_SPEC, + ORCAD_AUTO_CONVERT_E2E_SPEC ] const dedicatedSpecs = new Set(DEDICATED_E2E_SPECS) const dockerSpecs = new Set(DOCKER_SSH_E2E_SPECS) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index ff635c149d46..9ee6998d76d1 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -22,6 +22,15 @@ export const PR_E2E_SOURCE_ROUTES = [ file ) }, + { + id: 'ssh.orcad-auto-convert', + specs: ['tests/e2e/ssh-orcad-auto-convert.spec.ts'], + matches: (file) => + isProductSource(file) && + /^src\/main\/(?:ipc\/ssh-host-server-|ssh\/(?:ssh-host-server-|orcad-runtime-conversion|orcad-migration-|orcad-retained-source|orcad-runtime-deployment))/.test( + file + ) + }, { id: 'ssh.localhost-agent-hooks', specs: ['tests/e2e/ssh-localhost.spec.ts'], diff --git a/config/scripts/release-cut-token-permissions.test.mjs b/config/scripts/release-cut-token-permissions.test.mjs index 172d35a9f3e3..7e6267af6d6d 100644 --- a/config/scripts/release-cut-token-permissions.test.mjs +++ b/config/scripts/release-cut-token-permissions.test.mjs @@ -11,6 +11,7 @@ const EXPECTED_MATRIX = { '.github/workflows/e2e.yml#build': { contents: 'read' }, '.github/workflows/e2e.yml#changed-e2e': { contents: 'read' }, '.github/workflows/e2e.yml#e2e': { contents: 'read' }, + '.github/workflows/e2e.yml#orcad-auto-convert-docker': { contents: 'read' }, '.github/workflows/e2e.yml#orcad-serve-mode-switch': { contents: 'read' }, '.github/workflows/e2e.yml#prepare-native-cache': { contents: 'read' }, '.github/workflows/e2e.yml#ssh-browser-network-route': { contents: 'read' }, diff --git a/config/scripts/ssh-docker-ci-sharding.test.mjs b/config/scripts/ssh-docker-ci-sharding.test.mjs index 05a1f520613f..facbc005d502 100644 --- a/config/scripts/ssh-docker-ci-sharding.test.mjs +++ b/config/scripts/ssh-docker-ci-sharding.test.mjs @@ -36,6 +36,7 @@ it('gives every removed SSH spec a dedicated owner even for test-only edits', () 'tests/e2e/orcad-serve-mode-switch.spec.ts', 'tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts', 'tests/e2e/ssh-localhost.spec.ts', + 'tests/e2e/ssh-orcad-auto-convert.spec.ts', 'tests/e2e/terminal-ibus-hangul-native.spec.ts' ]) }) diff --git a/config/scripts/ssh-windows-hosts-workflow.test.mjs b/config/scripts/ssh-windows-hosts-workflow.test.mjs index 33a113d232cd..345e3c29b2e5 100644 --- a/config/scripts/ssh-windows-hosts-workflow.test.mjs +++ b/config/scripts/ssh-windows-hosts-workflow.test.mjs @@ -5,6 +5,7 @@ import { parse } from 'yaml' import { WINDOWS_FORBIDDEN_TOOLS, WINDOWS_HOST_CELL_IDS, + WINDOWS_CONVERT_CELL_ID, WINDOWS_ORCAD_CELL_IDS } from '../../src/main/ssh/ssh-windows-host-cells.ts' @@ -184,4 +185,21 @@ describe('SSH Windows-host workflow', () => { } expect(invoker).toContain('src/main/ssh/orcad-windows-host-lane.test.ts') }) + + it('provisions one private account for every cell, convert cell included', () => { + expect(runStep.run).toContain(`$cells+='${WINDOWS_CONVERT_CELL_ID}'`) + // Only the convert cell reaches a managed server, through an SSH local forward. + expect(runStep.run).toContain( + `$forwarding=if($cells[-1] -eq '${WINDOWS_CONVERT_CELL_ID}'){1}else{0}` + ) + expect(runStep.run).toContain('-ForwardingAccounts $forwarding') + const provisioner = readFileSync( + join(projectDir, 'config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1'), + 'utf8' + ) + const max = Number(/\[ValidateRange\(1,(\d+)\)\]\[int\]\$Accounts/.exec(provisioner)?.[1]) + expect(max).toBeGreaterThanOrEqual( + WINDOWS_HOST_CELL_IDS.length + WINDOWS_ORCAD_CELL_IDS.length + 1 + ) + }) }) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.test.ts index 49cb049057f4..9525565e2646 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.test.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.test.ts @@ -67,6 +67,26 @@ const MANIFEST: OrcadMigrationManifest = { } describe('orcad migration source dependency census', () => { + it('ignores client focus on another host copied into the source host partition', () => { + const state = getDefaultPersistedState('/home/test') + state.sshTargets = [TARGET] + state.repos = [REPO] + state.folderWorkspaces = [FOLDER] + const localWorktreeId = 'local-repo::/home/test/local' + state.workspaceSession = { ...state.workspaceSession, activeWorktreeId: localWorktreeId } + state.workspaceSessionsByHostId = { + [`ssh:${TARGET.id}`]: { + ...state.workspaceSession, + activeWorktreeId: localWorktreeId, + activeWorkspaceKey: worktreeWorkspaceKey(localWorktreeId) + } + } + + expect(collectOrcadMigrationSourceDependencyCensus(state, MANIFEST)).toMatchObject({ + totalCount: 0 + }) + }) + it('allows a static catalog with no unrepresented dependent state', () => { const state = getDefaultPersistedState('/home/test') state.sshTargets = [TARGET] diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts index 44e48a5a57a8..9ede45523e91 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts @@ -84,18 +84,12 @@ export function countUnsupportedSessionState( !(sourceHostPartition && scope.repoIds.has(session.activeRepoId)) ? 1 : 0 + // Why not in the source partition: there focus is a copy of the client-wide focus. An in-scope + // copy is carried; one aimed at another host's workspace is not host state and is never carried. count += - session.activeWorktreeId && - matches(session.activeWorktreeId) && - !(sourceHostPartition && orcadMigrationOwnerMatchesScope(session.activeWorktreeId, scope)) - ? 1 - : 0 + !sourceHostPartition && session.activeWorktreeId && owns(session.activeWorktreeId) ? 1 : 0 count += - session.activeWorkspaceKey && - matches(session.activeWorkspaceKey) && - !(sourceHostPartition && orcadMigrationOwnerMatchesScope(session.activeWorkspaceKey, scope)) - ? 1 - : 0 + !sourceHostPartition && session.activeWorkspaceKey && owns(session.activeWorkspaceKey) ? 1 : 0 count += session.activeWorkspaceExecutionHostId === scope.hostId && !sourceHostPartition ? 1 : 0 // activeConnectionIdsAtShutdown is not counted: it is the renderer's live "connected now" hint, and // the remote work it can stand for (tab PTYs, remote session ids, leases) is counted on its own. diff --git a/src/main/ssh/orcad-active-readiness.ts b/src/main/ssh/orcad-active-readiness.ts index 5fe6c157f9b8..73e228c29b1f 100644 --- a/src/main/ssh/orcad-active-readiness.ts +++ b/src/main/ssh/orcad-active-readiness.ts @@ -3,8 +3,6 @@ import { evaluateOrcadActivation, type OrcadActivationExpectation } from './orca import { orcadLivenessProbeCommand, parseOrcadLiveness, - parseOrcadReadinessOutput, - readOrcadReadinessCommand, type OrcadLaunchSpec, type OrcadReadinessParse } from './orcad-remote-launch' @@ -13,6 +11,10 @@ import { launchOrcadAndAwaitReadiness, type OrcadRemoteExecTarget } from './orcad-remote-runtime-control' +import { + parseOrcadReadinessWaitOutput, + readOrcadReadinessNowCommand +} from './orcad-remote-readiness-wait' import type { ServeReadiness } from '../server/serve-readiness' /** `exited` is proven absence; `unverifiable` means the host could not say, which is not death. */ @@ -94,8 +96,12 @@ export async function probeActiveOrcadReadiness( `orcad ${expectation.fullVersion} process state is unverifiable.` ) } - const parsed = parseOrcadReadinessOutput( - await execOrcadRemote(target, readOrcadReadinessCommand(target.host, target.remoteInstallDir)) + const parsed = parseOrcadReadinessWaitOutput( + target.host, + await execOrcadRemote( + target, + readOrcadReadinessNowCommand(target.host, target.remoteInstallDir) + ) ) return gatedReadiness(parsed, expectation, 'The active orcad') } diff --git a/src/main/ssh/orcad-managed-remote-stop.ts b/src/main/ssh/orcad-managed-remote-stop.ts index ecd0b0298866..0c76dfc405a2 100644 --- a/src/main/ssh/orcad-managed-remote-stop.ts +++ b/src/main/ssh/orcad-managed-remote-stop.ts @@ -8,10 +8,9 @@ import { randomUUID } from 'node:crypto' import { shellEscape } from './ssh-connection-utils' import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' -import { readOrcadReadinessCommand } from './orcad-remote-launch' import { - orcadReadinessWaitCommand, - parseOrcadReadinessWaitOutput + parseOrcadReadinessWaitOutput, + readOrcadReadinessNowCommand } from './orcad-remote-readiness-wait' import { execOrcadRemote } from './orcad-remote-runtime-control' import { readBoundedOrcadRemoteRecord } from './orcad-remote-record-file' @@ -55,15 +54,9 @@ export async function readRemoteOrcadManagedStopTarget( version: string ): Promise { const slotDir = orcadSlotDir(options, version) - // Windows reads it through the host script's wait op with no wait; POSIX keeps `head`. const readiness = parseOrcadReadinessWaitOutput( options.host, - await execOrcadRemote( - options, - isWindowsRemoteHost(options.host) - ? orcadReadinessWaitCommand(options.host, slotDir, 0) - : readOrcadReadinessCommand(options.host, slotDir) - ) + await execOrcadRemote(options, readOrcadReadinessNowCommand(options.host, slotDir)) ) if (readiness.state !== 'ready' || !readiness.readiness.health) { return refused( diff --git a/src/main/ssh/orcad-migration-source-retention.test.ts b/src/main/ssh/orcad-migration-source-retention.test.ts index 0c3b9b582c92..5b46226ce1ab 100644 --- a/src/main/ssh/orcad-migration-source-retention.test.ts +++ b/src/main/ssh/orcad-migration-source-retention.test.ts @@ -4,7 +4,8 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('../../shared/app-environment', () => ({ - getAppEnvironment: () => ({ getVersion: () => '1.5.0' }) + hasAppEnvironment: () => true, + getAppEnvironment: () => ({ getVersion: () => '1.5.0', isPackaged: () => true }) })) import { recordRolloutConfig, resetRolloutConfigForTests } from '../updater/rollout-flags' diff --git a/src/main/ssh/orcad-remote-readiness-wait.ts b/src/main/ssh/orcad-remote-readiness-wait.ts index e231bf1697c8..fe9334158ec8 100644 --- a/src/main/ssh/orcad-remote-readiness-wait.ts +++ b/src/main/ssh/orcad-remote-readiness-wait.ts @@ -12,6 +12,7 @@ import { ORCAD_READINESS_FILENAME, ORCAD_READINESS_MAX_BYTES, parseOrcadReadinessOutput, + readOrcadReadinessCommand, type OrcadReadinessParse } from './orcad-remote-launch' import { @@ -78,3 +79,14 @@ export function parseOrcadReadinessWaitOutput( : output ) } + +/** Reads the readiness line as it stands; parse with `parseOrcadReadinessWaitOutput`. */ +export function readOrcadReadinessNowCommand( + host: RemoteHostPlatform, + remoteInstallDir: string +): string { + // Why: Windows has no `head`; its host script's wait op with no wait reads the same bytes. + return isWindowsRemoteHost(host) + ? orcadReadinessWaitCommand(host, remoteInstallDir, 0) + : readOrcadReadinessCommand(host, remoteInstallDir) +} diff --git a/src/main/ssh/orcad-remote-windows-commands.test.ts b/src/main/ssh/orcad-remote-windows-commands.test.ts index 83f7287c6ab1..7ea3489576df 100644 --- a/src/main/ssh/orcad-remote-windows-commands.test.ts +++ b/src/main/ssh/orcad-remote-windows-commands.test.ts @@ -25,6 +25,7 @@ import { writeAtomicOrcadRemoteRecord } from './orcad-remote-record-file' import { launchOrcadAndAwaitReadiness } from './orcad-remote-runtime-control' +import { probeActiveOrcadReadiness } from './orcad-active-readiness' import { completeRemoteOrcadManagedStop } from './orcad-managed-remote-stop' import { installOrcadWindowsHostScript, @@ -134,6 +135,31 @@ describe('Windows orcad commands run node.exe directly', () => { }) }) +describe('the active-slot readiness probe on Windows', () => { + it('reads readiness through the host script instead of the POSIX head command', async () => { + mockExec + .mockResolvedValueOnce('LIVE\r\n') + .mockResolvedValueOnce( + encoded( + '__ORCAD_READINESS__', + `${JSON.stringify({ type: 'orca_server_ready', runtimeId: 'r1' })}\n` + ) + ) + const probe = probeActiveOrcadReadiness( + { conn: Object.create(null), host, remoteInstallDir: slot }, + { buildHash: 'bb01', fullVersion: '0.2.0+bb01' } + ) + + // The identity gate may still refuse this payload; the point is that the host is asked. + await probe.catch((error: unknown) => { + expect(error).not.toMatchObject({ name: 'OrcadRemoteLaunchUnsupportedError' }) + }) + expect(mockExec.mock.calls[1]?.[1]).toBe( + `${NODE} ${SCRIPT} readiness-wait ${slot}/.orcad-readiness "262144" "0"` + ) + }) +}) + describe('Windows command lines for both DefaultShells', () => { it('double-quotes arguments with spaces or leading digits, and passes the rest bare', () => { expect( diff --git a/src/main/ssh/orcad-runtime-conversion.ts b/src/main/ssh/orcad-runtime-conversion.ts index 481cacc41d88..ebae584928c3 100644 --- a/src/main/ssh/orcad-runtime-conversion.ts +++ b/src/main/ssh/orcad-runtime-conversion.ts @@ -173,9 +173,23 @@ async function fenceOrResume( signal: args.signal }) ) - return result.outcome === 'fenced' - ? result.cutover - : refuse(result.verdict, result.code, result.reason) + if (result.outcome === 'fenced') { + return result.cutover + } + if (result.blockers?.length) { + // Why logged: the connect surfaces only the reason, and support needs which state blocked. + console.warn( + '[ssh] Conversion refused by:', + JSON.stringify( + result.blockers.map((blocker) => + 'dependencies' in blocker + ? { code: blocker.code, dependencies: blocker.dependencies } + : { code: blocker.code } + ) + ) + ) + } + return refuse(result.verdict, result.code, result.reason) } function refuse( diff --git a/src/main/ssh/ssh-port-forward.test.ts b/src/main/ssh/ssh-port-forward.test.ts index 3cba9e0b3b47..24ebacf617eb 100644 --- a/src/main/ssh/ssh-port-forward.test.ts +++ b/src/main/ssh/ssh-port-forward.test.ts @@ -91,10 +91,13 @@ vi.mock('net', () => { return { createServer: vi.fn().mockImplementation((connectionHandler) => { const listeners = new Map void>() + let boundPort = 0 const server = { - listen: vi.fn().mockImplementation(() => { + listen: vi.fn().mockImplementation((port: number) => { + boundPort = port === 0 ? 43210 : port listeners.get('listening')?.() }), + address: vi.fn().mockImplementation(() => ({ address: '127.0.0.1', port: boundPort })), close: vi.fn().mockImplementation((cb?: () => void) => cb?.()), once: vi.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => { listeners.set(event, handler) @@ -119,6 +122,14 @@ describe('SshPortForwardManager', () => { startSystemSshPortForwardProcessMock.mockReset() }) + it('reports the port the OS bound when an ssh2 forward asks for port 0', async () => { + const conn = createMockConn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock implements the getClient/usesSystemSshTransport surface the ssh2 provider reads. + const entry = await manager.addForward('conn-1', conn as never, 0, '127.0.0.1', 6768) + + expect(entry.localPort).toBe(43210) + }) + it('adds an ssh2 port forward and returns entry', async () => { const conn = createMockConn() const entry = await manager.addForward('conn-1', conn as never, 3000, 'localhost', 8080) @@ -334,6 +345,19 @@ describe('SshPortForwardManager', () => { expect(socket.destroy).toHaveBeenCalled() }) + it('drops a local connection instead of throwing when the ssh2 client already disconnected', async () => { + const conn = createMockConn() + conn.mockClient.forwardOut.mockImplementation(() => { + throw new Error('Not connected') + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock implements the getClient/usesSystemSshTransport surface the ssh2 provider reads. + await manager.addForward('conn-1', conn as never, 3000, 'localhost', 8080) + const socket = createFakeSocket() + + expect(() => getLastMockServer()?._connectionHandler(socket)).not.toThrow() + expect(socket.destroy).toHaveBeenCalled() + }) + it('closes late ssh2 channels after the forward was removed', async () => { const mockChannel = { pipe: vi.fn().mockReturnThis(), diff --git a/src/main/ssh/ssh-relay-windows-host-lane.test.ts b/src/main/ssh/ssh-relay-windows-host-lane.test.ts index 56068d55bf44..00824806dca9 100644 --- a/src/main/ssh/ssh-relay-windows-host-lane.test.ts +++ b/src/main/ssh/ssh-relay-windows-host-lane.test.ts @@ -32,6 +32,7 @@ import { } from './ssh-session-command-audit' import { isWindowsOrcadCellId, + WINDOWS_CONVERT_CELL_ID, readWindowsHostCellDescriptor, windowsHostCell, windowsHostSshTarget @@ -55,8 +56,8 @@ describe.runIf(RUN)('SSH relay on a Windows OpenSSH host', () => { 'lands the cell the descriptor names', async () => { const descriptor = readWindowsHostCellDescriptor(process.env.ORCA_SSH_WINDOWS_HOST_CELL ?? '') - if (isWindowsOrcadCellId(descriptor.cell)) { - throw new Error(`${descriptor.cell} runs in orcad-windows-host-lane.test.ts`) + if (isWindowsOrcadCellId(descriptor.cell) || descriptor.cell === WINDOWS_CONVERT_CELL_ID) { + throw new Error(`${descriptor.cell} does not run in the relay lane`) } const cell = windowsHostCell(descriptor.cell, descriptor.target) const observer = localHostObserver(descriptor.forbiddenToolLog) diff --git a/src/main/ssh/ssh-windows-host-cells.test.ts b/src/main/ssh/ssh-windows-host-cells.test.ts index 90d7f317a261..ded145ff48f0 100644 --- a/src/main/ssh/ssh-windows-host-cells.test.ts +++ b/src/main/ssh/ssh-windows-host-cells.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { isWindowsOrcadCellId, + WINDOWS_CONVERT_CELL_ID, parseWindowsHostCellDescriptor, WINDOWS_FORBIDDEN_TOOLS, WINDOWS_HOST_CELL_IDS, @@ -45,7 +46,7 @@ describe('Windows SSH-host cells', () => { it('reads the descriptor PowerShell writes, BOM included', () => { const parsed = parseWindowsHostCellDescriptor(`\uFEFF${JSON.stringify(DESCRIPTOR)}`) expect(parsed).toEqual(DESCRIPTOR) - if (isWindowsOrcadCellId(parsed.cell)) { + if (isWindowsOrcadCellId(parsed.cell) || parsed.cell === WINDOWS_CONVERT_CELL_ID) { throw new Error(`expected a relay cell, got ${parsed.cell}`) } const target = windowsHostSshTarget(parsed, windowsHostCell(parsed.cell, parsed.target), 'r1') diff --git a/src/main/ssh/ssh-windows-host-cells.ts b/src/main/ssh/ssh-windows-host-cells.ts index 58bddc1acc7c..731b8f4a2a4d 100644 --- a/src/main/ssh/ssh-windows-host-cells.ts +++ b/src/main/ssh/ssh-windows-host-cells.ts @@ -62,9 +62,12 @@ export function windowsOrcadCellShell(id: WindowsOrcadCellId): WindowsSshDefault return id === 'orcad-cmd' ? 'cmd' : 'powershell' } +/** The app connects a relay-era host and converts it to managed orcad (tests/e2e). */ +export const WINDOWS_CONVERT_CELL_ID = 'orcad-convert' + /** Written by config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1, one per run. */ export type WindowsHostCellDescriptor = { - cell: WindowsHostCellId | WindowsOrcadCellId + cell: WindowsHostCellId | WindowsOrcadCellId | typeof WINDOWS_CONVERT_CELL_ID target: WindowsServerTarget host: string port: number @@ -92,7 +95,8 @@ export function parseWindowsHostCellDescriptor(text: string): WindowsHostCellDes const record = Object.fromEntries(Object.entries(parsed)) const cell = WINDOWS_HOST_CELL_IDS.find((id) => id === record.cell) ?? - WINDOWS_ORCAD_CELL_IDS.find((id) => id === record.cell) + WINDOWS_ORCAD_CELL_IDS.find((id) => id === record.cell) ?? + (record.cell === WINDOWS_CONVERT_CELL_ID ? WINDOWS_CONVERT_CELL_ID : undefined) if (!cell) { throw new Error(`Unknown Windows host cell: ${String(record.cell)}`) } diff --git a/src/main/ssh/ssh2-port-forward-provider.ts b/src/main/ssh/ssh2-port-forward-provider.ts index 06049816933f..016297ec44ee 100644 --- a/src/main/ssh/ssh2-port-forward-provider.ts +++ b/src/main/ssh/ssh2-port-forward-provider.ts @@ -26,35 +26,43 @@ export class Ssh2PortForwardProvider implements SshPortForwardProvider { socket.on('close', () => activeSockets.delete(socket)) socket.on('error', () => socket.destroy()) - client.forwardOut( - options.localHost, - options.localPort, - options.remoteHost, - options.remotePort, - (err, channel) => { - if (err) { - socket.destroy() - return + // Why: a client whose SSH transport dropped throws synchronously; uncaught, that kills main. + try { + client.forwardOut( + options.localHost, + options.localPort, + options.remoteHost, + options.remotePort, + (err, channel) => { + if (err) { + socket.destroy() + return + } + if (closed || socket.destroyed) { + closeChannel(channel) + socket.destroy() + return + } + socket.pipe(channel).pipe(socket) + channel.on('close', () => socket.destroy()) + channel.on('error', () => socket.destroy()) + socket.on('close', () => channel.close()) } - if (closed || socket.destroyed) { - closeChannel(channel) - socket.destroy() - return - } - socket.pipe(channel).pipe(socket) - channel.on('close', () => socket.destroy()) - channel.on('error', () => socket.destroy()) - socket.on('close', () => channel.close()) - } - ) + ) + } catch { + socket.destroy() + } }) await listen(server, options.localHost, options.localPort) + // Why: port 0 asks the OS for a free port, and callers must dial the one it bound. + const address = server.address() + const localPort = typeof address === 'object' && address ? address.port : options.localPort const entry = { id: options.id, connectionId: options.connectionId, - localPort: options.localPort, + localPort, remoteHost: options.remoteHost, remotePort: options.remotePort, label: options.label diff --git a/src/main/updater/rollout-flags.test.ts b/src/main/updater/rollout-flags.test.ts index 9573c69134ea..0fbacc4d3125 100644 --- a/src/main/updater/rollout-flags.test.ts +++ b/src/main/updater/rollout-flags.test.ts @@ -1,9 +1,14 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import { BAKED_ROLLOUT_FLAGS, ROLLOUT_FLAG_NAMES, isRolloutFlagActive, parseRolloutConfig, + readE2ERolloutConfigOverride, recordRolloutConfig, resetRolloutConfigForTests, resolveRolloutFlag, @@ -115,3 +120,54 @@ describe('rollout flags', () => { expect(isRolloutFlagActive('pinned-relay-default', install)).toBe(false) }) }) + +describe('e2e rollout override', () => { + const dirs: string[] = [] + afterEach(() => { + vi.unstubAllEnvs() + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + function flagsFile(flags: unknown): string { + const dir = mkdtempSync(join(tmpdir(), 'rollout-e2e-')) + dirs.push(dir) + const file = join(dir, 'flags.json') + writeFileSync(file, JSON.stringify(flags)) + return file + } + + it('reads the flags file only in an unpackaged e2e launch', () => { + const file = flagsFile({ 'orcad-source-retirement': { state: 'on' } }) + const env = { ORCA_E2E_USER_DATA_DIR: '/e2e', ORCA_E2E_ROLLOUT_FLAGS_FILE: file } + expect(readE2ERolloutConfigOverride(env, false)).toEqual({ + 'orcad-source-retirement': { state: 'on' } + }) + expect(readE2ERolloutConfigOverride({ ORCA_E2E_ROLLOUT_FLAGS_FILE: file }, false)).toBeNull() + }) + + it('never reads the flags file in a packaged release', () => { + const file = flagsFile({ 'orcad-source-retirement': { state: 'on' } }) + const env = { ORCA_E2E_USER_DATA_DIR: '/e2e', ORCA_E2E_ROLLOUT_FLAGS_FILE: file } + expect(readE2ERolloutConfigOverride(env, true)).toBeNull() + vi.stubEnv('ORCA_E2E_USER_DATA_DIR', env.ORCA_E2E_USER_DATA_DIR) + vi.stubEnv('ORCA_E2E_ROLLOUT_FLAGS_FILE', file) + const context = { appVersion: '1.4.218', installId: null } + installFakeAppEnvironment({ isPackaged: () => true }) + expect(isRolloutFlagActive('orcad-source-retirement', context)).toBe(false) + installFakeAppEnvironment({ isPackaged: () => false }) + expect(isRolloutFlagActive('orcad-source-retirement', context)).toBe(true) + }) + + it('treats a missing or unreadable file as no override', () => { + const env = { ORCA_E2E_USER_DATA_DIR: '/e2e' } + expect(readE2ERolloutConfigOverride(env, false)).toBeNull() + expect( + readE2ERolloutConfigOverride( + { ...env, ORCA_E2E_ROLLOUT_FLAGS_FILE: '/missing/flags.json' }, + false + ) + ).toBeNull() + }) +}) diff --git a/src/main/updater/rollout-flags.ts b/src/main/updater/rollout-flags.ts index 8503424800aa..8a9cb90c288b 100644 --- a/src/main/updater/rollout-flags.ts +++ b/src/main/updater/rollout-flags.ts @@ -6,7 +6,9 @@ * the flag's version range, or was never received, so the payload can only move what it names. */ import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' import { z } from 'zod' +import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' import { compareVersions, isValidVersion } from '../updater-fallback' export const ROLLOUT_FLAG_NAMES = [ @@ -133,7 +135,32 @@ export function isRolloutFlagActive( name: RolloutFlagName, context: Omit ): boolean { - return resolveRolloutFlag(name, { ...context, config: lastRolloutConfig }) + return resolveRolloutFlag(name, { + ...context, + config: readE2ERolloutConfigOverride() ?? lastRolloutConfig + }) +} + +/** + * E2E launches only: a `flags` block read from `ORCA_E2E_ROLLOUT_FLAGS_FILE` on every check, so a + * spec can flip a flag between two connects without the campaign fetch or a relaunch. + */ +export function readE2ERolloutConfigOverride( + env: NodeJS.ProcessEnv = process.env, + // Why fail closed: a packaged release must never take rollout flags from an env-named path. + isPackaged: boolean = !hasAppEnvironment() || getAppEnvironment().isPackaged() +): RolloutConfig | null { + const file = env.ORCA_E2E_ROLLOUT_FLAGS_FILE + if (isPackaged || !env.ORCA_E2E_USER_DATA_DIR || !file) { + return null + } + try { + return parseRolloutConfig({ + rollout: { version: 1, flags: JSON.parse(readFileSync(file, 'utf8')) } + }) + } catch { + return null + } } export function resetRolloutConfigForTests(): void { diff --git a/src/renderer/src/store/slices/runtime-environment-ssh.ts b/src/renderer/src/store/slices/runtime-environment-ssh.ts index e6b2f30db615..dfaaedfe7ae6 100644 --- a/src/renderer/src/store/slices/runtime-environment-ssh.ts +++ b/src/renderer/src/store/slices/runtime-environment-ssh.ts @@ -2,7 +2,8 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { SshConnectionState, SshTargetSummary } from '../../../../shared/ssh-types' import { sanitizeSshTargetGeneration } from '../../../../shared/ssh-target-generation' -import { sshConnectionStatesEqual, sshTargetLabelsEqual } from './ssh-target-cleanup' +import { sshConnectionStatesEqual } from './ssh-connection-state-equality' +import { sshTargetLabelsEqual } from './ssh-target-cleanup' export { selectRuntimeAwareSshConnectionGeneration, selectRuntimeAwareSshError, diff --git a/src/renderer/src/store/slices/ssh-connection-state-equality.ts b/src/renderer/src/store/slices/ssh-connection-state-equality.ts new file mode 100644 index 000000000000..6e2281547f16 --- /dev/null +++ b/src/renderer/src/store/slices/ssh-connection-state-equality.ts @@ -0,0 +1,20 @@ +import type { SshConnectionState } from '../../../../shared/ssh-types' + +export function sshConnectionStatesEqual( + a: SshConnectionState | undefined, + b: SshConnectionState +): boolean { + return ( + a?.targetId === b.targetId && + a?.status === b.status && + a?.error === b.error && + a?.reconnectAttempt === b.reconnectAttempt && + a?.providerEpoch === b.providerEpoch && + a?.connectionGeneration === b.connectionGeneration && + a?.supportsFolderDownload === b.supportsFolderDownload && + a?.remotePlatform === b.remotePlatform && + // Why: a connect can end on the same status and epoch it set up under, changing only these. + JSON.stringify(a?.managedServer ?? null) === JSON.stringify(b.managedServer ?? null) && + JSON.stringify(a?.plainSsh ?? null) === JSON.stringify(b.plainSsh ?? null) + ) +} diff --git a/src/renderer/src/store/slices/ssh-target-cleanup.test.ts b/src/renderer/src/store/slices/ssh-target-cleanup.test.ts index b3b7323ce206..05fe4f61aa82 100644 --- a/src/renderer/src/store/slices/ssh-target-cleanup.test.ts +++ b/src/renderer/src/store/slices/ssh-target-cleanup.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' +import type { SshConnectionState } from '../../../../shared/ssh-types' import type { AppState } from '../types' +import { sshConnectionStatesEqual } from './ssh-connection-state-equality' import { buildRemovedSshTargetCleanupPatch } from './ssh-target-cleanup' import { createTestStore, makeTab } from './store-test-helpers' @@ -141,3 +143,43 @@ describe('SSH target cleanup tab map', () => { expect(patch?.pendingDirectSshLayoutEditsByTabId).toEqual({ otherTab: retained }) }) }) + +describe('sshConnectionStatesEqual', () => { + it('treats a managed-server change on the same connection as a new state', () => { + const settingUp: SshConnectionState = { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + managedServer: { kind: 'setting-up', phase: 'converting' } + } + const managed: SshConnectionState = { + ...settingUp, + managedServer: { kind: 'managed', environmentId: 'env-1' } + } + + expect(sshConnectionStatesEqual(settingUp, { ...settingUp })).toBe(true) + expect(sshConnectionStatesEqual(settingUp, managed)).toBe(false) + }) + + it('lands a converted host on managed in the store', () => { + const store = createTestStore() + const settingUp: SshConnectionState = { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + managedServer: { kind: 'setting-up', phase: 'converting' } + } + store.getState().setSshConnectionState('ssh-1', settingUp) + store.getState().setSshConnectionState('ssh-1', { + ...settingUp, + managedServer: { kind: 'managed', environmentId: 'env-1' } + }) + + expect(store.getState().sshConnectionStates.get('ssh-1')?.managedServer).toEqual({ + kind: 'managed', + environmentId: 'env-1' + }) + }) +}) diff --git a/src/renderer/src/store/slices/ssh-target-cleanup.ts b/src/renderer/src/store/slices/ssh-target-cleanup.ts index 859fde228f66..294ad3f098de 100644 --- a/src/renderer/src/store/slices/ssh-target-cleanup.ts +++ b/src/renderer/src/store/slices/ssh-target-cleanup.ts @@ -1,25 +1,9 @@ import type { AppState } from '../types' -import type { SshConnectionState, SshTarget, SshTargetSummary } from '../../../../shared/ssh-types' +import type { SshTarget, SshTargetSummary } from '../../../../shared/ssh-types' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import { sanitizeSshTargetGeneration } from '../../../../shared/ssh-target-generation' import { resolveDirectSshTargetScope } from '../../lib/direct-ssh-target-scope' -export function sshConnectionStatesEqual( - a: SshConnectionState | undefined, - b: SshConnectionState -): boolean { - return ( - a?.targetId === b.targetId && - a?.status === b.status && - a?.error === b.error && - a?.reconnectAttempt === b.reconnectAttempt && - a?.providerEpoch === b.providerEpoch && - a?.connectionGeneration === b.connectionGeneration && - a?.supportsFolderDownload === b.supportsFolderDownload && - a?.remotePlatform === b.remotePlatform - ) -} - export function sshTargetLabelsEqual( labels: Map, targets: Pick[] diff --git a/src/renderer/src/store/slices/ssh.ts b/src/renderer/src/store/slices/ssh.ts index ddeaf415fd50..497ef0104ec8 100644 --- a/src/renderer/src/store/slices/ssh.ts +++ b/src/renderer/src/store/slices/ssh.ts @@ -9,10 +9,10 @@ import type { import { buildRemovedSshTargetCleanupPatch, collectSshTargetGenerations, - sshConnectionStatesEqual, sshTargetGenerationsEqual, sshTargetLabelsEqual } from './ssh-target-cleanup' +import { sshConnectionStatesEqual } from './ssh-connection-state-equality' export type RemoteWorkspaceSyncStatus = { phase: 'idle' | 'pulling' | 'pushing' | 'synced' | 'conflict' | 'error' | 'offline' diff --git a/tests/e2e/cross-version-wire/managed-orcad-convert-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/managed-orcad-convert-downgrade.unit.test.ts new file mode 100644 index 000000000000..9d50dcf10828 --- /dev/null +++ b/tests/e2e/cross-version-wire/managed-orcad-convert-downgrade.unit.test.ts @@ -0,0 +1,93 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { normalizeSshTarget } from '../../../src/main/persistence/leasing-ssh-ptys/ssh-normalization' +import { visibleRepos } from '../../../src/main/ssh/orcad-retained-source' +import type { Repo } from '../../../src/shared/repo-types' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +/** + * The downgrade direction for an SSH host converted to managed orcad (#24975, #24979). + * + * After conversion this build fences the host in `orcadFence` and keeps its source rows retained + * but hidden. A user who rolls back to the last release before auto-conversion must still find + * the host and its project there and reach them over that build's relay. A fence spelled as an + * `owner` would have hidden the host from that build, which is what this pins. + */ +const LAST_RELAY_ONLY_REF = 'v1.4.218' +const SUITE_TIMEOUT_MS = 180_000 +const TARGET_ID = 'ssh-converted' + +type ConnectionStore = new (store: { getSshTargets: () => unknown[] }) => { + listTargets: () => { id: string; host: string; port: number }[] +} + +function isFunction(value: unknown): value is (...args: unknown[]) => unknown { + return typeof value === 'function' +} + +function isConstructor(value: unknown): value is ConnectionStore { + return typeof value === 'function' +} + +/** What this build persists for a converted host: the relay endpoint plus its server fence. */ +const persistedTarget = normalizeSshTarget({ + id: TARGET_ID, + label: 'Converted host', + host: 'build.example.com', + port: 22, + username: 'dev', + orcadFence: { environmentId: 'env-managed' } +}) +const retainedRepo: Repo = { + id: 'repo-retained', + path: '/home/dev/project', + displayName: 'project', + badgeColor: '#888888', + addedAt: 0, + connectionId: TARGET_ID +} + +describe(`managed orcad conversion read back by ${LAST_RELAY_ONLY_REF}`, () => { + let oldNormalize: (target: unknown) => unknown + let OldConnectionStore: ConnectionStore + + beforeAll(async () => { + const checkout = await materializeReleaseCheckout(LAST_RELAY_ONLY_REF) + const normalization = await importReleaseCheckoutModule( + checkout, + 'src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts' + ) + const connections = await importReleaseCheckoutModule( + checkout, + 'src/main/ssh/ssh-connection-store.ts' + ) + if ( + !isFunction(normalization.normalizeSshTarget) || + !isConstructor(connections.SshConnectionStore) + ) { + throw new Error(`${LAST_RELAY_ONLY_REF} no longer exports the SSH target readers this pins`) + } + const normalize = normalization.normalizeSshTarget + oldNormalize = (target) => normalize(target) + OldConnectionStore = connections.SshConnectionStore + }, SUITE_TIMEOUT_MS) + + it('still lists the converted host with its relay endpoint', () => { + const oldTarget = oldNormalize(persistedTarget) + const listed = new OldConnectionStore({ getSshTargets: () => [oldTarget] }).listTargets() + expect(listed).toEqual([ + expect.objectContaining({ id: TARGET_ID, host: 'build.example.com', port: 22 }) + ]) + }) + + it('keeps the project this build retains but hides', () => { + // That release lists every stored repo; only this build filters the retained ones out. + const store = { + getSshTargets: () => [persistedTarget], + getRepos: () => [retainedRepo], + getFolderWorkspaces: () => [], + getProjectGroups: () => [] + } + expect(visibleRepos(store)).toEqual([]) + expect(store.getRepos()).toEqual([expect.objectContaining({ connectionId: TARGET_ID })]) + }) +}) diff --git a/tests/e2e/helpers/orcad-convert-flow.ts b/tests/e2e/helpers/orcad-convert-flow.ts new file mode 100644 index 000000000000..cac4b69610e8 --- /dev/null +++ b/tests/e2e/helpers/orcad-convert-flow.ts @@ -0,0 +1,192 @@ +/** + * The connect that converts a relay-era SSH host, and the source's retention then retirement, + * shared by the runtime relay-era cell and the upgraded-profile cell. + */ +import { writeFileSync } from 'node:fs' +import type { Page } from '@stablyai/playwright-test' +import { expect } from './orca-app' +import { readPersistedProfileState } from './persisted-profile-state' +import { findOrcadMigrationSourceCutoverForTarget } from '../../../src/main/ssh/orcad-migration-cutover-journal' +import { toRuntimeExecutionHostId } from '../../../src/shared/execution-host' + +const CONVERT_TIMEOUT_MS = 8 * 60_000 + +const RECONNECT_STEP_TIMEOUT_MS = 6 * 60_000 + +/** Returns what the connect itself resolved to; a step that hangs fails naming itself. */ +export async function reconnect(page: Page, targetId: string): Promise { + for (const step of ['disconnect', 'connect'] as const) { + const run = page.evaluate( + async ({ id, step }) => { + try { + if (step === 'disconnect') { + await window.api.ssh.disconnect({ targetId: id }) + return '' + } + const state = await window.api.ssh.connect({ targetId: id }) + return JSON.stringify(state?.managedServer ?? null) + } catch (error) { + return `${step} threw: ${String(error)}` + } + }, + { id: targetId, step } + ) + let timer: ReturnType | undefined + const result = await Promise.race([ + run, + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), RECONNECT_STEP_TIMEOUT_MS) + }) + ]).finally(() => clearTimeout(timer)) + if (result === null) { + const main = await page.evaluate( + async (id) => JSON.stringify(await window.api.ssh.getState({ targetId: id })), + targetId + ) + throw new Error(`ssh ${step} hung; main state ${main}`) + } + if (step === 'connect') { + return result + } + } + return '' +} + +export function managedServer(page: Page, targetId: string): Promise { + return page.evaluate( + (id) => window.__store?.getState().sshConnectionStates.get(id)?.managedServer ?? null, + targetId + ) +} + +function isManaged(server: unknown): boolean { + return ( + typeof server === 'object' && server !== null && 'kind' in server && server.kind === 'managed' + ) +} + +/** The relay-era catalog rows this profile still holds for the host. */ +function sourceRows( + userData: string, + targetId: string +): { repos: number; folderWorkspaces: number } { + const state = readPersistedProfileState(userData) + const count = (rows: unknown): number => + (Array.isArray(rows) ? rows : []).filter((row) => row?.connectionId === targetId).length + return { repos: count(state.repos), folderWorkspaces: count(state.folderWorkspaces) } +} + +export function targetLeases(userData: string, targetId: string): { state?: unknown }[] { + const leases = readPersistedProfileState(userData).sshRemotePtyLeases + return (Array.isArray(leases) ? leases : []).filter((lease) => lease?.targetId === targetId) +} + +async function serverCall( + page: Page, + selector: string, + method: string, + params?: unknown +): Promise { + // Why a long budget: a fresh server's first session inventory restores every migrated tab. + const response = await page.evaluate((args) => window.api.runtimeEnvironments.call(args), { + selector, + method, + params, + timeoutMs: 120_000 + }) + const text = JSON.stringify(response) + expect(response, `${method} on the managed server: ${text.slice(0, 2_000)}`).toMatchObject({ + ok: true + }) + return text +} + +/** A path as it appears inside a JSON response: Windows backslashes arrive escaped. */ +function jsonText(value: string): string { + return JSON.stringify(value).slice(1, -1) +} + +export type ConvertedHost = { + targetId: string + worktreeId: string + repoPath: string + folderPath: string +} + +/** Connects with the template in place, then proves conversion, retention and retirement. */ +export async function convertThenRetire( + page: Page, + userData: string, + host: ConvertedHost, + flagsFile: string +): Promise { + const connected = await reconnect(page, host.targetId) + // Polls the whole state so a timeout reports why the host stayed on the relay. + await expect + .poll( + async () => { + const server = await managedServer(page, host.targetId) + if (isManaged(server)) { + return 'managed' + } + const leases = targetLeases(userData, host.targetId) + const journal = findOrcadMigrationSourceCutoverForTarget(userData, host.targetId) + const mainState = await page.evaluate( + async (id) => (await window.api.ssh.getState({ targetId: id }))?.managedServer ?? null, + host.targetId + ) + return JSON.stringify({ + server, + mainState, + connected, + leases, + journal: journal && { phase: journal.phase, updatedAt: journal.updatedAt } + }) + }, + { timeout: CONVERT_TIMEOUT_MS } + ) + .toBe('managed') + const environments = await page.evaluate(() => window.api.runtimeEnvironments.list()) + const environment = environments.find( + (entry) => entry.orcadDeployment?.sshTargetId === host.targetId + ) + expect(environment, 'a managed server registered for the host').toBeTruthy() + expect(await serverCall(page, environment!.id, 'repo.list')).toContain(jsonText(host.repoPath)) + expect(await serverCall(page, environment!.id, 'folderWorkspace.list')).toContain( + jsonText(host.folderPath) + ) + // Not asserted yet: the server lists no migrated editor tab (see the PR); logged for the fix. + console.log( + `[orcad-convert] server tabs ${await serverCall(page, environment!.id, 'session.tabs.list', { + worktree: `id:${host.worktreeId}` + })} runtime partition ${await page.evaluate( + async (hostId) => JSON.stringify(await window.api.session.get(hostId)), + toRuntimeExecutionHostId(environment!.id) + )}` + ) + + // 3. Source retained for a downgrade, then retired once the rollout flag is on. + expect(findOrcadMigrationSourceCutoverForTarget(userData, host.targetId)).toMatchObject({ + phase: 'destination-committed', + sourceRetainedAt: expect.any(String) + }) + expect(sourceRows(userData, host.targetId)).toEqual({ repos: 1, folderWorkspaces: 1 }) + writeFileSync(flagsFile, JSON.stringify({ 'orcad-source-retirement': { state: 'on' } })) + await reconnect(page, host.targetId) + // Retirement drops the source rows, then compacts the journal away once the server matches it. + await expect + .poll( + () => { + const phase = findOrcadMigrationSourceCutoverForTarget(userData, host.targetId)?.phase + return JSON.stringify({ + // `source-retired` is retirement before compaction; either way the move is finished. + ...(phase && phase !== 'source-retired' ? { journal: phase } : {}), + ...sourceRows(userData, host.targetId) + }) + }, + { timeout: 120_000 } + ) + .toBe(JSON.stringify({ repos: 0, folderWorkspaces: 0 })) + expect(await managedServer(page, host.targetId)).toMatchObject({ kind: 'managed' }) + expect(await serverCall(page, environment!.id, 'repo.list')).toContain(jsonText(host.repoPath)) +} diff --git a/tests/e2e/helpers/orcad-convert-host.ts b/tests/e2e/helpers/orcad-convert-host.ts new file mode 100644 index 000000000000..85bb9d64e58d --- /dev/null +++ b/tests/e2e/helpers/orcad-convert-host.ts @@ -0,0 +1,62 @@ +/** + * The real SSH host a conversion cell runs against: the Linux Docker fixture, or a Windows + * OpenSSH host the ssh-windows-hosts lane provisioned and described in a descriptor file. + */ +import { readFileSync } from 'node:fs' +import type { TestInfo } from '@stablyai/playwright-test' +import type { SshTargetCreateInput } from '../../../src/shared/ssh-types' +import { parseWindowsHostCellDescriptor } from '../../../src/main/ssh/ssh-windows-host-cells' +import { + cleanupDockerSshRelayTarget, + DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + startDockerSshRelayTarget +} from './docker-ssh-relay-target' + +export const ORCAD_CONVERT_HOST_ENV = 'ORCA_E2E_ORCAD_CONVERT_HOST' + +export type OrcadConvertHost = { + input: SshTargetCreateInput + /** A git repository that already exists on the host. */ + remoteRepoPath: string + /** An existing remote directory the cell opens as a folder workspace. */ + remoteFolderPath: string + cleanup: () => void +} + +/** `docker`, or the path of a Windows host-cell descriptor. */ +export function startOrcadConvertHost(source: string, testInfo: TestInfo): OrcadConvertHost { + const label = `orcad convert E2E ${Date.now()}` + if (source === 'docker') { + const target = startDockerSshRelayTarget(testInfo) + return { + input: { + label, + host: target.host, + port: target.port, + username: 'root', + identityFile: target.identityFile, + identitiesOnly: true, + relayGracePeriodSeconds: 1 + }, + remoteRepoPath: DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + remoteFolderPath: '/tmp', + cleanup: () => cleanupDockerSshRelayTarget(target) + } + } + const descriptor = parseWindowsHostCellDescriptor(readFileSync(source, 'utf8')) + return { + input: { + label, + host: descriptor.host, + port: descriptor.port, + username: descriptor.username, + identityFile: descriptor.identityFile, + identitiesOnly: true, + relayGracePeriodSeconds: 1 + }, + // The lane creates this repository as the account before it runs the cell. + remoteRepoPath: `${descriptor.home}/orca-convert-repo`, + remoteFolderPath: descriptor.home, + cleanup: () => {} + } +} diff --git a/tests/e2e/helpers/orcad-upgrade-profile.ts b/tests/e2e/helpers/orcad-upgrade-profile.ts new file mode 100644 index 000000000000..8d66fd9fd968 --- /dev/null +++ b/tests/e2e/helpers/orcad-upgrade-profile.ts @@ -0,0 +1,165 @@ +/** + * The profile a relay-era build (v1.4.218, the last before auto-conversion) leaves behind for an + * SSH host: the target without a fence, its repository and remote folder workspace, and an editor + * tab in the host's session partition. Seeded into a stopped profile, so the next launch is the + * user's first on a converting build. + */ +import type { FolderWorkspace } from '../../../src/shared/folder-workspace-types' +import type { ProjectGroup } from '../../../src/shared/project-group-types' +import type { Repo } from '../../../src/shared/repo-types' +import type { SshTargetCreateInput } from '../../../src/shared/ssh-types' +import { getDefaultWorkspaceSession } from '../../../src/shared/constants' +import { toSshExecutionHostId } from '../../../src/shared/execution-host' +import { normalizeSshTarget } from '../../../src/main/persistence/leasing-ssh-ptys/ssh-normalization' +import { mutateStoppedProfileState } from './persisted-profile-state' + +export type RelayEraProfile = { + targetId: string + worktreeId: string + repoPath: string + folderPath: string + sessionFilePath: string +} + +function pushRow(state: Record, field: string, row: unknown): void { + const rows = state[field] + state[field] = [...(Array.isArray(rows) ? rows : []), row] +} + +function nextGeneration(state: Record): number { + const targets = Array.isArray(state.sshTargets) ? state.sshTargets : [] + const counter = + typeof state.sshTargetGenerationCounter === 'number' ? state.sshTargetGenerationCounter : 0 + return ( + Math.max( + counter, + ...targets.map((target) => (typeof target?.generation === 'number' ? target.generation : 0)) + ) + 1 + ) +} + +export function seedRelayEraProfile( + userDataDir: string, + input: SshTargetCreateInput, + paths: { repoPath: string; folderPath: string } +): RelayEraProfile { + const now = Date.now() + const targetId = `ssh-upgrade-${now}` + const hostId = toSshExecutionHostId(targetId) + const repo: Repo = { + id: `repo-upgrade-${now}`, + path: paths.repoPath, + displayName: 'orcad upgrade E2E', + badgeColor: '#737373', + addedAt: now, + connectionId: targetId + } + const group: ProjectGroup = { + id: `group-upgrade-${now}`, + name: 'orcad upgrade folders', + parentPath: paths.folderPath, + connectionId: targetId, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: now, + updatedAt: now + } + const folder: FolderWorkspace = { + id: `folder-upgrade-${now}`, + projectGroupId: group.id, + name: 'orcad upgrade folder', + folderPath: paths.folderPath, + connectionId: targetId, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: now, + createdAt: now, + updatedAt: now + } + const worktreeId = `${repo.id}::${repo.path}` + const sessionFilePath = `${paths.repoPath}/README.md` + const tabId = `tab-upgrade-${now}` + const groupId = `tab-group-upgrade-${now}` + // The shape the relay-era renderer persisted for one open editor (CI capture of a live profile). + const session = { + ...getDefaultWorkspaceSession(), + openFilesByWorktree: { + [worktreeId]: [ + { + filePath: sessionFilePath, + relativePath: 'README.md', + worktreeId, + language: 'markdown', + runtimeEnvironmentId: null + } + ] + }, + activeFileIdByWorktree: { [worktreeId]: sessionFilePath }, + activeTabTypeByWorktree: { [worktreeId]: 'editor' }, + activeTabIdByWorktree: { [worktreeId]: tabId }, + unifiedTabs: { + [worktreeId]: [ + { + id: tabId, + entityId: sessionFilePath, + groupId, + worktreeId, + executionHostId: hostId, + contentType: 'editor', + label: 'README.md', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: now, + lastFocusedAt: now, + isPreview: false + } + ] + }, + tabGroups: { + [worktreeId]: [ + { id: groupId, worktreeId, activeTabId: tabId, tabOrder: [tabId], recentTabIds: [tabId] } + ] + }, + tabGroupLayouts: { [worktreeId]: { type: 'leaf', groupId } }, + activeGroupIdByWorktree: { [worktreeId]: groupId } + } + mutateStoppedProfileState(userDataDir, (state) => { + // As SshConnectionStore.addTarget registers it: config alias, manual source, fresh generation. + const generation = nextGeneration(state) + state.sshTargetGenerationCounter = generation + pushRow( + state, + 'sshTargets', + normalizeSshTarget({ + ...input, + id: targetId, + configHost: input.host, + source: 'manual', + generation + }) + ) + pushRow(state, 'repos', repo) + pushRow(state, 'projectGroups', group) + pushRow(state, 'folderWorkspaces', folder) + const partitions = state.workspaceSessionsByHostId + state.workspaceSessionsByHostId = { + ...(partitions && typeof partitions === 'object' ? partitions : {}), + [hostId]: session + } + }) + return { + targetId, + worktreeId, + repoPath: paths.repoPath, + folderPath: paths.folderPath, + sessionFilePath + } +} diff --git a/tests/e2e/ssh-orcad-auto-convert.spec.ts b/tests/e2e/ssh-orcad-auto-convert.spec.ts new file mode 100644 index 000000000000..7bc535c8efb8 --- /dev/null +++ b/tests/e2e/ssh-orcad-auto-convert.spec.ts @@ -0,0 +1,242 @@ +/** + * A relay-era SSH host converts to managed orcad on connect (#24979), on a real host: + * + * 1. Without an orcad template the connect keeps the relay, so the host gains relay-era state: a + * repository, a folder workspace, an editor tab, and a relay terminal that has exited. + * 2. With the template in place and no relay terminal running, the next connect converts it, and + * the new server lists that repository and folder (the editor tab is logged, not yet asserted). + * 3. The source rows stay retained (downgrade safety) until `orcad-source-retirement` is on; the + * connect after that retires them while the server keeps serving the host. + * + * Host: `ORCA_E2E_ORCAD_CONVERT_HOST=docker` (Linux fixture) or a Windows host-cell descriptor. + * Template: `ORCA_E2E_ORCAD_CONVERT_TEMPLATE`, built for that host's target. + */ +import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { ElectronApplication } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { + ensureTerminalVisible, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { execInTerminal, waitForActivePanePtyId, waitForTerminalOutput } from './helpers/terminal' +import { connectSshTestTarget } from './helpers/ssh-test-target-connection' +import { createRestartSession } from './helpers/orca-restart' +import { convertThenRetire, managedServer, targetLeases } from './helpers/orcad-convert-flow' +import { seedRelayEraProfile } from './helpers/orcad-upgrade-profile' +import { ORCAD_CONVERT_HOST_ENV, startOrcadConvertHost } from './helpers/orcad-convert-host' +import { toSshExecutionHostId } from '../../src/shared/execution-host' + +const HOST = process.env[ORCAD_CONVERT_HOST_ENV] +const TEMPLATE_SOURCE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE +// Fixed per worker so the app's launch env can name them before the test runs. +const SCRATCH = path.join(os.tmpdir(), `orca-orcad-convert-${process.pid}`) +const TEMPLATE_DIR = path.join(SCRATCH, 'orcad-template') +const FLAGS_FILE = path.join(SCRATCH, 'rollout-flags.json') + +test.use({ + orcaAppExtraEnv: { + ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE_DIR, + ORCA_E2E_ROLLOUT_FLAGS_FILE: FLAGS_FILE + } +}) + +test('a relay host converts to managed orcad on connect, keeps its source, then retires it', async ({ + orcaPage: page, + electronApp +}, testInfo) => { + test.skip( + !HOST || !TEMPLATE_SOURCE, + `Set ${ORCAD_CONVERT_HOST_ENV} and ORCA_E2E_ORCAD_CONVERT_TEMPLATE` + ) + // Why Docker only: a relay era without the template needs host Node, which the Windows lane hides. + test.skip(HOST !== 'docker', 'The runtime relay era runs on the Docker host only') + test.setTimeout(20 * 60_000) + rmSync(SCRATCH, { recursive: true, force: true }) + mkdirSync(SCRATCH, { recursive: true }) + writeFileSync(FLAGS_FILE, '{}') + const host = startOrcadConvertHost(HOST!, testInfo) + try { + const userData = await electronApp.evaluate(({ app }) => app.getPath('userData')) + await waitForSessionReady(page) + const localWorktreeId = await waitForActiveWorktree(page) + + // 1. Relay era: no template, so the connect keeps the relay. + const remote = await connectSshTestTarget(page, host.input, { + remotePath: host.remoteRepoPath, + displayName: 'orcad convert E2E', + seedInitialTab: true + }) + expect(await managedServer(page, remote.targetId)).toMatchObject({ + kind: 'relay', + reason: 'orcad_unavailable' + }) + const folderPath = await page.evaluate( + async ({ targetId, folder }) => { + const group = await window.api.projectGroups.create({ + name: 'orcad convert folders', + parentPath: folder, + connectionId: targetId + }) + const workspace = await window.api.folderWorkspaces.create({ + projectGroupId: group.id, + folderPath: folder, + connectionId: targetId + }) + return workspace.folderPath + }, + { targetId: remote.targetId, folder: host.remoteFolderPath } + ) + await ensureTerminalVisible(page, 45_000) + const ptyId = await waitForActivePanePtyId(page, 60_000) + const marker = `ORCAD-CONVERT-${Date.now()}` + await execInTerminal(page, ptyId, `echo ${marker}`) + await waitForTerminalOutput(page, marker, 30_000) + // The session tab is an editor: every mounted terminal tab runs a shell, and an exited one closes. + const sessionFilePath = `${host.remoteRepoPath}/README.md` + await page.evaluate( + ({ filePath, worktreeId, hostId }) => { + // As a sidebar click does: with its host, so the new tab is stamped as that host's. + window.__store!.getState().setActiveWorktree(worktreeId, hostId) + window.__store!.getState().openFile({ + filePath, + relativePath: 'README.md', + worktreeId, + language: 'markdown', + mode: 'edit' + }) + }, + { + filePath: sessionFilePath, + worktreeId: remote.worktreeId, + hostId: toSshExecutionHostId(remote.targetId) + } + ) + // Off the remote worktree first, so nothing there restarts a shell once this one exits. + await switchToWorktree(page, localWorktreeId) + // An exited shell leaves an exit record, which is what lets the gate prove no terminal runs. + await execInTerminal(page, ptyId, 'exit') + // The connect's terminal gate asks the relay the same question, so a timeout names the blocker. + await expect + .poll( + () => + page.evaluate( + async (connectionId) => + JSON.stringify(await window.api.pty.listSessions({ connectionId })), + remote.targetId + ), + { timeout: 30_000 } + ) + .toBe('[]') + // The gate's other input: no lease may still read as a running terminal. + await expect + .poll( + () => { + const live = targetLeases(userData, remote.targetId).filter( + (lease) => lease.state === 'attached' || lease.state === 'detached' + ) + return JSON.stringify(live) + }, + { timeout: 30_000 } + ) + .toBe('[]') + // An SSH worktree's session lives in its host's partition, not the local one. + await expect + .poll( + () => + page.evaluate( + async ({ hostId, filePath }) => + JSON.stringify(await window.api.session.get(hostId)).includes(filePath), + { hostId: toSshExecutionHostId(remote.targetId), filePath: sessionFilePath } + ), + { timeout: 30_000 } + ) + .toBe(true) + + // 2. Template in place: the next connect converts the host. + cpSync(TEMPLATE_SOURCE!, TEMPLATE_DIR, { recursive: true }) + // A shell that starts after the checks above still blocks the gate; settle, then look again. + await page.waitForTimeout(5_000) + expect( + JSON.stringify({ + sessions: await page.evaluate( + (connectionId) => window.api.pty.listSessions({ connectionId }), + remote.targetId + ), + leases: targetLeases(userData, remote.targetId).filter( + (lease) => lease.state === 'attached' || lease.state === 'detached' + ) + }) + ).toBe(JSON.stringify({ sessions: [], leases: [] })) + await convertThenRetire( + page, + userData, + { + targetId: remote.targetId, + worktreeId: remote.worktreeId, + repoPath: host.remoteRepoPath, + folderPath + }, + FLAGS_FILE + ) + } finally { + host.cleanup() + if (existsSync(SCRATCH)) { + rmSync(SCRATCH, { recursive: true, force: true }) + } + } +}) + +test('a relay-era profile converts its SSH host on the first connect after upgrading', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. +{}, testInfo) => { + test.skip( + !HOST || !TEMPLATE_SOURCE, + `Set ${ORCAD_CONVERT_HOST_ENV} and ORCA_E2E_ORCAD_CONVERT_TEMPLATE` + ) + test.setTimeout(20 * 60_000) + rmSync(SCRATCH, { recursive: true, force: true }) + mkdirSync(SCRATCH, { recursive: true }) + writeFileSync(FLAGS_FILE, '{}') + const host = startOrcadConvertHost(HOST!, testInfo) + const session = createRestartSession(testInfo, { + ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE_SOURCE!, + ORCA_E2E_ROLLOUT_FLAGS_FILE: FLAGS_FILE + }) + let app: ElectronApplication | null = null + try { + // The first launch only establishes the profile the relay-era rows are written into. + const first = await session.launch() + app = first.app + await waitForSessionReady(first.page) + await session.close(app) + app = null + const seeded = seedRelayEraProfile(session.userDataDir, host.input, { + repoPath: host.remoteRepoPath, + folderPath: host.remoteFolderPath + }) + // No relay ever ran here, so the terminal gate must prove `exited` from an empty lease set. + // This launch bypasses the fixture's log relay; keep the SSH lines a failed move explains itself by. + const upgraded = await session.launch({ + onStderr: (chunk) => { + if (chunk.includes('[ssh]')) { + process.stderr.write(chunk) + } + } + }) + app = upgraded.app + await waitForSessionReady(upgraded.page) + await convertThenRetire(upgraded.page, session.userDataDir, seeded, FLAGS_FILE) + } finally { + if (app) { + await session.close(app) + } + await session.dispose() + host.cleanup() + if (existsSync(SCRATCH)) { + rmSync(SCRATCH, { recursive: true, force: true }) + } + } +})