diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index c3149f437ce..a8650e0f795 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -361,7 +361,8 @@ jobs: . != "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" and - . != "tests/e2e/ssh-orcad-explorer-selected-host.spec.ts" + . != "tests/e2e/ssh-orcad-explorer-selected-host.spec.ts" and + . != "tests/e2e/ssh-orcad-browser-drop-owner.spec.ts" )' <<<"$TEST_FILES_JSON" > "$RUNNER_TEMP/general-e2e-specs" fi mapfile -t TEST_FILES < "$RUNNER_TEMP/general-e2e-specs" @@ -595,7 +596,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') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-explorer-selected-host.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') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-drop-owner.spec.ts') runs-on: ubuntu-latest timeout-minutes: 45 env: @@ -631,7 +632,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 tests/e2e/ssh-orcad-explorer-selected-host.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 tests/e2e/ssh-orcad-browser-drop-owner.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 db6e669f035..b5aa63249b5 100644 --- a/config/scripts/ci-e2e-job-selection.mjs +++ b/config/scripts/ci-e2e-job-selection.mjs @@ -62,6 +62,7 @@ export const ORCAD_MARKDOWN_LINK_REFRESH_E2E_SPEC = 'tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts' export const ORCAD_MARKDOWN_LIVE_DOCUMENTS_E2E_SPEC = 'tests/e2e/ssh-orcad-markdown-live-documents.spec.ts' +export const ORCAD_BROWSER_DROP_OWNER_E2E_SPEC = 'tests/e2e/ssh-orcad-browser-drop-owner.spec.ts' export const ORCAD_OPEN_IN_OWNER_E2E_SPEC = 'tests/e2e/ssh-orcad-open-in-owner.spec.ts' export const ORCAD_EDITOR_WATCH_RECOVERY_E2E_SPEC = 'tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts' @@ -89,7 +90,8 @@ export const DEDICATED_E2E_SPECS = [ ORCAD_OPEN_IN_OWNER_E2E_SPEC, ORCAD_EDITOR_WATCH_RECOVERY_E2E_SPEC, ORCAD_EXPLORER_WATCH_RECOVERY_E2E_SPEC, - ORCAD_EXPLORER_SELECTED_HOST_E2E_SPEC + ORCAD_EXPLORER_SELECTED_HOST_E2E_SPEC, + ORCAD_BROWSER_DROP_OWNER_E2E_SPEC ] const dedicatedSpecs = new Set(DEDICATED_E2E_SPECS) const dockerSpecs = new Set(DOCKER_SSH_E2E_SPECS) @@ -142,7 +144,8 @@ export function classifyE2eJobs(input, sshSourceChanged = 'false') { spec === ORCAD_OPEN_IN_OWNER_E2E_SPEC || spec === ORCAD_EDITOR_WATCH_RECOVERY_E2E_SPEC || spec === ORCAD_EXPLORER_WATCH_RECOVERY_E2E_SPEC || - spec === ORCAD_EXPLORER_SELECTED_HOST_E2E_SPEC + spec === ORCAD_EXPLORER_SELECTED_HOST_E2E_SPEC || + spec === ORCAD_BROWSER_DROP_OWNER_E2E_SPEC ) } } diff --git a/config/scripts/ci-e2e-job-selection.test.mjs b/config/scripts/ci-e2e-job-selection.test.mjs index 1d503b155a6..4c9bf565aae 100644 --- a/config/scripts/ci-e2e-job-selection.test.mjs +++ b/config/scripts/ci-e2e-job-selection.test.mjs @@ -47,6 +47,25 @@ const workflow = parse(readFileSync('.github/workflows/e2e.yml', 'utf8')) const prWorkflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) const classify = (specs, ssh = 'false') => classifyE2eJobs(JSON.stringify(specs), ssh) +it('runs real browser file-drop ownership in the template-building Docker job', () => { + const spec = 'tests/e2e/ssh-orcad-browser-drop-owner.spec.ts' + 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.some((step) => step.run?.includes(spec) && step.env?.ORCA_E2E_SSH_DOCKER === '1') + ).toBe(true) + for (const source of [ + 'src/renderer/src/lib/file-preview.ts', + 'src/renderer/src/lib/workspace-file-drag.ts', + 'src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.ts', + 'src/renderer/src/components/right-sidebar/FileExplorerRow.tsx', + 'tests/e2e/helpers/browser-split-guest-probes.ts' + ]) { + expect(selectPrE2eSpecs([source]), source).toContain(spec) + } +}) + it('skips the general consumer only when every requested spec has a dedicated owner', () => { for (const spec of DEDICATED_E2E_SPECS) { expect(classify([spec]).e2e_run_changed, spec).toBe(false) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 9752ed174ad..c7ba2309bf3 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -134,6 +134,18 @@ export const PR_E2E_SOURCE_ROUTES = [ file ) }, + { + id: 'ssh.orcad-browser-drop-owner', + specs: ['tests/e2e/ssh-orcad-browser-drop-owner.spec.ts'], + matches: (file) => + /^tests\/e2e\/helpers\/(?:orcad-convert-(?:flow|host)|orcad-upgrade-profile|docker-ssh-relay-target|browser-split-(?:guest-probes|page-server))\.ts$/.test( + file + ) || + (isProductSource(file) && + /^src\/renderer\/src\/(?:lib\/(?:file-preview|workspace-file-drag|worktree-runtime-owner|connection-context)\.ts|components\/(?:right-sidebar\/FileExplorer(?:Row|VirtualRows)\.tsx|browser-pane\/(?:navigate\/use-browser-page-navigation-downloads\.ts|host-guest\/attach-browser-page-webview\.ts)))$/.test( + file + )) + }, { id: 'ssh.orcad-open-in-owner', specs: ['tests/e2e/ssh-orcad-open-in-owner.spec.ts'], diff --git a/src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.file-drop.test.tsx b/src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.file-drop.test.tsx new file mode 100644 index 00000000000..0f097a82c08 --- /dev/null +++ b/src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.file-drop.test.tsx @@ -0,0 +1,108 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useBrowserPageNavigationDownloads } from './use-browser-page-navigation-downloads' +import { + WORKSPACE_FILE_DRAG_SOURCE_MIME, + WORKSPACE_FILE_PATH_MIME +} from '@/lib/workspace-file-drag' + +const mocks = vi.hoisted(() => ({ notice: vi.fn(), target: vi.fn(), navigate: vi.fn() })) +vi.mock('@/lib/file-preview', () => ({ + REMOTE_FILE_BROWSER_UNSUPPORTED_MESSAGE: + 'Open in Orca Browser is only available for local files.', + getWorkspaceFileBrowserOpenTarget: mocks.target +})) +vi.mock('./navigate-browser-page-url', () => ({ navigateBrowserPageToUrl: mocks.navigate })) +vi.mock('./workspace-doc-address-submission', () => ({ + routeWorkspaceDocAddressSubmission: vi.fn() +})) +vi.mock('./use-browser-page-download-events', () => ({ + useBrowserPageDownloadEvents: () => ({ downloadStates: [], setDownloadStates: vi.fn() }) +})) + +function DropSurface() { + const nav = useBrowserPageNavigationDownloads({ + browserTabId: 'page-local', + worktreeId: 'global-floating-terminal', + webviewRef: { current: null }, + activeLoadFailureRef: { current: null }, + lastKnownWebviewUrlRef: { current: null }, + trackNextLoadingEventRef: { current: false }, + recoveryNavigationValidationRef: { current: null }, + onSetUrlRef: { current: vi.fn() }, + onUpdatePageStateRef: { current: vi.fn() }, + keepAddressBarFocusRef: { current: false }, + focusWebviewNow: vi.fn(() => false), + setResourceNotice: mocks.notice, + addressBarValueRef: { current: 'about:blank' }, + addressBarInputRef: { current: null }, + browserTabUrl: 'about:blank' + }) + return
nav.handleInternalFileDropRef.current(event)} /> +} + +function dropPayload(source: string | null) { + const values = new Map([[WORKSPACE_FILE_PATH_MIME, '/same/path/report.html']]) + if (source !== null) { + values.set(WORKSPACE_FILE_DRAG_SOURCE_MIME, source) + } + return { types: [...values.keys()], getData: (type: string) => values.get(type) ?? '' } +} + +describe('browser file drops preserve source ownership', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.target.mockReturnValue({ status: 'ready', url: 'file:///same/path/report.html' }) + }) + afterEach(cleanup) + + it.each(['runtime:host-a', 'ssh:host-a', 'runtime:unresolved-owner'])( + 'rejects a %s source before local URL resolution', + (executionHostId) => { + const view = render() + fireEvent.drop(view.getByTestId('drop'), { + dataTransfer: dropPayload( + JSON.stringify({ + version: 1, + executionHostId, + workspaceId: 'source-workspace' + }) + ) + }) + expect(mocks.target).not.toHaveBeenCalled() + expect(mocks.navigate).not.toHaveBeenCalled() + expect(mocks.notice).toHaveBeenCalledWith( + 'Open in Orca Browser is only available for local files.' + ) + } + ) + + it.each([null, '{broken', '{"version":1,"executionHostId":"local"}'])( + 'rejects an unresolved source %s', + (source) => { + const view = render() + fireEvent.drop(view.getByTestId('drop'), { dataTransfer: dropPayload(source) }) + expect(mocks.target).not.toHaveBeenCalled() + expect(mocks.navigate).not.toHaveBeenCalled() + } + ) + + it('accepts a desktop source from another workspace into the floating browser', () => { + const view = render() + fireEvent.drop(view.getByTestId('drop'), { + dataTransfer: dropPayload( + JSON.stringify({ + version: 1, + executionHostId: 'local', + workspaceId: 'source-workspace' + }) + ) + }) + expect(mocks.target).toHaveBeenCalledWith({ + filePath: '/same/path/report.html', + worktreeId: 'global-floating-terminal' + }) + expect(mocks.notice).toHaveBeenCalledWith('Browser page is not ready for file drops.') + }) +}) diff --git a/src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.ts b/src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.ts index 9d8f33f67de..6b2fa260ecc 100644 --- a/src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.ts +++ b/src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.ts @@ -10,11 +10,15 @@ import { type SetStateAction } from 'react' import { translate } from '@/i18n/i18n' -import { getWorkspaceFileBrowserOpenTarget } from '@/lib/file-preview' +import { + getWorkspaceFileBrowserOpenTarget, + REMOTE_FILE_BROWSER_UNSUPPORTED_MESSAGE +} from '@/lib/file-preview' import { routeWorkspaceDocAddressSubmission } from './workspace-doc-address-submission' import { getWorkspaceFileDragRejectionMessage, readWorkspaceFileDragPaths, + readWorkspaceFileDragSource, WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' import type { BrowserLoadError } from '../../../../../shared/browser-workspace-types' @@ -173,6 +177,13 @@ export function useBrowserPageNavigationDownloads({ return } + // A file URL reads this machine, regardless of which workspace owns the browser. + const source = readWorkspaceFileDragSource(event.dataTransfer) + if (source?.executionHostId !== 'local') { + setResourceNotice(REMOTE_FILE_BROWSER_UNSUPPORTED_MESSAGE) + return + } + const target = getWorkspaceFileBrowserOpenTarget({ filePath, worktreeId }) if (target.status === 'unsupported') { setResourceNotice(target.message) diff --git a/src/renderer/src/lib/file-preview.test.ts b/src/renderer/src/lib/file-preview.test.ts index 4c770c85d9e..c6a58edec22 100644 --- a/src/renderer/src/lib/file-preview.test.ts +++ b/src/renderer/src/lib/file-preview.test.ts @@ -481,6 +481,20 @@ describe('getWorkspaceFileBrowserOpenTarget', () => { message: REMOTE_FILE_BROWSER_UNSUPPORTED_MESSAGE }) }) + + it('refuses paired files even when their connection ID is null', () => { + mocks.environmentId = 'managed-host' + expect( + getWorkspaceFileBrowserOpenTarget({ + filePath: '/srv/repo/report.html', + worktreeId: 'wt-1' + }) + ).toEqual({ + status: 'unsupported', + reason: 'remote-worktree', + message: REMOTE_FILE_BROWSER_UNSUPPORTED_MESSAGE + }) + }) }) // Why the reuse case is pinned here too: the address bar's way into a document must obey the same diff --git a/src/renderer/src/lib/file-preview.ts b/src/renderer/src/lib/file-preview.ts index 7094d87165c..cb36ac814ad 100644 --- a/src/renderer/src/lib/file-preview.ts +++ b/src/renderer/src/lib/file-preview.ts @@ -147,7 +147,10 @@ export function getWorkspaceFileBrowserOpenTarget(params: { filePath: string worktreeId: string }): WorkspaceFileBrowserOpenTarget { - if (getConnectionIdForFile(params.worktreeId, params.filePath) !== null) { + if ( + getConnectionIdForFile(params.worktreeId, params.filePath) !== null || + getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), params.worktreeId) + ) { // Why: Chromium resolves file:// URLs on the local machine. Remote files // need an Orca-served URL before the browser can render them correctly. return { diff --git a/tests/e2e/ssh-orcad-browser-drop-owner.spec.ts b/tests/e2e/ssh-orcad-browser-drop-owner.spec.ts new file mode 100644 index 00000000000..471646d634d --- /dev/null +++ b/tests/e2e/ssh-orcad-browser-drop-owner.spec.ts @@ -0,0 +1,204 @@ +import { writeFileSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication, Page } 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 } from './helpers/orcad-upgrade-profile' +import { convertAndRetain, serverCall } from './helpers/orcad-convert-flow' +import { waitForSessionReady } from './helpers/store' +import { dismissTransientAnnouncement } from './helpers/ssh-config-host-picker' +import { openFileExplorer } from './helpers/file-explorer' +import { shellQuote } from './helpers/docker-ssh-relay-target' +import { startBrowserSplitPageServer } from './helpers/browser-split-page-server' +import { + navigateGuest, + waitForGuestUrl, + waitForGuestIdle +} from './helpers/browser-split-guest-probes' +import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' +import { getWorktreeHostIdentity } from '../../src/shared/worktree/host-qualified-identity' + +const TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE +test.skip(!TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', 'Needs owned Docker host') +test.skip(process.platform === 'win32', 'Owned POSIX host collision fixture') + +async function dropExplorerFile(page: Page, name: string, tabId: string) { + const row = page.locator('[data-file-explorer-row]').filter({ hasText: name }) + await expect(row).toBeVisible() + return row.evaluate((element, browserTabId) => { + const webview = document.querySelector( + `[data-browser-overlay-tab-id="${browserTabId}"] webview` + ) + if (!webview || !webview.parentElement) { + throw new Error('Missing actual guest container') + } + const dataTransfer = new DataTransfer() + element.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer })) + const payload = Object.fromEntries( + [...dataTransfer.types].map((type) => [type, dataTransfer.getData(type)]) + ) + const rect = webview.getBoundingClientRect() + if (rect.width < 1 || rect.height < 1) { + throw new Error('Actual guest has no drop surface') + } + webview.parentElement.dispatchEvent( + new DragEvent('drop', { + bubbles: true, + cancelable: true, + dataTransfer, + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2 + }) + ) + element.dispatchEvent(new DragEvent('dragend', { bubbles: true, dataTransfer })) + return payload + }, tabId) +} + +async function guestText(page: Page, tabId: string) { + return page.evaluate(async (id) => { + const webview = document.querySelector( + `[data-browser-overlay-tab-id="${id}"] webview` + ) + if (!webview) { + throw new Error('Missing actual guest') + } + return { + url: webview.getURL(), + text: await webview.executeJavaScript('document.body.innerText') + } + }, tabId) +} + +test('managed Files drag cannot load a desktop same-path HTML file', async ({ + testRepoPath +}, testInfo) => { + test.setTimeout(4 * 60_000) + const fileName = 'DROP_OWNER.html' + const filePath = path.join(testRepoPath, fileName) + writeFileSync(filePath, 'DESKTOP_BROWSER_DROP_OWNER') + const host = startOrcadConvertHost('docker', testInfo) + const server = await startBrowserSplitPageServer() + const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE! }) + let app: ElectronApplication | null = null + try { + if (!host.exec) { + throw new Error('Missing owned host control') + } + host.exec( + `mkdir -p ${shellQuote(path.dirname(testRepoPath))} && git clone --quiet ${shellQuote(host.remoteRepoPath)} ${shellQuote(testRepoPath)} && printf '%s' 'REMOTE_BROWSER_DROP_OWNER' > ${shellQuote(filePath)}` + ) + host.exec( + `printf '%s' REMOTE_DROP_READY > ${shellQuote(path.join(testRepoPath, 'REMOTE_DROP_READY.txt'))}` + ) + const first = await session.launch() + app = first.app + await waitForSessionReady(first.page) + const nativeId = await first.page.evaluate(async (repoPath) => { + const result = await window.api.repos.add({ path: repoPath }) + if ('error' in result) { + throw new Error(result.error) + } + return `${result.repo.id}::${repoPath}` + }, testRepoPath) + await session.close(app) + app = null + const seeded = seedRelayEraProfile(session.userDataDir, host.input, { + repoPath: testRepoPath, + folderPath: host.remoteFolderPath + }) + const launched = await session.launch() + app = launched.app + const page = launched.page + await waitForSessionReady(page) + await convertAndRetain(page, session.userDataDir, seeded) + const environment = (await page.evaluate(() => window.api.runtimeEnvironments.list())).find( + (entry) => entry.orcadDeployment?.sshTargetId === seeded.targetId + ) + if (!environment) { + throw new Error('Missing actual managed environment') + } + await dismissTransientAnnouncement(page) + const nativeIdentity = getWorktreeHostIdentity({ id: nativeId, hostId: 'local' }) + await page.locator(`[data-worktree-host-identity="${nativeIdentity}"]:visible`).click() + await openFileExplorer(page) + const floatingId = await page.evaluate( + (url) => { + const store = window.__store + const state = store?.getState() + if (!store || !state?.settings) { + throw new Error('Missing ready settings') + } + store.setState({ settings: { ...state.settings, floatingTerminalEnabled: true } }) + const tab = store.getState().createBrowserTab('global-floating-terminal', url, { + activate: true, + focusAddressBar: false, + targetGroupId: state.ensureWorktreeRootGroup('global-floating-terminal'), + browserRuntimeEnvironmentId: null + }) + return tab.id + }, + server.pageUrl('float', 1) + ) + await expect(page.locator('[data-floating-terminal-panel]')).toHaveCount(1) + if ((await page.locator('[data-floating-terminal-panel][aria-hidden="false"]').count()) === 0) { + await page.evaluate(() => window.dispatchEvent(new Event('orca-toggle-floating-terminal'))) + } + await expect(page.locator(`[data-browser-overlay-tab-id="${floatingId}"]`)).toBeVisible() + await waitForGuestUrl(page, floatingId, server.pageUrl('float', 1)) + console.log('[native-actual-drag-payload]', await dropExplorerFile(page, fileName, floatingId)) + await expect + .poll(async () => (await guestText(page, floatingId)).text) + .toContain('DESKTOP_BROWSER_DROP_OWNER') + console.log('[native-positive-actual-guest]', await guestText(page, floatingId)) + await navigateGuest(page, floatingId, server.pageUrl('float', 2)) + await waitForGuestIdle(page, floatingId) + const remoteIdentity = getWorktreeHostIdentity({ + id: seeded.worktreeId, + hostId: toRuntimeExecutionHostId(environment.id) + }) + await page.locator(`[data-worktree-host-identity="${remoteIdentity}"]:visible`).click() + await openFileExplorer(page) + await page.getByRole('button', { name: 'Refresh Explorer', exact: true }).click() + await expect( + page.locator('[data-file-explorer-row]').filter({ hasText: 'REMOTE_DROP_READY.txt' }) + ).toBeVisible() + expect( + await page.evaluate(() => window.__store?.getState().activeWorkspaceExecutionHostId) + ).toBe(toRuntimeExecutionHostId(environment.id)) + console.log( + '[independent-managed-source]', + await serverCall(page, environment.id, 'files.read', { + worktree: `id:${seeded.worktreeId}`, + relativePath: fileName + }) + ) + const remotePayload = await dropExplorerFile(page, fileName, floatingId) + console.log('[managed-actual-drag-payload]', remotePayload) + expect( + JSON.parse(remotePayload['application/x-orca-workspace-file-source'] ?? '{}') + ).toMatchObject({ + executionHostId: toRuntimeExecutionHostId(environment.id), + workspaceId: seeded.worktreeId + }) + await waitForGuestIdle(page, floatingId) + console.log('[managed-drop-actual-guest]', await guestText(page, floatingId)) + await page.screenshot({ path: testInfo.outputPath('managed-browser-drop-owner.png') }) + expect((await guestText(page, floatingId)).text).not.toContain('DESKTOP_BROWSER_DROP_OWNER') + await expect( + page.getByText('Open in Orca Browser is only available for local files.', { exact: true }) + ).toBeVisible() + expect((await guestText(page, floatingId)).url).toBe(server.pageUrl('float', 2)) + } finally { + try { + if (app) { + await session.close(app) + } + await session.dispose() + await server.close() + } finally { + host.cleanup() + } + } +}) diff --git a/tests/e2e/ssh-orcad-idle-exit.spec.ts b/tests/e2e/ssh-orcad-idle-exit.spec.ts index 6ed5a8919ab..cdfb78e060a 100644 --- a/tests/e2e/ssh-orcad-idle-exit.spec.ts +++ b/tests/e2e/ssh-orcad-idle-exit.spec.ts @@ -65,23 +65,7 @@ test('a managed orcad stops after idling and starts again on the next connect', app = first.app await waitForSessionReady(first.page) // A managed host is reached through its server, not a relay, so no relay repo is added. - const remote = await first.page.evaluate( - async (input) => { - const { target: created } = await window.api.ssh.addTarget({ target: input }) - const state = await window.api.ssh.connect({ targetId: created.id }) - return { targetId: created.id, managedServer: state?.managedServer ?? null } - }, - { - label: `orcad idle E2E ${Date.now()}`, - host: target.host, - port: target.port, - username: 'root', - identityFile: target.identityFile, - identitiesOnly: true, - relayGracePeriodSeconds: 1 - } - ) - expect(remote.managedServer).toMatchObject({ kind: 'managed' }) + const remote = await connectManagedHost(first.page, target) expect(runningOrcadPids(target)).toHaveLength(1) // While the client is connected the server stays up past its quiet period. @@ -112,6 +96,13 @@ test('a managed orcad stops after idling and starts again on the next connect', // A start inherited from the launch-time connect this reconnect dropped would report `serving`. expect(JSON.parse(connected)).toMatchObject({ kind: 'managed' }) expect(JSON.parse(connected)).not.toHaveProperty('serving') + await expect + .poll( + async () => + (await callEnvironment(second.page, remote.environmentId, 'terminal.list', {})).ok, + { timeout: 60_000 } + ) + .toBe(true) expect(runningOrcadPids(target)).toHaveLength(1) // The restarted server read the record, so a later crash cannot be mistaken for an idle stop. expect(readIdleStopRecord(target)).toBeNull()