mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
Merge #542: the Run dropdown fits between the header and the toolbar and scrolls
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* The Run dropdown grows UPWARD from the toolbar (`bottom: 100%`). With every CLI, the custom
|
||||
* endpoint entries ("Claude Code (llama.cpp)" ...), Terminal and the saved URLs it can be taller
|
||||
* than the space above the toolbar, which put its top off-screen with nothing to scroll: on a
|
||||
* phone the entries up there were unreachable. This pins the rule that makes it scroll, in the CI
|
||||
* gate; the real touch scroll is test/run-mode-menu-scroll.browser.test.ts.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const css = readFileSync(new URL('../src/web/public/styles.css', import.meta.url), 'utf8');
|
||||
|
||||
/** The declaration block of the FIRST rule whose selector is exactly `selector`. */
|
||||
function rule(selector: string): string {
|
||||
const start = css.search(new RegExp(`(^|\\n)${selector.replace(/[.>*]/g, '\\$&')}\\s*\\{`));
|
||||
expect(start, `no rule for ${selector}`).toBeGreaterThanOrEqual(0);
|
||||
const open = css.indexOf('{', start);
|
||||
return css.slice(open + 1, css.indexOf('}', open));
|
||||
}
|
||||
|
||||
describe('Run dropdown scrolls when it is taller than the room above the toolbar', () => {
|
||||
const menu = rule('.run-mode-menu');
|
||||
|
||||
it('scrolls vertically and keeps the scroll inside the menu', () => {
|
||||
expect(menu).toMatch(/overflow-y:\s*auto/);
|
||||
expect(menu).toMatch(/overscroll-behavior:\s*contain/);
|
||||
expect(menu).toMatch(/touch-action:\s*pan-y/);
|
||||
});
|
||||
|
||||
it('is capped to the space between the header and the toolbar, in dvh with a vh fallback', () => {
|
||||
const caps = [...menu.matchAll(/max-height:\s*([^;]+);/g)].map((m) => m[1]);
|
||||
expect(caps).toHaveLength(2);
|
||||
expect(caps[0]).toContain('100vh');
|
||||
expect(caps[1]).toContain('100dvh');
|
||||
for (const cap of caps) {
|
||||
expect(cap).toContain('var(--header-height)');
|
||||
expect(cap).toContain('var(--toolbar-height)');
|
||||
}
|
||||
});
|
||||
|
||||
it('does not let scrolling children (history, saved URLs) be squashed to nothing', () => {
|
||||
expect(rule('.run-mode-menu > *')).toMatch(/flex-shrink:\s*0/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user