mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
Merge pull request #714: feat(git): a Fetch button in the Git status header, and an interval for it app-wide and per case
Conflict in CLAUDE.md (Adding Features, App setting) resolved by keeping both sides: #714's sentence that a setting the server acts on must be synced, after the `.strict()` clause, and #719's mutateSettingsFile() rule at the end of the bullet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
6612a89ebc
36 files changed
+3662
-44
No files matched your search
@@ -29,6 +29,7 @@ function bareServer(live: string[] = [], persisted: string[] = []): CleanupServe
|
||||
Object.assign(server, {
|
||||
sessions: new Map(live.map((id) => [id, {}])),
|
||||
keepWarmService: { remove: () => {} },
|
||||
gitFetchScheduler: { remove: () => {} },
|
||||
store: {
|
||||
getSession: () => null,
|
||||
getSessions: () => Object.fromEntries(persisted.map((id) => [id, {}])),
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||
|
||||
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
|
||||
@@ -195,6 +196,47 @@ describe('App Settings modal structure', () => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Git fetch on an interval (src/git-fetch-scheduler.ts). Both keys are SYNCED, not display keys, because
|
||||
* the server's scheduler is what acts on them: kept per-device, the scheduler would read nobody's number.
|
||||
* Off by default, and the interval clamped on save to the same bounds the schema accepts.
|
||||
*/
|
||||
it('keeps the Git fetch rows synced, off by default, and inside the bounds the scheduler acts on', () => {
|
||||
const modal = settingsModal();
|
||||
const bar = modal.match(/<h4>Bottom bar<\/h4>([\s\S]*?)(?=<div class="set-group-head">)/)?.[1];
|
||||
expect(bar, 'the Git fetch rows are not in the Bottom bar group').toBeTruthy();
|
||||
for (const id of ['appSettingsGitFetchEnabled', 'appSettingsGitFetchIntervalSeconds']) {
|
||||
const at = modal.indexOf(`id="${id}"`);
|
||||
expect(at, `no element ${id}`).toBeGreaterThan(-1);
|
||||
const row = modal.slice(modal.lastIndexOf('<div class="set-row"', at), modal.indexOf('</div>', at));
|
||||
expect(row).toContain('<span class="set-scope">synced</span>');
|
||||
}
|
||||
// The number row's own bounds must agree with the schema's, or the browser sends a value the PUT rejects.
|
||||
expect(modal).toContain('id="appSettingsGitFetchIntervalSeconds" class="set-num" value="300" min="30" max="3600"');
|
||||
|
||||
// Display keys are the half of the per-device split that keeps a value off the server.
|
||||
const displayStart = settingsUi.indexOf('const displayKeys = new Set([');
|
||||
const displayBlock = settingsUi.slice(displayStart, settingsUi.indexOf(']);', displayStart));
|
||||
expect(displayBlock).not.toContain('gitFetchEnabled');
|
||||
expect(displayBlock).not.toContain('gitFetchIntervalSeconds');
|
||||
// The strip destructure is the other half: an aliased key here never reaches the settings PUT.
|
||||
expect(settingsUi).not.toMatch(/gitFetch(?:Enabled|IntervalSeconds):\s*_\w+/);
|
||||
|
||||
// Declared in the .strict() schema — an undeclared key 400s the whole settings PUT.
|
||||
expect(SettingsUpdateSchema.safeParse({ gitFetchEnabled: true, gitFetchIntervalSeconds: 300 }).success).toBe(true);
|
||||
|
||||
// Off by default (only an explicit true loads as on), and an empty box falls back to the default interval.
|
||||
expect(settingsUi).toContain(
|
||||
"document.getElementById('appSettingsGitFetchEnabled').checked = settings.gitFetchEnabled === true;"
|
||||
);
|
||||
expect(settingsUi).toContain(
|
||||
"document.getElementById('appSettingsGitFetchIntervalSeconds').value = settings.gitFetchIntervalSeconds ?? 300;"
|
||||
);
|
||||
expect(settingsUi).toMatch(
|
||||
/gitFetchIntervalSeconds: Math\.min\(\s*3600,\s*Math\.max\(30, parseInt\(document\.getElementById\('appSettingsGitFetchIntervalSeconds'\)\.value, 10\) \|\| 300\)\s*\),/
|
||||
);
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @fileoverview The Git fetch interval rows (App Settings → Bottom bar, Session Options → Git) in Chinese
|
||||
* (zh-CN): each label and description runs through the real translator over the real index.html in JSDOM and
|
||||
* reads in Chinese with no English left beyond the product name and the technical names Git / git fetch, and
|
||||
* every new key — the save toast in session-ui.js included — is in the dictionary exactly once (a repeated key
|
||||
* in the object literal silently replaces the first).
|
||||
*
|
||||
* The Git tab's Status row is not here: its text is written at runtime by updateGitFetchStatus and carries
|
||||
* data-i18n-skip, so translating it would rewrite the stamp the code builds. 'sec' is not a key either — only
|
||||
* whole unit words are ('hours'), so 'sec' stays as written.
|
||||
*
|
||||
* Port: N/A.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
|
||||
const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
|
||||
// Keys that contain a quote are written escaped in the object literal, so the dictionary is counted unescaped.
|
||||
const DICT = I18N.replace(/\\'/g, "'").replace(/\\"/g, '"');
|
||||
|
||||
type Api = { configure(o: Record<string, string>): void; start(): void };
|
||||
|
||||
const ROWS = [
|
||||
'appSettingsGitFetchEnabled',
|
||||
'appSettingsGitFetchIntervalSeconds',
|
||||
'modalGitFetchFollow',
|
||||
'modalGitFetchEnabled',
|
||||
'modalGitFetchInterval',
|
||||
];
|
||||
|
||||
/** What may stay Latin: the product names, and Git / git fetch as technical names. */
|
||||
const leftover = (text: string) =>
|
||||
text.replace(/\b(Claude Code|Claude|Codeman|Ralph|Git|git|fetch)\b/g, '').match(/[A-Za-z]+/g) ?? [];
|
||||
|
||||
function rowStrings(doc: Document, id: string): { label: string; description: string } {
|
||||
const row = doc.getElementById(id)!.closest('.set-row')!;
|
||||
const label = row.querySelector('.set-row-label')!;
|
||||
// The row's own scope marker ('synced') rides inside the label; the dictionary key is the label without it.
|
||||
const scope = label.querySelector('.set-scope')?.textContent ?? '';
|
||||
return {
|
||||
label: label.textContent!.replace(scope, '').trim(),
|
||||
description: row.querySelector('.set-row-desc')!.textContent!.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/** The Git section's blurb, which is not a row. */
|
||||
function blurb(doc: Document): string {
|
||||
const section = doc.getElementById('git-tab')!;
|
||||
return section.querySelector('.set-section-blurb')!.textContent!.trim();
|
||||
}
|
||||
|
||||
const source = new JSDOM(INDEX);
|
||||
const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
|
||||
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
|
||||
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
|
||||
api.start();
|
||||
api.configure({ language: 'zh-CN' });
|
||||
|
||||
afterAll(() => {
|
||||
source.window.close();
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
describe('the Git fetch rows in zh-CN', () => {
|
||||
it.each(ROWS)('translates the %s row, with no English left', (id) => {
|
||||
const english = rowStrings(source.window.document, id);
|
||||
const chinese = rowStrings(dom.window.document, id);
|
||||
expect(english.label.length).toBeGreaterThan(0);
|
||||
for (const key of ['label', 'description'] as const) {
|
||||
expect(chinese[key]).not.toBe(english[key]);
|
||||
expect(leftover(chinese[key])).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('translates the Git section blurb too, which is not a row', () => {
|
||||
expect(blurb(dom.window.document)).not.toBe(blurb(source.window.document));
|
||||
expect(leftover(blurb(dom.window.document))).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves the Status row's runtime text to the code that writes it", () => {
|
||||
// updateGitFetchStatus builds this row's sentence; a dictionary key for it would overwrite the stamp.
|
||||
expect(INDEX).toContain('id="gitFetchStatus" data-i18n-skip hidden');
|
||||
});
|
||||
|
||||
it('adds each key to the dictionary once, the save toast included', () => {
|
||||
const texts = ROWS.flatMap((id) => Object.values(rowStrings(source.window.document, id)));
|
||||
texts.push(blurb(source.window.document), 'Git fetch settings saved');
|
||||
// 'sec' is deliberately not a key: only whole unit words are, so the unit stays as the markup writes it.
|
||||
expect(DICT).not.toMatch(/\n sec: /);
|
||||
for (const text of texts) {
|
||||
const quoted = DICT.split(`'${text}'`).length + DICT.split(`"${text}"`).length - 2;
|
||||
const bare = /^\w+$/.test(text) ? DICT.split(`\n ${text}: `).length - 1 : 0;
|
||||
expect(quoted + bare, text).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* @fileoverview The Git fetch interval scheduler (src/git-fetch-scheduler.ts): which sessions a tick runs,
|
||||
* and which it must not. Every rule is checked by calling `tick(now)` with an explicit clock, so nothing here
|
||||
* depends on fake timers or on how long a real tick takes — the scheduler owns no timers itself.
|
||||
*
|
||||
* The run belongs to the FOLDER, so these tests put two sessions on one folder and expect one fetch that
|
||||
* both see, and they check the tick's two bounds separately: one shared bound for the fetches of the whole
|
||||
* tick, and the folders bounded by a bound that cannot be the same semaphore. A folder is due when ANY session
|
||||
* open on it is due, so the order the sessions were read cannot decide it — that is checked in both orders.
|
||||
* A run carries the trigger that caused it, and what is published says the run's, not the tick's.
|
||||
*
|
||||
* Port: N/A.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
GitFetchScheduler,
|
||||
effectiveGitFetch,
|
||||
type GitFetchPorts,
|
||||
type GitFetchSessionView,
|
||||
} from '../src/git-fetch-scheduler.js';
|
||||
import {
|
||||
FETCH_CONCURRENCY,
|
||||
type GitFetchOptions,
|
||||
type GitFetchReport,
|
||||
type GitFetchRun,
|
||||
type GitFetchSettings,
|
||||
type GitFetchStatus,
|
||||
type GitFetchTrigger,
|
||||
} from '../src/git-fetch.js';
|
||||
import { DEFAULT_GIT_TIMEOUT_MS, MAX_REPOS } from '../src/git-workspace-status.js';
|
||||
import type { PathProbeKind } from '../src/utils/bounded-path-probe.js';
|
||||
|
||||
const SETTINGS: GitFetchSettings = { enabled: true, intervalSeconds: 300 };
|
||||
const VIEW = (id: string, extra: Partial<GitFetchSessionView> = {}): GitFetchSessionView => ({
|
||||
id,
|
||||
workingDir: `/w/${id}`,
|
||||
...extra,
|
||||
});
|
||||
|
||||
/** An empty report, in the shape a run produces. */
|
||||
const report = (cwd: string, name: string): GitFetchReport => ({
|
||||
state: 'ok',
|
||||
repos: [{ name, path: '.', ok: true }],
|
||||
reposTruncated: false,
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
|
||||
/** A run: the report plus the trigger that caused it, which is what the published row has to name. */
|
||||
const run = (cwd: string, name: string, trigger: GitFetchTrigger = 'interval'): GitFetchRun => ({
|
||||
report: report(cwd, name),
|
||||
trigger,
|
||||
});
|
||||
|
||||
type Harness = {
|
||||
scheduler: GitFetchScheduler;
|
||||
ports: GitFetchPorts;
|
||||
/** The folders the tick actually ran, in the order they were picked, with the trigger the tick passed. */
|
||||
calls: Array<{ cwd: string; opts: GitFetchOptions; trigger: GitFetchTrigger }>;
|
||||
published: Array<{ id: string; status: GitFetchStatus }>;
|
||||
/** Every path the tick probed, in order — the probe has to happen before anything is fetched. */
|
||||
probed: string[];
|
||||
settings: GitFetchSettings;
|
||||
dockerReads: number;
|
||||
/**
|
||||
* A session that closed: the server calls `remove()` when the session is gone, so this takes it out of the
|
||||
* ports as well as out of the scheduler. Otherwise a test that removes a session still sees it on the next
|
||||
* tick, and the folder it was on looks still open.
|
||||
*/
|
||||
remove(id: string): void;
|
||||
};
|
||||
|
||||
/** A run the test decides when to let finish, so a slow repository can be held open across ticks. */
|
||||
function deferred<T>(value: T) {
|
||||
let resolve = (v: T) => {};
|
||||
const promise = new Promise<T>((r) => (resolve = r));
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
/**
|
||||
* A scheduler over an injected set of ports: nothing here touches a settings file, a repository or the
|
||||
* server, so what is being pinned is the scheduler's own decisions.
|
||||
*/
|
||||
function harness(
|
||||
sessions: GitFetchSessionView[],
|
||||
opts: {
|
||||
settings?: GitFetchSettings;
|
||||
fetch?: (cwd: string, portOpts: GitFetchOptions, trigger: GitFetchTrigger) => Promise<GitFetchRun>;
|
||||
/** Default: every path answers as a folder, so a test that does not care about the probe stays quiet. */
|
||||
probe?: (path: string) => Promise<PathProbeKind>;
|
||||
} = {}
|
||||
): Harness {
|
||||
const customFetch = opts.fetch;
|
||||
const settings = opts.settings ?? { ...SETTINGS };
|
||||
const gone = new Set<string>();
|
||||
const h: Harness = {
|
||||
scheduler: undefined as unknown as GitFetchScheduler,
|
||||
ports: undefined as unknown as GitFetchPorts,
|
||||
calls: [],
|
||||
published: [],
|
||||
probed: [],
|
||||
settings,
|
||||
dockerReads: 0,
|
||||
remove: (id) => {
|
||||
gone.add(id);
|
||||
h.scheduler.remove(id);
|
||||
},
|
||||
};
|
||||
h.ports = {
|
||||
sessionIds: () => sessions.filter((s) => !gone.has(s.id)).map((s) => s.id),
|
||||
getSession: (id) => (gone.has(id) ? undefined : sessions.find((s) => s.id === id)),
|
||||
readSettings: async () => {
|
||||
if (h.settings === null) throw new Error('settings.json is mid-write');
|
||||
return h.settings;
|
||||
},
|
||||
dockerWorkspaces: async () => {
|
||||
h.dockerReads++;
|
||||
return [];
|
||||
},
|
||||
probePath: async (path) => {
|
||||
h.probed.push(path);
|
||||
return opts.probe ? await opts.probe(path) : 'directory';
|
||||
},
|
||||
fetch: async (cwd, portOpts, trigger) => {
|
||||
h.calls.push({ cwd, opts: portOpts, trigger });
|
||||
return customFetch ? customFetch(cwd, portOpts, trigger) : run(cwd, cwd.slice(3), trigger);
|
||||
},
|
||||
publish: (id, status) => h.published.push({ id, status }),
|
||||
};
|
||||
h.scheduler = new GitFetchScheduler(h.ports);
|
||||
return h;
|
||||
}
|
||||
|
||||
describe('a quiet tick', () => {
|
||||
it('costs one settings read and nothing else, because nothing is on', async () => {
|
||||
const h = harness([VIEW('a'), VIEW('b')], { settings: { enabled: false, intervalSeconds: 300 } });
|
||||
expect(await h.scheduler.tick(1000)).toBe(0);
|
||||
expect(h.calls).toEqual([]);
|
||||
// The probe and the Docker case list are only worth reading once something is actually due.
|
||||
expect(h.probed).toEqual([]);
|
||||
expect(h.dockerReads).toBe(0);
|
||||
});
|
||||
|
||||
it('reads settings FRESH every tick, so a live toggle takes effect on the next tick and not at boot', async () => {
|
||||
const h = harness([VIEW('a')], { settings: { enabled: false, intervalSeconds: 30 } });
|
||||
await h.scheduler.tick(0);
|
||||
expect(h.calls.length).toBe(0);
|
||||
|
||||
h.settings = { enabled: true, intervalSeconds: 30 };
|
||||
await h.scheduler.tick(1000);
|
||||
expect(h.calls.length).toBe(1);
|
||||
|
||||
// Turning it off has to stop the fetching, not just stop it starting again later.
|
||||
h.settings = { enabled: false, intervalSeconds: 30 };
|
||||
await h.scheduler.tick(2000);
|
||||
expect(h.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('skips this tick only when a settings read fails, because settings.json is rewritten in place', async () => {
|
||||
const h = harness([VIEW('a')]);
|
||||
h.settings = null as unknown as GitFetchSettings;
|
||||
const logged = vi.fn();
|
||||
h.ports.log = logged;
|
||||
expect(await h.scheduler.tick(0)).toBe(0);
|
||||
expect(h.calls).toEqual([]);
|
||||
expect(logged).toHaveBeenCalled();
|
||||
|
||||
h.settings = SETTINGS;
|
||||
// Nothing was recorded for the failed tick, so the folder is still due at once.
|
||||
expect(await h.scheduler.tick(1000)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the bounded probe', () => {
|
||||
it('probes the folder before fetching it, and skips anything that is not a directory', async () => {
|
||||
const kinds = new Map<string, PathProbeKind>([
|
||||
['/w/file', 'file'],
|
||||
['/w/unknown', 'unknown'],
|
||||
['/w/absent', 'absent'],
|
||||
]);
|
||||
const h = harness([VIEW('file'), VIEW('unknown'), VIEW('absent'), VIEW('ok')], {
|
||||
probe: async (path) => kinds.get(path) ?? 'directory',
|
||||
});
|
||||
const logged = vi.fn();
|
||||
h.ports.log = logged;
|
||||
expect(await h.scheduler.tick(0)).toBe(1);
|
||||
expect(h.calls.map((c) => c.cwd)).toEqual(['/w/ok']);
|
||||
// The folders that answered badly were probed and left alone, never fetched.
|
||||
expect(h.probed).toEqual(['/w/file', '/w/unknown', '/w/absent', '/w/ok']);
|
||||
expect(logged).toHaveBeenCalledTimes(3);
|
||||
// `unknown` is never read as `absent`: the log says what it answered rather than what it was assumed.
|
||||
expect(logged.mock.calls.join(' ')).toContain('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('who runs', () => {
|
||||
it('runs every session the app setting covers, once per interval', async () => {
|
||||
const h = harness([VIEW('a'), VIEW('b')], { settings: { enabled: true, intervalSeconds: 60 } });
|
||||
await h.scheduler.tick(0);
|
||||
expect(h.calls.map((c) => c.cwd).sort()).toEqual(['/w/a', '/w/b']);
|
||||
|
||||
await h.scheduler.tick(30_000); // not due yet
|
||||
expect(h.calls.length).toBe(2);
|
||||
|
||||
await h.scheduler.tick(60_000);
|
||||
expect(h.calls.length).toBe(4);
|
||||
});
|
||||
|
||||
it('runs a session that turned itself on even while the app-wide switch is off — that is what a per-session switch has to mean', async () => {
|
||||
const h = harness([VIEW('plain'), VIEW('own', { gitFetchEnabled: true, gitFetchIntervalSeconds: 30 })], {
|
||||
settings: { enabled: false, intervalSeconds: 300 },
|
||||
});
|
||||
await h.scheduler.tick(0);
|
||||
expect(h.calls.map((c) => c.cwd)).toEqual(['/w/own']);
|
||||
|
||||
// Its own interval, not the app's: due at 30 s, not at 300.
|
||||
await h.scheduler.tick(30_000);
|
||||
expect(h.calls.length).toBe(2);
|
||||
});
|
||||
|
||||
it('never fetches a remote or Docker session, exactly as the Git panel excludes them', async () => {
|
||||
const h = harness([
|
||||
VIEW('ssh', { remote: { host: 'x' } }),
|
||||
VIEW('docker', { docker: { container: 'c' } }),
|
||||
VIEW('local'),
|
||||
]);
|
||||
await h.scheduler.tick(0);
|
||||
expect(h.calls.map((c) => c.cwd)).toEqual(['/w/local']);
|
||||
});
|
||||
|
||||
it('re-checks the Docker rule every tick, so a session whose case became a Docker case stops fetching', async () => {
|
||||
const sessions = [VIEW('a')];
|
||||
const h = harness(sessions);
|
||||
await h.scheduler.tick(0);
|
||||
expect(h.calls.length).toBe(1);
|
||||
|
||||
sessions[0] = { ...sessions[0], docker: { container: 'c' } };
|
||||
await h.scheduler.tick(400_000);
|
||||
expect(h.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('uses the module defaults for the limit and the timeout, because those settings are per device and never reach the server', async () => {
|
||||
const h = harness([VIEW('a')]);
|
||||
await h.scheduler.tick(0);
|
||||
expect(h.calls[0].opts).toMatchObject({ maxRepos: MAX_REPOS, timeoutMs: DEFAULT_GIT_TIMEOUT_MS });
|
||||
});
|
||||
});
|
||||
|
||||
describe('one folder, one run', () => {
|
||||
it('fetches a shared folder once and publishes the result to every session open on it', async () => {
|
||||
const h = harness([VIEW('a'), { ...VIEW('b'), workingDir: '/w/a' }]);
|
||||
expect(await h.scheduler.tick(0)).toBe(1); // one folder, not two runs
|
||||
expect(h.calls.length).toBe(1);
|
||||
expect(h.published.map((p) => p.id).sort()).toEqual(['a', 'b']);
|
||||
// Both see the same run, so the panel's row says the same thing in both tabs.
|
||||
expect(new Set(h.published.map((p) => p.status.failed)).size).toBe(1);
|
||||
});
|
||||
|
||||
it('is due when ANY session open on the folder is due, in either read order, because the clock belongs to the folder and not to whichever session was read first', async () => {
|
||||
const short = { ...VIEW('short'), workingDir: '/w/shared', gitFetchIntervalSeconds: 30 };
|
||||
const long = { ...VIEW('long'), workingDir: '/w/shared', gitFetchIntervalSeconds: 300 };
|
||||
|
||||
for (const order of [
|
||||
[short, long],
|
||||
[long, short],
|
||||
]) {
|
||||
const h = harness(order);
|
||||
|
||||
// Nothing has run yet, so both are due and both are published from the one run. The folder is probed
|
||||
// once, not once per session open on it.
|
||||
expect(await h.scheduler.tick(0)).toBe(1);
|
||||
expect(h.probed).toEqual(['/w/shared']);
|
||||
expect(h.published.map((p) => p.id).sort()).toEqual(['long', 'short']);
|
||||
|
||||
// Before the short interval elapses neither is due, and the order they were read in cannot change that.
|
||||
h.published.length = 0;
|
||||
expect(await h.scheduler.tick(15_000)).toBe(0);
|
||||
expect(h.published).toEqual([]);
|
||||
|
||||
// The short session is past its own interval, so the folder is fetched — once, for both. The run that
|
||||
// happens covers the long session's interval too, and both still get it because both are open on the
|
||||
// folder: publishing only the session that made the folder due would leave the other tab on a stale row.
|
||||
h.published.length = 0;
|
||||
expect(await h.scheduler.tick(30_000)).toBe(1);
|
||||
expect(h.calls.length).toBe(2);
|
||||
expect(h.published.map((p) => p.id).sort()).toEqual(['long', 'short']);
|
||||
|
||||
// The long one's own interval comes round, so the folder is due again.
|
||||
h.published.length = 0;
|
||||
expect(await h.scheduler.tick(300_000)).toBe(1);
|
||||
expect(h.published.map((p) => p.id).sort()).toEqual(['long', 'short']);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not pick a folder again while its run is going, and does not run a second tick over the same selection', async () => {
|
||||
const ids = Array.from({ length: FETCH_CONCURRENCY + 2 }, (_, i) => `s${i}`);
|
||||
const gate = deferred<GitFetchRun>(run('/w/first', 'first'));
|
||||
const h = harness(
|
||||
ids.map((id) => VIEW(id)),
|
||||
{
|
||||
settings: { enabled: true, intervalSeconds: 30 },
|
||||
fetch: () => gate.promise,
|
||||
}
|
||||
);
|
||||
|
||||
const first = h.scheduler.tick(0);
|
||||
await new Promise((resolve) => setImmediate(resolve)); // the first runs are going, the rest are queued
|
||||
const picked = h.calls.length;
|
||||
expect(picked).toBe(FETCH_CONCURRENCY);
|
||||
|
||||
// The server fires the next tick without waiting. The schedule was stamped at selection, so the folders
|
||||
// already picked are not picked again, and this tick is skipped while the previous one is still going.
|
||||
expect(await h.scheduler.tick(15_000)).toBe(0);
|
||||
expect(h.calls.length).toBe(picked);
|
||||
|
||||
gate.resolve(run('/w/first', 'first'));
|
||||
await first;
|
||||
// Every folder ran exactly once, and no folder ran twice.
|
||||
expect(h.calls.length).toBe(ids.length);
|
||||
expect(new Set(h.calls.map((c) => c.cwd)).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('shares one bound across the whole tick instead of giving each folder its own, and bounds the folders with a bound of its own', async () => {
|
||||
const ids = Array.from({ length: FETCH_CONCURRENCY + 1 }, (_, i) => `s${i}`);
|
||||
let fetchesLive = 0;
|
||||
let fetchesPeak = 0;
|
||||
let foldersLive = 0;
|
||||
let foldersPeak = 0;
|
||||
const hold = () => new Promise((resolve) => setImmediate(resolve));
|
||||
const h = harness(
|
||||
ids.map((id) => VIEW(id)),
|
||||
{
|
||||
// Every folder is handed the SAME limiter for its work, so that work cannot get its own bound: a
|
||||
// per-call bound would let 5 folders × 6 items run at once. The folders themselves are bounded by a
|
||||
// DIFFERENT bound, because one semaphore cannot serve both levels — a folder holding a slot and then
|
||||
// waiting for one for its own fetch is waiting on itself, and the folders that fill the bound are
|
||||
// exactly the ones blocking it. If the tick used one semaphore for both, this test would hang and fail
|
||||
// as a timeout rather than as an assertion.
|
||||
fetch: async (cwd, opts) => {
|
||||
foldersLive++;
|
||||
foldersPeak = Math.max(foldersPeak, foldersLive);
|
||||
await opts.limiter?.([0, 1, 2, 3, 4, 5], async () => {
|
||||
fetchesLive++;
|
||||
fetchesPeak = Math.max(fetchesPeak, fetchesLive);
|
||||
await hold();
|
||||
fetchesLive--;
|
||||
});
|
||||
foldersLive--;
|
||||
return run(cwd, cwd.slice(3));
|
||||
},
|
||||
}
|
||||
);
|
||||
await h.scheduler.tick(0);
|
||||
expect(fetchesPeak).toBe(FETCH_CONCURRENCY); // the tick's ceiling, not each folder's own
|
||||
expect(foldersPeak).toBe(FETCH_CONCURRENCY);
|
||||
expect(h.calls.length).toBe(ids.length); // every folder ran, none was left waiting forever
|
||||
});
|
||||
|
||||
it('times the interval from the START of a run, so a slow repository does not push the next one out', async () => {
|
||||
const slow = deferred<GitFetchRun>(run('/w/a', 'a'));
|
||||
const h = harness([VIEW('a')], { settings: { enabled: true, intervalSeconds: 30 }, fetch: () => slow.promise });
|
||||
|
||||
const first = h.scheduler.tick(0);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(h.calls.length).toBe(1);
|
||||
|
||||
// The run takes 200 s, longer than its own interval. Once it lands, the folder is already past due: the
|
||||
// next tick runs it again instead of waiting another 30 s from when the slow one finished.
|
||||
slow.resolve(run('/w/a', 'a'));
|
||||
await first;
|
||||
expect(await h.scheduler.tick(200_000)).toBe(1);
|
||||
expect(h.calls.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what a run reports', () => {
|
||||
it("publishes the run as the interval's own, so the panel can say the number was not asked for", async () => {
|
||||
const h = harness([VIEW('a')]);
|
||||
await h.scheduler.tick(0);
|
||||
expect(h.published).toEqual([
|
||||
{
|
||||
id: 'a',
|
||||
status: { ranAt: expect.any(Number), trigger: 'interval', state: 'ok', repos: 1, failed: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('publishes the trigger of the run that actually happened rather than the trigger the tick asked for, because a tick that joined a click did not fetch anything', async () => {
|
||||
const h = harness([VIEW('a')], {
|
||||
// The port is `fetchWorkspaceOnce`, so it can hand back a run a click had already started for this
|
||||
// folder. The tick asked for its own; the row has to name the run.
|
||||
fetch: (cwd, _opts, trigger) => {
|
||||
expect(trigger).toBe('interval');
|
||||
return Promise.resolve({ report: report(cwd, 'a'), trigger: 'click' });
|
||||
},
|
||||
});
|
||||
await h.scheduler.tick(0);
|
||||
expect(h.published[0].status.trigger).toBe('click');
|
||||
});
|
||||
|
||||
it("reports a failure instead of retrying harder, and keeps the folder's next run at its own interval", async () => {
|
||||
const h = harness([VIEW('a')], {
|
||||
settings: { enabled: true, intervalSeconds: 30 },
|
||||
fetch: () => Promise.reject(new Error('spawn git ENOENT')),
|
||||
});
|
||||
const logged = vi.fn();
|
||||
h.ports.log = logged;
|
||||
await h.scheduler.tick(0);
|
||||
expect(logged).toHaveBeenCalled();
|
||||
expect(h.published).toEqual([]);
|
||||
// Still remembered, so the next run is on schedule rather than immediate.
|
||||
expect(h.scheduler.tracked()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the effective choice', () => {
|
||||
it("a session's own override wins over the app setting", () => {
|
||||
const settings = { enabled: false, intervalSeconds: 600 };
|
||||
expect(effectiveGitFetch(VIEW('a', { gitFetchEnabled: true, gitFetchIntervalSeconds: 45 }), settings)).toEqual({
|
||||
enabled: true,
|
||||
intervalSeconds: 45,
|
||||
});
|
||||
// Half a choice still follows the app for the other half.
|
||||
expect(effectiveGitFetch(VIEW('b', { gitFetchEnabled: true }), settings)).toEqual({
|
||||
enabled: true,
|
||||
intervalSeconds: 600,
|
||||
});
|
||||
});
|
||||
|
||||
it('re-clamps a persisted interval that can predate the schema bounds', () => {
|
||||
expect(effectiveGitFetch(VIEW('a', { gitFetchIntervalSeconds: 5 }), SETTINGS)).toEqual({
|
||||
enabled: true,
|
||||
intervalSeconds: 30,
|
||||
});
|
||||
expect(effectiveGitFetch(VIEW('b', { gitFetchIntervalSeconds: 9000 }), SETTINGS)).toEqual({
|
||||
enabled: true,
|
||||
intervalSeconds: 3600,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('a session that goes away', () => {
|
||||
it('takes its own choice with it, but keeps the folder while another session is still open on it', async () => {
|
||||
const sessions = [VIEW('a'), { ...VIEW('b'), workingDir: '/w/a' }];
|
||||
const h = harness(sessions);
|
||||
await h.scheduler.tick(0);
|
||||
expect(h.scheduler.tracked()).toBe(1);
|
||||
|
||||
h.remove('a');
|
||||
// The run belongs to the clone, so 'b' keeps the folder's schedule and its next run is still on time:
|
||||
// the choice that got the folder going was 'a's, but the folder is not 'a's.
|
||||
expect(h.scheduler.tracked()).toBe(1);
|
||||
await h.scheduler.tick(1000); // not due yet, and 'b' is still open on the folder
|
||||
expect(h.calls.length).toBe(1);
|
||||
|
||||
// The last session open on the folder goes, so the folder is forgotten: a session coming back on it is due
|
||||
// at once rather than on the schedule the closed one left behind.
|
||||
h.remove('b');
|
||||
expect(h.scheduler.tracked()).toBe(0);
|
||||
sessions.push({ ...VIEW('c'), workingDir: '/w/a' });
|
||||
await h.scheduler.tick(2000);
|
||||
expect(h.calls.length).toBe(2); // remembered again, from now
|
||||
});
|
||||
|
||||
it('stops everything after stop(), including a folder that was already due', async () => {
|
||||
const h = harness([VIEW('a')]);
|
||||
h.scheduler.stop();
|
||||
expect(await h.scheduler.tick(0)).toBe(0);
|
||||
expect(h.calls).toEqual([]);
|
||||
expect(h.scheduler.tracked()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @fileoverview The Session Options → Git rows (session-ui.js `updateGitFetchStatus`) as the page drives them: the
|
||||
* real index.html in JSDOM with the real app.js and session-ui.js loaded into it, and every session update
|
||||
* delivered through app.js `_onSessionUpdated`, the path a `session:updated` SSE event takes.
|
||||
*
|
||||
* 1. A working session sends `session:updated` on each working/idle change, and that path refreshes the open Git
|
||||
* tab every time, so the control the user is in is left alone: rewriting it put a half-typed "12" back to "300"
|
||||
* before `onchange` could save it. Enter saves with the field still focused, so the save writes the clamped
|
||||
* number back itself.
|
||||
* 2. The route takes one field at a time, so a session can hold half an override. The half it did not choose
|
||||
* shows the app's value, which is what the scheduler runs it with, never a default.
|
||||
*
|
||||
* Port: N/A (no server; the one save is answered by a stub).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const SID = 'session-git-rows';
|
||||
/** The app setting every case here runs against: on, every 900 s. */
|
||||
const APP = { gitFetchEnabled: true, gitFetchIntervalSeconds: 900 };
|
||||
|
||||
type Update = Record<string, unknown> & { id: string };
|
||||
type TestApp = Record<string, unknown> & {
|
||||
_onSessionUpdated(data: Update): void;
|
||||
autoSaveGitFetch(): Promise<void>;
|
||||
_apiJson: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
let dom: JSDOM;
|
||||
let CodemanApp: { prototype: object };
|
||||
|
||||
beforeAll(async () => {
|
||||
dom = new JSDOM(readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'), {
|
||||
runScripts: 'outside-only',
|
||||
url: 'http://localhost/',
|
||||
});
|
||||
// app.js starts the app on DOMContentLoaded. Loading it once the document is complete means that never fires
|
||||
// here, so the real constructor (SSE, WebSocket, timers) never runs in a window with no server behind it.
|
||||
await new Promise<void>((done) =>
|
||||
dom.window.document.readyState === 'complete' ? done() : dom.window.addEventListener('load', () => done())
|
||||
);
|
||||
const context = dom.getInternalVMContext();
|
||||
// app.js's last line publishes MobileDetection (mobile-handlers.js), which nothing here otherwise needs.
|
||||
vm.runInContext('var MobileDetection = { isTouchDevice: () => false };', context);
|
||||
for (const file of ['constants.js', 'app.js', 'session-ui.js']) {
|
||||
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
|
||||
}
|
||||
CodemanApp = vm.runInContext('CodemanApp', context);
|
||||
});
|
||||
|
||||
afterAll(() => dom.window.close());
|
||||
|
||||
beforeEach(() => {
|
||||
(dom.window.document.activeElement as HTMLElement | null)?.blur?.();
|
||||
});
|
||||
|
||||
const el = <T extends HTMLElement = HTMLInputElement>(id: string) => dom.window.document.getElementById(id) as T;
|
||||
|
||||
/** An app with the Git tab open on SID and everything the update path draws besides it stubbed. */
|
||||
function openGitTab(): TestApp {
|
||||
const app = Object.create(CodemanApp.prototype) as TestApp;
|
||||
Object.assign(app, {
|
||||
sessions: new Map(),
|
||||
_closingSessions: new Set(),
|
||||
activeSessionId: null,
|
||||
editingSessionId: SID,
|
||||
renderSessionTabs: vi.fn(),
|
||||
updateCost: vi.fn(),
|
||||
updateSubagentParentNames: vi.fn(),
|
||||
showToast: vi.fn(),
|
||||
loadAppSettingsFromStorage: () => ({ ...APP }),
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('Session Options → Git, under session updates', () => {
|
||||
it('leaves the interval the user is typing alone when a session update arrives, and still refreshes the run line', () => {
|
||||
const app = openGitTab();
|
||||
app._onSessionUpdated({ id: SID, status: 'idle', gitFetchEnabled: true, gitFetchIntervalSeconds: 300 });
|
||||
const num = el('modalGitFetchInterval');
|
||||
expect(num.disabled).toBe(false);
|
||||
expect(num.value).toBe('300');
|
||||
|
||||
num.focus();
|
||||
expect(dom.window.document.activeElement).toBe(num);
|
||||
num.value = '12';
|
||||
// The working/idle flip that used to put the field back to 300, carrying a scheduled run's result this time.
|
||||
app._onSessionUpdated({
|
||||
id: SID,
|
||||
status: 'busy',
|
||||
gitFetchEnabled: true,
|
||||
gitFetchIntervalSeconds: 300,
|
||||
gitFetch: { ranAt: Date.now(), trigger: 'interval', state: 'ok', repos: 2, failed: 0 },
|
||||
});
|
||||
expect(num.value).toBe('12');
|
||||
expect(el('gitFetchStatus').textContent).toBe('Last run just now on the interval · 2 repositories');
|
||||
|
||||
// Only the control the user is in is spared: once they leave it, an update shows the stored value again.
|
||||
num.blur();
|
||||
app._onSessionUpdated({ id: SID, status: 'idle', gitFetchEnabled: true, gitFetchIntervalSeconds: 300 });
|
||||
expect(num.value).toBe('300');
|
||||
});
|
||||
|
||||
it('writes the clamped interval back when Enter saves with the field still focused', async () => {
|
||||
const app = openGitTab();
|
||||
app._apiJson = vi.fn(async (_url: string, opts: { body: unknown }) => ({ gitFetchSettings: opts.body }));
|
||||
app._onSessionUpdated({ id: SID, status: 'idle', gitFetchEnabled: true, gitFetchIntervalSeconds: 300 });
|
||||
const num = el('modalGitFetchInterval');
|
||||
num.focus();
|
||||
num.value = '12';
|
||||
|
||||
await app.autoSaveGitFetch();
|
||||
expect(app._apiJson).toHaveBeenCalledWith(`/api/sessions/${SID}/git-fetch-settings`, {
|
||||
method: 'POST',
|
||||
body: { enabled: true, intervalSeconds: 30 },
|
||||
});
|
||||
expect(dom.window.document.activeElement).toBe(num);
|
||||
expect(num.value).toBe('30');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Options → Git, with half an override', () => {
|
||||
it("shows the app's switch beside an interval-only override, because the scheduler runs it with that switch", () => {
|
||||
const app = openGitTab();
|
||||
app._onSessionUpdated({ id: SID, status: 'idle', gitFetchIntervalSeconds: 120 });
|
||||
expect(el('modalGitFetchFollow').checked).toBe(false);
|
||||
expect(el('modalGitFetchEnabled').checked).toBe(true);
|
||||
expect(el('modalGitFetchInterval').value).toBe('120');
|
||||
// Not following, so both rows are live, and touching either saves what they show.
|
||||
expect(el('modalGitFetchEnabled').disabled).toBe(false);
|
||||
expect(el('modalGitFetchInterval').disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the app's interval beside a switch-only override, because the scheduler runs it at that interval", () => {
|
||||
const app = openGitTab();
|
||||
app._onSessionUpdated({ id: SID, status: 'idle', gitFetchEnabled: true });
|
||||
expect(el('modalGitFetchFollow').checked).toBe(false);
|
||||
expect(el('modalGitFetchEnabled').checked).toBe(true);
|
||||
expect(el('modalGitFetchInterval').value).toBe('900');
|
||||
});
|
||||
|
||||
it('follows the app on both rows, inert, when the session holds no choice of its own', () => {
|
||||
const app = openGitTab();
|
||||
app._onSessionUpdated({ id: SID, status: 'idle' });
|
||||
expect(el('modalGitFetchFollow').checked).toBe(true);
|
||||
expect(el('modalGitFetchEnabled').checked).toBe(true);
|
||||
expect(el('modalGitFetchInterval').value).toBe('900');
|
||||
expect(el('modalGitFetchEnabled').disabled).toBe(true);
|
||||
expect(el('modalGitFetchInterval').disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,770 @@
|
||||
/**
|
||||
* @fileoverview Tests for the Git panel's Fetch button (src/git-fetch.ts): the only git command the Git panel
|
||||
* runs that writes, and the only one that can run without being asked — which is why it is a click, or an
|
||||
* interval the user turned on, and why the writing and the hooks it can run are pinned here. Cloning and
|
||||
* worktree management write too, but they write when the user asked.
|
||||
*
|
||||
* Four halves, mirroring the module:
|
||||
*
|
||||
* 1. The PURE half — the fixed argv, the `-c` overrides the runner adds ahead of it, the ssh environment the
|
||||
* runner refuses to override (`fetchEnvFor`), the failure classification, and the two reads the interval
|
||||
* scheduler depends on: what the settings say (`gitFetchSettingsFrom`) and what a run did
|
||||
* (`gitFetchStatusFrom`). No spawning.
|
||||
* 2. The IO half — a REAL git fetching a REAL local bare repo, so that "remote-tracking refs only" is
|
||||
* proven against git's actual behavior: HEAD, the index and the working tree must be exactly what they
|
||||
* were while the remote-tracking ref moves. The rest of the half uses an injected runner over real
|
||||
* repositories on disk, because what is being pinned is WHICH repositories git is allowed to touch.
|
||||
* 3. The process group — a timed-out fetch has to take its `git-remote-http` helper with it, against a
|
||||
* server that accepts and never answers, so the assertion is about what is still RUNNING after the answer
|
||||
* went out. A test that only checked the returned error would pass with the old `execFile` runner, which
|
||||
* signals the parent only.
|
||||
* 4. The join (`fetchWorkspaceOnce`) — one run per folder at a time, because two fetches in one clone
|
||||
* collide on git's ref lock, the bound is ONE limiter shared with the scheduler's tick rather than a bound
|
||||
* each run gets for itself, and the trigger a join reports is the joined run's, not the joiner's.
|
||||
*
|
||||
* Port: N/A (no Codeman server). The process-group test listens on its own loopback socket, on a port the OS
|
||||
* picks, because the point of that test is a remote that never answers.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { createServer, type AddressInfo } from 'node:http';
|
||||
import type { Socket } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { classifyGitFailure, isGitAvailable } from '../src/git-clone.js';
|
||||
import {
|
||||
FETCH_CONCURRENCY,
|
||||
FETCH_CONFIG_ARGS,
|
||||
FETCH_KILL_ESCALATION_MS,
|
||||
buildFetchArgs,
|
||||
clearGitFetchLane,
|
||||
describeFetchFailure,
|
||||
fetchEnvFor,
|
||||
fetchGitWorkspaceOverview,
|
||||
fetchWorkspaceOnce,
|
||||
gitFetchSettingsFrom,
|
||||
gitFetchStatusFrom,
|
||||
runFetch,
|
||||
type GitFetchReport,
|
||||
} from '../src/git-fetch.js';
|
||||
import { createLimiter, runGit, type GitRunner } from '../src/git-workspace-status.js';
|
||||
|
||||
const ENV = {
|
||||
...process.env,
|
||||
// As the production runner: git's messages are matched in English, and nothing reads the host's config.
|
||||
LC_ALL: 'C',
|
||||
GIT_AUTHOR_NAME: 'T',
|
||||
GIT_AUTHOR_EMAIL: 't@example.com',
|
||||
GIT_COMMITTER_NAME: 'T',
|
||||
GIT_COMMITTER_EMAIL: 't@example.com',
|
||||
GIT_CONFIG_GLOBAL: '/dev/null',
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
};
|
||||
const git = (cwd: string, ...args: string[]) =>
|
||||
execFileSync('git', args, { cwd, env: ENV, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
|
||||
// ─── The pure half ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildFetchArgs', () => {
|
||||
it('pins the argv to a fetch and nothing else', () => {
|
||||
expect(buildFetchArgs()).toEqual(['fetch', '--quiet']);
|
||||
// Not just the first word: the whole argv is fixed, so no verb that changes the repository can slip in.
|
||||
for (const verb of ['commit', 'pull', 'merge', 'checkout', 'reset', 'clean', 'prune', 'gc', 'amend']) {
|
||||
expect(buildFetchArgs().join(' ')).not.toContain(verb);
|
||||
}
|
||||
});
|
||||
|
||||
it('names no repository, so the repository can only be the process cwd', () => {
|
||||
// An operand-shaped argument would let a path or a URL choose the repository; the fixed argv cannot.
|
||||
for (const arg of buildFetchArgs()) {
|
||||
expect(arg.startsWith('-')).toBe(arg === '--quiet');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeFetchFailure', () => {
|
||||
it("calls a repository with no remote a fact, not a fault, in each of git's wordings", () => {
|
||||
for (const stderr of [
|
||||
'fatal: no default remote repository -- use --remote or specify a repository',
|
||||
'No remote configured',
|
||||
'fatal: nothing to fetch from remote repository',
|
||||
]) {
|
||||
const failure = describeFetchFailure({ stderr });
|
||||
expect(failure.code).toBe('NO_REMOTE');
|
||||
expect(failure.message).toMatch(/no remote/i);
|
||||
expect(failure.message).not.toMatch(/fail|error|unable/i);
|
||||
}
|
||||
});
|
||||
|
||||
it('writes its own timeout message, because the one to reuse was written for clones', () => {
|
||||
const failure = describeFetchFailure({ code: 'ETIMEDOUT', stderr: 'fatal: repository not found' });
|
||||
expect(failure.code).toBe('TIMEOUT');
|
||||
expect(failure.message).toMatch(/Git status: git timeout/);
|
||||
expect(failure.message).toMatch(/fetch from a shell/i);
|
||||
expect(failure.message).not.toBe(classifyGitFailure('fatal: repository not found', true).message);
|
||||
expect(describeFetchFailure({ killed: true, stderr: '' }).code).toBe('TIMEOUT');
|
||||
});
|
||||
|
||||
it('reuses the network reasons it already knows', () => {
|
||||
const prompt = "fatal: could not read Username for 'https://github.com': terminal prompts disabled";
|
||||
expect(describeFetchFailure({ stderr: prompt }).code).toBe('AUTH_REQUIRED');
|
||||
expect(describeFetchFailure({ stderr: 'git@github.com: Permission denied (publickey).' }).code).toBe(
|
||||
'AUTH_REQUIRED'
|
||||
);
|
||||
expect(describeFetchFailure({ message: 'Error: spawn git ENOENT' }).code).toBe('GIT_MISSING');
|
||||
expect(describeFetchFailure({ stderr: 'fatal: unable to access: Could not resolve host: nope.invalid' }).code).toBe(
|
||||
'HOST_UNREACHABLE'
|
||||
);
|
||||
});
|
||||
|
||||
it('hands the client a redacted, control-stripped tail, never what git printed', () => {
|
||||
const failure = describeFetchFailure({
|
||||
stderr: "fatal: unable to access 'https://bob:ghp_secret@github.com/x.git/'\u001b[31m",
|
||||
});
|
||||
expect(failure.stderr).toBe("fatal: unable to access 'https://***:***@github.com/x.git/'[31m");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── The two reads the interval feature depends on ───────────────────────────
|
||||
|
||||
describe('gitFetchSettingsFrom', () => {
|
||||
it('is off until the user turns it on, and 300 s when nothing was chosen', () => {
|
||||
// Absent means off: a quiet install must not start running git on a timer nobody asked for.
|
||||
for (const settings of [undefined, {}, { gitFetchEnabled: false }, { gitFetchEnabled: 'true' }]) {
|
||||
expect(gitFetchSettingsFrom(settings).enabled).toBe(false);
|
||||
}
|
||||
expect(gitFetchSettingsFrom({ gitFetchEnabled: true }).enabled).toBe(true);
|
||||
expect(gitFetchSettingsFrom({}).intervalSeconds).toBe(300);
|
||||
});
|
||||
|
||||
it('clamps a number outside the schema instead of trusting it, because a persisted value can predate the bounds', () => {
|
||||
expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: 1 }).intervalSeconds).toBe(30);
|
||||
expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: 99999 }).intervalSeconds).toBe(3600);
|
||||
expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: 120.6 }).intervalSeconds).toBe(121);
|
||||
expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: -300 }).intervalSeconds).toBe(30);
|
||||
// A hand-edited 0 is still a number it can act on, and acting on it means the smallest interval that runs.
|
||||
expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: 0 }).intervalSeconds).toBe(30);
|
||||
});
|
||||
|
||||
it('falls back to the default on a value it cannot act on, which is what a hand-edited settings.json can hold', () => {
|
||||
for (const raw of ['300', undefined, null, NaN, Infinity, true]) {
|
||||
expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: raw }).intervalSeconds, String(raw)).toBe(300);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('gitFetchStatusFrom', () => {
|
||||
it('counts what a run covered and what it could not do, and names the first failure', () => {
|
||||
const report: GitFetchReport = {
|
||||
state: 'ok',
|
||||
repos: [
|
||||
{ name: 'Codeman', path: '.', ok: true },
|
||||
{ name: 'api', path: 'api', ok: false, failure: { code: 'NO_REMOTE', message: 'no remote', stderr: '' } },
|
||||
],
|
||||
reposTruncated: false,
|
||||
fetchedAt: 1700,
|
||||
};
|
||||
expect(gitFetchStatusFrom(report, 'interval')).toEqual({
|
||||
ranAt: 1700,
|
||||
trigger: 'interval',
|
||||
state: 'ok',
|
||||
repos: 2,
|
||||
failed: 1,
|
||||
firstFailure: { name: 'api', code: 'NO_REMOTE' },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the trigger, because a number the user did not ask for should read differently from one they did', () => {
|
||||
const empty: GitFetchReport = { state: 'ok', repos: [], reposTruncated: false, fetchedAt: 1000 };
|
||||
expect(gitFetchStatusFrom(empty, 'click').trigger).toBe('click');
|
||||
expect(gitFetchStatusFrom(empty, 'interval').trigger).toBe('interval');
|
||||
});
|
||||
|
||||
it('records a state that stopped the run, with nothing to count and no failure to name', () => {
|
||||
const status = gitFetchStatusFrom(
|
||||
{ state: 'unsupported', reason: 'docker', repos: [], reposTruncated: false, fetchedAt: 5 },
|
||||
'interval'
|
||||
);
|
||||
expect(status.state).toBe('unsupported');
|
||||
expect(status.repos).toBe(0);
|
||||
expect(status.failed).toBe(0);
|
||||
expect(status.firstFailure).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FETCH_CONFIG_ARGS', () => {
|
||||
it('turns off the hooks and the maintenance a fetch can otherwise start, pinned without spawning git', () => {
|
||||
expect(FETCH_CONFIG_ARGS).toEqual([
|
||||
'-c',
|
||||
'core.fsmonitor=false',
|
||||
'-c',
|
||||
'maintenance.auto=false',
|
||||
'-c',
|
||||
'gc.auto=0',
|
||||
]);
|
||||
// Read as pairs: a `-c` and the setting it overrides, and every override is one of these three.
|
||||
for (let i = 0; i < FETCH_CONFIG_ARGS.length; i += 2) {
|
||||
expect(FETCH_CONFIG_ARGS[i]).toBe('-c');
|
||||
expect(FETCH_CONFIG_ARGS[i + 1]).toMatch(/^(core\.fsmonitor|maintenance\.auto|gc\.auto)=/);
|
||||
}
|
||||
expect(FETCH_CONFIG_ARGS.filter((a) => a !== '-c').sort()).toEqual([
|
||||
'core.fsmonitor=false',
|
||||
'gc.auto=0',
|
||||
'maintenance.auto=false',
|
||||
]);
|
||||
});
|
||||
|
||||
it('is kept apart from the pinned argv so both can be read without spawning, and the runner composes them in that order', () => {
|
||||
// The argv the recorded calls pin stays the verb alone: the overrides come from the runner, not from here.
|
||||
for (const arg of buildFetchArgs()) expect(arg).not.toBe('-c');
|
||||
// Overrides before the verb, which is where git will take them.
|
||||
expect([...FETCH_CONFIG_ARGS, ...buildFetchArgs()]).toEqual([...FETCH_CONFIG_ARGS, 'fetch', '--quiet']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchEnvFor', () => {
|
||||
const BASE = { HOME: '/home/t', PATH: '/usr/bin' };
|
||||
|
||||
it('writes the BatchMode default when nothing else already answered', () => {
|
||||
const env = fetchEnvFor(BASE, null);
|
||||
expect(env.GIT_SSH_COMMAND).toContain('BatchMode=yes');
|
||||
expect(env.GIT_TERMINAL_PROMPT).toBe('0');
|
||||
});
|
||||
|
||||
it('does not override a repository that already chose its own ssh, because that choice is usually the one that works', () => {
|
||||
const env = fetchEnvFor(BASE, 'ssh -i /home/t/work_key -o ProxyJump=gw');
|
||||
// The whole point: nothing is written, so git takes the repository's value from its own config.
|
||||
expect(env.GIT_SSH_COMMAND).toBeUndefined();
|
||||
// Honouring the repository's ssh is not the same as letting it prompt, so the rest still applies.
|
||||
expect(env.GIT_TERMINAL_PROMPT).toBe('0');
|
||||
expect(env.SSH_ASKPASS_REQUIRE).toBe('never');
|
||||
});
|
||||
|
||||
it("the user's own GIT_SSH_COMMAND still beats the repository's, because that is git's rule and not this module's to second-guess", () => {
|
||||
const env = fetchEnvFor({ ...BASE, GIT_SSH_COMMAND: 'ssh -i /home/t/personal' }, 'ssh -i /repo/key');
|
||||
expect(env.GIT_SSH_COMMAND).toBe('ssh -i /home/t/personal');
|
||||
});
|
||||
|
||||
it("leaves a GIT_SSH in the server's environment to git, which reads it last and would never see it under a written default", () => {
|
||||
const env = fetchEnvFor({ ...BASE, GIT_SSH: '/home/t/bin/work-ssh' }, null);
|
||||
expect(env.GIT_SSH_COMMAND).toBeUndefined();
|
||||
expect(env.GIT_SSH).toBe('/home/t/bin/work-ssh');
|
||||
expect(env.GIT_TERMINAL_PROMPT).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Real git, real local repository ─────────────────────────────────────────
|
||||
|
||||
const gitPresent = isGitAvailable();
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'git-fetch-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function initRepo(path: string, remote?: string) {
|
||||
mkdirSync(path, { recursive: true });
|
||||
git(path, 'init', '-q', '-b', 'main');
|
||||
// So a `file://` remote stays allowed for the production runner too, not only for this test's own git calls.
|
||||
git(path, 'config', 'protocol.file.allow', 'always');
|
||||
writeFileSync(join(path, 'f.txt'), '1\n');
|
||||
git(path, 'add', '-A');
|
||||
git(path, 'commit', '-q', '-m', 'init');
|
||||
if (remote) git(path, 'remote', 'add', 'origin', remote);
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Records what each repository was asked to do, and lets real git answer the rest. */
|
||||
function recordingRunner() {
|
||||
const calls: Array<[string, string[]]> = [];
|
||||
const runner: GitRunner = async (cwd, args, opts) => {
|
||||
calls.push([cwd, args]);
|
||||
return runGit(cwd, args, opts);
|
||||
};
|
||||
return { calls, runner };
|
||||
}
|
||||
|
||||
describe.skipIf(!gitPresent)('fetchGitWorkspaceOverview (real git)', () => {
|
||||
let work: string;
|
||||
|
||||
const snapshot = (path: string) => ({
|
||||
head: git(path, 'rev-parse', 'HEAD').trim(),
|
||||
tree: readdirSync(path, { withFileTypes: true })
|
||||
.filter((e) => e.name !== '.git' && e.isFile())
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((e) => `${e.name}=${readFileSync(join(path, e.name), 'utf8')}`),
|
||||
// The global option has to come before the verb, exactly as the read-only status module runs it.
|
||||
index: git(path, '--no-optional-locks', 'status', '--porcelain=v2').trim(),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const origin = join(dir, 'origin.git');
|
||||
mkdirSync(origin);
|
||||
git(origin, 'init', '--bare', '--quiet', '-b', 'main');
|
||||
work = initRepo(join(dir, 'work'), origin);
|
||||
git(work, 'push', '-q', 'origin', 'main');
|
||||
|
||||
// A commit made elsewhere and pushed: work's origin/main cannot hear about it until a fetch runs.
|
||||
// This has to be a clone of origin, not a fresh init — a commit on its own root commit is no
|
||||
// descendant of what origin already holds, so pushing it is rejected as non-fast-forward.
|
||||
const other = join(dir, 'other');
|
||||
git(dir, 'clone', '-q', origin, 'other');
|
||||
git(other, 'config', 'protocol.file.allow', 'always');
|
||||
writeFileSync(join(other, 'g.txt'), '2\n');
|
||||
git(other, 'add', '-A');
|
||||
git(other, 'commit', '-q', '-m', 'second');
|
||||
git(other, 'push', '-q', 'origin', 'main');
|
||||
|
||||
// Left dirty on purpose, so the snapshot has something proving it was not touched.
|
||||
writeFileSync(join(work, 'untracked.txt'), 'u\n');
|
||||
writeFileSync(join(work, 'f.txt'), 'modified\n');
|
||||
});
|
||||
|
||||
it('fetches through the production runner, moving remote-tracking refs and writing nothing else', async () => {
|
||||
// A push does not update the remote-tracking ref, so the fetch is what has to move it: 0 before, 1 after.
|
||||
expect(git(work, 'rev-list', '--count', 'main..origin/main').trim()).toBe('0');
|
||||
const before = snapshot(work);
|
||||
|
||||
const report = await fetchGitWorkspaceOverview(work);
|
||||
expect(report.state).toBe('ok');
|
||||
expect(report.repos).toHaveLength(1);
|
||||
expect(report.repos[0]).toMatchObject({ name: 'work', path: '.', ok: true });
|
||||
expect(report.fetchedAt).toBeGreaterThan(0);
|
||||
|
||||
expect(snapshot(work)).toEqual(before); // HEAD, the working tree and the index are exactly as they were
|
||||
expect(git(work, 'rev-list', '--count', 'main..origin/main').trim()).toBe('1');
|
||||
});
|
||||
|
||||
it('fetches exactly the repositories the panel would show, in the panel order', async () => {
|
||||
const folder = join(dir, 'folder');
|
||||
for (const name of ['api', 'web']) initRepo(join(folder, name));
|
||||
mkdirSync(join(folder, 'node_modules', 'pkg'), { recursive: true }); // never a project the panel lists
|
||||
const { calls, runner } = recordingRunner();
|
||||
const report = await fetchGitWorkspaceOverview(folder, { git: runner });
|
||||
expect(report.state).toBe('ok');
|
||||
expect(report.repos.map((r) => [r.name, r.path])).toEqual([
|
||||
['api', 'api'],
|
||||
['web', 'web'],
|
||||
]);
|
||||
// Only the fetches, not the resolver's own `rev-parse`: the repositories touched are the point.
|
||||
// A bounded run does not promise which one starts first, so compare the set.
|
||||
expect(calls.filter(([, args]) => args[0] === 'fetch').sort()).toEqual([
|
||||
[join(folder, 'api'), ['fetch', '--quiet']],
|
||||
[join(folder, 'web'), ['fetch', '--quiet']],
|
||||
]);
|
||||
});
|
||||
|
||||
it('never reaches an unrelated repository above the workspace', async () => {
|
||||
const home = join(dir, 'home');
|
||||
initRepo(home);
|
||||
const caseDir = join(home, 'case');
|
||||
mkdirSync(caseDir);
|
||||
const { calls, runner } = recordingRunner();
|
||||
const report = await fetchGitWorkspaceOverview(caseDir, { git: runner, home });
|
||||
expect(report.state).toBe('not-a-repo');
|
||||
expect(calls.filter(([, args]) => args[0] === 'fetch')).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses a repository at or inside a Docker case workspace before any git runs', async () => {
|
||||
initRepo(join(dir, 'repo'));
|
||||
const { calls, runner } = recordingRunner();
|
||||
const report = await fetchGitWorkspaceOverview(dir, { git: runner, dockerWorkspaces: [dir] });
|
||||
expect(report).toMatchObject({ state: 'unsupported', reason: 'docker' });
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a repository that could not be fetched, with its reason', async () => {
|
||||
for (const name of ['ok', 'private']) initRepo(join(dir, 'folder', name));
|
||||
const runner: GitRunner = async (cwd) => {
|
||||
if (basename(cwd) === 'private') {
|
||||
throw Object.assign(new Error('Command failed'), {
|
||||
stderr: "fatal: could not read Username for 'https://github.com': terminal prompts disabled",
|
||||
});
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const report = await fetchGitWorkspaceOverview(join(dir, 'folder'), { git: runner });
|
||||
expect(report.state).toBe('ok');
|
||||
const priv = report.repos.find((r) => r.name === 'private');
|
||||
expect(priv?.ok).toBe(false);
|
||||
expect(priv?.failure?.code).toBe('AUTH_REQUIRED');
|
||||
expect(priv?.failure?.message).toMatch(/authentication/i);
|
||||
expect(report.repos.find((r) => r.name === 'ok')?.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("runs at most FETCH_CONCURRENCY fetches at a time, with the setting's timeout", async () => {
|
||||
const folder = join(dir, 'folder');
|
||||
for (let i = 0; i < FETCH_CONCURRENCY + 2; i++) initRepo(join(folder, `r${i}`));
|
||||
let inFlight = 0;
|
||||
let max = 0;
|
||||
const seenTimeouts = new Set<number | undefined>();
|
||||
const runner: GitRunner = async (cwd, args, opts) => {
|
||||
seenTimeouts.add(opts?.timeoutMs);
|
||||
inFlight++;
|
||||
max = Math.max(max, inFlight);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
inFlight--;
|
||||
return '';
|
||||
};
|
||||
const report = await fetchGitWorkspaceOverview(folder, { git: runner, timeoutMs: 7000 });
|
||||
expect(report.repos).toHaveLength(FETCH_CONCURRENCY + 2);
|
||||
expect(report.repos.every((r) => r.ok)).toBe(true);
|
||||
expect(max).toBe(FETCH_CONCURRENCY);
|
||||
expect(seenTimeouts).toEqual(new Set([7000]));
|
||||
});
|
||||
|
||||
it('answers not-a-repo for a folder that is not a repository, running no fetch', async () => {
|
||||
const plain = join(dir, 'plain');
|
||||
mkdirSync(plain);
|
||||
const { calls, runner } = recordingRunner();
|
||||
expect(await fetchGitWorkspaceOverview(plain, { git: runner })).toMatchObject({ state: 'not-a-repo', repos: [] });
|
||||
expect(calls.filter(([, args]) => args[0] === 'fetch')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── The whole tree, not just the git parent ─────────────────────────────────
|
||||
|
||||
/** One process as /proc reports it. `null` means it is gone, which is what the last assertion is about. */
|
||||
interface Proc {
|
||||
pid: number;
|
||||
comm: string;
|
||||
ppid: number;
|
||||
/** The PROCESS GROUP. `detached: true` puts the fetch in its own, so the negative pid reaches the children. */
|
||||
pgrp: number;
|
||||
cmdline: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every process on the machine, as /proc reports it — so this half can only run on Linux, and the fields are
|
||||
* counted from the closing parenthesis because `comm` can hold spaces.
|
||||
*
|
||||
* Membership of the GROUP is what is checked, not parentage: a helper that outlives its parent stops being a
|
||||
* child of this test process, and a test that looked for children would pass for the wrong reason.
|
||||
*/
|
||||
function scanProc(): Proc[] {
|
||||
const out: Proc[] = [];
|
||||
for (const entry of readdirSync('/proc')) {
|
||||
if (!/^\d+$/.test(entry)) continue;
|
||||
const pid = Number(entry);
|
||||
try {
|
||||
const stat = readFileSync(join('/proc', entry, 'stat'), 'utf8');
|
||||
const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
||||
out.push({
|
||||
pid,
|
||||
comm: readFileSync(join('/proc', entry, 'comm'), 'utf8').trim(),
|
||||
ppid: Number(fields[1]),
|
||||
pgrp: Number(fields[2]),
|
||||
cmdline: readFileSync(join('/proc', entry, 'cmdline'), 'utf8')
|
||||
.replace(/\0/g, ' ')
|
||||
.trim(),
|
||||
});
|
||||
} catch {
|
||||
/* gone between the listing and the read */
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
describe.skipIf(!gitPresent)('runFetch (the whole tree, not just the git parent)', () => {
|
||||
it.skipIf(!existsSync('/proc/self/environ'))(
|
||||
'kills the helper processes too, so nothing is left running after the timeout',
|
||||
async () => {
|
||||
const server = createServer(() => {
|
||||
// Accept and never answer: git's http helper connects, waits, and stays.
|
||||
});
|
||||
const sockets = new Set<Socket>();
|
||||
server.on('connection', (s) => {
|
||||
sockets.add(s);
|
||||
s.on('close', () => sockets.delete(s));
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
|
||||
try {
|
||||
const repo = initRepo(join(dir, 'hang'), `http://127.0.0.1:${port}/x.git`);
|
||||
// The handler goes on the moment the promise is made, not when it is read: the timeout fires while the
|
||||
// tree is still being looked at, and a rejection with nothing listening to it becomes an unhandled
|
||||
// rejection instead of this test's answer.
|
||||
const answered = runFetch(repo, buildFetchArgs(), { timeoutMs: 1_500 }).catch((e) => e);
|
||||
|
||||
// The tree has to be a tree before the timeout, or nothing is being proven: git itself, and the helper
|
||||
// it spawns for an http remote. The helper is a grandchild — `git fetch` runs `git remote-http`, which
|
||||
// spawns `git-remote-http` — so the group, not the parentage, is what proves it. Each process is
|
||||
// remembered once found rather than re-scanned: after the kill the group is empty, and a test that kept
|
||||
// looking would report a tree it had already destroyed. The `git config` read the runner does first is
|
||||
// a `git` too, so the fetch is found by what it was asked to do.
|
||||
let parent: Proc | null = null;
|
||||
let helper: Proc | null = null;
|
||||
for (let i = 0; i < 60 && !(parent && helper); i++) {
|
||||
const live = scanProc();
|
||||
parent =
|
||||
parent ??
|
||||
live.find((p) => p.comm === 'git' && p.cmdline.includes('fetch') && p.ppid === process.pid) ??
|
||||
null;
|
||||
helper =
|
||||
helper ??
|
||||
(parent
|
||||
? live.find((p) => p.pgrp === parent!.pgrp && p.pid !== parent!.pid && p.cmdline.includes('remote-http'))
|
||||
: null);
|
||||
if (!helper) await sleep(25);
|
||||
}
|
||||
expect(parent, 'the fetch was never running').not.toBeNull();
|
||||
expect(helper, 'nothing else in the fetch group, so there was no tree to kill').not.toBeNull();
|
||||
// `detached: true` puts the fetch in a group of its own, which is why the negative pid is the right
|
||||
// thing to signal: a helper outside that group would survive a `child.kill()`.
|
||||
expect(parent!.pgrp).toBe(parent!.pid);
|
||||
expect(helper!.pgrp).toBe(parent!.pgrp);
|
||||
|
||||
const err = (await answered) as { code?: string; killed?: boolean };
|
||||
expect(err.code).toBe('ETIMEDOUT');
|
||||
expect(err.killed).toBe(true);
|
||||
|
||||
// The answer went out before the tree died — that is the whole point of the fix, and it is why the
|
||||
// escalation is given its own time here instead of being cancelled when the promise settles.
|
||||
const deadline = Date.now() + FETCH_KILL_ESCALATION_MS + 2_000;
|
||||
while (Date.now() < deadline && scanProc().some((p) => p.pgrp === parent!.pgrp)) await sleep(50);
|
||||
expect(scanProc().some((p) => p.pgrp === parent!.pgrp)).toBe(false);
|
||||
|
||||
// Nothing of this test's own is left running either.
|
||||
await sleep(200);
|
||||
expect(scanProc().filter((p) => p.ppid === process.pid)).toEqual([]);
|
||||
} finally {
|
||||
for (const s of sockets) s.destroy();
|
||||
server.close();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects on a non-zero exit instead of resolving empty stdout, so a failure keeps a reason to be read', async () => {
|
||||
// A remote git cannot read. A repository with no remote is not usable for this: `git fetch --quiet` exits 0
|
||||
// there on the git this was checked against (2.47.3), so resolving empty stdout would have been the right
|
||||
// answer and the test would prove nothing. A remote pointing at a path that is not a repository exits 128
|
||||
// and says why, which is the case the row needs a reason for.
|
||||
const repo = initRepo(join(dir, 'unreadable-remote'), 'file:///tmp/definitely-not-a-repo-xyz');
|
||||
let err: unknown = null;
|
||||
try {
|
||||
await runFetch(repo, buildFetchArgs());
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
expect(err, 'a fetch that failed resolved instead of rejecting').not.toBeNull();
|
||||
const e = err as { code?: unknown; stderr?: string; killed?: boolean };
|
||||
// The exit code, not the timeout marker, so a caller can still tell a failure from a stall.
|
||||
expect(e.code).toBe(128);
|
||||
expect(e.killed).toBe(false);
|
||||
expect(e.stderr).toMatch(/does not appear to be a git repository/);
|
||||
expect(describeFetchFailure(err).code).toBe('NOT_FOUND');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── One bound for the config read and the fetch ─────────────────────────────
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('runFetch (one bound for the config read and the fetch)', () => {
|
||||
it('answers at the timeout when git never answers, not at twice it, because the core.sshCommand read spends part of the bound', async () => {
|
||||
// A `git` that never answers anything, first on PATH, so the read ahead of the fetch hangs exactly as long as
|
||||
// the fetch would. Both spawns take PATH from process.env at call time. It records its arguments, so the test
|
||||
// can tell which of the two it was asked to run.
|
||||
const bin = join(dir, 'bin');
|
||||
const calls = join(dir, 'calls');
|
||||
mkdirSync(bin);
|
||||
writeFileSync(join(bin, 'git'), `#!/bin/sh\necho "$*" >> '${calls}'\nexec sleep 30\n`, { mode: 0o755 });
|
||||
const savedPath = process.env.PATH;
|
||||
process.env.PATH = `${bin}:${savedPath ?? ''}`;
|
||||
try {
|
||||
const started = Date.now();
|
||||
const err = (await runFetch(dir, buildFetchArgs(), { timeoutMs: 1_000 }).catch((e) => e)) as {
|
||||
code?: unknown;
|
||||
killed?: boolean;
|
||||
};
|
||||
const elapsed = Date.now() - started;
|
||||
|
||||
expect(err.code).toBe('ETIMEDOUT');
|
||||
expect(err.killed).toBe(true);
|
||||
expect(describeFetchFailure(err).code).toBe('TIMEOUT');
|
||||
// One bound, not one per spawn: the read used all of it, so the fetch got nothing and was not started
|
||||
// (2019 ms here before the fix, when the fetch was armed with a fresh timeout of its own).
|
||||
expect(elapsed).toBeGreaterThanOrEqual(950);
|
||||
expect(elapsed).toBeLessThan(1_600);
|
||||
expect(readFileSync(calls, 'utf8').trim().split('\n')).toEqual(['config --get core.sshCommand']);
|
||||
} finally {
|
||||
if (savedPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = savedPath;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── One run per folder, and one bound for the whole tick ────────────────────
|
||||
|
||||
describe.skipIf(!gitPresent)('fetchWorkspaceOnce (the join)', () => {
|
||||
// The resolver still runs real git, so these need real repositories on disk; what is injected is only the
|
||||
// fetch, because what is being pinned here is the joining, not git's answer.
|
||||
beforeEach(() => {
|
||||
// The lane is module state: a run left in it by the previous test would be joined by the next one.
|
||||
clearGitFetchLane();
|
||||
});
|
||||
|
||||
it('joins a run already going for the same folder instead of starting a second git fetch in the same clone', async () => {
|
||||
const folder = join(dir, 'folder');
|
||||
initRepo(join(folder, 'api'));
|
||||
|
||||
let releaseRun = () => {};
|
||||
const held = new Promise<void>((resolve) => (releaseRun = resolve));
|
||||
let running = () => {};
|
||||
const runStarted = new Promise<void>((resolve) => (running = resolve));
|
||||
let firstRunFetches = 0;
|
||||
const firstRunner: GitRunner = async (cwd, args) => {
|
||||
if (args[0] === 'fetch') {
|
||||
firstRunFetches++;
|
||||
running();
|
||||
await held;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const first = fetchWorkspaceOnce(folder, { git: firstRunner }, 'interval');
|
||||
await runStarted; // the run is going, and still going
|
||||
|
||||
// A click while that run is working the folder: it joins, so it never touches git at all — not even the
|
||||
// resolver's own probe, because it does not resolve anything for itself.
|
||||
let joinerCalls = 0;
|
||||
const joinerRunner: GitRunner = async () => {
|
||||
joinerCalls++;
|
||||
return '';
|
||||
};
|
||||
const joiner = fetchWorkspaceOnce(folder, { git: joinerRunner }, 'click');
|
||||
|
||||
releaseRun();
|
||||
const answer = await first;
|
||||
// The joiner gets that same run's answer, including its limits: the point is not to fetch twice.
|
||||
expect(await joiner).toBe(answer);
|
||||
expect(joinerCalls).toBe(0);
|
||||
expect(firstRunFetches).toBe(1);
|
||||
});
|
||||
|
||||
it('a join reports the trigger of the run it joined, not the trigger that made it ask, because a click that fetched nothing cannot claim the fetch', async () => {
|
||||
const folder = join(dir, 'folder');
|
||||
initRepo(join(folder, 'api'));
|
||||
|
||||
let releaseRun = () => {};
|
||||
const held = new Promise<void>((resolve) => (releaseRun = resolve));
|
||||
let running = () => {};
|
||||
const runStarted = new Promise<void>((resolve) => (running = resolve));
|
||||
const runner: GitRunner = async (cwd, args) => {
|
||||
if (args[0] === 'fetch') {
|
||||
running();
|
||||
await held;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// The tick got there first; the click is the joiner.
|
||||
const intervalRun = fetchWorkspaceOnce(folder, { git: runner }, 'interval');
|
||||
await runStarted;
|
||||
const joinerRun = fetchWorkspaceOnce(folder, { git: async () => '' }, 'click');
|
||||
releaseRun();
|
||||
|
||||
const run = await intervalRun;
|
||||
expect(await joinerRun).toBe(run); // one run, not two answers
|
||||
expect(run.trigger).toBe('interval');
|
||||
// The row the panel shows is built from the run that ran, so it names the interval.
|
||||
expect(gitFetchStatusFrom(run.report, run.trigger).trigger).toBe('interval');
|
||||
});
|
||||
|
||||
it('keys the join on the resolved folder, so the same folder written two ways is one run', async () => {
|
||||
const folder = join(dir, 'folder');
|
||||
initRepo(join(folder, 'api'));
|
||||
|
||||
let releaseRun = () => {};
|
||||
const held = new Promise<void>((resolve) => (releaseRun = resolve));
|
||||
let running = () => {};
|
||||
const runStarted = new Promise<void>((resolve) => (running = resolve));
|
||||
let fetches = 0;
|
||||
const firstRunner: GitRunner = async (cwd, args) => {
|
||||
if (args[0] === 'fetch') {
|
||||
fetches++;
|
||||
running();
|
||||
await held;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const first = fetchWorkspaceOnce(folder, { git: firstRunner }, 'click');
|
||||
await runStarted;
|
||||
// Two sessions on one folder reach it by different spellings; the scheduler keys by `resolve`.
|
||||
let joinerCalls = 0;
|
||||
const joinerRunner: GitRunner = async () => {
|
||||
joinerCalls++;
|
||||
return '';
|
||||
};
|
||||
const second = fetchWorkspaceOnce(join(dir, 'folder', '.'), { git: joinerRunner }, 'click');
|
||||
releaseRun();
|
||||
expect(await second).toBe(await first);
|
||||
expect(joinerCalls).toBe(0);
|
||||
expect(fetches).toBe(1);
|
||||
});
|
||||
|
||||
it('releases the folder when a run lands, so the next click is a new run and not an old answer', async () => {
|
||||
const folder = join(dir, 'folder');
|
||||
initRepo(join(folder, 'api'));
|
||||
let fetches = 0;
|
||||
const runner: GitRunner = async (cwd, args) => {
|
||||
if (args[0] === 'fetch') fetches++;
|
||||
return '';
|
||||
};
|
||||
const firstRun = await fetchWorkspaceOnce(folder, { git: runner }, 'interval');
|
||||
expect(fetches).toBe(1);
|
||||
const secondRun = await fetchWorkspaceOnce(folder, { git: runner }, 'click');
|
||||
expect(fetches).toBe(2);
|
||||
// A run that is its own reports its own trigger; only a joiner borrows the trigger of the run it joined.
|
||||
expect(firstRun.trigger).toBe('interval');
|
||||
expect(secondRun.trigger).toBe('click');
|
||||
});
|
||||
|
||||
it('bounds a whole tick with the limiter it is handed, because two runs given a bound each would get twice the bound', async () => {
|
||||
const left = join(dir, 'left');
|
||||
const right = join(dir, 'right');
|
||||
for (let i = 0; i < FETCH_CONCURRENCY + 1; i++) {
|
||||
initRepo(join(left, `l${i}`));
|
||||
initRepo(join(right, `r${i}`));
|
||||
}
|
||||
const held = () => new Promise((resolve) => setTimeout(resolve, 10));
|
||||
// Only the fetches go through the limiter, so only they are counted here: the resolver's own probe is
|
||||
// not a bounded git process.
|
||||
const counting =
|
||||
(state: { live: number; peak: number }): GitRunner =>
|
||||
async (cwd, args) => {
|
||||
if (args[0] !== 'fetch') return '';
|
||||
state.live++;
|
||||
state.peak = Math.max(state.peak, state.live);
|
||||
await held();
|
||||
state.live--;
|
||||
return '';
|
||||
};
|
||||
|
||||
// The scheduler hands every folder in the tick the SAME limiter, so the bound is the tick's, not the run's.
|
||||
const shared = { live: 0, peak: 0 };
|
||||
const sharedRunner = counting(shared);
|
||||
const limiter = createLimiter(FETCH_CONCURRENCY);
|
||||
await Promise.all([
|
||||
fetchGitWorkspaceOverview(left, { git: sharedRunner, limiter }),
|
||||
fetchGitWorkspaceOverview(right, { git: sharedRunner, limiter }),
|
||||
]);
|
||||
expect(shared.peak).toBe(FETCH_CONCURRENCY);
|
||||
|
||||
// Without a shared limiter each run bounds only its own call, which is the failure the scheduler is there
|
||||
// to prevent: two folders at FETCH_CONCURRENCY each, so twice as many git processes as the rule says.
|
||||
const alone = { live: 0, peak: 0 };
|
||||
const aloneRunner = counting(alone);
|
||||
await Promise.all([
|
||||
fetchGitWorkspaceOverview(left, { git: aloneRunner }),
|
||||
fetchGitWorkspaceOverview(right, { git: aloneRunner }),
|
||||
]);
|
||||
expect(alone.peak).toBe(2 * FETCH_CONCURRENCY);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ describe('Git status indicator in a real browser', () => {
|
||||
let repoSession: string;
|
||||
let plainSession: string;
|
||||
const gitStatusRequests: string[] = [];
|
||||
const gitFetchRequests: string[] = [];
|
||||
const settingsPutStatuses: number[] = [];
|
||||
|
||||
const write = (rel: string, text = 'x\n') => writeFileSync(join(repo, rel), text);
|
||||
@@ -91,6 +92,9 @@ describe('Git status indicator in a real browser', () => {
|
||||
page.on('request', (r) => {
|
||||
// The API route only: the page also loads /git-status-ui.js, whose URL contains the same words.
|
||||
if (/\/api\/sessions\/[^/]+\/git-status/.test(r.url())) gitStatusRequests.push(r.url());
|
||||
// Anchored: the per-case Git settings route (/git-fetch-settings) is a different route, and a bare
|
||||
// /git-fetch test would count its PUTs as fetches. The fetch route carries the limits as a query.
|
||||
if (/\/api\/sessions\/[^/]+\/git-fetch(\?|$)/.test(r.url())) gitFetchRequests.push(r.url());
|
||||
});
|
||||
page.on('response', (r) => {
|
||||
if (r.request().method() === 'PUT' && r.url().endsWith('/api/settings')) settingsPutStatuses.push(r.status());
|
||||
@@ -514,6 +518,147 @@ describe('Git status indicator in a real browser', () => {
|
||||
await refresh();
|
||||
}, 40000);
|
||||
|
||||
it('puts Fetch in the header row, between Refresh and Close', async () => {
|
||||
const order = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('.git-status-actions button')].map((b) => b.getAttribute('aria-label'))
|
||||
);
|
||||
expect(order).toEqual(['Refresh git status', 'Fetch git status', 'Close git status']);
|
||||
// A repository the panel can read is a repository Fetch can fetch, so the button is live here.
|
||||
expect(await page.evaluate(() => document.getElementById('gitStatusFetchBtn')!.disabled)).toBe(false);
|
||||
});
|
||||
|
||||
it('Fetch fetches for real, moves the behind number, and reports what it brought', async () => {
|
||||
// A second clone pushes a commit this repository has not fetched, so its remote-tracking ref is stale:
|
||||
// the panel cannot show a `behind` number until a fetch runs. That is what the footer means by
|
||||
// "as of the last fetch", and the number only becomes current by the button.
|
||||
const other = join(root, 'other-clone');
|
||||
git(root, 'clone', '-q', join(root, 'origin.git'), other);
|
||||
writeFileSync(join(other, 'from-elsewhere.txt'), 'y\n');
|
||||
git(other, 'add', '-A');
|
||||
git(other, 'commit', '-q', '-m', 'from elsewhere');
|
||||
git(other, 'push', '-q');
|
||||
await refresh();
|
||||
expect(await page.textContent('#gitStatusBody')).not.toMatch(/behind/);
|
||||
|
||||
const before = gitFetchRequests.length;
|
||||
await page.click('#gitStatusFetchBtn');
|
||||
await page.waitForFunction(
|
||||
() => /↓ 1 behind/.test(document.getElementById('gitStatusBody')?.textContent ?? ''),
|
||||
null,
|
||||
{ timeout: 20000 }
|
||||
);
|
||||
expect(gitFetchRequests.length).toBe(before + 1);
|
||||
// The same limits the status route gets go with the fetch, so the two agree on which repositories count.
|
||||
expect(gitFetchRequests.at(-1)).toMatch(/maxRepos=12/);
|
||||
expect(await page.locator('.git-status-chip', { hasText: 'behind' }).textContent()).toBe('↓ 1 behind');
|
||||
expect(await page.textContent('#gitStatusBody .git-status-fetch-summary')).toMatch(
|
||||
/^Fetched .+: 1 of 1 repository\.$/
|
||||
);
|
||||
expect(await page.locator('#gitStatusBody .git-status-fetch-row').count()).toBe(0);
|
||||
|
||||
// The 15 s poll rebuilds the whole body, so the note has to come from the renderer, not from the click.
|
||||
await refresh();
|
||||
expect(await page.locator('#gitStatusBody .git-status-fetch-summary').count()).toBe(1);
|
||||
const foot = (await page.textContent('#gitStatusFooter')) ?? '';
|
||||
expect(foot).toMatch(/Read-only apart from Fetch/);
|
||||
// No longer "only when you click": an interval the user turned on fetches too, and the footer says so.
|
||||
expect(foot).toMatch(/interval/);
|
||||
// The chip itself says how it got current, so the number is not read as live.
|
||||
expect(await page.locator('.git-status-chip', { hasText: 'behind' }).getAttribute('title')).toMatch(/interval/);
|
||||
rmSync(other, { recursive: true, force: true });
|
||||
}, 30000);
|
||||
|
||||
it('a repository Fetch could not fetch keeps its row and shows why', async () => {
|
||||
await page.route('**/api/sessions/*/git-fetch*', (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
state: 'ok',
|
||||
reposTruncated: false,
|
||||
fetchedAt: Date.now(),
|
||||
repos: [
|
||||
{ name: 'fetchable', path: 'fetchable', ok: true },
|
||||
{
|
||||
name: 'private',
|
||||
path: '/srv/private',
|
||||
ok: false,
|
||||
failure: { code: 'AUTH', message: 'could not read the remote' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
await page.click('#gitStatusFetchBtn');
|
||||
await page.waitForFunction(() =>
|
||||
/could not read the remote/.test(document.getElementById('gitStatusBody')?.textContent ?? '')
|
||||
);
|
||||
// The summary counts what landed, and the failure is named rather than folded into the count. The
|
||||
// summary is the panel's first note line, so the count is read off it, not off the whole body.
|
||||
expect(await page.textContent('#gitStatusBody .git-status-fetch-summary')).toMatch(
|
||||
/^Fetched .+: 1 of 2 repositories\.$/
|
||||
);
|
||||
const row = (await page.textContent('#gitStatusBody .git-status-fetch-row')) ?? '';
|
||||
expect(row).toContain('private');
|
||||
expect(row).toContain('⚠ could not read the remote');
|
||||
expect(await page.locator('#gitStatusBody .git-status-fetch-row').count()).toBe(1);
|
||||
// Git text is untrusted: the row is text, never markup.
|
||||
expect(await page.$('#gitStatusBody img')).toBeNull();
|
||||
await page.unroute('**/api/sessions/*/git-fetch*');
|
||||
}, 30000);
|
||||
|
||||
it('one click means one fetch: the button is busy while it runs and a second call is ignored', async () => {
|
||||
let slow = true;
|
||||
await page.route('**/api/sessions/*/git-fetch*', async (route) => {
|
||||
if (slow) await new Promise((r) => setTimeout(r, 1500));
|
||||
await route.continue();
|
||||
});
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.click('#gitStatusFetchBtn');
|
||||
await page.waitForFunction(() => (window as any).app._gitFetchInFlight === true);
|
||||
const busy = await page.evaluate(() => {
|
||||
const b = document.getElementById('gitStatusFetchBtn')!;
|
||||
return { ariaBusy: b.getAttribute('aria-busy'), disabled: b.disabled };
|
||||
});
|
||||
expect(busy.ariaBusy).toBe('true');
|
||||
// Disabled is what stops a second click reaching the handler, so one click is one fetch per repository.
|
||||
expect(busy.disabled).toBe(true);
|
||||
const during = gitFetchRequests.length;
|
||||
await page.evaluate(() => void (window as any).app.fetchGitStatusNow());
|
||||
expect(gitFetchRequests.length).toBe(during);
|
||||
slow = false;
|
||||
// The flag goes false before the re-render that follows it, so wait for the button itself, not the flag:
|
||||
// reading it right at the flip catches the panel mid-render.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const b = document.getElementById('gitStatusFetchBtn')!;
|
||||
return (window as any).app._gitFetchInFlight === false && !b.disabled && !b.getAttribute('aria-busy');
|
||||
},
|
||||
null,
|
||||
{ timeout: 15000 }
|
||||
);
|
||||
// The handler is still asleep: unrouting without waiting fails the run as an unhandled rejection.
|
||||
await page.unrouteAll({ behavior: 'wait' });
|
||||
page.setDefaultTimeout(30000);
|
||||
}, 30000);
|
||||
|
||||
it('Fetch is inert for a session nothing can be fetched for', async () => {
|
||||
await select(plainSession);
|
||||
await page.waitForFunction(
|
||||
() => /No git repository here/.test(document.getElementById('gitStatusBody')?.textContent ?? ''),
|
||||
null,
|
||||
{ timeout: 15000 }
|
||||
);
|
||||
expect(await page.evaluate(() => document.getElementById('gitStatusFetchBtn')!.disabled)).toBe(true);
|
||||
await select(repoSession);
|
||||
await page.waitForFunction(() => document.getElementById('gitStatusFetchBtn')!.disabled === false, null, {
|
||||
timeout: 15000,
|
||||
});
|
||||
await page.waitForSelector('#gitStatusPanel.visible');
|
||||
}, 30000);
|
||||
|
||||
it('closing the panel resets it; turning the setting off hides the button, closes the panel and stops polling', async () => {
|
||||
await page.click('.git-status-actions button[aria-label="Close git status"]');
|
||||
expect(await page.isVisible('#gitStatusPanel')).toBe(false);
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
SessionStatus,
|
||||
SessionRemote,
|
||||
} from '../../src/types.js';
|
||||
import type { GitFetchStatus } from '../../src/git-fetch.js';
|
||||
|
||||
/**
|
||||
* Enhanced mock session for testing RespawnController.
|
||||
@@ -69,6 +70,22 @@ export class MockSession extends EventEmitter {
|
||||
this.keepWarm = status;
|
||||
return changed;
|
||||
}
|
||||
/**
|
||||
* Mirrors the Git fetch interval surface on Session (src/git-fetch.ts): the case's own override, `undefined`
|
||||
* for either half meaning the app-wide setting applies, and what the scheduler last reported.
|
||||
*/
|
||||
gitFetchEnabled: boolean | undefined = undefined;
|
||||
gitFetchIntervalSeconds: number | undefined = undefined;
|
||||
setGitFetchSettings(enabled: boolean | undefined, intervalSeconds: number | undefined): void {
|
||||
this.gitFetchEnabled = enabled;
|
||||
this.gitFetchIntervalSeconds = intervalSeconds;
|
||||
}
|
||||
gitFetch: GitFetchStatus | null = null;
|
||||
setGitFetchStatus(status: GitFetchStatus): boolean {
|
||||
const changed = JSON.stringify(status) !== JSON.stringify(this.gitFetch);
|
||||
this.gitFetch = status;
|
||||
return changed;
|
||||
}
|
||||
private _activeChildProcesses: { pid: number; command: string }[] = [];
|
||||
ralphTracker: null = null;
|
||||
writeBuffer: string[] = [];
|
||||
|
||||
@@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerGitStatusRoutes } from '../../src/web/routes/git-status-routes.js';
|
||||
import { clearGitStatusCache, runGit, type GitRunner } from '../../src/git-workspace-status.js';
|
||||
import { clearGitFetchLane, fetchWorkspaceOnce } from '../../src/git-fetch.js';
|
||||
|
||||
const ENV = {
|
||||
...process.env,
|
||||
@@ -44,6 +45,8 @@ async function setup(
|
||||
|
||||
beforeEach(() => {
|
||||
clearGitStatusCache();
|
||||
// The fetch lane is module state: a run left in it by the previous test would be joined by the next one.
|
||||
clearGitFetchLane();
|
||||
dir = mkdtempSync(join(tmpdir(), 'git-status-route-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
@@ -353,6 +356,261 @@ describe('GET /api/sessions/:id/git-status limits', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/sessions/:id/git-fetch', () => {
|
||||
/** Records what each repository was asked to do, and lets real git answer the rest. */
|
||||
function recordingRunner() {
|
||||
const calls: Array<[string, string[]]> = [];
|
||||
const seenTimeouts: Array<number | undefined> = [];
|
||||
const runner: GitRunner = async (cwd, args, opts) => {
|
||||
calls.push([cwd, args]);
|
||||
seenTimeouts.push(opts?.timeoutMs);
|
||||
return execFileSyncGit(cwd, args);
|
||||
};
|
||||
return { calls, seenTimeouts, runner, fetches: () => calls.filter(([, args]) => args[0] === 'fetch') };
|
||||
}
|
||||
|
||||
it('runs one fetch per repository the window would show, in the session working directory', async () => {
|
||||
for (const name of ['api', 'web']) {
|
||||
mkdirSync(join(dir, name));
|
||||
git(join(dir, name), 'init', '-q', '-b', 'main');
|
||||
}
|
||||
const { calls, fetches, runner } = recordingRunner();
|
||||
const { app } = await setup({ git: runner });
|
||||
const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const data = res.json().data;
|
||||
expect(data.state).toBe('ok');
|
||||
expect(data.repos.map((r: { name: string; path: string }) => [r.name, r.path])).toEqual([
|
||||
['api', 'api'],
|
||||
['web', 'web'],
|
||||
]);
|
||||
expect(data.repos.every((r: { ok: boolean }) => r.ok)).toBe(true);
|
||||
// A bounded run does not promise which repository starts first, so compare the set.
|
||||
expect(fetches().sort()).toEqual([
|
||||
[join(dir, 'api'), ['fetch', '--quiet']],
|
||||
[join(dir, 'web'), ['fetch', '--quiet']],
|
||||
]);
|
||||
// Every repository git was asked to touch sits under the session folder, and under nothing else.
|
||||
for (const [cwd] of calls) expect(cwd.startsWith(dir)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a repository it could not fetch, with its reason, and says which ones it fetched', async () => {
|
||||
for (const name of ['ok', 'private']) {
|
||||
mkdirSync(join(dir, name));
|
||||
git(join(dir, name), 'init', '-q', '-b', 'main');
|
||||
}
|
||||
const { fetches, runner } = recordingRunner();
|
||||
const injected: GitRunner = async (cwd, args, opts) => {
|
||||
const recorded = runner(cwd, args, opts); // records the call before deciding what git would have said
|
||||
if (args[0] === 'fetch' && cwd === join(dir, 'private')) {
|
||||
throw Object.assign(new Error('Command failed'), {
|
||||
stderr: "fatal: could not read Username for 'https://github.com': terminal prompts disabled",
|
||||
});
|
||||
}
|
||||
return recorded;
|
||||
};
|
||||
const { app } = await setup({ git: injected });
|
||||
const data = (await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' })).json().data;
|
||||
const priv = data.repos.find((r: { name: string }) => r.name === 'private');
|
||||
expect(priv?.ok).toBe(false);
|
||||
expect(priv?.failure?.code).toBe('AUTH_REQUIRED');
|
||||
expect(data.repos.find((r: { name: string }) => r.name === 'ok')?.ok).toBe(true);
|
||||
// Both repositories are still in the list, so both were attempted: a failure does not cancel the run.
|
||||
expect(fetches()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('redacts credentials git may have echoed back before the failure reaches the client', async () => {
|
||||
git(dir, 'init', '-q', '-b', 'main');
|
||||
// Only the fetch is faked: the resolver still needs its own `rev-parse` to find the repository at all.
|
||||
const runner: GitRunner = async (cwd, args) => {
|
||||
if (args[0] === 'fetch') {
|
||||
throw Object.assign(new Error('Command failed'), {
|
||||
stderr: "fatal: unable to access 'https://bob:ghp_secret@github.com/x.git/'\u001b[31m",
|
||||
});
|
||||
}
|
||||
return execFileSyncGit(cwd, args);
|
||||
};
|
||||
const { app } = await setup({ git: runner });
|
||||
const data = (await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' })).json().data;
|
||||
expect(data.state).toBe('ok');
|
||||
expect(data.repos[0].failure.stderr).toBe("fatal: unable to access 'https://***:***@github.com/x.git/'[31m");
|
||||
});
|
||||
|
||||
it.each([
|
||||
['remote', { host: 'h', user: 'u' }],
|
||||
['docker', { container: 'c' }],
|
||||
])('does not run git for a %s session and says it is unsupported', async (kind, value) => {
|
||||
git(dir, 'init', '-q', '-b', 'main');
|
||||
const { calls, fetches, runner } = recordingRunner();
|
||||
const { app } = await setup({ git: runner });
|
||||
session[kind] = value;
|
||||
const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data).toMatchObject({ state: 'unsupported', reason: kind });
|
||||
expect(fetches()).toEqual([]);
|
||||
expect(calls).toEqual([]); // the resolver never ran either: the refusal comes before any git
|
||||
});
|
||||
|
||||
it('refuses a repository inside a Docker case workspace before any git runs', async () => {
|
||||
git(dir, 'init', '-q', '-b', 'main');
|
||||
const { calls, fetches, runner } = recordingRunner();
|
||||
const { app } = await setup({ git: runner, dockerWorkspaces: [realpathSync(dir)] });
|
||||
const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
|
||||
expect(res.json().data).toMatchObject({ state: 'unsupported', reason: 'docker' });
|
||||
expect(fetches()).toEqual([]);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('answers not-a-repo for a folder that is not a repository, running no fetch', async () => {
|
||||
const { fetches, runner } = recordingRunner();
|
||||
const { app } = await setup({ git: runner });
|
||||
const data = (await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' })).json().data;
|
||||
expect(data.state).toBe('not-a-repo');
|
||||
// The resolver looked once and found nothing, so nothing was fetched — and never guessed a repository.
|
||||
expect(fetches()).toEqual([]);
|
||||
});
|
||||
|
||||
it("publishes what the click got as the session's own fetch state, naming the trigger the user asked for", async () => {
|
||||
git(dir, 'init', '-q', '-b', 'main');
|
||||
const { runner } = recordingRunner();
|
||||
const { app, ctx } = await setup({ git: runner });
|
||||
const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
// The response body is one tab's copy of the run; the state is what every other tab open on the same
|
||||
// folder sees, and a number the user did not ask for has to read differently from one they did.
|
||||
expect(session.gitFetch).toMatchObject({ trigger: 'click', state: 'ok', repos: 1, failed: 0 });
|
||||
expect(ctx.persistSessionState).toHaveBeenCalled();
|
||||
expect(ctx.broadcast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('joins a run already going for the same folder instead of starting a second git process in the same clone', async () => {
|
||||
git(dir, 'init', '-q', '-b', 'main');
|
||||
let releaseRun = () => {};
|
||||
const held = new Promise<void>((resolve) => (releaseRun = resolve));
|
||||
let running = () => {};
|
||||
const runStarted = new Promise<void>((resolve) => (running = resolve));
|
||||
const fetches: string[] = [];
|
||||
const runner: GitRunner = async (cwd, args) => {
|
||||
if (args[0] === 'fetch') {
|
||||
fetches.push(cwd);
|
||||
running();
|
||||
await held;
|
||||
}
|
||||
return execFileSyncGit(cwd, args);
|
||||
};
|
||||
const { app } = await setup({ git: runner });
|
||||
|
||||
const first = app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
|
||||
await runStarted; // the run is going, and still going
|
||||
// A click from another tab open on the same clone. Two fetches there would collide on git's ref lock, so
|
||||
// this one joins the run already going rather than starting its own.
|
||||
const second = app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
|
||||
// Assert the join BEFORE releasing the run: the lane entry goes away when the run finishes, so releasing
|
||||
// first would let the run complete before this request reached the lane, and it would then be entitled to
|
||||
// start its own fetch — a test that passes for the wrong reason. One tick of the loop is enough for both
|
||||
// handlers to reach the lane while the run is still held open.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(fetches).toHaveLength(1); // one run, and the second request is waiting on it, not working
|
||||
|
||||
releaseRun();
|
||||
const a = (await first).json().data;
|
||||
const b = (await second).json().data;
|
||||
expect(b).toEqual(a); // both tabs get the same run's answer, not a partial one
|
||||
});
|
||||
|
||||
it('publishes the trigger of the run the click joined, because a click that fetched nothing cannot report itself as the fetch', async () => {
|
||||
git(dir, 'init', '-q', '-b', 'main');
|
||||
let releaseRun = () => {};
|
||||
const held = new Promise<void>((resolve) => (releaseRun = resolve));
|
||||
let running = () => {};
|
||||
const runStarted = new Promise<void>((resolve) => (running = resolve));
|
||||
const fetches: string[] = [];
|
||||
const runner: GitRunner = async (cwd, args) => {
|
||||
if (args[0] === 'fetch') {
|
||||
fetches.push(cwd);
|
||||
running();
|
||||
await held;
|
||||
}
|
||||
return execFileSyncGit(cwd, args);
|
||||
};
|
||||
const { app, ctx } = await setup({ git: runner });
|
||||
|
||||
// The interval tick got to this clone first. The click joins it, so the row the other tabs see has to name
|
||||
// the interval — the user clicked, but nothing this click did was a fetch. The request is left open, not
|
||||
// awaited: awaiting it here would wait on the run this test is holding, and the test would wait on itself.
|
||||
const intervalRun = fetchWorkspaceOnce(dir, { git: runner }, 'interval');
|
||||
await runStarted;
|
||||
const res = app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
releaseRun();
|
||||
const run = await intervalRun;
|
||||
const body = (await res).json().data;
|
||||
expect(body).toEqual(run.report); // the run it joined, not a partial answer of its own
|
||||
// Asserted after the run, so this can only pass if the click reached the lane and joined: a click that had
|
||||
// started its own fetch would make this two, and would have nothing to call the interval.
|
||||
expect(fetches).toHaveLength(1);
|
||||
expect(session.gitFetch).toMatchObject({ trigger: 'interval', state: 'ok', repos: 1, failed: 0 });
|
||||
expect(ctx.broadcast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('404s an unknown session, running no fetch', async () => {
|
||||
const { calls, fetches, runner } = recordingRunner();
|
||||
const { app } = await setup({ git: runner });
|
||||
expect((await app.inject({ method: 'POST', url: '/api/sessions/nope/git-fetch' })).statusCode).toBe(404);
|
||||
expect(fetches()).toEqual([]);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('multi-user: another user’s session is not found, and no fetch runs', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
git(dir, 'init', '-q', '-b', 'main');
|
||||
const { fetches, runner } = recordingRunner();
|
||||
const { app } = await setup({ git: runner, authUser: { username: 'bob', role: 'user' } });
|
||||
session.owner = 'alice';
|
||||
expect((await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' })).statusCode).toBe(404);
|
||||
expect(fetches()).toEqual([]);
|
||||
});
|
||||
|
||||
it('honours maxRepos and timeout (seconds) for a fetch too, clamped, and reports the limit it used', async () => {
|
||||
for (const n of ['a', 'b', 'c']) {
|
||||
mkdirSync(join(dir, n));
|
||||
git(join(dir, n), 'init', '-q', '-b', 'main');
|
||||
}
|
||||
const { seenTimeouts, fetches, runner } = recordingRunner();
|
||||
const { app } = await setup({ git: runner });
|
||||
const post = async (query: string) => {
|
||||
const res = await app.inject({ method: 'POST', url: `/api/sessions/test-session-1/git-fetch${query}` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
return res.json().data as { repoLimit: number; repos: Array<{ name: string }> };
|
||||
};
|
||||
const fetchCount = () => fetches().length;
|
||||
const ran = async (query: string, expected: number) => {
|
||||
const before = fetchCount();
|
||||
const data = await post(query);
|
||||
expect(fetchCount() - before).toBe(expected);
|
||||
return data;
|
||||
};
|
||||
|
||||
const limited = await ran('?maxRepos=2&timeout=7', 2);
|
||||
expect(limited.repos.map((r) => r.name)).toEqual(['a', 'b']);
|
||||
expect(limited).toMatchObject({ reposTruncated: true, repoLimit: 2 }); // the window has to say three were found
|
||||
expect(new Set(seenTimeouts)).toEqual(new Set([7000]));
|
||||
|
||||
const wild = await ran('?maxRepos=9999&timeout=1', 3);
|
||||
expect(wild.repoLimit).toBe(50); // clamped to MAX_REPOS_LIMIT, so one request cannot scan the whole disk
|
||||
expect(seenTimeouts.at(-1)).toBe(5000);
|
||||
|
||||
const junk = await ran('?maxRepos=abc&timeout=xyz', 3);
|
||||
expect(junk.repoLimit).toBe(12);
|
||||
expect(seenTimeouts.at(-1)).toBe(30_000);
|
||||
|
||||
// A repeated key reaches the route as an array: it means "default", never a 500.
|
||||
await ran('?timeout=5&timeout=6', 3);
|
||||
expect(seenTimeouts.at(-1)).toBe(30_000);
|
||||
});
|
||||
});
|
||||
|
||||
function execFileSyncGit(cwd: string, args: string[]): Promise<string> {
|
||||
return Promise.resolve(execFileSync('git', ['--no-optional-locks', ...args], { cwd, env: ENV, encoding: 'utf8' }));
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* `POST /api/sessions/:id/git-fetch-settings` (session-routes.ts): the per-case half of the fetch interval
|
||||
* (src/git-fetch-scheduler.ts) — the case's own switch and its own interval. `null` on either half clears
|
||||
* that half so the app-wide setting applies again, and a body that names one row leaves the other as it was,
|
||||
* because the Session Options rows save on their own. Refused for remote and Docker cases, the same rule the
|
||||
* Git panel and the scheduler use. app.inject(), no port.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
|
||||
interface Harness {
|
||||
app: FastifyInstance;
|
||||
ctx: MockRouteContext;
|
||||
}
|
||||
|
||||
async function createHarness(): Promise<Harness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
const ctx = createMockRouteContext();
|
||||
registerSessionRoutes(app, ctx);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return { app, ctx };
|
||||
}
|
||||
|
||||
describe('POST /api/sessions/:id/git-fetch-settings', () => {
|
||||
let harness: Harness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
const post = (payload: unknown) =>
|
||||
harness.app.inject({ method: 'POST', url: `/api/sessions/${harness.ctx._sessionId}/git-fetch-settings`, payload });
|
||||
|
||||
it('stores the case’s own choice, persists and broadcasts it', async () => {
|
||||
const res = await post({ enabled: true, intervalSeconds: 120 });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body).data.gitFetchSettings).toEqual({ enabled: true, intervalSeconds: 120 });
|
||||
expect(harness.ctx._session.gitFetchEnabled).toBe(true);
|
||||
expect(harness.ctx._session.gitFetchIntervalSeconds).toBe(120);
|
||||
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers `null` for a case that follows the app, which is what the Session Options row shows', async () => {
|
||||
const data = JSON.parse((await post({})).body).data.gitFetchSettings;
|
||||
expect(data).toEqual({ enabled: null, intervalSeconds: null });
|
||||
});
|
||||
|
||||
it('clears the override on null so the app-wide setting applies again', async () => {
|
||||
await post({ enabled: true, intervalSeconds: 120 });
|
||||
const res = await post({ enabled: null, intervalSeconds: null });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(harness.ctx._session.gitFetchEnabled).toBeUndefined();
|
||||
expect(harness.ctx._session.gitFetchIntervalSeconds).toBeUndefined();
|
||||
expect(JSON.parse(res.body).data.gitFetchSettings).toEqual({ enabled: null, intervalSeconds: null });
|
||||
});
|
||||
|
||||
it('leaves the other half as it was when only one row was saved', async () => {
|
||||
await post({ enabled: true });
|
||||
expect(harness.ctx._session.gitFetchIntervalSeconds).toBeUndefined(); // still following the app on that half
|
||||
const res = await post({ intervalSeconds: 90 });
|
||||
expect(JSON.parse(res.body).data.gitFetchSettings).toEqual({ enabled: true, intervalSeconds: 90 });
|
||||
});
|
||||
|
||||
it('rejects an interval outside the schema bounds, storing nothing, because that is what the scheduler acts on', async () => {
|
||||
// `null` is NOT on this list: it is the schema's way of clearing the override, tested above.
|
||||
for (const raw of [29, 3601, 12.5, '120', true]) {
|
||||
const res = await post({ enabled: true, intervalSeconds: raw });
|
||||
expect(res.statusCode, String(raw)).toBe(400);
|
||||
}
|
||||
expect(harness.ctx._session.gitFetchEnabled).toBeUndefined();
|
||||
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['remote', { host: 'h', user: 'u' }],
|
||||
['docker', { container: 'c' }],
|
||||
])('refuses a %s case, storing nothing', async (kind, value) => {
|
||||
(harness.ctx._session as unknown as Record<string, unknown>)[kind] = value;
|
||||
const res = await post({ enabled: true, intervalSeconds: 120 });
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' });
|
||||
expect(harness.ctx._session.gitFetchEnabled).toBeUndefined();
|
||||
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('404s an unknown session, storing nothing', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/nope/git-fetch-settings',
|
||||
payload: { enabled: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -34,13 +34,18 @@ function methodBody(signature: string): string {
|
||||
return sessionUi.slice(start, sessionUi.indexOf('\n },', start));
|
||||
}
|
||||
|
||||
const TABS = ['respawn', 'context', 'ralph', 'summary'];
|
||||
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()']) {
|
||||
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);
|
||||
@@ -83,6 +88,40 @@ describe('Session Options modal structure', () => {
|
||||
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"');
|
||||
|
||||
@@ -40,6 +40,7 @@ describe('restoreMuxSessions() hands back every persisted user setting', () => {
|
||||
['auto-compact', /session\.setAutoCompact\(/],
|
||||
['auto-resume', /session\.restoreAutoResume\(/],
|
||||
['nice priority', /session\.setNice\(/],
|
||||
['git fetch', /gitFetchEnabled:\s*savedState\?\.gitFetchEnabled/],
|
||||
])('%s', (_label, pattern) => {
|
||||
expect(body).toMatch(pattern);
|
||||
});
|
||||
@@ -58,6 +59,15 @@ describe('restoreMuxSessions() hands back every persisted user setting', () => {
|
||||
expect(body, `restoreMuxSessions lacks ${call}`).toContain(call);
|
||||
}
|
||||
});
|
||||
|
||||
it('re-applies the fetch choice only when the case actually chose one', () => {
|
||||
// `undefined` is what "follows the app" means here, so a restore that called the setter unconditionally
|
||||
// would hand every case the app default as if it had picked it, and the half-choice rows would read wrong.
|
||||
const reapply = functionBody('reapplyPersistedSessionState');
|
||||
expect(reapply).toMatch(
|
||||
/if \(saved\.gitFetchEnabled !== undefined \|\| saved\.gitFetchIntervalSeconds !== undefined\)[\s\S]{0,160}session\.setGitFetchSettings\(saved\.gitFetchEnabled, saved\.gitFetchIntervalSeconds\)/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the setters round-trip through toState()', () => {
|
||||
@@ -83,4 +93,26 @@ describe('the setters round-trip through toState()', () => {
|
||||
imageWatcherEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips the fetch choice, and keeps a case that follows the app unchosen', () => {
|
||||
const chose = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||||
chose.setGitFetchSettings(true, 120);
|
||||
const state = chose.toState();
|
||||
expect(state).toMatchObject({ gitFetchEnabled: true, gitFetchIntervalSeconds: 120 });
|
||||
|
||||
const rebuilt = new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'shell',
|
||||
gitFetchEnabled: state.gitFetchEnabled,
|
||||
gitFetchIntervalSeconds: state.gitFetchIntervalSeconds,
|
||||
});
|
||||
expect(rebuilt.toState()).toMatchObject({ gitFetchEnabled: true, gitFetchIntervalSeconds: 120 });
|
||||
|
||||
// Undefined is the whole meaning of "follows the app": a restore that wrote the app default into the
|
||||
// case would make it look like the case had picked it, and the row would stop following.
|
||||
const follower = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||||
follower.setGitFetchSettings(undefined, undefined);
|
||||
expect(follower.toState().gitFetchEnabled).toBeUndefined();
|
||||
expect(follower.toState().gitFetchIntervalSeconds).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -140,11 +140,13 @@ describe('the Bottom bar settings group in zh-CN', () => {
|
||||
/** What may stay Latin here: the git and Docker names, and the product name. */
|
||||
const left = (text: string) => text.replace(/\b(Git|git|Docker|Codeman)\b/g, '').match(/[A-Za-z]+/g) ?? [];
|
||||
|
||||
it("finds the heading and the four Git status rows, #543's two included (the check is not vacuous)", () => {
|
||||
expect(englishGroup.size).toBe(9);
|
||||
it("finds the heading and the six Git rows, #543's two included and the fetch pair among them (the check is not vacuous)", () => {
|
||||
expect(englishGroup.size).toBe(13);
|
||||
expect(englishGroup.get('heading')).toBe('Bottom bar');
|
||||
expect(englishGroup.get('appSettingsGitStatusMaxRepos label')).toBe('Git status: max repositories');
|
||||
expect(englishGroup.get('appSettingsGitStatusTimeout label')).toBe('Git status: git timeout');
|
||||
expect(englishGroup.get('appSettingsGitFetchEnabled label')).toBe('Git fetch on an interval');
|
||||
expect(englishGroup.get('appSettingsGitFetchIntervalSeconds label')).toBe('Git fetch: how often');
|
||||
});
|
||||
|
||||
it('translates the heading, every label and every description, with no English left', () => {
|
||||
@@ -167,7 +169,8 @@ describe('the Bottom bar settings group in zh-CN', () => {
|
||||
expect([...englishGroup.values()].filter((s) => t.t(s) !== s)).toEqual([]);
|
||||
en.window.close();
|
||||
const repeated = [...englishGroup.values()].filter((s) => {
|
||||
const key = s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
// A key with an apostrophe is written escaped in the object literal, so the pattern must allow both spellings.
|
||||
const key = s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/'/g, "(?:\\\\)?'");
|
||||
return (I18N.match(new RegExp(`^\\s*(?:'${key}'|"${key}"|${key}):`, 'gm')) ?? []).length !== 1;
|
||||
});
|
||||
expect(repeated).toEqual([]);
|
||||
@@ -179,6 +182,6 @@ describe('the Bottom bar settings group in zh-CN', () => {
|
||||
const labels = [...doc.querySelectorAll('#gitStatusPanel .git-status-actions button')].map((b) =>
|
||||
b.getAttribute('aria-label')
|
||||
);
|
||||
expect(labels).toEqual(['刷新 Git 状态', '关闭 Git 状态']);
|
||||
expect(labels).toEqual(['刷新 Git 状态', '拉取 Git 状态', '关闭 Git 状态']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user