diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 73e294a1633..c3149f437ce 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -360,7 +360,8 @@ jobs: . != "tests/e2e/ssh-orcad-markdown-live-documents.spec.ts" and . != "tests/e2e/ssh-orcad-open-in-owner.spec.ts" and . != "tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts" and - . != "tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts" + . != "tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts" and + . != "tests/e2e/ssh-orcad-explorer-selected-host.spec.ts" )' <<<"$TEST_FILES_JSON" > "$RUNNER_TEMP/general-e2e-specs" fi mapfile -t TEST_FILES < "$RUNNER_TEMP/general-e2e-specs" @@ -594,7 +595,7 @@ jobs: orcad-auto-convert-docker: name: ssh host auto-converts to managed orcad (Docker) needs: [build, prepare-native-cache] - if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-auto-convert.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-idle-exit.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-capabilities.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-service-status.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-routing.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-editor-ownership.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-conversion.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-live-documents.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-open-in-owner.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts') + if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-auto-convert.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-idle-exit.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-capabilities.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-service-status.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-routing.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-editor-ownership.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-conversion.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-live-documents.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-open-in-owner.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-explorer-selected-host.spec.ts') runs-on: ubuntu-latest timeout-minutes: 45 env: @@ -630,7 +631,7 @@ jobs: ORCA_RELAY_PATH: ${{ github.workspace }}/out/relay run: | export ORCA_E2E_ORCAD_CONVERT_TEMPLATE="$RUNNER_TEMP/orcad-convert-template" - xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad-auto-convert.spec.ts tests/e2e/ssh-orcad-idle-exit.spec.ts tests/e2e/ssh-orcad-browser-capabilities.spec.ts tests/e2e/ssh-orcad-browser-service-status.spec.ts tests/e2e/ssh-orcad-browser-routing.spec.ts tests/e2e/ssh-orcad-editor-ownership.spec.ts tests/e2e/ssh-orcad-markdown-conversion.spec.ts tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts tests/e2e/ssh-orcad-markdown-live-documents.spec.ts tests/e2e/ssh-orcad-open-in-owner.spec.ts tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts --project=electron-headless --workers=1 + xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad-auto-convert.spec.ts tests/e2e/ssh-orcad-idle-exit.spec.ts tests/e2e/ssh-orcad-browser-capabilities.spec.ts tests/e2e/ssh-orcad-browser-service-status.spec.ts tests/e2e/ssh-orcad-browser-routing.spec.ts tests/e2e/ssh-orcad-editor-ownership.spec.ts tests/e2e/ssh-orcad-markdown-conversion.spec.ts tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts tests/e2e/ssh-orcad-markdown-live-documents.spec.ts tests/e2e/ssh-orcad-open-in-owner.spec.ts tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts tests/e2e/ssh-orcad-explorer-selected-host.spec.ts --project=electron-headless --workers=1 - uses: actions/upload-artifact@v7 if: failure() with: diff --git a/config/scripts/ci-e2e-job-selection.mjs b/config/scripts/ci-e2e-job-selection.mjs index 8fd7fb19dd2..db6e669f035 100644 --- a/config/scripts/ci-e2e-job-selection.mjs +++ b/config/scripts/ci-e2e-job-selection.mjs @@ -67,6 +67,8 @@ export const ORCAD_EDITOR_WATCH_RECOVERY_E2E_SPEC = 'tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts' export const ORCAD_EXPLORER_WATCH_RECOVERY_E2E_SPEC = 'tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts' +export const ORCAD_EXPLORER_SELECTED_HOST_E2E_SPEC = + 'tests/e2e/ssh-orcad-explorer-selected-host.spec.ts' export const DEDICATED_E2E_SPECS = [ ...DOCKER_SSH_E2E_SPECS, NODE_NETWORK_E2E_SPEC, @@ -86,7 +88,8 @@ export const DEDICATED_E2E_SPECS = [ ORCAD_MARKDOWN_LIVE_DOCUMENTS_E2E_SPEC, ORCAD_OPEN_IN_OWNER_E2E_SPEC, ORCAD_EDITOR_WATCH_RECOVERY_E2E_SPEC, - ORCAD_EXPLORER_WATCH_RECOVERY_E2E_SPEC + ORCAD_EXPLORER_WATCH_RECOVERY_E2E_SPEC, + ORCAD_EXPLORER_SELECTED_HOST_E2E_SPEC ] const dedicatedSpecs = new Set(DEDICATED_E2E_SPECS) const dockerSpecs = new Set(DOCKER_SSH_E2E_SPECS) @@ -138,7 +141,8 @@ export function classifyE2eJobs(input, sshSourceChanged = 'false') { spec === ORCAD_MARKDOWN_LIVE_DOCUMENTS_E2E_SPEC || spec === ORCAD_OPEN_IN_OWNER_E2E_SPEC || spec === ORCAD_EDITOR_WATCH_RECOVERY_E2E_SPEC || - spec === ORCAD_EXPLORER_WATCH_RECOVERY_E2E_SPEC + spec === ORCAD_EXPLORER_WATCH_RECOVERY_E2E_SPEC || + spec === ORCAD_EXPLORER_SELECTED_HOST_E2E_SPEC ) } } diff --git a/config/scripts/ci-e2e-job-selection.test.mjs b/config/scripts/ci-e2e-job-selection.test.mjs index e75299156cf..1d503b155a6 100644 --- a/config/scripts/ci-e2e-job-selection.test.mjs +++ b/config/scripts/ci-e2e-job-selection.test.mjs @@ -266,3 +266,17 @@ it.each([ spec ) }) + +it.each([ + 'src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts', + 'src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts' +])('routes %s to the template-building selected-host explorer lane', (file) => { + const spec = 'tests/e2e/ssh-orcad-explorer-selected-host.spec.ts' + expect(selectPrE2eSpecs([file])).toContain(spec) + expect(classify([spec])).toEqual({ e2e_run_changed: false, e2e_needs_build: true }) + const job = workflow.jobs['orcad-auto-convert-docker'] + expect(job.if).toContain(spec) + expect(job.steps.find((step) => step.name === 'Convert a relay-era Docker host').run).toContain( + spec + ) +}) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 06d3515c265..9752ed174ad 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -67,6 +67,14 @@ export const PR_E2E_SOURCE_ROUTES = [ file ) }, + { + id: 'ssh.orcad-explorer-selected-host', + specs: ['tests/e2e/ssh-orcad-explorer-selected-host.spec.ts'], + matches: (file) => + isProductSource(file) && + (file === 'src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts' || + file === 'src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts') + }, { id: 'serve.orcad-mode-switch', specs: ['tests/e2e/orcad-serve-mode-switch.spec.ts'], diff --git a/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts b/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts index 2f629776450..06422959001 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts @@ -38,7 +38,8 @@ export type FileExplorerOwnerState = Pick< | 'folderWorkspaces' | 'projectGroups' | 'restoredRuntimeHostIdByWorkspaceSessionKey' -> +> & + Partial> export function getFileExplorerOperationOwnerFromState( state: FileExplorerOwnerState, diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-selected-host.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-selected-host.test.ts new file mode 100644 index 00000000000..8d36116efa8 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-selected-host.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import { makeFolderWorkspace, makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' +import { getDefaultSettings } from '../../../../shared/constants' +import { getFileExplorerWatchRuntimeEnvironmentId } from './useFileExplorerWatch' +import type { FileExplorerOperationOwner } from './file-explorer-types' + +type WatchState = Parameters[0] & + Pick + +const hosts = ['host-a', 'host-b'] as const +function makeState(workspace: 'worktree' | 'folder', selected: (typeof hosts)[number]): WatchState { + const id = workspace === 'folder' ? 'folder:same-folder' : 'same-worktree' + return { + settings: getDefaultSettings('/home/me'), + activeWorktreeId: id, + activeWorkspaceExecutionHostId: `runtime:${selected}`, + repos: [], + worktreesByRepo: + workspace === 'worktree' + ? { + repo: hosts.map((host) => + makeWorktree({ id, repoId: 'repo', path: '/repo', hostId: `runtime:${host}` }) + ) + } + : {}, + folderWorkspaces: + workspace === 'folder' + ? hosts.map((host) => + makeFolderWorkspace({ + id: 'same-folder', + folderPath: '/folder', + executionHostId: `runtime:${host}` + }) + ) + : [], + projectGroups: [] + } +} +function owner(environmentId: string): FileExplorerOperationOwner { + return { kind: 'runtime', environmentId, executionHostId: `runtime:${environmentId}` } +} + +describe('explorer watcher preserving the selected host', () => { + it.each(['worktree', 'folder'] as const)( + 'resolves the selected %s owner when IDs repeat across hosts', + (workspace) => { + for (const host of hosts) { + const state = makeState(workspace, host) + expect( + getFileExplorerWatchRuntimeEnvironmentId(state, state.activeWorktreeId, owner(host)) + ).toBe(host) + } + } + ) + + it.each(['worktree', 'folder'] as const)( + 'rejects the previous %s listing owner after host selection changes', + (workspace) => { + const state = makeState(workspace, 'host-b') + expect( + getFileExplorerWatchRuntimeEnvironmentId(state, state.activeWorktreeId, owner('host-a')) + ).toBeUndefined() + expect( + getFileExplorerWatchRuntimeEnvironmentId(state, state.activeWorktreeId, owner('host-b')) + ).toBe('host-b') + } + ) + + it('does not apply the selected host to a different workspace', () => { + const state = makeState('worktree', 'host-b') + state.activeWorktreeId = 'different-worktree' + expect( + getFileExplorerWatchRuntimeEnvironmentId(state, 'same-worktree', owner('host-b')) + ).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts index b44077f0df5..adf933daf46 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts @@ -51,6 +51,8 @@ export function getFileExplorerWatchRuntimeEnvironmentId( expectedOwner?: FileExplorerOperationOwner ): string | null | undefined { const ownerState: FileExplorerOwnerState = { + activeWorktreeId: state.activeWorktreeId, + activeWorkspaceExecutionHostId: state.activeWorkspaceExecutionHostId, settings: state.settings, repos: state.repos, worktreesByRepo: state.worktreesByRepo, diff --git a/tests/e2e/ssh-orcad-explorer-selected-host.spec.ts b/tests/e2e/ssh-orcad-explorer-selected-host.spec.ts new file mode 100644 index 00000000000..a6cb41d16fc --- /dev/null +++ b/tests/e2e/ssh-orcad-explorer-selected-host.spec.ts @@ -0,0 +1,207 @@ +import type { ElectronApplication } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { createRestartSession } from './helpers/orca-restart' +import { startOrcadConvertHost } from './helpers/orcad-convert-host' +import { seedRelayEraProfile, seedRelayEraTarget } from './helpers/orcad-upgrade-profile' +import { mutateStoppedProfileState } from './helpers/persisted-profile-state' +import { convertAndRetain, reconnect, serverCall } from './helpers/orcad-convert-flow' +import { waitForSessionReady, waitForStartupWorktreeRefresh } from './helpers/store' +import { dismissTransientAnnouncement } from './helpers/ssh-config-host-picker' +import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' +import { folderWorkspaceKey } from '../../src/shared/workspace-scope' + +const TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE +test.skip(!TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', 'Needs Docker and server template') + +for (const selectedHost of ['A', 'B'] as const) { + test(`same-ID folder explorer follows selected host ${selectedHost}`, async (// oxlint-disable-next-line no-empty-pattern -- This exercise owns its app launch. + {}, testInfo) => { + test.setTimeout(5 * 60_000) + const hostA = startOrcadConvertHost('docker', testInfo) + const hostB = startOrcadConvertHost('docker', testInfo) + const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE! }) + const otherSession = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE! }) + let app: ElectronApplication | null = null + let otherApp: ElectronApplication | null = null + try { + if (!hostA.exec || !hostB.exec) { + throw new Error('Docker controls unavailable') + } + const folderPath = '/tmp/orca-same-id-folder' + hostA.exec(`mkdir -p '${folderPath}' && printf 'initial A\n' > '${folderPath}/INITIAL_A.txt'`) + hostB.exec(`mkdir -p '${folderPath}' && printf 'initial B\n' > '${folderPath}/INITIAL_B.txt'`) + const first = await session.launch() + app = first.app + await waitForSessionReady(first.page) + await session.close(app) + app = null + const seededA = seedRelayEraProfile(session.userDataDir, hostA.input, { + repoPath: hostA.remoteRepoPath, + folderPath + }) + const initialOther = await otherSession.launch() + otherApp = initialOther.app + await waitForSessionReady(initialOther.page) + await otherSession.close(otherApp) + otherApp = null + const seededB = seedRelayEraProfile(otherSession.userDataDir, hostB.input, { + repoPath: hostB.remoteRepoPath, + folderPath + }) + const folderId = mutateStoppedProfileState(session.userDataDir, (state) => { + if (!Array.isArray(state.folderWorkspaces)) { + throw new Error('No folder fixture') + } + const folder = state.folderWorkspaces.find( + (entry) => entry.connectionId === seededA.targetId + ) + if (!folder || typeof folder.id !== 'string') { + throw new Error('Missing folder owner') + } + folder.name = 'Folder host A' + return folder.id + }) + mutateStoppedProfileState(otherSession.userDataDir, (state) => { + if (!Array.isArray(state.folderWorkspaces)) { + throw new Error('No folder fixture') + } + const folder = state.folderWorkspaces.find( + (entry) => entry.connectionId === seededB.targetId + ) + if (!folder) { + throw new Error('Missing other folder owner') + } + folder.id = folderId + folder.name = 'Folder host B' + }) + const launched = await session.launch() + app = launched.app + await waitForSessionReady(launched.page) + await convertAndRetain(launched.page, session.userDataDir, seededA) + await session.close(app) + app = null + const other = await otherSession.launch() + otherApp = other.app + await waitForSessionReady(other.page) + await convertAndRetain(other.page, otherSession.userDataDir, seededB) + await otherSession.close(otherApp) + otherApp = null + const adoptedTargetId = seedRelayEraTarget(session.userDataDir, hostB.input) + const restarted = await session.launch() + app = restarted.app + const page = restarted.page + page.on('console', (message) => { + if (message.text().startsWith('[file-change-event]') || message.type() === 'error') { + console.log('[renderer]', message.text()) + } + }) + await waitForSessionReady(page) + console.log('[adopt-existing-managed-host]', await reconnect(page, adoptedTargetId)) + const environments = await page.evaluate(() => window.api.runtimeEnvironments.list()) + const environmentA = environments.find( + (entry) => entry.orcadDeployment?.sshTargetId === seededA.targetId + ) + const environmentB = environments.find( + (entry) => entry.orcadDeployment?.sshTargetId === adoptedTargetId + ) + if (!environmentA || !environmentB) { + throw new Error('Missing converted host') + } + console.log( + '[actual-folder-owners]', + await serverCall(page, environmentA.id, 'folderWorkspace.list'), + await serverCall(page, environmentB.id, 'folderWorkspace.list') + ) + await waitForStartupWorktreeRefresh(page) + await expect + .poll(() => + page.evaluate( + (id) => + window.__store?.getState().folderWorkspaces.filter((folder) => folder.id === id) + .length, + folderId + ) + ) + .toBe(2) + await dismissTransientAnnouncement(page) + await page.evaluate(() => { + window.addEventListener('orca:worktree-file-change', (event) => { + if (event instanceof CustomEvent) { + console.log('[file-change-event]', JSON.stringify(event.detail)) + } + }) + }) + const worktreeId = folderWorkspaceKey(folderId) + const [index, environment, host, marker] = + selectedHost === 'A' + ? ([0, environmentA, hostA, 'HOST_A_READY.txt'] as const) + : ([1, environmentB, hostB, 'HOST_B_READY.txt'] as const) + await page.evaluate( + ({ id, hostId }) => window.__store?.getState().setActiveFolderWorkspace(id, hostId), + { + id: folderId, + hostId: toRuntimeExecutionHostId(environment.id) + } + ) + await expect + .poll(() => page.evaluate(() => window.__store?.getState().activeWorkspaceExecutionHostId)) + .toBe(toRuntimeExecutionHostId(environment.id)) + await expect(page.getByText(`INITIAL_${selectedHost}.txt`, { exact: true })).toBeVisible() + await expect( + page.getByText(`INITIAL_${selectedHost === 'A' ? 'B' : 'A'}.txt`, { exact: true }) + ).toHaveCount(0) + console.log( + '[selected-folder-host]', + index, + await page.evaluate(() => { + const state = window.__store?.getState() + return { + id: state?.activeWorktreeId, + host: state?.activeWorkspaceExecutionHostId, + settingsHost: state?.settings?.activeRuntimeEnvironmentId, + files: state?.openFiles.map((file) => ({ + id: file.id, + owner: file.runtimeEnvironmentId + })) + } + }) + ) + const changed = page.waitForEvent('console', { + predicate: (message) => + message.text().startsWith('[file-change-event]') && + message.text().includes(marker) && + message.text().includes(environment.id), + timeout: 15_000 + }) + host.exec?.(`printf 'host ${index}\n' > '${folderPath}/${marker}'`) + console.log( + '[positive-host-file]', + await serverCall(page, environment.id, 'files.read', { + worktree: `id:${worktreeId}`, + relativePath: marker + }) + ) + await changed + await page.screenshot({ path: testInfo.outputPath('selected-host-explorer-observed.png') }) + await expect(page.getByText(marker, { exact: true })).toBeVisible({ timeout: 10_000 }) + await page.screenshot({ path: testInfo.outputPath('selected-host-explorer-refreshed.png') }) + const otherHost = selectedHost === 'A' ? hostB : hostA + otherHost.exec?.(`printf 'other host\n' > '${folderPath}/OTHER_HOST_ONLY.txt'`) + await expect(page.getByText('OTHER_HOST_ONLY.txt', { exact: true })).toHaveCount(0) + host.exec?.(`rm '${folderPath}/${marker}'`) + await expect(page.getByText(marker, { exact: true })).toHaveCount(0, { timeout: 10_000 }) + await page.screenshot({ path: testInfo.outputPath(`host-${index}-folder-watch.png`) }) + } finally { + if (app) { + await session.close(app) + } + if (otherApp) { + await otherSession.close(otherApp) + } + await session.dispose() + await otherSession.dispose() + hostA.cleanup() + hostB.cleanup() + } + }) +}