Files
Codeman/test/terminal-tile-scroll.test.ts
T
Codeman maintainer edf3cb19e4 Merge pull request #699: fix(terminal): a mouse wheel notch pages the CLI's transcript at once
Conflict in docs/architecture-invariants.md resolved by keeping both
sides: the hollow-pane paragraph carries #681's unloaded-scrollback
sentence, #698's Codex-menu rewording and #699's notch rewording (a
word-level three-way merge; the three edits touch disjoint sentences).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 15:22:43 +02:00

1132 lines
46 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview A TerminalTile pages a hollow buffer's CLI transcript, and
* reports a plain click to a CLI whose mouse DECSETs the server strips, the way
* the primary pane does (#555), through the primary pane's own gates aimed at
* the TILE: its terminal, its session, never the active one.
*
* opencode draws in place on the alternate screen, so tmux keeps no history
* for it and the browser's buffer holds one screen. The primary pane turns the
* wheel into PageUp/PageDown there (`_maybePageCliTranscript`, terminal-ui.js);
* a tile left the wheel to xterm, which scrolled nothing. #555 also strips
* opencode's mouse DECSETs on the server, which reaches tile sockets too: a
* drag selects text again, but xterm's own encoder no longer reports a click,
* so the tile now hand-encodes it like the primary pane.
*
* A tile's buffer is rarely empty above the screen even so: its first capture
* is taken at the PTY's previous, taller size and written into a shorter
* xterm, and its own row-shrinking fits push more rows up. Those rows are not
* history, so the tile discounts them (`_localRows`); the overflow cases below
* fail without that discount.
*
* Claude's fullscreen renderer is the other half: it scrolls its own
* transcript on SGR wheel reports, which the primary pane sends it, while a
* tile's xterm holds only replayed repaint frames. A tile left that wheel to
* xterm too, so in a grid of fullscreen Claude sessions the wheel dragged
* stale frames around or did nothing; it now forwards the reports to its own
* session, from its own geometry (`_maybeForwardWheelToCli`).
*
* Real code under test: constants.js + app.js + terminal-ui.js +
* terminal-tile.js in one `vm` context, as in terminal-tile-input.test.ts.
* xterm, the fit addon and WebSocket are fakes (test/mocks/terminal-tile-fakes.ts),
* with the fake xterm's row emulation switched on.
*
* Port: none (no server, no browser).
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
import { FakeFit, FakeSocket, FakeTerminal } from './mocks/terminal-tile-fakes.js';
const fetchMock = vi.fn();
/**
* The run modes that page, read off the shipped registry exactly as the server
* builds `window.__codemanTranscriptPageKeys` (renderIndexHtml), which is the only
* place either pane learns them.
*/
const PAGE_KEY_MODES = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id);
/**
* The clock both panes read gesture gaps from (`performance.now()`). Real time by
* default; a test that needs a pause or a burst moves it by hand.
*/
const clock = { now: () => performance.now() };
function loadContext() {
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
const windowStub: Record<string, unknown> = {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
CodemanBase: { base: '' },
__codemanTranscriptPageKeys: PAGE_KEY_MODES,
};
const context = vm.createContext({
console: { ...console, log: vi.fn(), debug: vi.fn() },
performance: { now: () => clock.now() },
setInterval: vi.fn(),
clearInterval: vi.fn(),
// Late-bound, so vi.useFakeTimers() (which swaps the globals) reaches code
// running inside this context.
setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms),
clearTimeout: (id: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(id),
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: FakeSocket,
Terminal: FakeTerminal,
FitAddon: { FitAddon: FakeFit },
fetch: (...args: unknown[]) => fetchMock(...args),
location: { protocol: 'http:', host: 'codeman.test' },
document: { addEventListener: vi.fn(), documentElement: { dataset: {} } },
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
window: windowStub,
MobileDetection: {
isTouchDevice: () => false,
isHandheldDevice: () => false,
getDeviceType: () => 'desktop',
},
});
vm.runInContext(
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-tile.js')}\n` +
'globalThis.__CodemanApp = CodemanApp;',
context
);
return {
CodemanApp: (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp,
windowStub,
};
}
const { CodemanApp, windowStub } = loadContext();
type Session = { mode: string; cliVersion?: string; cliMouseTracking?: boolean };
type App = Record<string, unknown> & {
sessions: Map<string, Session>;
activeSessionId: string | null;
_linkHovered?: boolean;
loadAppSettingsFromStorage: () => Record<string, unknown>;
};
function makeApp(sessions: Record<string, Session>, activeSessionId: string | null = 'other'): App {
const app = Object.create(CodemanApp.prototype) as App;
app._clientId = 'c-test';
app._wsTabNonce = 'nonce-1';
app._seqCounters = new Map();
app._pendingDeliveries = new Map();
app._postDraining = new Set();
app._extraInputSockets = new Map();
app._persistReliableState = vi.fn();
app._persistReliableNow = vi.fn();
app._updateConnectionIndicator = vi.fn();
app.markIdleAlertSeen = vi.fn();
app.showToast = vi.fn();
app.loadAppSettingsFromStorage = () => ({});
app._ws = null;
app._wsSessionId = null;
app._estimateReplayRows = (text: string) => text.split('\n').length;
app.sessions = new Map(Object.entries(sessions));
app.activeSessionId = activeSessionId;
return app;
}
/** The tile's mount element: records its listeners by type, with their options. */
function makeMount() {
const listeners: Record<string, Array<{ fn: (ev: unknown) => void; opts: unknown }>> = {};
return {
listeners,
addEventListener: vi.fn((type: string, fn: (ev: unknown) => void, opts?: unknown) => {
(listeners[type] ||= []).push({ fn, opts });
}),
removeEventListener: vi.fn(),
fire(type: string, ev: unknown) {
for (const { fn } of listeners[type] || []) fn(ev);
},
};
}
type Tile = {
connect(): Promise<void>;
destroy(): void;
fit(opts?: { force?: boolean }): void;
ws: FakeSocket | null;
sessionId: string;
_linkHovered: boolean;
_scrollFlushTimer: unknown;
_onPtyGeometryReport(cols: number, rows: number): void;
};
const TerminalTile = windowStub.TerminalTile as new (id: string, mount: unknown, opts?: object) => Tile;
const liveTiles: Tile[] = [];
const lines = (n: number) => Array.from({ length: n }, (_, i) => `row ${i}`).join('\r\n');
/** The capture a tile's first load receives. `captureRows` absent = the server could not say. */
function serveCapture(terminalBuffer: string, captureRows?: number) {
fetchMock.mockImplementation(async () => ({
ok: true,
status: 200,
json: async () => ({ data: { terminalBuffer, ...(captureRows === undefined ? {} : { captureRows }) } }),
}));
}
async function connectTile(app: App, opts: { sessionId?: string; mode?: string } = {}) {
windowStub.app = app;
const mount = makeMount();
const tile = new TerminalTile(opts.sessionId ?? 's-tile', mount, { mode: opts.mode ?? 'opencode' });
liveTiles.push(tile);
await tile.connect();
const ws = FakeSocket.instances.at(-1)!;
ws.open();
return { tile, ws, term: FakeTerminal.last!, mount };
}
function wheel(deltaY: number, extra: Record<string, unknown> = {}) {
return {
deltaY,
deltaX: 0,
deltaMode: 0,
shiftKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
...extra,
};
}
/** One wheel event worth `rowsOfTravel` lines (pixel mode: 25 px a line). */
const wheelLines = (rowsOfTravel: number, extra: Record<string, unknown> = {}) => wheel(rowsOfTravel * 25, extra);
const PAGE_UP = '\x1b[5~';
const PAGE_DOWN = '\x1b[6~';
/** Frames the tile's socket sent once the 40 ms coalescer has flushed. */
function flushed(ws: FakeSocket) {
vi.advanceTimersByTime(40);
return ws.inputFrames();
}
beforeEach(() => {
clock.now = () => performance.now();
vi.useFakeTimers();
FakeFit.proposed = { cols: 80, rows: 24 };
FakeSocket.instances = [];
FakeTerminal.emulateScroll = true;
fetchMock.mockReset();
serveCapture(lines(10), 24);
});
afterEach(() => {
for (const tile of liveTiles.splice(0)) tile.destroy();
FakeTerminal.emulateScroll = false;
vi.useRealTimers();
});
describe('a tile pages a hollow buffer through the primary pane gates', () => {
it('turns half a screen of wheel-up into one ephemeral PageUp on its own socket, and down into PageDown', async () => {
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
expect(term.buffer.active.baseY).toBe(0);
const up = wheelLines(-term.rows / 2);
mount.fire('wheel', up);
expect(up.preventDefault).toHaveBeenCalled();
expect(up.stopPropagation).toHaveBeenCalled();
expect(ws.inputFrames()).toEqual([]); // fallback page reports remain batched
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); // no seq: never persisted
mount.fire('wheel', wheelLines(term.rows / 2));
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: PAGE_DOWN });
});
it('pages a copilot tile with an empty local buffer (the stale-tracking fallback)', async () => {
const app = makeApp({ other: { mode: 'shell' }, 's-tile': { mode: 'copilot', cliMouseTracking: false } }, 'other');
const { ws, mount } = await connectTile(app, { mode: 'copilot' });
mount.fire('wheel', wheelLines(-12));
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
});
it("reads the TILE's session, not the active one", async () => {
// Active session is a shell; the tile shows opencode: the tile still pages.
const app = makeApp({ other: { mode: 'shell' }, 's-tile': { mode: 'opencode' } }, 'other');
const { ws, mount } = await connectTile(app);
mount.fire('wheel', wheelLines(-12));
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
});
it.each(['shell', 'gemini', 'antigravity'])(
'leaves a %s tile to xterm even while the active session is opencode',
async (mode) => {
const app = makeApp({ other: { mode: 'opencode' }, 's-tile': { mode } }, 'other');
const { ws, mount } = await connectTile(app, { mode });
const ev = wheelLines(-12);
mount.fire('wheel', ev);
expect(ev.preventDefault).not.toHaveBeenCalled();
expect(ev.stopPropagation).not.toHaveBeenCalled();
expect(flushed(ws)).toEqual([]);
}
);
it('still pulls history on a wheel-up at the top of a shell tile', async () => {
const { mount } = await connectTile(makeApp({ 's-tile': { mode: 'shell' } }), { mode: 'shell' });
expect(fetchMock).toHaveBeenCalledTimes(1); // the initial load
mount.fire('wheel', wheelLines(-3));
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(String(fetchMock.mock.calls[1][0])).toContain('full=1&tail=');
});
it('never pages Shift, a tracking xterm, the alternate buffer, real history or a horizontal swipe', async () => {
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
const fireAndCheck = (ev: ReturnType<typeof wheel>) => {
mount.fire('wheel', ev);
expect(ev.preventDefault).not.toHaveBeenCalled();
expect(ev.stopPropagation).not.toHaveBeenCalled();
};
// The explicit "local scrollback" gesture: scrolled locally, never paged.
mount.fire('wheel', wheelLines(-12, { shiftKey: true }));
term.modes.mouseTrackingMode = 'any'; // xterm's own encoder forwards the wheel
fireAndCheck(wheelLines(-12));
term.modes.mouseTrackingMode = 'none';
term.buffer.active.type = 'alternate'; // xterm's alt-scroll owns it
fireAndCheck(wheelLines(-12));
term.buffer.active.type = 'normal';
fireAndCheck(wheel(0, { deltaX: 120 })); // pure horizontal: nothing to page
term.write(lines(40)); // real output scrolled real lines above the screen
expect(term.buffer.active.baseY).toBeGreaterThan(0);
fireAndCheck(wheelLines(-12));
expect(flushed(ws)).toEqual([]);
});
});
describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports', () => {
const FULLSCREEN: Session = { mode: 'claude', cliVersion: '2.1.295', cliMouseTracking: true };
// 10 + 8 * 20 + 1, 20 + 16 * 5 + 1 inside the tile's own screen: column 21, row 6.
const AT = { clientX: 171, clientY: 101 };
const UP = '\x1b[<64;21;6M';
const DOWN = '\x1b[<65;21;6M';
it("sends ephemeral reports on the tile's socket, from the tile's own geometry, and keeps xterm out", async () => {
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
const up = wheelLines(-3, AT);
mount.fire('wheel', up);
expect(up.preventDefault).toHaveBeenCalled();
expect(up.stopPropagation).toHaveBeenCalled();
expect(ws.inputFrames()).toEqual([{ t: 'i', d: UP.repeat(3) }]); // first report never waits for a timer
expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(3) }]); // no seq: never persisted
mount.fire('wheel', wheelLines(2, AT));
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: DOWN.repeat(2) });
});
it('uses fractional native scrolling for a tracked Codex tile, never PageUp', async () => {
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'codex', cliMouseTracking: true } }), {
mode: 'codex',
});
for (let i = 0; i < 10; i++) mount.fire('wheel', wheel(-3, AT));
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]);
});
it('preserves coarse pixel wheel travel and caps individual accelerated events', async () => {
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
mount.fire('wheel', wheel(-100, AT));
expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(4) }]);
mount.fire('wheel', wheelLines(-1000, AT));
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: UP.repeat(5) });
});
it('forwards an isolated small wheel event and five slow clicks for every tracked CLI', async () => {
for (const mode of ['claude', 'codex', 'copilot']) {
let now = 1000;
clock.now = () => now;
const session = mode === 'claude' ? FULLSCREEN : { mode, cliMouseTracking: true };
const { ws, mount } = await connectTile(makeApp({ 's-tile': session }), { mode });
mount.fire('wheel', wheel(-4, AT));
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]);
for (let i = 1; i < 5; i++) {
now += 300;
vi.advanceTimersByTime(300);
mount.fire('wheel', wheel(-4, AT));
flushed(ws);
}
expect(ws.inputFrames().map((f) => f.d)).toEqual(Array(5).fill(UP));
// Separate pane/visit: ten 2.5px events produce only the pre-paid tick.
const fine = await connectTile(makeApp({ 's-tile': session }), { mode });
for (let i = 0; i < 10; i++) fine.mount.fire('wheel', wheel(-2.5, AT));
expect(flushed(fine.ws)).toEqual([{ t: 'i', d: UP }]);
}
});
it('accumulates precision touchpad deltas and consumes a pure horizontal swipe without sending', async () => {
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
mount.fire('wheel', wheel(-3, AT)); // pre-pay one tick, then repay it
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]);
for (let i = 0; i < 8; i++) mount.fire('wheel', wheel(-3, AT));
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]);
const sideways = wheel(0, { ...AT, deltaX: 120 });
mount.fire('wheel', sideways);
expect(sideways.preventDefault).toHaveBeenCalled(); // never xterm's stale frames
expect(flushed(ws)).toHaveLength(1);
});
it('sends the first report immediately and coalesces subsequent wheels within 40 ms', async () => {
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
mount.fire('wheel', wheelLines(-2, AT));
vi.advanceTimersByTime(20);
mount.fire('wheel', wheelLines(-2, AT));
mount.fire('wheel', wheelLines(-2, AT));
expect(ws.inputFrames()).toEqual([{ t: 'i', d: UP.repeat(2) }]);
expect(flushed(ws)).toEqual([
{ t: 'i', d: UP.repeat(2) },
{ t: 'i', d: UP.repeat(4) },
]);
});
it('does not discard queued reports when a zero-delta wheel arrives', async () => {
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
mount.fire('wheel', wheelLines(-1, AT));
mount.fire('wheel', wheelLines(-2, AT));
mount.fire('wheel', wheel(0, AT));
expect(flushed(ws)).toEqual([
{ t: 'i', d: UP },
{ t: 'i', d: UP.repeat(2) },
]);
});
it('snaps a scrolled-up viewport home before reporting, since SGR rows address the live screen', async () => {
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
term.buffer.active.baseY = 10;
term.buffer.active.viewportY = 0;
mount.fire('wheel', wheelLines(-1, AT));
expect(term.scrolledToBottom).toBe(1);
expect(term.buffer.active.viewportY).toBe(10);
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]);
});
it("follows the tile session's gate, never the active session's", async () => {
// Active session is a shell; the tile shows fullscreen Claude: the tile forwards.
const forwarded = await connectTile(makeApp({ other: { mode: 'shell' }, 's-tile': FULLSCREEN }, 'other'), {
mode: 'claude',
});
forwarded.mount.fire('wheel', wheelLines(-1, AT));
expect(flushed(forwarded.ws)).toEqual([{ t: 'i', d: UP }]);
// Active session is fullscreen Claude; the tile shows inline Claude (no
// tracking): no reports, the hollow buffer is paged instead.
const inline = await connectTile(
makeApp({ other: FULLSCREEN, 's-tile': { ...FULLSCREEN, cliMouseTracking: false } }, 'other'),
{ mode: 'claude' }
);
inline.mount.fire('wheel', wheelLines(-12, AT));
expect(flushed(inline.ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
});
it('scrolls Shift locally, and leaves a tracking xterm and the alternate buffer to xterm', async () => {
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
const fireAndCheck = (ev: ReturnType<typeof wheel>) => {
mount.fire('wheel', ev);
expect(ev.preventDefault).not.toHaveBeenCalled();
expect(ev.stopPropagation).not.toHaveBeenCalled();
};
const shifted = wheelLines(-3, { ...AT, shiftKey: true }); // the explicit "local scrollback" gesture
mount.fire('wheel', shifted);
expect(shifted.preventDefault).toHaveBeenCalled();
expect(term.scrolledLines).toEqual([-3]);
term.modes.mouseTrackingMode = 'any'; // xterm's own encoder forwards the wheel
fireAndCheck(wheelLines(-3, AT));
term.modes.mouseTrackingMode = 'none';
term.buffer.active.type = 'alternate'; // xterm's alt-scroll owns it
fireAndCheck(wheelLines(-3, AT));
expect(flushed(ws)).toEqual([]);
});
it('pages, never reports, a Claude older than 2.1.187', async () => {
const app = makeApp({ 's-tile': { ...FULLSCREEN, cliVersion: '2.1.100' } });
const { ws, mount } = await connectTile(app, { mode: 'claude' });
mount.fire('wheel', wheelLines(-12, AT));
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
});
it('sends exactly the bytes the primary pane sends for the same wheel sequence', async () => {
const sequence = [-1, -3, 2, -100, 40, -0.2, 7].map((n) => wheelLines(n, AT));
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
const tileBytes: string[] = [];
for (const ev of sequence) {
mount.fire('wheel', ev);
tileBytes.push(
...flushed(ws)
.splice(tileBytes.length)
.map((f) => f.d as string)
);
}
const primary = makeApp({ other: FULLSCREEN }, 'other') as App & {
terminal: unknown;
_sendSyntheticSgrWheel(x: number, y: number, lines: number): void;
_wheelScrollLinesFloat(ev: unknown): number;
_flushWheelSgrQueue(): void;
};
const sent: string[] = [];
primary._sendInputEphemeral = (_id: string, data: string) => sent.push(data);
primary.terminal = new FakeTerminal({});
for (const ev of sequence) {
primary._sendSyntheticSgrWheel(ev.clientX, ev.clientY, primary._wheelScrollLinesFloat(ev));
primary._flushWheelSgrQueue();
}
expect(tileBytes.join('')).not.toBe('');
expect(tileBytes).toEqual(sent);
});
});
describe("Shift+wheel scrolls the tile's local scrollback itself", () => {
// xterm's own scroller turns a Shift+vertical wheel into a horizontal one off
// macOS, and Chrome on Windows already delivers Shift+wheel as deltaX, so
// left to xterm the gesture moved nothing.
it.each(['claude', 'opencode', 'shell'])('scrolls a %s tile with real history, and sends nothing', async (mode) => {
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode } }), { mode });
term.write(lines(40));
const bottom = term.buffer.active.baseY;
expect(bottom).toBeGreaterThan(5);
const up = wheelLines(-3, { shiftKey: true });
mount.fire('wheel', up);
expect(up.preventDefault).toHaveBeenCalled();
expect(up.stopPropagation).toHaveBeenCalled();
expect(term.buffer.active.viewportY).toBe(bottom - 3);
mount.fire('wheel', wheelLines(3, { shiftKey: true }));
expect(term.buffer.active.viewportY).toBe(bottom);
expect(flushed(ws)).toEqual([]);
});
it("reads Chrome-on-Windows' horizontal Shift+wheel (deltaX) as vertical travel", async () => {
const { term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'claude' } }), { mode: 'claude' });
mount.fire('wheel', wheel(0, { deltaX: -100, shiftKey: true }));
expect(term.scrolledLines).toEqual([-4]);
});
it('carries sub-line travel to the next event, so a trackpad still scrolls', async () => {
const { term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'claude' } }), { mode: 'claude' });
mount.fire('wheel', wheel(-10, { shiftKey: true })); // 0.4 of a line
mount.fire('wheel', wheel(-10, { shiftKey: true }));
expect(term.scrolledLines).toEqual([]);
mount.fire('wheel', wheel(-10, { shiftKey: true }));
expect(term.scrolledLines).toEqual([-1]);
});
it('still pulls history on a Shift+wheel-up at the top of a shell tile', async () => {
const { mount } = await connectTile(makeApp({ 's-tile': { mode: 'shell' } }), { mode: 'shell' });
expect(fetchMock).toHaveBeenCalledTimes(1); // the initial load
mount.fire('wheel', wheelLines(-3, { shiftKey: true }));
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(String(fetchMock.mock.calls[1][0])).toContain('full=1&tail=');
});
});
describe("the forwarding gate behaves as in the primary pane, for the tile's session", () => {
it('pages that same tile under the "Wheel scrolls local history" opt-out (the footgun rescue)', async () => {
const app = makeApp({ 's-tile': { mode: 'claude', cliVersion: '2.1.223', cliMouseTracking: true } });
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true });
const { ws, mount } = await connectTile(app, { mode: 'claude' });
mount.fire('wheel', wheelLines(-12));
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
});
it('pages a Claude tile whose CLI version is unknown', async () => {
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'claude', cliMouseTracking: true } }), {
mode: 'claude',
});
mount.fire('wheel', wheelLines(-12));
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
});
});
describe('page-key travel, cap and coalescing', () => {
beforeEach(() => {
FakeFit.proposed = { cols: 80, rows: 36 };
});
it('pages a coarse opening event at once, and the next one repays it instead of paging again', async () => {
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
mount.fire('wheel', wheelLines(-10));
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
mount.fire('wheel', wheelLines(-10));
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
});
it('caps one wheel at three PageUps, in one frame', async () => {
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
mount.fire('wheel', wheelLines(-1000));
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP.repeat(3) }]);
});
it('sends two pages queued within 40 ms as one frame', async () => {
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
mount.fire('wheel', wheelLines(-18));
vi.advanceTimersByTime(20);
mount.fire('wheel', wheelLines(-18));
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP.repeat(2) }]);
});
it('sends exactly the bytes the primary pane sends for the same wheel sequence', async () => {
const sequence = [-10, -10, -4, 30, -1000, 7, -18, 200].map((n) => wheelLines(n));
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
for (const ev of sequence) mount.fire('wheel', ev);
const tileBytes = flushed(ws)
.map((f) => f.d)
.join('');
const primary = makeApp({ other: { mode: 'opencode' } }, 'other') as App & {
terminal: unknown;
_maybePageCliTranscript(ev: unknown, lines: number): boolean;
_wheelScrollLinesFloat(ev: unknown): number;
_flushWheelSgrQueue(): void;
};
const sent: string[] = [];
primary._sendInputEphemeral = (_id: string, data: string) => sent.push(data);
primary.terminal = {
rows: 36,
modes: { mouseTrackingMode: 'none' },
buffer: { active: { type: 'normal', baseY: 0, viewportY: 0 } },
};
for (const ev of sequence) primary._maybePageCliTranscript(ev, primary._wheelScrollLinesFloat(ev));
primary._flushWheelSgrQueue();
expect(tileBytes).not.toBe('');
expect(tileBytes).toBe(sent.join(''));
});
});
describe("gesture handling is the primary pane's, through the shared pure helper", () => {
beforeEach(() => {
FakeFit.proposed = { cols: 80, rows: 36 }; // half a screen: 18 rows a page
});
/** A hand-moved clock; returns a function that advances it. */
function manualClock() {
let now = 10_000;
clock.now = () => now;
return (ms: number) => {
now += ms;
vi.advanceTimersByTime(ms);
};
}
it('pages an untracked Codex tile through the hollow-pane fallback', async () => {
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'codex' } }), { mode: 'codex' });
mount.fire('wheel', wheelLines(-18));
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
});
it('answers the first trackpad-sized event of a gesture at once, and owes the travel back', async () => {
manualClock();
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'codex' } }), { mode: 'codex' });
mount.fire('wheel', wheelLines(-1)); // a trackpad flick opens with a row or so
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
// Same gesture (no pause): the pre-paid page is owed back first, so the next
// PageUp needs a further half screen, not another first event.
mount.fire('wheel', wheelLines(-16));
mount.fire('wheel', wheelLines(-18));
expect(flushed(ws)).toHaveLength(1); // 1 + 16 + 18 rows: still one short of two pages
mount.fire('wheel', wheelLines(-1));
expect(flushed(ws)).toEqual([
{ t: 'i', d: PAGE_UP },
{ t: 'i', d: PAGE_UP },
]);
});
it('accumulates slow wheel notches instead of paging a screen per notch', async () => {
const advance = manualClock();
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
// Five notches of 4 rows, each past the gesture gap: 20 rows, one page.
for (let i = 0; i < 5; i++) {
mount.fire('wheel', wheelLines(-4));
advance(400);
}
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
});
it('consumes a pinch and a mostly horizontal swipe without paging', async () => {
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'codex' } }), { mode: 'codex' });
// Each carries a full page of vertical travel, so either one pages if it
// reaches the travel math at all.
const pinch = wheelLines(-18, { ctrlKey: true });
const swipe = wheel(-18 * 25, { deltaX: -40 * 25 });
mount.fire('wheel', pinch);
mount.fire('wheel', swipe);
// Consumed: xterm must not scroll its stale rows for them either.
expect(pinch.preventDefault).toHaveBeenCalled();
expect(swipe.preventDefault).toHaveBeenCalled();
expect(flushed(ws)).toEqual([]);
});
it('sends exactly the bytes the primary pane sends for a mixed gesture sequence', async () => {
const advance = manualClock();
// [rows of travel, ms before the next event, extra event fields]
const steps: Array<[number, number, Record<string, unknown>?]> = [
[-1, 10],
[-6, 10],
[-30, 300],
[-4, 400],
[-4, 400],
[-1, 10, { ctrlKey: true }],
[-1.2, 10, { deltaX: 200 }],
[1, 10], // reversal, trackpad-sized: a PageDown at once
[2, 500],
[-0.05, 10],
[-200, 10],
];
const events = () => steps.map(([rows, , extra]) => wheelLines(rows, extra));
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'codex' } }), { mode: 'codex' });
const tileEvents = events();
steps.forEach(([, gap], i) => {
mount.fire('wheel', tileEvents[i]);
advance(gap);
});
const tileBytes = flushed(ws)
.map((f) => f.d)
.join('');
const primary = makeApp({ other: { mode: 'codex' } }, 'other') as App & {
terminal: unknown;
_maybePageCliTranscript(ev: unknown, lines: number): boolean;
_wheelScrollLinesFloat(ev: unknown): number;
_flushWheelSgrQueue(): void;
};
const sent: string[] = [];
primary._sendInputEphemeral = (_id: string, data: string) => sent.push(data);
primary.terminal = {
rows: 36,
modes: { mouseTrackingMode: 'none' },
buffer: { active: { type: 'normal', baseY: 0, viewportY: 0 } },
};
const primaryEvents = events();
steps.forEach(([, gap], i) => {
primary._maybePageCliTranscript(primaryEvents[i], primary._wheelScrollLinesFloat(primaryEvents[i]));
advance(gap);
});
primary._flushWheelSgrQueue();
expect(tileBytes).toContain(PAGE_UP);
expect(tileBytes).toContain(PAGE_DOWN);
expect(tileBytes).toBe(sent.join(''));
});
it('pages slow small ticks and a back-and-forth wobble once, exactly as the primary pane does', async () => {
const advance = manualClock();
// Five 40 px ticks 250 ms apart (each under the 2-row trackpad size), a pause
// longer than the pre-paid debt's second, then a +/-3 px wobble at 16 ms.
const steps: Array<[number, number]> = [
...Array.from({ length: 5 }, () => [-40, 250] as [number, number]),
[0, 1_200],
...Array.from({ length: 12 }, (_, i) => [i % 2 ? 3 : -3, 16] as [number, number]),
];
const events = () => steps.filter(([px]) => px).map(([px]) => wheel(px));
const gaps = steps.reduce<number[]>((acc, [px, gap]) => {
if (px) acc.push(gap);
else acc[acc.length - 1] += gap;
return acc;
}, []);
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'codex' } }), { mode: 'codex' });
events().forEach((ev, i) => {
mount.fire('wheel', ev);
advance(gaps[i]);
});
const tileBytes = flushed(ws)
.map((f) => f.d)
.join('');
// One page for the slow ticks; the wobble cancels its unsent opening page on reversal.
expect(tileBytes).toBe(PAGE_UP); // the first slow tick flushed before the later wobble
const primary = makeApp({ other: { mode: 'codex' } }, 'other') as App & {
terminal: unknown;
_maybePageCliTranscript(ev: unknown, lines: number): boolean;
_wheelScrollLinesFloat(ev: unknown): number;
_flushWheelSgrQueue(): void;
};
const sent: string[] = [];
primary._sendInputEphemeral = (_id: string, data: string) => sent.push(data);
primary.terminal = {
rows: 36,
modes: { mouseTrackingMode: 'none' },
buffer: { active: { type: 'normal', baseY: 0, viewportY: 0 } },
};
events().forEach((ev, i) => {
primary._maybePageCliTranscript(ev, primary._wheelScrollLinesFloat(ev));
advance(gaps[i]);
});
primary._flushWheelSgrQueue();
expect(sent.join('')).toBe(tileBytes);
});
});
describe("the dialog check reads the TILE's session, never the active tab's", () => {
it('does not page a tile whose own session has a pending dialog while the active tab is clear', async () => {
const app = makeApp({ other: { mode: 'codex' }, 's-tile': { mode: 'codex' } }, 'other');
(app as App & { tabAlerts: Map<string, string> }).tabAlerts = new Map([['s-tile', 'action']]);
const { ws, mount } = await connectTile(app, { mode: 'codex' });
const ev = wheelLines(-18);
mount.fire('wheel', ev);
// Consumed, so xterm does not scroll the stale rows under the dialog either.
expect(ev.preventDefault).toHaveBeenCalled();
expect(flushed(ws)).toEqual([]);
});
it('pages a clear tile while the ACTIVE tab has a pending dialog', async () => {
const app = makeApp({ other: { mode: 'codex' }, 's-tile': { mode: 'codex' } }, 'other');
(app as App & { tabAlerts: Map<string, string> }).tabAlerts = new Map([['other', 'action']]);
const { ws, mount } = await connectTile(app, { mode: 'codex' });
mount.fire('wheel', wheelLines(-18));
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
});
});
describe("a Codex selection menu on the tile's own screen", () => {
const MENU = [
' Implement this plan?',
'',
'› 1. Yes, implement this plan Switch to Default and start coding',
' 2. Yes, clear context and implement Start a fresh thread (current context: 2% used)',
' 3. No, stay in Plan mode Continue planning with the model',
'',
' enter select · esc back',
];
/** Puts `rows` on the bottom of the tile's 24-row screen, the way xterm's buffer answers getLine. */
function showOnScreen(term: { rows: number; buffer: { active: Record<string, unknown> } }, rows: string[]) {
const screen = [...Array(term.rows - rows.length).fill(''), ...rows];
term.buffer.active.getLine = (y: number) => ({ translateToString: () => screen[y] ?? '' });
}
it('consumes the wheel without paging, and pages again once the menu is gone', async () => {
const app = makeApp({ other: { mode: 'claude' }, 's-tile': { mode: 'codex' } }, 'other');
const { ws, term, mount } = await connectTile(app, { mode: 'codex' });
showOnScreen(term, MENU);
for (const lines of [-18, 18, -18]) {
const ev = wheelLines(lines);
mount.fire('wheel', ev);
// Consumed (xterm does not scroll the stale rows under the menu either), and no key was sent.
expect(ev.preventDefault).toHaveBeenCalled();
}
expect(flushed(ws)).toEqual([]);
// Menu closed: the composer is back on screen and the same wheel pages the transcript.
showOnScreen(term, ['› Ask Codex to do anything', '', ' GPT-6-Astra medium · ~/cases/x', ' ? for shortcuts']);
mount.fire('wheel', wheelLines(-18));
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
});
it("asks about THIS tile's terminal, not the active tab's", async () => {
const app = makeApp({ other: { mode: 'codex' }, 's-tile': { mode: 'codex' } }, 'other');
(app as App & { terminal: unknown }).terminal = {
rows: 24,
buffer: { active: { getLine: () => ({ translateToString: () => '' }) } },
};
const { ws, term, mount } = await connectTile(app, { mode: 'codex' });
showOnScreen(term, MENU);
mount.fire('wheel', wheelLines(-18));
expect(flushed(ws)).toEqual([]);
});
});
describe('rows the tile pushed above the screen itself are not history', () => {
it('pages after a one-screen capture taken at a taller size (the first load)', async () => {
serveCapture(lines(40), 40); // the PTY was 40 rows when it was captured
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
expect(term.buffer.active.baseY).toBe(16); // 40 lines into a 24-row xterm
mount.fire('wheel', wheelLines(-12));
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
});
it('does not page a capture that carried real history', async () => {
serveCapture(lines(60), 24);
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
expect(term.buffer.active.baseY).toBeGreaterThan(0);
const ev = wheelLines(-12);
mount.fire('wheel', ev);
expect(ev.preventDefault).not.toHaveBeenCalled();
expect(flushed(ws)).toEqual([]);
});
it('falls back to the raw baseY when the server sent no captureRows', async () => {
serveCapture(lines(40));
const tall = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
expect(tall.term.buffer.active.baseY).toBe(16);
tall.mount.fire('wheel', wheelLines(-12));
expect(flushed(tall.ws)).toEqual([]);
serveCapture(lines(10));
const short = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
expect(short.term.buffer.active.baseY).toBe(0);
short.mount.fire('wheel', wheelLines(-12));
expect(flushed(short.ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
});
it('keeps paging after a row-shrinking fit pushes more rows up', async () => {
serveCapture(lines(40), 40);
const { tile, ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
FakeFit.proposed = { cols: 80, rows: 20 }; // a zoom-out or a divider drag
tile.fit();
expect(term.buffer.active.baseY).toBe(20);
mount.fire('wheel', wheelLines(-10));
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
});
it('stops paging once output scrolls real lines above the screen', async () => {
serveCapture(lines(40), 40);
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
ws.receive({ t: 'o', d: 'a\r\nb\r\nc\r\n' }); // three real lines scrolled off
const ev = wheelLines(-12);
mount.fire('wheel', ev);
expect(ev.preventDefault).not.toHaveBeenCalled();
expect(flushed(ws)).toEqual([]);
});
it('forgets the overflow when a server clear refreshes the tile, so later real history counts', async () => {
// A `{t:'c'}` is a refresh (a fresh Claude pane's first prompt), and its
// in-stream reset leaves nothing above the screen; the fresh capture is one
// line, so it adds no overflow of its own.
serveCapture(lines(40), 40);
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
expect(term.buffer.active.baseY).toBe(16);
serveCapture('fresh screen', 24);
ws.receive({ t: 'c' });
await vi.advanceTimersByTimeAsync(0);
expect(term.writes.slice(-2)).toEqual(['\x1bc', 'fresh screen']);
expect(term.buffer.active.baseY).toBe(0);
ws.receive({ t: 'o', d: '\r\n'.repeat(term.rows + 1) }); // two real lines above the screen
expect(term.buffer.active.baseY).toBe(2);
const ev = wheelLines(-12);
mount.fire('wheel', ev);
expect(ev.preventDefault).not.toHaveBeenCalled();
expect(flushed(ws)).toEqual([]);
});
it('leaves the wheel to xterm while the viewport sits in those rows, and pages again from the bottom', async () => {
// Hollow by the discount alone: baseY > 0, every row above the screen the
// tile's own. Shift+PageUp, a scrollbar drag or a wheel during the replay
// can leave the viewport up there; a primary hollow buffer (baseY 0) never
// can. Paging from there would swallow every wheel and keep the stale rows
// on screen, so xterm gets the wheel and a wheel-down brings it home.
serveCapture(lines(40), 40);
const { tile, ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
const baseY = term.buffer.active.baseY;
expect(baseY).toBe(16);
expect((tile as unknown as { _localRows(): number })._localRows()).toBe(0);
term.buffer.active.viewportY = baseY - 5;
for (const ev of [wheelLines(-12), wheelLines(12), wheelLines(-5), wheelLines(-5)]) {
mount.fire('wheel', ev);
expect(ev.preventDefault).not.toHaveBeenCalled();
expect(ev.stopPropagation).not.toHaveBeenCalled();
}
expect(flushed(ws)).toEqual([]);
// Back on the live screen, only travel made there counts: the first quarter-screen wheel pages at
// once and the second repays it, with nothing carried over from the wheels xterm had (the gate sits
// before the pending travel is touched).
term.buffer.active.viewportY = baseY;
const first = wheelLines(-6);
mount.fire('wheel', first);
expect(first.preventDefault).toHaveBeenCalled();
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
mount.fire('wheel', wheelLines(-6));
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
});
it('keeps paging when the PTY geometry report reflows the rows above the screen', async () => {
serveCapture(lines(40), 40);
const { tile, ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
// A narrower width wraps the overflow rows onto more rows, as xterm's reflow does.
term.afterResize = () => {
term.lineCount += 3;
term.settleRows();
};
tile._onPtyGeometryReport(60, 40);
expect(term.cols).toBe(60);
expect(term.buffer.active.baseY).toBe(19);
mount.fire('wheel', wheelLines(-12));
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
});
});
describe('a tile reports a plain click to a CLI with mouse tracking on', () => {
const click = (overrides: Record<string, unknown> = {}) => ({
isTrusted: true,
button: 0,
detail: 1,
// 10 + 8 * 20 + 1, 20 + 16 * 5 + 1 inside the tile's own screen: column 21, row 6.
clientX: 171,
clientY: 101,
target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) },
...overrides,
});
const TAP = '\x1b[<0;21;6M\x1b[<0;21;6m';
it("sends one ephemeral SGR press+release on the tile's socket, from the tile's own geometry", async () => {
const app = makeApp({ other: { mode: 'shell' }, 's-tile': { mode: 'opencode', cliMouseTracking: true } });
const { ws, mount } = await connectTile(app);
mount.fire('click', click());
// No seq: like every mouse report from a tile, it never enters the
// persisted exactly-once queue, so a reload cannot replay it.
expect(ws.inputFrames().map((f) => [f.d, typeof f.seq])).toEqual([[TAP, 'undefined']]);
expect((app._pendingDeliveries as Map<string, unknown[]>).get('s-tile')?.length ?? 0).toBe(0);
});
it('sends nothing without the flag, over a selection, over its own hovered link, or scrolled up', async () => {
const app = makeApp({ 's-tile': { mode: 'opencode' } });
const { tile, ws, term, mount } = await connectTile(app);
mount.fire('click', click()); // the CLI has no tracking mode on
app.sessions.set('s-tile', { mode: 'opencode', cliMouseTracking: true });
term.selection = 'picked';
mount.fire('click', click()); // a drag-selection just ended
term.selection = '';
tile._linkHovered = true;
mount.fire('click', click()); // the link provider opens this one
tile._linkHovered = false;
term.buffer.active.baseY = 10;
term.buffer.active.viewportY = 0;
mount.fire('click', click()); // would hit-test a different row
term.buffer.active.viewportY = 10;
expect(ws.inputFrames()).toEqual([]);
mount.fire('click', click()); // nothing in the way any more
expect(ws.inputFrames().map((f) => f.d)).toEqual([TAP]);
});
it("is not blocked by the primary pane's own link hover", async () => {
const app = makeApp({ 's-tile': { mode: 'opencode', cliMouseTracking: true } });
app._linkHovered = true;
const { ws, mount } = await connectTile(app);
mount.fire('click', click());
expect(ws.inputFrames().map((f) => f.d)).toEqual([TAP]);
});
it("follows the tile session's flag, never the active session's", async () => {
const reported = await connectTile(
makeApp({
other: { mode: 'claude', cliMouseTracking: false },
's-tile': { mode: 'opencode', cliMouseTracking: true },
})
);
reported.mount.fire('click', click());
expect(reported.ws.inputFrames().map((f) => f.d)).toEqual([TAP]);
const silent = await connectTile(
makeApp({
other: { mode: 'claude', cliMouseTracking: true },
's-tile': { mode: 'opencode', cliMouseTracking: false },
})
);
silent.mount.fire('click', click());
expect(silent.ws.inputFrames()).toEqual([]);
});
});
describe('destroy()', () => {
it('drops queued page keys and detaches both listeners it registered', async () => {
const { tile, ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
mount.fire('wheel', wheelLines(-12)); // queued, not yet flushed
const wheelFn = mount.listeners.wheel[0].fn;
const clickFn = mount.listeners.click[0].fn;
tile.destroy();
vi.advanceTimersByTime(100);
expect(ws.inputFrames()).toEqual([]);
expect(tile._scrollFlushTimer).toBeNull();
expect(mount.removeEventListener).toHaveBeenCalledWith('wheel', wheelFn, { capture: true });
expect(mount.removeEventListener).toHaveBeenCalledWith('click', clickFn);
});
it('registers the wheel listener non-passive in the capture phase and the click one in the bubble phase', async () => {
const { mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
expect(mount.listeners.wheel.map((l) => l.opts)).toEqual([{ capture: true, passive: false }]);
expect(mount.listeners.click.map((l) => l.opts)).toEqual([undefined]);
});
});