Files
Codeman/test/session-options-structure.test.ts
T
Codeman maintainer 1f6f21fd24 docs(adopt): document the routes and the overlay, correct stale comments
The two routes are public under /api/v1, so they get an API reference section.
The overlay gets an architecture note and a CLAUDE.md rule: an adopted session
has session.remote and session.docker undefined and a neutral workingDir, so any
new code that reads workingDir as a workspace must also test isAdopted. That is
the class of bug this round fixed a dozen times.

Records a measured limit: while the view exists an owner's kill-session leaves
the window alive under it until the tab closes.

Comments that still called workingDir the foreign pane's cwd now say what is
true since the wrapper got its neutral folder. Also drops a zh-CN key that
duplicated an existing one, and updates the two structure tests pinned on the
lines this round changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 03:29:35 +01:00

162 lines
8.3 KiB
TypeScript

/**
* 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');
const styles = readFileSync(resolve(publicDir, 'styles.css'), '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', 'git'];
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()',
'updateGitFetchStatus(sessionId)',
]) {
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('keeps the Git rail entry for every local session, and hides it for the ones nothing is ever fetched for', () => {
const modal = optionsModal();
// A shell session has repositories too, so Git must not ride the Claude-only flag.
expect(modal.match(/<button[^>]*data-tab="git"[^>]*>/)?.[0]).not.toContain('data-claude-only');
const open = methodBody('openSessionOptions(sessionId)');
expect(open).toContain('.set-rail-item[data-tab="git"]');
// Remote, Docker and adopted sessions answer 'unsupported' on both git routes and the scheduler never fetches
// them, so the entry goes the same way the Claude-only ones do.
expect(open).toMatch(
/gitTabBtn\.style\.display = session\.remote \|\| session\.docker \|\| session\.adopt \? 'none' : ''/
);
});
it('makes the Git rows inert while a session follows the app, the way the keep-warm rows go inert', () => {
const modal = optionsModal();
const update = methodBody('updateGitFetchStatus(sessionId)');
expect(update).toContain('set-row-disabled');
expect(update).toContain('el.disabled = follows');
// Decided from BOTH fields: a half-choice means the session is not following, and the half that still
// follows shows the app's value while the other shows its own; a switch-only check would gray out a live row.
expect(update).toMatch(
/follows = session\.gitFetchEnabled === undefined && session\.gitFetchIntervalSeconds === undefined/
);
// Following saves null on both halves, which is what clears the override on the route.
const save = methodBody('async autoSaveGitFetch()');
expect(save).toContain('{ enabled: null, intervalSeconds: null }');
// Saves through the helper that says whether the call went through: `_apiPost` hands back the Response
// without checking it, so a body the route refused (a 400) read as a save, and the panel showed a choice
// the server never took.
expect(save).toContain('this._apiJson(`/api/sessions/${this.editingSessionId}/git-fetch-settings`');
expect(save).toContain('if (!data)');
expect(save).not.toMatch(/await this\._apiPost\(/);
// The status row is runtime text this method writes, so translating it would rewrite the stamp.
expect(modal).toContain('id="gitFetchStatus" data-i18n-skip hidden');
});
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 {');
});
it('uses document-safe context columns and widens only at the desktop breakpoint', () => {
expect(styles).toMatch(/#sessionOptionsModal #context-tab\s*\{[^}]*minmax\(0, 1fr\)/s);
expect(styles).toMatch(
/@media \(min-width: 1200px\)[\s\S]*#sessionOptionsModal #context-tab[^}]*repeat\(2, minmax\(0, 1fr\)\)/
);
expect(styles).not.toMatch(/@media \(min-width: 680px\)[\s\S]{0,1200}#sessionOptionsModal #context-tab/);
});
it('shows the session working folder with a copy button', () => {
const modal = optionsModal();
expect(modal).toContain('id="modalSessionPath"');
expect(modal).toContain('app.copySessionPath()');
const open = methodBody('openSessionOptions(sessionId)');
expect(open).toContain("getElementById('modalSessionPath')");
expect(open).toContain('session.workingDir');
// An adopted session's workingDir is the neutral wrapper folder, so the row shows where the pane was.
expect(open).toContain('session.adopt?.paneCurrentPath || session.workingDir');
const copy = methodBody('async copySessionPath()');
expect(copy).toContain('session?.adopt?.paneCurrentPath || session?.workingDir');
expect(copy).toContain('_copyText(path)');
});
});