mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Restore editor file watching when remote host contact returns (#26606)
Co-authored-by: OrcaWin <alpha-eng@stably.ai>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user