mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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.
This commit is contained in:
@@ -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<string> {
|
||||
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'])
|
||||
|
||||
@@ -133,6 +133,68 @@ async function titlesOf(run: Run, tabId: string, views: View[]): Promise<string[
|
||||
return titles
|
||||
}
|
||||
|
||||
type GroupView = { id: string; activeTabId: string | null; tabOrder: string[] }
|
||||
|
||||
/** The groups paired client A is told about: order, membership and each group's selected tab. */
|
||||
async function groupsOf(run: Run): Promise<GroupView[]> {
|
||||
const tabs = await run.a.call<RuntimeMobileSessionTabsResult>('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<string, unknown> & { tabId: string },
|
||||
expectedOrders: string[][]
|
||||
): Promise<GroupView[]> {
|
||||
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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user