Restore editor file watching when remote host contact returns (#26606)

Co-authored-by: OrcaWin <alpha-eng@stably.ai>
This commit is contained in:
Neil
2026-10-09 04:22:54 -07:00
committed by GitHub
co-authored by OrcaWin
parent 3bcb312043
commit 4845ae8716
9 changed files with 538 additions and 39 deletions
+4 -3
View File
@@ -358,7 +358,8 @@ jobs:
. != "tests/e2e/ssh-orcad-markdown-conversion.spec.ts" and
. != "tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts" and
. != "tests/e2e/ssh-orcad-markdown-live-documents.spec.ts" and
. != "tests/e2e/ssh-orcad-open-in-owner.spec.ts"
. != "tests/e2e/ssh-orcad-open-in-owner.spec.ts" and
. != "tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts"
)' <<<"$TEST_FILES_JSON" > "$RUNNER_TEMP/general-e2e-specs"
fi
mapfile -t TEST_FILES < "$RUNNER_TEMP/general-e2e-specs"
@@ -592,7 +593,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')
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')
runs-on: ubuntu-latest
timeout-minutes: 45
env:
@@ -628,7 +629,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 --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 --project=electron-headless --workers=1
- uses: actions/upload-artifact@v7
if: failure()
with:
+6 -2
View File
@@ -63,6 +63,8 @@ export const ORCAD_MARKDOWN_LINK_REFRESH_E2E_SPEC =
export const ORCAD_MARKDOWN_LIVE_DOCUMENTS_E2E_SPEC =
'tests/e2e/ssh-orcad-markdown-live-documents.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'
export const DEDICATED_E2E_SPECS = [
...DOCKER_SSH_E2E_SPECS,
NODE_NETWORK_E2E_SPEC,
@@ -80,7 +82,8 @@ export const DEDICATED_E2E_SPECS = [
ORCAD_MARKDOWN_CONVERSION_E2E_SPEC,
ORCAD_MARKDOWN_LINK_REFRESH_E2E_SPEC,
ORCAD_MARKDOWN_LIVE_DOCUMENTS_E2E_SPEC,
ORCAD_OPEN_IN_OWNER_E2E_SPEC
ORCAD_OPEN_IN_OWNER_E2E_SPEC,
ORCAD_EDITOR_WATCH_RECOVERY_E2E_SPEC
]
const dedicatedSpecs = new Set(DEDICATED_E2E_SPECS)
const dockerSpecs = new Set(DOCKER_SSH_E2E_SPECS)
@@ -130,7 +133,8 @@ export function classifyE2eJobs(input, sshSourceChanged = 'false') {
spec === ORCAD_MARKDOWN_CONVERSION_E2E_SPEC ||
spec === ORCAD_MARKDOWN_LINK_REFRESH_E2E_SPEC ||
spec === ORCAD_MARKDOWN_LIVE_DOCUMENTS_E2E_SPEC ||
spec === ORCAD_OPEN_IN_OWNER_E2E_SPEC
spec === ORCAD_OPEN_IN_OWNER_E2E_SPEC ||
spec === ORCAD_EDITOR_WATCH_RECOVERY_E2E_SPEC
)
}
}
@@ -238,3 +238,17 @@ it.each([
spec
)
})
it.each([
'src/renderer/src/hooks/useEditorExternalWatch.ts',
'src/renderer/src/hooks/editor-runtime-file-watch.ts'
])('routes %s to the template-building editor watch recovery lane', (file) => {
const spec = 'tests/e2e/ssh-orcad-editor-watch-recovery.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
)
})
+9
View File
@@ -50,6 +50,15 @@ export const PR_E2E_SOURCE_ROUTES = [
file
)
},
{
id: 'ssh.orcad-editor-watch-recovery',
specs: ['tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts'],
matches: (file) =>
isProductSource(file) &&
/^src\/renderer\/src\/hooks\/(?:useEditorExternalWatch|editor-runtime-file-watch)\.ts$/.test(
file
)
},
{
id: 'serve.orcad-mode-switch',
specs: ['tests/e2e/orcad-serve-mode-switch.spec.ts'],
@@ -0,0 +1,187 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FsChangedPayload } from '../../../shared/filesystem-entry-types'
import { subscribeEditorRuntimeFileWatch } from './editor-runtime-file-watch'
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
const runtime = vi.hoisted(() => ({
subscribe: vi.fn(),
contact: new Map<string, () => void>(),
retire: new Set<(ids: readonly string[]) => void>(),
stopContact: vi.fn(),
stopRetirement: vi.fn(),
payloads: new Array<(payload: FsChangedPayload) => void>(),
errors: new Array<(error: Error) => void>()
}))
vi.mock('@/runtime/runtime-file-client', () => ({
subscribeRuntimeFileChanges: runtime.subscribe
}))
vi.mock('@/runtime/runtime-host-contact-regained', () => ({
subscribeRuntimeHostContactRegained: (id: string, listener: () => void) => {
runtime.contact.set(id, listener)
return runtime.stopContact
}
}))
vi.mock('@/runtime/runtime-environment-revision', () => ({
onRuntimeEnvironmentsRetired: (listener: (ids: readonly string[]) => void) => {
runtime.retire.add(listener)
return runtime.stopRetirement
}
}))
const target = {
worktreeId: 'wt',
worktreePath: '/repo',
connectionId: undefined,
runtimeEnvironmentId: 'host-a'
}
const change: FsChangedPayload = {
worktreePath: '/repo',
events: [{ kind: 'update', absolutePath: '/repo/README.md' }]
}
function pendingSubscription(): {
promise: Promise<() => void>
resolve: (stop: () => void) => void
} {
let resolve: (stop: () => void) => void = () => {}
const promise = new Promise<() => void>((done) => {
resolve = done
})
return { promise, resolve }
}
async function settle(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
}
beforeEach(() => {
vi.clearAllMocks()
runtime.contact.clear()
runtime.retire.clear()
runtime.payloads.length = 0
runtime.errors.length = 0
runtime.subscribe.mockReset()
})
describe('editor runtime file watch recovery', () => {
it('routes the initial watch to its owner and leaves ordinary payloads unchanged', async () => {
const onPayload = vi.fn()
const stopStream = vi.fn()
runtime.subscribe.mockResolvedValue(stopStream)
const stop = subscribeEditorRuntimeFileWatch(target, onPayload, vi.fn())
await settle()
expect(runtime.subscribe).toHaveBeenCalledWith(
{
settings: { activeRuntimeEnvironmentId: 'host-a' },
worktreeId: 'wt',
worktreePath: '/repo',
connectionId: undefined
},
expect.any(Function),
expect.any(Function)
)
expect(onPayload).not.toHaveBeenCalled()
runtime.subscribe.mock.calls[0][1](change)
expect(onPayload).toHaveBeenCalledWith(change)
stop()
stop()
expect(stopStream).toHaveBeenCalledOnce()
expect(runtime.stopContact).toHaveBeenCalledOnce()
expect(runtime.stopRetirement).toHaveBeenCalledOnce()
})
it('retains a non-Git folder workspace and its host during recovery', async () => {
runtime.subscribe.mockResolvedValue(vi.fn())
const folder = { ...target, worktreeId: folderWorkspaceKey('notes'), worktreePath: '/notes' }
const onPayload = vi.fn()
const stop = subscribeEditorRuntimeFileWatch(folder, onPayload, vi.fn())
await settle()
runtime.contact.get('host-a')?.()
await settle()
expect(runtime.subscribe).toHaveBeenLastCalledWith(
{
settings: { activeRuntimeEnvironmentId: 'host-a' },
worktreeId: folder.worktreeId,
worktreePath: '/notes',
connectionId: undefined
},
expect.any(Function),
expect.any(Function)
)
expect(onPayload).toHaveBeenCalledWith({
worktreePath: '/notes',
events: [{ kind: 'overflow', absolutePath: '/notes' }]
})
stop()
})
it('disposes late old stream handles without replacing the current handle', async () => {
const old = pendingSubscription()
const fresh = pendingSubscription()
const stopOld = vi.fn()
const stopFresh = vi.fn()
const onPayload = vi.fn()
runtime.subscribe.mockReturnValueOnce(old.promise).mockReturnValueOnce(fresh.promise)
const stop = subscribeEditorRuntimeFileWatch(target, onPayload, vi.fn())
runtime.contact.get('host-a')?.()
fresh.resolve(stopFresh)
await settle()
old.resolve(stopOld)
await settle()
expect(stopOld).toHaveBeenCalledOnce()
expect(stopFresh).not.toHaveBeenCalled()
expect(onPayload).toHaveBeenCalledOnce()
expect(onPayload).toHaveBeenCalledWith({
worktreePath: '/repo',
events: [{ kind: 'overflow', absolutePath: '/repo' }]
})
stop()
expect(stopFresh).toHaveBeenCalledOnce()
})
it('drops payloads and errors from replaced attempts and unrelated host contact', async () => {
const onPayload = vi.fn()
const onError = vi.fn()
runtime.subscribe.mockImplementation((_context, payload, error) => {
runtime.payloads.push(payload)
runtime.errors.push(error)
return Promise.resolve(vi.fn())
})
const stop = subscribeEditorRuntimeFileWatch(target, onPayload, onError)
await settle()
runtime.contact.get('host-b')?.()
expect(runtime.subscribe).toHaveBeenCalledOnce()
runtime.contact.get('host-a')?.()
await settle()
onPayload.mockClear()
runtime.payloads[0](change)
runtime.errors[0](new Error('old transport'))
expect(onPayload).not.toHaveBeenCalled()
expect(onError).not.toHaveBeenCalled()
runtime.payloads[1](change)
expect(onPayload).toHaveBeenCalledOnce()
stop()
runtime.payloads[1](change)
runtime.contact.get('host-a')?.()
expect(onPayload).toHaveBeenCalledOnce()
expect(runtime.subscribe).toHaveBeenCalledTimes(2)
})
it('stops on owner retirement so a same-ID replacement cannot revive old work', async () => {
const stopStream = vi.fn()
runtime.subscribe.mockResolvedValue(stopStream)
const stop = subscribeEditorRuntimeFileWatch(target, vi.fn(), vi.fn())
await settle()
for (const retire of runtime.retire) {
retire(['host-b'])
}
expect(stopStream).not.toHaveBeenCalled()
for (const retire of runtime.retire) {
retire(['host-a'])
}
expect(stopStream).toHaveBeenCalledOnce()
runtime.contact.get('host-a')?.()
expect(runtime.subscribe).toHaveBeenCalledOnce()
stop()
expect(stopStream).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,83 @@
import type { FsChangedPayload } from '../../../shared/filesystem-entry-types'
import { subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client'
import { subscribeRuntimeHostContactRegained } from '@/runtime/runtime-host-contact-regained'
import { onRuntimeEnvironmentsRetired } from '@/runtime/runtime-environment-revision'
import type { EditorExternalWatchTarget } from './editor-external-watch-targets'
export function subscribeEditorRuntimeFileWatch(
target: EditorExternalWatchTarget & { runtimeEnvironmentId: string },
onPayload: (payload: FsChangedPayload) => void,
onError: (error: unknown) => void
): () => void {
let disposed = false
let attempt = 0
let unsubscribe: (() => void) | null = null
const start = (resync: boolean): void => {
if (disposed) {
return
}
const currentAttempt = ++attempt
unsubscribe?.()
unsubscribe = null
const isCurrent = (): boolean => !disposed && attempt === currentAttempt
void subscribeRuntimeFileChanges(
{
settings: { activeRuntimeEnvironmentId: target.runtimeEnvironmentId },
worktreeId: target.worktreeId,
worktreePath: target.worktreePath,
connectionId: target.connectionId
},
(payload) => {
if (isCurrent()) {
onPayload(payload)
}
},
(error) => {
if (isCurrent()) {
onError(error)
}
}
)
.then((stop) => {
if (!isCurrent()) {
stop()
return
}
unsubscribe = stop
// A restored stream cannot replay file changes missed while contact was lost.
if (resync) {
onPayload({
worktreePath: target.worktreePath,
events: [{ kind: 'overflow', absolutePath: target.worktreePath }]
})
}
})
.catch((error: unknown) => {
if (isCurrent()) {
onError(error)
}
})
}
const stopContact = subscribeRuntimeHostContactRegained(target.runtimeEnvironmentId, () =>
start(true)
)
const stopRetirement = onRuntimeEnvironmentsRetired((ids) => {
if (ids.includes(target.runtimeEnvironmentId)) {
dispose()
}
})
function dispose(): void {
if (disposed) {
return
}
disposed = true
attempt += 1
unsubscribe?.()
unsubscribe = null
stopContact()
stopRetirement()
}
start(false)
return dispose
}
@@ -15,12 +15,21 @@ type TestWatchTarget = {
const subscriptionState = vi.hoisted(() => ({
snapshot: { targets: [] as TestWatchTarget[], targetsKey: '' },
subscribeRuntimeFileChanges: vi.fn(),
disposeEventHandler: vi.fn()
disposeEventHandler: vi.fn(),
handleFsChanged: vi.fn(),
contacts: new Map<string, () => void>(),
unsubscribeContact: vi.fn()
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: unknown) => unknown) => selector({})
}))
vi.mock('@/runtime/runtime-host-contact-regained', () => ({
subscribeRuntimeHostContactRegained: (id: string, listener: () => void) => {
subscriptionState.contacts.set(id, listener)
return subscriptionState.unsubscribeContact
}
}))
vi.mock('@/runtime/runtime-file-client', () => ({
subscribeRuntimeFileChanges: subscriptionState.subscribeRuntimeFileChanges
}))
@@ -37,7 +46,7 @@ vi.mock('./editor-external-watch-targets', () => ({
}))
vi.mock('./editor-external-watch-event-reconciliation', () => ({
buildEditorExternalWatchEventHandler: vi.fn(() => ({
handleFsChanged: vi.fn(),
handleFsChanged: subscriptionState.handleFsChanged,
dispose: subscriptionState.disposeEventHandler
})),
collectOverflowEditorExternalReloadTargets: vi.fn()
@@ -83,6 +92,8 @@ describe('useEditorExternalWatch subscriptions', () => {
beforeEach(() => {
vi.clearAllMocks()
subscriptionState.contacts.clear()
subscriptionState.subscribeRuntimeFileChanges.mockReset()
subscriptionState.snapshot = { targets: [], targetsKey: '' }
watchWorktree = vi.fn().mockResolvedValue(undefined)
unwatchWorktree = vi.fn().mockResolvedValue(undefined)
@@ -184,4 +195,44 @@ describe('useEditorExternalWatch subscriptions', () => {
await act(async () => root.unmount())
expect(unsubscribeCurrent).toHaveBeenCalledTimes(1)
})
it('retries a failed initial watch when the same host answers again', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
subscriptionState.subscribeRuntimeFileChanges
.mockRejectedValueOnce(new Error('connection lost'))
.mockResolvedValueOnce(vi.fn())
subscriptionState.snapshot = { targets: [runtimeTarget()], targetsKey: 'recovering-watch' }
try {
await act(async () => root.render(createElement(WatchProbe)))
await act(async () => subscriptionState.contacts.get('runtime-1')?.())
expect(subscriptionState.subscribeRuntimeFileChanges).toHaveBeenCalledTimes(2)
expect(subscriptionState.handleFsChanged).toHaveBeenCalledWith(
{
worktreePath: '/runtime/repo',
events: [{ kind: 'overflow', absolutePath: '/runtime/repo' }]
},
'runtime-1'
)
} finally {
warning.mockRestore()
}
})
it('replaces a lost watch and stops contact recovery when the editor unmounts', async () => {
const firstStop = vi.fn()
const secondStop = vi.fn()
subscriptionState.subscribeRuntimeFileChanges
.mockResolvedValueOnce(firstStop)
.mockResolvedValueOnce(secondStop)
subscriptionState.snapshot = { targets: [runtimeTarget()], targetsKey: 'live-watch' }
await act(async () => root.render(createElement(WatchProbe)))
await act(async () => subscriptionState.contacts.get('runtime-1')?.())
expect(firstStop).toHaveBeenCalledOnce()
expect(subscriptionState.subscribeRuntimeFileChanges).toHaveBeenCalledTimes(2)
await act(async () => root.unmount())
expect(secondStop).toHaveBeenCalledOnce()
expect(subscriptionState.unsubscribeContact).toHaveBeenCalledOnce()
subscriptionState.contacts.get('runtime-1')?.()
expect(subscriptionState.subscribeRuntimeFileChanges).toHaveBeenCalledTimes(2)
})
})
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react'
import { useAppStore } from '@/store'
import { subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client'
import { subscribeEditorRuntimeFileWatch } from './editor-runtime-file-watch'
import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path'
import type { FsChangedPayload } from '../../../shared/filesystem-entry-types'
import {
@@ -121,39 +121,19 @@ function subscribeRuntimeTarget(
current: ((payload: FsChangedPayload, runtimeEnvironmentId?: string | null) => void) | null
}
): void {
const key = getEditorExternalWatchTargetKey(target)
let cancelled = false
const pendingUnsubscribe = (): void => {
cancelled = true
const runtimeEnvironmentId = target.runtimeEnvironmentId
if (!runtimeEnvironmentId) {
return
}
remoteWatchUnsubs.set(key, pendingUnsubscribe)
void subscribeRuntimeFileChanges(
{
settings: { activeRuntimeEnvironmentId: target.runtimeEnvironmentId! },
worktreeId: target.worktreeId,
worktreePath: target.worktreePath,
connectionId: target.connectionId
},
(payload) => fsChangedHandlerRef.current?.(payload, target.runtimeEnvironmentId),
(err) => warnExternalWatchFailure(target, err)
const key = getEditorExternalWatchTargetKey(target)
remoteWatchUnsubs.set(
key,
subscribeEditorRuntimeFileWatch(
{ ...target, runtimeEnvironmentId },
(payload) => fsChangedHandlerRef.current?.(payload, runtimeEnvironmentId),
(error) => warnExternalWatchFailure(target, error)
)
)
.then((unsubscribe) => {
if (cancelled) {
unsubscribe()
return
}
if (remoteWatchUnsubs.get(key) === pendingUnsubscribe) {
remoteWatchUnsubs.set(key, unsubscribe)
} else {
unsubscribe()
}
})
.catch((err) => {
if (remoteWatchUnsubs.get(key) === pendingUnsubscribe) {
remoteWatchUnsubs.delete(key)
}
warnExternalWatchFailure(target, err)
})
}
export function verifyLatchedMoveDestinations(
@@ -0,0 +1,170 @@
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, waitForStartupWorktreeRefresh } from './helpers/store'
import { dismissTransientAnnouncement } from './helpers/ssh-config-host-picker'
import { toRuntimeExecutionHostId } from '../../src/shared/execution-host'
const TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE
test.skip(!TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', 'Needs Docker and server template')
function captureRendererMessages(page: Page): void {
page.on('console', (message) => {
if (
message.type() === 'error' ||
message.type() === 'warning' ||
message.text().startsWith('[file-change-event]')
) {
console.log('[renderer-event]', message.text())
}
})
}
for (const recovery of ['conversion', 'server restart'] as const) {
test(`remote editors receive host file changes after ${recovery}`, async (// oxlint-disable-next-line no-empty-pattern -- This exercise owns its app launch.
{}, testInfo) => {
test.setTimeout(4 * 60_000)
const host = startOrcadConvertHost('docker', testInfo)
const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE! })
let app: ElectronApplication | null = null
try {
if (!host.exec) {
throw new Error('Docker controls are unavailable')
}
const execute = host.exec
const first = await session.launch()
app = first.app
await waitForSessionReady(first.page)
await session.close(app)
app = null
const marker = `REMOTE_MARKDOWN_${Date.now()}`
execute(
`printf '# ${marker}\n\nHost-owned Markdown.\n\n[[LATE]]\n' > '${host.remoteRepoPath}/README.md'`
)
const seeded = seedRelayEraProfile(session.userDataDir, host.input, {
repoPath: host.remoteRepoPath,
folderPath: host.remoteFolderPath
})
const launched = await session.launch()
app = launched.app
let page = launched.page
captureRendererMessages(page)
await waitForSessionReady(page)
await convertAndRetain(page, session.userDataDir, seeded)
const environment = (await page.evaluate(() => window.api.runtimeEnvironments.list())).find(
(e) => e.orcadDeployment?.sshTargetId === seeded.targetId
)
if (!environment) {
throw new Error('Missing managed environment')
}
if (recovery === 'server restart') {
await session.close(app)
app = null
const restarted = await session.launch()
app = restarted.app
page = restarted.page
captureRendererMessages(page)
await waitForSessionReady(page)
await waitForStartupWorktreeRefresh(page)
}
await page.evaluate(
({ id, hostId }) => window.__store?.getState().setActiveWorktree(id, hostId),
{
id: seeded.worktreeId,
hostId: toRuntimeExecutionHostId(environment.id)
}
)
await dismissTransientAnnouncement(page)
await expect(page.locator('.rich-markdown-editor')).toContainText(marker, { timeout: 30_000 })
await page.evaluate(() => {
window.addEventListener('orca:worktree-file-change', (event) => {
if (event instanceof CustomEvent) {
console.log('[file-change-event]', JSON.stringify(event.detail))
}
})
})
if (recovery === 'server restart') {
const ready = page.waitForEvent('console', {
predicate: (message) =>
message.text().startsWith('[file-change-event]') &&
message.text().includes('WATCH_READY.txt'),
timeout: 30_000
})
execute(`printf 'ready\\n' > '${host.remoteRepoPath}/WATCH_READY.txt'`)
await ready
const serverPids = (): string =>
execute(
'for f in /root/.orca-remote/orcad-*/.orcad-pid; do pid=$(cat "$f" 2>/dev/null) && kill -0 "$pid" 2>/dev/null && echo "$pid"; done; true'
).trim()
const before = serverPids()
expect(before).toMatch(/^\d+$/)
execute('kill -TERM $(cat /root/.orca-remote/orcad-*/.orcad-pid)')
await expect.poll(serverPids, { timeout: 30_000 }).toBe('')
console.log('[managed-server-terminated]', before)
}
const updatedMarker = `UPDATED_${marker}`
execute(
`printf '# ${updatedMarker}\\n\\nChanged independently on the execution host.\\n' > '${host.remoteRepoPath}/README.md'`
)
if (recovery === 'server restart') {
await expect
.poll(
() =>
page.evaluate(
async ({ id, worktreeId }) => {
const response = await window.api.runtimeEnvironments.call({
selector: id,
method: 'files.read',
params: { worktree: `id:${worktreeId}`, relativePath: 'README.md' }
})
return response.ok ? JSON.stringify(response.result) : ''
},
{ id: environment.id, worktreeId: seeded.worktreeId }
),
{ timeout: 180_000 }
)
.toContain(updatedMarker)
console.log(
'[managed-server-restarted]',
execute('cat /root/.orca-remote/orcad-*/.orcad-pid').trim()
)
}
const content = await serverCall(page, environment.id, 'files.read', {
worktree: `id:${seeded.worktreeId}`,
relativePath: 'README.md'
})
console.log('[host-updated-content]', content)
expect(content).toContain(updatedMarker)
await page.screenshot({ path: testInfo.outputPath('remote-editor-observed.png') })
await expect(page.locator('.rich-markdown-editor')).toContainText(updatedMarker, {
timeout: 15_000
})
await page.screenshot({ path: testInfo.outputPath('remote-editor-refreshed.png') })
if (recovery === 'server restart') {
const resumed = page.waitForEvent('console', {
predicate: (message) =>
message.text().startsWith('[file-change-event]') &&
message.text().includes('WATCH_RECOVERED.txt'),
timeout: 15_000
})
execute(`printf 'watcher recovered\\n' > '${host.remoteRepoPath}/WATCH_RECOVERED.txt'`)
const created = await serverCall(page, environment.id, 'files.read', {
worktree: `id:${seeded.worktreeId}`,
relativePath: 'WATCH_RECOVERED.txt'
})
console.log('[host-post-restart-file]', created)
expect(created).toContain('watcher recovered')
await resumed
}
} finally {
if (app) {
await session.close(app)
}
await session.dispose()
host.cleanup()
}
})
}