From e5412cccec37df4ecf991698336e00c79c1fb48e Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:05:17 -0400 Subject: [PATCH] test(e2e): headless layout oracle covers tab moves between groups (#26857) Two headless scenarios drive session.tabs.move (split into a new group, move into an existing group, reorder, and a move that empties a group) on orcad and Electron serve, and check group order, the selected tab per group, and a warm restart. The temp repo is resolved to its real path so macOS runs list its worktree without a TMPDIR workaround. Two new main findings are listed as known: headless saves groups without a tab bar, and move-to-group leaves the target group's previous selection. --- .../helpers/headless-layout-oracle-hosts.ts | 5 +- .../workspace-layout-oracle-headless.spec.ts | 119 ++++++++++++++++++ .../workspace-layout-oracle-known-on-main.ts | 21 +++- 3 files changed, 142 insertions(+), 3 deletions(-) diff --git a/tests/e2e/helpers/headless-layout-oracle-hosts.ts b/tests/e2e/helpers/headless-layout-oracle-hosts.ts index 76566b58673..010eb4e372d 100644 --- a/tests/e2e/helpers/headless-layout-oracle-hosts.ts +++ b/tests/e2e/helpers/headless-layout-oracle-hosts.ts @@ -4,7 +4,7 @@ * and a headless runtime's saved profile is the runtime layout the oracle checks. */ -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { createServer, type AddressInfo } from 'node:net' import os from 'node:os' import path from 'node:path' @@ -224,7 +224,8 @@ export async function addRepoWorktree( host: HeadlessOracleHost, client: RuntimeClient ): Promise { - const repoPath = mkdtempSync(path.join(os.tmpdir(), 'layout-oracle-repo-')) + // The runtime lists a worktree by its real path; macOS's temp dir is behind a symlink. + const repoPath = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'layout-oracle-repo-'))) host.cleanups.push(() => rmSync(repoPath, PROFILE_REMOVAL)) writeFileSync(path.join(repoPath, 'README.md'), 'layout oracle\n') await git(repoPath, ['init', '-q']) diff --git a/tests/e2e/workspace-layout-oracle-headless.spec.ts b/tests/e2e/workspace-layout-oracle-headless.spec.ts index 98fdaf18095..c97d1af77a6 100644 --- a/tests/e2e/workspace-layout-oracle-headless.spec.ts +++ b/tests/e2e/workspace-layout-oracle-headless.spec.ts @@ -133,6 +133,68 @@ async function titlesOf(run: Run, tabId: string, views: View[]): Promise { + const tabs = await run.a.call('session.tabs.list', { + worktree: run.worktree + }) + return (tabs.result.tabGroups ?? []).map(({ id, activeTabId, tabOrder }) => ({ + id, + activeTabId, + tabOrder + })) +} + +/** + * Moves a tab through the phone/CLI path, then checks the groups' tab orders and selection: the + * moved tab is selected where it lands; any other group keeps its selection while that tab stays, + * else falls back to its first tab. + */ +async function moveTab( + run: Run, + step: string, + move: Record & { tabId: string }, + expectedOrders: string[][] +): Promise { + const before = await groupsOf(run) + await attempt(run, step, () => + run.a.call('session.tabs.move', { worktree: run.worktree, ...move }) + ) + let after: GroupView[] = [] + await expect + .poll( + async () => { + after = await groupsOf(run) + return JSON.stringify(after.map((group) => group.tabOrder)) + }, + { timeout: 15_000 } + ) + .toBe(JSON.stringify(expectedOrders)) + .catch(() => undefined) + const orders = JSON.stringify(after.map((group) => group.tabOrder)) + run.expectNone( + `${step}: group tab orders`, + orders === JSON.stringify(expectedOrders) + ? [] + : [`groups ${orders}, expected ${JSON.stringify(expectedOrders)}`] + ) + const selection = after.flatMap((group) => { + const kept = before.find((candidate) => candidate.id === group.id)?.activeTabId + const expected = group.tabOrder.includes(move.tabId) + ? move.tabId + : kept && group.tabOrder.includes(kept) + ? kept + : (group.tabOrder[0] ?? null) + return group.activeTabId === expected + ? [] + : [`group ${group.id} selects ${group.activeTabId}, expected ${expected}`] + }) + run.expectNone(`${step}: selection`, selection) + return after +} + const SCENARIOS: Scenario[] = [ { // #23429: a client-created and a CLI-created tab in one worktree must both stay listed. @@ -224,6 +286,63 @@ const SCENARIOS: Scenario[] = [ await run.check('B closes one', [1], views, 1) } }, + { + // A tab split off into a new group keeps its terminal; the group it left selects its first tab. + id: 'split-tab-into-new-group', + journey: async (run) => { + const first = await createTab(run, run.a) + const second = await createTab(run, run.a) + await run.check('two tabs', [1, 1]) + const [group] = await groupsOf(run) + await moveTab( + run, + 'split second tab right', + { kind: 'split', tabId: second, targetGroupId: group!.id, splitDirection: 'right' }, + [[first], [second]] + ) + await run.check('split into new group', [1, 1]) + await run.restart('warm restart', [1, 1]) + } + }, + { + // Moves between groups and a reorder; a group emptied by a move closes. + id: 'move-tab-between-groups', + journey: async (run) => { + const first = await createTab(run, run.a) + const second = await createTab(run, run.a) + const third = await createTab(run, run.a) + await run.check('three tabs', [1, 1, 1]) + const [source] = await groupsOf(run) + const [, target] = await moveTab( + run, + 'split third tab right', + { kind: 'split', tabId: third, targetGroupId: source!.id, splitDirection: 'right' }, + [[first, second], [third]] + ) + await moveTab( + run, + 'move second tab into the new group', + { kind: 'move-to-group', tabId: second, targetGroupId: target!.id, index: 0 }, + [[first], [second, third]] + ) + await run.check('move to group', [1, 1, 1]) + await moveTab( + run, + 'reorder the new group', + { kind: 'reorder', tabId: third, targetGroupId: target!.id, tabOrder: [third, second] }, + [[first], [third, second]] + ) + await run.check('reorder', [1, 1, 1]) + await moveTab( + run, + 'move the last tab out of the first group', + { kind: 'move-to-group', tabId: first, targetGroupId: target!.id, index: 2 }, + [[third, second, first]] + ) + await run.check('emptied group closes', [1, 1, 1]) + await run.restart('warm restart', [1, 1, 1]) + } + }, { id: 'split-close-pane-restarts', journey: async (run) => { diff --git a/tests/e2e/workspace-layout-oracle-known-on-main.ts b/tests/e2e/workspace-layout-oracle-known-on-main.ts index c46e558502b..212a2399a55 100644 --- a/tests/e2e/workspace-layout-oracle-known-on-main.ts +++ b/tests/e2e/workspace-layout-oracle-known-on-main.ts @@ -131,7 +131,26 @@ export const LAYOUT_ORACLE_KNOWN_ON_MAIN: readonly KnownOnMain[] = [ check: 'expected', step: 'rename after restart', cause: 'orcad title after restart' - } + }, + // Headless group moves save the groups but still no tab bar, so the groups list tabs the tab bar + // lacks. + ...(['orcad', 'electron'] as const).flatMap((kind) => + ['split-tab-into-new-group', 'move-tab-between-groups'].map((id) => ({ + scenario: `headless-${kind}-${id}`, + check: 'rules' as const, + rule: 'group_lists_missing_tab', + cause: 'headless saves groups but no tab bar' + })) + ), + // After a headless move into an existing group, paired clients still see that group's previous + // tab selected, not the moved one. A split into a new group selects the moved tab. + ...(['orcad', 'electron'] as const).map((kind) => ({ + scenario: `headless-${kind}-move-tab-between-groups`, + check: 'expected' as const, + step: 'move', + detail: /^group \S+ selects /, + cause: 'headless move-to-group keeps the target group selection' + })) ] function covers(