Merge #790: fix(home): swap the home surface on a fold whatever width the page loaded at

This commit is contained in:
Codeman maintainer committed 2026-11-09 07:57:00 +01:00
commit 20cd2eccc9
6 files changed
+366 -32

No files matched your search

+141
View File
@@ -0,0 +1,141 @@
// Port: none (the real home-screen modules in a vm context with a fake
// matchMedia; no browser, no server).
//
// #702: folding a phone on the home screen must swap the home surface even when
// the page loaded wide. The phone overview's breakpoint listener used to be
// wired from its first show, which a wide-loaded page never reaches. Both home
// screens' listeners are now installed once at startup and self-gate on a home
// screen being up. The real resize walk lives in
// test/home-breakpoint-swap.browser.test.ts (browser suite, not in the gate);
// this file keeps the wiring itself under `npm test`.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
type Listener = () => void;
function loadHomeApp(innerWidth = 1440) {
const listeners = new Map<string, Listener[]>();
const elements: Record<string, any> = {
welcomeOverlay: { classList: new Set<string>(), hidden: false },
mobileOverview: { classList: new Set<string>(), hidden: true },
homeSessions: { hidden: true },
};
for (const el of Object.values(elements)) {
if (el.classList) {
const set = el.classList as Set<string>;
el.classList = {
contains: (c: string) => set.has(c),
add: (c: string) => set.add(c),
remove: (c: string) => set.delete(c),
};
}
}
const timers: Array<() => void> = [];
const window: any = {
innerWidth,
matchMedia: (query: string) => ({
matches: false,
addEventListener: (_type: string, fn: Listener) => {
listeners.set(query, [...(listeners.get(query) || []), fn]);
},
}),
};
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
console,
window,
document: {
documentElement: { getAttribute: () => null },
getElementById: (id: string) => elements[id] || null,
createElement: () => ({ dataset: {}, style: {}, appendChild() {}, setAttribute() {} }),
},
MobileDetection: { getDeviceType: () => (window.innerWidth < 600 ? 'mobile' : 'desktop') },
});
for (const file of ['constants.js', 'mobile-overview.js', 'home-sessions.js']) {
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
}
const app = new (CodemanApp as any)();
app.loadAppSettingsFromStorage = () => ({});
app.activeSessionId = null;
app.showWelcomeCalls = 0;
app.showWelcome = () => app.showWelcomeCalls++;
app.renderHomeSessionsCalls = 0;
app.renderHomeSessions = () => {
app.renderHomeSessionsCalls++;
elements.homeSessions.hidden = false;
};
app._debouncedCall = (_key: string, fn: () => void) => timers.push(fn);
const fire = (query: string) => (listeners.get(query) || []).forEach((fn) => fn());
const flushTimers = () => timers.splice(0).forEach((fn) => fn());
return { app, window, elements, listeners, fire, flushTimers };
}
const PHONE_QUERY = '(max-width: 599px)';
const RAIL_QUERY = '(min-width: 1180px)';
describe('home-screen breakpoint listeners (#702)', () => {
it('installs each listener once, with nothing shown yet', () => {
const { app, listeners } = loadHomeApp();
app._wireMobileOverviewBreakpoint();
app._wireHomeSessionsBreakpoint();
// The lazy wiring from a first show must not add a second copy.
app._wireMobileOverviewBreakpoint();
app._wireHomeSessionsBreakpoint();
expect(listeners.get(PHONE_QUERY)).toHaveLength(1);
expect(listeners.get(RAIL_QUERY)).toHaveLength(1);
});
it('a fold on a wide-loaded welcome screen re-decides the surface', () => {
const { app, window, elements, fire } = loadHomeApp(1440);
app._wireMobileOverviewBreakpoint();
elements.welcomeOverlay.classList.add('visible');
window.innerWidth = 390;
fire(PHONE_QUERY);
expect(app.showWelcomeCalls).toBe(1);
});
it('an unfold on the phone overview re-decides the surface', () => {
const { app, window, elements, fire } = loadHomeApp(390);
app._wireMobileOverviewBreakpoint();
elements.mobileOverview.classList.add('visible');
window.innerWidth = 820;
fire(PHONE_QUERY);
expect(app.showWelcomeCalls).toBe(1);
});
it('stays out of the way inside a session and before any home screen is up', () => {
const { app, elements, fire } = loadHomeApp();
app._wireMobileOverviewBreakpoint();
fire(PHONE_QUERY); // page still loading: no home surface yet
elements.welcomeOverlay.classList.add('visible');
app.activeSessionId = 'abc';
fire(PHONE_QUERY);
expect(app.showWelcomeCalls).toBe(0);
});
it('a rail render queued before a fold does not bring the hidden rail back', () => {
const { app, window, elements, flushTimers } = loadHomeApp(1440);
elements.welcomeOverlay.classList.add('visible');
elements.homeSessions.hidden = false;
app._refreshHomeSessionsIfVisible(); // queued while the rail is still up
window.innerWidth = 390; // the fold
elements.homeSessions.hidden = true; // showWelcome()'s phone path hid it
flushTimers();
expect(app.renderHomeSessionsCalls).toBe(0);
expect(elements.homeSessions.hidden).toBe(true);
});
it('is wired from init(), not from the first show', () => {
const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const start = appJs.indexOf('\n init() {');
const end = appJs.indexOf('\n }\n', start);
const init = appJs.slice(start, end);
expect(init).toContain('this._wireMobileOverviewBreakpoint?.()');
expect(init).toContain('this._wireHomeSessionsBreakpoint?.()');
});
});
+151
View File
@@ -0,0 +1,151 @@
/**
* @fileoverview Folding or unfolding a phone on the home screen swaps the home
* surface in place, whichever width the page loaded at (#702).
*
* Both breakpoint listeners (the phone overview's `(max-width: 599px)` and the
* desktop rail's `(min-width: 1180px)`) used to be wired from the first show of
* their own surface. A page that loaded wide never showed the phone overview,
* so folding it to phone width left the welcome screen up until something
* called `showWelcome()` again. They are now installed once at startup.
*
* Real server, real Chromium, one page per direction, resized across 599/600
* and 767/768 in both directions with no navigation, plus a height-only resize
* (the on-screen keyboard) that must not re-decide the surface.
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
type Surface = { welcome: boolean; overview: boolean; rail: boolean };
describe('home surface follows a fold or unfold without a reload', () => {
let server: WebServer;
let browser: Browser;
let workDir: string;
beforeAll(async () => {
server = new WebServer(0, false, true);
await server.start();
// One session, so the desktop rail has a row to show (it hides when empty).
workDir = mkdtempSync(join(tmpdir(), 'home-breakpoint-'));
const res = await fetch(`http://localhost:${server.boundPort}/api/sessions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ workingDir: workDir }),
});
expect(res.ok).toBe(true);
browser = await chromium.launch({ headless: true });
}, 90000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
if (workDir) rmSync(workDir, { recursive: true, force: true });
}, 60000);
async function open(width: number, height: number): Promise<[BrowserContext, Page]> {
const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 1 });
const page = await context.newPage();
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
// The load selects the one session; Home is where a fold has to swap surfaces.
await page.waitForFunction(() => (window as any).app?.sessions?.size > 0, null, { timeout: 30000 });
await page.evaluate(() => (window as any).app.goHome());
await page.waitForFunction(
() =>
document.getElementById('welcomeOverlay')?.classList.contains('visible') ||
document.getElementById('mobileOverview')?.classList.contains('visible'),
null,
{ timeout: 30000 }
);
return [context, page];
}
const surface = (page: Page): Promise<Surface> =>
page.evaluate(() => {
const overview = document.getElementById('mobileOverview')!;
const rail = document.getElementById('homeSessions')!;
return {
welcome: document.getElementById('welcomeOverlay')!.classList.contains('visible'),
overview: !overview.hidden && overview.classList.contains('visible'),
rail: !rail.hidden,
};
});
/** Resize, then wait until the surface settles on what that width should show. */
async function resizeTo(page: Page, width: number, height: number, expected: Surface) {
await page.setViewportSize({ width, height });
await expect
.poll(() => surface(page), { timeout: 5000, message: `surface at ${width}x${height}` })
.toEqual(expected);
}
const PHONE: Surface = { welcome: false, overview: true, rail: false };
const TABLET: Surface = { welcome: true, overview: false, rail: false };
const DESKTOP: Surface = { welcome: true, overview: false, rail: true };
it('loaded wide: folding to phone width shows the phone overview, unfolding brings the welcome back', async () => {
const [context, page] = await open(1440, 900);
try {
expect(await surface(page)).toEqual(DESKTOP);
await resizeTo(page, 768, 900, TABLET);
await resizeTo(page, 767, 900, TABLET);
await resizeTo(page, 600, 900, TABLET);
await resizeTo(page, 599, 900, PHONE);
await resizeTo(page, 390, 844, PHONE);
await resizeTo(page, 600, 900, TABLET);
await resizeTo(page, 767, 900, TABLET);
await resizeTo(page, 768, 900, TABLET);
await resizeTo(page, 1440, 900, DESKTOP);
// A straight fold, the way a real device changes posture, in one step.
await resizeTo(page, 390, 844, PHONE);
} finally {
await context.close();
}
}, 60000);
it('loaded narrow: unfolding to desktop width shows the welcome and the rail, folding brings the overview back', async () => {
const [context, page] = await open(390, 844);
try {
expect(await surface(page)).toEqual(PHONE);
await resizeTo(page, 599, 844, PHONE);
await resizeTo(page, 600, 844, TABLET);
await resizeTo(page, 767, 844, TABLET);
await resizeTo(page, 768, 844, TABLET);
await resizeTo(page, 1440, 900, DESKTOP);
await resizeTo(page, 768, 900, TABLET);
await resizeTo(page, 767, 900, TABLET);
await resizeTo(page, 600, 900, TABLET);
await resizeTo(page, 599, 900, PHONE);
} finally {
await context.close();
}
}, 60000);
it('a height-only resize (the on-screen keyboard) does not re-decide the home surface', async () => {
const [context, page] = await open(1440, 900);
try {
await resizeTo(page, 390, 844, PHONE);
await page.evaluate(() => {
const app = (window as any).app;
app.__showWelcomeCalls = 0;
const original = app.showWelcome;
app.showWelcome = function (...args: unknown[]) {
app.__showWelcomeCalls++;
return original.apply(this, args);
};
});
await page.setViewportSize({ width: 390, height: 480 });
await page.waitForTimeout(400);
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(400);
expect(await surface(page)).toEqual(PHONE);
expect(await page.evaluate(() => (window as any).app.__showWelcomeCalls)).toBe(0);
} finally {
await context.close();
}
}, 60000);
});