diff --git a/src/renderer/src/components/sidebar/folder-workspace-host-id.ts b/src/renderer/src/components/sidebar/folder-workspace-host-id.ts index a29a0e9baf53..62acd84af13d 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-host-id.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-host-id.ts @@ -1,21 +1,13 @@ -import { toSshExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' import type { ProjectGroup } from '../../../../shared/project-group-types' +import { getFolderWorkspaceExecutionHostIdForRows } from './worktree-list/listing/host-filtering' -/** - * Which host section a folder workspace's row belongs to. - * - * Single source of truth: header counting, host bucketing and reveal all resolve - * the host through here. Two resolvers that drift is how folder workspaces went - * missing from the sidebar in the first place (#15362). Deliberately distinct - * from the host *filter* resolver, which also consults executionHostId — this - * one must match where the row actually renders. - */ +/** Host headers, filtering and reveal must agree on the folder's execution owner. */ export function getFolderWorkspaceHostId( - folderWorkspace: Pick, - projectGroup: Pick, + folderWorkspace: Pick, + projectGroup: Pick, defaultHostId: ExecutionHostId ): ExecutionHostId { - const connectionId = folderWorkspace.connectionId ?? projectGroup.connectionId - return connectionId ? toSshExecutionHostId(connectionId) : defaultHostId + return getFolderWorkspaceExecutionHostIdForRows({ folderWorkspace, projectGroup, defaultHostId }) } diff --git a/src/renderer/src/components/sidebar/host-section-collapse-folders.test.ts b/src/renderer/src/components/sidebar/host-section-collapse-folders.test.ts index c2382e93fceb..38a3fb736a5e 100644 --- a/src/renderer/src/components/sidebar/host-section-collapse-folders.test.ts +++ b/src/renderer/src/components/sidebar/host-section-collapse-folders.test.ts @@ -19,88 +19,106 @@ const localGroup: ProjectGroup = { createdAt: 1, updatedAt: 1 } -const remoteGroup = { ...localGroup, id: 'remote-group', connectionId: 'builder' } const local = makeFolderWorkspace({ id: 'local-folder', projectGroupId: localGroup.id }) -const remote = makeFolderWorkspace({ - id: 'remote-folder', - projectGroupId: remoteGroup.id, - connectionId: 'builder' -}) -for (const [groupBy, key] of [ - ['none', 'all'], - ['workspace-status', 'workspace-status:in-progress'], - ['pr-status', 'pr:in-progress'] -] as const) { - const rows = buildRows( - groupBy, - [], - new Map(), - null, - new Set(), - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - [localGroup, remoteGroup], - undefined, - undefined, - undefined, - undefined, - undefined, - [local, remote] - ) - const sectioned = addHostSectionRows({ - rows, - hostOptions: [ - { id: 'local', kind: 'local', label: 'Local', detail: '', health: 'local' }, - { id: 'ssh:builder', kind: 'ssh', label: 'Remote', detail: '', health: 'available' } - ], - workspaceHostScope: 'all', - visibleWorkspaceHostIds: ['local', 'ssh:builder'], - defaultHostId: 'local' +for (const hostId of ['ssh:builder', 'runtime:managed-builder'] as const) { + const remoteGroup = { + ...localGroup, + id: 'remote-group', + connectionId: 'builder', + executionHostId: hostId + } + const remote = makeFolderWorkspace({ + id: 'remote-folder', + projectGroupId: remoteGroup.id, + connectionId: 'builder', + executionHostId: hostId }) - it(`${groupBy}: collapses folder workspaces only on the owning host`, () => { - const collapsed = scopeHostSectionCollapse({ rows: sectioned, collapsedGroups: new Set([key]) }) - expect( - collapsed - .filter((row) => row.type === 'folder-workspace') - .map((row) => row.folderWorkspace.id) - ).toEqual([remote.id]) - const remoteCollapsed = scopeHostSectionCollapse({ - rows: sectioned, - collapsedGroups: new Set([`${key}:host:ssh:builder`]) - }) - expect( - remoteCollapsed - .filter((row) => row.type === 'folder-workspace') - .map((row) => row.folderWorkspace.id) - ).toEqual([local.id]) - }) - - it(`${groupBy}: reveals a remote folder using the rendered host lane key`, () => { - const keys = getFolderWorkspaceRevealGroupKeys( - folderWorkspaceKey(remote.id), - [local, remote], + for (const [groupBy, key] of [ + ['none', 'all'], + ['workspace-status', 'workspace-status:in-progress'], + ['pr-status', 'pr:in-progress'] + ] as const) { + const rows = buildRows( + groupBy, + [], + new Map(), + null, + new Set(), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, [localGroup, remoteGroup], - { - groupBy, - defaultHostId: 'local', - hostScopedGroups: true - } + undefined, + undefined, + undefined, + undefined, + undefined, + [local, remote] ) - expect(keys).toContain(`${key}:host:ssh:builder`) - expect(keys).toContain('host:ssh:builder') - expect(keys).not.toContain(key) - expect( - getFolderWorkspaceRevealGroupKeys(folderWorkspaceKey(remote.id), [remote], [remoteGroup], { - groupBy, - defaultHostId: 'local' + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { id: 'local', kind: 'local', label: 'Local', detail: '', health: 'local' }, + { + id: hostId, + kind: hostId.startsWith('runtime:') ? 'runtime' : 'ssh', + label: 'Remote', + detail: '', + health: 'available' + } + ], + workspaceHostScope: 'all', + visibleWorkspaceHostIds: ['local', hostId], + defaultHostId: 'local' + }) + + it(`${hostId} ${groupBy}: collapses folder workspaces only on the owning host`, () => { + const collapsed = scopeHostSectionCollapse({ + rows: sectioned, + collapsedGroups: new Set([key]) }) - ).toContain(key) - }) + expect( + collapsed + .filter((row) => row.type === 'folder-workspace') + .map((row) => row.folderWorkspace.id) + ).toEqual([remote.id]) + const remoteCollapsed = scopeHostSectionCollapse({ + rows: sectioned, + collapsedGroups: new Set([`${key}:host:${hostId}`]) + }) + expect( + remoteCollapsed + .filter((row) => row.type === 'folder-workspace') + .map((row) => row.folderWorkspace.id) + ).toEqual([local.id]) + }) + + it(`${hostId} ${groupBy}: reveals a remote folder using the rendered host lane key`, () => { + const keys = getFolderWorkspaceRevealGroupKeys( + folderWorkspaceKey(remote.id), + [local, remote], + [localGroup, remoteGroup], + { + groupBy, + defaultHostId: 'local', + hostScopedGroups: true + } + ) + expect(keys).toContain(`${key}:host:${hostId}`) + expect(keys).toContain(`host:${hostId}`) + expect(keys).not.toContain(key) + expect( + getFolderWorkspaceRevealGroupKeys(folderWorkspaceKey(remote.id), [remote], [remoteGroup], { + groupBy, + defaultHostId: 'local' + }) + ).toContain(key) + }) + } } diff --git a/tests/e2e/ssh-orcad-auto-convert.spec.ts b/tests/e2e/ssh-orcad-auto-convert.spec.ts index 7f9f7f0c3f27..fc99866f01a1 100644 --- a/tests/e2e/ssh-orcad-auto-convert.spec.ts +++ b/tests/e2e/ssh-orcad-auto-convert.spec.ts @@ -213,8 +213,9 @@ test('a relay host converts to managed orcad on connect and keeps its source', a }) }) -test('a relay-era profile converts its SSH host on the first connect after upgrading', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. -{}, testInfo) => { +test('a relay-era profile converts its SSH host on the first connect after upgrading', async ({ + testRepoPath +}, testInfo) => { test.setTimeout(20 * 60_000) const host = startHost(testInfo) const session = createRestartSession(testInfo, { @@ -226,6 +227,7 @@ test('a relay-era profile converts its SSH host on the first connect after upgra const first = await session.launch() app = first.app await waitForSessionReady(first.page) + await first.page.evaluate((repoPath) => window.api.repos.add({ path: repoPath }), testRepoPath) await session.close(app) app = null const seeded = seedRelayEraProfile(session.userDataDir, host.input, { @@ -244,6 +246,28 @@ test('a relay-era profile converts its SSH host on the first connect after upgra app = upgraded.app await waitForSessionReady(upgraded.page) await convertAndRetain(upgraded.page, session.userDataDir, seeded) + const environment = ( + await upgraded.page.evaluate(() => window.api.runtimeEnvironments.list()) + ).find((entry) => entry.orcadDeployment?.sshTargetId === seeded.targetId) + if (!environment) { + throw new Error('Converted host is missing from the environment catalog') + } + const hostId = `runtime:${environment.id}` as const + await upgraded.page.evaluate((hostId) => { + const state = window.__store!.getState() + state.setGroupBy('none') + window.__store!.setState({ visibleWorkspaceHostIds: ['local', hostId] }) + }, hostId) + const folder = upgraded.page.getByText('orcad upgrade folder', { exact: true }) + await expect(folder).toBeVisible() + await upgraded.page.screenshot({ path: testInfo.outputPath('managed-folder-host-section.png') }) + const sectionHeader = folder.locator('xpath=preceding::*[@data-host-header-drag-id][1]') + await expect(sectionHeader).toHaveAttribute('data-host-header-drag-id', hostId) + const managedHeader = upgraded.page.locator(`[data-host-header-drag-id="${hostId}"]`) + await managedHeader.click() + await expect(folder).toBeHidden() + await managedHeader.click() + await expect(folder).toBeVisible() } finally { if (app) { await session.close(app)