2026-08-10 11:19:56 +02:00
|
|
|
/**
|
|
|
|
|
* 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');
|
2026-08-23 12:21:02 -04:00
|
|
|
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
|
2026-08-10 11:19:56 +02:00
|
|
|
|
|
|
|
|
/** 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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-25 17:23:55 +08:00
|
|
|
const TABS = ['respawn', 'context', 'ralph', 'summary', 'git'];
|
2026-08-10 11:19:56 +02:00
|
|
|
|
|
|
|
|
describe('Session Options modal structure', () => {
|
|
|
|
|
it('keeps every element openSessionOptions and switchOptionsTab touch by id', () => {
|
|
|
|
|
const modal = optionsModal();
|
|
|
|
|
const ids = new Set<string>();
|
2026-10-25 17:23:55 +08:00
|
|
|
for (const sig of [
|
|
|
|
|
'openSessionOptions(sessionId)',
|
|
|
|
|
'switchOptionsTab(tabName)',
|
|
|
|
|
'getRalphConfig()',
|
|
|
|
|
'updateGitFetchStatus(sessionId)',
|
|
|
|
|
]) {
|
2026-08-10 11:19:56 +02:00
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-25 20:04:01 +08:00
|
|
|
it('keeps the Git rail entry for every local session, and hides it for the ones nothing is ever fetched for', () => {
|
2026-10-25 17:23:55 +08:00
|
|
|
const modal = optionsModal();
|
2026-10-25 20:04:01 +08:00
|
|
|
// A shell session has repositories too, so Git must not ride the Claude-only flag.
|
2026-10-25 17:23:55 +08:00
|
|
|
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"]');
|
2026-11-06 03:29:35 +01:00
|
|
|
// 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' : ''/
|
|
|
|
|
);
|
2026-10-25 17:23:55 +08:00
|
|
|
});
|
|
|
|
|
|
2026-10-25 20:04:01 +08:00
|
|
|
it('makes the Git rows inert while a session follows the app, the way the keep-warm rows go inert', () => {
|
2026-10-25 17:23:55 +08:00
|
|
|
const modal = optionsModal();
|
|
|
|
|
const update = methodBody('updateGitFetchStatus(sessionId)');
|
|
|
|
|
expect(update).toContain('set-row-disabled');
|
|
|
|
|
expect(update).toContain('el.disabled = follows');
|
2026-10-25 20:04:01 +08:00
|
|
|
// Decided from BOTH fields: a half-choice means the session is not following, and the half that still
|
2026-10-27 09:11:19 +01:00
|
|
|
// follows shows the app's value while the other shows its own; a switch-only check would gray out a live row.
|
2026-10-25 17:23:55 +08:00
|
|
|
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.
|
2026-10-25 20:04:01 +08:00
|
|
|
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\(/);
|
2026-10-25 17:23:55 +08:00
|
|
|
// 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');
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-10 11:19:56 +02:00
|
|
|
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');
|
2026-08-10 11:43:55 +02:00
|
|
|
expect(css).toContain(':is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row {');
|
|
|
|
|
expect(css).toContain(':is(#sessionOptionsModal, #createCaseModal) .set-section.hidden {');
|
2026-08-10 11:19:56 +02:00
|
|
|
});
|
2026-08-23 12:21:02 -04:00
|
|
|
|
|
|
|
|
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/);
|
|
|
|
|
});
|
2026-10-10 12:50:15 +08:00
|
|
|
|
|
|
|
|
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');
|
2026-11-06 03:29:35 +01:00
|
|
|
// 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)');
|
2026-10-10 12:50:15 +08:00
|
|
|
});
|
2026-08-10 11:19:56 +02:00
|
|
|
});
|