diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7ad97105..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. **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/src/main/split/index.ts b/src/main/split/index.ts index 9b2bf09c..88a4df58 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, @@ -32,9 +33,11 @@ import { detachTab, dropBlankPanes, focusInDraft, + growsFavoritesPastLimit, isBlankPane, isFailure, leavesOf, + overflowsFavorites, popOutInDraft, separateInDraft, sidebarSorted, @@ -138,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) => { @@ -428,18 +440,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])) { @@ -481,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 a654717b..4ec96ef4 100644 --- a/src/main/split/model.ts +++ b/src/main/split/model.ts @@ -4,6 +4,8 @@ * 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 { anchorOf, @@ -219,6 +221,33 @@ 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 +} + +/** 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) @@ -402,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 9c9efb27..73d96686 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 { @@ -39,6 +39,7 @@ import { import type { MainContext } from '../context' import { addPaneInDraft, + favoritesFull, isFailure, type PaneAnchor, restorePaneInDraft, @@ -151,7 +152,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 @@ -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 @@ -774,7 +779,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/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/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/src/shared/defaults.ts b/src/shared/defaults.ts index 7b16211e..5fca80e7 100644 --- a/src/shared/defaults.ts +++ b/src/shared/defaults.ts @@ -43,6 +43,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/helpers.ts b/tests/e2e/helpers.ts index c8c41216..12802e53 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -22,21 +22,70 @@ 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({ args, env: { ...process.env, ORBIT_E2E: '1', ORBIT_USER_DATA: userData, ...opts.env } as Record, }) + evaluateBetweenTasks(app) const shell = await viewPage(app, 'shell') await shell.getByTestId('sidebar').waitFor() + await waitOffline(app) return { app, shell, userData } } +/** + * Waits until every profile's session sends web requests to the dead proxy. A session picks up the + * command line's proxy a moment after it's made: here, about 50 ms after the sidebar shows. + */ +async function waitOffline(app: ElectronApplication): Promise { + 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: + * 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}`) @@ -159,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/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() diff --git a/tests/e2e/split.spec.ts b/tests/e2e/split.spec.ts index e996f841..44a26671 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) @@ -858,6 +899,66 @@ 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('[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/e2e/tabs-review.spec.ts b/tests/e2e/tabs-review.spec.ts index 668d71c9..63a11f76 100644 --- a/tests/e2e/tabs-review.spec.ts +++ b/tests/e2e/tabs-review.spec.ts @@ -2,7 +2,16 @@ * Regression tests for the PR #1 review findings owned by the tabs lane (M1, M3, M4, M6, M8). */ 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 @@ -187,9 +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

') }) - await new Promise((r) => server.listen(3390, '127.0.0.1', () => r())) + const port = await listenInRange(server) try { - const t = await newTab(app, 'http://127.0.0.1:3390/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) @@ -443,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(3392, '127.0.0.1', () => r())) + const port = await listenInRange(server) try { - const url = 'http://127.0.0.1:3392/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 diff --git a/tests/unit/split-tabs.test.ts b/tests/unit/split-tabs.test.ts index 41da52e5..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 } 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' @@ -277,4 +277,66 @@ 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) + }) + + /** 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()) + }) })