Merge remote-tracking branch 'origin/master' into feat/readmymind-phase3-part1

# Conflicts:
#	CLAUDE.md
#	src/web/public/home-sessions.js
This commit is contained in:
Codeman maintainer
2026-08-10 04:30:33 +02:00
30 changed files with 4321 additions and 931 deletions
+7 -6
View File
@@ -28,7 +28,7 @@ async function bootWith(me: Record<string, unknown>) {
const dom = new JSDOM(
`<!doctype html><body>
<button id="adminPanelBtn" class="btn-admin-panel btn-admin-panel--hidden"></button>
<div class="modal" id="appSettingsModal"><div class="modal-tabs"></div><div class="modal-body"></div></div>
<div class="modal" id="appSettingsModal"><nav class="set-rail"><div class="set-rail-items"></div></nav><div class="set-doc" id="appSettingsDoc"></div></div>
</body>`,
{ url: 'http://localhost/', runScripts: 'outside-only' }
);
@@ -45,22 +45,23 @@ async function bootWith(me: Record<string, unknown>) {
}
describe('admin-ui boot', () => {
it('exposes the identity and injects the Users tab for a multi-user admin', async () => {
it('exposes the identity and injects the Users section for a multi-user admin', async () => {
const { win } = await bootWith({ username: 'root', role: 'admin', multiUser: true, mustChangePassword: false });
expect(win.__codemanUser).toMatchObject({ username: 'root', role: 'admin', multiUser: true });
const btn = win.document.querySelector('[data-tab="settings-users"]');
// The settings modal is a rail over one document: a rail entry, not a tab.
const btn = win.document.querySelector('[data-section="settings-users"]');
expect(btn).toBeTruthy();
expect(win.document.getElementById('settings-users')).toBeTruthy();
});
it('does NOT inject the Users tab for a regular user', async () => {
it('does NOT inject the Users section for a regular user', async () => {
const { win } = await bootWith({ username: 'joe', role: 'user', multiUser: true, mustChangePassword: false });
expect(win.document.querySelector('[data-tab="settings-users"]')).toBeFalsy();
expect(win.document.querySelector('[data-section="settings-users"]')).toBeFalsy();
});
it('does NOT inject the Users tab in single-user mode', async () => {
const { win } = await bootWith({ username: 'admin', role: 'admin', multiUser: false, mustChangePassword: false });
expect(win.document.querySelector('[data-tab="settings-users"]')).toBeFalsy();
expect(win.document.querySelector('[data-section="settings-users"]')).toBeFalsy();
});
it('shows the change-password modal when mustChangePassword is set', async () => {
+102
View File
@@ -0,0 +1,102 @@
/**
* App Settings structural guard.
*
* The settings modal is a rail (table of contents) over ONE scrolling document.
* Its load/save path is pure `getElementById` by a fixed set of ids
* (openAppSettings / saveAppSettings in settings-ui.js), so a restructure of the
* markup that drops or renames an element does not fail loudly: the setting just
* silently stops loading, or stops being saved and falls back to its default.
*
* These tests read the REAL settings-ui.js and index.html and pin that contract.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
const settingsUi = readFileSync(resolve(publicDir, 'settings-ui.js'), 'utf8');
/** The App Settings modal markup, so assertions can't be satisfied elsewhere. */
function settingsModal(): string {
const start = html.indexOf('<div class="modal" id="appSettingsModal">');
expect(start).toBeGreaterThan(-1);
const end = html.indexOf('<!-- Shortcut Overlay Modal -->', start);
expect(end).toBeGreaterThan(start);
return html.slice(start, end);
}
/**
* Every id the load and save paths touch. Scoped to those two functions on
* purpose: settings-ui.js also drives elements that live OUTSIDE the modal
* (toasts, header chips), and those are not this file's contract.
*/
function referencedIds(): string[] {
const ids = new Set<string>();
for (const fn of ['openAppSettings()', 'async saveAppSettings()']) {
const start = settingsUi.indexOf(`\n ${fn} {`);
expect(start, `${fn} not found in settings-ui.js`).toBeGreaterThan(-1);
const body = settingsUi.slice(start, settingsUi.indexOf('\n },', start));
for (const m of body.matchAll(/getElementById\('([A-Za-z0-9_-]+)'\)/g)) ids.add(m[1]);
}
return [...ids];
}
describe('App Settings modal structure', () => {
it('keeps every element settings-ui.js loads or saves by id', () => {
const modal = settingsModal();
const missing = referencedIds().filter((id) => !modal.includes(`id="${id}"`));
expect(missing).toEqual([]);
});
it('carries every section the rail points at, exactly once', () => {
const modal = settingsModal();
const sections = [...modal.matchAll(/data-section="([a-z-]+)"/g)].map((m) => m[1]);
expect(sections.length).toBeGreaterThanOrEqual(9);
for (const id of new Set(sections)) {
const hits = modal.split(`<section class="set-section" id="${id}"`).length - 1;
expect(hits, `section ${id} should exist exactly once`).toBe(1);
}
});
it('opens on Terminal & Input, so Local Echo is the first thing in reach', () => {
expect(settingsUi).toContain("this.switchSettingsTab('settings-terminal')");
const terminal = settingsModal().match(/id="settings-terminal"([\s\S]*?)<\/section>/);
const localEcho = terminal?.[1].indexOf('appSettingsLocalEcho') ?? -1;
const cjk = terminal?.[1].indexOf('appSettingsCjkInput') ?? -1;
expect(localEcho).toBeGreaterThan(-1);
expect(localEcho).toBeLessThan(cjk);
});
it('models: keeps the 1M variants as select options behind the context switch', () => {
const modal = settingsModal();
const select = modal.match(/id="appSettingsClaudeModel"([\s\S]*?)<\/select>/)?.[1] ?? '';
// The cards render the base models; the [1m] rows exist so that base + the
// context switch can compose back into a real claudeModel value.
for (const value of ['opus[1m]', 'claude-fable-5[1m]', 'claude-opus-4-6[1m]']) {
expect(select).toContain(`value="${value}"`);
}
expect(select).toContain('data-ctx="1"');
expect(modal).toContain('id="appSettingsOpusContext1m"');
});
it('never hides sections behind .modal-tab-content (that class means display:none)', () => {
expect(settingsModal()).not.toContain('modal-tab-content');
});
it('leaves the shared modal tab classes to the other modals', () => {
// #sessionOptionsModal and #createCaseModal still use .modal-tabs; the
// settings rail must not restyle them out from under those.
expect(settingsModal()).not.toContain('class="modal-tabs"');
expect(html).toContain('<div class="modal-tabs">');
});
it('exposes the rail hooks admin-ui.js injects the Users section into', () => {
const modal = settingsModal();
expect(modal).toContain('class="set-rail-items"');
expect(modal).toContain('id="appSettingsDoc"');
const adminUi = readFileSync(resolve(publicDir, 'admin-ui.js'), 'utf8');
expect(adminUi).toContain('.set-rail-items');
expect(adminUi).toContain('.set-doc');
});
});
+291
View File
@@ -0,0 +1,291 @@
/**
* @fileoverview Issue #260, the home screen's "Resume Conversation" list.
*
* With ~35 past sessions the list showed 4 rows, then a button that dumped every
* remaining row into a fixed 240px box, with no way to sort or filter. The fix
* moved rendering into `_renderHistoryList()` over a cached corpus, so what is
* worth pinning is the model, not the pixels:
* 1. the collapsed page is _HISTORY_INITIAL_COUNT rows, not 4,
* 2. "Show more" expands the LIST and marks the box expanded (the CSS cap is
* class-driven, without the class, expanding just deepens a scroll well),
* 3. filtering matches name / folder / case label / prompt, and implies
* expansion (hiding matches behind "Show more" defeats typing a filter),
* 4. sorting is alphabetical by name or folder, with pinned rows still on top.
*
* Loaded via `vm` against a stub CodemanApp with a fake DOM, same harness as
* resume-name.test.ts. `_buildHistoryItem` is stubbed: this pins WHICH rows get
* rendered and in what order, not how one row looks.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
interface FakeEl {
id: string;
value: string;
textContent: string;
scrollTop: number;
className: string;
children: FakeEl[];
classes: Set<string>;
listeners: Record<string, ((ev: unknown) => void)[]>;
classList: { toggle: (c: string, on: boolean) => void; contains: (c: string) => boolean };
replaceChildren: () => void;
appendChild: (child: FakeEl) => FakeEl;
addEventListener: (type: string, fn: (ev: unknown) => void) => void;
style: Record<string, string>;
}
function fakeEl(id: string): FakeEl {
const el = {
id,
value: '',
textContent: '',
scrollTop: 0,
className: '',
children: [] as FakeEl[],
classes: new Set<string>(),
listeners: {} as Record<string, ((ev: unknown) => void)[]>,
style: {} as Record<string, string>,
} as FakeEl;
el.classList = {
toggle: (c: string, on: boolean) => (on ? el.classes.add(c) : el.classes.delete(c)),
contains: (c: string) => el.classes.has(c),
};
el.replaceChildren = () => {
el.children = [];
};
el.appendChild = (child: FakeEl) => {
el.children.push(child);
return child;
};
el.addEventListener = (type: string, fn: (ev: unknown) => void) => {
(el.listeners[type] ||= []).push(fn);
};
return el;
}
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* The element map the vm's `document.getElementById` resolves against. Swapped
* per test, the closure is defined in THIS realm, so the shipping code inside
* the vm reads whatever the current test installed.
*/
let currentEls: Record<string, FakeEl> = {};
function loadTerminalUiPrototype(): Record<string, any> {
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
const context = vm.createContext({
console,
CodemanApp: class CodemanApp {},
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
document: {
addEventListener: vi.fn(),
getElementById: (id: string) => currentEls[id] ?? null,
createElement: () => fakeEl('created'),
},
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
});
vm.runInContext(`${source}\nglobalThis.__proto = CodemanApp.prototype;`, context);
return (context as unknown as { __proto: Record<string, any> }).__proto;
}
const proto = loadTerminalUiPrototype();
type Row = {
sessionId: string;
name?: string;
workingDir?: string;
firstPrompt?: string;
pinned?: boolean;
lastActivityAt?: number;
};
/** Host object carrying the real render/filter/sort methods over a fake DOM. */
function makeApp(rows: Row[], cases: Array<{ name: string; path: string }> = []) {
const els: Record<string, FakeEl> = {
historyList: fakeEl('historyList'),
historyFilter: fakeEl('historyFilter'),
historySort: fakeEl('historySort'),
historyCount: fakeEl('historyCount'),
};
els.historySort.value = 'recent';
const app: any = {
_HISTORY_INITIAL_COUNT: proto._HISTORY_INITIAL_COUNT,
_historyAll: rows,
_historyCases: cases,
_renderHistoryList: proto._renderHistoryList,
_historyRowMatches: proto._historyRowMatches,
_sortHistoryRows: proto._sortHistoryRows,
_historyRowLabel: proto._historyRowLabel,
_resolveCaseLabel: proto._resolveCaseLabel,
_shortenHomePath: proto._shortenHomePath,
// One fake node per row, tagged so assertions can read back the order.
_buildHistoryItem: (s: Row) => {
const el = fakeEl('item');
el.textContent = s.sessionId;
return el;
},
els,
/** Rendered row ids, excluding the show-more/less button and empty state. */
renderedIds(): string[] {
return els.historyList.children.filter((c) => c.id === 'item').map((c) => c.textContent);
},
button(): FakeEl | undefined {
return els.historyList.children.find((c) => c.id === 'created');
},
};
// Point the vm's document at this app's elements, then run the shipping method.
app._render = () => {
currentEls = els;
app._renderHistoryList();
};
return app;
}
function rows(n: number, overrides: Partial<Row> = {}): Row[] {
return Array.from({ length: n }, (_, i) => ({
sessionId: `s${i}`,
name: `w${i}-project${i}`,
workingDir: `/home/u/project${i}`,
lastActivityAt: 1000 - i,
...overrides,
}));
}
describe('issue #260: collapsed page size', () => {
it('shows more than the old 4 rows before "Show more"', () => {
expect(proto._HISTORY_INITIAL_COUNT).toBeGreaterThanOrEqual(8);
});
it('renders the initial page and a "Show more" button for the rest', () => {
const app = makeApp(rows(35));
app._render();
expect(app.renderedIds()).toHaveLength(proto._HISTORY_INITIAL_COUNT);
expect(app.button()?.textContent).toBe(`Show ${35 - proto._HISTORY_INITIAL_COUNT} more`);
expect(app.els.historyList.classList.contains('expanded')).toBe(false);
});
it('expanding renders every row AND marks the box expanded', () => {
const app = makeApp(rows(35));
app._historyExpanded = true;
app._render();
expect(app.renderedIds()).toHaveLength(35);
// Without this class the CSS max-height stays at the collapsed cap and the
// extra rows land in a four-row scroll well, the original bug.
expect(app.els.historyList.classList.contains('expanded')).toBe(true);
expect(app.button()?.textContent).toBe('Show less');
});
it('shows no button at all when everything fits', () => {
const app = makeApp(rows(3));
app._render();
expect(app.renderedIds()).toHaveLength(3);
expect(app.button()).toBeUndefined();
});
});
describe('issue #260: filter', () => {
it('matches on folder name and shows every match without expanding first', () => {
const app = makeApp([
...rows(30),
{ sessionId: 'x1', name: 'w99-invoices', workingDir: '/home/u/invoices', lastActivityAt: 1 },
{ sessionId: 'x2', name: 'w98-other', workingDir: '/home/u/invoices-archive', lastActivityAt: 2 },
]);
app.els.historyFilter.value = 'invoices';
app._render();
expect(app.renderedIds().sort()).toEqual(['x1', 'x2']);
expect(app.els.historyList.classList.contains('expanded')).toBe(true);
expect(app.els.historyCount.textContent).toBe('2 of 32');
});
it('matches on the case label and on a prompt', () => {
const app = makeApp(
[
{ sessionId: 'c1', name: 'w1-x', workingDir: '/home/u/cases/billing', lastActivityAt: 1 },
{
sessionId: 'p1',
name: 'w2-y',
workingDir: '/home/u/other',
firstPrompt: 'fix the CSV export',
lastActivityAt: 2,
},
],
[{ name: 'billing', path: '/home/u/cases/billing' }]
);
app.els.historyFilter.value = '#billing';
app._render();
expect(app.renderedIds()).toEqual(['c1']);
app.els.historyFilter.value = 'csv export';
app._render();
expect(app.renderedIds()).toEqual(['p1']);
});
it('renders an empty state when nothing matches', () => {
const app = makeApp(rows(5));
app.els.historyFilter.value = 'zzzz';
app._render();
expect(app.renderedIds()).toEqual([]);
expect(app.els.historyList.children[0].textContent).toContain('No conversations match');
});
});
describe('issue #260: sort', () => {
const unsorted: Row[] = [
{ sessionId: 'b', name: 'beta', workingDir: '/home/u/zeta', lastActivityAt: 300 },
{ sessionId: 'a', name: 'alpha', workingDir: '/home/u/yankee', lastActivityAt: 200 },
{ sessionId: 'c', name: 'gamma', workingDir: '/home/u/xray', lastActivityAt: 100 },
];
it('recent keeps the backend order', () => {
const app = makeApp(unsorted);
app._render();
expect(app.renderedIds()).toEqual(['b', 'a', 'c']);
});
it('sorts by name', () => {
const app = makeApp(unsorted);
app.els.historySort.value = 'name';
app._render();
expect(app.renderedIds()).toEqual(['a', 'b', 'c']);
});
it('sorts by folder basename', () => {
const app = makeApp(unsorted);
app.els.historySort.value = 'folder';
app._render();
expect(app.renderedIds()).toEqual(['c', 'a', 'b']);
});
it('sorts transcript rows (no session name) by the prompt shown as their title', () => {
// Most past rows come from a transcript and have no name at all. Keying the
// A–Z sort off `name` alone made "Name A–Z" a no-op for them.
const app = makeApp([
{ sessionId: 'z', workingDir: '/home/u/one', firstPrompt: 'zebra crossing' },
{ sessionId: 'a', workingDir: '/home/u/two', firstPrompt: 'apple pie' },
{ sessionId: 'm', workingDir: '/home/u/three', firstPrompt: 'middle ground' },
]);
app.els.historySort.value = 'name';
app._render();
expect(app.renderedIds()).toEqual(['a', 'm', 'z']);
});
it('keeps pinned rows on top in every sort mode', () => {
const app = makeApp([{ sessionId: 'p', name: 'zulu', workingDir: '/home/u/zulu', pinned: true }, ...unsorted]);
for (const mode of ['recent', 'name', 'folder']) {
app.els.historySort.value = mode;
app._render();
expect(app.renderedIds()[0]).toBe('p');
}
});
});
+165
View File
@@ -183,6 +183,171 @@ describe('Tab Navigation', () => {
});
});
// ─── Tab Strip Scrolling (issue #257) ────────────────────────────────────
describe('Tab Strip Scrolling', () => {
/**
* Seed `count` real sessions and render the strip through the production
* code path (_fullRenderSessionTabs), so the tabs carry the real markup,
* widths and CSS rather than hand-built stand-ins.
*/
async function seedTabs(page: Page, count: number, activeIndex = 0): Promise<void> {
await page.evaluate(`(function (n, activeIndex) {
app.sessions.clear();
app.sessionOrder = [];
for (let i = 1; i <= n; i++) {
const id = 'scroll-sess-' + i;
app.sessions.set(id, { id, name: 'w' + i + '-project', status: 'idle', mode: 'claude', workingDir: '/tmp/p' + i });
app.sessionOrder.push(id);
}
app.activeSessionId = app.sessionOrder[activeIndex];
app._lastRenderedActiveTabId = null;
app._fullRenderSessionTabs();
})(${count}, ${activeIndex})`);
await page.waitForTimeout(200);
}
async function stripState(page: Page, sessionId: string) {
return page.evaluate(`(function (id) {
const c = document.getElementById('sessionTabs');
const tab = c.querySelector('.session-tab[data-id="' + id + '"]');
const cRect = c.getBoundingClientRect();
const tRect = tab ? tab.getBoundingClientRect() : null;
return {
scrollLeft: Math.round(c.scrollLeft),
maxScroll: Math.round(c.scrollWidth - c.clientWidth),
order: [...c.querySelectorAll('.session-tab[data-id]')].map((t) => t.dataset.id),
visible: tRect ? tRect.left >= cRect.left - 1 && tRect.right <= cRect.right + 1 : false,
};
})('${sessionId}')`) as Promise<{ scrollLeft: number; maxScroll: number; order: string[]; visible: boolean }>;
}
it('reveals a rightmost tab that selection would otherwise leave off-screen', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await seedTabs(page, 5);
const before = await stripState(page, 'scroll-sess-5');
// Precondition: the strip really does overflow and the last tab is hidden.
expect(before.maxScroll).toBeGreaterThan(0);
expect(before.visible).toBe(false);
// The selection path selectSession() uses (class toggle, no rebuild).
await page.evaluate(`(function () {
app.activeSessionId = 'scroll-sess-5';
app._updateActiveTabImmediate('scroll-sess-5');
})()`);
await page.waitForTimeout(600); // smooth scroll
const after = await stripState(page, 'scroll-sess-5');
expect(after.visible).toBe(true);
expect(after.scrollLeft).toBeGreaterThan(before.scrollLeft);
} finally {
await context.close();
}
});
it('scrolls back to reveal a leftmost tab', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await seedTabs(page, 5);
await page.evaluate(`document.getElementById('sessionTabs').scrollLeft = 9999`);
await page.evaluate(`(function () {
app.activeSessionId = 'scroll-sess-1';
app._updateActiveTabImmediate('scroll-sess-1');
})()`);
await page.waitForTimeout(600);
const after = await stripState(page, 'scroll-sess-1');
expect(after.visible).toBe(true);
expect(after.scrollLeft).toBe(0);
} finally {
await context.close();
}
});
it('keeps the scroll position across an ambient full re-render', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await seedTabs(page, 5);
// User swipes to the end of the strip, then a background rebuild fires
// (a task badge appearing forces the full-render path).
await page.evaluate(`document.getElementById('sessionTabs').scrollLeft = 9999`);
const scrolled = await stripState(page, 'scroll-sess-5');
expect(scrolled.scrollLeft).toBeGreaterThan(0);
await page.evaluate(`(function () {
app.sessions.get('scroll-sess-2').taskStats = { running: 2, total: 3 };
app._fullRenderSessionTabs();
})()`);
await page.waitForTimeout(200);
const after = await stripState(page, 'scroll-sess-5');
expect(after.scrollLeft).toBe(scrolled.scrollLeft);
} finally {
await context.close();
}
});
it('renders tabs in sessionOrder on phones instead of hoisting the active one', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await seedTabs(page, 5, 3); // 4th tab active
const state = await stripState(page, 'scroll-sess-4');
expect(state.order).toEqual([
'scroll-sess-1',
'scroll-sess-2',
'scroll-sess-3',
'scroll-sess-4',
'scroll-sess-5',
]);
// ...and the active tab is still brought into view by the render.
expect(state.visible).toBe(true);
} finally {
await context.close();
}
});
it('reaches the last tab with a horizontal touch drag', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await seedTabs(page, 5);
const cdp = await context.newCDPSession(page);
const box = await page.locator(SELECTORS.TABS_CONTAINER).boundingBox();
if (!box) throw new Error('tab strip not found');
const y = box.y + box.height / 2;
const startX = box.x + box.width * 0.85;
const endX = box.x + box.width * 0.1;
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: startX, y }] });
for (let i = 1; i <= 10; i++) {
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: startX + ((endX - startX) * i) / 10, y }],
});
await page.waitForTimeout(16);
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await page.waitForTimeout(400);
const after = await stripState(page, 'scroll-sess-5');
expect(after.scrollLeft).toBeGreaterThan(0);
expect(after.visible).toBe(true);
} finally {
await context.close();
}
});
});
// ─── Swipe Navigation (CDP - Chromium) ───────────────────────────────────
describe('Swipe Navigation (CDP - Chromium)', () => {
+96 -1
View File
@@ -9,12 +9,13 @@
* (sessions + events) return results. Source data is injected via the mock
* route context (sessions map, runSummaryTrackers map, attachment history).
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import { registerSearchRoutes } from '../../src/web/routes/search-routes.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { createMockRouteContext } from '../mocks/index.js';
import { RunSummaryTracker } from '../../src/run-summary.js';
import { resetHistorySessionIndex, setHistorySessionIndex } from '../../src/web/session-history-index.js';
type Ctx = ReturnType<typeof createMockRouteContext>;
@@ -206,3 +207,97 @@ describe('GET /api/search — caps & filters', () => {
expect(types).toEqual(['event']);
});
});
// Issue #261: with 3 live sessions and ~35 past ones, searching a past project's
// folder name matched nothing, the corpus was the live session map alone. Past
// sessions now arrive from the out-of-band history index snapshot.
describe('GET /api/search: past sessions (history index)', () => {
beforeEach(() => {
resetHistorySessionIndex();
});
afterEach(() => {
resetHistorySessionIndex();
delete process.env.CODEMAN_MULTIUSER;
});
it('matches a past session by folder name with no live session at all', async () => {
const { app } = await harness();
setHistorySessionIndex([
{
sessionId: 'cod-9',
name: 'w4-needlework',
workingDir: '/home/u/projects/needlework',
claudeSessionId: 'claude-uuid',
timestamp: 1000,
live: false,
},
]);
const res = await app.inject({ method: 'GET', url: '/api/search?q=needlework' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.totalResults).toBe(1);
expect(body.data.groups[0].results[0].jumpTo).toMatchObject({
kind: 'resume-session',
sessionId: 'cod-9',
claudeSessionId: 'claude-uuid',
});
});
it('does not duplicate a session that is both live and in the snapshot', async () => {
const { app } = await harness((ctx) => {
ctx.sessions.set(
'dup',
fakeSession({ id: 'dup', name: 'needle live', workingDir: '/home/u/needle', lastActivityAt: 5 }) as never
);
});
setHistorySessionIndex([
{ sessionId: 'dup', name: 'needle live', workingDir: '/home/u/needle', timestamp: 5, live: true },
]);
const body = JSON.parse((await app.inject({ method: 'GET', url: '/api/search?q=needle' })).body);
expect(body.data.totalResults).toBe(1);
// The live harvest wins, so the card still switches to the open tab.
expect(body.data.groups[0].results[0].jumpTo.kind).toBe('session');
});
it('multi-user: a non-admin sees neither another user’s past session nor unowned host-wide history', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const app = Fastify({ logger: false });
app.addHook('onRequest', async (req) => {
(req as unknown as { authUser: unknown }).authUser = { username: 'bob', role: 'user' };
});
const ctx = createMockRouteContext();
ctx.sessions.clear();
ctx.runSummaryTrackers.clear();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
registerSearchRoutes(app, ctx as any);
installRouteErrorHandler(app);
await app.ready();
setHistorySessionIndex([
{
sessionId: 'mine',
name: 'needle-bob',
workingDir: '/home/u/needle-bob',
timestamp: 3,
owner: 'bob',
live: false,
},
{
sessionId: 'hers',
name: 'needle-alice',
workingDir: '/home/u/needle-alice',
timestamp: 2,
owner: 'alice',
live: false,
},
// Host-wide transcript row: no owning session, so admin-only, the same
// rule GET /api/sessions/unified applies when it drops history for non-admins.
{ sessionId: 'hostwide', name: 'needle-host', workingDir: '/srv/needle-host', timestamp: 1, live: false },
]);
const body = JSON.parse((await app.inject({ method: 'GET', url: '/api/search?q=needle' })).body);
expect(body.data.groups[0].results.map((r: { sessionId: string }) => r.sessionId)).toEqual(['mine']);
await app.close();
});
});
+25 -25
View File
@@ -274,41 +274,41 @@ describe('Run launch synchronization', () => {
});
describe('Codex quick start settings', () => {
it('renders Codex CLI settings in a dedicated app settings tab', () => {
it('renders Codex CLI settings in their own group inside Agents & CLIs', () => {
const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
expect(html).toContain('data-tab="settings-codex">Codex CLI</button>');
// The settings modal is one scrolling document: Codex is a GROUP that can be
// hidden wholesale, not a tab (see _applyCodexSettingsVisibility).
const clis = html.match(/<section class="set-section" id="settings-clis"([\s\S]*?)<\/section>/);
expect(clis?.[1]).toBeTruthy();
const claudeTab = html.match(
/<div class="modal-tab-content hidden" id="settings-claude">([\s\S]*?)<!-- Codex CLI Tab -->/
);
expect(claudeTab?.[1]).not.toContain('appSettingsCodexDangerouslyBypassApprovals');
expect(claudeTab?.[1]).not.toContain('appSettingsCodexAnimations');
const codexGroup = clis![1].match(/id="appSettingsCodexGroup"([\s\S]*)$/);
expect(codexGroup?.[1]).toContain('appSettingsCodexDangerouslyBypassApprovals');
expect(codexGroup?.[1]).toContain('appSettingsCodexAnimations');
expect(codexGroup?.[1]).not.toContain('appSettingsCodexRenderMode');
const codexTab = html.match(
/<div class="modal-tab-content hidden" id="settings-codex">([\s\S]*?)<\/div>\s*<!-- Models Tab -->/
);
expect(codexTab?.[1]).toContain('appSettingsCodexDangerouslyBypassApprovals');
expect(codexTab?.[1]).toContain('appSettingsCodexAnimations');
expect(codexTab?.[1]).not.toContain('appSettingsCodexRenderMode');
// The Claude settings above it must not have absorbed the codex inputs.
const beforeCodex = clis![1].slice(0, clis![1].indexOf('id="appSettingsCodexGroup"'));
expect(beforeCodex).not.toContain('appSettingsCodexDangerouslyBypassApprovals');
expect(beforeCodex).not.toContain('appSettingsCodexAnimations');
});
describe('Codex CLI tab visibility', () => {
// Both settings on the tab are handed to `codex` at launch, so on an instance
// where the binary does not resolve the tab is a promise nothing can keep.
// renderIndexHtml injects window.__codemanCliAvailable; this pins the client
// half. Coupled test: it drives the REAL settings-ui.js against a stub button,
// so deleting the call in openAppSettings() is what it is meant to catch.
describe('Codex CLI group visibility', () => {
// Both settings in the group are handed to `codex` at launch, so on an
// instance where the binary does not resolve the group is a promise nothing
// can keep. renderIndexHtml injects window.__codemanCliAvailable; this pins
// the client half. Coupled test: it drives the REAL settings-ui.js against a
// stub element, so deleting the call in openAppSettings() is what it catches.
function loadSettingsUi(codexAvailable: boolean | undefined) {
const codexTabBtn = { dataset: { tab: 'settings-codex' }, style: { display: 'PRISTINE' } };
const codexTabBtn = { id: 'appSettingsCodexGroup', style: { display: 'PRISTINE' } };
const CodemanApp = function CodemanApp(this: any) {};
const context: any = vm.createContext({
CodemanApp,
MobileDetection: { getDeviceType: () => 'desktop', isTouchDevice: () => false, isHandheldDevice: () => false },
localStorage: { getItem: () => null, setItem: () => {} },
document: {
getElementById: () => null,
querySelector: (sel: string) => (sel.includes('[data-tab="settings-codex"]') ? codexTabBtn : null),
getElementById: (id: string) => (id === 'appSettingsCodexGroup' ? codexTabBtn : null),
querySelector: () => null,
},
console,
});
@@ -319,19 +319,19 @@ describe('Codex quick start settings', () => {
return { app: new (CodemanApp as any)(), codexTabBtn };
}
it('hides the Codex tab when the codex binary is not available', () => {
it('hides the Codex group when the codex binary is not available', () => {
const { app, codexTabBtn } = loadSettingsUi(false);
app._applyCodexSettingsVisibility();
expect(codexTabBtn.style.display).toBe('none');
});
it('hides the Codex tab when the availability flag was never injected', () => {
it('hides the Codex group when the availability flag was never injected', () => {
const { app, codexTabBtn } = loadSettingsUi(undefined);
app._applyCodexSettingsVisibility();
expect(codexTabBtn.style.display).toBe('none');
});
it('shows the Codex tab when codex is available', () => {
it('shows the Codex group when codex is available', () => {
const { app, codexTabBtn } = loadSettingsUi(true);
app._applyCodexSettingsVisibility();
expect(codexTabBtn.style.display).toBe('');
+58
View File
@@ -233,3 +233,61 @@ describe('searchSources — result card shape & path safety', () => {
expect(searchSources('', data).totalResults).toBe(0);
});
});
// Past sessions (issue #261). The corpus used to be the live session map alone,
// so a folder in the home screen's Resume list matched nothing. History rows now
// arrive marked, and a card for one has to RESUME the conversation, selecting a
// tab that no longer exists is a no-op the user reads as a broken result.
describe('searchSources: past (history) sessions', () => {
it('matches a past session by folder name and returns a resume jump target', () => {
const data = sources({
sessions: [
{
sessionId: 'cod-1',
sessionName: 'w3-invoices',
workingDir: '/home/u/projects/invoices',
timestamp: 500,
history: true,
claudeSessionId: 'claude-uuid-1',
},
],
});
const res = searchSources('invoices', data);
expect(res.totalResults).toBe(1);
expect(res.groups[0].results[0].jumpTo).toEqual({
kind: 'resume-session',
sessionId: 'cod-1',
claudeSessionId: 'claude-uuid-1',
workingDir: '/home/u/projects/invoices',
});
});
it('keeps a live session on the plain session jump target', () => {
const data = sources({
sessions: [{ sessionId: 'live-1', sessionName: 'w1-invoices', workingDir: '/home/u/invoices', timestamp: 1 }],
});
expect(searchSources('invoices', data).groups[0].results[0].jumpTo).toEqual({
kind: 'session',
sessionId: 'live-1',
});
});
it('does not offer a resume for a history row with no working directory', () => {
const data = sources({
sessions: [{ sessionId: 'cod-2', sessionName: 'needle-run', workingDir: '', timestamp: 1, history: true }],
});
// Nothing to resume INTO, a resume card here would always fail.
expect(searchSources('needle', data).groups[0].results[0].jumpTo.kind).toBe('session');
});
it('falls back to the folder basename when a transcript row has no name', () => {
const data = sources({
sessions: [
{ sessionId: 'cod-3', sessionName: '', workingDir: '/home/u/proj/needle-app', timestamp: 1, history: true },
],
});
const r = searchSources('needle', data).groups[0].results[0];
expect(r.sessionName).toBe('needle-app');
expect(r.snippet).toContain('/home/u/proj/needle-app');
});
});
+161
View File
@@ -0,0 +1,161 @@
/**
* Unit tests for the past-session search index (issue #261).
*
* The index is the seam that lets `GET /api/search` match sessions that are no
* longer running WITHOUT doing disk I/O per keystroke. Three properties matter
* and are pinned here: the snapshot stays bounded, the refresh never happens on
* the caller's timeline (fire-and-forget, single-flight, TTL-guarded), and the
* stored rows carry the owner needed to re-apply multi-user scoping on read,
* the snapshot is written unscoped, so losing that field would leak one user's
* folders into another user's search.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
buildHistorySessionIndexItems,
ensureHistorySessionIndexFresh,
getHistorySessionIndex,
isHistorySessionIndexStale,
resetHistorySessionIndex,
setHistoryIndexRefresher,
setHistorySessionIndex,
HISTORY_INDEX_MAX_ITEMS,
HISTORY_INDEX_TTL_MS,
type MergedSessionLike,
} from '../src/web/session-history-index.js';
beforeEach(() => {
resetHistorySessionIndex();
});
describe('buildHistorySessionIndexItems', () => {
const merged: MergedSessionLike[] = [
{ sessionId: 'a', name: 'w1-alpha', workingDir: '/home/u/alpha', lastActivityAt: 300 },
{ sessionId: 'b', name: '', workingDir: '/home/u/beta', claudeSessionId: 'uuid-b', createdAt: 200 },
{ sessionId: 'c', name: 'gamma', workingDir: '', lastActivityAt: 100 },
];
it('projects name, dir, timestamp, owner and liveness', () => {
const items = buildHistorySessionIndexItems(
merged,
new Map([
['a', 'alice'],
['b', undefined],
]),
new Set(['a'])
);
expect(items.map((i) => i.sessionId)).toEqual(['a', 'b', 'c']);
expect(items[0]).toMatchObject({ owner: 'alice', live: true, timestamp: 300 });
// Transcript-only row: no owner (host-wide) and not live.
expect(items[1]).toMatchObject({ owner: undefined, live: false, timestamp: 200, claudeSessionId: 'uuid-b' });
});
it('drops rows with neither a name nor a working directory', () => {
const items = buildHistorySessionIndexItems([{ sessionId: 'empty' }, ...merged], new Map(), new Set());
expect(items.some((i) => i.sessionId === 'empty')).toBe(false);
});
it('caps the projection at HISTORY_INDEX_MAX_ITEMS', () => {
const many: MergedSessionLike[] = Array.from({ length: HISTORY_INDEX_MAX_ITEMS + 50 }, (_, i) => ({
sessionId: `s${i}`,
name: `session ${i}`,
workingDir: `/home/u/p${i}`,
lastActivityAt: i,
}));
expect(buildHistorySessionIndexItems(many, new Map(), new Set())).toHaveLength(HISTORY_INDEX_MAX_ITEMS);
});
});
describe('snapshot storage', () => {
it('starts empty and stale', () => {
expect(getHistorySessionIndex().items).toEqual([]);
expect(isHistorySessionIndexStale()).toBe(true);
});
it('caps on write even when the caller did not', () => {
const items = Array.from({ length: HISTORY_INDEX_MAX_ITEMS + 10 }, (_, i) => ({
sessionId: `s${i}`,
name: 'x',
workingDir: '/x',
timestamp: i,
live: false,
}));
setHistorySessionIndex(items);
expect(getHistorySessionIndex().items).toHaveLength(HISTORY_INDEX_MAX_ITEMS);
});
it('goes stale again once the TTL elapses', () => {
const t0 = 1_000_000;
setHistorySessionIndex([{ sessionId: 's', name: 'n', workingDir: '/d', timestamp: 1, live: false }], t0);
expect(isHistorySessionIndexStale(t0 + HISTORY_INDEX_TTL_MS - 1)).toBe(false);
expect(isHistorySessionIndexStale(t0 + HISTORY_INDEX_TTL_MS + 1)).toBe(true);
});
});
describe('ensureHistorySessionIndexFresh', () => {
it('returns synchronously, the rebuild must never be on the request path', async () => {
let resolveRefresh: () => void = () => {};
const refresher = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveRefresh = resolve;
})
);
setHistoryIndexRefresher(refresher);
ensureHistorySessionIndexFresh();
// Called, but the caller is already past it while the rebuild is pending.
expect(refresher).toHaveBeenCalledTimes(1);
expect(getHistorySessionIndex().items).toEqual([]);
resolveRefresh();
await Promise.resolve();
});
it('is single-flight: a second call while a rebuild is pending is a no-op', async () => {
let resolveRefresh: () => void = () => {};
const refresher = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveRefresh = resolve;
})
);
setHistoryIndexRefresher(refresher);
ensureHistorySessionIndexFresh();
ensureHistorySessionIndexFresh();
ensureHistorySessionIndexFresh();
expect(refresher).toHaveBeenCalledTimes(1);
resolveRefresh();
await new Promise((r) => setTimeout(r, 0));
// Snapshot still stale (the fake refresher wrote nothing) → next call runs again.
ensureHistorySessionIndexFresh();
expect(refresher).toHaveBeenCalledTimes(2);
});
it('does not rebuild while the snapshot is fresh', () => {
const refresher = vi.fn(async () => {});
setHistoryIndexRefresher(refresher);
setHistorySessionIndex([{ sessionId: 's', name: 'n', workingDir: '/d', timestamp: 1, live: false }]);
ensureHistorySessionIndexFresh();
expect(refresher).not.toHaveBeenCalled();
});
it('keeps the previous snapshot when a rebuild throws, and retries next time', async () => {
setHistorySessionIndex([{ sessionId: 'keep', name: 'n', workingDir: '/d', timestamp: 1, live: false }], 1);
const refresher = vi.fn(async () => {
throw new Error('scan failed');
});
setHistoryIndexRefresher(refresher);
ensureHistorySessionIndexFresh();
await new Promise((r) => setTimeout(r, 0));
expect(getHistorySessionIndex().items[0].sessionId).toBe('keep');
ensureHistorySessionIndexFresh();
expect(refresher).toHaveBeenCalledTimes(2);
});
it('is a no-op when no refresher is registered', () => {
expect(() => ensureHistorySessionIndexFresh()).not.toThrow();
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* @fileoverview Worktree/branch identity on session rows (#265, #266).
*
* Two behaviours are pinned here:
* - the unified merge carries gitBranch/worktreeName/worktreeRepo through from
* the history source, and filterAndPaginate can search them;
* - the client-side badge helper renders `⑂ name · branch`, and stays SILENT
* when only a branch is known (a branch is not a worktree — badging those
* would put `⑂ master` on every ordinary session).
*
* The transcript extractor itself lives inside a closure in session-routes.ts
* and is covered by the route tests; what matters at this level is that the
* fields survive the merge and reach a label.
*/
import { describe, it, expect } from 'vitest';
import {
mergeUnifiedSessions,
filterAndPaginate,
type UnifiedSessionItem,
} from '../src/services/unified-session-service.js';
const historyRow = (over: Record<string, unknown> = {}) => ({
sessionId: 's1',
workingDir: '/repo/.claude/worktrees/autodev',
sizeBytes: 9000,
lastModified: '2026-01-01T00:00:00.000Z',
...over,
});
describe('worktree fields through the unified merge (#266)', () => {
it('carries gitBranch / worktreeName / worktreeRepo from the history source', () => {
const merged = mergeUnifiedSessions({
history: [historyRow({ gitBranch: 'feat/CF-195', worktreeName: 'autodev', worktreeRepo: '/repo' })],
});
expect(merged).toHaveLength(1);
expect(merged[0].worktreeName).toBe('autodev');
expect(merged[0].gitBranch).toBe('feat/CF-195');
expect(merged[0].worktreeRepo).toBe('/repo');
});
it('leaves the fields undefined for a non-worktree session rather than inventing them', () => {
const merged = mergeUnifiedSessions({ history: [historyRow({ workingDir: '/plain/repo' })] });
expect(merged[0].worktreeName).toBeUndefined();
expect(merged[0].gitBranch).toBeUndefined();
});
it('finds a session by worktree name and by branch', () => {
const items = [
{ sessionId: 'a', worktreeName: 'autodev', sources: ['history'] },
{ sessionId: 'b', gitBranch: 'feat/CF-195', sources: ['history'] },
{ sessionId: 'c', sources: ['history'] },
] as unknown as UnifiedSessionItem[];
expect(filterAndPaginate(items, { q: 'autodev' }).sessions.map((s) => s.sessionId)).toEqual(['a']);
expect(filterAndPaginate(items, { q: 'cf-195' }).sessions.map((s) => s.sessionId)).toEqual(['b']);
expect(filterAndPaginate(items, { q: 'nothing' }).sessions).toHaveLength(0);
});
});
/**
* Mirrors `_worktreeLabel` in terminal-ui.js. The frontend is plain browser JS
* with no module exports, so the logic is restated here; the rule it encodes —
* never print a branch that merely restates the worktree name — is the part
* worth pinning.
*/
function worktreeLabel(s: { worktreeName?: string; gitBranch?: string }): string {
const name = s.worktreeName;
if (!name) return '';
let branch = s.gitBranch || '';
if (branch === name || branch === `worktree-${name}`) branch = '';
if (branch.length > 24) branch = branch.slice(0, 23) + '…';
return '⑂ ' + [name, branch].filter(Boolean).join(' · ');
}
describe('worktree badge label', () => {
it('renders name and branch together', () => {
expect(worktreeLabel({ worktreeName: 'autodev', gitBranch: 'feat/CF-195' })).toBe('⑂ autodev · feat/CF-195');
});
it('renders NOTHING when only a branch is known — a branch is not a worktree', () => {
// Every ordinary repo session carries gitBranch. Badging those would put
// `⑂ master` on every row and bury the worktree rows this badge is for.
expect(worktreeLabel({ gitBranch: 'master' })).toBe('');
expect(worktreeLabel({ gitBranch: 'feat/CF-200' })).toBe('');
});
it('does not repeat the name when the branch just restates it', () => {
expect(worktreeLabel({ worktreeName: 'autodev', gitBranch: 'autodev' })).toBe('⑂ autodev');
expect(worktreeLabel({ worktreeName: 'autodev', gitBranch: 'worktree-autodev' })).toBe('⑂ autodev');
});
it('is empty for a session that is not on a worktree', () => {
expect(worktreeLabel({})).toBe('');
});
it('truncates a long branch so the single-line badge row cannot blow out', () => {
const label = worktreeLabel({ worktreeName: 'wt', gitBranch: 'feature/VERY-LONG-BRANCH-NAME-THAT-KEEPS-GOING' });
expect(label.length).toBeLessThanOrEqual(2 + 2 + 3 + 24);
expect(label.endsWith('…')).toBe(true);
});
});
+4 -4
View File
@@ -32,8 +32,8 @@ describe('shortcut registry and overlay', () => {
expect(appSource).not.toContain("key: '/'");
});
it('exposes shortcut overrides in a dedicated App Settings shortcuts tab', () => {
expect(htmlSource).toContain('data-tab="settings-shortcuts"');
it('exposes shortcut overrides in a dedicated App Settings shortcuts section', () => {
expect(htmlSource).toContain('data-section="settings-shortcuts"');
expect(htmlSource).toContain('id="settings-shortcuts"');
expect(htmlSource).toContain('id="appSettingsShortcutsList"');
expect(htmlSource).not.toContain('id="appSettingsShortcutOverrides"');
@@ -192,7 +192,7 @@ describe('shortcut settings persistence and capture', () => {
expect(app.showToast).toHaveBeenCalledWith('Shortcut must include Ctrl, Cmd, or Alt', 'error');
});
it('renders the shortcuts list when the Shortcuts settings tab is opened', () => {
it('renders the shortcuts list when the Shortcuts settings section is reached', () => {
const { app, elements } = loadSettingsHarness();
elements.appSettingsModal = { querySelectorAll: () => [] };
app.renderShortcutSettingsList = vi.fn();
@@ -200,7 +200,7 @@ describe('shortcut settings persistence and capture', () => {
app.switchSettingsTab('settings-shortcuts');
expect(app.renderShortcutSettingsList).toHaveBeenCalledTimes(1);
app.switchSettingsTab('settings-display');
app.switchSettingsTab('settings-terminal');
expect(app.renderShortcutSettingsList).toHaveBeenCalledTimes(1);
});
+79 -2
View File
@@ -3,12 +3,28 @@ import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
type ScrollInput = {
scrollLeft?: number;
clientWidth?: number;
scrollWidth?: number;
tabLeft?: number;
tabWidth?: number;
padding?: number;
};
function loadTabOverflowHelper() {
const context = vm.createContext({ window: {}, globalThis: {} });
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
vm.runInContext(source, context, { filename: 'constants.js' });
return (context.window as { CodemanTabOverflow: { shouldAutoWrapTabs: (input: unknown) => boolean } })
.CodemanTabOverflow;
return (
context.window as {
CodemanTabOverflow: {
shouldAutoWrapTabs: (input: unknown) => boolean;
computeTabScrollLeft: (input: ScrollInput) => number;
TAB_SCROLL_REVEAL_PX: number;
};
}
).CodemanTabOverflow;
}
describe('tab overflow layout policy', () => {
@@ -63,3 +79,64 @@ describe('tab overflow layout policy', () => {
expect(helper.shouldAutoWrapTabs({ ...base, tabCount: 1, scrollWidth: 1400, clientWidth: 760 })).toBe(false);
});
});
// Issue #257: the phone tab strip scrolls horizontally, so the active tab can
// sit entirely outside the visible slice. These pin the scroll target math that
// _scrollActiveTabIntoView() feeds with measured rects.
describe('mobile tab strip scroll-into-view policy', () => {
// A 5-tab phone strip: 335px visible of 558px of tabs.
const strip = { clientWidth: 335, scrollWidth: 558 };
const pad = 16;
it('scrolls right to reveal a tab past the right edge, leaving the reveal sliver', () => {
const helper = loadTabOverflowHelper();
// Last tab: 458..558, strip parked at 0.
const target = helper.computeTabScrollLeft({ ...strip, scrollLeft: 0, tabLeft: 458, tabWidth: 100 });
// 558 + 16 - 335 = 239, clamped to the 223px maximum.
expect(target).toBe(223);
// The revealed tab is now inside the window.
expect(458).toBeGreaterThanOrEqual(target);
expect(558).toBeLessThanOrEqual(target + strip.clientWidth);
});
it('scrolls left to reveal a tab before the left edge', () => {
const helper = loadTabOverflowHelper();
// First tab: 0..150, strip scrolled to the end.
expect(helper.computeTabScrollLeft({ ...strip, scrollLeft: 223, tabLeft: 0, tabWidth: 150 })).toBe(0);
// A middle tab partially cut off on the left: reveal it with the sliver.
expect(helper.computeTabScrollLeft({ ...strip, scrollLeft: 223, tabLeft: 200, tabWidth: 100 })).toBe(200 - pad);
});
it('leaves an already-visible tab alone (callers skip the write)', () => {
const helper = loadTabOverflowHelper();
expect(helper.computeTabScrollLeft({ ...strip, scrollLeft: 100, tabLeft: 152, tabWidth: 100 })).toBe(100);
});
it('never scrolls a strip that fits, and never leaves the scrollable range', () => {
const helper = loadTabOverflowHelper();
// Everything fits: nothing to scroll, whatever the tab geometry says.
expect(
helper.computeTabScrollLeft({ clientWidth: 900, scrollWidth: 400, scrollLeft: 0, tabLeft: 300, tabWidth: 100 })
).toBe(0);
// Clamped at both ends.
const low = helper.computeTabScrollLeft({ ...strip, scrollLeft: 40, tabLeft: 4, tabWidth: 100 });
expect(low).toBe(0);
const high = helper.computeTabScrollLeft({ ...strip, scrollLeft: 0, tabLeft: 500, tabWidth: 58 });
expect(high).toBeLessThanOrEqual(strip.scrollWidth - strip.clientWidth);
});
it('aligns the start of a tab too wide to fit the window', () => {
const helper = loadTabOverflowHelper();
// 330px tab in a 335px window: no position shows it plus padding.
expect(
helper.computeTabScrollLeft({ clientWidth: 335, scrollWidth: 900, scrollLeft: 0, tabLeft: 400, tabWidth: 330 })
).toBe(400);
});
it('tolerates missing measurements instead of producing NaN', () => {
const helper = loadTabOverflowHelper();
expect(helper.computeTabScrollLeft({})).toBe(0);
expect(helper.computeTabScrollLeft(undefined as unknown as ScrollInput)).toBe(0);
expect(helper.TAB_SCROLL_REVEAL_PX).toBe(pad);
});
});