mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
The scheduler is the part that runs with nobody watching, so it is decided per folder rather than per session: sessions sharing a folder are the same clone and share one run that both see, `inFlight`/`lastRunAt` are keyed by the resolved folder, the schedule is stamped when a folder is picked instead of when its run starts, and a tick is skipped while the previous one is still going. The click route joins a run already going for the same folder rather than starting a second fetch in one clone. The tick keeps ONE bound for the git processes of the whole run and hands it to every folder, and bounds its folders with a bound of its own: the same semaphore cannot serve both levels, because a folder holding a slot would then wait for a slot its own fetch needs. Both bounds are `FETCH_CONCURRENCY`, so the ceiling is unchanged. The bound claim in CLAUDE.md, the `Limiter` jsdoc and docs/api-reference.md now describes it as it is. Before anything is fetched the folder is probed with the bounded path probe, so a case on a mount that stopped answering is skipped and never touched. The rest of the review: the hooks are named as they really are (`reference-transaction`, `pre-auto-gc` through the maintenance a fetch triggers, config commands such as `credential.helper`) and `FETCH_CONFIG_ARGS` turns maintenance and gc off for an unattended run; the claim that an agent in the same repository is not disturbed is replaced by the ref-lock collision it actually is; the click route publishes its own run as `trigger: 'click'`; `autoSaveGitFetch` uses `_apiJson` so a 400 shows the error toast and applies the returned settings; and the two wording fixes. Tests: the scheduler pins one run per folder, the two bounds, the probe before anything runs, and that closing the session holding the override keeps the folder while another session is still open on it; the route test pins the click publish and the join, asserting the join before the held run is released — releasing first lets the run finish and clears the lane, so the joiner would be entitled to its own fetch and the test would pass for the wrong reason.
156 lines
8.0 KiB
TypeScript
156 lines
8.0 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 and Docker 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 \? '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');
|
|
expect(methodBody('async copySessionPath()')).toContain('_copyText(session.workingDir)');
|
|
});
|
|
});
|