mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-12 10:19:42 +02:00
Since the sidebar filter and the rail search share _applyTabListFilter(), the sidebar's filter box also marks a .tab-cluster it emptied in the by-case tab layout and rewrites its count. But the only rule hiding .tab-cluster.tab-filtered-out was the rail-scoped one, so in the sidebar the emptied box stayed painted with its label and a count of 0 while its rows were hidden. The sidebar rule now hides the marked case box as well. It stays scoped to html[data-session-list="sidebar"], so a leaked class still cannot hide anything on the header strip. An alerted row still counts toward its box (the owner's call on #580), so a box holding a row that needs the user never hides. Tests: a unit test renders the sidebar in the by-case layout and checks the emptied box is marked with a 0 count, that an alerted row keeps its box on screen and counted, and that clearing restores the totals; a stylesheet check pins the scoped selector; the browser test measures the shipped styles.css in Chromium: the marked box is not painted in the sidebar layout, an unmarked one is, and the header layout hides nothing. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
315 lines
14 KiB
TypeScript
315 lines
14 KiB
TypeScript
/**
|
|
* @fileoverview Real-Chromium coverage for the vertical rail's session search.
|
|
*
|
|
* What DOM emulation cannot answer: that the shipped CSS actually hides a
|
|
* filtered row and an emptied group (and, in the sidebar layout, an emptied
|
|
* case box), that the box shows only on the vertical rail, that the inline
|
|
* oninput/onkeydown/onclick handlers in index.html reach the app, that a match
|
|
* inside a collapsed group can be clicked, and that Escape in the box clears
|
|
* the search WITHOUT the global key handler (installed for real, capture
|
|
* phase, so it runs before the box's own onkeydown) also closing every panel.
|
|
* The real #tabRail markup is lifted from index.html, and the shipping
|
|
* constants.js, tab-layout-browser.js, app.js, webview-tabs.js and styles.css
|
|
* are loaded into a page.
|
|
*
|
|
* Port: none (page.route on a fake origin, no server).
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { JSDOM } from 'jsdom';
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
import { chromium, type Browser, type Page } from 'playwright';
|
|
|
|
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
|
const read = (name: string) => readFileSync(resolve(publicDir, name), 'utf8');
|
|
|
|
/** The shipped rail markup, so the inline handlers under test are the real ones. */
|
|
const RAIL_HTML = (() => {
|
|
const dom = new JSDOM(read('index.html'));
|
|
const html = dom.window.document.getElementById('tabRail')!.outerHTML;
|
|
dom.window.close();
|
|
return html;
|
|
})();
|
|
|
|
const LAYOUT = {
|
|
version: 1,
|
|
updatedAt: '2026-10-01T00:00:00.000Z',
|
|
groups: [
|
|
{ id: 'eng', name: 'Engineering', refs: [{ kind: 'session', id: 'alpha' }] },
|
|
{
|
|
id: 'plan',
|
|
name: 'Planning',
|
|
refs: [
|
|
{ kind: 'session', id: 'roadmap' },
|
|
{ kind: 'session', id: 'review' },
|
|
],
|
|
},
|
|
],
|
|
ungrouped: [
|
|
{ kind: 'webview', id: 'web' },
|
|
{ kind: 'session', id: 'notes' },
|
|
],
|
|
};
|
|
|
|
describe('vertical rail session search 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 }, deviceScaleFactor: 1 });
|
|
await page.route('http://codeman.test/', (route) =>
|
|
route.fulfill({ contentType: 'text/html', body: '<!doctype html><html><body></body></html>' })
|
|
);
|
|
await page.goto('http://codeman.test/');
|
|
await page.setContent(`<!doctype html>
|
|
<html data-tab-orientation="vertical" data-tab-rail-sort="manual">
|
|
<head><style>${read('styles.css')}</style></head>
|
|
<body>
|
|
<main class="main" style="width:100%;height:760px">${RAIL_HTML}</main>
|
|
</body>
|
|
</html>`);
|
|
await page.addScriptTag({
|
|
content:
|
|
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
|
|
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
|
|
read('constants.js') +
|
|
'\n' +
|
|
read('tab-layout-browser.js') +
|
|
'\n' +
|
|
read('app.js') +
|
|
'\nwindow.CodemanApp = CodemanApp; window.__setApp = (value) => { app = value; };',
|
|
});
|
|
await page.addScriptTag({ content: read('webview-tabs.js') });
|
|
await page.evaluate(() => {
|
|
const w = window as any;
|
|
const app = Object.create(w.CodemanApp.prototype);
|
|
app.$ = (id: string) => document.getElementById(id);
|
|
app.sessions = new Map([
|
|
['alpha', { id: 'alpha', name: 'Alpha API', status: 'idle' }],
|
|
['roadmap', { id: 'roadmap', name: 'Roadmap', status: 'idle' }],
|
|
['review', { id: 'review', name: 'API Review', status: 'idle' }],
|
|
['notes', { id: 'notes', name: 'Notes', status: 'idle' }],
|
|
]);
|
|
app.sessionOrder = ['alpha', 'roadmap', 'review', 'notes'];
|
|
app.webviews = new Map([['web', { id: 'web', name: 'Dashboard', url: 'https://example.test', icon: 'D' }]]);
|
|
app.webviewOrder = ['web'];
|
|
app._hiddenTabGroupByRef = new Map();
|
|
app._inlineRenameActive = false;
|
|
app._sidebarFilter = '';
|
|
app._tabRailSearch = '';
|
|
app.tabAlerts = new Map();
|
|
app.terminalLoadStates = new Map();
|
|
app.minimizedSubagents = new Map();
|
|
app.hasTabDetachOverride = () => false;
|
|
app.renderSubagentTabBadge = () => '';
|
|
app.cancelHideSubagentDropdown = () => undefined;
|
|
app.updateTabOverflowMode = () => undefined;
|
|
app.updateConnectionLines = () => {
|
|
w.__redraws++;
|
|
};
|
|
app._applyTabEntrances = () => undefined;
|
|
app._scrollActiveTabIntoView = () => undefined;
|
|
app.isSessionSidebarActive = () => false;
|
|
app._startSidebarRichClock = () => undefined;
|
|
app._stopSidebarRichClock = () => undefined;
|
|
app.selectSession = (id: string) => {
|
|
w.__activation = `session:${id}`;
|
|
};
|
|
app.openWebview = (id: string) => {
|
|
w.__activation = `webview:${id}`;
|
|
};
|
|
w.__setApp(app);
|
|
w.__app = app;
|
|
// The real global key handler (document, capture phase). The close
|
|
// methods it calls live in settings-ui.js / panels-ui.js, which are not
|
|
// loaded here, so they record instead.
|
|
app.setupColorPicker = () => undefined;
|
|
app.closeAllPanels = () => w.__closed.push('panels');
|
|
app.closeHelp = () => w.__closed.push('help');
|
|
app.closeSessionManager = () => w.__closed.push('session-manager');
|
|
app.setupEventListeners();
|
|
});
|
|
});
|
|
|
|
afterAll(async () => browser.close());
|
|
|
|
beforeEach(async () => {
|
|
await page.evaluate((layout) => {
|
|
const w = window as any;
|
|
const app = w.__app;
|
|
document.getElementById('sessionTabs')?.remove();
|
|
document
|
|
.getElementById('tabRail')!
|
|
.insertAdjacentHTML(
|
|
'beforeend',
|
|
'<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs"></div>'
|
|
);
|
|
localStorage.setItem('codeman:tab-groups-collapsed', '["plan"]');
|
|
app._tabKeydownHandler = null;
|
|
app._tabTreeFocusinHandler = null;
|
|
app.tabLayout = layout;
|
|
app.collapsedTabGroupIds = new Set(['plan']);
|
|
app.activeSessionId = 'alpha';
|
|
app.activeWebviewId = null;
|
|
app._lastTabGroupStructureKey = null;
|
|
app._resetTabRailSearch();
|
|
app._fullRenderSessionTabs();
|
|
w.__activation = null;
|
|
w.__closed = [];
|
|
w.__redraws = 0;
|
|
}, LAYOUT);
|
|
});
|
|
|
|
/** Rows actually painted (display != none), in DOM order. */
|
|
const paintedRows = () =>
|
|
page.evaluate(() =>
|
|
[...document.querySelectorAll<HTMLElement>('#sessionTabs .session-tab')]
|
|
.filter((tab) => tab.getClientRects().length > 0)
|
|
.map((tab) => tab.dataset.webviewId || tab.dataset.id)
|
|
);
|
|
const paintedGroups = () =>
|
|
page.evaluate(() =>
|
|
[...document.querySelectorAll<HTMLElement>('#sessionTabs .tab-layout-group')]
|
|
.filter((section) => section.getClientRects().length > 0)
|
|
.map((section) => section.dataset.tabGroupId)
|
|
);
|
|
|
|
it('reveals a collapsed match, selects it, and restores the collapse when cleared', async () => {
|
|
const search = page.getByRole('searchbox', { name: 'Search sessions' });
|
|
expect(await search.isVisible()).toBe(true);
|
|
expect(await paintedRows()).toEqual(['alpha', 'web', 'notes']);
|
|
expect(await page.locator('[data-tab-group-header="plan"]').getAttribute('aria-expanded')).toBe('false');
|
|
|
|
await search.fill('aPi');
|
|
|
|
expect(await paintedRows()).toEqual(['alpha', 'review']);
|
|
expect(await paintedGroups()).toEqual(['eng', 'plan']);
|
|
expect(await page.locator('[data-tab-group-header="plan"]').getAttribute('aria-expanded')).toBe('true');
|
|
|
|
await page.locator('#sessionTabs [data-id="review"]').click();
|
|
expect(await page.evaluate(() => (window as any).__activation)).toBe('session:review');
|
|
|
|
const clear = page.getByRole('button', { name: 'Clear search' });
|
|
expect(await clear.isVisible()).toBe(true);
|
|
await clear.click();
|
|
|
|
expect(await search.inputValue()).toBe('');
|
|
expect(await search.evaluate((el) => el === document.activeElement)).toBe(true);
|
|
expect(await clear.isHidden()).toBe(true);
|
|
expect(await page.locator('[data-tab-group-header="plan"]').getAttribute('aria-expanded')).toBe('false');
|
|
expect(await paintedRows()).toEqual(['alpha', 'web', 'notes']);
|
|
expect(await page.evaluate(() => localStorage.getItem('codeman:tab-groups-collapsed'))).toBe('["plan"]');
|
|
});
|
|
|
|
it('says so when nothing matches, and Escape in the box clears it', async () => {
|
|
const search = page.getByRole('searchbox', { name: 'Search sessions' });
|
|
await search.fill('zzz');
|
|
expect(await paintedRows()).toEqual([]);
|
|
expect(await paintedGroups()).toEqual([]);
|
|
expect(await page.getByRole('status').filter({ hasText: 'No sessions match' }).isVisible()).toBe(true);
|
|
|
|
await search.press('Escape');
|
|
expect(await search.inputValue()).toBe('');
|
|
expect(await page.locator('#tabRailSearchEmpty').isHidden()).toBe(true);
|
|
expect(await paintedRows()).toEqual(['alpha', 'web', 'notes']);
|
|
});
|
|
|
|
it('Escape with text in the box clears it and closes nothing else', async () => {
|
|
const search = page.getByRole('searchbox', { name: 'Search sessions' });
|
|
await search.fill('review');
|
|
expect(await paintedRows()).toEqual(['review']);
|
|
|
|
await search.press('Escape');
|
|
expect(await search.inputValue()).toBe('');
|
|
expect(await paintedRows()).toEqual(['alpha', 'web', 'notes']);
|
|
expect(await page.evaluate(() => (window as any).__closed)).toEqual([]);
|
|
|
|
// An empty box leaves Escape to the global handler, which closes as always.
|
|
await search.press('Escape');
|
|
expect(await page.evaluate(() => (window as any).__closed)).toEqual(['panels', 'help', 'session-manager']);
|
|
});
|
|
|
|
it('redraws the connector lines when a keystroke moves the rows', async () => {
|
|
const search = page.getByRole('searchbox', { name: 'Search sessions' });
|
|
const notesTop = () =>
|
|
page.evaluate(() => document.querySelector('#sessionTabs [data-id="notes"]')!.getBoundingClientRect().top);
|
|
// The first keystroke opens the collapsed Planning group, a render that
|
|
// redraws on its own. The next one only toggles classes.
|
|
await search.fill('e');
|
|
expect(await paintedRows()).toEqual(['review', 'notes']);
|
|
const before = await notesTop();
|
|
await page.evaluate(() => {
|
|
(window as any).__redraws = 0;
|
|
});
|
|
|
|
await search.fill('es');
|
|
expect(await paintedRows()).toEqual(['notes']);
|
|
expect(await notesTop()).toBeLessThan(before);
|
|
expect(await page.evaluate(() => (window as any).__redraws)).toBe(1);
|
|
});
|
|
|
|
it('hides a case box the sidebar filter emptied, in the sidebar layout only', async () => {
|
|
const painted = await page.evaluate(() => {
|
|
const root = document.documentElement;
|
|
const probeHtml =
|
|
'<aside class="session-sidebar" id="sidebarProbe"><div class="session-tabs tabs-clusters">' +
|
|
'<div class="tab-cluster tab-filtered-out" data-probe="emptied">' +
|
|
'<span class="tab-cluster-label">api <span class="tab-cluster-count">0</span></span></div>' +
|
|
'<div class="tab-cluster" data-probe="kept">' +
|
|
'<span class="tab-cluster-label">web <span class="tab-cluster-count">1</span></span>' +
|
|
'<div class="session-tab">w1-web</div></div>' +
|
|
'</div></aside>';
|
|
const measure = () => {
|
|
const probe = document.getElementById('sidebarProbe')!;
|
|
return {
|
|
emptied: probe.querySelector('[data-probe="emptied"]')!.getClientRects().length,
|
|
kept: probe.querySelector('[data-probe="kept"]')!.getClientRects().length,
|
|
};
|
|
};
|
|
root.setAttribute('data-tab-orientation', 'horizontal');
|
|
document.body.insertAdjacentHTML('beforeend', probeHtml);
|
|
const probe = document.getElementById('sidebarProbe')!;
|
|
// Header layout (the box outside any sidebar, which that layout hides):
|
|
// the rule is scoped to the sidebar, so a leaked class hides nothing.
|
|
root.setAttribute('data-session-list', 'header');
|
|
probe.classList.remove('session-sidebar');
|
|
const header = measure();
|
|
root.setAttribute('data-session-list', 'sidebar');
|
|
probe.classList.add('session-sidebar');
|
|
const sidebar = measure();
|
|
document.getElementById('sidebarProbe')!.remove();
|
|
root.removeAttribute('data-session-list');
|
|
root.setAttribute('data-tab-orientation', 'vertical');
|
|
return { header, sidebar };
|
|
});
|
|
expect(painted.sidebar).toEqual({ emptied: 0, kept: 1 });
|
|
expect(painted.header.emptied).toBe(1);
|
|
});
|
|
|
|
it('walks only the matches with the arrow keys', async () => {
|
|
await page.getByRole('searchbox', { name: 'Search sessions' }).fill('a');
|
|
// 'a' matches Alpha API, Roadmap, API Review and Dashboard, not Notes.
|
|
expect(await paintedRows()).toEqual(['alpha', 'roadmap', 'review', 'web']);
|
|
await page.locator('#sessionTabs [data-id="alpha"]').focus();
|
|
const walk: string[] = [];
|
|
for (let i = 0; i < 6; i++) {
|
|
await page.keyboard.press('ArrowDown');
|
|
walk.push(
|
|
await page.evaluate(() => {
|
|
const el = document.activeElement as HTMLElement;
|
|
return el.dataset.webviewId || el.dataset.id || el.dataset.tabGroupHeader || el.id;
|
|
})
|
|
);
|
|
}
|
|
expect(walk).toEqual(['plan', 'roadmap', 'review', 'web', 'eng', 'alpha']);
|
|
});
|
|
|
|
it('is hidden with the rail, on the horizontal strip', async () => {
|
|
await page.evaluate(() => document.documentElement.setAttribute('data-tab-orientation', 'horizontal'));
|
|
expect(await page.locator('#tabRailSearch').isVisible()).toBe(false);
|
|
await page.evaluate(() => document.documentElement.setAttribute('data-tab-orientation', 'vertical'));
|
|
});
|
|
});
|