diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index f5857a33e13..167dadbb988 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -10,9 +10,10 @@ import { type BackgroundMountTerminalWorktreeDetail } from '@/constants/terminal' import { useAppStore } from '../store' -import { folderWorkspaceKey } from '../../../shared/workspace-scope' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' -import { useAllWorktrees } from '../store/selectors' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { useWorktreeMap } from '../store/selectors' +import { projectWorkspaceSurfaces } from './workspace-surface-projection' import { getConnectionId } from '../lib/connection-context' import { basename } from '../lib/path' import { @@ -321,23 +322,29 @@ function Terminal(): React.JSX.Element | null { const measuringTerminalWorktreeIdsRef = useRef(new Set()) const terminalWorktreeParkCooldownUntilRef = useRef(new Map()) const terminalWorktreeParkingTimersRef = useRef(new Map()) - const allWorktrees = useAllWorktrees() + const worktreesById = useWorktreeMap() const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) - const workspaceSurfaces = useMemo( - () => [ - ...allWorktrees.map((worktree) => ({ id: worktree.id, path: worktree.path })), - ...folderWorkspaces.map((workspace) => ({ - id: folderWorkspaceKey(workspace.id), - path: workspace.folderPath - })) - ], - [allWorktrees, folderWorkspaces] - ) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const renderedActiveWorktreeId = activeWorktreeId const activeWorktreeDeferralHostId = useAppStore((s) => getResolvedExecutionHostIdForWorktree(s, renderedActiveWorktreeId) ) + // Why narrow it: only the folder-collision tie-break reads this host, so a git + // workspace's ownership settling must not re-identify the whole mount projection. + const activeFolderSurfaceHostId = + parseWorkspaceKey(renderedActiveWorktreeId ?? '')?.type === 'folder' + ? activeWorktreeDeferralHostId + : null + const workspaceSurfaces = useMemo( + () => + projectWorkspaceSurfaces({ + worktreesById, + folderWorkspaces, + activeWorkspaceId: renderedActiveWorktreeId, + activeWorkspaceResolvedHostId: activeFolderSurfaceHostId + }), + [worktreesById, folderWorkspaces, renderedActiveWorktreeId, activeFolderSurfaceHostId] + ) const activeView = useAppStore((s) => s.activeView) // Why: terminal titles are leaf chrome. The root host only subscribes to // mount/parking semantics; a real transition publishes fresh tab objects, diff --git a/src/renderer/src/components/workspace-surface-projection.test.ts b/src/renderer/src/components/workspace-surface-projection.test.ts new file mode 100644 index 00000000000..016184d001a --- /dev/null +++ b/src/renderer/src/components/workspace-surface-projection.test.ts @@ -0,0 +1,187 @@ +/** + * STA-4846: the terminal workbench is bare-workspace-id keyed end to end + * (`activeWorktreeId`, `tabsByWorktree`, `mountedWorktreeIdsRef`, React keys), + * but its catalog inputs are host-qualified — `getIndexedAllWorktrees` emits one + * row per (host, id) and `mergeFetchedFolderWorkspacesForHost` keeps one folder + * row per (host, id). A repo checked out at the same path locally and on an + * SSH/paired-runtime host therefore reached the mount loops twice, mounting the + * same tabIds under duplicate React keys with both trees marked visible. + */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { projectWorkspaceSurfaces } from './workspace-surface-projection' +import { getIndexedWorktreeMap } from '../store/worktree-repo-index' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import type { Worktree } from '../../../shared/worktree/types' + +const SHARED_WORKTREE_ID = 'repo-shared::/work/orca-feature' + +// A production collision differs only by host: `worktreeId` is `repoId::path`, +// so both rows necessarily carry the same repoId and path (see STA-4343's +// sidebar/worktree-list-groups-host-collision.test.ts). +const localWorktree: Worktree = { + id: SHARED_WORKTREE_ID, + repoId: 'repo-shared', + path: '/work/orca-feature', + hostId: 'local', + head: 'abc123', + branch: 'feature', + isBare: false, + isMainWorktree: false, + displayName: 'orca-feature', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1 +} +const sshWorktree: Worktree = { ...localWorktree, hostId: 'ssh:build-box' } + +const localFolder: FolderWorkspace = { + id: 'folder-shared', + projectGroupId: 'group-shared', + name: 'orca', + folderPath: '/work/orca-local', + executionHostId: 'local', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 +} +const runtimeFolder: FolderWorkspace = { + ...localFolder, + folderPath: '/remote/orca', + executionHostId: 'runtime:env-1' +} + +function project(input: { + worktrees?: readonly Worktree[] + folderWorkspaces?: readonly FolderWorkspace[] + activeWorkspaceId?: string | null + activeWorkspaceResolvedHostId?: ExecutionHostId | null +}): ReturnType { + return projectWorkspaceSurfaces({ + // Built through the production index so the test pins the composition the + // workbench actually runs, not a re-implementation of the per-id collapse. + worktreesById: getIndexedWorktreeMap({ 'repo-shared': [...(input.worktrees ?? [])] }), + folderWorkspaces: input.folderWorkspaces ?? [], + activeWorkspaceId: input.activeWorkspaceId ?? null, + activeWorkspaceResolvedHostId: input.activeWorkspaceResolvedHostId ?? null + }) +} + +describe('projectWorkspaceSurfaces', () => { + it('emits one surface per workspace id when two hosts publish the same worktree', () => { + const surfaces = project({ worktrees: [localWorktree, sshWorktree] }) + + expect(surfaces).toEqual([{ id: SHARED_WORKTREE_ID, path: '/work/orca-feature' }]) + }) + + it('never emits a duplicate id, so mount loops cannot reuse a React key', () => { + const surfaces = project({ + worktrees: [localWorktree, sshWorktree], + folderWorkspaces: [localFolder, runtimeFolder] + }) + + expect(new Set(surfaces.map((surface) => surface.id)).size).toBe(surfaces.length) + }) + + it('emits one surface per folder workspace id across hosts', () => { + const surfaces = project({ folderWorkspaces: [localFolder, runtimeFolder] }) + + expect(surfaces).toHaveLength(1) + expect(surfaces[0]?.id).toBe('folder:folder-shared') + }) + + it('mounts the active folder workspace at its resolved host path, not the first row', () => { + const surfaces = project({ + folderWorkspaces: [localFolder, runtimeFolder], + activeWorkspaceId: 'folder:folder-shared', + activeWorkspaceResolvedHostId: 'runtime:env-1' + }) + + expect(surfaces).toEqual([{ id: 'folder:folder-shared', path: '/remote/orca' }]) + }) + + it('keeps the first row when no resolved host disambiguates the folder collision', () => { + const surfaces = project({ folderWorkspaces: [runtimeFolder, localFolder] }) + + expect(surfaces).toEqual([{ id: 'folder:folder-shared', path: '/remote/orca' }]) + }) + + it('switches the active folder path from first-wins to the host that hydrates', () => { + // Ownership resolves to null while the folder-owner index still reads the + // colliding id as ambiguous, so the projection first-wins until it lands. + const folderWorkspaces = [localFolder, runtimeFolder] + const hydrating = project({ + folderWorkspaces, + activeWorkspaceId: 'folder:folder-shared', + activeWorkspaceResolvedHostId: null + }) + const hydrated = project({ + folderWorkspaces, + activeWorkspaceId: 'folder:folder-shared', + activeWorkspaceResolvedHostId: 'runtime:env-1' + }) + + expect(hydrating).toEqual([{ id: 'folder:folder-shared', path: '/work/orca-local' }]) + expect(hydrated).toEqual([{ id: 'folder:folder-shared', path: '/remote/orca' }]) + // The id is what mounts; only the path moves, so the transition is no remount. + expect(hydrated[0]?.id).toBe(hydrating[0]?.id) + }) + + it('keeps the surviving host row when a runtime disconnect drops its peer', () => { + // Loss of contact with the runtime must not unmount the workspace: the + // mount prune drops ids that leave the projection. + const surfaces = project({ worktrees: [localWorktree] }) + + expect(surfaces).toEqual([{ id: SHARED_WORKTREE_ID, path: '/work/orca-feature' }]) + }) + + it('keeps distinct workspaces on one host', () => { + const otherWorktree: Worktree = { + ...localWorktree, + id: 'repo-shared::/work/orca-other', + path: '/work/orca-other' + } + + expect(project({ worktrees: [localWorktree, otherWorktree] })).toEqual([ + { id: SHARED_WORKTREE_ID, path: '/work/orca-feature' }, + { id: 'repo-shared::/work/orca-other', path: '/work/orca-other' } + ]) + }) +}) + +// Why source text: the per-id collapse lives in the store index, so the module +// tests above stay green even if Terminal.tsx goes back to flattening the +// host-qualified array itself. The feed is the half that has to be ratcheted. +describe('Terminal workbench surface feed', () => { + const source = readFileSync( + join(process.cwd(), 'src/renderer/src/components/Terminal.tsx'), + 'utf8' + ) + + it('feeds the projection from the store per-id index, never the host-qualified array', () => { + expect(source).not.toContain('useAllWorktrees') + expect(source).toContain('const worktreesById = useWorktreeMap()') + }) + + it('has exactly one projection call site, so no second flatten can hide beside it', () => { + expect( + source.split('projectWorkspaceSurfaces(').length - 1, + 'expected exactly one projectWorkspaceSurfaces call in Terminal.tsx' + ).toBe(1) + expect(source).toContain('worktreesById,\n folderWorkspaces,') + }) +}) diff --git a/src/renderer/src/components/workspace-surface-projection.ts b/src/renderer/src/components/workspace-surface-projection.ts new file mode 100644 index 00000000000..f90d416d095 --- /dev/null +++ b/src/renderer/src/components/workspace-surface-projection.ts @@ -0,0 +1,66 @@ +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import type { Worktree } from '../../../shared/worktree/types' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { getCatalogOwnerHostId } from '../lib/worktree-runtime-owner-index' + +export type WorkspaceSurface = { id: string; path: string } + +type FolderWorkspaceSurfaceRow = Pick< + FolderWorkspace, + 'id' | 'folderPath' | 'connectionId' | 'executionHostId' +> + +/** + * The terminal workbench's mount set: exactly one surface per workspace id. + * + * Why collapse here and nowhere else: the workbench is bare-id keyed end to end + * (`activeWorktreeId`, `tabsByWorktree`, `mountedWorktreeIdsRef`, React keys), + * so it can only represent one surface per id — but both catalogs it reads are + * host-qualified on purpose (STA-4343), keeping a row per (host, id). Emitting + * both mounts one workspace's tabs twice under a duplicate React key. Listing + * surfaces such as the sidebar must keep showing every host. + * + * `worktreesById` is the store's first-wins per-id index, and that collapse is + * lossless: `worktreeId` is `repoId::path`, so colliding rows agree on the path. + */ +export function projectWorkspaceSurfaces({ + worktreesById, + folderWorkspaces, + activeWorkspaceId, + activeWorkspaceResolvedHostId +}: { + worktreesById: ReadonlyMap> + folderWorkspaces: readonly FolderWorkspaceSurfaceRow[] + activeWorkspaceId: string | null + /** Resolved (not user-selected) host of the active workspace; the folder tie-break. */ + activeWorkspaceResolvedHostId: ExecutionHostId | null +}): WorkspaceSurface[] { + const surfaces: WorkspaceSurface[] = [] + for (const [worktreeId, worktree] of worktreesById) { + surfaces.push({ id: worktreeId, path: worktree.path }) + } + const folderSurfaceIndexById = new Map() + for (const workspace of folderWorkspaces) { + const id = folderWorkspaceKey(workspace.id) + const surface = { id, path: workspace.folderPath } + const existingIndex = folderSurfaceIndexById.get(id) + if (existingIndex === undefined) { + folderSurfaceIndexById.set(id, surfaces.length) + surfaces.push(surface) + continue + } + // Why: a folder-workspace id is opaque, not path-derived, so colliding hosts + // disagree on the path; only the active workspace's resolved host breaks the tie. + // Deriving that host from the row alone is sufficient because every stored row is + // stamped with an explicit `executionHostId` by `folderWorkspaceWithFetchedOwner`. + if ( + activeWorkspaceResolvedHostId && + id === activeWorkspaceId && + getCatalogOwnerHostId(workspace) === activeWorkspaceResolvedHostId + ) { + surfaces[existingIndex] = surface + } + } + return surfaces +} diff --git a/tests/e2e/helpers/host-session-tabs.ts b/tests/e2e/helpers/host-session-tabs.ts index cfe504756bb..ed0b7be561d 100644 --- a/tests/e2e/helpers/host-session-tabs.ts +++ b/tests/e2e/helpers/host-session-tabs.ts @@ -16,22 +16,37 @@ export async function readHostTabs( return response.result } +type HostBrowserPageRow = { browserPageId: string; url: string } + /** - * The browser pages the host itself still holds for a worktree. + * The host's own browser page registry for a workspace, addressed by worktree selector. * * Why not readHostTabs: a headless paired host does not project browser pages into - * session.tabs.list — that snapshot carries only terminals — so asking it whether a page survived - * a close answers "no" whether or not the close ever reached the host. browser.tabList reads the - * host's own page registry, which is the thing a close has to empty. + * session.tabs.list — that snapshot carries only terminals, and it additionally hides client-placed + * pages from any peer that does not advertise `BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY`, which the + * CLI socket deliberately does not. `browser.tabList` has neither limitation, so it is the only + * oracle that answers "does the host still hold this page" for both placements. */ +async function readHostBrowserPages( + hostClient: RuntimeClient, + worktreeSelector: string, + timeoutMs?: number +): Promise { + const response = await hostClient.call<{ tabs: HostBrowserPageRow[] }>( + 'browser.tabList', + { worktree: worktreeSelector }, + { timeoutMs } + ) + return response.result.tabs +} + +/** The page ids the host still holds for a repo-backed worktree. */ export async function readHostBrowserPageIds( hostClient: RuntimeClient, repoPath: string ): Promise { - const response = await hostClient.call<{ tabs: { browserPageId: string }[] }>('browser.tabList', { - worktree: `path:${repoPath}` - }) - return response.result.tabs.map((tab) => tab.browserPageId).sort() + const tabs = await readHostBrowserPages(hostClient, `path:${repoPath}`) + return tabs.map((tab) => tab.browserPageId).sort() } /** @@ -46,9 +61,21 @@ export async function readHostBrowserPageUrl( repoPath: string, browserPageId: string ): Promise { - const response = await hostClient.call<{ tabs: { browserPageId: string; url: string }[] }>( - 'browser.tabList', - { worktree: `path:${repoPath}` } - ) - return response.result.tabs.find((tab) => tab.browserPageId === browserPageId)?.url ?? null + const tabs = await readHostBrowserPages(hostClient, `path:${repoPath}`) + return tabs.find((tab) => tab.browserPageId === browserPageId)?.url ?? null +} + +/** + * Every browser page URL the host holds, for callers that address the workspace by selector + * (`id:`/`path:`) rather than repo path — a folder workspace has no repo path. + * + * Why the explicit ceiling: a paired host's client is constructed with a 5s default, which the + * first tabList can outrun while the host brings its browser session up. + */ +export async function readHostBrowserPageUrls( + hostClient: RuntimeClient, + worktreeSelector: string +): Promise { + const tabs = await readHostBrowserPages(hostClient, worktreeSelector, 15_000) + return tabs.map((tab) => tab.url) } diff --git a/tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts b/tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts index be2082b3abb..91797ba00c8 100644 --- a/tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts +++ b/tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts @@ -5,7 +5,11 @@ import { launchPairedElectronClient, type PairedElectronClient } from './helpers/paired-electron-client' -import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + waitForActiveWorktree, + waitForSessionReady, + waitForStartupWorktreeRefresh +} from './helpers/store' test('routes same-id browser and simulator Cmd-J rows to their owning paired host', async ({ orcaPage @@ -66,6 +70,9 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos { timeout: 60_000, message: 'paired client never mirrored the host browser tab' } ) .toBe(remoteHostId) + // Why: hydration's deferred all-host scan rewrites worktreesByRepo; seeding ahead of it is silently reaped. + await waitForStartupWorktreeRefresh(page) + // Why: empties the mirror's reachable-target set, so the session.tabs subscription tears down before seeding. await page.evaluate(() => { window.__store!.setState({ runtimeEnvironments: [], @@ -113,6 +120,24 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos ) { throw new Error('Paired client did not retain the mirrored host browser topology') } + // Why: the fixture nests the client's own layout under its local pane, so the remote group + // has to already be rendered there — otherwise the remote rows silently stop being visible. + const renderedGroupIds = new Set() + const pendingLayoutNodes = [state.layoutByWorktree[sharedWorktreeId]] + while (pendingLayoutNodes.length > 0) { + const node = pendingLayoutNodes.pop() + if (!node) { + continue + } + if (node.type === 'leaf') { + renderedGroupIds.add(node.groupId) + continue + } + pendingLayoutNodes.push(node.first, node.second) + } + if (renderedGroupIds.size > 0 && !renderedGroupIds.has(remoteGroup.id)) { + throw new Error('Paired client layout does not render the mirrored host browser group') + } const local = { ...seed, hostId: 'local' as const, @@ -162,9 +187,17 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos sortOrder: 0, createdAt: 1 }) + // Why: a session.tabs frame rebuilds the host's live tabs from the snapshot either way; + // keeping their ids in the groups' tabOrder is what makes placement treat them as already + // known, so the frame re-lands them where they are instead of adopting them into a group. + const retainedMirroredTabs = (state.unifiedTabsByWorktree[sharedWorktreeId] ?? []).map( + (candidate) => + candidate.id === remoteUnifiedTab.id + ? { ...candidate, executionHostId: remoteHostId } + : candidate + ) const tabs = [ tab('browser-tab-local', 'browser-local', 'group-local', 'local', 'browser', 'Local'), - { ...remoteUnifiedTab, executionHostId: remoteHostId }, tab( 'simulator-local', 'simulator-local', @@ -180,15 +213,16 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos remoteHostId, 'simulator', 'Remote emulator proof' - ) + ), + ...retainedMirroredTabs ] store.setState({ - repos: [{ ...seedRepo, connectionId: null, executionHostId: 'local' }, seedRepo], - worktreesByRepo: { [seed.repoId]: [local, remote] }, + worktreesByRepo: { ...state.worktreesByRepo, [seed.repoId]: [local, remote] }, activeRepoId: seed.repoId, activeWorktreeId: sharedWorktreeId, activeWorkspaceExecutionHostId: 'local', browserTabsByWorktree: { + ...state.browserTabsByWorktree, [sharedWorktreeId]: [localBrowser, seededRemoteBrowser] }, browserPagesByWorkspace: { @@ -212,8 +246,9 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos } ] }, - unifiedTabsByWorktree: { [sharedWorktreeId]: tabs }, + unifiedTabsByWorktree: { ...state.unifiedTabsByWorktree, [sharedWorktreeId]: tabs }, groupsByWorktree: { + ...state.groupsByWorktree, [sharedWorktreeId]: [ { id: 'group-local', @@ -221,28 +256,45 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos activeTabId: 'browser-tab-local', tabOrder: ['browser-tab-local', 'simulator-local'] }, - { - ...remoteGroup, - worktreeId: sharedWorktreeId, - activeTabId: remoteUnifiedTab.id, - tabOrder: [remoteUnifiedTab.id, 'simulator-remote'] - } + ...(state.groupsByWorktree[sharedWorktreeId] ?? []).map((group) => + group.id === remoteGroup.id + ? { + ...group, + activeTabId: remoteUnifiedTab.id, + tabOrder: [...group.tabOrder, 'simulator-remote'] + } + : group + ) ] }, - activeGroupIdByWorktree: { [sharedWorktreeId]: 'group-local' }, + activeGroupIdByWorktree: { + ...state.activeGroupIdByWorktree, + [sharedWorktreeId]: 'group-local' + }, layoutByWorktree: { + ...state.layoutByWorktree, [sharedWorktreeId]: { type: 'split', direction: 'horizontal', first: { type: 'leaf', groupId: 'group-local' }, - second: { type: 'leaf', groupId: remoteGroup.id }, + // Why: wrap the client's own layout so a host-side split keeps rendering its panes. + second: state.layoutByWorktree[sharedWorktreeId] ?? { + type: 'leaf', + groupId: remoteGroup.id + }, ratio: 0.5 } }, activeBrowserTabId: 'browser-local', - activeBrowserTabIdByWorktree: { [sharedWorktreeId]: 'browser-local' }, + activeBrowserTabIdByWorktree: { + ...state.activeBrowserTabIdByWorktree, + [sharedWorktreeId]: 'browser-local' + }, activeTabType: 'browser', - activeTabTypeByWorktree: { [sharedWorktreeId]: 'browser' } + activeTabTypeByWorktree: { + ...state.activeTabTypeByWorktree, + [sharedWorktreeId]: 'browser' + } }) return { remoteGroupId: remoteGroup.id, @@ -282,20 +334,38 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos browserCount: 2, owners: [ ['browser-tab-local', 'local', seeded.sharedWorktreeId], - [seeded.remoteTabId, remoteHostId, seeded.sharedWorktreeId], ['simulator-local', 'local', seeded.sharedWorktreeId], - ['simulator-remote', remoteHostId, seeded.sharedWorktreeId] + ['simulator-remote', remoteHostId, seeded.sharedWorktreeId], + [seeded.remoteTabId, remoteHostId, seeded.sharedWorktreeId] ], workspaceOwners: [ ['browser-local', seeded.sharedWorktreeId], [seeded.remoteWorkspaceId, seeded.sharedWorktreeId] ] }) + // Why: a live catalog refresh that reaps one same-id row re-hosts the surviving palette + // entry, so assert the collision still exists at click time instead of blaming the palette. + const expectSameIdCollisionIntact = async (step: string): Promise => { + expect( + await page.evaluate( + (worktreeId) => + window + .__store!.getState() + .allWorktrees() + .filter((worktree) => worktree.id === worktreeId) + .map((worktree) => worktree.hostId) + .sort(), + seeded.sharedWorktreeId + ), + `same-id host rows before ${step}` + ).toEqual(['local', remoteHostId].sort()) + } await page.evaluate(() => window.__store!.getState().openModal('worktree-palette')) let palette = page.getByRole('dialog', { name: 'Jump to...' }) let input = palette.getByPlaceholder( 'Search chats, terminals, worktrees, settings, and actions...' ) + await expectSameIdCollisionIntact('remote browser page palette open') const remoteBrowserAfterOpen = await page.evaluate( ({ tabId, worktreeId }) => { const state = window.__store!.getState() @@ -304,10 +374,6 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos ) return { browserCount: state.browserTabsByWorktree[worktreeId]?.length ?? 0, - hosts: state - .allWorktrees() - .filter((worktree) => worktree.id === worktreeId) - .map((worktree) => worktree.hostId), owner: tab?.executionHostId ?? null } }, @@ -317,7 +383,6 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos } ) expect(remoteBrowserAfterOpen.browserCount).toBe(2) - expect(remoteBrowserAfterOpen.hosts).toEqual(['local', remoteHostId]) expect(remoteBrowserAfterOpen.owner).toBe(remoteHostId) await input.fill('New Tab') await expect( @@ -328,6 +393,7 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos body: await page.screenshot(), contentType: 'image/png' }) + await expectSameIdCollisionIntact('remote browser page click') await palette.locator(`[cmdk-item][data-value="browser-page:${seeded.remotePageId}"]`).click() await expect .poll(() => @@ -357,6 +423,7 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos await expect(palette.locator('[cmdk-item][data-value="browser-page:page-local"]')).toHaveCount( 1 ) + await expectSameIdCollisionIntact('local browser page click') await palette.locator('[cmdk-item][data-value="browser-page:page-local"]').click() await expect .poll(() => @@ -384,6 +451,7 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos body: await page.screenshot(), contentType: 'image/png' }) + await expectSameIdCollisionIntact('remote simulator click') await palette.locator('[cmdk-item][data-value="simulator-tab:simulator-remote"]').click() await expect .poll(() => @@ -415,6 +483,7 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos '[cmdk-item][data-value="simulator-tab:simulator-local"]' ) await expect(localSimulatorRow).toHaveCount(1) + await expectSameIdCollisionIntact('local simulator click') await localSimulatorRow.click() await expect .poll(() => diff --git a/tests/e2e/pr11346-selected-runtime-add.spec.ts b/tests/e2e/pr11346-selected-runtime-add.spec.ts index 04f4e8d52c0..93e49d4c4f2 100644 --- a/tests/e2e/pr11346-selected-runtime-add.spec.ts +++ b/tests/e2e/pr11346-selected-runtime-add.spec.ts @@ -7,6 +7,7 @@ import type { ProjectGroup } from '../../src/shared/project-group-types' import type { Repo } from '../../src/shared/repo-types' import { expect, test } from './helpers/orca-app' import { revealPairedClientWindow } from './helpers/paired-client-window-reveal' +import { forwardRendererConsole } from './helpers/renderer-console-forwarding' import { createRuntimeDesktopPairingOffer, launchPairedElectronClient @@ -21,11 +22,6 @@ import { } from './pr11346-selected-runtime-identity-oracle' async function selectRuntimeHost(page: Page, runtimeName: string): Promise { - const crashDialog = page.getByRole('dialog', { name: /recoverable UI error/i }) - if (await crashDialog.isVisible()) { - // Why: same-ID collision fixtures intentionally exceed terminal-workbench invariants. - await crashDialog.getByRole('button', { name: /Don't Send/i }).click() - } await page .getByRole('button', { name: /Add Project/i }) .first() @@ -100,6 +96,9 @@ async function runSelectedRuntimeAddJourney( const offer = await createRuntimeDesktopPairingOffer(orcaPage) const client = await launchPairedElectronClient(offer, testInfo, runtimeName) + // Why: the client renders the workbench under test, and a contained render + // crash only names its component stack on the renderer console. + forwardRendererConsole(client.page, testInfo) const serverUserDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) const clientUserDataDir = await client.app.evaluate(({ app }) => app.getPath('userData')) const serverRuntime = new RuntimeClient(serverUserDataDir)