Refresh open Markdown links when workspace files change (#26599)

* Refresh open Markdown document links after file changes

* Use the stable Markdown refresh callback in the watcher effect
This commit is contained in:
Neil
2026-10-08 23:23:14 -07:00
committed by GitHub
parent 6235f9a641
commit f9d0aa2f67
12 changed files with 513 additions and 9 deletions
+4 -3
View File
@@ -356,7 +356,8 @@ jobs:
. != "tests/e2e/ssh-orcad-browser-routing.spec.ts" and
. != "tests/e2e/ssh-orcad-editor-ownership.spec.ts" and
. != "tests/e2e/ssh-orcad-markdown-conversion.spec.ts" and
. != "tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts"
. != "tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts" and
. != "tests/e2e/ssh-orcad-markdown-live-documents.spec.ts"
)' <<<"$TEST_FILES_JSON" > "$RUNNER_TEMP/general-e2e-specs"
fi
mapfile -t TEST_FILES < "$RUNNER_TEMP/general-e2e-specs"
@@ -590,7 +591,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')
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')
runs-on: ubuntu-latest
timeout-minutes: 45
env:
@@ -626,7 +627,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 --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 --project=electron-headless --workers=1
- uses: actions/upload-artifact@v7
if: failure()
with:
+6 -2
View File
@@ -60,6 +60,8 @@ export const ORCAD_EDITOR_OWNERSHIP_E2E_SPEC = 'tests/e2e/ssh-orcad-editor-owner
export const ORCAD_MARKDOWN_CONVERSION_E2E_SPEC = 'tests/e2e/ssh-orcad-markdown-conversion.spec.ts'
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 DEDICATED_E2E_SPECS = [
...DOCKER_SSH_E2E_SPECS,
NODE_NETWORK_E2E_SPEC,
@@ -75,7 +77,8 @@ export const DEDICATED_E2E_SPECS = [
ORCAD_BROWSER_ROUTING_E2E_SPEC,
ORCAD_EDITOR_OWNERSHIP_E2E_SPEC,
ORCAD_MARKDOWN_CONVERSION_E2E_SPEC,
ORCAD_MARKDOWN_LINK_REFRESH_E2E_SPEC
ORCAD_MARKDOWN_LINK_REFRESH_E2E_SPEC,
ORCAD_MARKDOWN_LIVE_DOCUMENTS_E2E_SPEC
]
const dedicatedSpecs = new Set(DEDICATED_E2E_SPECS)
const dockerSpecs = new Set(DOCKER_SSH_E2E_SPECS)
@@ -123,7 +126,8 @@ export function classifyE2eJobs(input, sshSourceChanged = 'false') {
spec === ORCAD_BROWSER_ROUTING_E2E_SPEC ||
spec === ORCAD_EDITOR_OWNERSHIP_E2E_SPEC ||
spec === ORCAD_MARKDOWN_CONVERSION_E2E_SPEC ||
spec === ORCAD_MARKDOWN_LINK_REFRESH_E2E_SPEC
spec === ORCAD_MARKDOWN_LINK_REFRESH_E2E_SPEC ||
spec === ORCAD_MARKDOWN_LIVE_DOCUMENTS_E2E_SPEC
)
}
}
@@ -197,6 +197,7 @@ it.each([
const MARKDOWN_CONVERSION_SPEC = 'tests/e2e/ssh-orcad-markdown-conversion.spec.ts'
const MARKDOWN_LINK_REFRESH_SPEC = 'tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts'
const MARKDOWN_LIVE_DOCUMENTS_SPEC = 'tests/e2e/ssh-orcad-markdown-live-documents.spec.ts'
it.each([
['src/renderer/src/components/editor/useMarkdownDocuments.ts', MARKDOWN_CONVERSION_SPEC],
@@ -212,6 +213,15 @@ it.each([
[
'src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts',
MARKDOWN_LINK_REFRESH_SPEC
],
['src/renderer/src/components/editor/useMarkdownDocuments.ts', MARKDOWN_LIVE_DOCUMENTS_SPEC],
[
'src/renderer/src/components/editor/use-markdown-document-watch-refresh.ts',
MARKDOWN_LIVE_DOCUMENTS_SPEC
],
[
'src/renderer/src/components/editor/markdown-document-list-request.ts',
MARKDOWN_LIVE_DOCUMENTS_SPEC
]
])('routes %s to the template-building Markdown lane %s', (file, spec) => {
expect(selectPrE2eSpecs([file])).toContain(spec)
+9
View File
@@ -41,6 +41,15 @@ export const PR_E2E_SOURCE_ROUTES = [
file
)
},
{
id: 'ssh.orcad-markdown-live-documents',
specs: ['tests/e2e/ssh-orcad-markdown-live-documents.spec.ts'],
matches: (file) =>
isProductSource(file) &&
/^src\/renderer\/src\/components\/editor\/(?:useMarkdownDocuments|use-markdown-document-watch-refresh|markdown-document-list-request)\.ts$/.test(
file
)
},
{
id: 'serve.orcad-mode-switch',
specs: ['tests/e2e/orcad-serve-mode-switch.spec.ts'],
@@ -79,6 +79,40 @@ describe('shared Markdown document list requests', () => {
await expect(initialRequest).resolves.toBe(staleDocuments)
})
it('bypasses a pre-change scan but joins a scan started after the same watcher burst', async () => {
const old = deferred<MarkdownDocument[]>()
const fresh = deferred<MarkdownDocument[]>()
const load = vi.fn().mockReturnValueOnce(old.promise).mockReturnValueOnce(fresh.promise)
const now = vi.spyOn(performance, 'now').mockReturnValue(1_000)
try {
const initial = requestSharedMarkdownDocumentList(context(), '/watch-burst', {}, load)
now.mockReturnValue(1_200)
const afterChange = { freshAfter: 1_000 }
const refreshed = requestSharedMarkdownDocumentList(
context(),
'/watch-burst',
afterChange,
load
)
expect(refreshed).not.toBe(initial)
expect(requestSharedMarkdownDocumentList(context(), '/watch-burst', afterChange, load)).toBe(
refreshed
)
old.resolve([])
await initial
expect(requestSharedMarkdownDocumentList(context(), '/watch-burst', afterChange, load)).toBe(
refreshed
)
expect(load).toHaveBeenCalledTimes(2)
fresh.resolve([])
await refreshed
} finally {
old.resolve([])
fresh.resolve([])
now.mockRestore()
}
})
it('allows an ordinary retry after an older scan stops settling', async () => {
const stale = deferred<MarkdownDocument[]>()
const retry = deferred<MarkdownDocument[]>()
@@ -11,6 +11,7 @@ type MarkdownDocumentListLoader = (
type MarkdownDocumentListRequestOptions = {
requireFresh?: boolean
freshAfter?: number
}
type InFlightMarkdownDocumentList = {
@@ -51,7 +52,11 @@ export function requestSharedMarkdownDocumentList(
const key = getMarkdownDocumentListRequestKey(context, rootPath)
const existing = inFlightMarkdownDocumentLists.get(key)
if (existing && !options.requireFresh) {
if (
existing &&
!options.requireFresh &&
(options.freshAfter === undefined || existing.startedAt > options.freshAfter)
) {
return existing.request
}
@@ -56,7 +56,7 @@ function normalizePathForCompare(p: string): string {
return np
}
function hasMarkdownExtension(p: string): boolean {
export function hasMarkdownExtension(p: string): boolean {
const lastDot = p.lastIndexOf('.')
if (lastDot === -1) {
return false
@@ -0,0 +1,80 @@
import { useEffect } from 'react'
import type { FsChangedPayload } from '../../../../shared/filesystem-entry-types'
import {
normalizeRuntimePathForComparison,
relativePathInsideRoot
} from '../../../../shared/cross-platform-path'
import { ORCA_WORKTREE_FILE_CHANGE_EVENT } from '@/hooks/worktree-file-change-event'
import { hasMarkdownExtension } from './markdown-internal-links'
const DOCUMENT_WATCH_DEBOUNCE_MS = 125
function changesDocumentMembership(payload: FsChangedPayload, rootPath: string): boolean {
if (
normalizeRuntimePathForComparison(payload.worktreePath) !==
normalizeRuntimePathForComparison(rootPath)
) {
return false
}
return payload.events.some((event) => {
if (event.kind === 'overflow') {
return true
}
if (event.kind === 'update') {
return false
}
return [event.absolutePath, event.oldAbsolutePath].some((path) => {
if (!path) {
return false
}
const relativePath = relativePathInsideRoot(rootPath, path)
return (
relativePath !== null && (event.isDirectory === true || hasMarkdownExtension(relativePath))
)
})
})
}
export function useMarkdownDocumentWatchRefresh({
enabled,
worktreePath,
runtimeEnvironmentId,
refresh
}: {
enabled: boolean
worktreePath: string | null
runtimeEnvironmentId: string | null | undefined
refresh: (requireFresh: boolean, freshAfter: number) => Promise<void>
}): void {
const owner = runtimeEnvironmentId?.trim() || null
useEffect(() => {
if (!enabled || !worktreePath) {
return
}
let timer: ReturnType<typeof setTimeout> | undefined
const handleChange = (event: WindowEventMap[typeof ORCA_WORKTREE_FILE_CHANGE_EVENT]): void => {
const { payload, runtimeEnvironmentId: eventOwner } = event.detail
if (eventOwner !== owner || !changesDocumentMembership(payload, worktreePath)) {
return
}
if (timer !== undefined) {
clearTimeout(timer)
}
const changedAt = performance.now()
// Each pane can join a scan begun after this burst, while bypassing an older snapshot.
timer = setTimeout(() => {
timer = undefined
void refresh(false, changedAt)
}, DOCUMENT_WATCH_DEBOUNCE_MS)
}
window.addEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, handleChange)
return () => {
if (timer !== undefined) {
clearTimeout(timer)
}
window.removeEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, handleChange)
}
}, [enabled, owner, worktreePath, refresh])
}
@@ -15,6 +15,7 @@ import {
import { selectMarkdownDocumentWorktreePath } from './markdown-document-worktree-path-selector'
import { requestSharedMarkdownDocumentList } from './markdown-document-list-request'
import { findRestoredEditorWorkspaceRuntimeOwner } from './restored-editor-workspace-runtime-owner'
import { useMarkdownDocumentWatchRefresh } from './use-markdown-document-watch-refresh'
type OpenMarkdownDocumentOptions = {
anchor?: string | null
@@ -75,7 +76,7 @@ export function useMarkdownDocuments(
const requestRef = useRef(0)
const refreshMarkdownDocuments = useCallback(
async (requireFresh = false): Promise<void> => {
async (requireFresh = false, freshAfter?: number): Promise<void> => {
if (!worktreeId || !worktreePath) {
return
}
@@ -110,7 +111,7 @@ export function useMarkdownDocuments(
connectionId: connectionId ?? undefined
},
worktreePath,
{ requireFresh }
{ requireFresh, ...(freshAfter === undefined ? {} : { freshAfter }) }
)
if (requestRef.current !== requestId) {
return
@@ -118,6 +119,10 @@ export function useMarkdownDocuments(
setSnapshot({ key: scopeKey, documents })
} catch (err) {
console.error('Failed to list markdown documents:', err)
// Watcher refreshes are background work: keep the last good list and stay quiet.
if (freshAfter !== undefined) {
return
}
if (requestRef.current === requestId) {
toast.error(
err instanceof Error
@@ -222,6 +227,13 @@ export function useMarkdownDocuments(
}
}, [activeFile.id, isMarkdown, viewMode, refreshMarkdownDocuments])
useMarkdownDocumentWatchRefresh({
enabled: isMarkdown && !!worktreeId,
worktreePath,
runtimeEnvironmentId: activeFile.runtimeEnvironmentId,
refresh: refreshMarkdownDocuments
})
const markdownDocuments = useMemo(
() => (snapshot?.key === scopeKey ? snapshot.documents : []),
[scopeKey, snapshot]
@@ -0,0 +1,209 @@
// @vitest-environment happy-dom
import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FsChangeEvent, MarkdownDocument } from '../../../../shared/filesystem-entry-types'
import type { OpenFile } from '@/store/slices/editor'
import { ORCA_WORKTREE_FILE_CHANGE_EVENT } from '@/hooks/worktree-file-change-event'
import { useMarkdownDocuments } from './useMarkdownDocuments'
const runtime = vi.hoisted(() => ({ list: vi.fn(), stat: vi.fn() }))
const state = {
settings: {},
worktreesByRepo: { repo: [{ id: 'wt', path: '/repo' }] },
openFile: vi.fn(),
openMarkdownPreview: vi.fn()
}
vi.mock('@/store', () => ({
useAppStore: Object.assign((selector: (store: typeof state) => unknown) => selector(state), {
getState: () => state
})
}))
vi.mock('@/lib/connection-context', () => ({ getConnectionIdFromState: () => null }))
vi.mock('@/runtime/runtime-file-client', () => ({
listRuntimeMarkdownDocuments: runtime.list,
statRuntimePath: runtime.stat
}))
vi.mock('@/runtime/runtime-rpc-client', () => ({
settingsForRuntimeOwner: (_settings: unknown, owner: string | null | undefined) => ({
activeRuntimeEnvironmentId: owner
})
}))
const toastError = vi.hoisted(() => vi.fn())
vi.mock('sonner', () => ({ toast: { error: toastError } }))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
const target: MarkdownDocument = {
filePath: '/repo/LATE.md',
relativePath: 'LATE.md',
basename: 'LATE.md',
name: 'LATE'
}
let root: Root
let container: HTMLDivElement
const controllers: ReturnType<typeof useMarkdownDocuments>[] = []
function Harness({ owner, pane }: { owner: string | null; pane: number }): null {
const file: OpenFile = {
id: `source-${pane}`,
filePath: '/repo/README.md',
relativePath: 'README.md',
worktreeId: 'wt',
language: 'markdown',
isDirty: false,
mode: 'markdown-preview',
runtimeEnvironmentId: owner
}
controllers[pane] = useMarkdownDocuments(file, true, 'preview', async () => true)
return null
}
async function render(owner: string | null = 'host-a', panes = 1): Promise<void> {
await act(async () =>
root.render(
Array.from({ length: panes }, (_, pane) => createElement(Harness, { key: pane, owner, pane }))
)
)
}
function change(
events: FsChangeEvent[],
owner: string | null = 'host-a',
rootPath = '/repo'
): void {
window.dispatchEvent(
new CustomEvent(ORCA_WORKTREE_FILE_CHANGE_EVENT, {
detail: { runtimeEnvironmentId: owner, payload: { worktreePath: rootPath, events } }
})
)
}
async function settle(): Promise<void> {
await act(async () => {
await vi.advanceTimersByTimeAsync(125)
})
}
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
vi.useFakeTimers()
vi.clearAllMocks()
runtime.list.mockResolvedValue([])
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
vi.useRealTimers()
vi.unstubAllGlobals()
})
describe('Markdown metadata from the existing worktree watcher', () => {
it('discovers a created document and removes a deleted document without reopening', async () => {
await render()
runtime.list.mockResolvedValueOnce([target])
change([{ kind: 'create', absolutePath: target.filePath }])
await settle()
expect(controllers[0].markdownDocuments).toEqual([target])
change([{ kind: 'delete', absolutePath: target.filePath }])
await settle()
expect(controllers[0].markdownDocuments).toEqual([])
expect(runtime.list).toHaveBeenCalledTimes(3)
})
it('keeps the last list without a toast when a watcher refresh fails', async () => {
runtime.list.mockResolvedValueOnce([target])
await render()
expect(controllers[0].markdownDocuments).toEqual([target])
runtime.list.mockRejectedValueOnce(new Error('host unreachable'))
vi.spyOn(console, 'error').mockImplementation(() => {})
change([{ kind: 'create', absolutePath: '/repo/OTHER.md' }])
await settle()
expect(controllers[0].markdownDocuments).toEqual([target])
expect(toastError).not.toHaveBeenCalled()
})
it('keeps a late pre-change scan from replacing the refreshed document list', async () => {
let resolveOld: (documents: MarkdownDocument[]) => void = () => {}
runtime.list.mockReturnValueOnce(
new Promise<MarkdownDocument[]>((done) => {
resolveOld = done
})
)
await render()
runtime.list.mockResolvedValueOnce([target])
change([{ kind: 'create', absolutePath: target.filePath }])
await settle()
expect(controllers[0].markdownDocuments).toEqual([target])
await act(async () => resolveOld([]))
expect(controllers[0].markdownDocuments).toEqual([target])
expect(runtime.list).toHaveBeenCalledTimes(2)
})
it('refreshes a local owner while ignoring an identical remote path', async () => {
await render(null)
change([{ kind: 'create', absolutePath: target.filePath }], 'host-a')
await settle()
expect(runtime.list).toHaveBeenCalledOnce()
runtime.list.mockResolvedValueOnce([target])
change([{ kind: 'create', absolutePath: target.filePath }], null)
await settle()
expect(controllers[0].markdownDocuments).toEqual([target])
expect(runtime.list).toHaveBeenLastCalledWith(
expect.objectContaining({ settings: { activeRuntimeEnvironmentId: null } }),
'/repo'
)
})
it('ignores other hosts, roots, unrelated files, and ordinary content updates', async () => {
await render()
change([{ kind: 'create', absolutePath: target.filePath }], 'host-b')
change([{ kind: 'create', absolutePath: target.filePath }], null)
change([{ kind: 'create', absolutePath: '/other/LATE.md' }], 'host-a', '/other')
change([{ kind: 'create', absolutePath: '/repo-other/LATE.md' }])
change([{ kind: 'create', absolutePath: '/repo/app.ts' }])
change([{ kind: 'update', absolutePath: target.filePath }])
await settle()
expect(runtime.list).toHaveBeenCalledOnce()
})
it.each([
{ kind: 'rename', absolutePath: '/repo/LATE.txt', oldAbsolutePath: target.filePath },
{ kind: 'create', absolutePath: '/repo/late.MDX' },
{ kind: 'delete', absolutePath: '/repo/docs', isDirectory: true },
{ kind: 'overflow', absolutePath: '/repo' }
] satisfies FsChangeEvent[])(
'refreshes document membership for $kind $absolutePath',
async (event) => {
await render()
change([event])
await settle()
expect(runtime.list).toHaveBeenCalledTimes(2)
}
)
it('coalesces bursts and shares a fresh pending scan across split panes', async () => {
await render('host-a', 2)
let resolve: (documents: MarkdownDocument[]) => void = () => {}
runtime.list.mockReturnValueOnce(
new Promise<MarkdownDocument[]>((done) => {
resolve = done
})
)
change([{ kind: 'create', absolutePath: target.filePath }])
change([{ kind: 'delete', absolutePath: '/repo/old.markdown' }])
await settle()
expect(runtime.list).toHaveBeenCalledTimes(2)
await act(async () => resolve([target]))
expect(controllers.map((controller) => controller.markdownDocuments)).toEqual([
[target],
[target]
])
})
it.each(['unmount', 'owner change'])('cancels scheduled scans on %s', async (reason) => {
await render()
change([{ kind: 'create', absolutePath: target.filePath }])
await (reason === 'unmount' ? act(async () => root.render(null)) : render('host-b'))
const calls = runtime.list.mock.calls.length
await settle()
expect(runtime.list).toHaveBeenCalledTimes(calls)
})
})
@@ -6,3 +6,10 @@ export type WorktreeFileChangeEventDetail = {
payload: FsChangedPayload
runtimeEnvironmentId: string | null
}
declare global {
// oxlint-disable-next-line typescript/consistent-type-definitions -- WindowEventMap requires declaration merging.
interface WindowEventMap {
'orca:worktree-file-change': CustomEvent<WorktreeFileChangeEventDetail>
}
}
@@ -0,0 +1,133 @@
import type { ElectronApplication } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import { createRestartSession } from './helpers/orca-restart'
import { startOrcadConvertHost } from './helpers/orcad-convert-host'
import { seedRelayEraProfile } 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')
test('open remote Markdown previews discover host-created documents', 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 {
const first = await session.launch()
app = first.app
await waitForSessionReady(first.page)
await session.close(app)
app = null
const marker = `REMOTE_MARKDOWN_${Date.now()}`
host.exec!(
`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
page.on('console', (message) => {
if (
message.type() === 'error' ||
message.type() === 'warning' ||
message.text().startsWith('[file-change-event]')
) {
console.log('[renderer-error]', message.text())
}
})
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')
}
await session.close(app)
app = null
const restarted = await session.launch()
app = restarted.app
page = restarted.page
page.on('console', (message) => {
if (
message.type() === 'error' ||
message.type() === 'warning' ||
message.text().startsWith('[file-change-event]')
) {
console.log('[renderer-event]', message.text())
}
})
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(
({ filePath, worktreeId, environmentId }) => {
window.__store?.getState().openMarkdownPreview({
filePath,
relativePath: 'README.md',
worktreeId,
language: 'markdown',
runtimeEnvironmentId: environmentId
})
},
{
filePath: seeded.sessionFilePath,
worktreeId: seeded.worktreeId,
environmentId: environment.id
}
)
const link = page.locator('.markdown-preview').getByRole('link', { name: 'LATE', exact: true })
await expect(link).toBeVisible()
await expect(link).toHaveClass(/markdown-doc-link-broken/)
await page.evaluate(() => {
window.addEventListener('orca:worktree-file-change', (event) => {
if (event instanceof CustomEvent) {
console.log('[file-change-event]', JSON.stringify(event.detail))
}
})
})
host.exec!(`printf '# LATE_${marker}\n' > '${host.remoteRepoPath}/LATE.md'`)
const documents = await serverCall(page, environment.id, 'files.listMarkdownDocuments', {
worktree: `id:${seeded.worktreeId}`
})
console.log('[host-created-documents]', documents)
expect(documents).toContain('LATE.md')
await expect(page.getByText('LATE.md', { exact: true })).toBeVisible({ timeout: 30_000 })
console.log('[open-preview-link]', await link.getAttribute('class'))
await page.screenshot({ path: testInfo.outputPath('live-document-observed.png') })
await expect(link).not.toHaveClass(/markdown-doc-link-broken/, { timeout: 10_000 })
console.log('[resolved-preview-link]', await link.getAttribute('class'))
await page.screenshot({ path: testInfo.outputPath('live-document-created.png') })
host.exec!(`rm '${host.remoteRepoPath}/LATE.md'`)
const removed = await serverCall(page, environment.id, 'files.listMarkdownDocuments', {
worktree: `id:${seeded.worktreeId}`
})
console.log('[host-removed-documents]', removed)
expect(removed).not.toContain('LATE.md')
await expect(page.getByText('LATE.md', { exact: true })).not.toBeVisible({ timeout: 30_000 })
await expect(link).toHaveClass(/markdown-doc-link-broken/, { timeout: 10_000 })
await page.screenshot({ path: testInfo.outputPath('live-document-removed.png') })
} finally {
if (app) {
await session.close(app)
}
await session.dispose()
host.cleanup()
}
})