mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user