From 45a969f83f7cee7316d7b3be20f73a5df556c017 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:45:11 -0700 Subject: [PATCH] Wait for retained editor ownership before listing Markdown documents (#26590) --- .github/workflows/e2e.yml | 7 +- config/scripts/ci-e2e-job-selection.mjs | 7 +- config/scripts/ci-e2e-job-selection.test.mjs | 28 ++- config/scripts/pr-e2e-source-routing.mjs | 9 + ...restored-editor-workspace-runtime-owner.ts | 9 +- .../useMarkdownDocuments.navigation.test.ts | 3 +- .../useMarkdownDocuments.ownership.test.tsx | 182 ++++++++++++++++++ .../components/editor/useMarkdownDocuments.ts | 40 +++- .../e2e/ssh-orcad-markdown-conversion.spec.ts | 120 ++++++++++++ 9 files changed, 389 insertions(+), 16 deletions(-) create mode 100644 src/renderer/src/components/editor/useMarkdownDocuments.ownership.test.tsx create mode 100644 tests/e2e/ssh-orcad-markdown-conversion.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 888ee173d96..58d3aa4dbd1 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -354,7 +354,8 @@ jobs: . != "tests/e2e/ssh-orcad-browser-capabilities.spec.ts" and . != "tests/e2e/ssh-orcad-browser-service-status.spec.ts" and . != "tests/e2e/ssh-orcad-browser-routing.spec.ts" and - . != "tests/e2e/ssh-orcad-editor-ownership.spec.ts" + . != "tests/e2e/ssh-orcad-editor-ownership.spec.ts" and + . != "tests/e2e/ssh-orcad-markdown-conversion.spec.ts" )' <<<"$TEST_FILES_JSON" > "$RUNNER_TEMP/general-e2e-specs" fi mapfile -t TEST_FILES < "$RUNNER_TEMP/general-e2e-specs" @@ -588,7 +589,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') + 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') runs-on: ubuntu-latest timeout-minutes: 45 env: @@ -624,7 +625,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 --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 --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 400de8e1f7b..5d55abc9801 100644 --- a/config/scripts/ci-e2e-job-selection.mjs +++ b/config/scripts/ci-e2e-job-selection.mjs @@ -57,6 +57,7 @@ export const ORCAD_BROWSER_SERVICE_STATUS_E2E_SPEC = 'tests/e2e/ssh-orcad-browser-service-status.spec.ts' export const ORCAD_BROWSER_ROUTING_E2E_SPEC = 'tests/e2e/ssh-orcad-browser-routing.spec.ts' export const ORCAD_EDITOR_OWNERSHIP_E2E_SPEC = 'tests/e2e/ssh-orcad-editor-ownership.spec.ts' +export const ORCAD_MARKDOWN_CONVERSION_E2E_SPEC = 'tests/e2e/ssh-orcad-markdown-conversion.spec.ts' export const DEDICATED_E2E_SPECS = [ ...DOCKER_SSH_E2E_SPECS, NODE_NETWORK_E2E_SPEC, @@ -70,7 +71,8 @@ export const DEDICATED_E2E_SPECS = [ ORCAD_BROWSER_CAPABILITIES_E2E_SPEC, ORCAD_BROWSER_SERVICE_STATUS_E2E_SPEC, ORCAD_BROWSER_ROUTING_E2E_SPEC, - ORCAD_EDITOR_OWNERSHIP_E2E_SPEC + ORCAD_EDITOR_OWNERSHIP_E2E_SPEC, + ORCAD_MARKDOWN_CONVERSION_E2E_SPEC ] const dedicatedSpecs = new Set(DEDICATED_E2E_SPECS) const dockerSpecs = new Set(DOCKER_SSH_E2E_SPECS) @@ -116,7 +118,8 @@ export function classifyE2eJobs(input, sshSourceChanged = 'false') { spec === ORCAD_BROWSER_CAPABILITIES_E2E_SPEC || spec === ORCAD_BROWSER_SERVICE_STATUS_E2E_SPEC || spec === ORCAD_BROWSER_ROUTING_E2E_SPEC || - spec === ORCAD_EDITOR_OWNERSHIP_E2E_SPEC + spec === ORCAD_EDITOR_OWNERSHIP_E2E_SPEC || + spec === ORCAD_MARKDOWN_CONVERSION_E2E_SPEC ) } } diff --git a/config/scripts/ci-e2e-job-selection.test.mjs b/config/scripts/ci-e2e-job-selection.test.mjs index 659150d5bb8..601a1d769be 100644 --- a/config/scripts/ci-e2e-job-selection.test.mjs +++ b/config/scripts/ci-e2e-job-selection.test.mjs @@ -50,7 +50,10 @@ it('skips the general consumer only when every requested spec has a dedicated ow } expect(classify(DEDICATED_E2E_SPECS).e2e_run_changed).toBe(false) const future = 'tests/e2e/future-unclassified.spec.ts' - expect(classify([future])).toEqual({ e2e_run_changed: true, e2e_needs_build: true }) + expect(classify([future])).toEqual({ + e2e_run_changed: true, + e2e_needs_build: true + }) expect(selectGeneralE2eSpecs([...DEDICATED_E2E_SPECS, future])).toEqual([future]) expect(classify([...DEDICATED_E2E_SPECS, future]).e2e_run_changed).toBe(true) }) @@ -181,7 +184,28 @@ it.each([ ])('routes %s to the template-building editor ownership lane', (file) => { const spec = 'tests/e2e/ssh-orcad-editor-ownership.spec.ts' expect(selectPrE2eSpecs([file])).toContain(spec) - expect(classify([spec])).toEqual({ e2e_run_changed: false, e2e_needs_build: true }) + 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 + ) +}) + +it.each([ + 'src/renderer/src/components/editor/useMarkdownDocuments.ts', + 'src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.ts', + 'src/renderer/src/components/editor/migrate-restored-editor-file-owner.ts' +])('routes %s to the template-building Markdown conversion lane', (file) => { + const spec = 'tests/e2e/ssh-orcad-markdown-conversion.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( diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index d14f71326ae..f3d53e1fb33 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -23,6 +23,15 @@ export const PR_E2E_SOURCE_ROUTES = [ file ) }, + { + id: 'ssh.orcad-markdown-conversion', + specs: ['tests/e2e/ssh-orcad-markdown-conversion.spec.ts'], + matches: (file) => + isProductSource(file) && + /^src\/renderer\/src\/components\/editor\/(?:useMarkdownDocuments|restored-editor-workspace-runtime-owner|migrate-restored-editor-file-owner)\.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/restored-editor-workspace-runtime-owner.ts b/src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.ts index 31489b42e15..1e2f7aedc73 100644 --- a/src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.ts +++ b/src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.ts @@ -19,7 +19,14 @@ export type RestoredEditorWorkspaceRuntimeOwner = { */ export function findRestoredEditorWorkspaceRuntimeOwner( state: AppState, - file: OpenFile, + file: Pick< + OpenFile, + | 'worktreeId' + | 'filePath' + | 'externalSshTargetId' + | 'operationProvenance' + | 'runtimeEnvironmentId' + >, worktreeId: string | undefined ): RestoredEditorWorkspaceRuntimeOwner | null { if (!worktreeId || file.worktreeId !== worktreeId || hasOpenFileExecutionHostEvidence(file)) { diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.navigation.test.ts b/src/renderer/src/components/editor/useMarkdownDocuments.navigation.test.ts index 25956dc5476..d2d328c5777 100644 --- a/src/renderer/src/components/editor/useMarkdownDocuments.navigation.test.ts +++ b/src/renderer/src/components/editor/useMarkdownDocuments.navigation.test.ts @@ -20,6 +20,7 @@ const target = { } const state = { settings: {}, + repos: [], worktreesByRepo: { repo: [{ id: 'wt', path: '/repo' }] }, openFile: vi.fn(), openMarkdownPreview: vi.fn() @@ -30,7 +31,7 @@ vi.mock('@/store', () => ({ getState: () => state }) })) -vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => runtimeConnectionId })) +vi.mock('@/lib/connection-context', () => ({ getConnectionIdFromState: () => runtimeConnectionId })) vi.mock('@/runtime/runtime-file-client', () => ({ statRuntimePath: runtime.stat })) vi.mock('@/runtime/runtime-rpc-client', () => ({ settingsForRuntimeOwner: (_settings: unknown, owner: string | null | undefined) => ({ owner }) diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.ownership.test.tsx b/src/renderer/src/components/editor/useMarkdownDocuments.ownership.test.tsx new file mode 100644 index 00000000000..854d3ab925b --- /dev/null +++ b/src/renderer/src/components/editor/useMarkdownDocuments.ownership.test.tsx @@ -0,0 +1,182 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { OpenFile } from '@/store/slices/editor' +import { makeWorktree } from '../../store/slices/worktrees-slice-test-fixtures' +import { useMarkdownDocuments } from './useMarkdownDocuments' + +const mocks = vi.hoisted(() => ({ list: vi.fn(), toastError: vi.fn() })) +vi.mock('./markdown-document-list-request', () => ({ + requestSharedMarkdownDocumentList: mocks.list +})) +vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) + +const initialState = useAppStore.getInitialState() +const worktreeId = 'repo-1::/remote/repo' +const file: OpenFile = { + id: 'restored', + filePath: '/remote/repo/README.md', + relativePath: 'README.md', + worktreeId, + language: 'markdown', + isDirty: false, + runtimeEnvironmentId: null, + mode: 'edit' +} +const save = vi.fn(async () => true) +const document = { + filePath: file.filePath, + relativePath: 'README.md', + basename: 'README.md', + name: 'README' +} + +beforeEach(() => { + useAppStore.setState(initialState, true) + mocks.list.mockReset().mockResolvedValue([document]) + mocks.toastError.mockClear() + useAppStore.setState({ + worktreesByRepo: { + 'repo-1': [makeWorktree({ id: worktreeId, repoId: 'repo-1', path: '/remote/repo' })] + } + }) +}) +afterEach(() => { + cleanup() + useAppStore.setState(initialState, true) +}) + +function render(file: OpenFile) { + return renderHook(({ file }) => useMarkdownDocuments(file, true, 'rich', save), { + initialProps: { file } + }) +} + +it('waits for the retained editor to take its managed workspace owner before listing', async () => { + useAppStore.setState({ + repos: [ + { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#737373', + addedAt: 1, + executionHostId: 'runtime:env-1' + } + ], + worktreesByRepo: { + 'repo-1': [ + makeWorktree({ + id: worktreeId, + repoId: 'repo-1', + path: '/remote/repo', + hostId: 'runtime:env-1', + runtimeOwnerEnvironmentId: 'env-1' + }) + ] + } + }) + const { result, rerender } = render(file) + expect(mocks.list).not.toHaveBeenCalled() + expect(mocks.toastError).not.toHaveBeenCalled() + rerender({ file: { ...file, id: 'owned', runtimeEnvironmentId: 'env-1' } }) + await waitFor(() => expect(result.current.markdownDocuments).toEqual([document])) + expect(mocks.list).toHaveBeenCalledTimes(1) + expect(mocks.list).toHaveBeenCalledWith( + expect.objectContaining({ settings: { activeRuntimeEnvironmentId: 'env-1' }, worktreeId }), + '/remote/repo', + { requireFresh: false } + ) +}) + +it('waits for missing ownership and resumes when the SSH catalog lands without a path change', async () => { + const { result } = render(file) + expect(mocks.list).not.toHaveBeenCalled() + act(() => + useAppStore.setState({ + repos: [ + { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#737373', + addedAt: 1, + connectionId: 'ssh-1' + } + ] + }) + ) + await waitFor(() => expect(result.current.markdownDocuments).toEqual([document])) + expect(mocks.list).toHaveBeenCalledTimes(1) + expect(mocks.list).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'ssh-1' }), + '/remote/repo', + { requireFresh: false } + ) +}) + +it('uses an explicit server owner even before its desktop repository catalog is present', async () => { + const { result } = render({ ...file, runtimeEnvironmentId: 'env-1' }) + await waitFor(() => expect(result.current.markdownDocuments).toEqual([document])) + expect(mocks.list).toHaveBeenCalledTimes(1) + expect(mocks.list).toHaveBeenCalledWith( + expect.objectContaining({ settings: { activeRuntimeEnvironmentId: 'env-1' } }), + '/remote/repo', + { requireFresh: false } + ) +}) + +it('waits for ownership migration in a non-git folder workspace too', async () => { + useAppStore.setState({ + worktreesByRepo: {}, + projectGroups: [ + { + id: 'group-1', + name: 'Notes', + parentPath: '/notes', + connectionId: null, + executionHostId: 'runtime:env-1', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + ], + folderWorkspaces: [ + { + id: 'notes', + projectGroupId: 'group-1', + name: 'Notes', + folderPath: '/notes', + connectionId: null, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + } + ] + }) + const folderFile = { ...file, worktreeId: 'folder:notes', filePath: '/notes/README.md' } + const { result, rerender } = render(folderFile) + expect(mocks.list).not.toHaveBeenCalled() + rerender({ file: { ...folderFile, id: 'folder-owned', runtimeEnvironmentId: 'env-1' } }) + await waitFor(() => expect(result.current.markdownDocuments).toEqual([document])) + expect(mocks.list).toHaveBeenCalledWith( + expect.objectContaining({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'folder:notes' + }), + '/notes', + { requireFresh: false } + ) +}) diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.ts b/src/renderer/src/components/editor/useMarkdownDocuments.ts index 6a7a087a9d2..1c595308bf8 100644 --- a/src/renderer/src/components/editor/useMarkdownDocuments.ts +++ b/src/renderer/src/components/editor/useMarkdownDocuments.ts @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { MarkdownDocument } from '../../../../shared/filesystem-entry-types' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' -import { getConnectionId } from '@/lib/connection-context' +import { getConnectionIdFromState } from '@/lib/connection-context' import { statRuntimePath } from '@/runtime/runtime-file-client' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor' @@ -14,6 +14,7 @@ import { } from './markdown-doc-links' import { selectMarkdownDocumentWorktreePath } from './markdown-document-worktree-path-selector' import { requestSharedMarkdownDocumentList } from './markdown-document-list-request' +import { findRestoredEditorWorkspaceRuntimeOwner } from './restored-editor-workspace-runtime-owner' type OpenMarkdownDocumentOptions = { anchor?: string | null @@ -61,7 +62,7 @@ export function useMarkdownDocuments( const worktreePath = useAppStore((s) => selectMarkdownDocumentWorktreePath(s, worktreeId)) const openFile = useAppStore((s) => s.openFile) const openMarkdownPreview = useAppStore((s) => s.openMarkdownPreview) - const connectionId = getConnectionId(worktreeId) + const connectionId = useAppStore((state) => getConnectionIdFromState(state, worktreeId)) const scopeKey = JSON.stringify([ activeFile.runtimeEnvironmentId, connectionId, @@ -78,16 +79,32 @@ export function useMarkdownDocuments( if (!worktreeId || !worktreePath) { return } + const state = useAppStore.getState() + const settings = settingsForRuntimeOwner(state.settings, activeFile.runtimeEnvironmentId) + // The content loader reowns retained tabs before metadata may use their new host. + if ( + findRestoredEditorWorkspaceRuntimeOwner( + state, + { + worktreeId, + filePath: activeFile.filePath, + externalSshTargetId: activeFile.externalSshTargetId, + operationProvenance: activeFile.operationProvenance, + runtimeEnvironmentId: activeFile.runtimeEnvironmentId + }, + worktreeId + ) || + (connectionId === undefined && !settings?.activeRuntimeEnvironmentId?.trim()) + ) { + return + } const requestId = requestRef.current + 1 requestRef.current = requestId try { const documents = await requestSharedMarkdownDocumentList( { - settings: settingsForRuntimeOwner( - useAppStore.getState().settings, - activeFile.runtimeEnvironmentId - ), + settings, worktreeId, worktreePath, connectionId: connectionId ?? undefined @@ -116,7 +133,16 @@ export function useMarkdownDocuments( } } }, - [activeFile.runtimeEnvironmentId, connectionId, worktreeId, worktreePath, scopeKey] + [ + activeFile.filePath, + activeFile.externalSshTargetId, + activeFile.operationProvenance, + activeFile.runtimeEnvironmentId, + connectionId, + worktreeId, + worktreePath, + scopeKey + ] ) const openMarkdownDocument = useCallback( diff --git a/tests/e2e/ssh-orcad-markdown-conversion.spec.ts b/tests/e2e/ssh-orcad-markdown-conversion.spec.ts new file mode 100644 index 00000000000..93f2b43e28b --- /dev/null +++ b/tests/e2e/ssh-orcad-markdown-conversion.spec.ts @@ -0,0 +1,120 @@ +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 } 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('retained Markdown lists documents on its managed owner after conversion', 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[[NOTES]]\n' > '${host.remoteRepoPath}/README.md'` + ) + host.exec!(`printf '# SIBLING_${marker}\n' > '${host.remoteRepoPath}/NOTES.md'`) + const seeded = seedRelayEraProfile(session.userDataDir, host.input, { + repoPath: host.remoteRepoPath, + folderPath: host.remoteFolderPath + }) + const launched = await session.launch() + app = launched.app + const page = launched.page + const errors: string[] = [] + let failureScreenshot: Promise | undefined + page.on('console', (message) => { + if (message.type() === 'error') { + errors.push(message.text()) + console.log('[renderer-error]', message.text()) + if (message.text().includes('Failed to list markdown documents') && !failureScreenshot) { + failureScreenshot = (async () => { + await page.screenshot({ path: testInfo.outputPath('markdown-list-failure.png') }) + })() + } + } + }) + 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 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 }) + console.log( + '[markdown-conversion]', + JSON.stringify({ + errors, + files: await page.evaluate(() => window.__store?.getState().openFiles) + }) + ) + await page.screenshot({ path: testInfo.outputPath('markdown-converted.png') }) + await failureScreenshot + expect(errors.filter((e) => e.includes('Failed to list markdown documents'))).toEqual([]) + expect( + await serverCall(page, environment.id, 'files.listMarkdownDocuments', { + worktree: `id:${seeded.worktreeId}` + }) + ).toContain('NOTES.md') + const link = page.locator('.rich-markdown-editor [data-doc-link-target="NOTES"]') + await expect(link).toBeVisible() + console.log('[rich-link-state]', await link.getAttribute('class')) + 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 previewLink = page + .locator('.markdown-preview') + .getByRole('link', { name: 'NOTES', exact: true }) + await expect(previewLink).toBeVisible() + await expect(previewLink).not.toHaveClass(/markdown-doc-link-broken/) + await previewLink.click() + await expect( + page.getByRole('heading', { name: `SIBLING_${marker}`, exact: true }) + ).toBeVisible() + expect(errors.filter((e) => e.includes('Failed to list markdown documents'))).toEqual([]) + await page.screenshot({ path: testInfo.outputPath('markdown-converted-ready.png') }) + } finally { + if (app) { + await session.close(app) + } + await session.dispose() + host.cleanup() + } +})