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..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), @@ -77,9 +79,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 @@ -169,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), @@ -214,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) @@ -420,14 +435,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/core.spec.ts b/tests/e2e/core.spec.ts index 05b73521..2c1c5885 100644 --- a/tests/e2e/core.spec.ts +++ b/tests/e2e/core.spec.ts @@ -407,6 +407,36 @@ test("[TB-22] a quit Orbit cancels fails the test: Playwright's own quit doesn't await orbit.app.close() }) +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 b6aac5cc..3e50f97e 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -31,9 +31,35 @@ 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=' +/** + * 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 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(', ')}`] +} /** * Launches the built app (out/) with an isolated profile directory. `args` are extra Chromium @@ -47,7 +73,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({ @@ -86,7 +114,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`. */ export function evaluateBetweenTasks(app: ElectronApplication): void { const evaluate = app.evaluate.bind(app) @@ -97,9 +127,34 @@ export 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/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 baeac373..b5a1e276 100644 --- a/tests/e2e/smoke.spec.ts +++ b/tests/e2e/smoke.spec.ts @@ -54,6 +54,30 @@ 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, + ) + // 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) => ({ mapped: await lookUp(s, '127.0.0.2'), local: await lookUp(s, 'localhost') })), + ) + }, partitions) + expect(lookups.length).toBeGreaterThan(1) + 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/) + } +}) + 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) diff --git a/tests/e2e/split.spec.ts b/tests/e2e/split.spec.ts index 85e19062..e106b347 100644 --- a/tests/e2e/split.spec.ts +++ b/tests/e2e/split.spec.ts @@ -559,8 +559,12 @@ 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) + // 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) @@ -742,13 +746,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 +773,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 +782,52 @@ 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), 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++) { + 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 + // 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('; ')})`) + 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) 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])