From 93a6b1abff2fc448adb1b93a49c28fb7a44920e3 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Fri, 9 Oct 2026 05:09:00 -0700
Subject: [PATCH] Keep managed file drags out of desktop browser paths (#26698)
---
.github/workflows/e2e.yml | 7 +-
config/scripts/ci-e2e-job-selection.mjs | 7 +-
config/scripts/ci-e2e-job-selection.test.mjs | 19 ++
config/scripts/pr-e2e-source-routing.mjs | 12 ++
...ge-navigation-downloads.file-drop.test.tsx | 108 ++++++++++
.../use-browser-page-navigation-downloads.ts | 13 +-
src/renderer/src/lib/file-preview.test.ts | 14 ++
src/renderer/src/lib/file-preview.ts | 5 +-
.../e2e/ssh-orcad-browser-drop-owner.spec.ts | 204 ++++++++++++++++++
tests/e2e/ssh-orcad-idle-exit.spec.ts | 25 +--
10 files changed, 390 insertions(+), 24 deletions(-)
create mode 100644 src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.file-drop.test.tsx
create mode 100644 tests/e2e/ssh-orcad-browser-drop-owner.spec.ts
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()