From 9b516159ee12885e00b91552e1d81605185d755a Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:12:05 +0000 Subject: [PATCH 1/9] split: a full split row is no drop target; SV-26 test can't race or hang - A tab resting on a full, 4-pane split row no longer gets a half highlighted: the row reorders like any other, as the page drop already shows no indicator for a full split (SV-25, split review follow-up). New e2e test fails without the fix (the half is highlighted). - SV-26 grow-in test: the frame sampler is installed before the pane is added, times frames from when the card enters the page, checks the spring's shape only on frames that came in time, and stops by itself. CI hung on it for 120 s and, on retry, saw a first sample at 94 %. Co-Authored-By: Claude Opus 5.5 --- src/renderer/src/shell/sidebar/drag.ts | 12 +++- tests/e2e/split.spec.ts | 97 ++++++++++++++++++-------- 2 files changed, 80 insertions(+), 29 deletions(-) diff --git a/src/renderer/src/shell/sidebar/drag.ts b/src/renderer/src/shell/sidebar/drag.ts index 4efeb484..7319805e 100644 --- a/src/renderer/src/shell/sidebar/drag.ts +++ b/src/renderer/src/shell/sidebar/drag.ts @@ -9,7 +9,7 @@ */ import { animate, motionValue } from 'motion/react' import { isFolderId, isSplitId, isTabId } from '../../../../shared/ids' -import { rowDropSide } from '../../../../shared/split' +import { layoutLeaves, MAX_PANES, rowDropSide } from '../../../../shared/split' import type { ItemId, OrbitState, SpaceId } from '../../../../shared/state' import { locate, parseContainerKey, visibleTabOrder } from '../../../../shared/tree' import { invoke } from '../../api' @@ -367,6 +367,14 @@ function clearSplitDwell(s: Session): void { s.splitDwell = null } +/** A split with no room for the tab: it takes no fifth pane, so its row is no split target (SV-25). */ +function isFullFor(key: string, tabId: string): boolean { + const layout = isSplitId(key) ? getOrbitState().splits[key]?.layout : undefined + if (!layout) return false + const leaves = layoutLeaves(layout) + return leaves.length >= MAX_PANES && !leaves.includes(tabId) +} + function splitTarget(s: Session, y: number): DropTarget | null { const first = s.ids[0] const scroll = ctx.scroll @@ -380,6 +388,8 @@ function splitTarget(s: Session, y: number): DropTarget | null { for (const sl of s.slots) { if (sl.kind !== 'item' || sl.folder || s.dragged.has(sl.key) || sl.ancestors.some((a) => s.dragged.has(a))) continue if (!isTabId(sl.key) && !isSplitId(sl.key)) continue + // A full split shows no target and reorders like any other row, as on the page (SV-25). + if (isFullFor(sl.key, first)) continue const row = { x: box.left, y: sl.top + (shifts[sl.key] ?? 0), width: box.width, height: sl.height } const side = rowDropSide(row, s.lastX, y) if (!side) continue diff --git a/tests/e2e/split.spec.ts b/tests/e2e/split.spec.ts index e996f841..47a0abc4 100644 --- a/tests/e2e/split.spec.ts +++ b/tests/e2e/split.spec.ts @@ -37,6 +37,8 @@ test.afterEach(async () => { }) type Box = { x: number; y: number; width: number; height: number } +/** What the SV-26 test's in-page sampler records about a pane growing in. */ +type GrowProbe = { insertedAt: number; ticks: number; samples: { t: number; w: number }[]; done: boolean } async function openTab(app: ElectronApplication, name: string, bg?: string): Promise { const url = fx.url(`/page/${name}${bg ? `?bg=${encodeURIComponent(bg)}` : ''}`) @@ -542,6 +544,32 @@ test('[SV-25][SV-04] a tab dropped on a full split is refused with a toast', asy await expect(orbit.shell.locator(`[data-testid="tab-row"][data-tab-id="${extra}"]`)).toHaveCount(1) }) +test('[SV-25][SV-05] a full split row shows no drop target: a tab resting on it gets no half to take', async () => { + const extra = await openTab(orbit.app, 'Extra') + const ids = [await openTab(orbit.app, 'Q4'), await openTab(orbit.app, 'Q3'), await openTab(orbit.app, 'Q2')] + const first = await openTab(orbit.app, 'Q1') + const splitId = (await run(orbit.app, 'split.create', { tabIds: [first, ...ids], focus: first })) as string + await expect(orbit.shell.getByTestId('split-row')).toHaveCount(1) + await settled(orbit.shell) + const a = await orbit.shell.locator(`[data-testid="tab-row"][data-tab-id="${extra}"]`).boundingBox() + const to = await orbit.shell.getByTestId('split-row').boundingBox() + if (!a || !to) throw new Error('rows') + await orbit.shell.mouse.move(a.x + 30, a.y + a.height / 2) + await orbit.shell.mouse.down() + await orbit.shell.mouse.move(a.x + 40, a.y + a.height / 2 - 6, { steps: 2 }) + await orbit.shell.mouse.move(to.x + to.width * 0.75, to.y + to.height * 0.5, { steps: 10 }) + // Well past the rest that makes a split on a row with room (400 ms). + await orbit.shell.waitForTimeout(900) + expect(await orbit.shell.locator('.row-slot.is-split-target').count()).toBe(0) + await orbit.shell.mouse.up() + await settled(orbit.shell) + // Nothing joins the split: the tab is still its own row (the press selected it, so the page shows it). + const layout = JSON.stringify((await state(orbit.app)).splits[splitId]?.layout) + for (const id of [first, ...ids]) expect(layout).toContain(id) + expect(layout).not.toContain(extra) + await expect(orbit.shell.locator(`[data-testid="tab-row"][data-tab-id="${extra}"]`)).toHaveCount(1) +}) + test('[SV-22][SV-21] a split can be named; the name replaces the pane titles', async () => { const b = await openTab(orbit.app, 'Docs') const a = await openTab(orbit.app, 'Mail') @@ -717,37 +745,50 @@ test('[SV-26] a new pane grows in with a spring and a closed one fades out', asy const b = await openTab(orbit.app, 'Grow2') const a = await openTab(orbit.app, 'Grow1') await expect.poll(async () => (await tabView(orbit.app, a))?.visible).toBe(true) - // Sample the new pane's card width every frame from the moment it appears. - const sampling = orbit.shell.evaluate( - (id) => - new Promise<{ t: number; w: number }[]>((resolve) => { - const samples: { t: number; w: number }[] = [] - let start = 0 - const tick = (now: number) => { - const el = document.querySelector(`[data-testid="split-pane"][data-tab-id="${id}"]`) - if (el) { - if (!start) start = now - samples.push({ t: now - start, w: el.getBoundingClientRect().width }) - } - if (start && now - start > 900) resolve(samples) - else requestAnimationFrame(tick) - } - requestAnimationFrame(tick) - }), - b, - ) - await run(orbit.app, 'split.addTab', { tabId: b, target: a, side: 'right' }) - const samples = await sampling + // Sample the new pane's card width every frame, timed from when the card enters the page. The + // sampler runs before the pane is added: started alongside the command it could begin late and + // miss the growth (CI saw a first sample at 94 %). It stops by itself, so it can't hang the test. + await orbit.shell.evaluate((id) => { + const probe: GrowProbe = { insertedAt: 0, ticks: 0, samples: [], done: false } + ;(window as unknown as { __grow: GrowProbe }).__grow = probe + const started = performance.now() + const card = () => document.querySelector(`[data-testid="split-pane"][data-tab-id="${id}"]`) + const seen = new MutationObserver(() => { + if (!card()) return + probe.insertedAt = performance.now() + seen.disconnect() + }) + seen.observe(document.body, { subtree: true, childList: true }) + const tick = () => { + probe.ticks++ + const now = performance.now() + const el = card() + if (el && probe.insertedAt) probe.samples.push({ t: now - probe.insertedAt, w: el.getBoundingClientRect().width }) + if (probe.insertedAt ? now - probe.insertedAt > 900 : now - started > 8000) probe.done = true + else requestAnimationFrame(tick) + } + requestAnimationFrame(tick) + }, b) + expect(await run(orbit.app, 'split.addTab', { tabId: b, target: a, side: 'right' })).toBeTruthy() + const probe = () => orbit.shell.evaluate(() => (window as unknown as { __grow: GrowProbe }).__grow) + await expect + .poll(async () => { + const p = await probe() + return p.done ? 'done' : `${p.ticks} frames, card ${p.insertedAt ? 'in' : 'not in'} the page` + }) + .toBe('done') + const { samples, insertedAt, ticks } = await probe() + expect(insertedAt, `the new pane never appeared (${ticks} frames)`).toBeGreaterThan(0) const final = samples[samples.length - 1]?.w ?? 0 // It starts small and grows over several frames (a spring, not a jump), settles within 1 px - // in well under a second (about 300 ms on an idle machine), without visible overshoot. - // Thresholds leave room for dropped frames when CI runs two apps at once. - // On the macOS CI VM a frame can take 100–200 ms (D17): its first frame may already show most of - // the spring, so the shape is checked where frames are timely and the end state everywhere. - if (FRAME_SLACK === 1) { - expect(samples[0]?.w ?? final).toBeLessThan(final * 0.8) + // in well under a second (about 330 ms on an idle machine), without visible overshoot. + // The spring runs on the clock, so a frame that comes late already shows more of it: the shape + // is checked on frames that came in time (here, at 100 ms, it is about half grown). On the macOS + // CI VM a frame can take 100–200 ms (D17), so there only the end state is checked. + const early = samples.filter((s) => s.t <= 100) + if (FRAME_SLACK === 1 && early[0]) expect(early[0].w).toBeLessThan(final * 0.8) + if (FRAME_SLACK === 1 && samples.filter((s) => s.t <= 300).length >= 8) expect(samples.filter((s) => s.w > 1 && s.w < final - 1).length).toBeGreaterThan(1) - } const settledAt = samples.find((s) => Math.abs(s.w - final) <= 1) expect(settledAt?.t ?? 1000).toBeLessThan(FRAME_SLACK === 1 ? 650 : 900) expect(Math.max(...samples.map((s) => s.w))).toBeLessThan(final + 4) From 8edc3e53a7ddd086d37ed5e93e5fa37ecd892a15 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:00:14 +0000 Subject: [PATCH 2/9] fix(split): a Favorite split never comes apart past 12 Favorites (FV-02, lead review F1) A Favorite split counts as one tile (D19). Separating it makes one tile per pane, and moving a pane out makes two, so both could take a profile past the 12-Favorite limit. Both are now refused with the "Favorites are full" toast when they would; a split anywhere else is never limited. The toast text is one constant, shared with the tabs code. Unit test for the count, and an e2e test that fills Favorites, checks both refusals leave everything as it was, then frees a tile and moves a pane out. Co-Authored-By: Claude Opus 5.5 --- src/main/split/index.ts | 17 +++++++++++++++-- src/main/split/model.ts | 12 ++++++++++++ src/main/tabs/ops.ts | 6 +++--- src/shared/defaults.ts | 3 +++ tests/e2e/split.spec.ts | 28 ++++++++++++++++++++++++++++ tests/unit/split-tabs.test.ts | 18 +++++++++++++++++- 6 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src/main/split/index.ts b/src/main/split/index.ts index 9b2bf09c..ed8685f2 100644 --- a/src/main/split/index.ts +++ b/src/main/split/index.ts @@ -1,4 +1,5 @@ import { clipboard, Menu, type MenuItemConstructorOptions, nativeTheme } from 'electron' +import { FAVORITES_FULL_MESSAGE } from '../../shared/defaults' import { newId } from '../../shared/ids' import { equalizeLayout, @@ -35,6 +36,7 @@ import { isBlankPane, isFailure, leavesOf, + overflowsFavorites, popOutInDraft, separateInDraft, sidebarSorted, @@ -428,18 +430,29 @@ export function registerSplit(ctx: MainContext): void { if (rest.length > 0) void commands.run('tabs.close', { tabIds: rest }, { windowId: windowOf(c) }) }) - commands.register('split.separate', ({ splitId }) => { + commands.register('split.separate', ({ splitId }, c) => { + // A Favorite split becomes one tile per pane: refused when that passes 12 Favorites (FV-02). + const tiles = leavesOf(store.state, splitId).filter((id) => !isPendingPane(store.state, id)).length + if (overflowsFavorites(store.state, splitId, tiles)) { + ctx.toast(windowOf(c), FAVORITES_FULL_MESSAGE) + return + } for (const id of leavesOf(store.state, splitId)) if (isPendingPane(store.state, id)) dropPending(id) store.update((d) => { separateInDraft(d, splitId) }) }) - commands.register('split.popOut', ({ tabId }) => { + commands.register('split.popOut', ({ tabId }, c) => { const state = store.state const splitId = splitOfTab(state, tabId) const split = splitId ? state.splits[splitId] : undefined if (!split) return + // The pane becomes a tile of its own beside a Favorite split (FV-02). + if (overflowsFavorites(state, split.id, 2)) { + ctx.toast(windowOf(c), FAVORITES_FULL_MESSAGE) + return + } const sibling = siblingOf(split.layout, tabId) store.update((d) => { for (const { windowId, spaceId } of windowsShowing(d, [tabId])) { diff --git a/src/main/split/model.ts b/src/main/split/model.ts index a654717b..c9f21c39 100644 --- a/src/main/split/model.ts +++ b/src/main/split/model.ts @@ -4,6 +4,7 @@ * themselves. Every helper keeps the invariants: 2–4 panes per split, canonical layout, * a tab in at most one place. */ +import { MAX_FAVORITES } from '../../shared/defaults' import { newId } from '../../shared/ids' import { anchorOf, @@ -219,6 +220,17 @@ export function popOutInDraft(d: OrbitState, tabId: TabId, opts: { toToday?: boo adoptPlacement(d, [tabId], today) } +/** + * Whether a Favorite split coming apart into `tiles` tiles would take its profile past the + * Favorites limit (FV-02). A split anywhere else never counts. + */ +export function overflowsFavorites(d: OrbitState, splitId: SplitId, tiles: number): boolean { + const loc = locate(d, splitId) + if (loc?.container.kind !== 'favorites') return false + const count = d.profiles[loc.container.profileId]?.favorites.length ?? 0 + return count - 1 + tiles > MAX_FAVORITES +} + /** Replaces a pane's tab with another open tab (which leaves its own place). The old tab goes to Today. */ export function swapIntoPaneInDraft(d: OrbitState, paneTab: TabId, incoming: TabId): boolean { const splitId = splitOfTab(d, paneTab) diff --git a/src/main/tabs/ops.ts b/src/main/tabs/ops.ts index 9c9efb27..ade642cd 100644 --- a/src/main/tabs/ops.ts +++ b/src/main/tabs/ops.ts @@ -3,7 +3,7 @@ * drag-and-drop and the maintenance timers all go through these so the rules * (selection after close, archive, undo, pinned URL bookkeeping) live in one place. */ -import { createWindowState, MAX_FAVORITES } from '../../shared/defaults' +import { createWindowState, FAVORITES_FULL_MESSAGE, MAX_FAVORITES } from '../../shared/defaults' import { isFolderId, isTabId, newId } from '../../shared/ids' import { isPendingPane, layoutLeaves, paneAfterClose, SPLIT_FULL_MESSAGE } from '../../shared/split' import type { @@ -151,7 +151,7 @@ export class TabOps { if (!spaceObj) throw new Error(`Unknown space ${space}`) const target: ContainerRef = opts.container ?? { kind: 'today', spaceId: space } if (target.kind === 'favorites' && this.favoriteCount(target.profileId) >= MAX_FAVORITES) { - this.ctx.toast(opts.windowId, `Favorites are full (${MAX_FAVORITES} max)`) + this.ctx.toast(opts.windowId, FAVORITES_FULL_MESSAGE) throw new Error('Favorites are full') } const profileId = profileOfContainer(state, target) ?? spaceObj.profileId @@ -774,7 +774,7 @@ export class TabOps { return !from || containerKey(from.container) !== target }).length if (incoming > 0 && this.favoriteCount(to.profileId) + incoming > MAX_FAVORITES) { - this.ctx.toast(opts.windowId, `Favorites are full (${MAX_FAVORITES} max)`) + this.ctx.toast(opts.windowId, FAVORITES_FULL_MESSAGE) return false } } diff --git a/src/shared/defaults.ts b/src/shared/defaults.ts index e177632d..b27f2ecc 100644 --- a/src/shared/defaults.ts +++ b/src/shared/defaults.ts @@ -38,6 +38,9 @@ export const DEFAULT_SETTINGS: Settings = { /** Favorites per profile (FV-02). */ export const MAX_FAVORITES = 12 +/** The toast when a change would pass the Favorites limit (FV-02). */ +export const FAVORITES_FULL_MESSAGE = `Favorites are full (${MAX_FAVORITES} max)` + const preset = (...colors: string[]): SpaceTheme => ({ colors, intensity: DEFAULT_INTENSITY, grain: 0 }) /** diff --git a/tests/e2e/split.spec.ts b/tests/e2e/split.spec.ts index 47a0abc4..6cac95e4 100644 --- a/tests/e2e/split.spec.ts +++ b/tests/e2e/split.spec.ts @@ -899,6 +899,34 @@ test('[SV-02][SV-23] a split made from a Favorite stays in the Favorites grid', await expect(orbit.shell.getByTestId('split-pane')).toHaveCount(2) }) +test('[FV-02][SV-23] a Favorite split never comes apart past 12 Favorites: Separate and Move Out are refused (lead review F1)', async () => { + const panes = [await openTab(orbit.app, 'P3'), await openTab(orbit.app, 'P2'), await openTab(orbit.app, 'P1')] + const s = await state(orbit.app) + const spaceId = s.tabs[panes[0] as string]?.spaceId as string + const profileId = s.spaces[spaceId]?.profileId as string + const favorites = { kind: 'favorites', profileId } + for (let i = 0; i < 11; i++) { + const id = await run(orbit.app, 'tabs.create', { url: fx.url(`/page/F${i}`), activate: false }) + expect(await run(orbit.app, 'tabs.move', { id, to: favorites, index: i }), `Favorite ${i}`).toBe(true) + } + const splitId = (await run(orbit.app, 'split.create', { tabIds: panes })) as string + await run(orbit.app, 'tabs.move', { id: splitId, to: favorites, index: 11 }) + const tiles = async () => (await state(orbit.app)).profiles[profileId]?.favorites ?? [] + await expect.poll(async () => (await tiles()).length).toBe(12) + // Separating would make 14 tiles and moving one pane out 13: both are refused, and nothing changes. + await run(orbit.app, 'split.separate', { splitId }) + await expect(orbit.shell.getByTestId('toast').last()).toContainText('Favorites are full (12 max)') + await run(orbit.app, 'split.popOut', { tabId: panes[0] }) + expect(await tiles()).toHaveLength(12) + const layout = JSON.stringify((await state(orbit.app)).splits[splitId]?.layout) + for (const id of panes) expect(layout).toContain(id) + // With room for one more tile, a pane moves out as usual. + await run(orbit.app, 'tabs.move', { id: (await tiles())[0], to: { kind: 'today', spaceId }, index: 0 }) + await run(orbit.app, 'split.popOut', { tabId: panes[0] }) + await expect.poll(async () => (await tiles()).length).toBe(12) + expect(JSON.stringify((await state(orbit.app)).splits[splitId]?.layout)).not.toContain(panes[0]) +}) + test('[SV-17] a reopened pane returns to its exact spot beside a stacked pair', async () => { const b = await openTab(orbit.app, 'StackTop') const a = await openTab(orbit.app, 'LeftTall') diff --git a/tests/unit/split-tabs.test.ts b/tests/unit/split-tabs.test.ts index 41da52e5..a69bc1cd 100644 --- a/tests/unit/split-tabs.test.ts +++ b/tests/unit/split-tabs.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { MainContext } from '../../src/main/context' -import { detachTab } from '../../src/main/split/model' +import { detachTab, overflowsFavorites } from '../../src/main/split/model' import { Store } from '../../src/main/store' import { activateInDraft, makeTab } from '../../src/main/tabs/model' import { TabOps } from '../../src/main/tabs/ops' @@ -277,4 +277,20 @@ describe('Favorite splits and the Favorites cap', () => { expect(ops.moveItems([add()], favorites, 0)).toBe(false) expect(store.state.profiles[DEFAULT_PROFILE_ID]?.favorites).toHaveLength(12) }) + + it('[FV-02][SV-23] a Favorite split may not come apart past 12 tiles; one elsewhere always may (lead review F1)', () => { + const { store, ops, add, split } = world() + const favorites = { kind: 'favorites', profileId: DEFAULT_PROFILE_ID } as const + for (let i = 0; i < 10; i++) expect(ops.favorite(add())).toBe(true) + const v = split({ type: 'row', sizes: [0.4, 0.3, 0.3], children: [add(), add(), add()].map(L) }) + // In Today, any number of tiles is fine. + expect(overflowsFavorites(store.state, v, 3)).toBe(false) + expect(ops.moveItems([v], favorites, 10)).toBe(true) + // 11 tiles: a pane popped out makes 12, separating all three makes 13. + expect(overflowsFavorites(store.state, v, 2)).toBe(false) + expect(overflowsFavorites(store.state, v, 3)).toBe(true) + expect(ops.favorite(add())).toBe(true) + // 12 tiles: no room for even one more. + expect(overflowsFavorites(store.state, v, 2)).toBe(true) + }) }) From f7555932dc2090c286c7d2487fb50de6c660ca0a Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:00:14 +0000 Subject: [PATCH 3/9] test(e2e): start each main-process evaluate in a task of its own Playwright runs main-process evaluates through the inspector, which can run them in the middle of other main-process code, even inside a store update. A tab a test created there was overwritten when that update finished, so it vanished: about 1 run in 12 of the new Favorites test. A stack trace placed the test's tabs.create inside immer's patch generation for a favicon update. launchOrbit now wraps app.evaluate so the test's function runs after a setImmediate. The test hooks' run and action defer too, which covers the perf and capture scripts. The Favorites test then passed 60 of 60, and the full suite passes. The SP-11 flake (a profile change that reached listeners, then reverted in the store) fits the same cause. Co-Authored-By: Claude Opus 5.5 --- src/main/test-hooks.ts | 9 +++++++-- tests/e2e/helpers.ts | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/main/test-hooks.ts b/src/main/test-hooks.ts index 12e6abac..1532b428 100644 --- a/src/main/test-hooks.ts +++ b/src/main/test-hooks.ts @@ -7,11 +7,16 @@ import type { Persistence } from './persist' * ORBIT_E2E=1. Playwright reaches it with electronApp.evaluate(). */ export function exposeTestHooks(ctx: MainContext, persistence: Persistence): void { + // Playwright's evaluate can run in the middle of other main-process code (the inspector + // dispatches it by interrupt), even inside a store update whose result would then drop the + // command's change. The e2e helpers defer every evaluate; this covers the perf and capture scripts. + const later = (fn: () => T | Promise): Promise => new Promise((r) => setImmediate(r)).then(fn) const hooks = { ctx, state: () => ctx.store.state, - run: (name: CommandName, args: unknown, windowId?: string) => ctx.commands.run(name, args as never, { windowId }), - action: (id: string, windowId?: string) => ctx.actions.run(id, { windowId }), + run: (name: CommandName, args: unknown, windowId?: string) => + later(() => ctx.commands.run(name, args as never, { windowId })), + action: (id: string, windowId?: string) => later(() => ctx.actions.run(id, { windowId })), flush: () => persistence.flush(), marks: () => ctx.perf.marks, windowIds: () => ctx.windows.ids(), diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index c8c41216..c794296a 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -32,11 +32,31 @@ export async function launchOrbit(opts: { userData?: string; env?: Record, }) + evaluateBetweenTasks(app) const shell = await viewPage(app, 'shell') await shell.getByTestId('sidebar').waitFor() return { app, shell, userData } } +/** + * Playwright runs main-process evaluates through the inspector, which can interrupt main-process + * code part-way, even inside a store update. A change made there is lost when that update finishes: + * a background tab a test had just created vanished. Each evaluate starts in a task of its own instead. + */ +function evaluateBetweenTasks(app: ElectronApplication): void { + const evaluate = app.evaluate.bind(app) + app.evaluate = ((fn: unknown, arg?: unknown) => { + if (typeof fn !== 'function') return evaluate(fn as never, arg as never) + return evaluate( + async (electron, [source, a]) => { + await new Promise((resolve) => setImmediate(resolve)) + return new Function(`return (${source})`)()(electron, a) + }, + [fn.toString(), arg] as const, + ) + }) as ElectronApplication['evaluate'] +} + /** Finds an Orbit UI page (shell/overlay) by its ?view= query. */ export async function viewPage(app: ElectronApplication, view: 'shell' | 'overlay'): Promise { const match = (p: Page) => p.url().includes(`view=${view}`) From 6d73442d8f1e1829b83d85b0704cae8d7b96ef34 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:07:51 +0000 Subject: [PATCH 4/9] test(tabs): the favicon tests' servers listen on port 0 They bound 127.0.0.1:3390 and 3392, so two full suites running at once on one machine (several agents do) could collide. Now the OS picks a free port. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/tabs-review.spec.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/e2e/tabs-review.spec.ts b/tests/e2e/tabs-review.spec.ts index 668d71c9..b79af731 100644 --- a/tests/e2e/tabs-review.spec.ts +++ b/tests/e2e/tabs-review.spec.ts @@ -1,6 +1,7 @@ /** * Regression tests for the PR #1 review findings owned by the tabs lane (M1, M3, M4, M6, M8). */ +import type { AddressInfo } from 'node:net' import { expect, test } from '@playwright/test' import { type Fixtures, launchOrbit, type Orbit, startFixtures, state, tabPage, tabView } from './helpers' import { act, activeId, center, cmd, drag, liveTabs, newTab, row, setState, space, waitTab } from './tabs-support' @@ -187,9 +188,10 @@ test('[TB-01] favicons are fetched in the tab session and stored as small data U res.writeHead(200, { 'content-type': 'text/html' }) res.end('Iconic

hi

') }) - await new Promise((r) => server.listen(3390, '127.0.0.1', () => r())) + // Port 0: the OS picks a free port, so two runs at once can't collide. + await new Promise((r) => server.listen(0, '127.0.0.1', () => r())) try { - const t = await newTab(app, 'http://127.0.0.1:3390/page') + const t = await newTab(app, `http://127.0.0.1:${(server.address() as AddressInfo).port}/page`) await expect.poll(async () => (await state(app)).tabs[t]?.favicon ?? '').toMatch(/^data:image\/png;base64,/) const favicon = (await state(app)).tabs[t]?.favicon as string expect(favicon.length).toBeLessThan(16 * 1024 * 1.4) @@ -443,9 +445,9 @@ test('[TB-01][TD-02] an .ico favicon is stored as a small PNG; archive entries c res.writeHead(200, { 'content-type': 'text/html' }) res.end('Ico

hi

') }) - await new Promise((r) => server.listen(3392, '127.0.0.1', () => r())) + await new Promise((r) => server.listen(0, '127.0.0.1', () => r())) try { - const url = 'http://127.0.0.1:3392/page' + const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/page` const t = await newTab(app, url) await expect.poll(async () => (await state(app)).tabs[t]?.favicon ?? '').toMatch(/^data:image\/png;base64,/) const favicon = (await state(app)).tabs[t]?.favicon as string From 1c14d54304d222fdb93cc86df86cacea67dd4d09 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:13:35 +0000 Subject: [PATCH 5/9] docs(architecture): why e2e evaluates start in a task of their own (D25) Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7ad97105..8ef1b07b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -112,7 +112,7 @@ Rules: no synchronous disk I/O on hot paths after boot, no full-state broadcasts ## 9. Testing and CI - **Unit** (`vitest`): shared logic (tree moves, URL parsing, accelerators, theme math, persistence normalization), main-process models with fakes. Fast; run on every commit. -- **E2E** (`@playwright/test` `_electron`): `tests/e2e/helpers.ts` launches the built app with an isolated `userData` and `ORBIT_E2E=1`. That exposes `globalThis.__orbit` test hooks in main (state, view bounds, run command) and serves local fixture pages from a loopback HTTP server, so there's no network dependence. **Every test title starts with the feature IDs it covers**, e.g. `test('[TB-06][TB-07] close and reopen a tab', …)`. `scripts/features.mjs` reads those IDs to mark rows tested in `FEATURES.md`. +- **E2E** (`@playwright/test` `_electron`): `tests/e2e/helpers.ts` launches the built app with an isolated `userData` and `ORBIT_E2E=1`. That exposes `globalThis.__orbit` test hooks in main (state, view bounds, run command) and serves local fixture pages from a loopback HTTP server, so there's no network dependence. Playwright reaches main through the inspector, which can interrupt main-process code part-way, even inside a store update. So `launchOrbit` makes each `app.evaluate` start in a task of its own, and the hooks' `run` and `action` defer the same way (D25). **Every test title starts with the feature IDs it covers**, e.g. `test('[TB-06][TB-07] close and reopen a tab', …)`. `scripts/features.mjs` reads those IDs to mark rows tested in `FEATURES.md`. - **CI** (`.github/workflows/ci.yml`): typecheck + lint + unit (Linux), e2e under Xvfb (Linux), and build + unit + e2e + unsigned `.dmg`/`.zip` packaging (macOS). Green CI is required before merge. - **Feel checks and clips**: `scripts/capture/record.mjs ` drives the built app through `scripts/capture/scenarios/.mjs` while ffmpeg (`ORBIT_FFMPEG`, needs x11grab) records the X display, so native tab views are captured exactly as composited. Clips are named by feature ID and stored in `clips/` in the Orca context directory, screenshots in `screenshots/`. "Feels right" in `FEATURES.md` is a lead sign-off against `feel.md`. From 0a63ed649f5c3a3bf45e0709f989d5fa8727e0e2 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:31:12 +0000 Subject: [PATCH 6/9] test(e2e): keep tests offline behind a dead proxy Three command bar tests opened real DuckDuckGo and YouTube searches and a Peek test opened meet.google.com. They only check URLs, but the pages loaded from CI and dev machines. launchOrbit now starts Orbit with --proxy-server pointing at a closed loopback port (loopback, where the fixtures are served, bypasses it), and waits until every profile's session resolves through it: a session picks the command line's proxy up about 50 ms after the sidebar shows. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 2 +- tests/e2e/helpers.ts | 31 ++++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8ef1b07b..52aa9b01 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -112,7 +112,7 @@ Rules: no synchronous disk I/O on hot paths after boot, no full-state broadcasts ## 9. Testing and CI - **Unit** (`vitest`): shared logic (tree moves, URL parsing, accelerators, theme math, persistence normalization), main-process models with fakes. Fast; run on every commit. -- **E2E** (`@playwright/test` `_electron`): `tests/e2e/helpers.ts` launches the built app with an isolated `userData` and `ORBIT_E2E=1`. That exposes `globalThis.__orbit` test hooks in main (state, view bounds, run command) and serves local fixture pages from a loopback HTTP server, so there's no network dependence. Playwright reaches main through the inspector, which can interrupt main-process code part-way, even inside a store update. So `launchOrbit` makes each `app.evaluate` start in a task of its own, and the hooks' `run` and `action` defer the same way (D25). **Every test title starts with the feature IDs it covers**, e.g. `test('[TB-06][TB-07] close and reopen a tab', …)`. `scripts/features.mjs` reads those IDs to mark rows tested in `FEATURES.md`. +- **E2E** (`@playwright/test` `_electron`): `tests/e2e/helpers.ts` launches the built app with an isolated `userData` and `ORBIT_E2E=1`. That exposes `globalThis.__orbit` test hooks in main (state, view bounds, run command) and serves local fixture pages from a loopback HTTP server. The app runs behind a dead local proxy that loopback bypasses, and `launchOrbit` waits until every profile's session uses it, so a test that searches or follows a link to a real site gets the URL but loads nothing. Playwright reaches main through the inspector, which can interrupt main-process code part-way, even inside a store update. So `launchOrbit` makes each `app.evaluate` start in a task of its own, and the hooks' `run` and `action` defer the same way (D25). **Every test title starts with the feature IDs it covers**, e.g. `test('[TB-06][TB-07] close and reopen a tab', …)`. `scripts/features.mjs` reads those IDs to mark rows tested in `FEATURES.md`. - **CI** (`.github/workflows/ci.yml`): typecheck + lint + unit (Linux), e2e under Xvfb (Linux), and build + unit + e2e + unsigned `.dmg`/`.zip` packaging (macOS). Green CI is required before merge. - **Feel checks and clips**: `scripts/capture/record.mjs ` drives the built app through `scripts/capture/scenarios/.mjs` while ffmpeg (`ORBIT_FFMPEG`, needs x11grab) records the X display, so native tab views are captured exactly as composited. Clips are named by feature ID and stored in `clips/` in the Orca context directory, screenshots in `screenshots/`. "Feels right" in `FEATURES.md` is a lead sign-off against `feel.md`. diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index c794296a..2faaf339 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -22,10 +22,17 @@ export interface Orbit { userData: string } +/** + * Tests stay offline. Orbit runs behind a dead local proxy, which loopback (where the fixture pages + * are served) bypasses, so a search or a link to a real site gets its URL but loads nothing. + */ +const DEAD_PROXY = '127.0.0.1:9' +const OFFLINE = `--proxy-server=http://${DEAD_PROXY}` + /** Launches the built app (out/) with an isolated profile directory. */ export async function launchOrbit(opts: { userData?: string; env?: Record } = {}): Promise { const userData = opts.userData ?? mkdtempSync(join(tmpdir(), 'orbit-e2e-')) - const args = [resolve('out/main/index.js')] + const args = [OFFLINE, resolve('out/main/index.js')] // Linux CI/dev boxes often lack the setuid sandbox helper; macOS runs fully sandboxed. if (process.platform === 'linux') args.unshift('--no-sandbox') const app = await electron.launch({ @@ -35,9 +42,31 @@ export async function launchOrbit(opts: { userData?: string; env?: Record { + const deadline = Date.now() + 10_000 + for (;;) { + const routes = await app.evaluate(async ({ session }) => { + const { profiles } = (globalThis as unknown as { __orbit: { state: () => OrbitState } }).__orbit.state() + const sessions = [ + session.defaultSession, + ...Object.values(profiles).map((p) => session.fromPartition(p.partition)), + ] + return Promise.all(sessions.map((s) => s.resolveProxy('https://example.com/'))) + }) + if (routes.every((r) => r === `PROXY ${DEAD_PROXY}`)) return + if (Date.now() > deadline) throw new Error(`Orbit would reach the network: ${routes.join(', ')}`) + await new Promise((r) => setTimeout(r, 20)) + } +} + /** * Playwright runs main-process evaluates through the inspector, which can interrupt main-process * code part-way, even inside a store update. A change made there is lost when that update finishes: From f8a1988797a2fe69519a016f808c1426ce621d8c Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:35:15 +0000 Subject: [PATCH 7/9] test(e2e): every test server listens within ports 3300-3399 The favicon tests' servers moved to port 0 in 6d73442, which the OS picks from outside the project's port range. listenInRange takes the first free port from a base up to 3399, as the fixture servers already did; startFixtures, the Peek fixtures and both favicon servers use it. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/helpers.ts | 28 +++++++++++++++++++--------- tests/e2e/peek-helpers.ts | 16 +++------------- tests/e2e/tabs-review.spec.ts | 21 ++++++++++++++------- 3 files changed, 36 insertions(+), 29 deletions(-) diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 2faaf339..12802e53 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -208,18 +208,28 @@ export async function startFixtures(): Promise { res.writeHead(404, { 'content-type': 'text/plain' }) res.end('not found') }) - const base = 3300 + (Number(process.env.TEST_PARALLEL_INDEX ?? 0) % 50) - await new Promise((resolveListen, reject) => { + const port = await listenInRange(server, 3300 + (Number(process.env.TEST_PARALLEL_INDEX ?? 0) % 50)) + return { + url: (path) => `http://localhost:${port}${path}`, + close: () => new Promise((r) => server.close(() => r())), + } +} + +/** + * Listens on loopback at the first free port from `base` up: Orbit keeps to ports 3300–3399 (the + * brief's range), and several suites or agents may be using some of them at once. + */ +export function listenInRange(server: Server, base = 3300): Promise { + return new Promise((resolve, reject) => { let port = base - server.on('error', (err: NodeJS.ErrnoException) => { + const retry = (err: NodeJS.ErrnoException) => { if (err.code === 'EADDRINUSE' && port < 3399) server.listen(++port, '127.0.0.1') else reject(err) + } + server.on('error', retry) + server.listen(port, '127.0.0.1', () => { + server.off('error', retry) + resolve(port) }) - server.listen(port, '127.0.0.1', () => resolveListen()) }) - const { port } = server.address() as AddressInfo - return { - url: (path) => `http://localhost:${port}${path}`, - close: () => new Promise((r) => server.close(() => r())), - } } diff --git a/tests/e2e/peek-helpers.ts b/tests/e2e/peek-helpers.ts index db684b81..1b09f747 100644 --- a/tests/e2e/peek-helpers.ts +++ b/tests/e2e/peek-helpers.ts @@ -1,9 +1,8 @@ import { createServer, type Server } from 'node:http' -import type { AddressInfo } from 'node:net' import type { ElectronApplication, Page } from '@playwright/test' import type { MainContext } from '../../src/main/context' import type { OrbitState, WindowState } from '../../src/shared/state' -import { state } from './helpers' +import { listenInRange, state } from './helpers' /** The main process's test handle (src/main/test-hooks.ts), for app.evaluate() bodies. */ export type OrbitGlobal = { __orbit: { ctx: MainContext } } @@ -96,17 +95,8 @@ export async function startSites(): Promise { ) }) }) - // Ports 3350-3399 (helpers.ts's fixtures use 3300-3349). - const base = 3350 + (Number(process.env.TEST_PARALLEL_INDEX ?? 0) % 50) - await new Promise((resolve, reject) => { - let p = base - server.on('error', (err: NodeJS.ErrnoException) => { - if (err.code === 'EADDRINUSE' && p < 3399) server.listen(++p, '127.0.0.1') - else reject(err) - }) - server.listen(p, '127.0.0.1', () => resolve()) - }) - port = (server.address() as AddressInfo).port + // From 3350 up (helpers.ts's fixtures start at 3300). + port = await listenInRange(server, 3350 + (Number(process.env.TEST_PARALLEL_INDEX ?? 0) % 50)) return { url: (path) => `http://localhost:${port}${path}`, other: (path) => `http://127.0.0.1:${port}${path}`, diff --git a/tests/e2e/tabs-review.spec.ts b/tests/e2e/tabs-review.spec.ts index b79af731..63a11f76 100644 --- a/tests/e2e/tabs-review.spec.ts +++ b/tests/e2e/tabs-review.spec.ts @@ -1,9 +1,17 @@ /** * Regression tests for the PR #1 review findings owned by the tabs lane (M1, M3, M4, M6, M8). */ -import type { AddressInfo } from 'node:net' import { expect, test } from '@playwright/test' -import { type Fixtures, launchOrbit, type Orbit, startFixtures, state, tabPage, tabView } from './helpers' +import { + type Fixtures, + launchOrbit, + listenInRange, + type Orbit, + startFixtures, + state, + tabPage, + tabView, +} from './helpers' import { act, activeId, center, cmd, drag, liveTabs, newTab, row, setState, space, waitTab } from './tabs-support' let fx: Fixtures @@ -188,10 +196,9 @@ test('[TB-01] favicons are fetched in the tab session and stored as small data U res.writeHead(200, { 'content-type': 'text/html' }) res.end('Iconic

hi

') }) - // Port 0: the OS picks a free port, so two runs at once can't collide. - await new Promise((r) => server.listen(0, '127.0.0.1', () => r())) + const port = await listenInRange(server) try { - const t = await newTab(app, `http://127.0.0.1:${(server.address() as AddressInfo).port}/page`) + const t = await newTab(app, `http://127.0.0.1:${port}/page`) await expect.poll(async () => (await state(app)).tabs[t]?.favicon ?? '').toMatch(/^data:image\/png;base64,/) const favicon = (await state(app)).tabs[t]?.favicon as string expect(favicon.length).toBeLessThan(16 * 1024 * 1.4) @@ -445,9 +452,9 @@ test('[TB-01][TD-02] an .ico favicon is stored as a small PNG; archive entries c res.writeHead(200, { 'content-type': 'text/html' }) res.end('Ico

hi

') }) - await new Promise((r) => server.listen(0, '127.0.0.1', () => r())) + const port = await listenInRange(server) try { - const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/page` + const url = `http://127.0.0.1:${port}/page` const t = await newTab(app, url) await expect.poll(async () => (await state(app)).tabs[t]?.favicon ?? '').toMatch(/^data:image\/png;base64,/) const favicon = (await state(app)).tabs[t]?.favicon as string From 3942167200bbe8410e7e4518073f73ca8a69f1bc Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:19:32 +0000 Subject: [PATCH 8/9] fix(favorites): Duplicate, Open in Split View, reopen and undo never make a 13th Favorite (PR #10 review M1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Favorite split could still take Favorites past 12 four ways (FV-02): - Duplicate put a copy beside it as a new tile; - Open in Split View on one of its panes and a Today tab left that pane's split in place and added the new split as a tile; - ⌘⇧T or ⌘Z on a pane closed from it put the pane back in its Favorites spot when the split was full again; - ⌘⇧T on a whole Favorite split removed from the sidebar put it back after Favorites had filled up. Duplicate and Open in Split View are now refused with the "Favorites are full" toast. Open in Split View tries the change on a throwaway copy of the state and refuses it when a Favorites grid would grow past 12. A tab or split coming back to a full Favorites grid lands in Today instead. Tests (each fails without the fix: 13 tiles, or a split id instead of null): - split-tabs.test.ts: a closed pane reopened, and one undone, while its Favorite split is full; a removed Favorite split reopened once Favorites filled up. - split.spec.ts: Duplicate and Open in Split View at 12 tiles are refused, and both work once there is room. Co-Authored-By: Claude Opus 5.5 --- src/main/split/index.ts | 17 ++++++++++++- src/main/split/model.ts | 20 ++++++++++++++- src/main/tabs/ops.ts | 7 ++++- tests/e2e/split.spec.ts | 32 +++++++++++++++++++++++ tests/unit/split-tabs.test.ts | 48 ++++++++++++++++++++++++++++++++++- 5 files changed, 120 insertions(+), 4 deletions(-) diff --git a/src/main/split/index.ts b/src/main/split/index.ts index ed8685f2..88a4df58 100644 --- a/src/main/split/index.ts +++ b/src/main/split/index.ts @@ -33,6 +33,7 @@ import { detachTab, dropBlankPanes, focusInDraft, + growsFavoritesPastLimit, isBlankPane, isFailure, leavesOf, @@ -140,6 +141,15 @@ export function registerSplit(ctx: MainContext): void { ctx.toast(windowId, SPLIT_FULL_MESSAGE) return null } + // A Favorite split's pane made into a new split leaves its own split as a new tile (FV-02). + if ( + growsFavoritesPastLimit(state, (d) => { + createSplitInDraft(d, ordered, { axis: orientation, at: ordered[0] }) + }) + ) { + ctx.toast(windowId, FAVORITES_FULL_MESSAGE) + return null + } const active = windowId ? activeTabOf(state, windowId) : null let result: ReturnType = 'invalid' updateMoving(ordered, (d) => { @@ -494,11 +504,16 @@ export function registerSplit(ctx: MainContext): void { if (other) updateLayout(splitId, (layout) => swapPanes(layout, tabId, other)) }) - commands.register('split.duplicate', ({ splitId }) => { + commands.register('split.duplicate', ({ splitId }, c) => { const state = store.state const split = state.splits[splitId] const loc = locate(state, splitId) if (!split || !loc) return null + // A Favorite split's copy is one more tile beside it (FV-02). + if (overflowsFavorites(state, splitId, 2)) { + ctx.toast(windowOf(c), FAVORITES_FULL_MESSAGE) + return null + } const now = Date.now() const copies = new Map() const tabs = layoutLeaves(split.layout) diff --git a/src/main/split/model.ts b/src/main/split/model.ts index c9f21c39..4ec96ef4 100644 --- a/src/main/split/model.ts +++ b/src/main/split/model.ts @@ -4,6 +4,7 @@ * themselves. Every helper keeps the invariants: 2–4 panes per split, canonical layout, * a tab in at most one place. */ +import { produce } from 'immer' import { MAX_FAVORITES } from '../../shared/defaults' import { newId } from '../../shared/ids' import { @@ -231,6 +232,22 @@ export function overflowsFavorites(d: OrbitState, splitId: SplitId, tiles: numbe return count - 1 + tiles > MAX_FAVORITES } +/** Whether `container` is a Favorites grid with no room for one more tile (FV-02). */ +export function favoritesFull(d: OrbitState, container: ContainerRef): boolean { + return container.kind === 'favorites' && (d.profiles[container.profileId]?.favorites.length ?? 0) >= MAX_FAVORITES +} + +/** + * Whether `recipe` would take a profile's Favorites past the limit (FV-02). It runs on a + * throwaway copy of the state; only a grid that grows counts. + */ +export function growsFavoritesPastLimit(d: OrbitState, recipe: (draft: OrbitState) => void): boolean { + const after = produce(d, recipe) + return Object.values(after.profiles).some( + (p) => p.favorites.length > MAX_FAVORITES && p.favorites.length > (d.profiles[p.id]?.favorites.length ?? 0), + ) +} + /** Replaces a pane's tab with another open tab (which leaves its own place). The old tab goes to Today. */ export function swapIntoPaneInDraft(d: OrbitState, paneTab: TabId, incoming: TabId): boolean { const splitId = splitOfTab(d, paneTab) @@ -414,8 +431,9 @@ export function restoreUnitInDraft(d: OrbitState, unit: SplitUnit, now = Date.no const canonical = normalizeLayout(layout) if (!canonical || canonical.type === 'leaf') return undefined const first = present[0] as TabId + // Its old place, unless that is a full Favorites grid (FV-02): then Today. const where = - unit.location && containerItems(d, unit.location.container) + unit.location && containerItems(d, unit.location.container) && !favoritesFull(d, unit.location.container) ? unit.location : (() => { const today = todayOf(d, first) diff --git a/src/main/tabs/ops.ts b/src/main/tabs/ops.ts index ade642cd..73d96686 100644 --- a/src/main/tabs/ops.ts +++ b/src/main/tabs/ops.ts @@ -39,6 +39,7 @@ import { import type { MainContext } from '../context' import { addPaneInDraft, + favoritesFull, isFailure, type PaneAnchor, restorePaneInDraft, @@ -366,7 +367,11 @@ export class TabOps { } if (!d.spaces[tab.spaceId]) tab.spaceId = d.spaceOrder[0] as string d.tabs[tab.id] = tab - const where = rec.location && containerItems(d, rec.location.container) ? rec.location : undefined + // Its old place, unless that is a full Favorites grid (FV-02): then Today. + const where = + rec.location && containerItems(d, rec.location.container) && !favoritesFull(d, rec.location.container) + ? rec.location + : undefined let landed: ContainerRef | undefined // A pane goes back into its split, in this same update (no frame shows it on its own). const intoSplit = rec.pane ? restorePaneInDraft(d, tab.id, rec.pane) : undefined diff --git a/tests/e2e/split.spec.ts b/tests/e2e/split.spec.ts index 6cac95e4..44a26671 100644 --- a/tests/e2e/split.spec.ts +++ b/tests/e2e/split.spec.ts @@ -927,6 +927,38 @@ test('[FV-02][SV-23] a Favorite split never comes apart past 12 Favorites: Separ expect(JSON.stringify((await state(orbit.app)).splits[splitId]?.layout)).not.toContain(panes[0]) }) +test('[FV-02][SV-23] Duplicate and Open in Split View never make a 13th Favorite from a Favorite split (PR #10 review M1)', async () => { + const panes = [await openTab(orbit.app, 'P3'), await openTab(orbit.app, 'P2'), await openTab(orbit.app, 'P1')] + const s = await state(orbit.app) + const spaceId = s.tabs[panes[0] as string]?.spaceId as string + const profileId = s.spaces[spaceId]?.profileId as string + const favorites = { kind: 'favorites', profileId } + for (let i = 0; i < 11; i++) { + const id = await run(orbit.app, 'tabs.create', { url: fx.url(`/page/F${i}`), activate: false }) + expect(await run(orbit.app, 'tabs.move', { id, to: favorites, index: i }), `Favorite ${i}`).toBe(true) + } + const splitId = (await run(orbit.app, 'split.create', { tabIds: panes })) as string + await run(orbit.app, 'tabs.move', { id: splitId, to: favorites, index: 11 }) + const tiles = async () => (await state(orbit.app)).profiles[profileId]?.favorites ?? [] + await expect.poll(async () => (await tiles()).length).toBe(12) + // A copy of the split would be a 13th tile beside it. + expect(await run(orbit.app, 'split.duplicate', { splitId })).toBeNull() + await expect(orbit.shell.getByTestId('toast').last()).toContainText('Favorites are full (12 max)') + expect(await tiles()).toHaveLength(12) + // A pane split with a Today tab would leave its split as a new tile, also a 13th. + const today = await openTab(orbit.app, 'Today') + expect(await run(orbit.app, 'split.create', { tabIds: [panes[0], today] })).toBeNull() + expect(await tiles()).toHaveLength(12) + expect(JSON.stringify((await state(orbit.app)).splits[splitId]?.layout)).toContain(panes[0]) + // With room for one more tile, both work as usual. + await run(orbit.app, 'tabs.move', { id: (await tiles())[0], to: { kind: 'today', spaceId }, index: 0 }) + expect(await run(orbit.app, 'split.duplicate', { splitId })).toBeTruthy() + await expect.poll(async () => (await tiles()).length).toBe(12) + await run(orbit.app, 'tabs.move', { id: (await tiles())[0], to: { kind: 'today', spaceId }, index: 0 }) + expect(await run(orbit.app, 'split.create', { tabIds: [panes[0], today] })).toBeTruthy() + await expect.poll(async () => (await tiles()).length).toBe(12) +}) + test('[SV-17] a reopened pane returns to its exact spot beside a stacked pair', async () => { const b = await openTab(orbit.app, 'StackTop') const a = await openTab(orbit.app, 'LeftTall') diff --git a/tests/unit/split-tabs.test.ts b/tests/unit/split-tabs.test.ts index a69bc1cd..9bd0e6e8 100644 --- a/tests/unit/split-tabs.test.ts +++ b/tests/unit/split-tabs.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { MainContext } from '../../src/main/context' -import { detachTab, overflowsFavorites } from '../../src/main/split/model' +import { addPaneInDraft, detachTab, overflowsFavorites } from '../../src/main/split/model' import { Store } from '../../src/main/store' import { activateInDraft, makeTab } from '../../src/main/tabs/model' import { TabOps } from '../../src/main/tabs/ops' @@ -293,4 +293,50 @@ describe('Favorite splits and the Favorites cap', () => { // 12 tiles: no room for even one more. expect(overflowsFavorites(store.state, v, 2)).toBe(true) }) + + /** 11 Favorites and a 3-pane Favorite split: 12 tiles, Favorites full. */ + const fullFavorites = () => { + const w = world() + const favorites = { kind: 'favorites', profileId: DEFAULT_PROFILE_ID } as const + for (let i = 0; i < 11; i++) expect(w.ops.favorite(w.add())).toBe(true) + const panes = [w.add(), w.add(), w.add()] + const v = w.split({ type: 'row', sizes: [0.4, 0.3, 0.3], children: panes.map(L) }) + expect(w.ops.moveItems([v], favorites, 11)).toBe(true) + const tiles = () => w.store.state.profiles[DEFAULT_PROFILE_ID]?.favorites ?? [] + expect(tiles()).toHaveLength(12) + return { ...w, panes, v, tiles } + } + + for (const how of ['reopened (⌘⇧T)', 'undone (⌘Z)'] as const) { + it(`[FV-02][SV-17] a closed pane ${how} when its Favorite split is full goes to Today at 12 Favorites (PR #10 review M1)`, () => { + const { store, ops, add, w1, panes, v, tiles } = fullFavorites() + const [closed, kept] = panes as [TabId, TabId] + ops.closeTabs([closed], 'closed') + expect(tiles()).toHaveLength(12) + // Two new panes fill the split up to 4, so the closed one can't rejoin it. + const extra = [add(), add()] + store.update((d) => { + for (const id of extra) addPaneInDraft(d, id, kept, 'right') + }) + expect(layoutLeaves(store.state.splits[v]?.layout as SplitLayout)).toHaveLength(4) + if (how === 'undone (⌘Z)') expect(ops.undo.undo()).toBe('Close Tab') + else expect(ops.reopenLast(w1)).toBe(closed) + expect(tiles()).toHaveLength(12) + expect(locate(store.state, closed)?.container.kind).toBe('today') + expect(layoutLeaves(store.state.splits[v]?.layout as SplitLayout)).toHaveLength(4) + }) + } + + it('[FV-02][SV-19] a Favorite split removed from the sidebar comes back whole in Today once Favorites filled up (PR #10 review M1)', () => { + const { store, ops, add, w1, panes, v, tiles } = fullFavorites() + ops.deleteItem(v) + expect(tiles()).toHaveLength(11) + expect(ops.favorite(add())).toBe(true) + expect(tiles()).toHaveLength(12) + ops.reopenLast(w1) + expect(tiles()).toHaveLength(12) + const back = splitOfTab(store.state, panes[0] as TabId) + expect(back && locate(store.state, back)?.container.kind).toBe('today') + expect(layoutLeaves(store.state.splits[back as string]?.layout as SplitLayout).sort()).toEqual([...panes].sort()) + }) }) From d2e4b8d7cec35086c443d45a2db747841a9864c2 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:19:32 +0000 Subject: [PATCH 9/9] test(spaces): SP-21 waits until the new row takes presses (PR #10 review M2) A row that has just finished growing in drops `.is-growing`, and its slot goes back to `content-visibility: auto`. For a frame or two after that, hit testing skips the row, so the test's press landed on the slot and no drag started. The reviewer saw 4 failures in 15 runs at f8a1988. The test now waits until `elementFromPoint` at the row's centre is the row. With the wait, SP-21 passed 15 of 15 runs. Users can lose a click the same way: that follow-up (F1) goes to the feel lane. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/spaces.spec.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/e2e/spaces.spec.ts b/tests/e2e/spaces.spec.ts index f8db1d0e..59f41463 100644 --- a/tests/e2e/spaces.spec.ts +++ b/tests/e2e/spaces.spec.ts @@ -951,6 +951,20 @@ test('[SP-21][TB-09] a tab dropped right after an edge-dwell Space switch lands for (let i = 0; i < 3; i++) inB.push(await newTab(app, fx.url(`/page/InB${i}`), { spaceId: b, activate: false })) const mover = await newTab(app, fx.url('/page/Mover')) await settled(shell) + // A row that has just finished growing in takes no presses for a frame or two: its slot goes + // back to `content-visibility: auto` and hit testing skips the row (PR #10 review M2). + await expect + .poll(() => + shell.evaluate((id) => { + const slot = document.querySelector(`.row-slot[data-key="${id}"]`) + const row = slot?.querySelector('[data-testid="tab-row"]') + const b = row?.getBoundingClientRect() + if (!slot || !row || !b || slot.classList.contains('is-growing')) return false + const hit = document.elementFromPoint(b.x + b.width / 2, b.y + b.height / 2) + return hit?.closest('[data-testid="tab-row"]') === row + }, mover), + ) + .toBe(true) const rowOf = (id: string) => shell.locator(`[data-testid="tab-row"][data-tab-id="${id}"]`) const start = await rowOf(mover).boundingBox() const sidebar = await shell.getByTestId('sidebar').boundingBox()