Files
Codeman/test/mobile/keyboard.test.ts

1692 lines
69 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.
// Port 3200 - Virtual keyboard simulation tests
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import type { Page, BrowserContext } from 'playwright';
import { PORTS, KEYBOARD, SELECTORS, BODY_CLASSES, WAIT } from './helpers/constants.js';
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, getBrowser, closeAllBrowsers } from './helpers/browser.js';
import {
showKeyboard,
hideKeyboard,
showKeyboardViaCDP,
hideKeyboardViaCDP,
showKeyboardViaMock,
hideKeyboardViaMock,
showKeyboardViaDOM,
hideKeyboardViaDOM,
setupViewportMock,
} from './helpers/keyboard-sim.js';
import { getCDP, setVisualViewportHeight } from './helpers/cdp.js';
import {
assertHasClass,
assertNotHasClass,
assertVisible,
assertHidden,
getCSSProperty,
} from './helpers/assertions.js';
import { REPRESENTATIVE_DEVICES } from './devices.js';
import type { WebServer } from '../src/web/server.js';
const PORT = PORTS.KEYBOARD;
const BASE_URL = `http://localhost:${PORT}`;
// ─── Page-global access helpers ───
// KeyboardHandler is a `const` in app.js — NOT on `window`.
// Use string-based page.evaluate() to access it in the global lexical scope.
async function getKeyboardVisible(page: Page): Promise<boolean | undefined> {
return page.evaluate(`
typeof KeyboardHandler !== 'undefined' ? KeyboardHandler.keyboardVisible : undefined
`);
}
async function getKeyboardState(page: Page) {
return page.evaluate(`({
exists: typeof KeyboardHandler !== 'undefined',
keyboardVisible: typeof KeyboardHandler !== 'undefined' ? KeyboardHandler.keyboardVisible : undefined,
hasViewportHandler: typeof KeyboardHandler !== 'undefined' ? KeyboardHandler._viewportResizeHandler != null : false,
hasHandleViewportResize: typeof KeyboardHandler !== 'undefined' ? typeof KeyboardHandler.handleViewportResize === 'function' : false,
initialViewportHeight: typeof KeyboardHandler !== 'undefined' ? KeyboardHandler.initialViewportHeight : 0,
})`) as Promise<{
exists: boolean;
keyboardVisible: boolean | undefined;
hasViewportHandler: boolean;
hasHandleViewportResize: boolean;
initialViewportHeight: number;
}>;
}
describe('Virtual Keyboard', () => {
let server: WebServer;
beforeAll(async () => {
server = await createTestServer(PORT);
});
afterAll(async () => {
await stopTestServer(server);
await closeAllBrowsers();
});
// ── Layer 1 - CDP Metrics Override (Chromium) ──────────────────────────
describe('Layer 1 - CDP Metrics Override (Chromium)', () => {
let page: Page;
let context: BrowserContext;
const device = REPRESENTATIVE_DEVICES['standard-phone']; // iPhone 14 Pro
beforeEach(async () => {
const result = await createDevicePage(device, BASE_URL, 'chromium');
page = result.page;
context = result.context;
});
afterEach(async () => {
await context.close();
});
it('fires real visualViewport resize event', async () => {
const success = await showKeyboardViaCDP(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
expect(success).toBe(true);
const visible = await getKeyboardVisible(page);
expect(visible).toBe(true);
});
it('adds keyboard-visible class to body', async () => {
await showKeyboardViaCDP(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
await assertHasClass(page, 'body', BODY_CLASSES.KEYBOARD_VISIBLE);
});
it('show threshold: viewport shrink >150px triggers keyboard', async () => {
// Shrink by 151px — should trigger
await showKeyboardViaCDP(page, KEYBOARD.SHOW_THRESHOLD + 1);
const visible = await getKeyboardVisible(page);
expect(visible).toBe(true);
});
it('hide threshold: viewport shrink <100px triggers hide', async () => {
// Show keyboard first
await showKeyboardViaCDP(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
expect(await getKeyboardVisible(page)).toBe(true);
// Restore viewport (hide keyboard via CDP)
await hideKeyboardViaCDP(page);
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
expect(await getKeyboardVisible(page)).toBe(false);
});
it('small viewport changes (<150px) do not trigger keyboard', async () => {
// Shrink by only 100px — below 150px threshold
const cdp = await getCDP(page);
const viewport = page.viewportSize()!;
await setVisualViewportHeight(cdp, viewport.width, viewport.height - 100, 1);
await page.waitForTimeout(300);
const visible = await getKeyboardVisible(page);
expect(visible).toBe(false);
});
it('dismissing keyboard restores original state', async () => {
// Show
await showKeyboardViaCDP(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
await assertHasClass(page, 'body', BODY_CLASSES.KEYBOARD_VISIBLE);
// Hide
await hideKeyboardViaCDP(page);
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
await assertNotHasClass(page, 'body', BODY_CLASSES.KEYBOARD_VISIBLE);
// Verify resetLayout was called — toolbar transform should be cleared
const transform = await getCSSProperty(page, SELECTORS.TOOLBAR, 'transform');
expect(transform === 'none' || transform === '').toBe(true);
});
});
// ── Layer 2 - VisualViewport Mock (cross-engine) ──────────────────────
describe('Layer 2 - VisualViewport Mock (cross-engine)', () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
it('mock visualViewport.height triggers handleViewportResize', async () => {
const { context, page } = await createDevicePage(device, 'about:blank', 'chromium');
try {
await setupViewportMock(page);
await page.goto(BASE_URL, { waitUntil: WAIT.DOM_CONTENT_LOADED });
await page.waitForTimeout(2000);
const success = await showKeyboardViaMock(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
expect(success).toBe(true);
} finally {
await context.close();
}
});
it('works on Chromium', async () => {
const { context, page } = await createDevicePage(device, 'about:blank', 'chromium');
try {
await setupViewportMock(page);
await page.goto(BASE_URL, { waitUntil: WAIT.DOM_CONTENT_LOADED });
await page.waitForTimeout(2000);
const success = await showKeyboardViaMock(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
expect(success).toBe(true);
const hasClass = await page.evaluate(() => document.body.classList.contains('keyboard-visible'));
expect(hasClass).toBe(true);
} finally {
await context.close();
}
});
it('works on WebKit', async () => {
let context: BrowserContext | undefined;
try {
const result = await createDevicePage(device, 'about:blank', 'webkit');
context = result.context;
await setupViewportMock(result.page);
await result.page.goto(BASE_URL, { waitUntil: WAIT.DOM_CONTENT_LOADED });
await result.page.waitForTimeout(2000);
const success = await showKeyboardViaMock(result.page, KEYBOARD.TYPICAL_IOS_HEIGHT);
expect(success).toBe(true);
} catch (e: unknown) {
// Skip if WebKit libraries not installed on this system
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes('Missing libraries') || msg.includes('browserType.launch')) {
console.log('Skipping WebKit test: system libraries not installed');
return;
}
throw e;
} finally {
if (context) await context.close();
}
});
});
// ── Layout Behavior ───────────────────────────────────────────────────
describe('Layout Behavior', () => {
let page: Page;
let context: BrowserContext;
const device = REPRESENTATIVE_DEVICES['standard-phone'];
beforeEach(async () => {
const result = await createDevicePage(device, BASE_URL, 'chromium');
page = result.page;
context = result.context;
});
afterEach(async () => {
await context.close();
});
it('toolbar remains below terminal when keyboard show shrinks the app viewport', async () => {
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
const layout = await page.evaluate(() => {
const toolbar = document.querySelector('.toolbar') as HTMLElement | null;
const accessory = document.querySelector('.keyboard-accessory-bar') as HTMLElement | null;
const terminalWrap = document.querySelector('.terminal-wrap') as HTMLElement | null;
const toolbarRect = toolbar?.getBoundingClientRect();
const accessoryRect = accessory?.getBoundingClientRect();
const terminalRect = terminalWrap?.getBoundingClientRect();
return {
toolbarTransform: toolbar?.style.transform ?? '',
accessoryTransform: (accessory as HTMLElement | null)?.style.transform ?? '',
toolbarTop: toolbarRect?.top ?? 0,
accessoryTop: accessoryRect?.top ?? 0,
terminalBottom: terminalRect?.bottom ?? 0,
};
});
expect(layout.toolbarTransform).toBe('');
expect(layout.accessoryTransform).toBe('');
expect(layout.accessoryTop).toBeGreaterThanOrEqual(layout.terminalBottom - 4);
expect(layout.toolbarTop).toBeGreaterThan(layout.accessoryTop);
});
it('accessory bar gets .visible class', async () => {
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
const hasVisible = await page.evaluate(() => {
const bar = document.querySelector('.keyboard-accessory-bar');
return bar?.classList.contains('visible') ?? false;
});
expect(hasVisible).toBe(true);
});
it('main padding increases on keyboard show', async () => {
const initialPadding = await page.evaluate(() => {
const main = document.querySelector('.main') as HTMLElement | null;
return main ? getComputedStyle(main).paddingBottom : '0px';
});
const initialPx = parseFloat(initialPadding) || 0;
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
const newPadding = await page.evaluate(() => {
const main = document.querySelector('.main') as HTMLElement | null;
return main ? main.style.paddingBottom : '';
});
const newPx = parseFloat(newPadding) || 0;
expect(newPx).toBeGreaterThan(initialPx);
});
it('does not reserve the keyboard height as visible terminal dead space', async () => {
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
const layout = await page.evaluate(() => {
const main = document.querySelector('.main') as HTMLElement | null;
const appEl = document.querySelector('.app') as HTMLElement | null;
const terminalWrap = document.querySelector('.terminal-wrap') as HTMLElement | null;
const toolbar = document.querySelector('.toolbar') as HTMLElement | null;
const accessory = document.querySelector('.keyboard-accessory-bar') as HTMLElement | null;
return {
appHeight: appEl?.getBoundingClientRect().height ?? 0,
mainPaddingBottom: main ? parseFloat(main.style.paddingBottom || '0') : 0,
terminalHeight: terminalWrap?.getBoundingClientRect().height ?? 0,
toolbarHeight: toolbar?.getBoundingClientRect().height ?? 0,
accessoryHeight: accessory?.getBoundingClientRect().height ?? 0,
visualViewportHeight: window.visualViewport?.height ?? window.innerHeight,
};
});
expect(layout.appHeight).toBeLessThanOrEqual(layout.visualViewportHeight + 2);
expect(layout.mainPaddingBottom).toBeLessThan(KEYBOARD.TYPICAL_IOS_HEIGHT);
expect(layout.mainPaddingBottom).toBeGreaterThanOrEqual(layout.toolbarHeight + layout.accessoryHeight - 4);
expect(layout.terminalHeight).toBeGreaterThan(160);
});
it('resetLayout clears transforms on hide', async () => {
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
await hideKeyboard(page);
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
// Toolbar transform should be cleared
const toolbarTransform = await page.evaluate(() => {
const toolbar = document.querySelector('.toolbar') as HTMLElement | null;
return toolbar?.style.transform ?? '';
});
expect(toolbarTransform).toBe('');
const mainPadding = await page.evaluate(() => {
const main = document.querySelector('.main') as HTMLElement | null;
return main?.style.paddingBottom ?? '';
});
expect(mainPadding).toBe('');
});
it('coalesces keyboard animation frames into one final terminal fit', async () => {
const result = await page.evaluate(async () => {
// `app.terminal` and `app.fitAddon` are only assigned by initTerminal(),
// which needs a selected session this harness never creates. Both are
// null at rest, and the settle callback returns early on a falsy
// terminal — so without stand-ins this test cannot reach the behavior
// it asserts. Install the minimum surface the callback touches.
const hadTerminal = app.terminal !== null && app.terminal !== undefined;
const hadFitAddon = app.fitAddon !== null && app.fitAddon !== undefined;
if (!hadTerminal) app.terminal = { scrollToBottom() {} };
if (!hadFitAddon) app.fitAddon = { fit() {}, proposeDimensions: () => null };
const originalFit = app.fitAddon.fit.bind(app.fitAddon);
const originalSendResize = KeyboardHandler._sendTerminalResize.bind(KeyboardHandler);
const originalScrollToBottom = app.terminal.scrollToBottom.bind(app.terminal);
let fits = 0;
let resizes = 0;
let bottomRestores = 0;
app.fitAddon.fit = () => {
fits++;
};
KeyboardHandler._sendTerminalResize = () => {
resizes++;
};
app.terminal.scrollToBottom = () => {
bottomRestores++;
};
KeyboardHandler._scheduleViewportSettle({ restoreScroll: true });
await new Promise((resolve) => setTimeout(resolve, 30));
KeyboardHandler._scheduleViewportSettle();
await new Promise((resolve) => setTimeout(resolve, 30));
KeyboardHandler._scheduleViewportSettle();
await new Promise((resolve) => setTimeout(resolve, 50));
const beforeFinalSettle = { fits, resizes, bottomRestores };
await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.VIEWPORT_SETTLE_MS));
const afterFinalSettle = { fits, resizes, bottomRestores };
app.fitAddon.fit = originalFit;
KeyboardHandler._sendTerminalResize = originalSendResize;
app.terminal.scrollToBottom = originalScrollToBottom;
if (!hadFitAddon) app.fitAddon = null;
if (!hadTerminal) app.terminal = null;
return { beforeFinalSettle, afterFinalSettle };
});
expect(result.beforeFinalSettle).toEqual({ fits: 0, resizes: 0, bottomRestores: 0 });
expect(result.afterFinalSettle).toEqual({ fits: 1, resizes: 1, bottomRestores: 1 });
});
// Behavioral counterpart to the test above, driven through the PUBLIC entry
// point rather than the internal scheduler. Before this change each
// onKeyboardShow armed its own uncoalesced 150ms setTimeout, so a keyboard
// animation that reports several viewport steps refit the terminal once per
// step — the visible symptom being repeated reflow while the keyboard slides
// up. This asserts the observable outcome (one refit for a burst) and so
// fails on master by COUNT, not by a missing method.
it('refits once for a burst of keyboard viewport steps', async () => {
const counts = await page.evaluate(async () => {
const hadTerminal = app.terminal !== null && app.terminal !== undefined;
const hadFitAddon = app.fitAddon !== null && app.fitAddon !== undefined;
if (!hadTerminal) app.terminal = { scrollToBottom() {} };
if (!hadFitAddon) app.fitAddon = { fit() {}, proposeDimensions: () => null };
const originalFit = app.fitAddon.fit.bind(app.fitAddon);
const originalSendResize = KeyboardHandler._sendTerminalResize.bind(KeyboardHandler);
let fits = 0;
app.fitAddon.fit = () => {
fits++;
};
KeyboardHandler._sendTerminalResize = () => {};
// Three viewport steps in quick succession, as a keyboard animation
// produces on a real device.
KeyboardHandler.onKeyboardShow();
await new Promise((resolve) => setTimeout(resolve, 30));
KeyboardHandler.onKeyboardShow();
await new Promise((resolve) => setTimeout(resolve, 30));
KeyboardHandler.onKeyboardShow();
// Well past both the coalescing window and master's fixed 150ms timer.
await new Promise((resolve) => setTimeout(resolve, 400));
app.fitAddon.fit = originalFit;
KeyboardHandler._sendTerminalResize = originalSendResize;
if (!hadFitAddon) app.fitAddon = null;
if (!hadTerminal) app.terminal = null;
return fits;
});
// Coalesced: one refit for the whole burst. Master fires one per step.
expect(counts).toBe(1);
});
// A viewport resize with NO pending show/hide transition must not arm settle
// work of its own: keyboard detection can miss a fine-grained OS animation
// entirely (sub-150px steps with the baseline chasing the animation), and a
// fit against that mid-animation, uncompensated layout resizes the PTY to
// transient dims. The resulting SIGWINCH thrash duplicates prompts and
// garbles the transcript. Wiggles may only push a pending settle back.
it('does not refit on viewport wiggles without a keyboard transition', async () => {
const result = await page.evaluate(async () => {
const hadTerminal = app.terminal !== null && app.terminal !== undefined;
const hadFitAddon = app.fitAddon !== null && app.fitAddon !== undefined;
if (!hadTerminal) app.terminal = { scrollToBottom() {} };
if (!hadFitAddon) app.fitAddon = { fit() {}, proposeDimensions: () => null };
const originalFit = app.fitAddon.fit.bind(app.fitAddon);
const originalSendResize = KeyboardHandler._sendTerminalResize.bind(KeyboardHandler);
let fits = 0;
app.fitAddon.fit = () => {
fits++;
};
KeyboardHandler._sendTerminalResize = () => {};
// Wiggle only: nothing pending, so nothing may fire.
KeyboardHandler._deferViewportSettle();
KeyboardHandler._deferViewportSettle();
await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.VIEWPORT_SETTLE_MS + 80));
const wiggleOnly = fits;
// A real transition arms the work; a following wiggle defers it but the
// settle still fires exactly once.
KeyboardHandler._scheduleViewportSettle({ restoreScroll: true });
await new Promise((resolve) => setTimeout(resolve, 30));
KeyboardHandler._deferViewportSettle();
await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.VIEWPORT_SETTLE_MS + 80));
const afterTransition = fits;
app.fitAddon.fit = originalFit;
KeyboardHandler._sendTerminalResize = originalSendResize;
if (!hadFitAddon) app.fitAddon = null;
if (!hadTerminal) app.terminal = null;
return { wiggleOnly, afterTransition };
});
expect(result.wiggleOnly).toBe(0);
expect(result.afterTransition).toBe(1);
});
it('accessory bar has the simple-mode action buttons', async () => {
const actions = await page.evaluate(() => {
return Array.from(document.querySelectorAll('.keyboard-accessory-bar [data-action]')).map(
(button) => (button as HTMLElement).dataset.action
);
});
// Tab replaced /clear in the simple bar; /clear and /compact live in the
// extended bar only.
expect(actions).toEqual([
'scroll-up',
'scroll-down',
'init',
'tab',
'shift-left',
'shift-right',
'compose',
'readmymind',
'esc',
'dismiss',
]);
});
it('double-tap confirm on /clear button', async () => {
// Make accessory bar visible
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
// handleAction() early-returns if app.activeSessionId is falsy — mock it.
// /clear only exists in the extended bar now, so switch modes first.
await page.evaluate(`
if (typeof app !== 'undefined') app.activeSessionId = 'test-session';
KeyboardAccessoryBar.setMode('extended');
`);
// Click via JS since the button is positioned outside the viewport
// by the keyboard CSS transform
await page.evaluate(() => {
const btn = document.querySelector('[data-action="clear"]') as HTMLElement;
btn?.click();
});
await page.waitForTimeout(100);
// Should enter confirming state
const confirming = await page.evaluate(() => {
const btn = document.querySelector('[data-action="clear"]');
return btn?.classList.contains('confirming') ?? false;
});
expect(confirming).toBe(true);
// Button text should change to "Tap again"
const text = await page.evaluate(() => {
const btn = document.querySelector('[data-action="clear"]');
return btn?.textContent?.trim();
});
expect(text).toBe('Tap again');
await page.evaluate(`KeyboardAccessoryBar.setMode('simple');`);
});
it('double-tap expires after 2s', async () => {
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
await page.evaluate(`
if (typeof app !== 'undefined') app.activeSessionId = 'test-session';
KeyboardAccessoryBar.setMode('extended');
`);
// First tap on clear via JS
await page.evaluate(() => {
const btn = document.querySelector('[data-action="clear"]') as HTMLElement;
btn?.click();
});
await page.waitForTimeout(100);
// Verify confirming state
const beforeExpiry = await page.evaluate(() => {
const btn = document.querySelector('[data-action="clear"]');
return btn?.classList.contains('confirming') ?? false;
});
expect(beforeExpiry).toBe(true);
// Wait for confirm timeout to expire (2s + buffer)
await page.waitForTimeout(KEYBOARD.CONFIRM_TIMEOUT + 500);
const afterExpiry = await page.evaluate(() => {
const btn = document.querySelector('[data-action="clear"]');
return btn?.classList.contains('confirming') ?? false;
});
expect(afterExpiry).toBe(false);
await page.evaluate(`KeyboardAccessoryBar.setMode('simple');`);
});
it('dismiss button blurs active element', async () => {
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
// Focus an element
await page.evaluate(() => {
const el = document.querySelector('.terminal-container') || document.querySelector('textarea') || document.body;
if (el instanceof HTMLElement) el.focus();
});
// Click dismiss via JS (positioned off-screen by keyboard transform)
await page.evaluate(() => {
const btn = document.querySelector('[data-action="dismiss"]') as HTMLElement;
btn?.click();
});
await page.waitForTimeout(100);
const activeTag = await page.evaluate(() => document.activeElement?.tagName ?? '');
expect(activeTag).toBe('BODY');
});
it('terminal fit called on keyboard toggle', async () => {
// Inject spy on fitAddon.fit
await page.evaluate(`
if (typeof app !== 'undefined' && app.fitAddon) {
window.__fitCallCount = 0;
var orig = app.fitAddon.fit;
app.fitAddon.fit = function () {
window.__fitCallCount++;
try { orig.call(this); } catch(e) {}
};
}
`);
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
// Wait for the setTimeout(150) in onKeyboardShow
await page.waitForTimeout(300);
const callCount = await page.evaluate(() => (window as any).__fitCallCount ?? 0);
// Soft assertion — fitAddon may not be initialized without real terminal
expect(callCount).toBeGreaterThanOrEqual(0);
});
it('keeps xterm helper textarea focusable near the terminal cursor on touch devices', async () => {
const styles = await page.evaluate(async () => {
await new Promise<void>((resolve) => app.terminal.write('prompt', resolve));
app.terminal.focus();
app._syncMobileHelperTextareaToCursor?.();
const textarea = document.querySelector('.xterm-helper-textarea');
const cursor = document.querySelector('.xterm-cursor');
const screen = document.querySelector('.xterm-screen');
if (!(textarea instanceof HTMLElement) || !(cursor instanceof HTMLElement) || !(screen instanceof HTMLElement))
return null;
const cs = getComputedStyle(textarea);
const cursorRect = cursor.getBoundingClientRect();
const screenRect = screen.getBoundingClientRect();
return {
left: cs.left,
top: cs.top,
width: cs.width,
height: cs.height,
zIndex: cs.zIndex,
opacity: cs.opacity,
cursorLeft: `${Math.max(0, Math.round(cursorRect.left - screenRect.left))}px`,
cursorTop: `${Math.max(0, Math.round(cursorRect.top - screenRect.top))}px`,
};
});
expect(styles).not.toBeNull();
expect(styles?.left).toBe(styles?.cursorLeft);
expect(styles?.top).toBe(styles?.cursorTop);
expect(styles?.cursorLeft).not.toBe('0px');
expect(styles?.width).toBe('1px');
expect(styles?.height).toBe('1px');
expect(styles?.opacity).toBe('0');
expect(Number(styles?.zIndex)).toBeGreaterThanOrEqual(0);
});
it('dismisses the on-screen keyboard when a tap lands outside the terminal', async () => {
// The terminal holds focus on a hidden textarea and nothing released it,
// so once the keyboard was up every tap on the header or page chrome left
// it up — covering half a phone screen with no in-app way to close it.
//
// Driven as a real dispatched gesture: the handler is bound to touchend on
// document, and calling the internal helper would bypass the routing this
// test exists to check.
const result = await page.evaluate(async () => {
const sampleX = (rect: DOMRect) => Math.max(2, rect.left + Math.min(6, rect.width / 2));
const sampleY = (rect: DOMRect) => Math.max(2, rect.top + Math.min(6, rect.height / 2));
const tap = async (el: Element, travel = 0, point?: { x: number; y: number }) => {
const rect = el.getBoundingClientRect();
const x = point ? point.x : sampleX(rect);
const y = point ? point.y : sampleY(rect);
const target = document.elementFromPoint(x, y) || el;
const at = (cy: number) => new Touch({ identifier: 21, target, clientX: x, clientY: cy });
target.dispatchEvent(
new TouchEvent('touchstart', {
touches: [at(y)],
targetTouches: [at(y)],
changedTouches: [at(y)],
bubbles: true,
cancelable: true,
})
);
for (const step of travel ? [travel / 3, (travel * 2) / 3, travel] : []) {
target.dispatchEvent(
new TouchEvent('touchmove', {
touches: [at(y + step)],
targetTouches: [at(y + step)],
changedTouches: [at(y + step)],
bubbles: true,
cancelable: true,
})
);
await new Promise((resolve) => setTimeout(resolve, 15));
}
await new Promise((resolve) => setTimeout(resolve, 25));
target.dispatchEvent(
new TouchEvent('touchend', {
touches: [],
changedTouches: [at(y + travel)],
bubbles: true,
cancelable: true,
})
);
await new Promise((resolve) => setTimeout(resolve, 250));
return document.activeElement?.className ?? '';
};
app.hideWelcome();
app.terminal.reset();
await new Promise<void>((resolve) => app.terminal.write('transcript\r\n\r\n> ', resolve));
// Inert page chrome: the keyboard must close.
app._focusMobileTerminalInput();
const focusedBefore = document.activeElement?.className ?? '';
const afterOutside = await tap(document.querySelector('.logo, .header-brand, header') ?? document.body);
// A real control: it takes focus itself, so we must NOT interfere.
app._focusMobileTerminalInput();
const button = Array.from(document.querySelectorAll('button:not([disabled])')).find((candidate) => {
const rect = candidate.getBoundingClientRect();
if (rect.width <= 8 || rect.height <= 8) return false;
// A rect is not enough. The welcome overlay is hidden by hideWelcome()
// above but its buttons still MEASURE, so a rect-only pick sampled a
// point the terminal actually owns — elementFromPoint returned
// .xterm-screen and this case tapped the terminal instead of a
// control, passing for the wrong reason. Require the sampled point to
// really resolve to this button.
const hit = document.elementFromPoint(sampleX(rect), sampleY(rect));
return !!hit && candidate.contains(hit);
});
const afterButton = button ? await tap(button) : 'no-visible-button';
// Inside the terminal, tap classification owns the decision, so this
// handler must keep its hands off. Aimed at the PROMPT row: that is the
// one in-terminal tap whose outcome belongs to nobody else, since an
// inert transcript row is claimed by the in-terminal dismiss toggle
// (`toggles the keyboard shut on a second inert Claude transcript tap`)
// and asserting focus there would be asserting that toggle's behaviour
// rather than this exemption. The guard still bites: the container's own
// touchend listener runs first and refocuses, so a missing
// #terminalContainer exemption would blur right back over it.
app._focusMobileTerminalInput();
const screen = app.terminal.element?.querySelector('.xterm-screen');
const cell = app.terminal._core?._renderService?.dimensions?.css?.cell;
const screenRect = screen?.getBoundingClientRect();
const promptPoint =
screenRect && cell?.width && cell?.height
? {
x: screenRect.left + cell.width * 2,
y: screenRect.top + cell.height * (app.terminal.buffer.active.cursorY + 0.5),
}
: undefined;
const afterTerminal = await tap(document.querySelector('#terminalContainer')!, 0, promptPoint);
// A SCROLL also ends in touchend. Scrolling to read something while
// composing must not close the keyboard and drop the composer.
app._focusMobileTerminalInput();
const afterScroll = await tap(document.querySelector('.logo, .header-brand, header') ?? document.body, 120);
return { focusedBefore, afterOutside, afterButton, afterTerminal, afterScroll };
});
expect(result.focusedBefore).toContain('xterm-helper-textarea');
// Red on master: the textarea keeps focus and the keyboard stays up.
expect(result.afterOutside).not.toContain('xterm-helper-textarea');
expect(result.afterButton).not.toBe('no-visible-button');
expect(result.afterButton).toContain('xterm-helper-textarea');
expect(result.afterTerminal).toContain('xterm-helper-textarea');
// A scroll ends in touchend too, and must NOT close the keyboard.
expect(result.afterScroll).toContain('xterm-helper-textarea');
});
it('routes CJK textarea typing through local echo on Enter', async () => {
await page.evaluate(() => {
window.__sentInputs = [];
const sessionId = 'mobile-cjk-local-echo-test';
app.activeSessionId = sessionId;
app.sessions.set(sessionId, { id: sessionId, mode: 'codex' });
app._localEchoEnabled = true;
app._localEchoOverlay = {
pendingText: '',
appendText(text: string) {
this.pendingText += text;
},
removeChar() {
this.pendingText = this.pendingText.slice(0, -1);
return 'pending';
},
clear() {
this.pendingText = '';
},
suppressBufferDetection() {},
};
app._sendInputAsync = (_sessionId: string, input: string) => {
window.__sentInputs.push(input);
};
const settings = app.loadAppSettingsFromStorage();
settings.cjkInputEnabled = true;
app.saveAppSettingsToStorage(settings);
app._serverCjkOverride = true;
app._updateCjkInputState?.();
});
await page.locator('#cjkInput').focus();
await page.keyboard.type('hello');
const beforeEnter = await page.evaluate(() => ({
visibleText: (document.getElementById('cjkInput') as HTMLTextAreaElement).value.replace(/\u200B/g, ''),
pendingText: app._localEchoOverlay.pendingText,
sentInputs: window.__sentInputs,
}));
expect(beforeEnter.visibleText).toBe('hello');
expect(beforeEnter.pendingText).toBe('');
expect(beforeEnter.sentInputs).toEqual([]);
await page.keyboard.press('Enter');
await page.waitForFunction(() => window.__sentInputs?.length === 2);
const afterEnter = await page.evaluate(() => ({
pendingText: app._localEchoOverlay.pendingText,
sentInputs: window.__sentInputs,
}));
expect(afterEnter.pendingText).toBe('');
expect(afterEnter.sentInputs).toEqual(['hello', '\r']);
});
it('shows the CJK textarea on mobile for server override only inside an active session', async () => {
const state = await page.evaluate(() => {
const input = document.getElementById('cjkInput');
if (!(input instanceof HTMLElement)) return null;
// Welcome screen (no active session): even with the server override on, the
// fixed-position textarea must stay hidden so it doesn't float over the overlay.
app.activeSessionId = null;
app._serverCjkOverride = true;
app._updateCjkInputState();
const onWelcomeDisplay = getComputedStyle(input).display;
// Entering a session reveals it.
app.activeSessionId = 'cjk-server-override-test';
app._updateCjkInputState();
const cs = getComputedStyle(input);
return {
onWelcomeDisplay,
display: cs.display,
position: cs.position,
bottom: cs.bottom,
zIndex: cs.zIndex,
ariaHidden: input.getAttribute('aria-hidden'),
};
});
expect(state).not.toBeNull();
expect(state?.onWelcomeDisplay).toBe('none');
expect(state?.display).not.toBe('none');
expect(state?.position).toBe('fixed');
expect(Number(state?.zIndex)).toBeGreaterThan(50);
expect(state?.ariaHidden).toBe('false');
});
it('hides the CJK textarea by default on phones', async () => {
const state = await page.evaluate(() => {
localStorage.removeItem(app.getSettingsStorageKey());
app._cachedAppSettings = null;
app._updateCjkInputState();
const input = document.getElementById('cjkInput');
if (!(input instanceof HTMLElement)) return null;
const cs = getComputedStyle(input);
return {
display: cs.display,
position: cs.position,
bodyClass: document.body.classList.contains('cjk-input-visible'),
};
});
expect(state).not.toBeNull();
expect(state?.display).toBe('none');
expect(state?.bodyClass).toBe(false);
});
it('keeps the CJK textarea hidden even when old phone settings enabled it', async () => {
const state = await page.evaluate(() => {
const settings = app.loadAppSettingsFromStorage();
settings.cjkInputEnabled = true;
app.saveAppSettingsToStorage(settings);
app._updateCjkInputState();
const input = document.getElementById('cjkInput');
if (!(input instanceof HTMLElement)) return null;
const cs = getComputedStyle(input);
return {
display: cs.display,
position: cs.position,
bodyClass: document.body.classList.contains('cjk-input-visible'),
};
});
expect(state).not.toBeNull();
expect(state?.display).toBe('none');
expect(state?.bodyClass).toBe(false);
});
it('focuses the terminal helper textarea when the terminal is tapped', async () => {
await page.evaluate(() => {
app.activeSessionId = 'mobile-focus-visible-input-test';
app.sessions.set('mobile-focus-visible-input-test', {
id: 'mobile-focus-visible-input-test',
mode: 'codex',
status: 'running',
});
app.hideWelcome();
const settings = app.loadAppSettingsFromStorage();
settings.cjkInputEnabled = false;
app.saveAppSettingsToStorage(settings);
app._updateCjkInputState();
});
await page.locator('#terminalContainer').tap({ position: { x: 40, y: 40 } });
const activeClass = await page.evaluate(() => document.activeElement?.className);
expect(activeClass).toContain('xterm-helper-textarea');
});
// Regression guard for the phone-keyboard blocker reduced in #173 and re-hit
// by #244. selectSession() ends with scrollToLastNonEmptyLine(), which parks
// the viewport ABOVE the bottom for any session whose buffer is taller than
// the screen and ends in blank rows, i.e. every real session after a tab
// switch. A tap-routing scheme that treats "viewport is scrolled up" as a
// reason to blur strands document.activeElement on <body> with no way to
// raise the keyboard, and the prompt row is no exception. Suppressing the
// MOUSE REPORT while scrolled up is correct and pinned below; suppressing
// FOCUS is not. Measured against PR #244 on 2026-08-09: body vs textarea.
//
// Must be a dispatched gesture: calling the touchend handler directly
// bypasses touchstart's preventDefault, which is half of what closes the
// focus path, so a direct call reports the right intent and still misses.
it('keeps the terminal input focusable after a tab switch parks the viewport off-bottom', async () => {
const probe = await page.evaluate(async () => {
window.__sentInputs = [];
app.activeSessionId = 'mobile-offbottom-tap-test';
app.sessions.set('mobile-offbottom-tap-test', {
id: 'mobile-offbottom-tap-test',
mode: 'claude',
cliVersion: '2.1.220',
status: 'running',
});
app._sendInputAsync = (_sessionId: string, input: string) => {
window.__sentInputs.push(input);
};
app.hideWelcome();
const settings = app.loadAppSettingsFromStorage();
settings.cjkInputEnabled = false;
app.saveAppSettingsToStorage(settings);
app._updateCjkInputState();
app.terminal.reset();
// Taller than the viewport, ending in the trailing blank rows that make
// scrollToLastNonEmptyLine() stop short of the bottom.
const lines: string[] = [];
for (let i = 1; i <= app.terminal.rows * 3; i++) lines.push(`Transcript row ${i}`);
lines.push('', '❯ ', '', '');
await new Promise<void>((resolve) => app.terminal.write(lines.join('\r\n'), resolve));
app.scrollToLastNonEmptyLine(); // what selectSession() does on every tab switch
(document.activeElement as HTMLElement | null)?.blur?.();
const screen = app.terminal.element?.querySelector('.xterm-screen');
const cell = app.terminal._core?._renderService?.dimensions?.css?.cell;
const rect = screen?.getBoundingClientRect();
if (!rect || !cell?.width || !cell?.height) return null;
const buffer = app.terminal.buffer.active;
return {
x: rect.left + cell.width * 2,
y: rect.top + cell.height * 5.5,
atBottom: buffer.viewportY >= buffer.baseY,
};
});
expect(probe).not.toBeNull();
// The guard only means anything if the viewport really did park off-bottom.
expect(probe!.atBottom).toBe(false);
await page.touchscreen.tap(probe!.x, probe!.y);
const state = await page.evaluate(() => ({
activeClass: document.activeElement?.className,
sentInputs: window.__sentInputs,
}));
expect(state.activeClass).toContain('xterm-helper-textarea');
// SGR coordinates are meaningless off-bottom, so the tap must stay silent.
expect(state.sentInputs).toEqual([]);
});
it('collapses a terminal readback without focusing the hidden textarea', async () => {
const point = await page.evaluate(async () => {
window.__sentInputs = [];
app.activeSessionId = 'mobile-readback-tap-test';
app.sessions.set('mobile-readback-tap-test', {
id: 'mobile-readback-tap-test',
mode: 'codex',
status: 'running',
});
app._sendInputAsync = (_sessionId: string, input: string) => {
window.__sentInputs.push(input);
};
app.hideWelcome();
const settings = app.loadAppSettingsFromStorage();
settings.cjkInputEnabled = false;
app.saveAppSettingsToStorage(settings);
app._updateCjkInputState();
app.terminal.reset();
await new Promise<void>((resolve) =>
app.terminal.write('Agent readback\r\n tap to collapse\r\n\r\n› ask', resolve)
);
app.terminal.focus();
const screen = app.terminal.element?.querySelector('.xterm-screen');
const cell = app.terminal._core?._renderService?.dimensions?.css?.cell;
const rect = screen?.getBoundingClientRect();
if (!rect || !cell?.width || !cell?.height) return null;
return {
x: rect.left + cell.width * 2,
y: rect.top + cell.height / 2,
};
});
expect(point).not.toBeNull();
await page.touchscreen.tap(point!.x, point!.y);
const state = await page.evaluate(() => ({
activeClass: document.activeElement?.className,
sentInputs: window.__sentInputs,
}));
expect(state.activeClass).not.toContain('xterm-helper-textarea');
expect(state.sentInputs).toHaveLength(1);
expect(state.sentInputs[0]).toMatch(/^\x1b\[<0;\d+;1M\x1b\[<0;\d+;1m$/);
});
it('toggles the keyboard shut on a second inert Claude transcript tap', async () => {
const point = await page.evaluate(async () => {
window.__sentInputs = [];
app.activeSessionId = 'mobile-claude-transcript-tap-test';
app.sessions.set('mobile-claude-transcript-tap-test', {
id: 'mobile-claude-transcript-tap-test',
mode: 'claude',
cliVersion: '2.1.220',
status: 'working',
});
app._sendInputAsync = (_sessionId: string, input: string) => {
window.__sentInputs.push(input);
};
app.hideWelcome();
const settings = app.loadAppSettingsFromStorage();
settings.cjkInputEnabled = false;
app.saveAppSettingsToStorage(settings);
app._updateCjkInputState();
app.terminal.reset();
await new Promise<void>((resolve) =>
app.terminal.write(
'Transcript row one\r\nTranscript row two\r\nTranscript row three\r\nTranscript row four\r\nTranscript row five\r\nTranscript row six\r\nTranscript row seven\r\nTranscript row eight\r\nTranscript row nine\r\nTranscript row ten\r\n\r\n❯ ',
resolve
)
);
app.terminal.focus();
const screen = app.terminal.element?.querySelector('.xterm-screen');
const cell = app.terminal._core?._renderService?.dimensions?.css?.cell;
const rect = screen?.getBoundingClientRect();
if (!screen || !rect || !cell?.width || !cell?.height) return null;
const cursorRow = app.terminal.buffer.active.cursorY;
const transcriptRow = Math.max(1, Math.floor(cursorRow / 2));
const x = rect.left + cell.width * 2;
const y = rect.top + cell.height * (transcriptRow + 0.5);
return {
x,
y,
intent: app._classifyMobileTerminalTap(x, y),
activeClass: document.activeElement?.className,
};
});
expect(point).toEqual(
expect.objectContaining({
intent: 'content',
activeClass: expect.stringContaining('xterm-helper-textarea'),
})
);
await page.touchscreen.tap(point!.x, point!.y);
// The setup above leaves the terminal focused, so this tap is the SECOND
// one on an inert row — the case that now closes the keyboard. Previously
// it re-focused, which left the accessory bar's chevron as the only way to
// dismiss. The prompt row is unaffected and still positions the caret.
const activeClass = await page.evaluate(() => document.activeElement?.className);
expect(activeClass).not.toContain('xterm-helper-textarea');
});
it('prevents Claude subagent status taps from opening the hidden keyboard input', async () => {
const point = await page.evaluate(async () => {
window.__sentInputs = [];
app.activeSessionId = 'mobile-claude-subagent-tap-test';
app.sessions.set('mobile-claude-subagent-tap-test', {
id: 'mobile-claude-subagent-tap-test',
mode: 'claude',
cliVersion: '2.1.220',
status: 'working',
});
app._sendInputAsync = (_sessionId: string, input: string) => {
window.__sentInputs.push(input);
};
app.hideWelcome();
app.terminal.reset();
const statusRow = Math.max(0, app.terminal.rows - 2);
await new Promise<void>((resolve) =>
app.terminal.write(
`${'\r\n'.repeat(statusRow)}• Working (1m 50s • esc to interrupt) · 1 background teammate`,
resolve
)
);
app.terminal.focus();
const screen = app.terminal.element?.querySelector('.xterm-screen');
const cell = app.terminal._core?._renderService?.dimensions?.css?.cell;
const rect = screen?.getBoundingClientRect();
if (!screen || !rect || !cell?.width || !cell?.height) return null;
const cursorRow = app.terminal.buffer.active.cursorY;
const x = rect.left + cell.width * 2;
const y = rect.top + cell.height * (cursorRow + 0.5);
return {
x,
y,
intent: app._classifyMobileTerminalTap(x, y),
cursorRow,
screenBottom: rect.bottom,
};
});
expect(point).toEqual(
expect.objectContaining({
intent: 'content',
})
);
const dispatch = await page.evaluate(({ x, y }) => {
const target = document.querySelector('#terminalContainer .xterm-screen');
if (!(target instanceof Element)) {
return { prevented: false, insideTerminal: false, targetClass: null };
}
const touch = new Touch({
identifier: 3,
target,
clientX: x,
clientY: y,
pageX: x,
pageY: y,
});
const allowed = target.dispatchEvent(
new TouchEvent('touchstart', {
touches: [touch],
changedTouches: [touch],
bubbles: true,
cancelable: true,
})
);
target.dispatchEvent(
new TouchEvent('touchend', {
touches: [],
changedTouches: [touch],
bubbles: true,
cancelable: true,
})
);
return {
prevented: !allowed,
insideTerminal: Boolean(target.closest('#terminalContainer')),
targetClass: target.className,
};
}, point!);
const state = await page.evaluate(() => ({
activeClass: document.activeElement?.className,
sentInputs: window.__sentInputs,
}));
expect(dispatch).toEqual(
expect.objectContaining({
prevented: true,
insideTerminal: true,
})
);
expect(state.activeClass).not.toContain('xterm-helper-textarea');
expect(state.sentInputs).toHaveLength(1);
});
it('focuses the terminal helper textarea when the visible prompt is tapped', async () => {
const point = await page.evaluate(async () => {
window.__sentInputs = [];
app.activeSessionId = 'mobile-focus-visible-input-test';
app.sessions.set('mobile-focus-visible-input-test', {
id: 'mobile-focus-visible-input-test',
mode: 'codex',
status: 'running',
});
app._sendInputAsync = (_sessionId: string, input: string) => {
window.__sentInputs.push(input);
};
app.hideWelcome();
const settings = app.loadAppSettingsFromStorage();
settings.cjkInputEnabled = false;
app.saveAppSettingsToStorage(settings);
app._updateCjkInputState();
app.terminal.reset();
await new Promise<void>((resolve) =>
app.terminal.write('Agent readback\r\n tap to collapse\r\n\r\n› ask', resolve)
);
(document.activeElement as HTMLElement | null)?.blur?.();
const screen = app.terminal.element?.querySelector('.xterm-screen');
const cell = app.terminal._core?._renderService?.dimensions?.css?.cell;
const rect = screen?.getBoundingClientRect();
if (!rect || !cell?.width || !cell?.height) return null;
return {
x: rect.left + cell.width * 2,
y: rect.top + cell.height * (app.terminal.buffer.active.cursorY + 0.5),
};
});
expect(point).not.toBeNull();
await page.touchscreen.tap(point!.x, point!.y);
const state = await page.evaluate(() => ({
activeClass: document.activeElement?.className,
sentInputs: window.__sentInputs,
}));
expect(state.activeClass).toContain('xterm-helper-textarea');
expect(state.sentInputs).toEqual([]);
});
it('focuses the live Claude cursor when a redraw omits the prompt glyph', async () => {
const point = await page.evaluate(async () => {
window.__sentInputs = [];
app.activeSessionId = 'mobile-focus-promptless-claude-test';
app.sessions.set('mobile-focus-promptless-claude-test', {
id: 'mobile-focus-promptless-claude-test',
mode: 'claude',
status: 'running',
});
app._sendInputAsync = (_sessionId: string, input: string) => {
window.__sentInputs.push(input);
};
app.hideWelcome();
app.terminal.reset();
await new Promise<void>((resolve) => app.terminal.write('Claude response\r\nready for input', resolve));
(document.activeElement as HTMLElement | null)?.blur?.();
const screen = app.terminal.element?.querySelector('.xterm-screen');
const cell = app.terminal._core?._renderService?.dimensions?.css?.cell;
const rect = screen?.getBoundingClientRect();
if (!rect || !cell?.width || !cell?.height) return null;
return {
x: rect.left + cell.width * 2,
y: rect.top + cell.height * (app.terminal.buffer.active.cursorY + 0.5),
};
});
expect(point).not.toBeNull();
await page.touchscreen.tap(point!.x, point!.y);
const state = await page.evaluate(() => ({
activeClass: document.activeElement?.className,
sentInputs: window.__sentInputs,
}));
expect(state.activeClass).toContain('xterm-helper-textarea');
expect(state.sentInputs).toEqual([]);
});
it('keeps terminal touch drag available for scrollback with the visible textarea enabled', async () => {
const calls = await page.evaluate(async () => {
app.activeSessionId = 'mobile-touch-scroll-test';
app.sessions.set('mobile-touch-scroll-test', {
id: 'mobile-touch-scroll-test',
mode: 'codex',
status: 'running',
});
app.hideWelcome();
app._updateCjkInputState();
const originalScrollLines = app.terminal.scrollLines.bind(app.terminal);
const scrollCalls: number[] = [];
app.terminal.scrollLines = (lines: number) => {
scrollCalls.push(lines);
return originalScrollLines(lines);
};
const target =
document.querySelector('#terminalContainer .xterm-screen') ?? document.getElementById('terminalContainer');
if (!target) return scrollCalls;
const rect = target.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const startY = rect.top + Math.min(180, rect.height - 20);
const endY = startY - 120;
function createTouch(y: number) {
return new Touch({
identifier: 1,
target,
clientX: x,
clientY: y,
pageX: x,
pageY: y,
});
}
target.dispatchEvent(
new TouchEvent('touchstart', {
touches: [createTouch(startY)],
changedTouches: [createTouch(startY)],
bubbles: true,
cancelable: true,
})
);
target.dispatchEvent(
new TouchEvent('touchmove', {
touches: [createTouch(endY)],
changedTouches: [createTouch(endY)],
bubbles: true,
cancelable: true,
})
);
target.dispatchEvent(
new TouchEvent('touchend', {
touches: [],
changedTouches: [createTouch(endY)],
bubbles: true,
cancelable: true,
})
);
await new Promise((resolve) => setTimeout(resolve, 50));
return scrollCalls;
});
expect(calls.length).toBeGreaterThan(0);
expect(calls.some((lines) => lines !== 0)).toBe(true);
});
it('keeps typed phone text in the terminal local echo path', async () => {
await page.evaluate(() => {
window.__sentInputs = [];
app.activeSessionId = 'mobile-visible-input-test';
app.sessions.set('mobile-visible-input-test', {
id: 'mobile-visible-input-test',
mode: 'claude',
status: 'running',
});
app.hideWelcome();
app._sendInputAsync = (_sessionId: string, input: string) => {
window.__sentInputs.push(input);
};
const settings = app.loadAppSettingsFromStorage();
settings.cjkInputEnabled = false;
settings.localEchoEnabled = true;
app.saveAppSettingsToStorage(settings);
app._updateCjkInputState();
app._updateLocalEchoState();
app.terminal.focus();
});
await page.locator('#terminalContainer').tap({ position: { x: 40, y: 40 } });
await page.keyboard.type('find bug');
const beforeEnter = await page.evaluate(() => ({
activeClass: document.activeElement?.className,
cjkDisplay: getComputedStyle(document.getElementById('cjkInput') as HTMLElement).display,
pendingText: app._localEchoOverlay?.pendingText,
sentInputs: window.__sentInputs,
}));
expect(beforeEnter.activeClass).toContain('xterm-helper-textarea');
expect(beforeEnter.cjkDisplay).toBe('none');
expect(beforeEnter.pendingText).toBe('find bug');
expect(beforeEnter.sentInputs).toEqual([]);
await page.keyboard.press('Enter');
await page.waitForFunction(() => window.__sentInputs?.join('') === 'find bug\r');
const afterEnter = await page.evaluate(() => ({
pendingText: app._localEchoOverlay?.pendingText,
sentInputs: window.__sentInputs,
}));
expect(afterEnter.pendingText).toBe('');
expect(afterEnter.sentInputs.join('')).toBe('find bug\r');
});
it('shows terminal local echo at the cursor when no prompt marker is visible', async () => {
await page.evaluate(async () => {
app.activeSessionId = 'mobile-cursor-fallback-test';
app.sessions.set('mobile-cursor-fallback-test', {
id: 'mobile-cursor-fallback-test',
mode: 'claude',
status: 'running',
});
app.hideWelcome();
const settings = app.loadAppSettingsFromStorage();
settings.cjkInputEnabled = false;
settings.localEchoEnabled = true;
app.saveAppSettingsToStorage(settings);
app._updateCjkInputState();
app._updateLocalEchoState();
app.terminal.reset();
await new Promise<void>((resolve) => app.terminal.write('working without prompt marker', resolve));
app.terminal.focus();
});
await page.keyboard.type('abc');
const state = await page.evaluate(() => ({
cjkDisplay: getComputedStyle(document.getElementById('cjkInput') as HTMLElement).display,
pendingText: app._localEchoOverlay?.pendingText,
overlayState: app._localEchoOverlay?.state,
}));
expect(state.cjkDisplay).toBe('none');
expect(state.pendingText).toBe('abc');
expect(state.overlayState?.visible).toBe(true);
expect(state.overlayState?.promptPosition).not.toBeNull();
});
it('codex: streams keystrokes write-through and paints predictions (no buffering)', async () => {
await page.evaluate(async () => {
window.__sentInputs = [];
app.activeSessionId = 'mobile-codex-predict-test';
app.sessions.set('mobile-codex-predict-test', {
id: 'mobile-codex-predict-test',
mode: 'codex',
status: 'running',
});
app.hideWelcome();
app._sendInputAsync = (_sessionId: string, input: string) => {
window.__sentInputs.push(input);
};
const settings = app.loadAppSettingsFromStorage();
settings.cjkInputEnabled = false;
settings.localEchoEnabled = true;
app.saveAppSettingsToStorage(settings);
app._updateCjkInputState();
app._updateLocalEchoState();
// Paint a codex-like composer row so the predictWhen gate passes
app.terminal.reset();
await new Promise<void>((resolve) => app.terminal.write('\u203a ', resolve));
app.terminal.focus();
});
await page.locator('#terminalContainer').tap({ position: { x: 40, y: 40 } });
await page.keyboard.type('hey');
// Write-through: the keystrokes reach the send path BEFORE any Enter,
// and the buffer overlay holds nothing
await page.waitForFunction(() => window.__sentInputs?.join('') === 'hey');
const typed = await page.evaluate(() => ({
policy: app._localEchoPolicy,
pendingText: app._localEchoOverlay?.pendingText ?? '',
outstanding: app._predictiveEcho?.state.outstanding ?? -1,
spans: document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length,
}));
expect(typed.policy).toBe('predict');
expect(typed.pendingText).toBe('');
expect(typed.outstanding).toBeGreaterThan(0);
expect(typed.spans).toBe(typed.outstanding);
// No echo ever arrives (stubbed session): TTL self-heals within ~1s
await page.waitForFunction(
() => document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length === 0,
undefined,
{ timeout: 4000 }
);
const settled = await page.evaluate(() => app._predictiveEcho?.state.outstanding);
expect(settled).toBe(0);
});
});
// ── Shell keyboard bar + one-shot Ctrl (issue #262) ───────────────────
//
// The bar swaps layouts per session mode, and Ctrl is a one-shot modifier
// applied to the next character typed on the SYSTEM keyboard, which on a
// phone reaches the app as xterm onData text, not a key event. These drive
// the real xterm instance with page.keyboard.type() and assert on what would
// go out on the wire (_sendInputAsync), not on DOM state alone.
describe('Shell keyboard bar', () => {
let context: BrowserContext;
let page: Page;
beforeAll(async () => {
({ context, page } = await createDevicePage(REPRESENTATIVE_DEVICES['standard-phone'], BASE_URL, 'chromium'));
await page.waitForTimeout(WAIT.PAGE_SETTLE);
});
afterAll(async () => {
await context.close();
});
/** Point the app at a fake session of `mode` and re-resolve the bar. */
async function activateSession(mode: string, id = 'kb-shell-1'): Promise<void> {
await page.evaluate(`(function (id, mode) {
app.sessions.set(id, { id, name: id, status: 'idle', mode, workingDir: '/tmp' });
app.activeSessionId = id;
KeyboardAccessoryBar.show();
KeyboardAccessoryBar.refreshForActiveSession();
})('${id}', '${mode}')`);
}
/** Capture what the terminal would send, while typing on the real keyboard. */
async function typeAndCapture(text: string): Promise<string[]> {
await page.evaluate(`(function () {
window.__sent = [];
if (!app.__origSend) app.__origSend = app._sendInputAsync;
app._sendInputAsync = function (sessionId, input) { window.__sent.push(input); };
app.terminal.focus();
})()`);
await page.keyboard.type(text);
await page.waitForTimeout(200);
const sent = (await page.evaluate(`window.__sent`)) as string[];
await page.evaluate(`(function () { app._sendInputAsync = app.__origSend; })()`);
return sent;
}
async function tapCtrl(): Promise<void> {
await page.evaluate(`document.querySelector('.keyboard-accessory-bar [data-action="ctrl"]').click()`);
}
it('shows the terminal bar for shell sessions', async () => {
await activateSession('shell');
const actions = await page.evaluate(() =>
Array.from(document.querySelectorAll('.keyboard-accessory-bar [data-action]')).map(
(button) => (button as HTMLElement).dataset.action
)
);
expect(actions).toEqual([
'ctrl',
'esc',
'tab',
'scroll-up',
'scroll-down',
'arrow-left',
'arrow-right',
'paste',
'dismiss',
]);
});
it('keeps the command bar for agent sessions', async () => {
await activateSession('claude', 'kb-agent-1');
const actions = await page.evaluate(() =>
Array.from(document.querySelectorAll('.keyboard-accessory-bar [data-action]')).map(
(button) => (button as HTMLElement).dataset.action
)
);
expect(actions).toContain('init');
expect(actions).not.toContain('ctrl');
});
it('sends Ctrl+C for the next typed character and disarms', async () => {
await activateSession('shell');
await tapCtrl();
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(true);
expect(await typeAndCapture('c')).toEqual(['\x03']);
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(false);
// The very next keystroke is a literal c again.
expect(await typeAndCapture('c')).toEqual(['c']);
});
it('sends Ctrl+D for the next typed character', async () => {
await activateSession('shell');
await tapCtrl();
expect(await typeAndCapture('d')).toEqual(['\x04']);
});
it('survives a terminal tap while the pane has mouse reporting on', async () => {
// A shell session keeps the narrow scrollback strip, so mouse DECSETs
// reach the browser: run vim or htop and xterm starts reporting taps
// through onData as \x1b[<0;31;23M. Those arrive on the same channel as
// typed characters, so a hook that treats every chunk as "the next
// keystroke" spends Ctrl on a tap and the button looks dead. Verified
// against a real shell session before this guard existed.
await activateSession('shell');
await page.evaluate(`app.terminal.write('\\x1b[?1000h\\x1b[?1006h')`);
await page.waitForTimeout(150);
await tapCtrl();
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(true);
const box = await page.locator('.xterm-screen').first().boundingBox();
await page.touchscreen.tap(box!.x + box!.width / 2, box!.y + box!.height / 2);
await page.waitForTimeout(200);
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(true);
expect(await typeAndCapture('c')).toEqual(['\x03']);
await page.evaluate(`app.terminal.write('\\x1b[?1000l\\x1b[?1006l')`);
});
it('cancels on a second tap of Ctrl', async () => {
await activateSession('shell');
await tapCtrl();
await tapCtrl();
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(false);
expect(await typeAndCapture('c')).toEqual(['c']);
});
it('shows the armed state and keeps the terminal focused', async () => {
await activateSession('shell');
await tapCtrl();
const state = await page.evaluate(() => {
const button = document.querySelector('.keyboard-accessory-bar [data-action="ctrl"]') as HTMLElement;
const style = getComputedStyle(button);
return {
armed: button.classList.contains('armed'),
pressed: button.getAttribute('aria-pressed'),
background: style.backgroundColor,
focusedTerminal: document.activeElement === (app.terminal as { textarea: Element }).textarea,
};
});
expect(state.armed).toBe(true);
expect(state.pressed).toBe('true');
// Armed styling must actually land (three-class rule beating the skin
// overrides): an invisible modifier is worse than none.
expect(state.background).not.toBe('rgba(0, 0, 0, 0)');
expect(state.focusedTerminal).toBe(true);
});
it('drops the armed modifier when switching sessions', async () => {
await activateSession('shell');
await tapCtrl();
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(true);
await activateSession('shell', 'kb-shell-2');
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(false);
expect(await typeAndCapture('c')).toEqual(['c']);
});
it('drops the armed modifier when the keyboard is dismissed', async () => {
await activateSession('shell');
await tapCtrl();
await page.evaluate(`KeyboardAccessoryBar.hide()`);
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(false);
});
it('spends the modifier on another accessory key instead of the next keystroke', async () => {
await activateSession('shell');
await tapCtrl();
await page.evaluate(`document.querySelector('.keyboard-accessory-bar [data-action="esc"]').click()`);
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(false);
expect(await typeAndCapture('c')).toEqual(['c']);
});
});
// ── Cross-device keyboard behavior ────────────────────────────────────
describe('Cross-device keyboard behavior', () => {
it('phone: full keyboard handling active', async () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
try {
const state = await getKeyboardState(page);
expect(state.exists).toBe(true);
expect(state.hasHandleViewportResize).toBe(true);
// Show keyboard and verify it works
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
expect(await getKeyboardVisible(page)).toBe(true);
} finally {
await context.close();
}
});
it('tablet: keyboard handling active', async () => {
const device = REPRESENTATIVE_DEVICES['standard-tablet']; // iPad Mini
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
try {
const state = await getKeyboardState(page);
expect(state.exists).toBe(true);
expect(state.hasHandleViewportResize).toBe(true);
} finally {
await context.close();
}
});
it('desktop: KeyboardHandler.init() skips (no touch device)', async () => {
// Create a non-mobile, non-touch context
const browser = await getBrowser('chromium');
const context = await browser.newContext({
viewport: { width: 1280, height: 800 },
isMobile: false,
hasTouch: false,
});
const page = await context.newPage();
try {
await page.goto(BASE_URL, { waitUntil: WAIT.DOM_CONTENT_LOADED });
await page.waitForTimeout(1000);
const state = await getKeyboardState(page);
// KeyboardHandler object exists (it's a const), but init() is a no-op
// on non-touch devices, so no viewport handler is registered
expect(state.exists).toBe(true);
expect(state.keyboardVisible).toBe(false);
expect(state.hasViewportHandler).toBe(false);
} finally {
await context.close();
}
});
});
});