From 49c8b6a7a40d781c3ec5ff0719b53a83df2b6be5 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:41:47 +0000 Subject: [PATCH 1/6] test: no host name but localhost resolves in tests, captures or perf runs (PR #10 review F2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dead proxy stops every request, but Chromium looks up a link's host before anyone clicks it: PK-04's link to meet.google.com sent A, AAAA and HTTPS queries upstream through systemd-resolved. Orbit now launches with --host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost, EXCLUDE 127.0.0.1 in the e2e harness, the capture script and the perf script (Orbit and its Chromium baseline; the perf script had no dead proxy either, and now has both). A test's own --host-resolver-rules go first, so a lane can still map or fail a host of its own. The new smoke test resolves example.com and localhost in every profile's session. Without the rules it fails: Expected substring "ERR_NAME_NOT_RESOLVED", Received "2606:4700:10::6814:179a … 104.20.23.154 172.66.147.243". Co-Authored-By: Claude Opus 5.5 --- scripts/capture/record.mjs | 9 ++++++++- scripts/perf/run.mjs | 15 ++++++++++++--- tests/e2e/helpers.ts | 18 ++++++++++++++++-- tests/e2e/smoke.spec.ts | 22 ++++++++++++++++++++++ 4 files changed, 58 insertions(+), 6 deletions(-) diff --git a/scripts/capture/record.mjs b/scripts/capture/record.mjs index 57072c16..17d7d6b0 100644 --- a/scripts/capture/record.mjs +++ b/scripts/capture/record.mjs @@ -219,6 +219,8 @@ function makeToolkit(app, shell, fx) { * while loopback, where the fixture pages are served, bypasses proxies. */ const OFFLINE_PROXY = '127.0.0.1:9' +/** And no host name resolves but localhost: Chromium looks up a link's host before anyone clicks it (PR #10 review F2). */ +const NO_LOOKUPS = '--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost, EXCLUDE 127.0.0.1' /** * Puts every session Orbit has, and every one it makes later, on the dead proxy, and cancels any request @@ -254,7 +256,12 @@ let saved = false try { log('launching Orbit') app = await electron.launch({ - args: [...(process.platform === 'linux' ? ['--no-sandbox'] : []), `--proxy-server=http://${OFFLINE_PROXY}`, main], + args: [ + ...(process.platform === 'linux' ? ['--no-sandbox'] : []), + `--proxy-server=http://${OFFLINE_PROXY}`, + NO_LOOKUPS, + main, + ], env: { ...process.env, ORBIT_E2E: '1', ORBIT_USER_DATA: userData }, }) let shell diff --git a/scripts/perf/run.mjs b/scripts/perf/run.mjs index 89a839f9..76155a43 100644 --- a/scripts/perf/run.mjs +++ b/scripts/perf/run.mjs @@ -77,9 +77,18 @@ function treeRssMb(rootPid) { // ---------- Orbit helpers ---------- const MAIN = resolve('out/main/index.js') +/** + * Offline, as in the e2e tests (tests/e2e/helpers.ts): a dead local proxy, which loopback (the + * fixture server) bypasses, and no host name resolves but localhost. Orbit and the Chromium baseline + * both run with these. + */ +const OFFLINE = [ + '--proxy-server=http://127.0.0.1:9', + '--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost, EXCLUDE 127.0.0.1', +] async function launchOrbit(userData) { const app = await electron.launch({ - args: process.platform === 'linux' ? ['--no-sandbox', MAIN] : [MAIN], + args: [...(process.platform === 'linux' ? ['--no-sandbox'] : []), ...OFFLINE, MAIN], env: { ...process.env, ORBIT_E2E: '1', ORBIT_USER_DATA: userData }, }) const deadline = Date.now() + 30_000 @@ -420,14 +429,14 @@ async function chromeBaseline(fx) { for (let i = 0; i < RUNS; i++) { const dir = mkdtempSync(join(tmpdir(), 'chrome-perf-')) const t0 = Date.now() - const ctx = await chromium.launchPersistentContext(dir, { headless: false }) + const ctx = await chromium.launchPersistentContext(dir, { headless: false, args: OFFLINE }) const page = ctx.pages()[0] ?? (await ctx.newPage()) await page.goto(fx.url(`c${i}`)) coldStart.push(Date.now() - t0) await ctx.close() rmSync(dir, { recursive: true, force: true }) } - const server = await chromium.launchServer({ headless: false }) + const server = await chromium.launchServer({ headless: false, args: OFFLINE }) const pid = server.process().pid const browser = await chromium.connect(server.wsEndpoint()) const ctx = await browser.newContext() diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index b91def4b..d9406cab 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -31,9 +31,21 @@ export interface Orbit { /** * 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. + * No host name resolves either, except localhost: Chromium looks up a link's host before anyone + * clicks it, and that lookup would leave the machine (PR #10 review F2). */ const DEAD_PROXY = '127.0.0.1:9' -const OFFLINE = `--proxy-server=http://${DEAD_PROXY}` +const RESOLVER_RULES = '--host-resolver-rules=' +const NO_LOOKUPS = 'MAP * ~NOTFOUND, EXCLUDE localhost, EXCLUDE 127.0.0.1' + +/** + * The switches that keep Orbit offline. A test's own resolver rules go first, so they still apply + * (the first rule that matches a host wins), and every other host still fails to resolve. + */ +export function offlineArgs(args: string[] = []): string[] { + const own = args.filter((a) => a.startsWith(RESOLVER_RULES)).map((a) => a.slice(RESOLVER_RULES.length)) + return [`--proxy-server=http://${DEAD_PROXY}`, `${RESOLVER_RULES}${[...own, NO_LOOKUPS].join(', ')}`] +} /** * Launches the built app (out/) with an isolated profile directory. `args` are extra Chromium @@ -47,7 +59,9 @@ export async function launchOrbit( for (const old of quitAlone) await old.close() const userData = opts.userData ?? mkdtempSync(join(tmpdir(), 'orbit-e2e-')) const motion = opts.reducedMotion ? '--force-prefers-reduced-motion' : '--force-prefers-no-reduced-motion' - const args = [OFFLINE, motion, ...(opts.args ?? []), resolve('out/main/index.js')] + const extra = opts.args ?? [] + const rest = extra.filter((a) => !a.startsWith(RESOLVER_RULES)) + const args = [...offlineArgs(extra), motion, ...rest, 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({ diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts index baeac373..69e23033 100644 --- a/tests/e2e/smoke.spec.ts +++ b/tests/e2e/smoke.spec.ts @@ -54,6 +54,28 @@ test('opens a page from the command bar into a real web view', async () => { expect(Object.keys(s.tabs)).toHaveLength(1) }) +test('stays offline: no host name but localhost resolves, in any profile (PR #10 review F2)', async () => { + const { profiles } = await state(orbit.app) + const partitions = Object.values(profiles).map((p) => p.partition) + const lookups = await orbit.app.evaluate(async ({ session }, parts) => { + const sessions = [session.defaultSession, ...parts.map((p) => session.fromPartition(p))] + const lookUp = (s: Electron.Session, host: string) => + s.resolveHost(host).then( + (r) => r.endpoints.map((e) => e.address).join(' '), + (e: Error) => e.message, + ) + return Promise.all( + sessions.map(async (s) => ({ web: await lookUp(s, 'example.com'), local: await lookUp(s, 'localhost') })), + ) + }, partitions) + expect(lookups.length).toBeGreaterThan(1) + for (const { web, local } of lookups) { + // Refused by the resolver rules at once: no query leaves the machine. + expect(web).toContain('ERR_NAME_NOT_RESOLVED') + expect(local).toMatch(/127\.0\.0\.1|::1/) + } +}) + test('[FL-01] every CSS animation in the shell and the overlay names keyframes defined in that page', async () => { for (const view of ['shell', 'overlay'] as const) { const page = await viewPage(orbit.app, view) From 162d58dda0db8d86d2c3c57db016fd62115a4e36 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:44:06 +0000 Subject: [PATCH 2/6] test: evaluateHandle runs between tasks too, and method shorthands evaluate (PR #10 review F4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The harness defers app.evaluate to a task of its own, so it never lands part-way through main's code, but app.evaluateHandle still ran by interrupt, and the wrapper threw on a method shorthand (`({ m(_e, a) {…} }).m`: SyntaxError: Unexpected token '{') that Playwright's own evaluate runs. Both now go through the same deferral, and a shorthand becomes a function as Playwright does it. The relaunch test's two calls through app.browserWindow(page) (a handle whose own evaluate can't be deferred) use app.evaluate instead. The new TB-22 test keeps main busy in timers and asks five times through each path. Without the evaluateHandle wrapper: Expected false, Received true (the handle's code ran inside a timer). Without the shorthand change: SyntaxError: Unexpected token '{'. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/core.spec.ts | 30 ++++++++++++++++++++++++++++++ tests/e2e/helpers.ts | 31 +++++++++++++++++++++++++++++-- tests/e2e/tabs.spec.ts | 17 ++++++++++++----- 3 files changed, 71 insertions(+), 7 deletions(-) diff --git a/tests/e2e/core.spec.ts b/tests/e2e/core.spec.ts index 1b0266c1..182a5e94 100644 --- a/tests/e2e/core.spec.ts +++ b/tests/e2e/core.spec.ts @@ -393,6 +393,36 @@ test('[TB-22] app.close() asks Orbit to quit between tasks, never part-way throu expect(log).not.toContain('[test] quit handler inside a timer: true') }) +test('[TB-22] main-process evaluates and handles run between tasks, method shorthands too (PR #10 review F4)', async () => { + // Main stays busy in timers, so an evaluate through the inspector lands inside one unless it + // waits for a task of its own (helpers.ts, evaluateBetweenTasks). + await orbit.app.evaluate(() => { + const g = globalThis as unknown as { inTimer?: boolean } + for (let i = 0; i < 10; i++) { + setInterval(() => { + g.inTimer = true + const end = Date.now() + 10 + while (Date.now() < end) { + // busy + } + g.inTimer = false + }, 0) + } + }) + const inTimer = () => (globalThis as unknown as { inTimer?: boolean }).inTimer === true + for (let i = 0; i < 5; i++) { + expect(await orbit.app.evaluate(inTimer)).toBe(false) + expect(await (await orbit.app.evaluateHandle(inTimer)).jsonValue()).toBe(false) + } + // A method shorthand is a function too, as in Playwright's own evaluate. + const calc = { + add(_electron: unknown, n: number) { + return n + 1 + }, + } + expect(await orbit.app.evaluate(calc.add, 1)).toBe(2) +}) + 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 d9406cab..3d650b57 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -100,7 +100,9 @@ async function waitOffline(app: ElectronApplication): Promise { /** * 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. + * a background tab a test had just created vanished. Each evaluate and evaluateHandle starts in a + * task of its own instead. A handle's own `evaluate` (such as `app.browserWindow(page)`'s) still + * runs by interrupt, so tests reach main-process objects through `app.evaluate`. */ function evaluateBetweenTasks(app: ElectronApplication): void { const evaluate = app.evaluate.bind(app) @@ -111,9 +113,34 @@ function evaluateBetweenTasks(app: ElectronApplication): void { await new Promise((resolve) => setImmediate(resolve)) return new Function(`return (${source})`)()(electron, a) }, - [fn.toString(), arg] as const, + [functionSource(fn), arg] as const, ) }) as ElectronApplication['evaluate'] + const evaluateHandle = app.evaluateHandle.bind(app) + app.evaluateHandle = ((fn: unknown, arg?: unknown) => { + if (typeof fn !== 'function') return evaluateHandle(fn as never, arg as never) + return evaluateHandle( + async (electron, [source, a]) => { + await new Promise((resolve) => setImmediate(resolve)) + return new Function(`return (${source})`)()(electron, a) + }, + [functionSource(fn), arg] as const, + ) + }) as ElectronApplication['evaluateHandle'] +} + +/** + * A function's source as an expression. A method shorthand (`m(a) {…}`) becomes a function, as in + * Playwright's own evaluate (PR #10 review F4). + */ +function functionSource(fn: { toString(): string }): string { + const source = fn.toString() + try { + new Function(`(${source})`) + return source + } catch { + return source.startsWith('async ') ? `async function ${source.slice('async '.length)}` : `function ${source}` + } } /** diff --git a/tests/e2e/tabs.spec.ts b/tests/e2e/tabs.spec.ts index 938447ef..7b164ddb 100644 --- a/tests/e2e/tabs.spec.ts +++ b/tests/e2e/tabs.spec.ts @@ -521,9 +521,12 @@ test('[TB-22] relaunch restores windows, Spaces, pinned, Favorites, Today, folde await waitTab(app, t2, '/page/Two') await shell.keyboard.press('Escape') // The window's size comes back on relaunch. - const bw = await app.browserWindow(shell) // Small enough for a 1024×768 CI screen (macOS clamps windows to the work area). - await bw.evaluate((w) => w.setBounds({ x: 40, y: 40, width: 900, height: 600 })) + await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows() + .find((w) => w.webContents.getURL().includes('view=shell')) + ?.setBounds({ x: 40, y: 40, width: 900, height: 600 }), + ) await app.close() orbit = await launchOrbit({ userData }) @@ -536,9 +539,13 @@ test('[TB-22] relaunch restores windows, Spaces, pinned, Favorites, Today, folde expect(after.profiles[PROFILE]?.favorites).toEqual([fav]) const w = Object.values(after.windows)[0] expect(w?.activeTab[sA]).toBe(t2) - const shown = await (await orbit.app.browserWindow(orbit.shell)).evaluate((bw) => bw.getBounds()) - expect(shown.width).toBe(900) - expect(shown.height).toBe(600) + const shown = await orbit.app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows() + .find((w) => w.webContents.getURL().includes('view=shell')) + ?.getBounds(), + ) + expect(shown?.width).toBe(900) + expect(shown?.height).toBe(600) // Only the selected tab comes back loaded. await waitTab(orbit.app, t2, '/page/Two') expect(await liveTabs(orbit.app)).toEqual([t2]) From eba76a72e367cd49cf9113b6cb686fb37f4453c5 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:46:54 +0000 Subject: [PATCH 3/6] test(split): SV-26 never passes without seeing the grow-in; SV-25 checks its drag is live (PR #10 review F6, F7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SV-26: when no frame landed in the first 100 ms and fewer than 8 in the first 300 ms (a starved renderer), both shape checks were skipped and the test passed on the end state alone. Now that round's pane is closed and a new one grows in, up to three rounds (as for timing budgets, D46), and the test fails, naming each round's frame counts, if none came in time. On the macOS CI VM only the end state is checked, as before (D17). SV-25's sidebar test passed if the drag never started. It now checks html.is-dragging-tab before looking for a drop target. With the press taken out of SV-25's drag and a sampler that misses every frame before 300 ms (scratch edits), the old tests both pass; the new ones fail: "Expected pattern: /\bis-dragging-tab\b/, Received string: """ and "frames never came in time to show the grow-in (round 1: 0 frames by 100 ms, 0 by 300 ms; …)". Co-Authored-By: Claude Opus 5.5 --- tests/e2e/split.spec.ts | 62 ++++++++++++++++++++++++++++++----------- 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/tests/e2e/split.spec.ts b/tests/e2e/split.spec.ts index 85e19062..05584ef7 100644 --- a/tests/e2e/split.spec.ts +++ b/tests/e2e/split.spec.ts @@ -559,6 +559,8 @@ test('[SV-25][SV-05] a full split row shows no drop target: a tab resting on it 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 }) + // The drag is live, so the check below can't pass because no drag started (PR #10 review F7). + await expect(orbit.shell.locator('html')).toHaveClass(/\bis-dragging-tab\b/) // 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) @@ -742,13 +744,13 @@ test('[SV-13] resizing the window keeps each pane’s proportion', async () => { expect(pa.width / pb.width).toBeCloseTo(0.7 / 0.3, 1) }) -test('[SV-26] a new pane grows in with a spring and a closed one fades out', async () => { - 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, 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. +/** + * Adds `tabId` beside `target` and samples 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. + */ +async function growIn(tabId: string, target: string): Promise { await orbit.shell.evaluate((id) => { const probe: GrowProbe = { insertedAt: 0, ticks: 0, samples: [], done: false } ;(window as unknown as { __grow: GrowProbe }).__grow = probe @@ -769,8 +771,8 @@ test('[SV-26] a new pane grows in with a spring and a closed one fades out', asy else requestAnimationFrame(tick) } requestAnimationFrame(tick) - }, b) - expect(await run(orbit.app, 'split.addTab', { tabId: b, target: a, side: 'right' })).toBeTruthy() + }, tabId) + expect(await run(orbit.app, 'split.addTab', { tabId, target, side: 'right' })).toBeTruthy() const probe = () => orbit.shell.evaluate(() => (window as unknown as { __grow: GrowProbe }).__grow) await expect .poll(async () => { @@ -778,18 +780,44 @@ test('[SV-26] a new pane grows in with a spring and a closed one fades out', asy 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 result = await probe() + expect(result.insertedAt, `the new pane never appeared (${result.ticks} frames)`).toBeGreaterThan(0) + return result +} + +test('[SV-26] a new pane grows in with a spring and a closed one fades out', async () => { + const a = await openTab(orbit.app, 'Grow1') + await expect.poll(async () => (await tabView(orbit.app, a))?.visible).toBe(true) + // 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 (at 100 ms it is about half grown). A starved renderer can + // miss them; then that pane is closed and a new one grows in, up to three rounds (as for timing + // budgets, D46), so the test never passes without seeing the shape (PR #10 review F6). On the + // macOS CI VM a frame can take 100–200 ms (D17), so there only the end state is checked. + let grown: { tabId: string; samples: GrowProbe['samples'] } | undefined + const rounds: string[] = [] + for (let round = 1; round <= 3 && !grown; round++) { + const b = (await run(orbit.app, 'tabs.create', { + url: fx.url(`/page/Grow${round + 1}`), + activate: false, + })) as string + const { samples } = await growIn(b, a) + const early = samples.filter((s) => s.t <= 100).length + const by300 = samples.filter((s) => s.t <= 300).length + if (FRAME_SLACK !== 1 || (early > 0 && by300 >= 8)) grown = { tabId: b, samples } + else { + rounds.push(`round ${round}: ${early} frames by 100 ms, ${by300} by 300 ms`) + await run(orbit.app, 'split.closePane', { tabId: b }) + } + } + if (!grown) throw new Error(`frames never came in time to show the grow-in (${rounds.join('; ')})`) + const { tabId: b, samples } = grown 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 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) + if (FRAME_SLACK === 1) { + expect(samples.filter((s) => s.t <= 100)[0]?.w).toBeLessThan(final * 0.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 c8957f6ec735d35aa20f8f277c6fca9083c3c50c Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:48:59 +0000 Subject: [PATCH 4/6] perf: time the tab switch without the test hook's deferral; defer the direct sleep call (PR #10 review F5) __orbit.run waits for a task of its own before running a command, so tabSwitch.mainSide also counted that hop (2.6 ms median in the review). The perf script now waits for the task itself and times only ctx.commands.run. Its direct ctx.tabs.sleep calls, which bypass __orbit.run, start between tasks too. A --port option sets the fixture server's first port (default 3350), so a run can stay out of a port range another agent is using. Run once offline (--runs 1 --no-chrome --port 3390): exit 0; tab switch 5.7 ms median in main, 25 ms until the shell painted it; 15.2 MB per sleeping tab. Co-Authored-By: Claude Opus 5.5 --- scripts/perf/run.mjs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/scripts/perf/run.mjs b/scripts/perf/run.mjs index 76155a43..aafe1726 100644 --- a/scripts/perf/run.mjs +++ b/scripts/perf/run.mjs @@ -6,6 +6,7 @@ * scenarios in Chromium on the same machine. * * Linux: xvfb-run -a -s "-screen 0 1440x900x24" node scripts/perf/run.mjs [--runs 5] [--out perf-results/latest.json] + * [--port 3350] (the fixture server's first port; it takes the next free one up to 3399) */ import { execFileSync } from 'node:child_process' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' @@ -22,6 +23,7 @@ const opt = (name, fallback) => { const RUNS = Number(opt('runs', '5')) const OUT = resolve(opt('out', 'perf-results/latest.json')) const SKIP_CHROME = args.includes('--no-chrome') +const FIRST_PORT = Number(opt('port', '3350')) const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) const median = (xs) => { const s = [...xs].sort((a, b) => a - b) @@ -45,7 +47,7 @@ async function startFixtures() { res.writeHead(200, { 'content-type': 'text/html' }) res.end(PAGE(n)) }) - let port = 3350 + let port = FIRST_PORT await new Promise((ok, fail) => { server.on('error', (err) => err.code === 'EADDRINUSE' && port < 3399 ? server.listen(++port, '127.0.0.1') : fail(err), @@ -178,9 +180,11 @@ async function orbitInteractive(fx) { } await sleep(1500) const rss10 = treeRssMb(pid) - // Put every background tab to sleep and measure what is freed. + // Put every background tab to sleep and measure what is freed. Between tasks, as __orbit.run does: + // the inspector could otherwise run this part-way through other main-process code. await app.evaluate( - (_e, ids) => { + async (_e, ids) => { + await new Promise((r) => setImmediate(r)) for (const id of ids) globalThis.__orbit.ctx.tabs.sleep(id) }, tabs.slice(0, -1), @@ -223,8 +227,10 @@ async function orbitInteractive(fx) { const t0 = Date.now() const inMain = await app.evaluate(async (_e, id) => { const o = globalThis.__orbit + // Start between tasks, as __orbit.run does, but time the command alone, not that hop. + await new Promise((r) => setImmediate(r)) const s = performance.now() - await o.run('tabs.activate', { tabId: id }, o.windowIds()[0]) + await o.ctx.commands.run('tabs.activate', { tabId: id }, { windowId: o.windowIds()[0] }) const visible = o.ctx.tabs.get(id)?.getVisible() ?? false return { ms: performance.now() - s, visible } }, target) From 4ead39e312b67706b09607970cc3beffcfd78281 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:24:20 +0000 Subject: [PATCH 5/6] test(split): SV-26 retries wait for the close to finish, and its gate asks for what its checks need (PR #19 review M1, M2) M1: a retry added its pane while the last close was still animating. The neighbour then hadn't grown past the new pane's edge, nothing overlapped it, and Orbit's entryRect falls back to the full target, so the pane appeared at full size and the shape check failed ("Expected: < 389.6, Received: 487"). A retry now waits until the closed pane is gone from the shell and its ghost has faded. Scratch (rounds 1-2 forced to retry, 10 runs each, load 34-43): - without the wait: 6 of the 20 panes added after a close entered at full size (round 2: 4 of 10, round 3: 2 of 10), and 2 of 10 runs failed with the error above; - with it: 0 of 20, and 10 of 10 runs passed. Round 1, with the neighbour at rest: 0 of 20. M2: the gate asked for 8 frames by 300 ms (about 27 fps); the two shape checks need a frame by 100 ms and two while the pane grows. A loaded renderer gives 4-7, so the test threw "frames never came in time" on grow-ins it could judge. It now asks for 3. Scratch with the shell renderer kept busy 45 ms per frame: gated at 8, 3 of 3 runs failed, though every round grew from width 0 with 1-2 frames by 100 ms and 4-6 by 300 ms; gated at 3, 3 of 3 passed in round 1, with both shape checks run. A pane that jumps or a starved renderer still fails. F9: SV-25 checks again that the drag is live just before its count, after the 900 ms wait. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/split.spec.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/e2e/split.spec.ts b/tests/e2e/split.spec.ts index 05584ef7..e106b347 100644 --- a/tests/e2e/split.spec.ts +++ b/tests/e2e/split.spec.ts @@ -563,6 +563,8 @@ test('[SV-25][SV-05] a full split row shows no drop target: a tab resting on it await expect(orbit.shell.locator('html')).toHaveClass(/\bis-dragging-tab\b/) // Well past the rest that makes a split on a row with room (400 ms). await orbit.shell.waitForTimeout(900) + // Still live at the count, so a drag that ended during the wait can't pass it (PR #19 review F9). + expect(await orbit.shell.locator('html').getAttribute('class')).toMatch(/\bis-dragging-tab\b/) expect(await orbit.shell.locator('.row-slot.is-split-target').count()).toBe(0) await orbit.shell.mouse.up() await settled(orbit.shell) @@ -789,10 +791,11 @@ test('[SV-26] a new pane grows in with a spring and a closed one fades out', asy const a = await openTab(orbit.app, 'Grow1') await expect.poll(async () => (await tabView(orbit.app, a))?.visible).toBe(true) // 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 (at 100 ms it is about half grown). A starved renderer can - // miss them; then that pane is closed and a new one grows in, up to three rounds (as for timing - // budgets, D46), so the test never passes without seeing the shape (PR #10 review F6). On the - // macOS CI VM a frame can take 100–200 ms (D17), so there only the end state is checked. + // checked on frames that came in time (at 100 ms it is about half grown), which needs a frame by + // 100 ms and a few while it grows. A starved renderer can miss them; then that pane is closed and a + // new one grows in, up to three rounds (as for timing budgets, D46), so the test never passes + // without seeing the shape (PR #10 review F6). On the macOS CI VM a frame can take 100–200 ms + // (D17), so there only the end state is checked. let grown: { tabId: string; samples: GrowProbe['samples'] } | undefined const rounds: string[] = [] for (let round = 1; round <= 3 && !grown; round++) { @@ -803,10 +806,17 @@ test('[SV-26] a new pane grows in with a spring and a closed one fades out', asy const { samples } = await growIn(b, a) const early = samples.filter((s) => s.t <= 100).length const by300 = samples.filter((s) => s.t <= 300).length - if (FRAME_SLACK !== 1 || (early > 0 && by300 >= 8)) grown = { tabId: b, samples } + // The checks below need a frame by 100 ms and two while it grows (PR #19 review M2). + if (FRAME_SLACK !== 1 || (early > 0 && by300 >= 3)) grown = { tabId: b, samples } else { rounds.push(`round ${round}: ${early} frames by 100 ms, ${by300} by 300 ms`) await run(orbit.app, 'split.closePane', { tabId: b }) + // The next pane grows from the neighbour that overlaps it, so wait for the close to finish: the + // shell has drawn it (its ghost exists from here) and the ghost has faded. Until then the + // neighbour may not reach the next pane's edge yet, and that pane appears at full size + // (PR #19 review M1). + await expect(orbit.shell.locator(`[data-testid="split-pane"][data-tab-id="${b}"]`)).toHaveCount(0) + await expect(orbit.shell.locator('[data-testid="split-ghost"]')).toHaveCount(0) } } if (!grown) throw new Error(`frames never came in time to show the grow-in (${rounds.join('; ')})`) From 4345e7219b29ebcb62c8c7cb83d39c2278a1fc67 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:24:20 +0000 Subject: [PATCH 6/6] test(e2e): first-run launches get the resolver rules; own EXCLUDEs only for loopback; a probe only the rules refuse (PR #19 review F1, F3-F5) F1: launchFirstRun (onboarding's first-run launches) passed only the dead proxy, which doesn't stop speculative lookups. It now builds its switches with offlineArgs(), as launchOrbit does. F3: Chromium checks every EXCLUDE rule before any MAP, wherever it is in the list, and looks an excluded host up for real, so a test's own EXCLUDE turned real DNS back on for that host. The comment said the first matching rule wins, which holds for MAP only. offlineArgs() now throws on an own EXCLUDE for a host that isn't loopback (localhost, 127.0.0.1, ::1, *.localhost). No test passes its own rules today. Checked with a scratch spec: example.com, host.example and 127.0.0.2 are refused; localhost, x.localhost and ::1 pass through in order. F4: a note beside the rules that they don't stop an mDNS lookup; nothing asks for one. F5: the smoke test's lookup probe was example.com, which passes without the rules on a machine with no DNS, and would itself send a query on one with DNS if the rules went missing. It now probes 127.0.0.2: an address needs no lookup, so only the rules refuse it. With the rules missing that one address (scratch), the test fails: "Received string: 127.0.0.2". zz-guard scratch, smoke, onboarding and split: 53 passed, at load 8-31. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/helpers.ts | 18 ++++++++++++++++-- tests/e2e/settings-support.ts | 8 ++++---- tests/e2e/smoke.spec.ts | 10 ++++++---- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 8dd7b3be..3e50f97e 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -36,14 +36,28 @@ export interface Orbit { */ const DEAD_PROXY = '127.0.0.1:9' const RESOLVER_RULES = '--host-resolver-rules=' +/** + * These rules don't stop an mDNS lookup: Chromium still multicasts the mapped name on the local + * network. Nothing in Orbit or the tests asks for one (`resolveHost` with `source: 'mdns'`). + */ const NO_LOOKUPS = 'MAP * ~NOTFOUND, EXCLUDE localhost, EXCLUDE 127.0.0.1' +/** Hosts a test may exclude from the rules: Chromium or the hosts file answers them, on this machine. */ +const LOOPBACK_HOST = /^(localhost|127\.0\.0\.1|::1|[\w.-]+\.localhost)$/ /** - * The switches that keep Orbit offline. A test's own resolver rules go first, so they still apply - * (the first rule that matches a host wins), and every other host still fails to resolve. + * The switches that keep Orbit offline. A test's own resolver rules go first, so its MAP rules win + * (the first MAP that matches a host does), and every other host still fails to resolve. Chromium + * checks every EXCLUDE before any MAP, wherever it is in the list, and an excluded host is looked up + * for real, so a test may only exclude loopback hosts (PR #19 review F3). */ export function offlineArgs(args: string[] = []): string[] { const own = args.filter((a) => a.startsWith(RESOLVER_RULES)).map((a) => a.slice(RESOLVER_RULES.length)) + for (const rule of own.flatMap((r) => r.split(','))) { + const host = /^\s*EXCLUDE\s+(\S+)\s*$/i.exec(rule)?.[1] + if (host !== undefined && !LOOPBACK_HOST.test(host)) { + throw new Error(`a test's resolver rules exclude ${host}, which would be looked up for real`) + } + } return [`--proxy-server=http://${DEAD_PROXY}`, `${RESOLVER_RULES}${[...own, NO_LOOKUPS].join(', ')}`] } diff --git a/tests/e2e/settings-support.ts b/tests/e2e/settings-support.ts index 3cfd26cf..2f394f5c 100644 --- a/tests/e2e/settings-support.ts +++ b/tests/e2e/settings-support.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { type ElectronApplication, _electron as electron, expect, type Page } from '@playwright/test' import type { MainContext } from '../../src/main/context' -import { checkQuit, evaluateBetweenTasks, run } from './helpers' +import { checkQuit, evaluateBetweenTasks, offlineArgs, run } from './helpers' /** The main process's test handle (src/main/test-hooks.ts), for app.evaluate() bodies. */ export type OrbitGlobal = { __orbit: { ctx: MainContext; windowIds: () => string[] } } @@ -91,15 +91,15 @@ export interface FirstRun { } /** - * Starts Orbit as launchOrbit does (offline, behind the same dead proxy; isolated profile), and also - * records every network request Chromium makes from the first moment, for OB-02. + * Starts Orbit as launchOrbit does (offline: the same dead proxy and resolver rules; isolated + * profile), and also records every network request Chromium makes from the first moment, for OB-02. */ export async function launchFirstRun( opts: { userData?: string; env?: Record } = {}, ): Promise { const userData = opts.userData ?? mkdtempSync(join(tmpdir(), 'orbit-e2e-')) const netLog = join(mkdtempSync(join(tmpdir(), 'orbit-netlog-')), 'netlog.json') - const args = ['--proxy-server=http://127.0.0.1:9', `--log-net-log=${netLog}`, resolve('out/main/index.js')] + const args = [...offlineArgs(), `--log-net-log=${netLog}`, resolve('out/main/index.js')] if (process.platform === 'linux') args.unshift('--no-sandbox') const app = await electron.launch({ args, diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts index 69e23033..b5a1e276 100644 --- a/tests/e2e/smoke.spec.ts +++ b/tests/e2e/smoke.spec.ts @@ -64,14 +64,16 @@ test('stays offline: no host name but localhost resolves, in any profile (PR #10 (r) => r.endpoints.map((e) => e.address).join(' '), (e: Error) => e.message, ) + // An address needs no lookup, so only the rules refuse 127.0.0.2, and no query can leave the + // machine even when the rules are missing (a host name would then be looked up; PR #19 review F5). return Promise.all( - sessions.map(async (s) => ({ web: await lookUp(s, 'example.com'), local: await lookUp(s, 'localhost') })), + sessions.map(async (s) => ({ mapped: await lookUp(s, '127.0.0.2'), local: await lookUp(s, 'localhost') })), ) }, partitions) expect(lookups.length).toBeGreaterThan(1) - for (const { web, local } of lookups) { - // Refused by the resolver rules at once: no query leaves the machine. - expect(web).toContain('ERR_NAME_NOT_RESOLVED') + for (const { mapped, local } of lookups) { + // Refused by the resolver rules at once, as every host but localhost is. + expect(mapped).toContain('ERR_NAME_NOT_RESOLVED') expect(local).toMatch(/127\.0\.0\.1|::1/) } })