mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
Merge remote-tracking branch 'origin/master' into feat/claude-voice-integration
# Conflicts: # CLAUDE.md
This commit is contained in:
+10
-5
@@ -135,11 +135,16 @@ describe('admin panel modal', () => {
|
||||
|
||||
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);
|
||||
// Match the SCRIPT TAG, not the bare filename: modal markup earlier in the
|
||||
// document cites these modules in comments ("session-ui.js: openSessionOptions"),
|
||||
// and a bare indexOf finds the comment instead of the load order.
|
||||
const at = (file: string) => {
|
||||
const i = INDEX_HTML.indexOf(`src="${file}"`);
|
||||
expect(i, `no <script src="${file}"> in index.html`).toBeGreaterThan(-1);
|
||||
return i;
|
||||
};
|
||||
expect(at('admin-ui.js')).toBeGreaterThan(at('settings-ui.js'));
|
||||
expect(at('session-ui.js')).toBeGreaterThan(at('admin-ui.js'));
|
||||
});
|
||||
|
||||
it('ships the header Admin Panel button hidden by default', () => {
|
||||
|
||||
@@ -59,8 +59,28 @@ describe('App Settings modal structure', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('opens on Terminal & Input, so Local Echo is the first thing in reach', () => {
|
||||
expect(settingsUi).toContain("this.switchSettingsTab('settings-terminal')");
|
||||
it('opens on Updates: the version and the updater above everything else', () => {
|
||||
expect(settingsUi).toContain("this.switchSettingsTab('settings-updates')");
|
||||
const modal = settingsModal();
|
||||
const order = [...modal.matchAll(/<section class="set-section" id="([a-z-]+)"/g)].map((m) => m[1]);
|
||||
// Rail and document must agree, or scroll-spy paints the wrong entry.
|
||||
const rail = [...modal.matchAll(/data-section="([a-z-]+)"/g)].map((m) => m[1]);
|
||||
expect(rail.slice(0, 3)).toEqual(['settings-updates', 'settings-terminal', 'settings-layout']);
|
||||
expect(order.slice(0, 3)).toEqual(['settings-updates', 'settings-terminal', 'settings-layout']);
|
||||
// Updates carries ONLY the version and the update action; the rest of the
|
||||
// system settings tail the document under System, out of the way.
|
||||
const updates = modal.match(/id="settings-updates"([\s\S]*?)<\/section>/)?.[1] ?? '';
|
||||
expect(updates).toContain('id="updateCurrentVersion"');
|
||||
expect(updates).toContain('id="updateCheckBtn"');
|
||||
expect(updates).not.toContain('id="appSettingsClaudeMdPath"');
|
||||
expect(rail[rail.length - 1]).toBe('settings-system');
|
||||
expect(order[order.length - 1]).toBe('settings-system');
|
||||
const system = modal.match(/id="settings-system"([\s\S]*?)<\/section>/)?.[1] ?? '';
|
||||
expect(system).toContain('id="appSettingsClaudeMdPath"');
|
||||
expect(system).toContain('id="appSettingsTunnelEnabled"');
|
||||
});
|
||||
|
||||
it('keeps Local Echo the first row of the second section', () => {
|
||||
const terminal = settingsModal().match(/id="settings-terminal"([\s\S]*?)<\/section>/);
|
||||
const localEcho = terminal?.[1].indexOf('appSettingsLocalEcho') ?? -1;
|
||||
const cjk = terminal?.[1].indexOf('appSettingsCjkInput') ?? -1;
|
||||
@@ -68,6 +88,33 @@ describe('App Settings modal structure', () => {
|
||||
expect(localEcho).toBeLessThan(cjk);
|
||||
});
|
||||
|
||||
it('gives every previewed chip an icon to clone, and a slot that exists', () => {
|
||||
// _syncLayoutPreview clones `.set-chip-ico` out of the chip, so a chip that
|
||||
// opts into the preview without an icon renders as an empty button, and one
|
||||
// pointing at a slot id that does not exist renders as nothing at all.
|
||||
const layout = settingsModal().match(/id="settings-layout"([\s\S]*?)<\/section>/)?.[1] ?? '';
|
||||
const chips = [...layout.matchAll(/<label class="set-chip"([^>]*)>([\s\S]*?)<\/label>/g)];
|
||||
const previewed = chips.filter(([, attrs]) => attrs.includes('data-preview='));
|
||||
expect(previewed.length).toBeGreaterThanOrEqual(15);
|
||||
for (const [, attrs, body] of previewed) {
|
||||
const kind = attrs.match(/data-preview="([a-z]+)"/)?.[1];
|
||||
expect(['header', 'panel', 'toolbar', 'float']).toContain(kind);
|
||||
expect(attrs, `chip ${body} needs a preview order`).toMatch(/data-preview-order="\d+"/);
|
||||
// A text token replaces the icon for readouts (plan usage, CPU, font size).
|
||||
const hasIcon = body.includes('class="set-chip-ico') || attrs.includes('data-preview-text=');
|
||||
expect(hasIcon, `chip ${body} has nothing to render in the preview`).toBe(true);
|
||||
}
|
||||
for (const id of [
|
||||
'appSettingsPreviewHeader',
|
||||
'appSettingsPreviewPanels',
|
||||
'appSettingsPreviewToolbar',
|
||||
'appSettingsPreviewFloats',
|
||||
]) {
|
||||
expect(layout).toContain(`id="${id}"`);
|
||||
expect(settingsUi).toContain(`'${id}'`);
|
||||
}
|
||||
});
|
||||
|
||||
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] ?? '';
|
||||
@@ -80,15 +127,15 @@ describe('App Settings modal structure', () => {
|
||||
expect(modal).toContain('id="appSettingsOpusContext1m"');
|
||||
});
|
||||
|
||||
it('never hides sections behind .modal-tab-content (that class means display:none)', () => {
|
||||
it('has retired the modal-tab chrome everywhere, not just here', () => {
|
||||
// Session Options and Add Case moved onto this same `set-*` surface, so the
|
||||
// old tab classes have no users left. A reappearance means a modal drifted
|
||||
// back off the shared surface (or the dead CSS was resurrected).
|
||||
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">');
|
||||
expect(html).not.toContain('class="modal-tabs"');
|
||||
expect(html).not.toContain('modal-tab-btn');
|
||||
const css = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
|
||||
expect(css).not.toContain('.modal-tab-btn {');
|
||||
});
|
||||
|
||||
it('exposes the rail hooks admin-ui.js injects the Users section into', () => {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @fileoverview Issue #273 and its mirror image: abbreviating `$HOME` in path labels.
|
||||
*
|
||||
* The rule ("show `~/project` rather than `/home/<user>/project`") had three
|
||||
* implementations in the frontend, and two of them were platform-specific in
|
||||
* opposite directions, so each looked correct to whoever wrote it:
|
||||
*
|
||||
* - the Run menu's Recent Sessions rows matched `/home/<user>/` only, so on
|
||||
* macOS nothing was stripped, every row spent its first ~19 characters on an
|
||||
* identical `/Users/<user>/` prefix, and the left-to-right ellipsis removed
|
||||
* the tail that identifies the row (#273),
|
||||
* - the case-manage list matched `/Users/<user>` only, so on a Linux host no
|
||||
* case path was ever abbreviated at all.
|
||||
*
|
||||
* Both now call `_shortenHomePath()`, which is pinned here for both layouts, and
|
||||
* a static guard fails if a fourth copy of the pattern appears.
|
||||
*
|
||||
* Loaded via `vm` against a stub CodemanApp with a fake DOM, same harness as
|
||||
* history-list-controls.test.ts. Port: none (no browser, no server).
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
|
||||
/**
|
||||
* The container the vm's `document.getElementById` resolves for the case list.
|
||||
* Swapped per test: the closure lives in THIS realm, so the shipping code inside
|
||||
* the vm reads whatever the current test installed.
|
||||
*/
|
||||
let currentCaseList: { innerHTML: string } | null = null;
|
||||
|
||||
function loadTerminalUiPrototype(): Record<string, any> {
|
||||
const source = readFileSync(resolve(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: () => null, createElement: () => ({}) },
|
||||
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;
|
||||
}
|
||||
|
||||
function loadSessionUiPrototype(): Record<string, any> {
|
||||
const source = readFileSync(resolve(PUBLIC, 'session-ui.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
CodemanApp: class CodemanApp {},
|
||||
VoiceInput: {},
|
||||
escapeHtml: (t: unknown) => String(t ?? ''),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: { getElementById: (id: string) => (id === 'caseManageList' ? currentCaseList : null) },
|
||||
window: { addEventListener: vi.fn() },
|
||||
});
|
||||
vm.runInContext(`${source}\nglobalThis.__proto = CodemanApp.prototype;`, context);
|
||||
return (context as unknown as { __proto: Record<string, any> }).__proto;
|
||||
}
|
||||
|
||||
const terminalProto = loadTerminalUiPrototype();
|
||||
const sessionProto = loadSessionUiPrototype();
|
||||
const shorten = (p: unknown) => terminalProto._shortenHomePath.call(terminalProto, p);
|
||||
|
||||
describe('_shortenHomePath', () => {
|
||||
it('abbreviates the Linux home prefix', () => {
|
||||
expect(shorten('/home/arkon/default/claudeman')).toBe('~/default/claudeman');
|
||||
});
|
||||
|
||||
it('abbreviates the macOS home prefix, which the Run menu never did (#273)', () => {
|
||||
expect(shorten('/Users/jordanryan/code/facet/facet-agency-ops')).toBe('~/code/facet/facet-agency-ops');
|
||||
});
|
||||
|
||||
it('abbreviates the home directory itself, not only paths below it', () => {
|
||||
// The case-manage list's old regex had no trailing slash and did collapse
|
||||
// this to "~"; keep that, or a case whose path IS $HOME would regress.
|
||||
expect(shorten('/home/arkon')).toBe('~');
|
||||
expect(shorten('/Users/jordanryan')).toBe('~');
|
||||
});
|
||||
|
||||
it('leaves paths that only look like a home prefix alone', () => {
|
||||
expect(shorten('/homer/bob/x')).toBe('/homer/bob/x');
|
||||
expect(shorten('/Userspace/bob/x')).toBe('/Userspace/bob/x');
|
||||
expect(shorten('/home')).toBe('/home');
|
||||
expect(shorten('/mnt/d/work')).toBe('/mnt/d/work');
|
||||
expect(shorten('/opt/codeman')).toBe('/opt/codeman');
|
||||
});
|
||||
|
||||
it('replaces only the leading occurrence', () => {
|
||||
expect(shorten('/home/arkon/home/bob/x')).toBe('~/home/bob/x');
|
||||
});
|
||||
|
||||
it('tolerates empty and missing input', () => {
|
||||
expect(shorten('')).toBe('');
|
||||
expect(shorten(undefined)).toBe('');
|
||||
expect(shorten(null)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderCaseManageList path labels', () => {
|
||||
function render(cases: Array<{ name: string; path: string; location?: string }>): string {
|
||||
currentCaseList = { innerHTML: '' };
|
||||
const app: any = {
|
||||
cases,
|
||||
_shortenHomePath: terminalProto._shortenHomePath,
|
||||
renderCaseManageList: sessionProto.renderCaseManageList,
|
||||
};
|
||||
app.renderCaseManageList();
|
||||
const html = currentCaseList.innerHTML;
|
||||
currentCaseList = null;
|
||||
return html;
|
||||
}
|
||||
|
||||
it('abbreviates a Linux case path (the mirror of #273)', () => {
|
||||
const html = render([{ name: 'demo', path: '/home/arkon/codeman-cases/demo' }]);
|
||||
expect(html).toContain('~/codeman-cases/demo');
|
||||
expect(html).not.toContain('/home/arkon/codeman-cases/demo');
|
||||
});
|
||||
|
||||
it('still abbreviates a macOS case path', () => {
|
||||
const html = render([{ name: 'demo', path: '/Users/jordanryan/codeman-cases/demo' }]);
|
||||
expect(html).toContain('~/codeman-cases/demo');
|
||||
expect(html).not.toContain('/Users/jordanryan/codeman-cases/demo');
|
||||
});
|
||||
|
||||
it('renders the row when a case has no path at all', () => {
|
||||
const html = render([{ name: 'demo', path: '' }]);
|
||||
expect(html).toContain('demo');
|
||||
expect(html).toContain('class="case-manage-path"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('single implementation of the home-prefix rule', () => {
|
||||
/** Every top-level frontend module (vendor/ and subdirs are not ours). */
|
||||
const sources = readdirSync(PUBLIC)
|
||||
.filter((name) => name.endsWith('.js'))
|
||||
.map((name) => ({ name, text: readFileSync(resolve(PUBLIC, name), 'utf8') }));
|
||||
|
||||
it('has exactly one home-prefix regex, in terminal-ui.js', () => {
|
||||
// Any regex literal anchored at a home root. Three of these had drifted
|
||||
// apart; a fourth would drift the same way.
|
||||
const pattern = /\/\^\\\/(?:\(\?:home\|Users\)|home|Users)\\\//g;
|
||||
const hits = sources.flatMap(({ name, text }) => (text.match(pattern) ?? []).map(() => name));
|
||||
expect(hits).toEqual(['terminal-ui.js']);
|
||||
});
|
||||
|
||||
it('routes both session-ui path labels through the helper', () => {
|
||||
// Deliberately counts calls rather than pinning source lines: the Run menu
|
||||
// row is being restructured in #274, and this guard should survive that as
|
||||
// long as the label still goes through the helper.
|
||||
const sessionUi = sources.find((s) => s.name === 'session-ui.js')!.text;
|
||||
const calls = sessionUi.match(/this\._shortenHomePath\(/g) ?? [];
|
||||
expect(calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -73,4 +73,36 @@ describe('read my mind phone key + alternates (static guards)', () => {
|
||||
expect(ui).toContain('readMyMindAlternates');
|
||||
expect(ui).toMatch(/\.textContent = suggestion\.prompt/);
|
||||
});
|
||||
|
||||
// Phase 3 part 2: the Rethink steer note (docs/readmymind-plan.md phase 3).
|
||||
it('wires the rethink steer note end to end: field, payload, phase visibility, reset', () => {
|
||||
// The field lives in the modal, capped to the schema's 2000-char limit,
|
||||
// and Enter in it triggers a rethink (mirroring the prompt field's
|
||||
// Enter-to-send).
|
||||
expect(html).toMatch(/id="readMyMindSteer"[^>]*maxlength="2000"/);
|
||||
expect(html).toMatch(/id="readMyMindSteer"[^>]*onkeydown="[^"]*rethinkReadMyMind\(\)"/);
|
||||
// Predict sends the trimmed note as `steer`, bounded to the schema cap.
|
||||
expect(ui).toMatch(/body\.steer = steer\.slice\(0, 2000\)/);
|
||||
// The row hides ONLY during loading: Rethink is live in both the ready
|
||||
// and the empty-result phases, so the note must be reachable in both.
|
||||
expect(ui).toMatch(/steerRow\.style\.display = phase === 'loading' \? 'none' : ''/);
|
||||
// A fresh open resets the note along with the rethink memory.
|
||||
expect(ui).toMatch(/steer\.value = ''/);
|
||||
});
|
||||
|
||||
it('styles the footer with btn-toolbar (bare "btn btn-*" matches no CSS in this codebase)', () => {
|
||||
const modal = html.slice(html.indexOf('id="readMyMindModal"'), html.indexOf('id="approvalsDrawer"'));
|
||||
// The unstyled classes the footer originally shipped with must not return.
|
||||
expect(modal).not.toMatch(/class="btn /);
|
||||
expect(modal.match(/class="btn-toolbar/g)?.length).toBe(4);
|
||||
expect(modal).toMatch(/class="btn-toolbar btn-primary"[^>]*sendReadMyMind\(true\)/);
|
||||
// btn-toolbar is display:flex (block-level): without the desktop footer
|
||||
// row rule the four buttons would stack vertically.
|
||||
expect(styles).toMatch(/\.readmymind-modal \.modal-footer \{[^}]*display: flex/);
|
||||
// The skin block's bare .btn-toolbar (0,2,1) greys out .btn-primary
|
||||
// (0,2,0), so Send's accent must be re-asserted at higher specificity.
|
||||
expect(styles).toMatch(/\.readmymind-modal \.modal-footer \.btn-toolbar\.btn-primary \{[^}]*var\(--accent\)/);
|
||||
// The phone block sizes the same class for finger targets.
|
||||
expect(phoneBlock).toMatch(/\.readmymind-modal \.modal-footer \.btn-toolbar/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -760,6 +760,11 @@ describe('case selector refresh', () => {
|
||||
];
|
||||
app.showToast = vi.fn();
|
||||
|
||||
// deleteCase re-renders the case-manage list, whose path label goes through
|
||||
// _shortenHomePath. That method lives in terminal-ui.js, which this harness
|
||||
// does not load (the real app always has it: load order 7 before 12).
|
||||
app._shortenHomePath = (p: string) => p;
|
||||
|
||||
await app.deleteCase('deleted-case');
|
||||
|
||||
expect(quickStartCase.blur).toHaveBeenCalled();
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Session Options structural guard.
|
||||
*
|
||||
* The modal shares the `set-*` settings surface with App Settings, but its rail
|
||||
* is a real switcher: switchOptionsTab shows one `.set-section` and hides the
|
||||
* rest. Like App Settings, its load/save path is `getElementById` by a fixed set
|
||||
* of ids, so dropping or renaming an element in the markup fails silently — the
|
||||
* option just stops loading, or stops being written back.
|
||||
*
|
||||
* These tests read the REAL session-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 sessionUi = readFileSync(resolve(publicDir, 'session-ui.js'), 'utf8');
|
||||
|
||||
/** The Session Options markup, so assertions can't be satisfied elsewhere. */
|
||||
function optionsModal(): string {
|
||||
const start = html.indexOf('<div class="modal" id="sessionOptionsModal">');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const end = html.indexOf('<!-- Close Session Confirmation Modal -->', start);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
return html.slice(start, end);
|
||||
}
|
||||
|
||||
/** Body of a session-ui.js method, by name. */
|
||||
function methodBody(signature: string): string {
|
||||
const start = sessionUi.indexOf(`\n ${signature} {`);
|
||||
expect(start, `${signature} not found in session-ui.js`).toBeGreaterThan(-1);
|
||||
return sessionUi.slice(start, sessionUi.indexOf('\n },', start));
|
||||
}
|
||||
|
||||
const TABS = ['respawn', 'context', 'ralph', 'summary'];
|
||||
|
||||
describe('Session Options modal structure', () => {
|
||||
it('keeps every element openSessionOptions and switchOptionsTab touch by id', () => {
|
||||
const modal = optionsModal();
|
||||
const ids = new Set<string>();
|
||||
for (const sig of ['openSessionOptions(sessionId)', 'switchOptionsTab(tabName)', 'getRalphConfig()']) {
|
||||
for (const m of methodBody(sig).matchAll(/getElementById\('([A-Za-z0-9_-]+)'\)/g)) ids.add(m[1]);
|
||||
}
|
||||
// openSessionOptions also drives elements outside this modal (tabs, toasts);
|
||||
// only the ones it expects to find in here are this file's contract.
|
||||
const outside = new Set(['sessionOptionsDoc']);
|
||||
const missing = [...ids].filter((id) => !outside.has(id) && !modal.includes(`id="${id}"`));
|
||||
expect(missing).toEqual([]);
|
||||
expect(modal).toContain('id="sessionOptionsDoc"');
|
||||
});
|
||||
|
||||
it('pairs each rail entry with exactly one section, in the same order', () => {
|
||||
const modal = optionsModal();
|
||||
const rail = [...modal.matchAll(/class="set-rail-item[^"]*" data-tab="([a-z]+)"/g)].map((m) => m[1]);
|
||||
expect(rail).toEqual(TABS);
|
||||
for (const tab of TABS) {
|
||||
const hits = modal.split(`id="${tab}-tab"`).length - 1;
|
||||
expect(hits, `section ${tab}-tab should exist exactly once`).toBe(1);
|
||||
}
|
||||
// switchOptionsTab queries the rail by THIS class; `.modal-tab-btn` here
|
||||
// would silently stop the active marker from moving.
|
||||
expect(methodBody('switchOptionsTab(tabName)')).toContain("'#sessionOptionsModal .set-rail-item'");
|
||||
expect(methodBody('openSessionOptions(sessionId)')).toContain('.set-rail-item[data-tab="ralph"]');
|
||||
});
|
||||
|
||||
it('opens with exactly one section visible, the rest hidden', () => {
|
||||
const modal = optionsModal();
|
||||
const visible = TABS.filter((t) => modal.includes(`<section class="set-section" id="${t}-tab"`));
|
||||
expect(visible).toEqual(['respawn']);
|
||||
for (const t of TABS.filter((t) => t !== 'respawn')) {
|
||||
expect(modal).toContain(`<section class="set-section hidden" id="${t}-tab"`);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the Claude-only rail entries marked, so external CLIs lose them', () => {
|
||||
const modal = optionsModal();
|
||||
for (const tab of ['respawn', 'ralph']) {
|
||||
const entry = modal.match(new RegExp(`<button[^>]*data-tab="${tab}"[^>]*>`))?.[0] ?? '';
|
||||
expect(entry, `${tab} rail entry`).toContain('data-claude-only');
|
||||
}
|
||||
expect(modal.match(/<button[^>]*data-tab="context"[^>]*>/)?.[0]).not.toContain('data-claude-only');
|
||||
});
|
||||
|
||||
it('uses the shared settings surface rather than the modal-tab chrome', () => {
|
||||
const modal = optionsModal();
|
||||
expect(modal).toContain('class="modal-content modal-lg set-shell"');
|
||||
expect(modal).toContain('class="set-body"');
|
||||
expect(modal).not.toContain('class="modal-tabs"');
|
||||
expect(modal).not.toContain('modal-tab-btn');
|
||||
expect(modal).not.toContain('modal-tab-content');
|
||||
// The `set-*` rules are shared by both modals through one :is() scope.
|
||||
const css = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
|
||||
expect(css).toContain(':is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row {');
|
||||
expect(css).toContain(':is(#sessionOptionsModal, #createCaseModal) .set-section.hidden {');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user