From 4fe445a21f33fcaca021cfcd87ebf47c3d6c72c9 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:13:39 +0000 Subject: [PATCH 1/6] fix(main): log exceptions nothing caught; Electron's modal error box blocked quitting (D28) Electron's default uncaughtException handler shows a modal error box that holds the main thread until someone dismisses it, so Orbit couldn't quit. Under Xvfb nobody dismisses it: e2e app.close() hung, then Playwright's worker teardown, which failed whole jobs (macOS CI on PR #7 and main, three local full runs). A user would have seen the box and an app that wouldn't quit. - src/main/index.ts: log "[orbit] uncaught exception:" and carry on. - tests/e2e/helpers.ts: app.close() fails a test whose main process logged one (takeUncaught() claims one thrown on purpose) and kills an Orbit that still hasn't quit 15 s after being asked, so one stuck quit can't hang the run. - core.spec.ts: [TB-22] a throw in a main-process timer is logged, Orbit quits and the session restores. Without the fix nothing is logged and the quit hangs until the harness kills it. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 2 +- src/main/index.ts | 4 +++ tests/e2e/core.spec.ts | 16 ++++++++++ tests/e2e/helpers.ts | 70 ++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 89 insertions(+), 3 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 52aa9b01..758b9cfc 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. 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`. +- **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). Main logs an exception nothing caught as `[orbit] uncaught exception:` rather than letting Electron show its modal box, which blocks quitting. `app.close()` fails a test whose main process logged one (`takeUncaught()` claims one thrown on purpose), and kills an app that hasn't quit 15 s after being asked (D28). **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/index.ts b/src/main/index.ts index fb2cff53..a70b9e20 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,6 +17,10 @@ import { WindowManager } from './windows' const perf = new Perf() perf.mark('main:start') +// An exception nothing caught is logged, and Orbit carries on. Electron's default shows it in a modal +// box that blocks the main thread until someone dismisses it: until then Orbit can't even quit (D28). +process.on('uncaughtException', (err) => console.error('[orbit] uncaught exception:', err)) + // Tests and perf runs isolate their profile data. if (process.env.ORBIT_USER_DATA) app.setPath('userData', process.env.ORBIT_USER_DATA) const e2e = process.env.ORBIT_E2E === '1' diff --git a/tests/e2e/core.spec.ts b/tests/e2e/core.spec.ts index ca38aa48..45935b01 100644 --- a/tests/e2e/core.spec.ts +++ b/tests/e2e/core.spec.ts @@ -303,6 +303,22 @@ test('[TB-22] closing the last window keeps it for the next launch (review F1)', expect(win?.bounds).toMatchObject({ width: 900, height: 640 }) }) +test('[TB-22] an exception nothing caught in the main process is logged; Orbit still quits and restores (D28)', async () => { + const userData = orbit.userData + const tabId = (await run(orbit.app, 'tabs.create', { url: fx.url('/page/Kept') })) as string + // Electron's default shows the exception in a modal box that holds the main thread, so Orbit + // couldn't quit: app.close() waited until the harness killed it. + await orbit.app.evaluate(() => { + setTimeout(() => { + throw new Error('a bug nothing caught') + }, 0) + }) + await expect.poll(() => orbit.takeUncaught()).toContain('a bug nothing caught') + await orbit.app.close() + orbit = await launchOrbit({ userData }) + expect((await state(orbit.app)).tabs[tabId]).toBeDefined() +}) + test('[TB-02] opening tabs keeps every UI mirror in step without resyncs (review M2, F4)', async () => { const errors: string[] = [] orbit.shell.on('console', (m) => m.type() === 'error' && errors.push(m.text())) diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 12802e53..f8c26561 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -1,4 +1,4 @@ -import { mkdtempSync } from 'node:fs' +import { mkdtempSync, readFileSync } from 'node:fs' import { createServer, type Server } from 'node:http' import type { AddressInfo } from 'node:net' import { tmpdir } from 'node:os' @@ -20,6 +20,11 @@ export interface Orbit { app: ElectronApplication shell: Page userData: string + /** + * Takes what the main process has logged about exceptions nothing caught. A test that throws one on + * purpose checks it here; `app.close()` then doesn't fail the test for it (D28). + */ + takeUncaught: () => string } /** @@ -40,10 +45,11 @@ export async function launchOrbit(opts: { userData?: string; env?: Record, }) evaluateBetweenTasks(app) + const takeUncaught = checkQuit(app) const shell = await viewPage(app, 'shell') await shell.getByTestId('sidebar').waitFor() await waitOffline(app) - return { app, shell, userData } + return { app, shell, userData, takeUncaught } } /** @@ -86,6 +92,66 @@ function evaluateBetweenTasks(app: ElectronApplication): void { }) as ElectronApplication['evaluate'] } +/** How Orbit's main process logs an exception nothing caught (src/main/index.ts). */ +const UNCAUGHT = '[orbit] uncaught exception:' + +/** + * `app.close()` checks the quit too. The test fails if Orbit's main process logged an exception + * nothing caught, even one thrown while quitting. If Orbit still hasn't quit 15 s after being asked, + * the test fails and Orbit is killed, so one stuck quit can't hang the rest of the run (D28). + */ +function checkQuit(app: ElectronApplication): () => string { + const proc = app.process() + let recent = '' + let thrown = '' + proc.stderr?.on('data', (chunk) => { + if (thrown) { + if (thrown.length < 4000) thrown += String(chunk) + return + } + recent = (recent + String(chunk)).slice(-4000) + const at = recent.indexOf(UNCAUGHT) + if (at >= 0) thrown = recent.slice(at) + }) + const outputDone = new Promise((resolve) => (proc.stderr ? proc.stderr.once('close', resolve) : resolve(null))) + const close = app.close.bind(app) + let checked = false + app.close = async () => { + let timer: NodeJS.Timeout | undefined + const late = new Promise((resolve) => { + timer = setTimeout(() => resolve(true), 15_000) + }) + const closed = close() + const stuck = await Promise.race([closed.then(() => false), late]) + clearTimeout(timer) + if (checked) return + checked = true + if (stuck) { + const waiting = mainThreadWait(proc.pid) + proc.kill('SIGKILL') + await closed + expect.soft(`still running (main thread: ${waiting})`, 'Orbit 15 s after app.close(); it was killed').toBe('quit') + } + await Promise.race([outputDone, new Promise((resolve) => setTimeout(resolve, 1000))]) + expect.soft(thrown, "Orbit's main process threw an exception nothing caught").toBe('') + } + return () => { + const taken = thrown + thrown = '' + recent = '' + return taken + } +} + +/** Where a process's main thread is waiting in the kernel (Linux only). */ +function mainThreadWait(pid: number | undefined): string { + try { + return readFileSync(`/proc/${pid}/wchan`, 'utf8') + } catch { + return 'unknown' + } +} + /** 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 5efc4355c5583c0e60ae5a34359314428765cbef Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:20:37 +0000 Subject: [PATCH 2/6] test(mini-window): a command that closes the Mini Window may close it before the evaluate replies fromMini sends a command from the Mini Window's own page. mini.control's close can close that page before Playwright gets the evaluate's reply, which then fails with "Target page, context or browser has been closed" (MW-10, once in a full run). The helper now ignores that error only when the page really closed. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/mini-window.spec.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/e2e/mini-window.spec.ts b/tests/e2e/mini-window.spec.ts index 3569b208..23c07fe8 100644 --- a/tests/e2e/mini-window.spec.ts +++ b/tests/e2e/mini-window.spec.ts @@ -268,15 +268,23 @@ function isMacKeys(mac: string, other: string): string { return process.platform === 'darwin' ? mac : other } +type UiBridge = { orbit: { invoke: (n: string, a: unknown) => Promise } } + /** Runs a command from a Mini Window's own UI (as its buttons and menus do), without waiting on it. */ async function fromMini(app: ElectronApplication, windowId: string, name: string, args: unknown): Promise { const ui = await miniUi(app, windowId) - await ui.evaluate( - ([n, a]) => { - void (window as unknown as { orbit: { invoke: (n: string, a: unknown) => Promise } }).orbit.invoke(n, a) - }, - [name, args] as const, - ) + await ui + .evaluate( + ([n, a]) => { + void (window as unknown as UiBridge).orbit.invoke(n, a) + }, + [name, args] as const, + ) + .catch(async (err: unknown) => { + // A command that closes the Mini Window can close it before the evaluate's reply gets back. + if (!ui.isClosed()) await ui.waitForEvent('close', { timeout: 2000 }).catch(() => {}) + if (!ui.isClosed()) throw err + }) } test('[MW-05] "Open in…" moves the page into the chosen Space of the main window', async () => { From 721fdc813d2913c34b01e9aca0423ba838ef05f1 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:28:31 +0000 Subject: [PATCH 3/6] test(split): the views-over-panes poll waits for a pane that is still growing in A pane added a moment ago springs open from zero width (SV-26), so its first bounding box can be empty. The poll now retries instead of failing at once. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/split.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/e2e/split.spec.ts b/tests/e2e/split.spec.ts index 6cac95e4..983c5364 100644 --- a/tests/e2e/split.spec.ts +++ b/tests/e2e/split.spec.ts @@ -75,7 +75,8 @@ async function expectViewsOverPanes(o: Orbit, tabIds: string[]): Promise { .poll( async () => { const view = await tabView(o.app, tabId) - const pane = await paneBox(o.shell, tabId) + // A pane that has only just been added grows in from nothing (SV-26): no box yet, poll on. + const pane = await paneBox(o.shell, tabId).catch(() => null) return view?.visible === true && near(view.bounds, pane) }, { message: `view of ${tabId} over its pane` }, From 13a2494904beea44a703aad73adfdd71bde97499 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:34:30 +0000 Subject: [PATCH 4/6] fix(split): the pane strip's timers stop when its window closes; a fade-out threw and blocked quitting (D28) The strip fades out for 120 ms before its view hides. When its window closed during the fade (as happens when Orbit quits right after a pane strip hid), the fade's timer still ran on a view whose page was gone: view.webContents was undefined, and reading isDestroyed() threw. Nothing caught that, and Electron's modal error box then held the main thread, so Orbit never quit. This is the exception behind the SV-03 hangs: 15 repeats of SV-03 with the D28 check logged it once. The window's closed handler now clears the strip's show, hide and fade timers, and every check of the strip's view also handles a view whose webContents is gone. Test: "[SV-12] a window that closes while its pane strip fades out closes cleanly (D28)". It fails without the fix (3/3: the TypeError above) and passes with it. Co-Authored-By: Claude Opus 5.5 --- src/main/split/strip.ts | 15 +++++++++++---- tests/e2e/split.spec.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/main/split/strip.ts b/src/main/split/strip.ts index 876f2603..8fb50210 100644 --- a/src/main/split/strip.ts +++ b/src/main/split/strip.ts @@ -30,6 +30,10 @@ interface StripState { /** The strip fades out over this long before its view hides (SV-12). */ const FADE_OUT_MS = 120 +/** The view still has its page. Once the page is destroyed, `view.webContents` is undefined. */ +const live = (view: WebContentsView | undefined): view is WebContentsView => + !!view?.webContents && !view.webContents.isDestroyed() + /** * The floating pane strip (SV-12): a small transparent view centred at the top of a split * pane with the pane's close button and its split-controls menu. Pages are native views @@ -46,7 +50,7 @@ export class PaneStrip { // The strip follows its pane when the panes move (window resize, divider drag; review F7). ctx.events.on('layout.panes', ({ panes }, { windowId }) => { const s = windowId ? this.windows.get(windowId) : undefined - if (!windowId || !s?.tabId || !s.view || s.view.webContents.isDestroyed()) return + if (!windowId || !s?.tabId || !live(s.view)) return const pane = panes.find((p) => p.tabId === s.tabId) if (pane) s.view.setBounds(this.boundsOver(pane.rect)) else this.hide(windowId, true) @@ -75,7 +79,7 @@ export class PaneStrip { const view = s?.view return { tabId: s?.tabId ?? null, - visible: !!view && !view.webContents.isDestroyed() && view.getVisible(), + visible: live(view) && view.getVisible(), bounds: view?.getBounds(), } } @@ -158,14 +162,14 @@ export class PaneStrip { s.hovered = false s.dragging = false const view = s.view - if (!view || view.webContents.isDestroyed()) return + if (!live(view)) return if (now || !wasShown) { view.setVisible(false) return } view.webContents.send('orbit:strip:hide', {}) s.fadeTimer = setTimeout(() => { - if (!s.tabId && !view.webContents.isDestroyed()) view.setVisible(false) + if (!s.tabId && live(view)) view.setVisible(false) }, FADE_OUT_MS) } @@ -208,6 +212,9 @@ export class PaneStrip { loadRenderer(contents, 'strip') const win = this.ctx.windows.get(windowId) win?.once('closed', () => { + // A timer left running would act on a view that is gone: a throw there kept Orbit from quitting (D28). + const s = this.windows.get(windowId) + if (s) for (const timer of [s.showTimer, s.hideTimer, s.fadeTimer]) clearTimeout(timer) this.windows.delete(windowId) if (!contents.isDestroyed()) contents.close() }) diff --git a/tests/e2e/split.spec.ts b/tests/e2e/split.spec.ts index 983c5364..09ce17a6 100644 --- a/tests/e2e/split.spec.ts +++ b/tests/e2e/split.spec.ts @@ -1338,6 +1338,34 @@ test('[SV-12][SV-22] the strip follows its pane when the panes move; Duplicate k expect((await state(orbit.app)).splits[copy]?.name).toBe('Pair') }) +test('[SV-12] a window that closes while its pane strip fades out closes cleanly (D28)', async () => { + const b = await openTab(orbit.app, 'Second') + const a = await openTab(orbit.app, 'First') + await run(orbit.app, 'split.create', { tabIds: [a, b] }) + await expectViewsOverPanes(orbit, [a, b]) + const windowIds = () => + orbit.app.evaluate(() => (globalThis as unknown as { __orbit: { windowIds: () => string[] } }).__orbit.windowIds()) + const [first] = await windowIds() + // A second window keeps Orbit running once the first one closes. + await action(orbit.app, 'window.new') + await expect.poll(async () => (await windowIds()).length).toBe(2) + await showStrip(orbit.app, a) + // The strip starts fading out, and its window closes before the fade ends. The fade's timer used to + // fire on a view that was gone and throw, and a throw nothing caught kept Orbit from quitting. + await orbit.app.evaluate((_e, id) => { + const g = globalThis as unknown as { + __orbitSplit: { strip: { hide: (w: string) => void } } + __orbit: { ctx: { windows: { get: (w: string) => { close: () => void } | undefined } } } + } + g.__orbitSplit.strip.hide(id) + g.__orbit.ctx.windows.get(id)?.close() + }, first as string) + await expect.poll(async () => (await windowIds()).includes(first as string)).toBe(false) + // Main-process timers fire in order: by the end of this one, the fade's has run. + await orbit.app.evaluate(() => new Promise((resolve) => setTimeout(resolve, 300))) + expect(orbit.takeUncaught()).toBe('') +}) + test('[SV-08] a link opens in a new pane beside the page that holds it', async () => { const a = await openTab(orbit.app, 'Source') await expect.poll(async () => (await tabView(orbit.app, a))?.visible).toBe(true) From 504872ae568f6e46706c4db2211ba1e52fa5f3be Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:47:21 +0000 Subject: [PATCH 5/6] fix(e2e): the quit check finds an uncaught exception in a long log (PR #11 review M1) checkQuit kept only the last 4,000 characters of stderr before it looked for the "[orbit] uncaught exception:" marker. An exception logged in one longer write (a big message, a cause chain, an AggregateError) lost its marker to the trim: the test passed and takeUncaught() returned ''. The scan now looks for the marker in the whole chunk first, keeps up to 4,000 characters from it, and otherwise carries only a marker-length tail so a marker split across two writes is still found. New TB-22 test: a main-process timer throws a 5,000-character message. It fails without the fix (takeUncaught() returns '') and passes with it, 3/3. core + split + smoke e2e: 62/62. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/core.spec.ts | 11 +++++++++++ tests/e2e/helpers.ts | 9 ++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/e2e/core.spec.ts b/tests/e2e/core.spec.ts index 45935b01..180f5339 100644 --- a/tests/e2e/core.spec.ts +++ b/tests/e2e/core.spec.ts @@ -319,6 +319,17 @@ test('[TB-22] an exception nothing caught in the main process is logged; Orbit s expect((await state(orbit.app)).tabs[tabId]).toBeDefined() }) +test('[TB-22] the quit check also catches an exception whose log is long (D28, PR #11 review M1)', async () => { + // Logged in one write of over 5,000 characters. The check used to keep only the last 4,000 + // characters before looking for the marker, which cut the marker off. + await orbit.app.evaluate(() => { + setTimeout(() => { + throw new Error(`long ${'z'.repeat(5000)}`) + }, 0) + }) + await expect.poll(() => orbit.takeUncaught()).toContain('long zzz') +}) + test('[TB-02] opening tabs keeps every UI mirror in step without resyncs (review M2, F4)', async () => { const errors: string[] = [] orbit.shell.on('console', (m) => m.type() === 'error' && errors.push(m.text())) diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index f8c26561..2aaa283c 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -109,9 +109,12 @@ function checkQuit(app: ElectronApplication): () => string { if (thrown.length < 4000) thrown += String(chunk) return } - recent = (recent + String(chunk)).slice(-4000) - const at = recent.indexOf(UNCAUGHT) - if (at >= 0) thrown = recent.slice(at) + // Look for the marker before trimming: one write can be longer than what is kept. The tail + // kept is just long enough to find a marker split across two writes. + const text = recent + String(chunk) + const at = text.indexOf(UNCAUGHT) + if (at >= 0) thrown = text.slice(at, at + 4000) + else recent = text.slice(-UNCAUGHT.length) }) const outputDone = new Promise((resolve) => (proc.stderr ? proc.stderr.once('close', resolve) : resolve(null))) const close = app.close.bind(app) From d19b9da066c279411bcfd41dbb554c9e0c71fc1a Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:49:10 +0000 Subject: [PATCH 6/6] ci: pin the Linux jobs to ubuntu-24.04 before ubuntu-latest moves to Ubuntu 26 (Oct 19) GitHub's notice says `ubuntu-latest` becomes Ubuntu 26 from 2026-10-19. The e2e job installs xvfb, libgbm1 and libasound2t64 with apt, which is only known to work on 24.04, the image `ubuntu-latest` runs today. So nothing changes until that date; after it, the jobs stay on 24.04 until someone checks 26 on purpose. CI-only change (D18). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7b900bc..f5981ab1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,9 @@ env: jobs: checks: name: Typecheck, lint, unit tests - runs-on: ubuntu-latest + # Pinned: `ubuntu-latest` moves to Ubuntu 26 from 2026-10-19. The e2e job's apt packages + # (xvfb, libasound2t64) are only known to work on 24.04. + runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - uses: actions/checkout@v4 @@ -42,7 +44,7 @@ jobs: e2e-linux: name: E2E (Linux, Xvfb) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 25 steps: - uses: actions/checkout@v4