Files
Codeman/test/run-mode-menu-scroll.browser.test.ts
T
DevvynandClaude Sonnet 5.5 90649fc363 fix(ui): make the Run dropdown scrollable
The menu opens upward (bottom: 100%) with no max-height or overflow, so with the stock CLIs plus custom endpoint entries it was taller than the room above the toolbar and its top was off-screen and unreachable, worst on phones. Cap it to the space between the header and the toolbar (dvh, vh fallback), scroll inside it with overscroll containment, and stop its overflow:auto children (history, saved URLs) being squashed.

Tests: a CI-gate source guard and a real mobile-browser test (touch swipe reaches the last entry), which fails without the CSS.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-06 19:06:39 +08:00

131 lines
5.4 KiB
TypeScript

/**
* Run dropdown on a phone, mixing the stock CLIs with custom-endpoint entries: it must fit the
* screen and scroll by touch, so the first and the last entry can both be reached.
*
* Before the fix the menu (anchored `bottom: 100%`) had no max-height and no overflow, so with
* enough entries its top ran off the top of the screen and could not be scrolled to.
*
* Browser-driven, so excluded from `npm run test:ci` (config/test-suites.ts). Run locally:
* npm run test:browser -- test/run-mode-menu-scroll.browser.test.ts
*
* Port: 3290
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3290;
describe('Run dropdown on a phone', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
beforeAll(async () => {
server = new WebServer(PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 390, height: 640 },
isMobile: true,
hasTouch: true,
deviceScaleFactor: 2,
});
page = await context.newPage();
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
}, 90000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
/** Open the menu with `extra` generated custom-endpoint entries, as _refreshCustomModelRunOptions builds them. */
async function openWithEntries(extra: number) {
await page.evaluate((count) => {
const menu = document.getElementById('runModeMenu')!;
const box = document.getElementById('runModeCustomModels')!;
box.innerHTML = Array.from(
{ length: count },
(_, i) =>
`<button class="run-mode-option" data-test-entry="${i}"><span class="run-mode-dot claude"></span>Claude Code (endpoint ${i})</button>`
).join('');
document.getElementById('runModeCustomModelSep')!.style.display = '';
document.getElementById('runModeCustomModelHeader')!.style.display = '';
menu.classList.add('active');
}, extra);
}
const geometry = () =>
page.evaluate(() => {
const menu = document.getElementById('runModeMenu')!;
const r = menu.getBoundingClientRect();
// The menu sits just above the Run button, which is inside the toolbar.
const anchor = menu.parentElement!.getBoundingClientRect();
return {
top: r.top,
bottom: r.bottom,
anchorTop: anchor.top,
scrollTop: menu.scrollTop,
scrollHeight: menu.scrollHeight,
clientHeight: menu.clientHeight,
viewport: window.innerHeight,
overflowY: getComputedStyle(menu).overflowY,
};
});
const visible = (selector: string) =>
page.evaluate((sel) => {
const menu = document.getElementById('runModeMenu')!.getBoundingClientRect();
const el = document.querySelector(sel)!.getBoundingClientRect();
return el.top >= menu.top - 1 && el.bottom <= menu.bottom + 1 && el.top >= 0;
}, selector);
it('stays on screen however many entries there are, and becomes scrollable', async () => {
await openWithEntries(30);
const g = await geometry();
expect(g.overflowY).toBe('auto');
expect(g.top).toBeGreaterThanOrEqual(0); // the top is not off the top of the screen
expect(g.bottom).toBeLessThanOrEqual(g.anchorTop); // above the Run button, not over it
expect(g.scrollHeight).toBeGreaterThan(g.clientHeight); // there is something to scroll to
});
it('a touch swipe scrolls it, and the last entry can be reached', async () => {
await openWithEntries(30);
expect(await visible('[data-test-entry="29"]')).toBe(false); // starts out of reach
// Real touch input (touchstart / touchmove / touchend), not scrollTo(): this is the path a
// finger takes, and the one a missing overflow or `touch-action` would block.
const cdp = await page.context().newCDPSession(page);
const g = await geometry();
const y0 = (g.top + g.bottom) / 2 + 100;
for (let swipe = 0; swipe < 6; swipe += 1) {
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: 100, y: y0 }] });
for (let step = 1; step <= 12; step += 1) {
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: 100, y: y0 - step * 20 }], // finger moves up: content scrolls down
});
await page.waitForTimeout(16);
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await page.waitForTimeout(300);
}
const after = await geometry();
expect(after.scrollTop).toBeGreaterThan(0);
expect(await visible('[data-test-entry="29"]')).toBe(true);
// Scrolling the menu must not have scrolled the page behind it.
expect(await page.evaluate(() => window.scrollY)).toBe(0);
});
it('a short menu is not stretched or squashed (no scrollbar when it fits)', async () => {
await openWithEntries(0);
await page.evaluate(() => {
document.getElementById('runModeCustomModelSep')!.style.display = 'none';
document.getElementById('runModeCustomModelHeader')!.style.display = 'none';
});
const g = await geometry();
expect(g.scrollHeight).toBeLessThanOrEqual(g.clientHeight + 1);
});
});