fix(terminal): mount one surface per workspace id in the workbench (STA-4846)

This commit is contained in:
Neil
2026-08-30 16:22:56 -07:00
committed by Jinjing
parent f7d8d7f77a
commit d8f48dacc0
6 changed files with 409 additions and 54 deletions
+20 -13
View File
@@ -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<string>())
const terminalWorktreeParkCooldownUntilRef = useRef(new Map<string, number>())
const terminalWorktreeParkingTimersRef = useRef(new Map<string, number>())
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,
@@ -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<typeof projectWorkspaceSurfaces> {
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,')
})
})
@@ -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<string, Pick<Worktree, 'path'>>
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<string, number>()
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
}
+40 -13
View File
@@ -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<HostBrowserPageRow[]> {
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<string[]> {
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<string | null> {
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<string[]> {
const tabs = await readHostBrowserPages(hostClient, worktreeSelector, 15_000)
return tabs.map((tab) => tab.url)
}
@@ -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<string>()
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<void> => {
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(() =>
@@ -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<Locator> {
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)