mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
Merge pull request #334 from aakhter/pr/cod-358-vertical-rail
feat(tabs): add a resizable vertical session rail
This commit is contained in:
@@ -40,13 +40,16 @@ function fakeElement(): any {
|
||||
* point: if that reuse ever breaks, these tests stop loading rather than
|
||||
* quietly testing a divergent copy.
|
||||
*/
|
||||
function loadHomeSessionsApp(overrides: Record<string, any> = {}, innerWidth = 1512) {
|
||||
function loadHomeSessionsApp(overrides: Record<string, any> = {}, innerWidth = 1512, tabOrientation = 'horizontal') {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console,
|
||||
window: { innerWidth },
|
||||
document: {
|
||||
documentElement: {
|
||||
getAttribute: (name: string) => (name === 'data-tab-orientation' ? tabOrientation : null),
|
||||
},
|
||||
getElementById: () => null,
|
||||
createElement: () => fakeElement(),
|
||||
createElementNS: () => fakeElement(),
|
||||
@@ -198,6 +201,11 @@ describe('home sessions column: gate', () => {
|
||||
expect(app.shouldShowHomeSessions()).toBe(true);
|
||||
});
|
||||
|
||||
it('yields to the persistent rail when the effective tab orientation is vertical', () => {
|
||||
expect(loadHomeSessionsApp({}, 1512, 'vertical').shouldShowHomeSessions()).toBe(false);
|
||||
expect(loadHomeSessionsApp({}, 1512, 'horizontal').shouldShowHomeSessions()).toBe(true);
|
||||
});
|
||||
|
||||
it('stays out of a window too narrow to hold it beside the centered content', () => {
|
||||
// Absolutely positioned: below the gate it would overlap the search panel
|
||||
// rather than push it aside.
|
||||
@@ -238,6 +246,12 @@ describe('home sessions column: wiring', () => {
|
||||
expect(css).toMatch(/\.home-sessions\[hidden\]\s*\{\s*display:\s*none;/);
|
||||
});
|
||||
|
||||
it('has a CSS backstop that suppresses the homepage rail in vertical mode', () => {
|
||||
expect(css).toMatch(
|
||||
/html\[data-tab-orientation='vertical'\]\s+\.home-sessions\s*\{\s*display:\s*none\s*!important;/
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses the tab-load spinner rather than declaring a second one', () => {
|
||||
// The working ring is the same motion a tab shows while it loads, on both
|
||||
// home screens. Re-declaring the keyframes here is how they drift apart.
|
||||
|
||||
@@ -186,6 +186,9 @@ describe('Inline rename input', () => {
|
||||
renameActiveAfter: !!app._activeRename,
|
||||
sessionGone: !app.sessions.has('ghost-id'),
|
||||
fetchFired,
|
||||
renameClassActive:
|
||||
document.querySelector('.tab-name[data-session-id="ghost-id"]')?.classList.contains('tab-name-renaming') ??
|
||||
false,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -194,6 +197,7 @@ describe('Inline rename input', () => {
|
||||
expect(result.sessionGone).toBe(true);
|
||||
// Cancel path skips the API call — deleting a session shouldn't trigger a stale rename PUT.
|
||||
expect(result.fetchFired).toBe(false);
|
||||
expect(result.renameClassActive).toBe(false);
|
||||
});
|
||||
|
||||
it('Ghost tab: _cleanupSessionData for a DIFFERENT session does NOT cancel rename', async () => {
|
||||
@@ -386,10 +390,16 @@ describe('Inline rename input', () => {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
|
||||
window.fetch = origFetch;
|
||||
return { mapName: app.sessions.get('no-sse')?.name ?? null };
|
||||
return {
|
||||
mapName: app.sessions.get('no-sse')?.name ?? null,
|
||||
renameClassActive:
|
||||
document.querySelector('.tab-name[data-session-id="no-sse"]')?.classList.contains('tab-name-renaming') ??
|
||||
false,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.mapName).toBe('w9-case: fresh');
|
||||
expect(result.renameClassActive).toBe(false);
|
||||
});
|
||||
|
||||
it('A rejected rename restores the old label and leaves app.sessions untouched', async () => {
|
||||
@@ -426,12 +436,16 @@ describe('Inline rename input', () => {
|
||||
return {
|
||||
mapName: app.sessions.get('rename-500')?.name ?? null,
|
||||
label: document.querySelector('.tab-name[data-session-id="rename-500"]')?.textContent ?? null,
|
||||
renameClassActive:
|
||||
document.querySelector('.tab-name[data-session-id="rename-500"]')?.classList.contains('tab-name-renaming') ??
|
||||
false,
|
||||
toasts,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.mapName).toBe('w9-case');
|
||||
expect(result.label).toBe('w9-case');
|
||||
expect(result.renameClassActive).toBe(false);
|
||||
expect(result.toasts).toContain('Failed to rename');
|
||||
});
|
||||
|
||||
@@ -445,6 +459,7 @@ describe('Inline rename input', () => {
|
||||
app: {
|
||||
_activeRename: { sessionId: string } | null;
|
||||
sessions: Map<string, { id: string; name: string }>;
|
||||
renderSessionTabs: () => void;
|
||||
startInlineRename: (id: string) => void;
|
||||
};
|
||||
}
|
||||
@@ -461,11 +476,80 @@ describe('Inline rename input', () => {
|
||||
tabName.textContent = 'Second';
|
||||
wrap.appendChild(tabName);
|
||||
document.body.appendChild(wrap);
|
||||
|
||||
// Cancelling the first rename is allowed to repaint the tab list. Model
|
||||
// that synchronously so a target captured before cancel() becomes stale.
|
||||
const originalRenderSessionTabs = app.renderSessionTabs;
|
||||
app.renderSessionTabs = () => {
|
||||
const current = document.querySelector('.tab-name[data-session-id="second-id"]');
|
||||
current?.replaceWith(current.cloneNode(true));
|
||||
};
|
||||
app.startInlineRename('second-id');
|
||||
return { firstActive, secondActive: app._activeRename?.sessionId };
|
||||
app.renderSessionTabs = originalRenderSessionTabs;
|
||||
return {
|
||||
firstActive,
|
||||
secondActive: app._activeRename?.sessionId,
|
||||
secondInputVisible: !!document.querySelector('.tab-name[data-session-id="second-id"] input.tab-rename-input'),
|
||||
firstRenameClassActive:
|
||||
document.querySelector('.tab-name[data-session-id="first-id"]')?.classList.contains('tab-name-renaming') ??
|
||||
false,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.firstActive).toBe('first-id');
|
||||
expect(result.secondActive).toBe('second-id');
|
||||
expect(result.secondInputVisible).toBe(true);
|
||||
expect(result.firstRenameClassActive).toBe(false);
|
||||
});
|
||||
|
||||
it('Vertical rail paints typing in an unclamped editor and restores the clamp on cancel', async () => {
|
||||
await resetState();
|
||||
const id = 'vertical-live-input';
|
||||
|
||||
await page.evaluate((sessionId) => {
|
||||
const app = (
|
||||
window as unknown as {
|
||||
app: {
|
||||
sessions: Map<string, { id: string; name: string }>;
|
||||
startInlineRename: (id: string) => void;
|
||||
};
|
||||
}
|
||||
).app;
|
||||
document.documentElement.dataset.tabOrientation = 'vertical';
|
||||
const rail = document.getElementById('tabRail') as HTMLElement;
|
||||
const tab = document.createElement('div');
|
||||
tab.setAttribute('data-test-tab', '1');
|
||||
tab.className = 'session-tab';
|
||||
tab.innerHTML =
|
||||
`<span class="tab-name" data-session-id="${sessionId}">` +
|
||||
'<span class="tab-name-prefix">w9-case: </span>old</span>';
|
||||
rail.appendChild(tab);
|
||||
app.sessions.set(sessionId, { id: sessionId, name: 'w9-case: old' });
|
||||
app.startInlineRename(sessionId);
|
||||
}, id);
|
||||
|
||||
const label = page.locator(`.tab-name[data-session-id="${id}"]`);
|
||||
const input = label.locator('input.tab-rename-input');
|
||||
await input.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
|
||||
await page.keyboard.type('edited title');
|
||||
|
||||
expect(await input.inputValue()).toBe('edited title');
|
||||
expect(await input.evaluate((node) => document.activeElement === node)).toBe(true);
|
||||
expect(await label.evaluate((node) => node.classList.contains('tab-name-renaming'))).toBe(true);
|
||||
expect(await label.evaluate((node) => getComputedStyle(node).webkitLineClamp)).toBe('none');
|
||||
expect(await input.evaluate((node) => node.getBoundingClientRect().width)).toBeGreaterThan(0);
|
||||
|
||||
const settled = await page.evaluate((sessionId) => {
|
||||
const app = (window as unknown as { app: { _activeRename: { cancel: () => void } | null } }).app;
|
||||
app._activeRename?.cancel();
|
||||
const label = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`) as HTMLElement;
|
||||
return {
|
||||
classActive: label.classList.contains('tab-name-renaming'),
|
||||
inputPresent: !!label.querySelector('input.tab-rename-input'),
|
||||
webkitLineClamp: getComputedStyle(label).webkitLineClamp,
|
||||
};
|
||||
}, id);
|
||||
|
||||
expect(settled).toEqual({ classActive: false, inputPresent: false, webkitLineClamp: '2' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,10 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
type Rect = { left: number; top: number; width: number; height: number };
|
||||
type LineagePath = { d: string; endX: number; endY: number; sameRow: boolean } | null;
|
||||
type Orientation = 'horizontal' | 'vertical';
|
||||
|
||||
const lineageJs = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-lineage.js'), 'utf8');
|
||||
const stylesCss = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
|
||||
|
||||
function loadLineageHelper() {
|
||||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||
@@ -20,7 +24,13 @@ function loadLineageHelper() {
|
||||
return (
|
||||
context.window as {
|
||||
CodemanLineage: {
|
||||
computePath: (input: { parent: Rect | null; child: Rect | null; strip?: Rect; depth?: number }) => LineagePath;
|
||||
computePath: (input: {
|
||||
parent: Rect | null;
|
||||
child: Rect | null;
|
||||
strip?: Rect;
|
||||
depth?: number;
|
||||
orientation?: Orientation;
|
||||
}) => LineagePath;
|
||||
DIP_MIN_PX: number;
|
||||
DIP_MAX_PX: number;
|
||||
SIBLING_STEP_PX: number;
|
||||
@@ -48,7 +58,11 @@ function loadLineageApp(): { app: LineageApp; sandbox: LineageSandbox } {
|
||||
globalThis: {},
|
||||
CodemanApp,
|
||||
MobileDetection: { getDeviceType: () => 'desktop' },
|
||||
document: { getElementById: () => null, createElementNS: () => null },
|
||||
document: {
|
||||
documentElement: { getAttribute: () => 'horizontal' },
|
||||
getElementById: () => null,
|
||||
createElementNS: () => null,
|
||||
},
|
||||
};
|
||||
const context = vm.createContext(sandbox);
|
||||
for (const file of ['constants.js', 'session-lineage.js']) {
|
||||
@@ -258,6 +272,76 @@ describe('lineage line geometry', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('routes vertical tabs through the empty left gutter instead of their shared centerline', () => {
|
||||
const helper = loadLineageHelper();
|
||||
const strip: Rect = { left: 100, top: 20, width: 320, height: 320 };
|
||||
const parent: Rect = { left: 132, top: 40, width: 260, height: 40 };
|
||||
const child: Rect = { left: 132, top: 200, width: 260, height: 40 };
|
||||
const geom = helper.computePath({ parent, child, strip, orientation: 'vertical' })!;
|
||||
const nums = geom.d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
|
||||
|
||||
expect(geom).not.toBeNull();
|
||||
expect(nums).toHaveLength(5);
|
||||
expect(nums[0]).toBe(parent.left);
|
||||
expect(nums[1]).toBe(parent.top + parent.height / 2);
|
||||
expect(nums[2]).toBeGreaterThan(strip.left);
|
||||
expect(nums[2]).toBeLessThan(parent.left);
|
||||
expect(nums[3]).toBe(child.top + child.height / 2);
|
||||
expect(nums[4]).toBe(child.left);
|
||||
expect(geom.endX).toBe(child.left);
|
||||
expect(geom.endY).toBe(child.top + child.height / 2);
|
||||
});
|
||||
|
||||
it('offsets vertical sibling tracks without moving either tab endpoint', () => {
|
||||
const helper = loadLineageHelper();
|
||||
const strip: Rect = { left: 100, top: 20, width: 320, height: 320 };
|
||||
const parent: Rect = { left: 132, top: 40, width: 260, height: 40 };
|
||||
const child: Rect = { left: 132, top: 200, width: 260, height: 40 };
|
||||
const first = helper.computePath({ parent, child, strip, orientation: 'vertical', depth: 0 })!;
|
||||
const second = helper.computePath({ parent, child, strip, orientation: 'vertical', depth: 1 })!;
|
||||
const numbers = (d: string) => d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
|
||||
|
||||
expect(numbers(second.d)[2]).toBeGreaterThan(numbers(first.d)[2]);
|
||||
expect([second.endX, second.endY]).toEqual([first.endX, first.endY]);
|
||||
});
|
||||
|
||||
it('keeps the same gutter shape when the child sits above its parent', () => {
|
||||
const helper = loadLineageHelper();
|
||||
const strip: Rect = { left: 100, top: 20, width: 320, height: 320 };
|
||||
const parent: Rect = { left: 132, top: 220, width: 260, height: 40 };
|
||||
const child: Rect = { left: 132, top: 60, width: 260, height: 40 };
|
||||
const geom = helper.computePath({ parent, child, strip, orientation: 'vertical' })!;
|
||||
const nums = geom.d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
|
||||
|
||||
expect(nums).toEqual([parent.left, 240, expect.any(Number), 80, child.left]);
|
||||
expect(nums[2]).toBeGreaterThan(strip.left);
|
||||
expect(nums[2]).toBeLessThan(parent.left);
|
||||
expect([geom.endX, geom.endY]).toEqual([child.left, 80]);
|
||||
});
|
||||
|
||||
it('clips vertical lineage by the visible Y range after rail scrolling', () => {
|
||||
const helper = loadLineageHelper();
|
||||
const strip: Rect = { left: 100, top: 100, width: 320, height: 300 };
|
||||
const visible: Rect = { left: 132, top: 160, width: 260, height: 40 };
|
||||
const above: Rect = { left: 132, top: 20, width: 260, height: 40 };
|
||||
const below: Rect = { left: 132, top: 460, width: 260, height: 40 };
|
||||
|
||||
expect(helper.computePath({ parent: above, child: visible, strip, orientation: 'vertical' })).toBeNull();
|
||||
expect(helper.computePath({ parent: visible, child: below, strip, orientation: 'vertical' })).toBeNull();
|
||||
expect(
|
||||
helper.computePath({ parent: visible, child: { ...visible, top: 300 }, strip, orientation: 'vertical' })
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('passes the resolved DOM orientation into geometry and reserves a vertical gutter', () => {
|
||||
expect(lineageJs).toContain("getAttribute('data-tab-orientation')");
|
||||
expect(lineageJs).toMatch(/compute\(\{[\s\S]{0,180}orientation/);
|
||||
const selector = "html[data-tab-orientation='vertical'] .tab-rail .session-tabs {";
|
||||
const verticalRailBlock = stylesCss.slice(stylesCss.indexOf(selector), stylesCss.indexOf(selector) + 600);
|
||||
expect(verticalRailBlock).toContain('--lineage-vertical-gutter');
|
||||
expect(verticalRailBlock).toContain('padding-left');
|
||||
});
|
||||
|
||||
it('still draws when no strip rect is supplied (clipping is opt-in)', () => {
|
||||
const helper = loadLineageHelper();
|
||||
const geom = helper.computePath({ parent: tab(0), child: tab(9000) });
|
||||
|
||||
@@ -34,6 +34,7 @@ const SCHEMAS = readFileSync(new URL('../src/web/schemas.ts', import.meta.url),
|
||||
|
||||
interface LayoutApp {
|
||||
soloSessionId: string | null;
|
||||
isSoloWindow: boolean;
|
||||
sessions: Map<string, unknown>;
|
||||
sessionOrder: string[];
|
||||
_tallTabsEnabled?: boolean;
|
||||
@@ -44,6 +45,7 @@ interface LayoutApp {
|
||||
isSessionSidebarActive(): boolean;
|
||||
isSessionSidebarCollapsed(): boolean;
|
||||
applySessionListLayout(): void;
|
||||
applyTabOrientation(): void;
|
||||
toggleSessionSidebar(): void;
|
||||
updateSidebarCount(): void;
|
||||
closeSessionSidebarOnHandheld(): void;
|
||||
@@ -86,6 +88,7 @@ const SHELL = `
|
||||
</div>
|
||||
</header>
|
||||
<main class="main">
|
||||
<div class="tab-rail" id="tabRail"></div>
|
||||
<aside class="session-sidebar" id="sessionSidebar" aria-label="Sessions">
|
||||
<div class="session-sidebar-head">
|
||||
<span class="session-sidebar-title">Sessions</span>
|
||||
@@ -154,6 +157,7 @@ function boot(
|
||||
// terminal stack. Only the layout surface is under test here.
|
||||
const app = Object.create(win.__CodemanApp.prototype) as LayoutApp;
|
||||
app.soloSessionId = options.solo ?? null;
|
||||
app.isSoloWindow = !!app.soloSessionId;
|
||||
app.sessions = new Map();
|
||||
app.sessionOrder = [];
|
||||
app._elemCache = new Map();
|
||||
@@ -177,6 +181,39 @@ describe('session list layout', () => {
|
||||
expect(toggleBtn(win).classList.contains('btn-sidebar-toggle--hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves vertical rail ownership when the session-list layout reapplies', () => {
|
||||
const { win, app } = boot({ stored: { sessionListLayout: 'header', tabOrientation: 'vertical' } });
|
||||
|
||||
app.applySessionListLayout();
|
||||
app.applyTabOrientation();
|
||||
expect(tabsEl(win).parentElement?.id).toBe('tabRail');
|
||||
|
||||
app.applySessionListLayout();
|
||||
|
||||
expect(tabsEl(win).parentElement?.id).toBe('tabRail');
|
||||
expect(tabsEl(win).getAttribute('aria-orientation')).toBe('vertical');
|
||||
});
|
||||
|
||||
it('keeps aria orientation synchronized when tab orientation moves hosts', () => {
|
||||
const { win, app } = boot({ stored: { sessionListLayout: 'header', tabOrientation: 'vertical' } });
|
||||
|
||||
app.applySessionListLayout();
|
||||
app.applyTabOrientation();
|
||||
|
||||
expect(tabsEl(win).parentElement?.id).toBe('tabRail');
|
||||
expect(tabsEl(win).getAttribute('aria-orientation')).toBe('vertical');
|
||||
|
||||
win.localStorage.setItem(
|
||||
'codeman-app-settings',
|
||||
JSON.stringify({ sessionListLayout: 'header', tabOrientation: 'horizontal' })
|
||||
);
|
||||
delete (app as unknown as { _cachedAppSettings?: unknown })._cachedAppSettings;
|
||||
app.applyTabOrientation();
|
||||
|
||||
expect(tabsEl(win).parentElement?.id).toBe('sessionTabsHost');
|
||||
expect(tabsEl(win).getAttribute('aria-orientation')).toBe('horizontal');
|
||||
});
|
||||
|
||||
it('re-parents the tab list into the sidebar and flips the a11y state', () => {
|
||||
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } });
|
||||
expect(app.getSessionListLayout()).toBe('sidebar');
|
||||
@@ -225,6 +262,20 @@ describe('session list layout', () => {
|
||||
expect(tabsEl(win).parentElement?.id).toBe('sessionTabsHost');
|
||||
});
|
||||
|
||||
it('forces horizontal tabs in a solo window even when vertical orientation is preferred', () => {
|
||||
const { win, app } = boot({
|
||||
stored: { sessionListLayout: 'header', tabOrientation: 'vertical' },
|
||||
solo: 'sess-1',
|
||||
});
|
||||
|
||||
app.applySessionListLayout();
|
||||
app.applyTabOrientation();
|
||||
|
||||
expect(win.document.documentElement.dataset.tabOrientation).toBe('horizontal');
|
||||
expect(tabsEl(win).parentElement?.id).toBe('sessionTabsHost');
|
||||
expect(tabsEl(win).getAttribute('aria-orientation')).toBe('horizontal');
|
||||
});
|
||||
|
||||
it('round-trips the collapse state through its own storage key', () => {
|
||||
// Deliberately NOT in the app-settings blob: saveAppSettings() rebuilds that
|
||||
// blob from the DOM controls, so a key without a control is wiped on Save.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/** Real-browser responsive layout coverage for Session Options. */
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
|
||||
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
|
||||
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
|
||||
const mobileStyles = readFileSync(resolve(publicDir, 'mobile.css'), 'utf8');
|
||||
|
||||
function sessionOptionsMarkup() {
|
||||
const start = html.indexOf('<div class="modal" id="sessionOptionsModal">');
|
||||
const end = html.indexOf('<!-- Close Session Confirmation Modal -->', start);
|
||||
if (start < 0 || end < 0) throw new Error('Session Options markup not found');
|
||||
return html.slice(start, end);
|
||||
}
|
||||
|
||||
describe('Session Options responsive layout in Chromium', () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
afterAll(async () => browser.close());
|
||||
|
||||
async function renderAt(width: number) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
await page.setContent(`<!doctype html><html><head><style>${styles}</style>
|
||||
<style>@media (max-width: 1023px) { ${mobileStyles} }</style></head>
|
||||
<body>${sessionOptionsMarkup()}</body></html>`);
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('sessionOptionsModal')!.classList.add('active');
|
||||
document
|
||||
.querySelectorAll('#sessionOptionsModal .set-section')
|
||||
.forEach((section) => section.classList.add('hidden'));
|
||||
document.getElementById('context-tab')!.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
async function metrics() {
|
||||
return page.evaluate(() => {
|
||||
const modal = document.querySelector<HTMLElement>('#sessionOptionsModal .modal-content')!;
|
||||
const doc = document.getElementById('sessionOptionsDoc')!;
|
||||
const panel = document.getElementById('context-tab')!;
|
||||
const header = panel.querySelector<HTMLElement>(':scope > .set-section-head')!;
|
||||
const blurb = panel.querySelector<HTMLElement>(':scope > .set-section-blurb')!;
|
||||
return {
|
||||
modalWidth: modal.getBoundingClientRect().width,
|
||||
docFits: doc.scrollWidth === doc.clientWidth,
|
||||
panelFits: panel.scrollWidth === panel.clientWidth,
|
||||
tracks: getComputedStyle(panel).gridTemplateColumns.split(' '),
|
||||
headerGridColumn: getComputedStyle(header).gridColumn,
|
||||
blurbGridColumn: getComputedStyle(blurb).gridColumn,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
it('uses one fitting column at the tablet-width desktop viewport', async () => {
|
||||
await renderAt(974);
|
||||
expect(await metrics()).toMatchObject({ docFits: true, panelFits: true, tracks: [expect.any(String)] });
|
||||
});
|
||||
|
||||
it('uses two fitting columns with a full-width introduction on wide screens', async () => {
|
||||
await renderAt(1440);
|
||||
const layout = await metrics();
|
||||
expect(layout.modalWidth).toBeGreaterThan(1000);
|
||||
expect(layout.tracks).toHaveLength(2);
|
||||
expect(layout.headerGridColumn).toBe('1 / -1');
|
||||
expect(layout.blurbGridColumn).toBe('1 / -1');
|
||||
expect(layout.docFits).toBe(true);
|
||||
expect(layout.panelFits).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import { resolve } from 'node:path';
|
||||
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
|
||||
const sessionUi = readFileSync(resolve(publicDir, 'session-ui.js'), 'utf8');
|
||||
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
|
||||
|
||||
/** The Session Options markup, so assertions can't be satisfied elsewhere. */
|
||||
function optionsModal(): string {
|
||||
@@ -94,4 +95,12 @@ describe('Session Options modal structure', () => {
|
||||
expect(css).toContain(':is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row {');
|
||||
expect(css).toContain(':is(#sessionOptionsModal, #createCaseModal) .set-section.hidden {');
|
||||
});
|
||||
|
||||
it('uses document-safe context columns and widens only at the desktop breakpoint', () => {
|
||||
expect(styles).toMatch(/#sessionOptionsModal #context-tab\s*\{[^}]*minmax\(0, 1fr\)/s);
|
||||
expect(styles).toMatch(
|
||||
/@media \(min-width: 1200px\)[\s\S]*#sessionOptionsModal #context-tab[^}]*repeat\(2, minmax\(0, 1fr\)\)/
|
||||
);
|
||||
expect(styles).not.toMatch(/@media \(min-width: 680px\)[\s\S]{0,1200}#sessionOptionsModal #context-tab/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/** Real Chromium visibility and click coverage for nested vertical session actions. */
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
|
||||
const styles = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
|
||||
const controller = readFileSync(resolve(import.meta.dirname, '../src/web/public/tab-rail-resize.js'), 'utf8');
|
||||
|
||||
function fixture(surface: 'sidebar' | 'rail', active = false) {
|
||||
const root =
|
||||
surface === 'sidebar'
|
||||
? 'data-session-list="sidebar" data-sidebar="expanded" data-tab-orientation="horizontal"'
|
||||
: 'data-session-list="header" data-sidebar="expanded" data-tab-orientation="vertical"';
|
||||
const hostClass = surface === 'sidebar' ? 'session-sidebar' : 'tab-rail';
|
||||
return `<!doctype html><html ${root}><head><style>${styles}</style></head><body>
|
||||
<div class="${hostClass}"><div class="session-tabs">
|
||||
<div class="session-tab${active ? ' active' : ''}" tabindex="0">
|
||||
<span class="tab-info"><span class="tab-name-row"><span class="tab-name">session</span>
|
||||
<span class="tab-actions"><button class="tab-more" type="button">…</button></span>
|
||||
</span></span>
|
||||
</div>
|
||||
</div></div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
async function installActionMenuController(page: Page) {
|
||||
await page.addScriptTag({ content: 'class CodemanApp {}; window.CodemanApp = CodemanApp;' });
|
||||
await page.addScriptTag({ content: controller });
|
||||
await page.evaluate(() => {
|
||||
const app = new (window as any).CodemanApp();
|
||||
app.loadAppSettingsFromStorage = () => ({ showTabDetachButton: false });
|
||||
app.openSessionOptions = () => undefined;
|
||||
app.requestCloseSession = () => undefined;
|
||||
document
|
||||
.querySelector('.tab-more')
|
||||
?.addEventListener('click', (event) => app.openTabRailActionMenu(event, 'session-1'));
|
||||
(window as any).app = app;
|
||||
});
|
||||
}
|
||||
|
||||
async function actionState(page: Page) {
|
||||
return page.locator('.tab-more').evaluate((node) => {
|
||||
const style = getComputedStyle(node);
|
||||
return { visibility: style.visibility, pointerEvents: style.pointerEvents };
|
||||
});
|
||||
}
|
||||
|
||||
describe('expanded vertical session actions in Chromium', () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
for (const surface of ['sidebar', 'rail'] as const) {
|
||||
it(`${surface} hides inactive actions, reveals them contextually, and opens the menu`, async () => {
|
||||
await page.setContent(fixture(surface));
|
||||
await installActionMenuController(page);
|
||||
await page.mouse.move(1200, 760);
|
||||
const tab = page.locator('.session-tab');
|
||||
expect(await actionState(page)).toEqual({ visibility: 'hidden', pointerEvents: 'none' });
|
||||
await tab.hover();
|
||||
expect(await actionState(page)).toEqual({ visibility: 'visible', pointerEvents: 'auto' });
|
||||
await page.locator('.tab-more').click();
|
||||
expect(await page.locator('.tab-rail-action-menu').count()).toBe(1);
|
||||
|
||||
await page.setContent(fixture(surface));
|
||||
await installActionMenuController(page);
|
||||
await page.locator('.session-tab').focus();
|
||||
expect(await actionState(page)).toEqual({ visibility: 'visible', pointerEvents: 'auto' });
|
||||
|
||||
await page.setContent(fixture(surface, true));
|
||||
await installActionMenuController(page);
|
||||
expect(await actionState(page)).toEqual({ visibility: 'visible', pointerEvents: 'auto' });
|
||||
});
|
||||
}
|
||||
|
||||
it('keeps nested actions reachable for a coarse pointer', async () => {
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, hasTouch: true });
|
||||
try {
|
||||
const touchPage = await context.newPage();
|
||||
for (const surface of ['sidebar', 'rail'] as const) {
|
||||
await touchPage.setContent(fixture(surface));
|
||||
expect(await actionState(touchPage)).toEqual({ visibility: 'visible', pointerEvents: 'auto' });
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('closes the real controller menu when viewport geometry changes', async () => {
|
||||
await page.setContent(fixture('rail', true));
|
||||
await installActionMenuController(page);
|
||||
await page.locator('.tab-more').click();
|
||||
expect(await page.locator('.tab-rail-action-menu').count()).toBe(1);
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('resize')));
|
||||
expect(await page.locator('.tab-rail-action-menu').count()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/** Structural and schema coverage for vertical session navigation density and actions. */
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||
|
||||
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const app = readFileSync(resolve(publicDir, 'app.js'), 'utf8');
|
||||
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
|
||||
const settingsUi = readFileSync(resolve(publicDir, 'settings-ui.js'), 'utf8');
|
||||
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
|
||||
const i18n = readFileSync(resolve(publicDir, 'i18n.js'), 'utf8');
|
||||
const railController = readFileSync(resolve(publicDir, 'tab-rail-resize.js'), 'utf8');
|
||||
|
||||
describe('vertical session navigation UX contract', () => {
|
||||
it('accepts only integer session-name sizes from 11 through 18 pixels', () => {
|
||||
for (const value of [11, 14, 18]) {
|
||||
expect(SettingsUpdateSchema.safeParse({ sessionSidebarFontSize: value }).success).toBe(true);
|
||||
}
|
||||
for (const value of [10, 19, 14.5, '14']) {
|
||||
expect(SettingsUpdateSchema.safeParse({ sessionSidebarFontSize: value }).success).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('renders one existing action cluster through a shared placement resolver', () => {
|
||||
expect(app).toContain('shouldInlineSessionActions()');
|
||||
expect(app).toContain('const inlineSessionActions = this.shouldInlineSessionActions();');
|
||||
expect(app).toContain('const tabActionsHtml =');
|
||||
expect(app).toContain("${inlineSessionActions ? tabActionsHtml : ''}");
|
||||
expect(app).toContain("${inlineSessionActions ? '' : tabActionsHtml}");
|
||||
expect(app.match(/class="tab-actions"/g)).toHaveLength(1);
|
||||
expect(app).toContain("tab.querySelector(':scope > .tab-actions')");
|
||||
});
|
||||
|
||||
it('limits inline actions to expanded sidebar and expanded non-compact rail', () => {
|
||||
expect(app).toMatch(/isSessionSidebarActive\(\)[\s\S]{0,100}!this\.isSessionSidebarCollapsed\(\)/);
|
||||
expect(app).toMatch(/_tabOrientation\(\) === 'vertical'[\s\S]{0,120}tab-rail-compact/);
|
||||
expect(styles).toContain("html[data-session-list='sidebar'][data-sidebar='expanded']");
|
||||
expect(styles).toContain("html[data-tab-orientation='vertical']:not(.tab-rail-compact)");
|
||||
});
|
||||
|
||||
it('opens only the existing session actions from the overflow trigger', () => {
|
||||
expect(railController).toContain('openTabRailActionMenu(event, sessionId)');
|
||||
expect(railController).toContain("label: 'Session options'");
|
||||
expect(railController).toContain("label: 'Open in a new window'");
|
||||
expect(railController).toContain("label: 'Close session'");
|
||||
expect(railController).not.toContain('Move to group');
|
||||
});
|
||||
|
||||
it('wires the name-only size through first paint, settings, defaults, and both vertical surfaces', () => {
|
||||
expect(html).toMatch(/id="appSettingsSessionSidebarFontSize"[^>]*min="11"[^>]*max="18"[^>]*step="1"/);
|
||||
expect(html).toContain('aria-labelledby="appSettingsSessionSidebarFontSizeLabel"');
|
||||
expect(html).toContain('--session-sidebar-name-font-size');
|
||||
expect(settingsUi).toContain('sessionSidebarFontSize: this.resolveSessionSidebarFontSize(');
|
||||
expect(settingsUi).toContain('sessionSidebarFontSize: 14,');
|
||||
expect(settingsUi).toContain("'sessionSidebarFontSize'");
|
||||
expect(app).toContain('resolveSessionSidebarFontSize(value)');
|
||||
expect(app).toContain('applySessionSidebarFontSize(settings = null)');
|
||||
expect(styles).toMatch(
|
||||
/\.session-sidebar \.tab-name[^}]*font-size: var\(--session-sidebar-name-font-size, 14px\)/s
|
||||
);
|
||||
expect(styles).toMatch(
|
||||
/\.tab-rail \.session-tab \.tab-name[^}]*font-size: var\(--session-sidebar-name-font-size, 14px\)/s
|
||||
);
|
||||
});
|
||||
|
||||
it('labels and translates the name-only scope', () => {
|
||||
expect(html).toContain('Session Name Font Size');
|
||||
expect(html).toContain('Adjust only session names in the vertical sidebar.');
|
||||
expect(i18n).toContain("'Session Name Font Size':");
|
||||
expect(i18n).toContain("'Adjust only session names in the vertical sidebar.':");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/** @fileoverview COD-358 vertical session rail orientation policy and wiring. */
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const read = (path: string) => readFileSync(resolve(import.meta.dirname, '..', path), 'utf8');
|
||||
const indexHtml = read('src/web/public/index.html');
|
||||
const stylesCss = read('src/web/public/styles.css');
|
||||
const settingsJs = read('src/web/public/settings-ui.js');
|
||||
const appJs = read('src/web/public/app.js');
|
||||
const schemasTs = read('src/web/schemas.ts');
|
||||
|
||||
function loadPolicy() {
|
||||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||
vm.runInContext(read('src/web/public/constants.js'), context, { filename: 'constants.js' });
|
||||
return (
|
||||
context.window as {
|
||||
CodemanTabOverflow: { resolveTabOrientation: (input: unknown) => string };
|
||||
}
|
||||
).CodemanTabOverflow;
|
||||
}
|
||||
|
||||
describe('vertical tab orientation policy', () => {
|
||||
it('honors vertical on desktop and tablet but forces phones horizontal', () => {
|
||||
const policy = loadPolicy();
|
||||
expect(policy.resolveTabOrientation({ deviceType: 'desktop', setting: 'vertical' })).toBe('vertical');
|
||||
expect(policy.resolveTabOrientation({ deviceType: 'tablet', setting: 'vertical' })).toBe('vertical');
|
||||
expect(policy.resolveTabOrientation({ deviceType: 'mobile', setting: 'vertical' })).toBe('horizontal');
|
||||
});
|
||||
|
||||
it('fails closed to horizontal for absent and unknown values', () => {
|
||||
const policy = loadPolicy();
|
||||
expect(policy.resolveTabOrientation(undefined)).toBe('horizontal');
|
||||
expect(policy.resolveTabOrientation({})).toBe('horizontal');
|
||||
expect(policy.resolveTabOrientation({ deviceType: 'desktop', setting: 'sideways' })).toBe('horizontal');
|
||||
});
|
||||
});
|
||||
|
||||
describe('vertical tab rail wiring', () => {
|
||||
it('ships one rail before the terminal and one orientation control', () => {
|
||||
expect(indexHtml).toContain('id="tabRail"');
|
||||
expect(indexHtml.indexOf('id="tabRail"')).toBeLessThan(indexHtml.indexOf('id="terminalContainer"'));
|
||||
expect(indexHtml).toContain('id="appSettingsTabOrientation"');
|
||||
expect(indexHtml).toContain('<option value="horizontal">');
|
||||
expect(indexHtml).toContain('<option value="vertical">');
|
||||
});
|
||||
|
||||
it('moves the existing list between the rail and its header host', () => {
|
||||
expect(settingsJs).toMatch(/applyTabOrientation\(options = \{\}\)/);
|
||||
expect(settingsJs).toContain('rail.appendChild(tabsEl)');
|
||||
expect(settingsJs).toContain('headerHost.appendChild(tabsEl)');
|
||||
expect(settingsJs).toContain("tabsEl.setAttribute('aria-orientation'");
|
||||
});
|
||||
|
||||
it('applies orientation before horizontal wrapping and suppresses wrap vertically', () => {
|
||||
expect(appJs.indexOf('this.applyTabOrientation()')).toBeLessThan(appJs.indexOf('this.applyTabWrapSettings()'));
|
||||
const start = appJs.indexOf('updateTabOverflowMode() {');
|
||||
const block = appJs.slice(start, start + 3500);
|
||||
expect(block).toContain('resolveTabOrientation');
|
||||
expect(block.indexOf('resolveTabOrientation')).toBeLessThan(block.indexOf('shouldAutoWrapTabs'));
|
||||
});
|
||||
|
||||
it('round-trips a device-scoped strict setting and has vertical styles', () => {
|
||||
expect(settingsJs).toMatch(/tabOrientation:\s*'horizontal'/);
|
||||
expect(settingsJs).toMatch(/tabOrientation:\s*document\.getElementById\('appSettingsTabOrientation'\)\.value/);
|
||||
const displayKeys = settingsJs.slice(settingsJs.indexOf('const displayKeys = new Set(['));
|
||||
expect(displayKeys.slice(0, 1800)).toContain("'tabOrientation'");
|
||||
expect(schemasTs).toMatch(/tabOrientation:\s*z\.enum\(\['horizontal',\s*'vertical'\]\)\.optional\(\)/);
|
||||
expect(stylesCss).toContain("html[data-tab-orientation='vertical'] .tab-rail");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/** @fileoverview Real Chromium coverage for COD-358 rail resize and label geometry. */
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
|
||||
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
|
||||
const constants = readFileSync(resolve(publicDir, 'constants.js'), 'utf8');
|
||||
const settingsUi = readFileSync(resolve(publicDir, 'settings-ui.js'), 'utf8');
|
||||
const controller = readFileSync(resolve(publicDir, 'tab-rail-resize.js'), 'utf8');
|
||||
|
||||
describe('COD-358 vertical rail in Chromium', () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
await page.setContent(`<!doctype html><html data-tab-orientation="vertical"><head><style>${styles}</style></head>
|
||||
<body><header class="header"><div id="sessionTabsHost"></div></header><main class="main" style="width:100%;height:700px">
|
||||
<aside class="tab-rail" id="tabRail"><div class="session-tabs" id="sessionTabs"><div class="session-tab active">
|
||||
<span class="tab-info"><span class="tab-name-row"><span class="tab-name"><span class="tab-name-prefix">w2-codeman: </span>super-long-unbroken-session-name-for-wrapping</span></span></span>
|
||||
</div></div><div id="tabRailResizeHandle" class="tab-rail-resize-handle" role="separator" tabindex="0" aria-valuemin="208" aria-valuemax="360" aria-valuenow="256"></div></aside>
|
||||
<div class="terminal-wrap"><iframe title="terminal"></iframe></div></main>
|
||||
<div id="tabRailResizeShield" class="tab-rail-resize-shield" hidden></div>
|
||||
<select id="appSettingsTabRailWidth"><option value="custom">Custom</option><option value="208">Compact</option><option value="256">Default</option><option value="320">Wide</option><option value="360">Maximum</option></select>
|
||||
</body></html>`);
|
||||
await page.addScriptTag({ content: constants });
|
||||
await page.addScriptTag({ content: 'class CodemanApp {}; window.CodemanApp = CodemanApp;' });
|
||||
await page.addScriptTag({ content: settingsUi });
|
||||
await page.addScriptTag({ content: controller });
|
||||
await page.evaluate(() => {
|
||||
const app = new (window as any).CodemanApp();
|
||||
const settings = { tabOrientation: 'vertical', tabRailWidth: 256 };
|
||||
(window as any).__settings = settings;
|
||||
(window as any).__deviceType = 'desktop';
|
||||
(window as any).MobileDetection = { getDeviceType: () => (window as any).__deviceType };
|
||||
app.activeSessionId = 'session-1';
|
||||
app.loadAppSettingsFromStorage = () => settings;
|
||||
app.saveAppSettingsToStorage = (next: Record<string, unknown>) => Object.assign(settings, next);
|
||||
app.getDefaultSettings = () => ({});
|
||||
app.isSessionSidebarActive = () => false;
|
||||
app.updateTabOverflowMode = () => undefined;
|
||||
app._fullRenderSessionTabs = () => undefined;
|
||||
app._updateConnectionLinesImmediate = () => undefined;
|
||||
app.fitAddon = { proposeDimensions: () => ({ cols: 80, rows: 24 }), fit: () => undefined };
|
||||
app.sendResize = async () => ((window as any).__resizeCount = ((window as any).__resizeCount || 0) + 1);
|
||||
app.initTabRailResize();
|
||||
(window as any).app = app;
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => browser.close());
|
||||
|
||||
it('shows the complete label while clamping it to two lines', async () => {
|
||||
const metrics = await page.locator('.tab-name').evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
text: element.textContent,
|
||||
clamp: style.getPropertyValue('-webkit-line-clamp'),
|
||||
whiteSpace: style.whiteSpace,
|
||||
overflowWrap: style.overflowWrap,
|
||||
height: element.getBoundingClientRect().height,
|
||||
lineHeight: Number.parseFloat(style.lineHeight),
|
||||
};
|
||||
});
|
||||
expect(metrics.text).toBe('w2-codeman: super-long-unbroken-session-name-for-wrapping');
|
||||
expect(metrics.clamp).toBe('2');
|
||||
expect(metrics.whiteSpace).toBe('normal');
|
||||
expect(metrics.overflowWrap).toBe('anywhere');
|
||||
expect(metrics.height).toBeLessThanOrEqual(metrics.lineHeight * 2 + 1);
|
||||
});
|
||||
|
||||
it('resizes from the keyboard and settles once', async () => {
|
||||
const handle = page.locator('#tabRailResizeHandle');
|
||||
await handle.focus();
|
||||
await handle.press('ArrowRight');
|
||||
expect(await handle.getAttribute('aria-valuenow')).toBe('264');
|
||||
await page.waitForTimeout(180);
|
||||
expect(await page.evaluate(() => (window as any).__resizeCount)).toBe(1);
|
||||
});
|
||||
|
||||
it('preserves 360px while a narrow viewport applies only an effective clamp', async () => {
|
||||
await page.setViewportSize({ width: 700, height: 800 });
|
||||
const narrow = await page.evaluate(() => {
|
||||
(window as any).__settings.tabRailWidth = 360;
|
||||
return { effective: (window as any).app.applyTabRailWidth(), preferred: (window as any).__settings.tabRailWidth };
|
||||
});
|
||||
expect(narrow).toEqual({ effective: 280, preferred: 360 });
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
expect(await page.evaluate(() => (window as any).app.applyTabRailWidth())).toBe(360);
|
||||
});
|
||||
|
||||
it('reapplies phone fallback in both directions without changing preferences', async () => {
|
||||
await page.evaluate(() => {
|
||||
(window as any).__settings.tabRailWidth = 360;
|
||||
(window as any).__deviceType = 'mobile';
|
||||
});
|
||||
await page.setViewportSize({ width: 390, height: 800 });
|
||||
await page.waitForTimeout(130);
|
||||
expect(await page.locator('#sessionTabs').evaluate((node) => node.parentElement?.id)).toBe('sessionTabsHost');
|
||||
expect(await page.evaluate(() => (window as any).__settings)).toEqual({
|
||||
tabOrientation: 'vertical',
|
||||
tabRailWidth: 360,
|
||||
});
|
||||
|
||||
await page.evaluate(() => ((window as any).__deviceType = 'desktop'));
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.waitForTimeout(130);
|
||||
expect(await page.locator('#sessionTabs').evaluate((node) => node.parentElement?.id)).toBe('tabRail');
|
||||
expect(await page.evaluate(() => (window as any).app._getCurrentTabRailWidth())).toBe(360);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
/** @fileoverview COD-358 resizable vertical session rail policy and wiring. */
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const readPublic = (name: string) => readFileSync(resolve(publicDir, name), 'utf8');
|
||||
const indexHtml = readPublic('index.html');
|
||||
const stylesCss = readPublic('styles.css');
|
||||
const settingsJs = readPublic('settings-ui.js');
|
||||
const appJs = readPublic('app.js');
|
||||
const terminalUiJs = readPublic('terminal-ui.js');
|
||||
const buildJs = readFileSync(resolve(import.meta.dirname, '../scripts/build.mjs'), 'utf8');
|
||||
|
||||
type RailPolicy = {
|
||||
DEFAULT_WIDTH: number;
|
||||
MIN_WIDTH: number;
|
||||
MAX_WIDTH: number;
|
||||
resolveWidth: (input?: Record<string, unknown>) => number;
|
||||
resolveKeyboardWidth: (input: Record<string, unknown>) => number | null;
|
||||
};
|
||||
|
||||
function loadRailPolicy(): RailPolicy {
|
||||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||
vm.runInContext(readPublic('constants.js'), context, { filename: 'constants.js' });
|
||||
return (context.window as { CodemanTabRail: RailPolicy }).CodemanTabRail;
|
||||
}
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('tab rail width policy', () => {
|
||||
it('uses fixed storage bounds and separate viewport/terminal clamps', () => {
|
||||
const policy = loadRailPolicy();
|
||||
expect(policy.DEFAULT_WIDTH).toBe(256);
|
||||
expect(policy.MIN_WIDTH).toBe(208);
|
||||
expect(policy.MAX_WIDTH).toBe(360);
|
||||
expect(policy.resolveWidth()).toBe(256);
|
||||
expect(policy.resolveWidth({ width: 120 })).toBe(208);
|
||||
expect(policy.resolveWidth({ width: 900 })).toBe(360);
|
||||
expect(policy.resolveWidth({ width: 360, viewportWidth: 700 })).toBe(280);
|
||||
expect(policy.resolveWidth({ width: 360 })).toBe(360);
|
||||
expect(policy.resolveWidth({ width: 360, mainWidth: 700, minTerminalWidth: 400 })).toBe(300);
|
||||
});
|
||||
|
||||
it('supports accessible keyboard steps and reset keys', () => {
|
||||
const policy = loadRailPolicy();
|
||||
const base = { currentWidth: 256, viewportWidth: 1600, mainWidth: 1200, minTerminalWidth: 400 };
|
||||
expect(policy.resolveKeyboardWidth({ ...base, key: 'ArrowLeft' })).toBe(248);
|
||||
expect(policy.resolveKeyboardWidth({ ...base, key: 'ArrowRight' })).toBe(264);
|
||||
expect(policy.resolveKeyboardWidth({ ...base, key: 'ArrowRight', shiftKey: true })).toBe(288);
|
||||
expect(policy.resolveKeyboardWidth({ ...base, key: 'Home' })).toBe(208);
|
||||
expect(policy.resolveKeyboardWidth({ ...base, key: 'End' })).toBe(360);
|
||||
expect(policy.resolveKeyboardWidth({ ...base, key: 'Enter' })).toBe(256);
|
||||
expect(policy.resolveKeyboardWidth({ ...base, key: 'Escape' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab rail resize wiring', () => {
|
||||
it('ships an accessible handle and width presets', () => {
|
||||
expect(indexHtml).toContain('id="tabRailResizeHandle"');
|
||||
expect(indexHtml).toContain('role="separator"');
|
||||
expect(indexHtml).toContain('aria-orientation="vertical"');
|
||||
expect(indexHtml).toContain('aria-valuemin="208"');
|
||||
expect(indexHtml).toContain('aria-valuemax="360"');
|
||||
expect(indexHtml).toContain('id="appSettingsTabRailWidth"');
|
||||
expect(indexHtml).toContain('<option value="208">Compact');
|
||||
expect(indexHtml).toContain('<option value="256">Default');
|
||||
expect(indexHtml).toContain('<option value="320">Wide');
|
||||
expect(indexHtml).toContain('<option value="360">Maximum');
|
||||
});
|
||||
|
||||
it('keeps preferred width device-local and distinct from effective width', () => {
|
||||
expect(settingsJs).toMatch(/tabRailWidth:\s*256/);
|
||||
expect(settingsJs).toMatch(/tabRailWidth:\s*this\.readTabRailWidthSetting/);
|
||||
const displayKeys = settingsJs.slice(settingsJs.indexOf('const displayKeys = new Set(['));
|
||||
expect(displayKeys.slice(0, 1900)).toContain("'tabRailWidth'");
|
||||
const controller = readPublic('tab-rail-resize.js');
|
||||
expect(controller).toContain('_scheduleTabRailSettle(resolved, preferred)');
|
||||
expect(controller).toContain('_scheduleTabRailSettle(effective, preferred)');
|
||||
expect(controller).toContain('applyTabOrientation?.()');
|
||||
expect(controller).toContain('applyTabRailWidth({ persist: false })');
|
||||
});
|
||||
|
||||
it('installs the controller before terminal creation and owns generic observer churn', () => {
|
||||
const controller = readPublic('tab-rail-resize.js');
|
||||
expect(indexHtml).toContain('<script defer src="tab-rail-resize.js"></script>');
|
||||
expect(appJs).toContain('this.initTabRailResize?.()');
|
||||
expect(controller).toContain('setPointerCapture');
|
||||
expect(controller).toContain("addEventListener('lostpointercapture'");
|
||||
expect(controller).toContain('_tabRailResizeWatchdog');
|
||||
expect(terminalUiJs).toMatch(/if \(this\._tabRailResizeOwnsObserver\) return/);
|
||||
expect(buildJs).toContain("run('minify tab-rail-resize.js'");
|
||||
expect(buildJs).toMatch(/HASHABLE[\s\S]{0,1000}'tab-rail-resize\.js'/);
|
||||
});
|
||||
|
||||
it('coalesces effective changes while persisting the preferred width', async () => {
|
||||
vi.useFakeTimers();
|
||||
const controller = readPublic('tab-rail-resize.js');
|
||||
class FakeCodemanApp {}
|
||||
const context = vm.createContext({
|
||||
CodemanApp: FakeCodemanApp,
|
||||
window: {},
|
||||
document: { querySelector: () => null },
|
||||
console,
|
||||
clearTimeout,
|
||||
setTimeout,
|
||||
});
|
||||
vm.runInContext(controller, context, { filename: 'tab-rail-resize.js' });
|
||||
const app = new FakeCodemanApp() as FakeCodemanApp & Record<string, any>;
|
||||
app.activeSessionId = 'session-1';
|
||||
app.sendResize = vi.fn(async () => true);
|
||||
app._persistTabRailWidth = vi.fn();
|
||||
app._tabRailResizeOwnsObserver = true;
|
||||
|
||||
app._scheduleTabRailSettle(280, 360);
|
||||
app._scheduleTabRailSettle(300, 360);
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
expect(app._persistTabRailWidth).toHaveBeenCalledOnce();
|
||||
expect(app._persistTabRailWidth).toHaveBeenCalledWith(360);
|
||||
expect(app.sendResize).toHaveBeenCalledOnce();
|
||||
expect(app._tabRailResizeOwnsObserver).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps resize-observer ownership for pointer drags longer than the watchdog', async () => {
|
||||
vi.useFakeTimers();
|
||||
const controller = readPublic('tab-rail-resize.js');
|
||||
class FakeCodemanApp {}
|
||||
let resizing = true;
|
||||
const context = vm.createContext({
|
||||
CodemanApp: FakeCodemanApp,
|
||||
window: {},
|
||||
document: {
|
||||
body: { classList: { contains: (name: string) => name === 'tab-rail-resizing' && resizing } },
|
||||
querySelector: () => null,
|
||||
},
|
||||
console,
|
||||
clearTimeout,
|
||||
setTimeout,
|
||||
});
|
||||
vm.runInContext(controller, context, { filename: 'tab-rail-resize.js' });
|
||||
const app = new FakeCodemanApp() as FakeCodemanApp & Record<string, any>;
|
||||
|
||||
app._claimTabRailResize();
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
expect(app._tabRailResizeOwnsObserver).toBe(true);
|
||||
|
||||
resizing = false;
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(app._tabRailResizeOwnsObserver).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a viewport-clamped effective width out of the preferred-width control', () => {
|
||||
const controller = readPublic('tab-rail-resize.js');
|
||||
class FakeCodemanApp {}
|
||||
const values = new Map<string, string>();
|
||||
const rootClasses = new Set<string>();
|
||||
const custom = { textContent: 'Custom' };
|
||||
const select = {
|
||||
value: '360',
|
||||
dataset: { currentWidth: '360' },
|
||||
querySelector(selector: string) {
|
||||
if (selector === 'option[value="360"]') return {};
|
||||
if (selector === 'option[value="custom"]') return custom;
|
||||
return null;
|
||||
},
|
||||
};
|
||||
const handle = { setAttribute: vi.fn() };
|
||||
const document = {
|
||||
documentElement: {
|
||||
style: {
|
||||
getPropertyValue: (name: string) => values.get(name) ?? '',
|
||||
setProperty: (name: string, value: string) => values.set(name, value),
|
||||
},
|
||||
classList: {
|
||||
contains: (name: string) => rootClasses.has(name),
|
||||
toggle: (name: string, force: boolean) => (force ? rootClasses.add(name) : rootClasses.delete(name)),
|
||||
},
|
||||
},
|
||||
querySelector: (selector: string) => (selector === '.main' ? { clientWidth: 700 } : null),
|
||||
getElementById: (id: string) => {
|
||||
if (id === 'appSettingsTabRailWidth') return select;
|
||||
if (id === 'tabRailResizeHandle') return handle;
|
||||
return null;
|
||||
},
|
||||
};
|
||||
const context = vm.createContext({
|
||||
CodemanApp: FakeCodemanApp,
|
||||
window: { innerWidth: 700 },
|
||||
document,
|
||||
console,
|
||||
clearTimeout,
|
||||
setTimeout,
|
||||
});
|
||||
vm.runInContext(readPublic('constants.js'), context, { filename: 'constants.js' });
|
||||
vm.runInContext(controller, context, { filename: 'tab-rail-resize.js' });
|
||||
const app = new FakeCodemanApp() as FakeCodemanApp & Record<string, any>;
|
||||
app.loadAppSettingsFromStorage = () => ({ tabRailWidth: 360 });
|
||||
app.saveAppSettingsToStorage = vi.fn();
|
||||
|
||||
expect(app.applyTabRailWidth({ persist: false })).toBe(280);
|
||||
expect(values.get('--tab-rail-width')).toBe('280px');
|
||||
expect(select.value).toBe('360');
|
||||
expect(select.dataset.currentWidth).toBe('360');
|
||||
expect(app.readTabRailWidthSetting()).toBe(360);
|
||||
});
|
||||
|
||||
it('retains complete labels and clamps only the settled vertical presentation', () => {
|
||||
expect(appJs).toContain('class="tab-name-prefix"');
|
||||
const start = stylesCss.indexOf("html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name");
|
||||
const rule = stylesCss.slice(start, start + 800);
|
||||
expect(rule).toContain('-webkit-line-clamp: 2');
|
||||
expect(rule).toContain('overflow-wrap: anywhere');
|
||||
expect(rule).toContain('white-space: normal');
|
||||
});
|
||||
|
||||
it('closes detached rail menus before tab rebuilds and session cleanup', () => {
|
||||
const fullRenderStart = appJs.indexOf(' _fullRenderSessionTabs() {');
|
||||
const fullRender = appJs.slice(fullRenderStart, fullRenderStart + 250);
|
||||
const cleanupStart = appJs.indexOf(' _cleanupSessionData(sessionId) {');
|
||||
const cleanup = appJs.slice(cleanupStart, cleanupStart + 250);
|
||||
expect(fullRender).toContain('this.closeTabRailActionMenu?.()');
|
||||
expect(cleanup).toContain('this.closeTabRailActionMenu?.()');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user