Files
Codeman/test/header-stats-style.test.ts
T
Codeman maintainer 96f89718a7 feat(tabs,header): group tabs by state and add header stats styles (#426)
Two directions from Discussion #426, each a per-device setting and the
new default.

Tab Grouping (tabGrouping: 'state' | 'none', default 'state', option C):
tabs are grouped needs you (red, plus failed sessions), waiting (yellow),
working and idle (also ended, exited panes and web tabs), most urgent on
top. The desktop header strip draws a row per group with its label and
count in a left gutter; the flat vertical rail and the sidebar draw a
section per group; tablets keep their scrolling row with inline dividers;
phones keep the chip row in group order without headings. Classification
is the home screens' own (_mobileOverviewState/_mobileOverviewExit), the
fold into four groups is pure in CodemanTabTriage (constants.js). It is
flex `order` plus aria-hidden heading/break elements reconciled in place
after both render paths, never a DOM reorder, so Alt+N, the keyboard walk
and drag keep reading tab order; a drop is refused across groups. Named
groups in the vertical rail take precedence.

Header Stats Style (headerStatsStyle: 'classic' | 'compact' | 'tiles',
default 'tiles', option G): tiles give WS, CPU, MEM and each plan window
a label-over-value tile with a bar underneath; compact is one WS/CPU/MEM
pill with sparklines plus a plan-ring pill; classic is the header as
before. Desktop only (classic below 768px and in solo windows). The
clustered styles move #connectionIndicator into #headerSystemStats and
#planUsageChip after it, and classic moves them back to comment anchors;
WS stays out of a hidden System Stats pill. The extra parts are always
rendered and hidden by default in CSS, so classic is unchanged.

Tests: test/tab-triage.test.ts, test/header-stats-style.test.ts; three
source pins in test/tab-rail-order.test.ts follow renamed lines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 01:57:25 +02:00

220 lines
10 KiB
TypeScript

/**
* @fileoverview The header-stats styles (`headerStatsStyle`, Discussion #426
* option G): 'classic' (as before), 'compact' (one system pill with sparklines
* plus a plan-ring pill) and 'tiles' (label over value, bar underneath; the
* default).
*
* Pinned here:
* - The default is 'tiles', and only the three known values are honoured.
* - The two clustered styles move the connection indicator INTO the system
* stats pill and the plan chip right after it, on the REAL header markup
* from index.html, and 'classic' puts both back exactly where the template
* had them.
* - The WS readout never disappears with a hidden System Stats pill.
* - The parts only the new styles draw (sparklines, rings, meters, tile words)
* are rendered with sane values and hidden by default in CSS, which is what
* keeps 'classic' looking exactly as before.
*
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only').
*
* Port: none.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { JSDOM } from 'jsdom';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
const PUBLIC = join(process.cwd(), 'src/web/public');
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
const INDEX = read('index.html');
const HEADER = INDEX.slice(INDEX.indexOf('<header class="header">'), INDEX.indexOf('</header>') + '</header>'.length);
let CodemanApp: { prototype: Record<string, any> };
let window: any;
let document: Document;
beforeAll(async () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://localhost/',
runScripts: 'outside-only',
});
if (dom.window.document.readyState !== 'complete') {
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
}
window = dom.window;
document = window.document;
window.setInterval = () => 0;
window.requestAnimationFrame = () => 0;
window.eval(
'var MobileDetection = { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => "desktop" }, ' +
'KeyboardHandler = {}, SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
read('constants.js') +
'\n' +
read('app.js') +
'\n' +
read('settings-ui.js') +
'\n' +
read('panels-ui.js') +
'\n;window.__HeaderStatsCodemanApp = CodemanApp;'
);
CodemanApp = window.__HeaderStatsCodemanApp;
});
function makeApp() {
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
app.$ = (id: string) => document.getElementById(id);
return app;
}
/** Element children of the header's right side, by id (or first class). */
const rightSide = () =>
[...document.getElementById('headerRight')!.children].map((el) => el.id || el.classList[0]);
const statsChildren = () =>
[...document.getElementById('headerSystemStats')!.children].map((el) => el.id || el.classList[0]);
beforeEach(() => {
document.body.innerHTML = HEADER;
delete document.documentElement.dataset.headerStats;
});
describe('resolveHeaderStatsStyle', () => {
it("defaults to 'tiles' and honours only the three known styles", () => {
const app = makeApp();
expect(app.resolveHeaderStatsStyle({})).toBe('tiles');
expect(app.resolveHeaderStatsStyle(undefined)).toBe('tiles');
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'classic' })).toBe('classic');
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'compact' })).toBe('compact');
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'tiles' })).toBe('tiles');
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'neon' })).toBe('tiles');
});
});
describe('applyHeaderStatsStyle', () => {
it('clusters WS, the system stats and the plan chip for tiles and compact', () => {
for (const style of ['tiles', 'compact']) {
document.body.innerHTML = HEADER;
const app = makeApp();
app.applyHeaderStatsStyle({ style, showSystemStats: true });
expect(document.documentElement.dataset.headerStats).toBe(style);
expect(statsChildren()[0]).toBe('connectionIndicator');
const right = rightSide();
expect(right[right.indexOf('headerSystemStats') + 1]).toBe('planUsageChip');
expect(right).not.toContain('connectionIndicator');
}
});
it("'classic' puts both back exactly where the template had them", () => {
const template = rightSide();
const app = makeApp();
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: true });
app.applyHeaderStatsStyle({ style: 'compact', showSystemStats: true });
app.applyHeaderStatsStyle({ style: 'classic', showSystemStats: true });
expect(rightSide()).toEqual(template);
expect(statsChildren()).not.toContain('connectionIndicator');
expect(document.documentElement.dataset.headerStats).toBe('classic');
});
it('keeps WS out of a hidden System Stats pill', () => {
const template = rightSide();
const app = makeApp();
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: true });
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: false });
expect(statsChildren()).not.toContain('connectionIndicator');
expect(rightSide().indexOf('connectionIndicator')).toBe(template.indexOf('connectionIndicator'));
});
it('is idempotent: re-applying the same style moves nothing', () => {
const app = makeApp();
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: true });
const once = document.getElementById('headerRight')!.innerHTML;
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: true });
expect(document.getElementById('headerRight')!.innerHTML).toBe(once);
});
});
describe('the parts the new styles draw', () => {
it('gives every plan window a ring and a meter with a clamped fill, the label the real number', () => {
const app = makeApp();
app.updatePlanUsageChip({ fiveHour: { usedPercentage: 28 }, sevenDay: { usedPercentage: 104 } });
const wins = [...document.querySelectorAll<HTMLElement>('#planUsageChip .pu-win')];
expect(wins).toHaveLength(2);
expect(wins[0].querySelector<HTMLElement>('.pu-ring')!.style.getPropertyValue('--pu')).toBe('28');
expect(wins[0].querySelector<HTMLElement>('.pu-meter > i')!.style.width).toBe('28%');
expect(wins[0].querySelector('.pu-ring')!.classList.contains('pu-green')).toBe(true);
expect(wins[1].querySelector('.pu-val')!.textContent).toBe('104%');
expect(wins[1].querySelector<HTMLElement>('.pu-meter > i')!.style.width).toBe('100%');
expect(wins[1].querySelector('.pu-meter')!.classList.contains('pu-red')).toBe(true);
});
it('keeps an idle Claude window as a dimmed slot with an empty ring and meter', () => {
const app = makeApp();
app.updatePlanUsageChip({ sevenDay: { usedPercentage: 35 } });
const idle = document.querySelector<HTMLElement>('#planUsageChip .pu-win-idle')!;
expect(idle.querySelector('.pu-val')!.textContent).toBe('—');
expect(idle.querySelector<HTMLElement>('.pu-ring')!.style.getPropertyValue('--pu')).toBe('0');
expect(idle.querySelector<HTMLElement>('.pu-meter > i')!.style.width).toBe('0%');
});
it('names the connection state in two short words per dot state', () => {
const app = makeApp();
const words = (dotClass: string, text = '') => app._connectionTileWords({ dotClass, text });
expect(words('connection-dot connected', 'WS')).toMatchObject({ label: 'WS', value: 'live', state: 'connected' });
expect(words('connection-dot fallback', 'HTTP')).toMatchObject({ label: 'HTTP', value: 'fallback' });
expect(words('connection-dot offline', 'Offline')).toMatchObject({ label: 'NET', value: 'offline' });
expect(words('connection-dot draining', 'Sending...')).toMatchObject({ label: 'SEND', value: 'queued' });
expect(words('connection-dot reconnecting', 'WS…')).toMatchObject({ label: 'WS', value: 'retry' });
expect(words('connection-dot reconnecting', 'Reconnecting...')).toMatchObject({ label: 'SSE', value: 'retry' });
});
it('writes the tile words beside the classic text, which stays as it was', () => {
const app = makeApp();
app._computeConnectionDescriptor = () => ({
display: 'flex',
dotClass: 'connection-dot connected',
text: 'WS · 2.0KB queued',
title: 'Terminal connected over WebSocket',
});
app._updateConnectionIndicator();
expect(document.getElementById('connectionText')!.textContent).toBe('WS · 2.0KB queued');
expect(document.getElementById('connectionTileLabel')!.textContent).toBe('WS');
expect(document.getElementById('connectionTileValue')!.textContent).toBe('live');
expect(document.getElementById('connectionTileValue')!.className).toBe('connection-tile-value connected');
});
it('draws the sparkline newest-right, with a 10% floor and only real samples', () => {
const app = makeApp();
const bars = () =>
[...document.querySelectorAll<HTMLElement>('#statCpuSpark > i')].map((bar) => bar.style.height);
app._pushStatSpark('cpu', 40);
expect(bars()).toEqual(['', '', '', '40%']);
app._pushStatSpark('cpu', 2);
app._pushStatSpark('cpu', 250);
app._pushStatSpark('cpu', 60);
app._pushStatSpark('cpu', 70);
expect(bars()).toEqual(['10%', '100%', '60%', '70%']);
});
});
describe('header stats wiring (static)', () => {
const css = read('styles.css');
it('hides the new-style parts by default, so classic looks exactly as before', () => {
expect(css).toMatch(
/\.stat-spark,\s*\.connection-tile-label,\s*\.connection-tile-value,\s*\.header-plan-usage \.pu-ring,\s*\.header-plan-usage \.pu-meter \{\s*display: none;/
);
});
it('stamps data-header-stats before first paint, tiles by default and classic on narrow screens', () => {
expect(INDEX).toContain(
"dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='compact')?H:'tiles'"
);
});
it('offers the three styles with tiles marked as the default', () => {
expect(INDEX).toMatch(
/<select id="appSettingsHeaderStatsStyle"[^>]*>\s*<option value="classic">[^<]+<\/option>\s*<option value="compact">[^<]+<\/option>\s*<option value="tiles">Tiles \(default\)<\/option>/
);
});
});