mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
A mid-terminal tap on a claude-mode session left document.activeElement on <body>, so the on-screen keyboard could not be raised and there was no way to type — the blocker reduced upstream in #173. _classifyMobileTerminalTap returns 'content' for any non-prompt row, and _handleMobileTerminalTap blurred on every 'content' tap while touchstart's preventDefault had already cancelled the compatibility click that would otherwise focus xterm. Both routes to focus were closed on the same gesture. Blur now applies only to rows that are actually TUI-owned. The distinguishing signal is the affordance a CLI prints on or beside the row ("ctrl+r to expand", "tap to collapse", "esc to interrupt"), not the row's title text — a readback's title row carries no hint of its own, so the adjacent row is consulted too. Keying on titles would recognise only the exact strings a fixture happens to use and would let a real readback keep the keyboard open. Measured with a real touchstart/touchend gesture, iPhone-class viewport, claude-mode session, tapping mid-transcript: before document.activeElement = body after document.activeElement = xterm-helper-textarea Note: upstream master already passes this assertion, so the added test is a regression guard for this branch, not a test that fails on master. test/mobile/keyboard.test.ts: 40 tests, 5 failed | 35 passed — the same 5 pre-existing failures as master (stale layout/accessory-bar expectations and a CJK timeout), unchanged by this commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1521 lines
61 KiB
TypeScript
1521 lines
61 KiB
TypeScript
// 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({ scrollToBottom: 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({ scrollToBottom: 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', 'paste', '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('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(() => {
|
||
|
||
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('keeps the hidden keyboard input focused after an 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);
|
||
|
||
const activeClass = await page.evaluate(() => document.activeElement?.className);
|
||
expect(activeClass).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.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('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();
|
||
}
|
||
});
|
||
});
|
||
});
|