Wait for retained editor ownership before listing Markdown documents (#26590)

This commit is contained in:
Neil
2026-10-08 22:45:11 -07:00
committed by GitHub
parent f3c0390d67
commit 45a969f83f
9 changed files with 389 additions and 16 deletions
+4 -3
View File
@@ -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:
+5 -2
View File
@@ -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
)
}
}
+26 -2
View File
@@ -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(
+9
View File
@@ -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'],
@@ -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)) {
@@ -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 })
@@ -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 }
)
})
@@ -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(
@@ -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<void> | 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()
}
})