diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index cd290606fb2..787e130821d 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -16,6 +16,7 @@ import { AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, @@ -94,6 +95,7 @@ describe('WebRuntimeClient', () => { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, diff --git a/src/renderer/src/web/web-runtime-connection-frame-router.ts b/src/renderer/src/web/web-runtime-connection-frame-router.ts index 626b49f5612..78dbf6f0553 100644 --- a/src/renderer/src/web/web-runtime-connection-frame-router.ts +++ b/src/renderer/src/web/web-runtime-connection-frame-router.ts @@ -5,6 +5,7 @@ import { AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, @@ -66,6 +67,7 @@ export async function routeWebRuntimeConnectionFrame( AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, diff --git a/tests/e2e/paired-terminal-after-anchor-placement.spec.ts b/tests/e2e/paired-terminal-after-anchor-placement.spec.ts new file mode 100644 index 00000000000..8583b58c587 --- /dev/null +++ b/tests/e2e/paired-terminal-after-anchor-placement.spec.ts @@ -0,0 +1,150 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { + createRuntimeDesktopPairingOffer, + launchPairedWebClient +} from './helpers/paired-electron-client' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { toHostSessionTabId, toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id' + +async function createTerminal(page: Page, worktreeId: string, afterTabId?: string) { + const tabs = page.locator('[data-testid="sortable-tab"]') + const before = await tabs.evaluateAll((tabs) => + tabs.map((tab) => tab.getAttribute('data-tab-id')) + ) + const outcome = await page.evaluate( + async ({ worktreeId, afterTabId }) => { + const environment = (await window.api.runtimeEnvironments.list())[0] + const bridge: unknown = + '__webRuntimeSessionE2E' in window ? window.__webRuntimeSessionE2E : undefined + if ( + !environment || + !bridge || + typeof bridge !== 'object' || + !('createTerminal' in bridge) || + typeof bridge.createTerminal !== 'function' + ) { + throw new Error('Paired terminal creation is unavailable') + } + return bridge.createTerminal({ + worktreeId, + environmentId: environment.id, + activate: false, + ...(afterTabId ? { afterTabId } : {}) + }) + }, + { worktreeId, afterTabId } + ) + expect(outcome).toEqual({ status: 'created' }) + let created: string[] = [] + await expect + .poll(async () => { + const ids = await tabs.evaluateAll((tabs) => + tabs.map((tab) => tab.getAttribute('data-tab-id')) + ) + created = ids.filter((id): id is string => id !== null && !before.includes(id)) + return created.length + }) + .toBe(1) + const parentTabId = toHostSessionTabId(created[0]) + const surfaceId = await page.evaluate( + async ({ worktreeId, parentTabId }) => { + const response = await window.api.runtime.call({ + method: 'session.tabs.list', + params: { worktree: `id:${worktreeId}` } + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = response.result + if ( + !result || + typeof result !== 'object' || + !('tabs' in result) || + !Array.isArray(result.tabs) + ) { + throw new Error('Terminal inventory is missing') + } + const surface: unknown = result.tabs.find((tab) => tab.parentTabId === parentTabId) + if ( + !surface || + typeof surface !== 'object' || + !('id' in surface) || + typeof surface.id !== 'string' + ) { + throw new Error('Created terminal surface is missing') + } + return surface.id + }, + { worktreeId, parentTabId } + ) + return { id: toWebTerminalSurfaceTabId(surfaceId), parentTabId } +} + +async function tabWindow(page: Page, anchor: string) { + return page.locator('[data-testid="sortable-tab"]').evaluateAll((tabs, anchor) => { + const ids = tabs.map((tab) => tab.getAttribute('data-tab-id')) + const index = ids.indexOf(anchor) + return index === -1 ? [] : ids.slice(index, index + 3) + }, anchor) +} + +test('paired web creation places a terminal after its anchor on both host and client', async ({ + electronApp, + orcaPage +}, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + const client = await launchPairedWebClient(electronApp, offer) + try { + const worktreeId = await orcaPage.evaluate(() => window.__store?.getState().activeWorktreeId) + if (!worktreeId) { + throw new Error('Paired worktree is missing') + } + await client.page.evaluate((id) => window.__store?.getState().setActiveWorktree(id), worktreeId) + await expect( + client.page.locator('[data-testid="sortable-tab"][data-active="true"]') + ).toBeVisible() + const first = await createTerminal(client.page, worktreeId) + const second = await createTerminal(client.page, worktreeId) + const inserted = await createTerminal(client.page, worktreeId, first.id) + const labels = [first, second, inserted].map((tab, index) => ({ + id: tab.parentTabId, + webId: toWebTerminalSurfaceTabId(tab.parentTabId), + title: ['Anchor A', 'Successor B', 'Inserted C'][index] + })) + for (const [page, web] of [ + [orcaPage, false], + [client.page, true] + ] as const) { + await page.evaluate( + ({ labels, web }) => { + for (const { id, webId, title } of labels) { + window.__store?.getState().setTabCustomTitle(web ? webId : id, title) + } + }, + { labels, web } + ) + } + await expect + .poll(() => tabWindow(orcaPage, first.parentTabId), { timeout: 15_000 }) + .toEqual([first.parentTabId, inserted.parentTabId, second.parentTabId]) + const clientIds = [first, inserted, second].map((tab) => + toWebTerminalSurfaceTabId(tab.parentTabId) + ) + await expect + .poll(() => tabWindow(client.page, clientIds[0]), { timeout: 15_000 }) + .toEqual(clientIds) + } finally { + await testInfo.attach('host-tab-placement', { + body: await orcaPage.screenshot({ path: testInfo.outputPath('host-tab-placement.png') }), + contentType: 'image/png' + }) + await testInfo.attach('client-tab-placement', { + body: await client.page.screenshot({ path: testInfo.outputPath('client-tab-placement.png') }), + contentType: 'image/png' + }) + await client.dispose() + } +})