diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index bb85ff7d729..257e263d043 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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: diff --git a/config/scripts/ci-e2e-job-selection.mjs b/config/scripts/ci-e2e-job-selection.mjs index 54a860188b0..27a903c4379 100644 --- a/config/scripts/ci-e2e-job-selection.mjs +++ b/config/scripts/ci-e2e-job-selection.mjs @@ -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 ) } } diff --git a/config/scripts/ci-e2e-job-selection.test.mjs b/config/scripts/ci-e2e-job-selection.test.mjs index 5b77c2501f8..f842d6b6cab 100644 --- a/config/scripts/ci-e2e-job-selection.test.mjs +++ b/config/scripts/ci-e2e-job-selection.test.mjs @@ -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) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 75b0bf524dc..17ba3f8e3cd 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -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'], diff --git a/src/renderer/src/components/editor/markdown-document-list-request.test.ts b/src/renderer/src/components/editor/markdown-document-list-request.test.ts index e94501a9140..d9a56114ea0 100644 --- a/src/renderer/src/components/editor/markdown-document-list-request.test.ts +++ b/src/renderer/src/components/editor/markdown-document-list-request.test.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() + const fresh = deferred() + 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() const retry = deferred() diff --git a/src/renderer/src/components/editor/markdown-document-list-request.ts b/src/renderer/src/components/editor/markdown-document-list-request.ts index a97ada9abbf..f88406704c8 100644 --- a/src/renderer/src/components/editor/markdown-document-list-request.ts +++ b/src/renderer/src/components/editor/markdown-document-list-request.ts @@ -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 } diff --git a/src/renderer/src/components/editor/markdown-internal-links.ts b/src/renderer/src/components/editor/markdown-internal-links.ts index b7801e378c6..693a84aed35 100644 --- a/src/renderer/src/components/editor/markdown-internal-links.ts +++ b/src/renderer/src/components/editor/markdown-internal-links.ts @@ -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 diff --git a/src/renderer/src/components/editor/use-markdown-document-watch-refresh.ts b/src/renderer/src/components/editor/use-markdown-document-watch-refresh.ts new file mode 100644 index 00000000000..4ad92602cf5 --- /dev/null +++ b/src/renderer/src/components/editor/use-markdown-document-watch-refresh.ts @@ -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 { + const owner = runtimeEnvironmentId?.trim() || null + + useEffect(() => { + if (!enabled || !worktreePath) { + return + } + let timer: ReturnType | 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]) +} diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.ts b/src/renderer/src/components/editor/useMarkdownDocuments.ts index 1c595308bf8..3e5b287d396 100644 --- a/src/renderer/src/components/editor/useMarkdownDocuments.ts +++ b/src/renderer/src/components/editor/useMarkdownDocuments.ts @@ -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 => { + async (requireFresh = false, freshAfter?: number): Promise => { 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] diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.watch.test.ts b/src/renderer/src/components/editor/useMarkdownDocuments.watch.test.ts new file mode 100644 index 00000000000..107504763d7 --- /dev/null +++ b/src/renderer/src/components/editor/useMarkdownDocuments.watch.test.ts @@ -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[] = [] +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 { + 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 { + 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((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((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) + }) +}) diff --git a/src/renderer/src/hooks/worktree-file-change-event.ts b/src/renderer/src/hooks/worktree-file-change-event.ts index 1cfe6fca1ae..db1627feec9 100644 --- a/src/renderer/src/hooks/worktree-file-change-event.ts +++ b/src/renderer/src/hooks/worktree-file-change-event.ts @@ -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 + } +} diff --git a/tests/e2e/ssh-orcad-markdown-live-documents.spec.ts b/tests/e2e/ssh-orcad-markdown-live-documents.spec.ts new file mode 100644 index 00000000000..2b8c71d74f1 --- /dev/null +++ b/tests/e2e/ssh-orcad-markdown-live-documents.spec.ts @@ -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() + } +})