fix(mobile): terminal and layout fixes for touch devices
Mobile-focused fixes for the web UI: keyboard-accessory layout and overlap, native input visibility above the keyboard, CJK input handling, terminal touch scrolling, tab-menu tap targets, mic-recording glow containment, and mobile resize/keyboard-state handling on tab switch, plus mobile visual-regression test coverage and snapshots. Co-Authored-By: Saqeb Akhter <saqeb.akhter@gmail.com>
@@ -222,16 +222,29 @@ describe('Virtual Keyboard', () => {
|
||||
await context.close();
|
||||
});
|
||||
|
||||
it('toolbar slides up via translateY on keyboard show', async () => {
|
||||
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 transform = await page.evaluate(() => {
|
||||
const layout = await page.evaluate(() => {
|
||||
const toolbar = document.querySelector('.toolbar') as HTMLElement | null;
|
||||
return toolbar?.style.transform ?? '';
|
||||
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(transform).not.toBe('');
|
||||
expect(transform).toContain('translateY');
|
||||
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 () => {
|
||||
@@ -263,6 +276,32 @@ describe('Virtual Keyboard', () => {
|
||||
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);
|
||||
@@ -400,6 +439,332 @@ describe('Virtual Keyboard', () => {
|
||||
// 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 only for server override', async () => {
|
||||
const state = await page.evaluate(() => {
|
||||
app._serverCjkOverride = true;
|
||||
app._updateCjkInputState();
|
||||
|
||||
const input = document.getElementById('cjkInput');
|
||||
if (!(input instanceof HTMLElement)) return null;
|
||||
const cs = getComputedStyle(input);
|
||||
return {
|
||||
display: cs.display,
|
||||
position: cs.position,
|
||||
bottom: cs.bottom,
|
||||
zIndex: cs.zIndex,
|
||||
ariaHidden: input.getAttribute('aria-hidden'),
|
||||
};
|
||||
});
|
||||
|
||||
expect(state).not.toBeNull();
|
||||
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');
|
||||
});
|
||||
|
||||
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: '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();
|
||||
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: 'codex',
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cross-device keyboard behavior ────────────────────────────────────
|
||||
|
||||
@@ -151,6 +151,145 @@ describe('Mobile Layout', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Toolbar Collision Regression ───────────────────────────────────────
|
||||
|
||||
describe('Toolbar Collision Regression', () => {
|
||||
it('keeps phone toolbar controls inside the viewport', async () => {
|
||||
const device = DEVICE_REGISTRY.find((d) => d.name === 'iPhone 8')!;
|
||||
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
|
||||
try {
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
const layout = await page.evaluate(() => {
|
||||
const buttons = [...document.querySelectorAll('.toolbar button')]
|
||||
.filter((el): el is HTMLButtonElement => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = getComputedStyle(el);
|
||||
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
|
||||
})
|
||||
.map((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
selector: el.id ? `#${el.id}` : `.${[...el.classList].join('.')}`,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
};
|
||||
});
|
||||
return {
|
||||
overflow: buttons.filter(({ left, right }) => left < 0 || right > window.innerWidth),
|
||||
caseWidth: document.querySelector('.btn-case-mobile')?.getBoundingClientRect().width ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
expect(layout.overflow).toEqual([]);
|
||||
expect(layout.caseWidth).toBeGreaterThanOrEqual(36);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not render the desktop voice button at the 430px phone/tablet boundary', async () => {
|
||||
const device = REPRESENTATIVE_DEVICES['large-phone'];
|
||||
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
|
||||
try {
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
await assertHidden(page, '#voiceInputBtn');
|
||||
await assertVisible(page, '#voiceInputBtnMobile');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses phone upload and voice controls without toolbar overlap', async () => {
|
||||
const device = REPRESENTATIVE_DEVICES['small-phone'];
|
||||
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
|
||||
try {
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
expect(await page.locator('.toolbar-center .btn-upload').isVisible()).toBe(false);
|
||||
await assertVisible(page, '.btn-upload-mobile');
|
||||
await assertVisible(page, '#voiceInputBtnMobile');
|
||||
|
||||
const violations = await page.evaluate(() => {
|
||||
const visibleToolbarButtons = [...document.querySelectorAll('.toolbar button')]
|
||||
.filter((el): el is HTMLButtonElement => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = getComputedStyle(el);
|
||||
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
|
||||
})
|
||||
.map((el) => ({
|
||||
selector: el.id ? `#${el.id}` : `.${[...el.classList].join('.')}`,
|
||||
rect: el.getBoundingClientRect(),
|
||||
}));
|
||||
|
||||
const overlaps: string[] = [];
|
||||
for (let i = 0; i < visibleToolbarButtons.length; i += 1) {
|
||||
for (let j = i + 1; j < visibleToolbarButtons.length; j += 1) {
|
||||
const a = visibleToolbarButtons[i];
|
||||
const b = visibleToolbarButtons[j];
|
||||
const intersects =
|
||||
a.rect.left < b.rect.right &&
|
||||
a.rect.right > b.rect.left &&
|
||||
a.rect.top < b.rect.bottom &&
|
||||
a.rect.bottom > b.rect.top;
|
||||
if (intersects) {
|
||||
overlaps.push(`${a.selector} overlaps ${b.selector}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return overlaps;
|
||||
});
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the mobile recording mic effect inside the button bounds', async () => {
|
||||
const device = REPRESENTATIVE_DEVICES['small-phone'];
|
||||
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
|
||||
try {
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('voiceInputBtnMobile')?.classList.add('recording');
|
||||
});
|
||||
|
||||
const shadow = await page.evaluate(() => {
|
||||
const button = document.getElementById('voiceInputBtnMobile');
|
||||
return button ? getComputedStyle(button).boxShadow : '';
|
||||
});
|
||||
|
||||
expect(shadow).toContain('inset');
|
||||
expect(shadow).not.toContain(' 6px ');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps desktop upload and voice controls in one centered row', async () => {
|
||||
const { context, page } = await createDevicePage(iPadPro, BASE_URL, 'chromium');
|
||||
try {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
await assertVisible(page, '.toolbar-center .btn-upload');
|
||||
await assertVisible(page, '#voiceInputBtn');
|
||||
|
||||
const layout = await page.evaluate(() => {
|
||||
const upload = document.querySelector('.toolbar-center .btn-upload')!.getBoundingClientRect();
|
||||
const voice = document.querySelector('#voiceInputBtn')!.getBoundingClientRect();
|
||||
return {
|
||||
centerYDifference: Math.abs(upload.top + upload.height / 2 - (voice.top + voice.height / 2)),
|
||||
centerXDifference: Math.abs(upload.left + upload.width / 2 - (voice.left + voice.width / 2)),
|
||||
};
|
||||
});
|
||||
|
||||
expect(layout.centerYDifference).toBeLessThanOrEqual(2);
|
||||
expect(layout.centerXDifference).toBeGreaterThan(20);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Device Classes ───────────────────────────────────────────────────────
|
||||
|
||||
describe('Device Classes', () => {
|
||||
@@ -164,7 +303,7 @@ describe('Mobile Layout', () => {
|
||||
});
|
||||
|
||||
it('Android user agent does NOT add ios-device', async () => {
|
||||
const pixel = DEVICE_REGISTRY.find(d => d.name === 'Pixel 7')!;
|
||||
const pixel = DEVICE_REGISTRY.find((d) => d.name === 'Pixel 7')!;
|
||||
const { context, page } = await createDevicePage(pixel, BASE_URL);
|
||||
try {
|
||||
await assertNotHasClass(page, 'body', BODY_CLASSES.IOS);
|
||||
@@ -273,7 +412,7 @@ describe('Mobile Layout', () => {
|
||||
if (violations.length > 0) {
|
||||
console.warn(
|
||||
`Touch target violations (${violations.length}):\n` +
|
||||
violations.map(v => ` ${v.selector}: ${v.width}x${v.height}px`).join('\n'),
|
||||
violations.map((v) => ` ${v.selector}: ${v.width}x${v.height}px`).join('\n')
|
||||
);
|
||||
}
|
||||
// Allow known small elements — notification action buttons (26x26px),
|
||||
|
||||
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 390 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 394 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 193 KiB |
@@ -93,6 +93,122 @@ describe('Tab Navigation', () => {
|
||||
expect(maxWidthPx).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('active tab menu target is touch-sized and opens session options', async () => {
|
||||
const hasActiveTab = await page.locator('.session-tab.active').count();
|
||||
if (!hasActiveTab) {
|
||||
await page.evaluate(() => {
|
||||
const container = document.querySelector('.session-tabs');
|
||||
if (!container) return;
|
||||
container.innerHTML = `
|
||||
<div class="session-tab active" data-id="mobile-menu-test">
|
||||
<span class="tab-number">1</span>
|
||||
<span class="tab-status idle"></span>
|
||||
<span class="tab-info">
|
||||
<span class="tab-name-row"><span class="tab-name">Session</span></span>
|
||||
</span>
|
||||
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('mobile-menu-test')" title="Session options" aria-label="Session options" tabindex="0">⚙</span>
|
||||
<span class="tab-close">×</span>
|
||||
</div>`;
|
||||
(window as any).app.sessions.set('mobile-menu-test', {
|
||||
id: 'mobile-menu-test',
|
||||
name: 'Session',
|
||||
status: 'idle',
|
||||
mode: 'shell',
|
||||
workingDir: '/tmp',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const gear = page.locator('.session-tab.active .tab-gear').first();
|
||||
expect(await gear.isVisible()).toBe(true);
|
||||
const box = await gear.boundingBox();
|
||||
|
||||
expect(box?.width ?? 0).toBeGreaterThanOrEqual(32);
|
||||
expect(box?.height ?? 0).toBeGreaterThanOrEqual(32);
|
||||
|
||||
await gear.click();
|
||||
const modalClass = await page.locator('#sessionOptionsModal').getAttribute('class');
|
||||
expect(modalClass).toMatch(/active/);
|
||||
});
|
||||
|
||||
it('top-left mobile menu button opens the header utility tray', async () => {
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('.modal.active').forEach((modal) => modal.classList.remove('active'));
|
||||
document.getElementById('headerRight')?.classList.add('mobile-collapsed');
|
||||
const toggle = document.getElementById('mobileHeaderUtilityToggle');
|
||||
toggle?.classList.remove('active');
|
||||
toggle?.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
const topLeftElements = await page.evaluate(() => {
|
||||
return document.elementsFromPoint(16, 16).map((el) => ({
|
||||
tag: el.tagName,
|
||||
id: el.id,
|
||||
className: String(el.className),
|
||||
closestButtonId: el.closest('button')?.id ?? '',
|
||||
}));
|
||||
});
|
||||
|
||||
expect(topLeftElements[0]?.closestButtonId).toBe('mobileHeaderUtilityToggle');
|
||||
|
||||
const toggleBox = await page.locator('#mobileHeaderUtilityToggle').boundingBox();
|
||||
expect(toggleBox?.width ?? 0).toBeGreaterThanOrEqual(44);
|
||||
expect(toggleBox?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
|
||||
await page.touchscreen.tap(
|
||||
(toggleBox?.x ?? 0) + (toggleBox?.width ?? 0) / 2,
|
||||
(toggleBox?.y ?? 0) + (toggleBox?.height ?? 0) / 2
|
||||
);
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
const trayClass = await page.locator('#headerRight').getAttribute('class');
|
||||
const expanded = await page.locator('#mobileHeaderUtilityToggle').getAttribute('aria-expanded');
|
||||
const trayBox = await page.locator('#headerRight').boundingBox();
|
||||
const topTrayElement = await page.evaluate(() => {
|
||||
const tray = document.getElementById('headerRight');
|
||||
const rect = tray?.getBoundingClientRect();
|
||||
if (!rect) return '';
|
||||
return (
|
||||
document
|
||||
.elementsFromPoint(rect.left + Math.min(24, rect.width / 2), rect.top + Math.min(24, rect.height / 2))
|
||||
.find((el) => el.id === 'headerRight' || el.closest?.('#headerRight'))
|
||||
?.closest?.('#headerRight')?.id ?? ''
|
||||
);
|
||||
});
|
||||
expect(trayClass).not.toMatch(/mobile-collapsed/);
|
||||
expect(expanded).toBe('true');
|
||||
expect(trayBox?.x ?? 9999).toBeLessThanOrEqual((toggleBox?.x ?? 0) + (toggleBox?.width ?? 0) + 8);
|
||||
expect(topTrayElement).toBe('headerRight');
|
||||
});
|
||||
|
||||
it('tabs remain visible on large phone and tablet headers', async () => {
|
||||
for (const device of [REPRESENTATIVE_DEVICES['large-phone'], REPRESENTATIVE_DEVICES['small-tablet']]) {
|
||||
const { context: deviceContext, page: devicePage } = await createDevicePage(device, BASE_URL, 'chromium');
|
||||
try {
|
||||
await devicePage.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
await devicePage.evaluate(() => {
|
||||
const container = document.querySelector('.session-tabs');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const tab = document.createElement('div');
|
||||
tab.className = i === 1 ? 'session-tab active' : 'session-tab';
|
||||
tab.innerHTML = `<span class="tab-status idle"></span><span class="tab-name">Session ${i}</span>`;
|
||||
container.appendChild(tab);
|
||||
}
|
||||
});
|
||||
|
||||
const tabsWidth = await devicePage.evaluate(() => {
|
||||
return document.querySelector('.session-tabs')?.clientWidth ?? 0;
|
||||
});
|
||||
|
||||
expect(tabsWidth).toBeGreaterThanOrEqual(120);
|
||||
} finally {
|
||||
await deviceContext.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Swipe Navigation (CDP - Chromium) ───────────────────────────────────
|
||||
@@ -134,7 +250,9 @@ describe('Tab Navigation', () => {
|
||||
}
|
||||
|
||||
async function clearSwipeLog(): Promise<void> {
|
||||
await page.evaluate(() => { (window as any).__swipeLog = []; });
|
||||
await page.evaluate(() => {
|
||||
(window as any).__swipeLog = [];
|
||||
});
|
||||
}
|
||||
|
||||
it('swipe left calls nextSession', async () => {
|
||||
@@ -193,10 +311,12 @@ describe('Tab Navigation', () => {
|
||||
const steps = 5;
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const progress = i / steps;
|
||||
await dispatchTouchEvent(cdp, 'touchMove', [{
|
||||
x: startX + (endX - startX) * progress,
|
||||
y: startY + (endY - startY) * progress,
|
||||
}]);
|
||||
await dispatchTouchEvent(cdp, 'touchMove', [
|
||||
{
|
||||
x: startX + (endX - startX) * progress,
|
||||
y: startY + (endY - startY) * progress,
|
||||
},
|
||||
]);
|
||||
await page.waitForTimeout(20);
|
||||
}
|
||||
await dispatchTouchEvent(cdp, 'touchEnd', []);
|
||||
@@ -451,6 +571,51 @@ describe('Tab Navigation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tab Touch Focus', () => {
|
||||
it('switching tabs with the keyboard closed does not leave the terminal textarea focused', async () => {
|
||||
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
|
||||
try {
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
if (typeof app === 'undefined') return { hasHandler: false };
|
||||
const textarea = document.querySelector('.xterm-helper-textarea');
|
||||
if (textarea) textarea.focus();
|
||||
if (typeof KeyboardHandler !== 'undefined') KeyboardHandler.keyboardVisible = false;
|
||||
|
||||
let selected: string | null = null;
|
||||
let selectedOptions: { preserveKeyboard?: boolean } | null = null;
|
||||
const originalSelect = app.selectSession;
|
||||
app.selectSession = function (id, options) {
|
||||
selected = id;
|
||||
selectedOptions = options || {};
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const event = new Event('click', { bubbles: true, cancelable: true });
|
||||
if (typeof app.handleSessionTabClick === 'function') {
|
||||
app.handleSessionTabClick(event, 'mock-session-2');
|
||||
}
|
||||
const activeIsTextarea = document.activeElement === textarea;
|
||||
app.selectSession = originalSelect;
|
||||
return {
|
||||
hasHandler: typeof app.handleSessionTabClick === 'function',
|
||||
selected: selected,
|
||||
preserveKeyboard: selectedOptions ? selectedOptions.preserveKeyboard : undefined,
|
||||
activeIsTextarea: activeIsTextarea,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.hasHandler).toBe(true);
|
||||
expect(result.selected).toBe('mock-session-2');
|
||||
expect(result.preserveKeyboard).toBe(false);
|
||||
expect(result.activeIsTextarea).toBe(false);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tab Close Button Visibility ─────────────────────────────────────────
|
||||
|
||||
describe('Tab Close Button Visibility', () => {
|
||||
@@ -521,10 +686,11 @@ describe('Tab Navigation', () => {
|
||||
const isActive = tab?.classList.contains('active') ?? false;
|
||||
const style = getComputedStyle(gear);
|
||||
// Gear hidden via display:none (mobile) or opacity:0 + width:0 (desktop)
|
||||
const isVisible = style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& parseFloat(style.opacity) > 0
|
||||
&& parseFloat(style.width) > 0;
|
||||
const isVisible =
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
parseFloat(style.opacity) > 0 &&
|
||||
parseFloat(style.width) > 0;
|
||||
return { isActive, isVisible };
|
||||
});
|
||||
});
|
||||
|
||||
@@ -315,6 +315,18 @@ describe('session-routes', () => {
|
||||
expect(harness.ctx._session.resize).toHaveBeenCalledWith(120, 40);
|
||||
});
|
||||
|
||||
it('passes viewport type through for resize arbitration', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
|
||||
payload: { cols: 48, rows: 28, viewportType: 'mobile' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx._session.resize).toHaveBeenCalledWith(48, 28, { viewportType: 'mobile' });
|
||||
});
|
||||
|
||||
it('rejects cols exceeding max (500)', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
|
||||
@@ -292,6 +292,21 @@ describe('ws-routes', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('passes viewport type through for resize arbitration', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
ws.send(JSON.stringify({ t: 'z', c: 48, r: 28, v: 'mobile' }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session.resize).toHaveBeenCalledWith(48, 28, { viewportType: 'mobile' });
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts resize at minimum bounds (1x1)', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
|
||||
@@ -81,6 +81,10 @@ vi.mock('../src/session.js', () => {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getAttachmentHistoryForPersist() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getOutput() {
|
||||
return 'mock output';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
|
||||
type ResizeableSessionInternals = {
|
||||
ptyProcess: { resize: (cols: number, rows: number) => void };
|
||||
_ptyCols: number;
|
||||
_ptyRows: number;
|
||||
};
|
||||
|
||||
function attachFakePty(session: Session, cols = 160, rows = 48) {
|
||||
const resize = vi.fn();
|
||||
const internals = session as unknown as ResizeableSessionInternals;
|
||||
internals.ptyProcess = { resize };
|
||||
internals._ptyCols = cols;
|
||||
internals._ptyRows = rows;
|
||||
return resize;
|
||||
}
|
||||
|
||||
describe('Session resize arbitration', () => {
|
||||
it('ignores mobile resizes that would shrink a wider desktop PTY', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||||
const resize = attachFakePty(session, 160, 48);
|
||||
|
||||
session.resize(48, 28, { viewportType: 'mobile' });
|
||||
|
||||
expect(resize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows desktop resizes to change the shared PTY dimensions', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||||
const resize = attachFakePty(session, 160, 48);
|
||||
|
||||
session.resize(120, 40, { viewportType: 'desktop' });
|
||||
|
||||
expect(resize).toHaveBeenCalledWith(120, 40);
|
||||
});
|
||||
});
|
||||