Files
Codeman/test/admin-ui.test.ts
T
Codeman maintainer 45ad9de89e feat(settings): rebuild App Settings as a rail over one scrolling document
The modal had grown to 8 tabs that wrapped onto two rows on desktop and
became a horizontal scroller on phones, with a "Display" mega-tab holding
11 sections and ~35 controls. Local Echo sat 60% down it, and the model
settings were split across two tabs whose three controls fought each
other (the 1M Opus toggle's own hint said it was "ignored when a Claude
Model is selected above").

Replaced with a left rail that is a TABLE OF CONTENTS over one scrolling
document: every section stays mounted, the rail follows the scroll, and
find-in-page works across the whole thing. Nine sections:

  Terminal & Input (Local Echo is the first row of the first section)
  Appearance, Header & Panels, Models, Agents & CLIs,
  Notifications, Voice, Shortcuts, System

Models are now one page. The picker is a card grid of BASE models with a
single "1M context window" switch; context becomes a property of the
chosen model and composes back into `claudeModel` as `base + [1m]`, which
retires the precedence trap. Thinking effort is a segmented control on
the same page, and the old Models tab (task routing) becomes a collapsed
Advanced block under it.

The 12 header-button toggles and the 8 panel toggles become chip grids,
which is most of the old Display tab reclaimed. Rows now say whether a
setting is per-device or synced, stated once per group.

Phones drop the rail for a sticky jump pill that names the current
section and opens a jump list, move Save into the header (the bottom
action bar cost 60px), and render groups as one inset rounded list with
hairline dividers instead of a stack of bordered cards.

Load and save are untouched: every control keeps its id, so
openAppSettings()/saveAppSettings() work as before. Model cards and the
effort segment are views over hidden <select>s that stay the source of
truth. test/app-settings-structure.test.ts pins that contract, plus the
rail hooks admin-ui.js injects the multi-user Users section into.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 03:59:43 +02:00

150 lines
6.8 KiB
TypeScript

/**
* @fileoverview Frontend test for admin-ui.js (multi-user identity boot + admin
* Users tab + change-password modal). Builds a JSDOM window in-test under the
* default node env (constructing the DOM in-test avoids the vitest environment
* comment-directive gotcha) and evaluates the real module against it.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
const ADMIN_UI = readFileSync(new URL('../src/web/public/admin-ui.js', import.meta.url), 'utf-8');
const INDEX_HTML = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf-8');
function resp(status: number, body: unknown) {
const r = {
status,
ok: status >= 200 && status < 300,
json: async () => body,
clone() {
return r;
},
};
return r;
}
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"><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' }
);
const win = dom.window as unknown as Window & typeof globalThis & { __codemanUser?: Record<string, unknown> };
win.fetch = (async (path: string) => {
if (path === '/api/me') return resp(200, { success: true, data: me });
if (path === '/api/admin/users') return resp(200, { success: true, data: [] });
return resp(200, { success: true });
}) as unknown as typeof fetch;
(win as unknown as { eval: (s: string) => void }).eval(ADMIN_UI);
// Let the async boot() (fetch /api/me → DOM inject) settle.
for (let i = 0; i < 4; i++) await new Promise((r) => setTimeout(r, 0));
return { dom, win };
}
describe('admin-ui boot', () => {
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 });
// 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 section for a regular user', async () => {
const { win } = await bootWith({ username: 'joe', role: 'user', multiUser: true, mustChangePassword: false });
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-section="settings-users"]')).toBeFalsy();
});
it('shows the change-password modal when mustChangePassword is set', async () => {
const { win } = await bootWith({ username: 'dave', role: 'user', multiUser: true, mustChangePassword: true });
const modal = win.document.getElementById('changePasswordModal') as HTMLElement | null;
expect(modal).toBeTruthy();
expect(modal!.style.display).toBe('flex');
// Forced: the cancel button is hidden.
expect((modal!.querySelector('#cpCancel') as HTMLElement).style.display).toBe('none');
});
it('reveals the header Admin Panel button for a multi-user admin only', async () => {
const hidden = (w: Window) =>
w.document.getElementById('adminPanelBtn')!.classList.contains('btn-admin-panel--hidden');
const a = await bootWith({ username: 'root', role: 'admin', multiUser: true, mustChangePassword: false });
expect(hidden(a.win)).toBe(false);
const b = await bootWith({ username: 'joe', role: 'user', multiUser: true, mustChangePassword: false });
expect(hidden(b.win)).toBe(true);
const c = await bootWith({ username: 'admin', role: 'admin', multiUser: false, mustChangePassword: false });
expect(hidden(c.win)).toBe(true);
});
});
describe('admin panel modal', () => {
it('opens for an admin, renders users, and shows the case-folder drawer', async () => {
const { win } = await bootWith({ username: 'root', role: 'admin', multiUser: true, mustChangePassword: false });
win.fetch = (async (path: string) => {
if (path === '/api/admin/users')
return resp(200, {
success: true,
data: [
{
username: 'root',
role: 'admin',
disabled: false,
mustChangePassword: false,
canBypassPermissions: true,
createdAt: 1,
lastLoginAt: 2,
stats: { liveSessions: 1, activeSessions: 2, caseCount: 1 },
},
],
});
if (path === '/api/admin/users/root/cases')
return resp(200, {
success: true,
data: { dir: '/tmp/spaces/root/cases', cases: [{ name: 'proj1', modifiedAt: 3, liveSessions: 0 }] },
});
return resp(200, { success: true });
}) as unknown as typeof fetch;
(win as unknown as { codemanAdmin: { openAdminPanel: () => void } }).codemanAdmin.openAdminPanel();
for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0));
const modal = win.document.getElementById('adminPanelModal') as HTMLElement;
expect(modal).toBeTruthy();
expect(modal.style.display).toBe('flex');
expect(modal.querySelector('#apTable')!.textContent).toContain('root');
(modal.querySelector('button[data-act="cases"]') as HTMLButtonElement).click();
for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0));
expect(modal.textContent).toContain('proj1');
expect(modal.textContent).toContain('/tmp/spaces/root/cases');
});
it('does NOT open for a regular user', async () => {
const { win } = await bootWith({ username: 'joe', role: 'user', multiUser: true, mustChangePassword: false });
(win as unknown as { codemanAdmin: { openAdminPanel: () => void } }).codemanAdmin.openAdminPanel();
expect(win.document.getElementById('adminPanelModal')).toBeFalsy();
});
});
describe('index.html wiring', () => {
it('loads admin-ui.js after settings-ui.js and before session-ui.js', () => {
const settings = INDEX_HTML.indexOf('settings-ui.js');
const admin = INDEX_HTML.indexOf('admin-ui.js');
const session = INDEX_HTML.indexOf('session-ui.js');
expect(admin).toBeGreaterThan(settings);
expect(session).toBeGreaterThan(admin);
});
it('ships the header Admin Panel button hidden by default', () => {
expect(INDEX_HTML).toContain('id="adminPanelBtn"');
expect(INDEX_HTML).toContain('btn-admin-panel--hidden');
});
});