mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
157 lines
6.2 KiB
TypeScript
157 lines
6.2 KiB
TypeScript
// 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');
|
||
|
|
});
|
||
|
|
});
|