Files
Codeman/test/terminal-scroll-intent.test.ts
T

157 lines
6.2 KiB
TypeScript
Raw Normal View History

// Port: none (pure logic in a vm context — no browser, no server).
//
// Issue #259: opening or closing the mobile keyboard forced the terminal to the
// bottom, so a user reading scrollback was yanked down to the live output. The
// settle cycle now captures scroll intent BEFORE the keyboard reflow and returns
// to that anchor instead.
//
// This lives outside test/mobile/ deliberately. That suite is Playwright-driven
// and EXCLUDED from `npm run test:ci` (config/vitest.ci.config.ts), so a
// regression guarded only there is invisible to CI — the exact blind spot that
// let the #279/#280 merge land a red mobile suite behind two green checks.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const SOURCE = readFileSync(resolve(PUBLIC, 'mobile-handlers.js'), 'utf8');
interface FakeTerminal {
buffer: { active: { viewportY: number; baseY: number } };
scrollToBottom: () => void;
scrollToLine: (line: number) => void;
}
/**
* Load mobile-handlers.js and hand back its KeyboardHandler.
*
* The module declares `const KeyboardHandler = {...}` at top level, and a
* lexical binding does not survive to the next vm.runInContext call, so the
* export is appended to the SAME script rather than read back afterwards.
*/
function loadKeyboardHandler(opts: { viewportY: number; baseY: number }) {
const calls: string[] = [];
const terminal: FakeTerminal = {
buffer: { active: { viewportY: opts.viewportY, baseY: opts.baseY } },
scrollToBottom: () => calls.push('scrollToBottom'),
scrollToLine: (line: number) => calls.push(`scrollToLine:${line}`),
};
const app: any = {
terminal,
fitAddon: { fit: () => calls.push('fit') },
// The real predicate (terminal-ui.js isTerminalAtBottom), reproduced so the
// test exercises the same tolerance the runtime uses.
isTerminalAtBottom: () => terminal.buffer.active.viewportY >= terminal.buffer.active.baseY - 2,
relayoutMobileSubagentWindows: () => {},
};
let pendingTimer: (() => void) | null = null;
const context = vm.createContext({
console,
window: { scrollTo: () => {}, matchMedia: () => ({ matches: false }), addEventListener: () => {} },
document: { body: { classList: { add: () => {}, remove: () => {} } }, addEventListener: () => {} },
navigator: { userAgent: 'test', maxTouchPoints: 0 },
app,
setTimeout: (fn: () => void) => {
pendingTimer = fn;
return 1;
},
clearTimeout: () => {
pendingTimer = null;
},
});
vm.runInContext(`${SOURCE}\n;globalThis.__KeyboardHandler = KeyboardHandler;`, context, {
filename: 'mobile-handlers.js',
});
const kh = (context as any).__KeyboardHandler;
// Stub the layout side effects the settle timer fires alongside the scroll.
kh._shrinkPaddingToFit = () => {};
kh._sendTerminalResize = () => {};
return {
kh,
terminal,
calls,
/** Run the coalesced settle timer the way the OS animation eventually would. */
settle: () => {
const fn = pendingTimer;
pendingTimer = null;
fn?.();
},
};
}
describe('keyboard settle preserves scroll intent (issue #259)', () => {
it('scrolls to bottom when the user is following live output', () => {
const { kh, calls, settle } = loadKeyboardHandler({ viewportY: 500, baseY: 500 });
kh._scheduleViewportSettle({ restoreScroll: true });
settle();
expect(calls).toContain('scrollToBottom');
expect(calls.some((c) => c.startsWith('scrollToLine'))).toBe(false);
});
it('returns to the anchor instead of the bottom when the user is reading history', () => {
const { kh, calls, settle } = loadKeyboardHandler({ viewportY: 120, baseY: 500 });
kh._scheduleViewportSettle({ restoreScroll: true });
settle();
expect(calls).toContain('scrollToLine:120');
expect(calls).not.toContain('scrollToBottom');
});
it('captures the anchor BEFORE the reflow, not after', () => {
// The OS emits several viewport heights per animation, so the settle is
// re-scheduled repeatedly. Only the first capture predates fit(); a later
// one would read a viewportY the reflow had already moved.
const { kh, terminal, calls, settle } = loadKeyboardHandler({ viewportY: 120, baseY: 500 });
kh._scheduleViewportSettle({ restoreScroll: true });
terminal.buffer.active.viewportY = 480; // reflow drags the viewport down
kh._scheduleViewportSettle({ restoreScroll: true });
settle();
expect(calls).toContain('scrollToLine:120');
});
it('clamps an anchor that outlives the buffer it was captured from', () => {
const { kh, terminal, calls, settle } = loadKeyboardHandler({ viewportY: 400, baseY: 500 });
kh._scheduleViewportSettle({ restoreScroll: true });
terminal.buffer.active.baseY = 90; // buffer shrank under us
settle();
expect(calls).toContain('scrollToLine:90');
});
it('leaves the terminal alone when the settle was not a keyboard transition', () => {
const { kh, calls, settle } = loadKeyboardHandler({ viewportY: 120, baseY: 500 });
kh._scheduleViewportSettle({});
settle();
expect(calls).toContain('fit');
expect(calls).not.toContain('scrollToBottom');
expect(calls.some((c) => c.startsWith('scrollToLine'))).toBe(false);
});
});
describe('keyboard show/hide route through the intent-preserving path (static guard)', () => {
it('both transitions ask to restore scroll, never to force the bottom', () => {
// Slice from the METHOD DEFINITIONS ("\n name() {"), not the first
// occurrence of the name — both are called from _checkKeyboard() further up.
const bodyOf = (name: string) => {
const start = SOURCE.indexOf(`\n ${name}() {`);
expect(start, `${name} definition not found`).toBeGreaterThan(-1);
return SOURCE.slice(start, SOURCE.indexOf('\n },', start));
};
const show = bodyOf('onKeyboardShow');
const hide = bodyOf('onKeyboardHide');
expect(show).toContain('_scheduleViewportSettle({ restoreScroll: true })');
expect(hide).toContain('_scheduleViewportSettle({ restoreScroll: true })');
// The old unconditional call must not come back.
expect(SOURCE).not.toContain('scrollToBottom: true');
});
});