Merge remote-tracking branch 'origin/master' into pr/cod-455-xlsx-preview

# Conflicts:
#	CLAUDE.md
#	src/web/public/styles.css
This commit is contained in:
Aamer Akhter
2026-10-05 21:19:54 -04:00
67 changed files with 10477 additions and 164 deletions
+369
View File
@@ -0,0 +1,369 @@
/**
* @fileoverview Tests for the bounded path probe (src/utils/bounded-path-probe.ts):
* a stat() that never settles (an unreachable hard network mount) must not hold
* the caller past the timeout, must read as "unknown" rather than "absent", must
* not be re-issued while it is still pending, must not let stalled probes pile up
* in libuv's shared threadpool, and must not make unrelated healthy paths unknown.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('node:fs/promises', () => ({
default: { stat: vi.fn() },
}));
// The kernel mount table the probe scopes a stall by. `/mnt/nas` and `/mnt/nas b`
// (a mount point with a space, octal-escaped in the table) are network mounts;
// everything else sits on the root filesystem. `null` = no table (not Linux).
const mounts = vi.hoisted(() => ({
table: null as string | null,
default: [
'sysfs /sys sysfs rw 0 0',
'/dev/sda1 / ext4 rw 0 0',
'nas:/export /mnt/nas nfs rw,hard 0 0',
'nas:/other /mnt/nas\\040b nfs rw,hard 0 0',
'',
].join('\n'),
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const readFileSync = ((path: unknown, ...rest: unknown[]) => {
if (String(path) === '/proc/self/mounts') {
if (mounts.table === null) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
return mounts.table;
}
return (actual.readFileSync as (...a: unknown[]) => unknown)(path, ...rest);
}) as typeof actual.readFileSync;
return { ...actual, readFileSync, default: { ...actual, readFileSync } };
});
import fs from 'node:fs/promises';
import {
boundedPathExists,
describeUnknownPath,
isNearStalledPath,
probePath,
probePathKind,
unknownPathReason,
} from '../src/utils/bounded-path-probe.js';
import { MAX_STALLED_PATH_PROBES, PATH_PROBE_STALL_CEILING, PATH_PROBE_TIMEOUT_MS } from '../src/config/path-probe.js';
const stat = vi.mocked(fs.stat);
const dirStats = { isDirectory: () => true } as never;
const fileStats = { isDirectory: () => false } as never;
let releases: Map<string, () => void>;
let warn: ReturnType<typeof vi.spyOn>;
/**
* Make the first stat() of each given path hang until released (the mount is
* down); every later stat, and every other path, answers "a directory exists".
*/
function hangOn(paths: string[]): Map<string, () => void> {
stat.mockImplementation((path) => {
if (!paths.includes(String(path)) || releases.has(String(path))) return Promise.resolve(dirStats);
return new Promise((resolve) => {
releases.set(String(path), () => resolve(dirStats));
});
});
return releases;
}
/** Start probes for `paths` and let them time out, leaving each one stalled. */
async function stall(paths: string[]): Promise<void> {
const pending = paths.map((p) => probePath(p));
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
expect(await Promise.all(pending)).toEqual(paths.map(() => 'unknown'));
}
beforeEach(() => {
mounts.table = mounts.default;
releases = new Map();
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(async () => {
// Settle every stalled stat so module state does not leak into the next test.
releases.forEach((release) => release());
if (vi.isFakeTimers()) await vi.advanceTimersByTimeAsync(0);
else await new Promise((r) => setTimeout(r, 0));
vi.useRealTimers();
stat.mockReset();
warn.mockRestore();
});
describe('probePath', () => {
it('tells present, absent and unreadable apart', async () => {
stat.mockImplementation(async (path) => {
if (String(path) === '/present') return dirStats;
if (String(path) === '/eio') throw Object.assign(new Error('EIO'), { code: 'EIO' });
if (String(path) === '/notdir/child') throw Object.assign(new Error('ENOTDIR'), { code: 'ENOTDIR' });
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
expect(await probePath('/present')).toBe('present');
expect(await probePath('/missing')).toBe('absent');
expect(await probePath('/notdir/child')).toBe('absent');
// A soft mount that gave up answers EIO: that is not proof the path is gone.
expect(await probePath('/eio')).toBe('unknown');
expect(await boundedPathExists('/present')).toBe(true);
expect(await boundedPathExists('/missing')).toBe(false);
expect(await boundedPathExists('/eio')).toBe(false);
// An error answer is neither a stall nor a refusal.
expect(unknownPathReason('/eio')).toBe('unreadable');
expect(describeUnknownPath('Case folder', '/eio')).toBe('Case folder is not responding or not readable: /eio');
});
it('reports whether a present path is a directory', async () => {
stat.mockImplementation(async (path) => (String(path) === '/dir' ? dirStats : fileStats));
expect(await probePathKind('/dir')).toBe('directory');
expect(await probePathKind('/file')).toBe('file');
});
it('answers unknown (not absent) after the timeout, and does not re-probe until the stat settles', async () => {
vi.useFakeTimers();
hangOn(['/mnt/stalled/case']);
const result = probePath('/mnt/stalled/case');
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
expect(await result).toBe('unknown');
// A second caller gets the cached verdict immediately, without another stat.
expect(await probePath('/mnt/stalled/case')).toBe('unknown');
expect(stat).toHaveBeenCalledTimes(1);
// Once the mount answers, the path is probed afresh.
releases.get('/mnt/stalled/case')!();
await vi.advanceTimersByTimeAsync(0);
expect(await probePath('/mnt/stalled/case')).toBe('present');
expect(stat).toHaveBeenCalledTimes(2);
});
it('shares one in-flight stat between concurrent callers of the same path', async () => {
hangOn(['/slow']);
const a = probePath('/slow');
const b = boundedPathExists('/slow');
expect(stat).toHaveBeenCalledTimes(1);
releases.get('/slow')!();
expect(await a).toBe('present');
expect(await b).toBe(true);
});
it('does not give concurrent healthy probes a false negative', async () => {
stat.mockImplementation(async () => dirStats);
const results = await Promise.all(['/a', '/b', '/c', '/d', '/e'].map((p) => boundedPathExists(p)));
expect(results).toEqual([true, true, true, true, true]);
});
it('still probes a healthy path as present while fewer unrelated paths are stalled than the cap', async () => {
vi.useFakeTimers();
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES - 1 }, (_, i) => `/mnt/nas-${i}/project`);
hangOn(dead);
await stall(dead);
expect(await probePath('/home/user/codeman-cases/healthy')).toBe('present');
expect(await boundedPathExists('/home/user/codeman-cases/healthy/CLAUDE.md')).toBe(true);
expect(isNearStalledPath('/home/user/codeman-cases/healthy')).toBe(false);
});
it('answers unknown, without a stat, for paths near a stalled one', async () => {
vi.useFakeTimers();
hangOn(['/mnt/nas/project-one']);
await stall(['/mnt/nas/project-one']);
stat.mockClear();
// Its own files, and a sibling linked case on the same mount.
expect(await probePath('/mnt/nas/project-one/CLAUDE.md')).toBe('unknown');
expect(await probePath('/mnt/nas/project-two')).toBe('unknown');
expect(isNearStalledPath('/mnt/nas/project-two/.claude/settings.local.json')).toBe(true);
expect(stat).not.toHaveBeenCalled();
releases.get('/mnt/nas/project-one')!();
await vi.advanceTimersByTimeAsync(0);
expect(isNearStalledPath('/mnt/nas/project-two')).toBe(false);
expect(await probePath('/mnt/nas/project-two')).toBe('present');
});
it('reads octal-escaped mount points from the table', async () => {
vi.useFakeTimers();
hangOn(['/mnt/nas b/one']);
await stall(['/mnt/nas b/one']);
expect(isNearStalledPath('/mnt/nas b/two')).toBe(true);
expect(isNearStalledPath('/mnt/nas/two')).toBe(false);
});
it('never takes the root filesystem down with a stalled path on it, only that path', async () => {
vi.useFakeTimers();
hangOn(['/srv/projects/stuck']);
await stall(['/srv/projects/stuck']);
expect(await probePath('/srv/projects/stuck/CLAUDE.md')).toBe('unknown');
expect(await probePath('/srv/projects/other')).toBe('present');
expect(await probePath('/home/user/codeman-cases/one')).toBe('present');
});
it('narrows a stall on a local mount to the stalled path, even when that mount is not /', async () => {
// /home is its own local filesystem; ~/nas is a symlink to a network mount, so
// the stalled path is typed under /home. Only network and FUSE mounts widen.
vi.useFakeTimers();
mounts.table = [mounts.default, '/dev/sdb1 /home ext4 rw,relatime 0 0', ''].join('\n');
hangOn(['/home/user/nas/project']);
await stall(['/home/user/nas/project']);
stat.mockClear();
expect(await probePath('/home/user/nas/project/CLAUDE.md')).toBe('unknown');
expect(isNearStalledPath('/home/user/codeman-cases/one')).toBe(false);
expect(await probePath('/home/user/codeman-cases/one')).toBe('present');
expect(await probePath('/home/user/nas/other')).toBe('present');
expect(stat).toHaveBeenCalledTimes(2);
});
it('widens a stall to the whole mount for network and FUSE filesystems', async () => {
vi.useFakeTimers();
mounts.table = [
mounts.default,
'nas:/four /srv/nas4 nfs4 rw,hard 0 0',
'user@host:/ /srv/sshfs fuse.sshfs rw 0 0',
'//nas/share /srv/smb cifs rw 0 0',
'',
].join('\n');
const dead = ['/srv/nas4/one', '/srv/sshfs/one'];
hangOn(dead);
await stall(dead);
expect(isNearStalledPath('/srv/nas4/two')).toBe(true);
expect(isNearStalledPath('/srv/sshfs/two')).toBe(true);
expect(isNearStalledPath('/srv/smb/two')).toBe(false);
expect(isNearStalledPath('/srv/elsewhere')).toBe(false);
});
it('narrows a stall to the stalled path when there is no mount table', async () => {
vi.useFakeTimers();
mounts.table = null;
hangOn(['/mnt/nas/project-one']);
await stall(['/mnt/nas/project-one']);
expect(await probePath('/mnt/nas/project-one/CLAUDE.md')).toBe('unknown');
expect(await probePath('/mnt/nas/project-two')).toBe('present');
});
it('refuses new stats once stalled probes would tie up the threadpool, answering unknown', async () => {
vi.useFakeTimers();
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `/mnt/dead-${i}/case`);
hangOn(dead);
await stall(dead);
stat.mockClear();
// Every slot is held by a stat that never returned: refuse another, but never
// claim the path is absent.
expect(await probePath('/healthy/elsewhere')).toBe('unknown');
expect(stat).not.toHaveBeenCalled();
// Once the stalled stats settle, probing resumes normally.
releases.forEach((release) => release());
await vi.advanceTimersByTimeAsync(0);
expect(await probePath('/healthy/elsewhere')).toBe('present');
expect(stat).toHaveBeenCalledTimes(1);
});
it('lets a pastCap probe through the cap, still bounded and still recorded as stalled', async () => {
vi.useFakeTimers();
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `/mnt/full-${i}/case`);
hangOn([...dead, '/mnt/another-dead/case']);
await stall(dead);
stat.mockClear();
expect(await probePath('/healthy/explicit', { pastCap: true })).toBe('present');
const hung = probePath('/mnt/another-dead/case', { pastCap: true });
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
expect(await hung).toBe('unknown');
// A retry is answered from the stall record, not with another stat.
expect(await probePath('/mnt/another-dead/case', { pastCap: true })).toBe('unknown');
expect(stat).toHaveBeenCalledTimes(2);
});
it('stops pastCap probes at the threadpool ceiling, answering unknown without a stat', async () => {
vi.useFakeTimers();
// Fill the bulk cap, then let pastCap probes stall until the ceiling is reached.
const dead = Array.from({ length: PATH_PROBE_STALL_CEILING + 1 }, (_, i) => `/mnt/ceiling-${i}/case`);
hangOn(dead);
await stall(dead.slice(0, MAX_STALLED_PATH_PROBES));
for (const path of dead.slice(MAX_STALLED_PATH_PROBES, PATH_PROBE_STALL_CEILING)) {
const hung = probePath(path, { pastCap: true });
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
expect(await hung).toBe('unknown');
}
stat.mockClear();
// One worker must stay free: no new stat, even for an explicit request.
const refused = probePath(dead[PATH_PROBE_STALL_CEILING], { pastCap: true });
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
expect(await refused).toBe('unknown');
expect(stat).not.toHaveBeenCalled();
expect(await probePath('/healthy/explicit', { pastCap: true })).toBe('unknown');
expect(stat).not.toHaveBeenCalled();
// ...and the message says the folder was never checked, rather than blaming it.
expect(unknownPathReason('/healthy/explicit', { pastCap: true })).toBe('refused');
expect(unknownPathReason(dead[0], { pastCap: true })).toBe('stalled');
expect(describeUnknownPath('workingDir', '/healthy/explicit', { pastCap: true })).toMatch(
/^workingDir was not checked: .*not answering.*: \/healthy\/explicit$/
);
expect(describeUnknownPath('workingDir', dead[0], { pastCap: true })).toBe(
`workingDir is not responding or not readable: ${dead[0]}`
);
// Once one stalled stat settles, an explicit request is probed again.
releases.get(dead[0])!();
await vi.advanceTimersByTimeAsync(0);
expect(await probePath('/healthy/explicit', { pastCap: true })).toBe('present');
});
it('keeps the bulk cap below the ceiling, so a pastCap probe has room', async () => {
// Read under a controlled environment: the limits are computed at import from
// UV_THREADPOOL_SIZE and CODEMAN_PATH_PROBE_MAX_STALLED, which the test process
// could otherwise inherit.
const saved = { uv: process.env.UV_THREADPOOL_SIZE, max: process.env.CODEMAN_PATH_PROBE_MAX_STALLED };
const limitsUnder = async (uv: string | undefined, max: string | undefined) => {
if (uv === undefined) delete process.env.UV_THREADPOOL_SIZE;
else process.env.UV_THREADPOOL_SIZE = uv;
if (max === undefined) delete process.env.CODEMAN_PATH_PROBE_MAX_STALLED;
else process.env.CODEMAN_PATH_PROBE_MAX_STALLED = max;
vi.resetModules();
return import('../src/config/path-probe.js');
};
try {
const defaults = await limitsUnder(undefined, undefined);
expect([defaults.MAX_STALLED_PATH_PROBES, defaults.PATH_PROBE_STALL_CEILING]).toEqual([2, 3]);
const bigPool = await limitsUnder('8', undefined);
expect([bigPool.MAX_STALLED_PATH_PROBES, bigPool.PATH_PROBE_STALL_CEILING]).toEqual([6, 7]);
// An override may reach the ceiling but never pass it.
const overridden = await limitsUnder(undefined, '64');
expect(overridden.MAX_STALLED_PATH_PROBES).toBe(overridden.PATH_PROBE_STALL_CEILING);
} finally {
if (saved.uv === undefined) delete process.env.UV_THREADPOOL_SIZE;
else process.env.UV_THREADPOOL_SIZE = saved.uv;
if (saved.max === undefined) delete process.env.CODEMAN_PATH_PROBE_MAX_STALLED;
else process.env.CODEMAN_PATH_PROBE_MAX_STALLED = saved.max;
vi.resetModules();
}
});
it('warns once when a path first stalls and once when the cap engages', async () => {
vi.useFakeTimers();
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `/mnt/gone-${i}/case`);
hangOn(dead);
await stall([dead[0]]);
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0][0])).toContain('/mnt/gone-0/case');
// Asking again about the same stalled path does not warn again.
await probePath(dead[0]);
expect(warn).toHaveBeenCalledTimes(1);
await stall(dead.slice(1));
warn.mockClear();
await probePath('/healthy/one');
await probePath('/healthy/two');
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0][0])).toMatch(/stalled/i);
});
});
+173
View File
@@ -0,0 +1,173 @@
/** @fileoverview Add Case → Create New → "Create in a custom folder", end to end: real server, real Chromium, real folders. */
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { basename, join } from 'node:path';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
declare const PathPicker: any; // evaluated inside the page, where it is a global
const PORT = 3193;
describe('Create a case in a custom folder', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
let parent: string;
beforeAll(async () => {
parent = mkdtempSync(join(homedir(), 'custom-case-'));
server = new WebServer(PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
}, 90000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
rmSync(parent, { recursive: true, force: true });
}, 60000);
const open = async () => {
await page.evaluate(() => {
(window as any).app.showCreateCaseModal();
(window as any).app.switchCaseModalTab('case-create');
});
};
const toastText = () =>
page.evaluate(() => [...document.querySelectorAll('.toast')].map((t) => t.textContent).join('|'));
it('hides the parent-folder field until the box is ticked, and shows what it will create', async () => {
await open();
expect(await page.isVisible('#newCaseCustomPathRow')).toBe(false);
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
expect(await page.isVisible('#newCaseCustomPathRow')).toBe(true);
await page.fill('#newCaseName', 'my-app');
await page.fill('#newCasePath', '~/projects/');
expect(await page.textContent('#newCasePathPreview')).toBe('Will create: ~/projects/my-app');
});
it('is exclusive with the Docker option, in both directions', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
expect(await page.isDisabled('#newCaseDocker')).toBe(true);
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
expect(await page.isDisabled('#newCaseDocker')).toBe(false);
await page.click('label.checkbox-row:has(#newCaseDocker)');
expect(await page.isDisabled('#newCaseCustomPathToggle')).toBe(true);
await page.click('label.checkbox-row:has(#newCaseDocker)');
});
it('Browse opens the folder picker for directories only and fills the field with the choice', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'picked');
const opts = await page.evaluate(() => {
// A top-level `const` in a classic script: a global binding, not a window property.
const picker = PathPicker;
let captured: any = null;
const original = picker.open;
picker.open = (o: any) => (captured = o);
(document.querySelector('#newCaseCustomPathRow .path-input-browse') as HTMLElement).click();
picker.open = original;
captured.onSelect('/srv/work');
return { directoriesOnly: captured.directoriesOnly };
});
expect(opts.directoriesOnly).toBe(true);
expect(await page.inputValue('#newCasePath')).toBe('/srv/work');
expect(await page.textContent('#newCasePathPreview')).toBe('Will create: /srv/work/picked');
});
it('asks for a folder when the box is ticked and the field is empty, and creates nothing', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'no-folder');
await page.evaluate(() => (window as any).app.createCase());
expect(await toastText()).toMatch(/Choose the folder/);
});
it('creates the case in the chosen folder, scaffolds it, and lists it at that path', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'in-custom');
await page.fill('#newCasePath', parent);
await page.evaluate(() => (window as any).app.submitCaseModal());
await page.waitForFunction(() =>
/created in/.test([...document.querySelectorAll('.toast')].map((t) => t.textContent).join('|'))
);
const target = join(parent, 'in-custom');
expect(readFileSync(join(target, 'CLAUDE.md'), 'utf8')).toContain('in-custom');
expect(existsSync(join(target, 'src'))).toBe(true);
const cases = await page.evaluate(async () => {
const body = await (await fetch('/api/cases')).json();
return Array.isArray(body) ? body : body.data;
});
expect(cases.find((c: { name: string }) => c.name === 'in-custom')).toMatchObject({ path: target });
expect(existsSync(join(homedir(), 'codeman-cases', 'in-custom'))).toBe(false);
expect(await page.isVisible('#createCaseModal.active')).toBe(false);
});
it('shows the server’s reason for a folder that already has files, and leaves it untouched', async () => {
const busy = join(parent, 'busy');
mkdirSync(busy);
writeFileSync(join(busy, 'keep.txt'), 'mine');
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'busy');
await page.fill('#newCasePath', parent);
await page.evaluate(() => (window as any).app.createCase());
await page.waitForFunction(() =>
/Link Existing/.test([...document.querySelectorAll('.toast')].map((t) => t.textContent).join('|'))
);
expect(readFileSync(join(busy, 'keep.txt'), 'utf8')).toBe('mine');
expect(existsSync(join(busy, 'CLAUDE.md'))).toBe(false);
});
it('rewords the "under ~/codeman-cases" hints while a custom folder is picked', async () => {
await open();
expect(await page.textContent('#newCaseNameHint')).toMatch(/Created in ~\/codeman-cases/);
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
expect(await page.textContent('#newCaseNameHint')).toMatch(/parent folder below/);
expect(await page.textContent('#newCaseBlurb')).toMatch(/a folder you choose/);
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
expect(await page.textContent('#newCaseNameHint')).toMatch(/Created in ~\/codeman-cases/);
expect(await page.textContent('#newCaseBlurb')).toMatch(/under ~\/codeman-cases/);
});
it('keeps / as the root parent instead of sending an empty path', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'at-root');
await page.fill('#newCasePath', '/');
expect(await page.textContent('#newCasePathPreview')).toBe('Will create: /at-root');
expect(await page.evaluate(() => (window as any).app._newCaseTargetPath())).toBe('/at-root');
});
it('names the folder the server created in the success toast (~ expanded)', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'via-tilde');
await page.fill('#newCasePath', `~/${basename(parent)}`);
await page.evaluate(() => (window as any).app.submitCaseModal());
const target = join(realpathSync(parent), 'via-tilde');
await page.waitForFunction(
(t) => [...document.querySelectorAll('.toast')].some((el) => el.textContent?.includes(t)),
target
);
expect(await toastText()).not.toMatch(/created in ~\//);
});
it('starts unticked every time the modal opens', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCasePath', '/tmp');
await open();
expect(await page.isChecked('#newCaseCustomPathToggle')).toBe(false);
expect(await page.inputValue('#newCasePath')).toBe('');
expect(await page.isVisible('#newCaseCustomPathRow')).toBe(false);
});
});
+91
View File
@@ -0,0 +1,91 @@
/**
* @fileoverview "Create in a custom folder" (#535) on a parent folder that does not
* answer (#516): `prepareNewCasePath` must ask the bounded path probe first, and give
* up with UNREACHABLE within the probe timeout instead of reaching the realpath / stat /
* lstat / readdir calls that would wait on a hard mount forever.
*
* Only the chosen dead paths hang; everything else is the real filesystem.
* Port: none.
*/
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const probe = vi.hoisted(() => {
// Short probe timeout so a stall costs ~100 ms, read at import.
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '100';
return { dead: '/mnt/dead-nas-case-parent', releases: [] as Array<() => void>, touched: [] as string[] };
});
/** A call on the dead path never settles (a hard mount), until afterEach releases it. */
function hangOnDead<F extends (...a: never[]) => unknown>(name: string, real: F): F {
return ((path: string, ...rest: unknown[]) => {
if (String(path) === probe.dead || String(path).startsWith(probe.dead + '/')) {
probe.touched.push(`${name} ${String(path)}`);
return new Promise((_resolve, reject) => {
probe.releases.push(() => reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })));
});
}
return (real as unknown as (...a: unknown[]) => unknown)(path, ...rest);
}) as unknown as F;
}
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
const stat = hangOnDead('stat', actual.stat);
return { ...actual, stat, default: { ...actual, stat } };
});
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const promises = {
...actual.promises,
realpath: hangOnDead('realpath', actual.promises.realpath),
stat: hangOnDead('stat', actual.promises.stat),
lstat: hangOnDead('lstat', actual.promises.lstat),
readdir: hangOnDead('readdir', actual.promises.readdir),
};
return { ...actual, promises, default: { ...actual, promises } };
});
import { prepareNewCasePath } from '../src/web/case-path.js';
const root = realpathSync(mkdtempSync(join(tmpdir(), 'case-path-unreachable-')));
const ctx = { home: join(root, 'home'), dataDir: join(root, 'home', '.codeman'), casesDirs: [join(root, 'cases')] };
afterEach(async () => {
probe.releases.splice(0).forEach((release) => release());
probe.touched.length = 0;
await new Promise((r) => setTimeout(r, 0));
});
afterAll(() => {
rmSync(root, { recursive: true, force: true });
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
});
describe('prepareNewCasePath on a parent folder that does not answer', () => {
it('answers UNREACHABLE within the probe timeout and never touches the path unbounded', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = await Promise.race([
prepareNewCasePath(`${probe.dead}/new-case`, ctx),
new Promise<'hung'>((resolve) => setTimeout(() => resolve('hung'), 2_000)),
]);
warn.mockRestore();
expect(result).toMatchObject({ ok: false, code: 'UNREACHABLE' });
expect((result as { reason: string }).reason).toBe(
`The parent folder is not responding or not readable: ${probe.dead}`
);
// Only the bounded probe's own stat reached the dead mount.
expect(probe.touched).toEqual([`stat ${probe.dead}`]);
});
it('still reports a parent that definitely does not exist as NOT_FOUND', async () => {
expect(await prepareNewCasePath(join(root, 'no-such-parent', 'new-case'), ctx)).toMatchObject({
ok: false,
code: 'NOT_FOUND',
});
});
});
+192
View File
@@ -0,0 +1,192 @@
// @vitest-environment node
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { blockedReason, expandHome, prepareNewCasePath, type NewCasePathContext } from '../src/web/case-path.js';
let root: string;
let home: string;
let ctx: NewCasePathContext;
beforeEach(() => {
// Resolved: prepareNewCasePath answers with symlink-resolved paths (macOS temp is under /private).
root = realpathSync(mkdtempSync(join(tmpdir(), 'case-path-')));
home = join(root, 'home');
mkdirSync(join(home, 'code'), { recursive: true });
ctx = { home, dataDir: join(home, '.codeman') };
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
describe('expandHome', () => {
it('expands ~ and ~/x only', () => {
expect(expandHome('~', '/h')).toBe('/h');
expect(expandHome('~/code/app', '/h')).toBe('/h/code/app');
expect(expandHome('~other/x', '/h')).toBe('~other/x');
expect(expandHome('/abs/~/x', '/h')).toBe('/abs/~/x');
});
});
describe('blockedReason', () => {
const c = { home: '/home/u', dataDir: '/home/u/.codeman' };
it.each([
['/', /root/],
['/etc', /system directory/],
['/etc/cron.d/x', /system directory/],
['/usr/local/src', /system directory/],
['/proc/1', /system directory/],
['/home/u', /home folder itself/],
['/home/u/.ssh', /credentials/],
['/home/u/.ssh/proj', /credentials/],
['/home/u/.aws/x', /credentials/],
['/home/u/.claude/skills/x', /configuration/],
['/home/u/.codeman/cases/x', /data folder/],
['/home/u/.codeman-beta/x', /data folder/],
])('refuses %s', (p, why) => expect(blockedReason(p, c)).toMatch(why));
it.each(['/home/u/code/app', '/home/u/.config/app', '/srv/projects/x', '/opt/work', '/tmp/x', '/home/u/etc/app'])(
'allows %s (a name that merely contains a blocked word is fine)',
(p) => expect(blockedReason(p, c)).toBeNull()
);
it('does not treat /etcetera or /usrlocal as the system directories', () => {
expect(blockedReason('/etcetera/x', c)).toBeNull();
expect(blockedReason('/usrlocal', c)).toBeNull();
});
it('refuses a cases directory and anything inside it, when given', () => {
const withCases = { ...c, casesDirs: ['/home/u/codeman-cases'] };
expect(blockedReason('/home/u/codeman-cases', withCases)).toMatch(/plain Create New/);
expect(blockedReason('/home/u/codeman-cases/foo', withCases)).toMatch(/plain Create New/);
expect(blockedReason('/home/u/codeman-cases-old/foo', withCases)).toBeNull();
expect(blockedReason('/home/u/codeman-cases/foo', c)).toBeNull();
});
it('judges against the system roots it is given', () => {
expect(blockedReason('/private/etc/x', c)).toBeNull();
expect(blockedReason('/private/etc/x', c, ['/private/etc'])).toMatch(/system directory/);
});
});
describe('prepareNewCasePath', () => {
it('accepts a new folder under an existing parent and reports it does not exist yet', async () => {
const r = await prepareNewCasePath(join(home, 'code', 'new-app'), ctx);
expect(r).toMatchObject({ ok: true, existedEmpty: false });
if (r.ok) expect(r.path).toMatch(/code\/new-app$/);
});
it('accepts an existing EMPTY folder and says so', async () => {
mkdirSync(join(home, 'code', 'empty'));
expect(await prepareNewCasePath(join(home, 'code', 'empty'), ctx)).toMatchObject({ ok: true, existedEmpty: true });
});
it('expands ~, tolerates a trailing slash and a ./ segment, and accepts spaces', async () => {
const a = await prepareNewCasePath('~/code/from-tilde', ctx);
expect(a.ok && a.path).toBe(join(home, 'code', 'from-tilde'));
// A "./" segment and a trailing slash normalise away; the folder name may contain a space.
const b = await prepareNewCasePath(`${join(home, 'code')}/./with space/`, ctx);
expect(b.ok && b.path).toBe(join(home, 'code', 'with space'));
// ...and a folder with a space in it can be the PARENT of the next one.
mkdirSync(join(home, 'code', 'with space'));
expect(await prepareNewCasePath(`${join(home, 'code', 'with space')}/proj/`, ctx)).toMatchObject({ ok: true });
});
it.each([
['empty', ''],
['whitespace', ' '],
['relative', 'code/app'],
['dot-relative', './app'],
['traversal', '/tmp/../etc/x'],
['shell metacharacters', '/tmp/a;rm -rf /'],
['command substitution', '/tmp/$(id)'],
['quotes', "/tmp/it's"],
['newline', '/tmp/a\nb'],
])('rejects %s as invalid', async (_label, raw) => {
expect(await prepareNewCasePath(raw, ctx)).toMatchObject({ ok: false, code: 'INVALID' });
});
it('refuses system, home, credential and Codeman folders as BLOCKED', async () => {
for (const raw of [
'/etc/proj',
'/usr/src/x',
home,
join(home, '.ssh', 'x'),
join(home, '.codeman', 'cases', 'x'),
]) {
expect(await prepareNewCasePath(raw, ctx), raw).toMatchObject({ ok: false, code: 'BLOCKED' });
}
});
it('judges the symlink-resolved path too: a link into a blocked tree is not a way around it', async () => {
symlinkSync('/etc', join(home, 'code', 'sneaky'));
expect(await prepareNewCasePath(join(home, 'code', 'sneaky', 'proj'), ctx)).toMatchObject({
ok: false,
code: 'BLOCKED',
});
});
it('judges the resolved path against the resolved home too, when home is reached through a symlink', async () => {
const realHome = join(root, 'realhome');
mkdirSync(join(realHome, '.ssh'), { recursive: true });
mkdirSync(join(realHome, 'code'));
const linkHome = join(root, 'linkhome');
symlinkSync(realHome, linkHome);
// Typed, this reads as <link home>/code/innocent/x; resolved, it is <real home>/.ssh/x.
symlinkSync(join(realHome, '.ssh'), join(realHome, 'code', 'innocent'));
const viaLink = { home: linkHome, dataDir: join(linkHome, '.codeman') };
expect(await prepareNewCasePath(join(linkHome, 'code', 'innocent', 'x'), viaLink)).toMatchObject({
ok: false,
code: 'BLOCKED',
});
// The same for Codeman's data dir given through the link.
mkdirSync(join(realHome, '.codeman'));
symlinkSync(join(realHome, '.codeman'), join(realHome, 'code', 'state'));
expect(await prepareNewCasePath(join(linkHome, 'code', 'state', 'x'), viaLink)).toMatchObject({
ok: false,
code: 'BLOCKED',
});
});
it('refuses a link into a cases directory, judged on its resolved form', async () => {
const cases = join(home, 'codeman-cases');
mkdirSync(cases);
symlinkSync(cases, join(home, 'code', 'shortcut'));
const r = await prepareNewCasePath(join(home, 'code', 'shortcut', 'app'), { ...ctx, casesDirs: [cases] });
expect(r).toMatchObject({ ok: false, code: 'BLOCKED' });
if (!r.ok) expect(r.reason).toMatch(/plain Create New/);
});
it('reports a missing parent as NOT_FOUND and never makes a chain of folders', async () => {
const r = await prepareNewCasePath(join(home, 'code', 'nope', 'deeper', 'app'), ctx);
expect(r).toMatchObject({ ok: false, code: 'NOT_FOUND' });
});
it('refuses a parent that is a file', async () => {
writeFileSync(join(home, 'code', 'afile'), 'x');
expect(await prepareNewCasePath(join(home, 'code', 'afile', 'app'), ctx)).toMatchObject({ ok: false });
});
it('refuses a folder that already has files in it, pointing at Link Existing', async () => {
mkdirSync(join(home, 'code', 'mine'));
writeFileSync(join(home, 'code', 'mine', 'README.md'), 'hello');
const r = await prepareNewCasePath(join(home, 'code', 'mine'), ctx);
expect(r).toMatchObject({ ok: false, code: 'EXISTS' });
if (!r.ok) expect(r.reason).toMatch(/Link Existing/);
});
it('refuses a target that is a file or a symbolic link', async () => {
writeFileSync(join(home, 'code', 'plain'), 'x');
expect(await prepareNewCasePath(join(home, 'code', 'plain'), ctx)).toMatchObject({ ok: false, code: 'INVALID' });
mkdirSync(join(home, 'code', 'real'));
symlinkSync(join(home, 'code', 'real'), join(home, 'code', 'link'));
const r = await prepareNewCasePath(join(home, 'code', 'link'), ctx);
expect(r).toMatchObject({ ok: false, code: 'INVALID' });
if (!r.ok) expect(r.reason).toMatch(/symbolic link/);
});
it('never creates anything', async () => {
await prepareNewCasePath(join(home, 'code', 'dry-run'), ctx);
const { existsSync } = await import('node:fs');
expect(existsSync(join(home, 'code', 'dry-run'))).toBe(false);
});
});
+136 -1
View File
@@ -1,4 +1,7 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { dependencyRegistry } from '../src/config/dependency-registry.js';
import {
detectEnvironment,
@@ -129,6 +132,7 @@ function fakeHost(env: ProbeEnvironment, over: Partial<ProbeHost> = {}): ProbeHo
environment: env,
which: () => null,
fileExists: () => false,
isExecutableFile: () => false,
runVersion: () => null,
windowsProgramRoots: () => [],
windowsFileVersion: () => null,
@@ -259,6 +263,121 @@ describe('checkTool with requireVersionMatch (generic binary names)', () => {
});
});
describe('checkTool with searchDirs (service PATH is minimal)', () => {
const claudeLike: ToolDependency = {
...tmuxTool,
id: 'claude',
label: 'Claude CLI',
resolvers: [
{
match: ['linux'],
resolver: { kind: 'path', bins: ['claude'], searchDirs: ['/home/u/.local/bin', '/opt/npm/bin/'] },
},
],
};
it('finds a CLI that only lives in a searchDirs entry and runs --version on the absolute path', () => {
const runVersion = vi.fn(() => 'claude 2.1.0');
const host = fakeHost('linux', { isExecutableFile: (p) => p === '/opt/npm/bin/claude', runVersion });
expect(checkTool(claudeLike, host)).toMatchObject({
status: 'ok',
path: '/opt/npm/bin/claude',
version: '2.1.0',
});
expect(runVersion).toHaveBeenCalledWith('/opt/npm/bin/claude', ['--version']);
});
it('still reports missing when neither PATH nor any search dir has it', () => {
expect(checkTool(claudeLike, fakeHost('linux'))).toMatchObject({ status: 'missing' });
});
it('prefers the PATH hit over a search dir', () => {
const host = fakeHost('linux', {
which: () => '/usr/bin/claude',
isExecutableFile: () => true,
runVersion: () => '1.0.0',
});
expect(checkTool(claudeLike, host)).toMatchObject({ path: '/usr/bin/claude' });
});
// The run mode's resolver (createCliExecutableResolver) accepts a search-dir candidate only
// as an absolute path to an executable regular file. A file that merely exists is not one.
it('skips a search-dir file that exists but is not executable', () => {
const runVersion = vi.fn(() => 'claude 2.1.0');
const host = fakeHost('linux', { fileExists: () => true, isExecutableFile: () => false, runVersion });
expect(checkTool(claudeLike, host)).toMatchObject({ status: 'missing' });
expect(runVersion).not.toHaveBeenCalled();
});
it('ignores a relative search dir (a custom clis.json entry) as the resolver does', () => {
const relative: ToolDependency = {
...claudeLike,
resolvers: [{ match: ['linux'], resolver: { kind: 'path', bins: ['claude'], searchDirs: ['tools/bin'] } }],
};
const runVersion = vi.fn(() => 'claude 2.1.0');
const host = fakeHost('linux', { fileExists: () => true, isExecutableFile: () => true, runVersion });
expect(checkTool(relative, host)).toMatchObject({ status: 'missing' });
expect(runVersion).not.toHaveBeenCalled();
});
// The grok case: an npm squatter answers on the PATH while the real CLI sits in ~/.grok/bin.
// The Run menu's resolver rejects the squatter and moves on to the search dirs; the doctor
// used to stop at the PATH hit and report MISSING.
const squatted: ToolDependency = {
...claudeLike,
id: 'pi',
label: 'Pi CLI',
resolvers: [
{
match: ['linux'],
resolver: {
kind: 'path',
bins: ['pi'],
versionRegex: PI_VERSION_REGEX,
requireVersionMatch: true,
searchDirs: ['/home/u/.local/bin', '/home/u/.npm-global/bin'],
},
},
],
};
it('finds the right binary in a search dir when a wrong one is on the PATH', () => {
const host = fakeHost('linux', {
which: () => '/usr/bin/pi',
isExecutableFile: (p) => p === '/home/u/.npm-global/bin/pi',
runVersion: (bin) => (bin === '/usr/bin/pi' ? 'Raspberry Pi utility\n' : '0.84.3\n'),
});
expect(checkTool(squatted, host)).toMatchObject({
status: 'ok',
path: '/home/u/.npm-global/bin/pi',
version: '0.84.3',
});
});
it('version-checks each search-dir candidate and moves past one that fails', () => {
const runVersion = vi.fn((bin: string) => (bin === '/home/u/.local/bin/pi' ? 'something else\n' : '0.84.3\n'));
const host = fakeHost('linux', { isExecutableFile: () => true, runVersion });
expect(checkTool(squatted, host)).toMatchObject({ status: 'ok', path: '/home/u/.npm-global/bin/pi' });
expect(runVersion.mock.calls.map(([bin]) => bin)).toEqual(['/home/u/.local/bin/pi', '/home/u/.npm-global/bin/pi']);
});
it('probes a search dir that is also on the PATH only once', () => {
const runVersion = vi.fn(() => 'Raspberry Pi utility\n');
const host = fakeHost('linux', { which: () => '/home/u/.local/bin/pi', isExecutableFile: () => true, runVersion });
expect(checkTool(squatted, host)).toMatchObject({ status: 'missing' });
expect(runVersion.mock.calls.map(([bin]) => bin)).toEqual(['/home/u/.local/bin/pi', '/home/u/.npm-global/bin/pi']);
});
it('carries each enabled CLI’s expanded discovery.searchDirs onto its registry row', () => {
const rows = dependencyRegistry().flatMap((t) => t.resolvers.map((r) => r.resolver));
const withDirs = rows.filter((r) => r.kind === 'path' && r.searchDirs?.length);
expect(withDirs.length).toBeGreaterThan(0);
for (const r of withDirs) {
if (r.kind === 'path') for (const d of r.searchDirs ?? []) expect(d.startsWith('~')).toBe(false);
}
});
});
describe('checkAll', () => {
it('maps every tool to a result', () => {
const results = checkAll([tmuxTool, msTool], fakeHost('linux'));
@@ -273,4 +392,20 @@ describe('createRealHost', () => {
expect(typeof host.which).toBe('function');
expect(Array.isArray(host.windowsProgramRoots())).toBe(true);
});
it('counts only an executable regular file as a search-dir candidate', () => {
const dir = mkdtempSync(join(tmpdir(), 'doctor-exec-'));
try {
const file = join(dir, 'tool');
writeFileSync(file, '#!/bin/sh\necho 1.0.0\n', { mode: 0o644 });
const host = createRealHost();
expect(host.isExecutableFile(file)).toBe(false);
chmodSync(file, 0o755);
expect(host.isExecutableFile(file)).toBe(true);
expect(host.isExecutableFile(dir)).toBe(false);
expect(host.isExecutableFile(join(dir, 'absent'))).toBe(false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
+109
View File
@@ -0,0 +1,109 @@
// @vitest-environment node
// The contract GET /api/doctor's default runner relies on: the same entry script, given
// `doctor --json`, prints a parseable DependencyReportJson on stdout, even when it exits
// non-zero because something required is missing.
//
// Hermetic: the doctor runs `--version` on every CLI it finds, and a suite must never execute
// whatever happens to be installed on the machine running it (cli-executable-resolver.ts
// @fileoverview). Each run gets a temp HOME and a PATH holding only `which` and `node`, and a
// clis.json in that HOME's data dir drops the registry's absolute search dirs
// (`/usr/local/bin`), so the only CLI the doctor can find is a fixture this file wrote.
import { execFile, execFileSync } from 'node:child_process';
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { isAbsolute, join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
const ROOT = join(import.meta.dirname, '..');
interface DoctorRun {
report: { tools: Array<{ id: string; status: string; path?: string; category: string }> } & Record<string, any>;
stderr: string;
}
function hermeticDoctorEnv(): { home: string; bare: string; env: NodeJS.ProcessEnv; cleanup: () => void } {
const home = mkdtempSync(join(tmpdir(), 'doctor-home-'));
const bare = mkdtempSync(join(tmpdir(), 'doctor-path-'));
symlinkSync(execFileSync('sh', ['-c', 'command -v which'], { encoding: 'utf-8' }).trim(), join(bare, 'which'));
symlinkSync(process.execPath, join(bare, 'node'));
// Overrides deep-merge by id and arrays replace wholesale, so this keeps every stock entry
// and only narrows its search dirs to the `~` ones, which resolve inside the temp HOME.
const clis = Object.fromEntries(
STOCK_CLIS.map((e) => [e.id, { discovery: { searchDirs: e.discovery.searchDirs.filter((d) => !isAbsolute(d)) } }])
);
mkdirSync(join(home, '.codeman'), { recursive: true });
// 0600 or the registry ignores the file (isUnsafePermissions).
writeFileSync(join(home, '.codeman', 'clis.json'), JSON.stringify({ schemaVersion: 1, clis }), { mode: 0o600 });
const env: NodeJS.ProcessEnv = { ...process.env, HOME: home, PATH: bare };
delete env.CODEMAN_DATA_DIR;
delete env.CODEMAN_INSTANCE;
return {
home,
bare,
env,
cleanup: () => {
rmSync(home, { recursive: true, force: true });
rmSync(bare, { recursive: true, force: true });
},
};
}
function runDoctor(env: NodeJS.ProcessEnv): Promise<DoctorRun> {
return new Promise((resolve, reject) => {
execFile(
process.execPath,
[
join(ROOT, 'node_modules/tsx/dist/cli.mjs'),
join(ROOT, 'src/index.ts'),
'doctor',
'--json',
'--category',
'core',
],
{ timeout: 60_000, cwd: ROOT, env },
(err, out, stderr) => (out ? resolve({ report: JSON.parse(out), stderr }) : reject(err ?? new Error('no output')))
);
});
}
describe('codeman doctor --json', () => {
it('prints a report that includes Node and a summary, whatever the exit code', async () => {
const h = hermeticDoctorEnv();
try {
const { report, stderr } = await runDoctor(h.env);
expect(report.platform.environment).toMatch(/linux|darwin|win32|wsl/);
expect(report.summary).toEqual(expect.objectContaining({ ok: expect.any(Number), exitCode: expect.any(Number) }));
const node = report.tools.find((t) => t.id === 'node');
expect(node?.status).toBe('ok');
expect(report.tools.every((t) => t.category === 'core')).toBe(true);
// The override was accepted (an ignored or invalid clis.json warns on stderr), and nothing
// the doctor found, and so ran, lives outside this test's own temp dirs.
expect(stderr).not.toContain('[cli-registry]');
for (const t of report.tools.filter((t) => t.path)) {
expect(t.path!.startsWith(h.bare) || t.path!.startsWith(h.home)).toBe(true);
}
} finally {
h.cleanup();
}
}, 90_000);
// The report an operator got wrong in production: under systemd the PATH is minimal, so a CLI
// installed in ~/.local/bin read `missing` while the Run menu (which also searches the registry's
// searchDirs) found it.
it('finds a CLI that lives only in a registry searchDirs entry when the PATH is minimal', async () => {
const h = hermeticDoctorEnv();
try {
mkdirSync(join(h.home, '.local/bin'), { recursive: true });
const fake = join(h.home, '.local/bin/claude');
writeFileSync(fake, '#!/bin/sh\necho "2.1.0 (Claude Code)"\n');
chmodSync(fake, 0o755);
const { report } = await runDoctor(h.env);
const claude = report.tools.find((t) => t.id === 'claude');
expect(claude).toMatchObject({ status: 'ok', path: fake });
} finally {
h.cleanup();
}
}, 90_000);
});
+110
View File
@@ -0,0 +1,110 @@
/** @fileoverview Settings → System → Diagnostics in a real browser, with GET /api/doctor stubbed at the network layer. */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3196;
const REPORT = {
platform: { environment: 'linux' },
summary: { ok: 1, requiredMissing: 1, optionalMissing: 0, exitCode: 1 },
tools: [
{
id: 'node',
label: 'Node.js',
category: 'core',
required: true,
usedBy: [],
status: 'ok',
version: '22.1.0',
path: '/usr/bin/node',
},
{
id: 'tmux',
label: 'tmux',
category: 'core',
required: true,
usedBy: [],
status: 'missing',
installHint: 'apt install tmux',
},
// Host-supplied strings must be rendered as text, never as markup.
{
id: 'x',
label: '<img src=x onerror=window.__pwned=1>',
category: 'other',
required: false,
usedBy: [],
status: 'missing',
},
],
};
describe('Diagnostics panel in a real browser', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
beforeAll(async () => {
server = new WebServer(PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
// A controlling service worker can swallow requests before page.route() sees them, letting the
// real /api/doctor (a forked Node process) answer instead; block it so the stub is reliable.
page = await (await browser.newContext({ serviceWorkers: 'block' })).newPage();
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
await page.evaluate(() => (window as any).app.openAppSettings());
}, 90000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
it('lists each tool with status, version, path and install hint, and renders host strings as text', async () => {
await page.route('**/api/doctor', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ success: true, data: REPORT }) })
);
await page.click('#doctorRunBtn');
await page.waitForFunction(() => /1 ok/.test(document.getElementById('doctorResult')?.textContent ?? ''));
const text = await page.textContent('#doctorResult');
expect(text).toContain('1 ok · 1 required missing · 0 optional missing (linux)');
expect(text).toContain('✓ Node.js ok · 22.1.0');
expect(text).toContain('/usr/bin/node');
expect(text).toContain('✗ tmux missing · required');
// A missing OPTIONAL tool is not an error: ○, as the terminal doctor marks it.
expect(text).toContain('○ <img src=x onerror=window.__pwned=1> missing · optional');
expect(text).toContain('Install: apt install tmux');
expect(text).toContain('<img src=x onerror=window.__pwned=1>'); // shown literally
expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();
expect(await page.$('#doctorResult img')).toBeNull();
expect(await page.isDisabled('#doctorRunBtn')).toBe(false);
});
it('shows the server’s message when the check fails, and re-enables the button', async () => {
await page.unroute('**/api/doctor');
await page.route('**/api/doctor', (route) =>
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ success: false, errorCode: 'OPERATION_FAILED', error: 'doctor failed: boom' }),
})
);
await page.click('#doctorRunBtn');
await page.waitForFunction(() => /boom/.test(document.getElementById('doctorResult')?.textContent ?? ''));
expect(await page.isDisabled('#doctorRunBtn')).toBe(false);
});
it('hides the Diagnostics group from a non-admin in multi-user mode and shows it to an admin', async () => {
const visible = (user: Record<string, unknown>) =>
page.evaluate((u) => {
(window as any).__codemanUser = u;
document.dispatchEvent(new CustomEvent('codeman:me'));
return getComputedStyle(document.getElementById('doctorGroup')!).display !== 'none';
}, user);
expect(await visible({ multiUser: true, role: 'user' })).toBe(false);
expect(await visible({ multiUser: true, role: 'admin' })).toBe(true);
expect(await visible({ multiUser: false })).toBe(true);
});
});
+414
View File
@@ -0,0 +1,414 @@
/** @fileoverview Bottom-bar Git indicator and panel, end to end: real server, real Chromium, a real git repo with a remote. */
import { execFileSync } from 'node:child_process';
import { homedir } from 'node:os';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3192;
const ENV = {
...process.env,
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', ['-c', 'protocol.file.allow=always', ...args], { cwd, env: ENV, stdio: 'ignore' });
describe('Git status indicator in a real browser', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
let root: string;
let repo: string;
let plain: string;
let repoSession: string;
let plainSession: string;
const gitStatusRequests: string[] = [];
const settingsPutStatuses: number[] = [];
const write = (rel: string, text = 'x\n') => writeFileSync(join(repo, rel), text);
const commitAll = (msg: string) => {
git(repo, 'add', '-A');
git(repo, 'commit', '-q', '-m', msg);
};
const label = () => page.evaluate(() => document.querySelector('#gitStatusBtn .git-status-label')?.textContent ?? '');
const buttonVisible = () => page.evaluate(() => !document.getElementById('gitStatusBtn')!.hidden);
const refresh = () => page.evaluate(() => (window as any).app.refreshGitStatusNow());
const setSetting = async (on: boolean) => {
await page.evaluate(() => (window as any).app.openAppSettings());
if ((await page.isChecked('#appSettingsShowGitStatus')) !== on)
await page.click('label.switch:has(#appSettingsShowGitStatus)');
await page.evaluate(() => (window as any).app.saveAppSettings());
await page.waitForTimeout(300);
await page.evaluate(() => (window as any).app.closeAppSettings());
};
const createSession = (dir: string) =>
page.evaluate(async (workingDir) => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir, mode: 'shell' }),
});
const id = (await res.json()).data.session.id;
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
return id as string;
}, dir);
const select = (id: string) =>
page.evaluate(async (sid) => {
const app = (window as any).app;
for (let i = 0; i < 100 && !app.sessions.has(sid); i++) await new Promise((r) => setTimeout(r, 100));
await app.selectSession(sid);
}, id);
beforeAll(async () => {
root = mkdtempSync(join(homedir(), 'git-ui-'));
const bare = join(root, 'origin.git');
repo = join(root, 'repo');
plain = join(root, 'plain');
mkdirSync(repo);
mkdirSync(plain);
git(root, 'init', '-q', '--bare', '-b', 'main', bare);
git(repo, 'init', '-q', '-b', 'main');
git(repo, 'remote', 'add', 'origin', bare);
write('a.txt', '1\n');
commitAll('base');
git(repo, 'push', '-q', '-u', 'origin', 'main');
// Dirty: a modified file, an untracked file, and one commit nobody has pushed.
write('b.txt', 'b\n');
commitAll('add b (not pushed)');
write('a.txt', '2\n');
write('new file.txt', 'n\n');
server = new WebServer(PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
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());
});
page.on('response', (r) => {
if (r.request().method() === 'PUT' && r.url().endsWith('/api/settings')) settingsPutStatuses.push(r.status());
});
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
repoSession = await createSession(repo);
plainSession = await createSession(plain);
await select(repoSession);
}, 120000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
rmSync(root, { recursive: true, force: true });
}, 60000);
it('is off by default: no button and no request to the git-status route', async () => {
await page.waitForTimeout(2500); // longer than one tick
expect(await buttonVisible()).toBe(false);
expect(gitStatusRequests).toEqual([]);
});
it('turning it on through Settings saves cleanly (the key must not reach the strict PUT) and shows the counts', async () => {
await setSetting(true);
expect(settingsPutStatuses.length).toBeGreaterThan(0);
expect(settingsPutStatuses.every((s) => s === 200)).toBe(true);
await page.waitForFunction(() => !document.getElementById('gitStatusBtn')!.hidden, null, { timeout: 15000 });
expect(await label()).toContain('● 2'); // a.txt modified + new file.txt untracked
expect(await label()).toContain('↑ 1'); // one commit not pushed
const title = await page.getAttribute('#gitStatusBtn', 'title');
expect(title).toMatch(/main/);
expect(title).toMatch(/2 uncommitted files/);
expect(title).toMatch(/1 commit not pushed/);
expect(await page.getAttribute('#gitStatusBtn', 'class')).toMatch(/git-status--dirty/);
// The toolbar skin rules out-rank a bare class, so check the colour the user actually sees.
const colour = await page.evaluate(() => getComputedStyle(document.getElementById('gitStatusBtn')!).color);
expect(colour).toBe('rgb(224, 160, 48)');
});
it('sits at the right of the bottom bar, beside the version', async () => {
const inRight = await page.evaluate(() => !!document.querySelector('.toolbar-right #gitStatusBtn'));
expect(inRight).toBe(true);
const order = await page.evaluate(() => {
const right = document.querySelector('.toolbar-right')!;
return [...right.children].map((c) => c.id).filter(Boolean);
});
expect(order.indexOf('gitStatusBtn')).toBeLessThan(order.indexOf('versionDisplay'));
});
it('opens a panel listing the uncommitted files and the unpushed commit', async () => {
await page.click('#gitStatusBtn');
await page.waitForSelector('#gitStatusPanel.visible');
await page.waitForFunction(() => /Not staged/.test(document.getElementById('gitStatusBody')!.textContent ?? ''));
const body = (await page.textContent('#gitStatusBody')) ?? '';
expect(body).toContain('main → origin/main');
expect(body).toContain('Uncommitted changes (2)');
expect(body).toMatch(/Not staged \(1\)/);
expect(body).toMatch(/Untracked \(1\)/);
expect(body).toContain('a.txt');
expect(body).toContain('new file.txt');
expect(body).toContain('Not pushed (1)');
expect(body).toContain('add b (not pushed)');
expect(await page.textContent('#gitStatusBranch')).toBe('main');
expect(await page.textContent('#gitStatusFooter')).toMatch(/Read-only/);
expect(await page.getAttribute('#gitStatusBtn', 'aria-expanded')).toBe('true');
});
it('renders hostile file names as text, never as markup', async () => {
write('<img src=x onerror=window.__pwned=1>.txt');
await refresh();
await page.waitForFunction(() => /onerror/.test(document.getElementById('gitStatusBody')!.textContent ?? ''));
expect(await page.$('#gitStatusBody img')).toBeNull();
expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();
});
it('clicking a file shows its diff in the panel; Back returns to the list; Open file previews it', async () => {
await page.evaluate(() => {
(window as any).__previewed = [];
(window as any).app.openFilePreview = (p: string) => (window as any).__previewed.push(p);
});
await page.click('.git-status-file:has-text("a.txt")');
await page.waitForSelector('#gitStatusBody .git-diff');
expect(await page.textContent('#gitStatusBody .git-diff-path')).toBe('a.txt');
expect(await page.textContent('#gitStatusBody .git-diff-line--del')).toBe('-1\n');
expect(await page.textContent('#gitStatusBody .git-diff-line--add')).toBe('+2\n');
// The 15 s poll re-renders the panel; the diff must survive it.
await refresh();
expect(await page.$('#gitStatusBody .git-diff')).not.toBeNull();
await page.click('#gitStatusBody button:has-text("Open file")');
expect(await page.evaluate(() => (window as any).__previewed)).toEqual([join(repo, 'a.txt')]);
await page.click('#gitStatusBody button:has-text("Back")');
await page.waitForSelector('.git-status-file:has-text("a.txt")');
expect(await page.$('#gitStatusBody .git-diff')).toBeNull();
});
it('an untracked file diffs as all additions, and a deleted file as all removals (with no Open file)', async () => {
await page.click('.git-status-file:has-text("new file.txt")');
await page.waitForSelector('#gitStatusBody .git-diff-line--add');
expect(await page.locator('#gitStatusBody .git-diff-line--del').count()).toBe(0);
await page.click('#gitStatusBody button:has-text("Back")');
rmSync(join(repo, 'b.txt'));
await refresh();
await page.waitForSelector('.git-status-file:has(.git-status-badge--D)');
await page.click('.git-status-file:has(.git-status-badge--D)');
await page.waitForSelector('#gitStatusBody .git-diff-line--del');
expect(await page.locator('#gitStatusBody .git-diff-line--add').count()).toBe(0);
expect(await page.locator('#gitStatusBody button:has-text("Open file")').count()).toBe(0);
await page.click('#gitStatusBody button:has-text("Back")');
});
it('keeps keyboard focus on the same file row across the 15 s re-render', async () => {
await page.focus('.git-status-file:has-text("a.txt")');
const key = () => page.evaluate(() => (document.activeElement as HTMLElement | null)?.dataset?.gitKey ?? null);
expect(await key()).toBe('unstaged|a.txt');
await refresh();
await page.waitForFunction(() => document.activeElement?.getAttribute('data-git-key') === 'unstaged|a.txt');
expect(await key()).toBe('unstaged|a.txt');
});
it('the panel never starts off-screen, even on a 650px-wide viewport', async () => {
const original = page.viewportSize()!;
await page.setViewportSize({ width: 650, height: original.height });
const left = await page.evaluate(() => document.getElementById('gitStatusPanel')!.getBoundingClientRect().left);
await page.setViewportSize(original);
expect(left).toBeGreaterThanOrEqual(0);
});
it('drags by the header', async () => {
const before = await page.evaluate(() => document.getElementById('gitStatusPanel')!.getBoundingClientRect().left);
const box = (await page.locator('.git-status-header').boundingBox())!;
await page.mouse.move(box.x + 40, box.y + 10);
await page.mouse.down();
await page.mouse.move(box.x - 140, box.y + 70, { steps: 5 });
await page.mouse.up();
const after = await page.evaluate(() => document.getElementById('gitStatusPanel')!.getBoundingClientRect().left);
expect(after).toBeLessThan(before - 100);
});
it('groups files under folders that start collapsed and expand on click; the setting turns it off', async () => {
mkdirSync(join(repo, 'deep/er/still'), { recursive: true });
mkdirSync(join(repo, 'docs'));
write('deep/er/still/one.txt');
write('docs/a.md');
write('docs/b.md');
// git reports an all-untracked folder as ONE `dir/` entry, so commit these first and then edit them.
git(repo, 'add', 'deep', 'docs');
git(repo, 'commit', '-q', '-m', 'add folders');
write('deep/er/still/one.txt', 'changed\n');
write('docs/a.md', 'changed\n');
write('docs/b.md', 'changed\n');
await refresh();
await page.waitForSelector('.git-tree-dir');
// `deep/er/still` is a chain of single-child folders: one row, not three.
const names = await page.locator('.git-tree-name').allTextContents();
expect(names).toContain('deep/er/still/');
expect(names).toContain('docs/');
expect(await page.locator('.git-tree-dir[open]').count()).toBe(0);
expect(await page.locator('.git-status-file:has-text("one.txt")').isVisible()).toBe(false);
expect(await page.locator('.git-tree-dir:has(.git-tree-name:text-is("docs/")) .git-tree-count').textContent()).toBe(
'2'
);
await page.click('.git-tree-summary:has-text("docs/")');
expect(await page.locator('.git-status-file:has-text("a.md")').isVisible()).toBe(true);
// The open folder survives the re-render a refresh causes.
await refresh();
await page.waitForSelector('.git-tree-dir[open]');
expect(await page.locator('.git-status-file:has-text("a.md")').isVisible()).toBe(true);
// A file in a folder still opens its diff, and shows only its own name.
await page.click('.git-status-file:has-text("a.md")');
await page.waitForSelector('#gitStatusBody .git-diff-path');
expect(await page.textContent('#gitStatusBody .git-diff-path')).toBe('docs/a.md');
await page.click('#gitStatusBody button:has-text("Back")');
// Setting off: the flat list, every file by its full path.
await page.evaluate(() => (window as any).app.openAppSettings());
await page.click('label.switch:has(#appSettingsGitStatusTree)');
await page.evaluate(() => (window as any).app.saveAppSettings());
await page.waitForTimeout(300);
await page.evaluate(() => (window as any).app.closeAppSettings());
await page.evaluate(() => (window as any).app._renderGitStatusPanel());
expect(await page.locator('.git-tree-dir').count()).toBe(0);
expect(await page.locator('.git-status-path', { hasText: 'docs/a.md' }).count()).toBe(1);
// Back on for the rest of the file.
await page.evaluate(() => (window as any).app.openAppSettings());
await page.click('label.switch:has(#appSettingsGitStatusTree)');
await page.evaluate(() => (window as any).app.saveAppSettings());
await page.waitForTimeout(300);
await page.evaluate(() => (window as any).app.closeAppSettings());
// gitStatusTree is per-device: it must never reach the strict PUT /api/settings (a 400 there is
// what an unstripped key looks like), and it must round-trip through the saved settings.
expect(settingsPutStatuses.every((st) => st === 200)).toBe(true);
expect(await page.evaluate(() => (window as any).app.isGitStatusTree())).toBe(true);
rmSync(join(repo, 'deep'), { recursive: true });
rmSync(join(repo, 'docs'), { recursive: true });
}, 30000);
it('once everything is committed and pushed the button says so, and the panel agrees', async () => {
rmSync(join(repo, '<img src=x onerror=window.__pwned=1>.txt'));
git(repo, 'checkout', '-q', '--', '.');
git(repo, 'clean', '-fdq');
git(repo, 'push', '-q');
await refresh();
// A manual refresh bypasses the server's short cache, so this shows the push at once (not at the
// next 15 s poll, which is what a cached answer would mean).
await page.waitForFunction(
() => (document.querySelector('#gitStatusBtn .git-status-label')?.textContent ?? '').includes('✓'),
null,
{ timeout: 5000 }
);
expect(await page.getAttribute('#gitStatusBtn', 'class')).toMatch(/git-status--clean/);
// Toolbar buttons animate colour changes, so wait for the transition rather than racing it.
await page.waitForFunction(
() => getComputedStyle(document.getElementById('gitStatusBtn')!).color !== 'rgb(224, 160, 48)'
);
const body = (await page.textContent('#gitStatusBody')) ?? '';
expect(body).toContain('Nothing uncommitted.');
expect(body).toContain('Every commit on this branch is on a remote.');
});
it('shows nothing for a session whose folder is not a repository, and follows the active session', async () => {
await select(plainSession);
await page.waitForFunction(() => document.getElementById('gitStatusBtn')!.hidden, null, { timeout: 15000 });
// The open panel must stop showing the previous repo at once, then say why there is nothing.
await page.waitForFunction(
() => /No git repository here/.test(document.getElementById('gitStatusBody')!.textContent ?? ''),
null,
{
timeout: 15000,
}
);
expect(await page.textContent('#gitStatusBody')).not.toContain('a.txt');
await select(repoSession);
await page.waitForFunction(() => !document.getElementById('gitStatusBtn')!.hidden, null, { timeout: 15000 });
});
it('a folder that holds several repositories shows each one, and the indicator adds them up', async () => {
const parent = join(root, 'monorepo-ish');
mkdirSync(parent);
for (const name of ['api', 'web']) {
const r = join(parent, name);
mkdirSync(r);
git(r, 'init', '-q', '-b', 'main');
writeFileSync(join(r, 'f.txt'), '1\n');
git(r, 'add', '-A');
git(r, 'commit', '-q', '-m', 'init');
}
writeFileSync(join(parent, 'api', 'dirty.txt'), 'x');
writeFileSync(join(parent, 'api', 'dirty2.txt'), 'y');
const multiSession = await createSession(parent);
await select(multiSession);
await page.waitForFunction(() => document.querySelectorAll('#gitStatusBody .git-status-repo').length === 2, null, {
timeout: 15000,
});
expect(await page.textContent('#gitStatusBranch')).toBe('2 repositories');
expect(await label()).toContain('● 2'); // only api is dirty, with two files
expect(await page.getAttribute('#gitStatusBtn', 'title')).toMatch(/Git \(2 repositories\)/);
const names = await page.$$eval('.git-status-repo-name', (els) => els.map((e) => e.textContent));
expect(names).toEqual(['api', 'web']);
// Every repository starts collapsed (the summary line shows what is outstanding); one the user
// opens stays open when a refresh re-renders the panel.
const allClosed = await page.$$eval('.git-status-repo', (els) => els.map((e) => (e as HTMLDetailsElement).open));
expect(allClosed).toEqual([false, false]);
await page.click('.git-status-repo:nth-of-type(1) > summary');
await refresh();
await page.waitForSelector('.git-status-repo[open]');
const open = await page.$$eval('.git-status-repo', (els) => els.map((e) => (e as HTMLDetailsElement).open));
expect(open).toEqual([true, false]);
const apiBody = (await page.textContent('.git-status-repo:nth-of-type(1)')) ?? '';
expect(apiBody).toContain('dirty.txt');
expect(apiBody).toContain('dirty2.txt');
await select(repoSession);
await page.waitForFunction(() => document.querySelectorAll('#gitStatusBody .git-status-repo').length === 0, null, {
timeout: 15000,
});
});
it('turning the setting off while a read is in flight, then on again, does not leave polling dead', async () => {
let slow = true;
await page.route('**/api/sessions/*/git-status*', async (route) => {
if (slow) await new Promise((r) => setTimeout(r, 1500));
await route.continue();
});
page.setDefaultTimeout(6000);
// A background poll may be mid-read: let it finish so OUR read is the one the slow route holds.
await page.waitForFunction(() => (window as any).app._gitStatusInFlight === false);
await page.evaluate(() => void (window as any).app.refreshGitStatus({ fresh: true }));
await page.waitForFunction(() => (window as any).app._gitStatusInFlight === true);
await setSetting(false);
expect(await page.evaluate(() => (window as any).app._gitStatusInFlight)).toBe(false);
slow = false;
await setSetting(true);
// Re-enabling starts its own read. With the flag stuck true that read is skipped for this session
// and the indicator never comes back.
await page.waitForFunction(() => !!(window as any).app._currentGitStatus());
expect(await page.evaluate(() => (window as any).app._gitStatusInFlight)).toBe(false);
page.setDefaultTimeout(30000);
// The first read may still be asleep in the handler: wait for it to continue, or its late
// route.continue() lands after the route is gone and fails the run as an unhandled rejection.
await page.unrouteAll({ behavior: 'wait' });
expect(await buttonVisible()).toBe(true);
// Turning the setting off closed the panel; reopen it for the tests that follow.
await page.click('#gitStatusBtn');
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);
await page.click('#gitStatusBtn');
expect(await page.isVisible('#gitStatusPanel')).toBe(true);
await setSetting(false);
expect(await buttonVisible()).toBe(false);
expect(await page.isVisible('#gitStatusPanel')).toBe(false);
expect(await page.evaluate(() => (window as any).app._gitStatusTimer)).toBeNull();
const before = gitStatusRequests.length;
await page.waitForTimeout(3000);
expect(gitStatusRequests.length).toBe(before);
}, 30000);
});
+898
View File
@@ -0,0 +1,898 @@
// @vitest-environment node
import { execFileSync } from 'node:child_process';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
clearGitStatusCache,
getGitWorkspaceStatus,
MAX_FILES,
parseCommitLog,
parsePorcelainV2,
runGit,
type GitRunner,
} from '../src/git-workspace-status.js';
const NUL = '\0';
describe('parsePorcelainV2', () => {
const header = (extra: string[] = []) =>
['# branch.oid abc123', '# branch.head main', '# branch.upstream origin/main', '# branch.ab +2 -1', ...extra].join(
NUL
) + NUL;
it('reads the branch, upstream and ahead/behind', () => {
const p = parsePorcelainV2(header());
expect(p).toMatchObject({
branch: 'main',
detached: false,
upstream: 'origin/main',
ahead: 2,
behind: 1,
files: [],
});
});
it('flags an upstream whose remote branch is gone: branch.upstream without branch.ab', () => {
expect(
parsePorcelainV2(['# branch.oid x', '# branch.head feature', '# branch.upstream origin/feature'].join(NUL) + NUL)
).toMatchObject({ upstream: 'origin/feature', upstreamGone: true });
expect(
parsePorcelainV2(
['# branch.oid x', '# branch.head main', '# branch.upstream origin/main', '# branch.ab +0 -0'].join(NUL) + NUL
)
).toMatchObject({ upstreamGone: false });
expect(parsePorcelainV2(['# branch.oid x', '# branch.head feature'].join(NUL) + NUL).upstreamGone).toBe(false);
});
it('reads a detached HEAD and a branch with no upstream (no branch.ab line either)', () => {
expect(parsePorcelainV2(['# branch.oid x', '# branch.head (detached)'].join(NUL) + NUL)).toMatchObject({
branch: null,
detached: true,
upstream: null,
ahead: 0,
});
expect(parsePorcelainV2(['# branch.oid x', '# branch.head feature'].join(NUL) + NUL)).toMatchObject({
branch: 'feature',
upstream: null,
});
});
it('turns an entry that is staged AND modified in the tree into one row per kind', () => {
const line = '1 MM N... 100644 100644 100644 aaa bbb src/a.ts';
const files = parsePorcelainV2(header([line])).files;
expect(files.map((f) => [f.kind, f.index, f.worktree, f.path])).toEqual([
['staged', 'M', 'M', 'src/a.ts'],
['unstaged', 'M', 'M', 'src/a.ts'],
]);
});
it('classifies staged-only, unstaged-only, added and deleted', () => {
const lines = [
'1 M. N... 100644 100644 100644 a b staged.ts',
'1 .M N... 100644 100644 100644 a b unstaged.ts',
'1 A. N... 000000 100644 100644 0 b added.ts',
'1 .D N... 100644 100644 000000 a b gone.ts',
];
const files = parsePorcelainV2(header(lines)).files;
expect(files.map((f) => `${f.kind}:${f.index}${f.worktree}:${f.path}`)).toEqual([
'staged:M.:staged.ts',
'unstaged:.M:unstaged.ts',
'staged:A.:added.ts',
'unstaged:.D:gone.ts',
]);
});
it('reads a rename with its original path from the following token', () => {
const text = header(['2 R. N... 100644 100644 100644 a b R100 new name.ts' + NUL + 'old name.ts']);
expect(parsePorcelainV2(text).files).toEqual([
{ path: 'new name.ts', origPath: 'old name.ts', index: 'R', worktree: '.', kind: 'staged' },
]);
});
it('reads unmerged and untracked entries, and keeps odd names intact', () => {
const text = header([
'u UU N... 100644 100644 100644 100644 a b c conflict.ts',
'? with space.txt',
'? quote"and\'tick.txt',
'? new\nline.txt',
'? dir/',
]);
const files = parsePorcelainV2(text).files;
expect(files.map((f) => [f.kind, f.path])).toEqual([
['conflicted', 'conflict.ts'],
['untracked', 'with space.txt'],
['untracked', 'quote"and\'tick.txt'],
['untracked', 'new\nline.txt'],
['untracked', 'dir/'],
]);
});
it('skips unknown lines and survives empty input', () => {
expect(parsePorcelainV2('')).toMatchObject({ files: [], branch: null });
expect(parsePorcelainV2('! ignored.log' + NUL + 'weird line' + NUL).files).toEqual([]);
});
});
describe('parseCommitLog', () => {
it('reads hash, author, time and subject, including unicode and empty input', () => {
const text = 'abc1234\x1fAda\x1f1700000000\x1ffix: café\x1e\ndef5678\x1fBob\x1f1700000100\x1fsecond\x1e';
expect(parseCommitLog(text)).toEqual([
{ hash: 'abc1234', author: 'Ada', time: 1700000000, subject: 'fix: café' },
{ hash: 'def5678', author: 'Bob', time: 1700000100, subject: 'second' },
]);
expect(parseCommitLog('')).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// Real git
// ---------------------------------------------------------------------------
const GIT_ENV = {
...process.env,
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[]): string =>
execFileSync('git', ['-c', 'commit.gpgsign=false', '-c', 'protocol.file.allow=always', ...args], {
cwd,
env: GIT_ENV,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
let root: string;
let repo: string;
const write = (rel: string, text = 'x\n', dir = repo) => {
const f = join(dir, rel);
mkdirSync(join(f, '..'), { recursive: true });
writeFileSync(f, text);
};
const commit = (msg: string, dir = repo) => {
git(dir, 'add', '-A');
git(dir, 'commit', '-q', '-m', msg);
};
beforeEach(() => {
clearGitStatusCache();
root = mkdtempSync(join(tmpdir(), 'git-status-'));
repo = join(root, 'repo');
mkdirSync(repo);
git(repo, 'init', '-q', '-b', 'main');
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
describe('getGitWorkspaceStatus against a real repository', () => {
it('is not-a-repo outside a repository', async () => {
const plain = join(root, 'plain');
mkdirSync(plain);
expect((await getGitWorkspaceStatus(plain)).state).toBe('not-a-repo');
});
it('reports a folder that no longer exists as not-a-repo or an error, never a crash', async () => {
const s = await getGitWorkspaceStatus(join(root, 'gone'));
expect(['not-a-repo', 'error']).toContain(s.state);
});
it('a fresh repo: on its branch, untracked files, no remote, nothing "unpushed"', async () => {
write('a.txt');
const s = await getGitWorkspaceStatus(repo);
expect(s).toMatchObject({ state: 'ok', branch: 'main', upstream: null, hasRemote: false, unpushedCount: 0 });
expect(s.counts).toMatchObject({ untracked: 1, uncommitted: 1, staged: 0, unstaged: 0 });
});
it('a clean repo with commits but no remote has nothing to push (not "every commit")', async () => {
write('a.txt');
commit('one');
write('b.txt');
commit('two');
const s = await getGitWorkspaceStatus(repo);
expect(s.counts.uncommitted).toBe(0);
expect(s).toMatchObject({ hasRemote: false, unpushedCount: 0, unpushed: [] });
});
it('separates staged, unstaged and untracked, and counts a staged+modified file once as uncommitted', async () => {
write('tracked.txt', '1\n');
write('both.txt', '1\n');
write('removed.txt', '1\n');
commit('base');
write('both.txt', '2\n');
git(repo, 'add', 'both.txt');
write('both.txt', '3\n'); // staged AND modified again
write('tracked.txt', '2\n'); // unstaged only
git(repo, 'rm', '-q', 'removed.txt'); // staged delete
write('new.txt'); // untracked
const s = await getGitWorkspaceStatus(repo);
expect(s.counts).toMatchObject({ staged: 2, unstaged: 2, untracked: 1, conflicted: 0, uncommitted: 4 });
const row = (kind: string, path: string) => s.files.find((f) => f.kind === kind && f.path === path);
expect(row('staged', 'both.txt')).toBeTruthy();
expect(row('unstaged', 'both.txt')).toBeTruthy();
expect(row('staged', 'removed.txt')?.index).toBe('D');
expect(row('untracked', 'new.txt')).toBeTruthy();
});
it('reports a staged rename with its original path', async () => {
write('old.txt', 'content that is long enough to be detected as a rename\n'.repeat(5));
commit('base');
git(repo, 'mv', 'old.txt', 'new.txt');
const s = await getGitWorkspaceStatus(repo);
expect(s.files).toContainEqual(
expect.objectContaining({ path: 'new.txt', origPath: 'old.txt', index: 'R', kind: 'staged' })
);
});
it('reports merge conflicts', async () => {
write('c.txt', 'base\n');
commit('base');
git(repo, 'checkout', '-q', '-b', 'other');
write('c.txt', 'other\n');
commit('other');
git(repo, 'checkout', '-q', 'main');
write('c.txt', 'main\n');
commit('main');
expect(() => git(repo, 'merge', 'other')).toThrow();
const s = await getGitWorkspaceStatus(repo);
expect(s.counts.conflicted).toBe(1);
expect(s.files.find((f) => f.kind === 'conflicted')?.path).toBe('c.txt');
});
it('handles file names with spaces, quotes and unicode', async () => {
write('with space.txt');
write('quote"d.txt');
write('café ☕.txt');
const s = await getGitWorkspaceStatus(repo);
expect(s.files.map((f) => f.path).sort()).toEqual(['café ☕.txt', 'quote"d.txt', 'with space.txt']);
});
it('caps the file list but keeps the counts exact', async () => {
for (let i = 0; i < MAX_FILES + 20; i++) write(`f${i}.txt`);
const s = await getGitWorkspaceStatus(repo);
expect(s.files).toHaveLength(MAX_FILES);
expect(s.filesTruncated).toBe(true);
expect(s.counts.untracked).toBe(MAX_FILES + 20);
expect(s.counts.uncommitted).toBe(MAX_FILES + 20);
});
it('counts stashes', async () => {
write('a.txt', '1\n');
commit('base');
write('a.txt', '2\n');
git(repo, 'stash', '-q');
expect((await getGitWorkspaceStatus(repo)).counts.stashes).toBe(1);
});
it('reports a detached HEAD', async () => {
write('a.txt');
commit('one');
git(repo, 'checkout', '-q', '--detach');
const s = await getGitWorkspaceStatus(repo);
expect(s).toMatchObject({ detached: true, branch: null });
});
describe('with a remote', () => {
let bare: string;
beforeEach(() => {
bare = join(root, 'origin.git');
git(root, 'init', '-q', '--bare', '-b', 'main', bare);
git(repo, 'remote', 'add', 'origin', bare);
write('a.txt', '1\n');
commit('first');
git(repo, 'push', '-q', '-u', 'origin', 'main');
});
it('in sync: nothing ahead, nothing unpushed', async () => {
const s = await getGitWorkspaceStatus(repo);
expect(s).toMatchObject({ upstream: 'origin/main', ahead: 0, behind: 0, hasRemote: true, unpushedCount: 0 });
});
it('lists commits that are ahead of the upstream, newest first, with the subject and author', async () => {
write('b.txt');
commit('second: add b');
write('c.txt');
commit('third: add c');
const s = await getGitWorkspaceStatus(repo);
expect(s).toMatchObject({ ahead: 2, unpushedCount: 2 });
expect(s.unpushed.map((c) => c.subject)).toEqual(['third: add c', 'second: add b']);
expect(s.unpushed[0]).toMatchObject({ author: 'T' });
expect(s.unpushed[0].hash).toMatch(/^[0-9a-f]{7,}$/);
expect(s.unpushed[0].time).toBeGreaterThan(1_600_000_000);
});
it('behind reflects the last fetch only: it never fetches on its own', async () => {
const other = join(root, 'other');
git(root, 'clone', '-q', bare, other);
write('theirs.txt', 'x\n', other);
commit('theirs', other);
git(other, 'push', '-q', 'origin', 'main');
expect((await getGitWorkspaceStatus(repo)).behind).toBe(0); // not fetched yet
clearGitStatusCache();
git(repo, 'fetch', '-q');
expect((await getGitWorkspaceStatus(repo)).behind).toBe(1);
});
it('a branch with no upstream lists what no remote has', async () => {
git(repo, 'checkout', '-q', '-b', 'feature');
write('f1.txt');
commit('f1');
write('f2.txt');
commit('f2');
const s = await getGitWorkspaceStatus(repo);
expect(s).toMatchObject({ branch: 'feature', upstream: null, hasRemote: true, unpushedCount: 2 });
expect(s.unpushed.map((c) => c.subject)).toEqual(['f2', 'f1']);
});
it('a branch whose upstream was deleted and pruned is NOT reported as everything pushed', async () => {
git(repo, 'checkout', '-q', '-b', 'feature');
write('f1.txt');
commit('f1');
git(repo, 'push', '-q', '-u', 'origin', 'feature');
git(repo, 'push', '-q', 'origin', '--delete', 'feature');
git(repo, 'fetch', '-q', '--prune');
write('f2.txt');
commit('f2');
const s = await getGitWorkspaceStatus(repo);
expect(s).toMatchObject({ branch: 'feature', upstream: 'origin/feature', upstreamGone: true });
expect(s.unpushedCount).toBe(2);
expect(s.unpushed.map((c) => c.subject)).toEqual(['f2', 'f1']);
});
it('a pushed branch is not reported as unpushed once it has an upstream', async () => {
git(repo, 'checkout', '-q', '-b', 'feature');
write('f1.txt');
commit('f1');
git(repo, 'push', '-q', '-u', 'origin', 'feature');
expect((await getGitWorkspaceStatus(repo)).unpushedCount).toBe(0);
});
});
describe('it only reads', () => {
it('does not rewrite the index or leave a lock, even when stat data is stale', async () => {
write('a.txt', '1\n');
commit('base');
const before = readFileSync(join(repo, '.git', 'index'));
// Touching a tracked file makes a plain `git status` want to refresh the index.
const now = new Date();
const { utimesSync } = await import('node:fs');
utimesSync(join(repo, 'a.txt'), now, new Date(now.getTime() + 5000));
await getGitWorkspaceStatus(repo);
expect(readFileSync(join(repo, '.git', 'index')).equals(before)).toBe(true);
expect(existsSync(join(repo, '.git', 'index.lock'))).toBe(false);
});
it('does not run a repository-configured fsmonitor hook (and a plain git status would have)', async () => {
write('a.txt', '1\n');
commit('base');
const hook = join(root, 'fsmonitor.sh');
const marker = join(root, 'fsmonitor-ran');
writeFileSync(hook, `#!/bin/sh\necho ran >> '${marker}'\nprintf ''\n`);
chmodSync(hook, 0o755);
git(repo, 'config', 'core.fsmonitor', hook);
// Control: git itself runs it for a plain status, so the assertion below is not vacuous.
git(repo, 'status', '--short');
expect(existsSync(marker)).toBe(true);
rmSync(marker);
await getGitWorkspaceStatus(repo);
expect(existsSync(marker)).toBe(false);
});
});
});
// ---------------------------------------------------------------------------
// Cache and error mapping (fake runner)
// ---------------------------------------------------------------------------
describe('caching and failures', () => {
const okRunner = (calls: string[][] = []): GitRunner =>
vi.fn(async (_cwd, args) => {
calls.push(args);
if (args[0] === 'status') return ['# branch.oid x', '# branch.head main'].join(NUL) + NUL;
return '';
});
it('never runs a git command that writes or touches the network', async () => {
const calls: string[][] = [];
await getGitWorkspaceStatus('/w/never-network', { git: okRunner(calls) });
const verbs = new Set(calls.map((a) => a[0]));
for (const forbidden of ['fetch', 'pull', 'push', 'commit', 'add', 'checkout', 'reset', 'clean', 'gc']) {
expect(verbs.has(forbidden), forbidden).toBe(false);
}
expect([...verbs].sort()).toEqual(['log', 'remote', 'rev-list', 'rev-parse', 'stash', 'status']);
});
it('shares one in-flight computation between concurrent callers', async () => {
const calls: string[][] = [];
const git = okRunner(calls);
const [a, b, c] = await Promise.all([1, 2, 3].map(() => getGitWorkspaceStatus('/w/shared', { git })));
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(1);
expect(a).toBe(b);
expect(b).toBe(c);
});
it('reuses a fresh result, recomputes after the TTL, and keeps folders apart', async () => {
let t = 1_000_000;
const calls: string[][] = [];
const git = okRunner(calls);
const get = (cwd: string) => getGitWorkspaceStatus(cwd, { git, now: () => t });
await get('/w/a');
t += 1000;
await get('/w/a');
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(1);
await get('/w/b');
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(2);
t += 10_000;
await get('/w/a');
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(3);
});
it('`fresh` skips the reuse of a recent result, but still joins a computation already running', async () => {
let t = 1_000_000;
const calls: string[][] = [];
const git = okRunner(calls);
const get = (fresh: boolean) => getGitWorkspaceStatus('/w/fresh', { git, now: () => t, fresh });
await get(false);
t += 500;
await get(false); // reused
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(1);
await get(true); // a person pressed Refresh
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(2);
clearGitStatusCache();
calls.length = 0;
await Promise.all([get(true), get(true), get(true)]);
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(1);
});
it('maps "not a git repository" to not-a-repo', async () => {
const git: GitRunner = async () => {
throw Object.assign(new Error('x'), {
stderr: 'fatal: not a git repository (or any of the parent directories): .git',
});
};
expect((await getGitWorkspaceStatus('/w/none', { git })).state).toBe('not-a-repo');
});
it('reports a missing git binary and a timeout as short errors', async () => {
const enoent: GitRunner = async () => {
throw Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' });
};
expect(await getGitWorkspaceStatus('/w/e1', { git: enoent })).toMatchObject({
state: 'error',
error: expect.stringMatching(/not installed/),
});
const slow: GitRunner = async () => {
throw Object.assign(new Error('timed out'), { killed: true });
};
expect(await getGitWorkspaceStatus('/w/e2', { git: slow })).toMatchObject({
state: 'error',
error: 'git timed out',
});
});
it('redacts credentials embedded in a remote URL from an error message', async () => {
const git: GitRunner = async () => {
throw Object.assign(new Error('x'), {
stderr: "fatal: unable to access 'https://user:ghp_SECRET@github.com/o/r.git/'",
});
};
const s = await getGitWorkspaceStatus('/w/redact', { git });
expect(s.state).toBe('error');
expect(s.error).not.toContain('ghp_SECRET');
expect(s.error).toContain('***:***@');
});
it('survives the secondary calls failing: status still comes back', async () => {
const git: GitRunner = async (_cwd, args) => {
if (args[0] === 'status')
return (
['# branch.oid x', '# branch.head main', '# branch.upstream o/main', '# branch.ab +3 -0'].join(NUL) + NUL
);
throw new Error('boom');
};
const s = await getGitWorkspaceStatus('/w/partial', { git });
expect(s).toMatchObject({ state: 'ok', branch: 'main', ahead: 3, unpushedCount: 0, unpushed: [] });
});
});
// ---------------------------------------------------------------------------
// Which repositories: getGitWorkspaceOverview
// ---------------------------------------------------------------------------
import { mkdirSync as mkdir, symlinkSync as symlink } from 'node:fs';
import {
discoverChildRepos,
getGitWorkspaceOverview,
isUnrelatedAncestor,
MAX_REPOS,
} from '../src/git-workspace-status.js';
describe('getGitWorkspaceOverview', () => {
let top: string;
let home: string;
const repoAt = (p: string): string => {
mkdir(p, { recursive: true });
git(p, 'init', '-q', '-b', 'main');
writeFileSync(join(p, 'f.txt'), '1\n');
git(p, 'add', '-A');
git(p, 'commit', '-q', '-m', 'c');
return p;
};
const names = (o: { repos: { path: string }[] }) => o.repos.map((r) => r.path);
beforeEach(() => {
top = mkdtempSync(join(tmpdir(), 'git-overview-'));
home = join(top, 'home');
mkdir(home, { recursive: true });
clearGitStatusCache();
});
afterEach(() => rmSync(top, { recursive: true, force: true }));
it('a folder that holds several repositories reports each, alphabetically, with its own status', async () => {
const ws = join(home, 'case');
repoAt(join(ws, 'web'));
repoAt(join(ws, 'api'));
writeFileSync(join(ws, 'api', 'dirty.txt'), 'x');
const o = await getGitWorkspaceOverview(ws, { home });
expect(o.state).toBe('ok');
expect(names(o)).toEqual(['api', 'web']);
expect(o.repos.map((r) => r.name)).toEqual(['api', 'web']);
expect(o.repos[0].status.counts.uncommitted).toBe(1);
expect(o.repos[1].status.counts.uncommitted).toBe(0);
expect(o.reposTruncated).toBe(false);
});
it('finds repositories two levels down but not three', async () => {
const ws = join(home, 'case');
repoAt(join(ws, 'apps', 'web'));
repoAt(join(ws, 'a', 'b', 'too-deep'));
const o = await getGitWorkspaceOverview(ws, { home });
expect(names(o)).toEqual([join('apps', 'web')]);
});
it('a subfolder of a repository reports the whole enclosing repository, naming where it is', async () => {
const r = repoAt(join(home, 'proj'));
mkdir(join(r, 'src', 'deep'), { recursive: true });
writeFileSync(join(r, 'top.txt'), 'x');
const o = await getGitWorkspaceOverview(join(r, 'src', 'deep'), { home });
expect(o.repos).toHaveLength(1);
expect(o.repos[0]).toMatchObject({ name: 'proj', path: join('..', '..') });
expect(o.repos[0].status.files.map((f) => f.path)).toContain('top.txt');
});
it('inside a repository it does not go looking for nested ones (they are just an untracked folder to the outer repo)', async () => {
const r = repoAt(join(home, 'outer'));
repoAt(join(r, 'vendor-ish', 'inner'));
const o = await getGitWorkspaceOverview(r, { home });
expect(names(o)).toEqual(['.']);
expect(o.repos[0].status.files.map((f) => f.path)).toEqual(['vendor-ish/']);
});
it('a session started inside the nested repository reports that one', async () => {
const r = repoAt(join(home, 'outer'));
const inner = repoAt(join(r, 'vendor-ish', 'inner'));
const o = await getGitWorkspaceOverview(inner, { home });
expect(o.repos[0].name).toBe('inner');
});
it('ignores a repository that merely sits above the workspace and is the home folder (a dotfiles repo)', async () => {
repoAt(home);
writeFileSync(join(home, 'zshrc'), 'x'); // dirty, and nothing to do with this session
const ws = join(home, 'codeman-cases', 'my-case');
mkdir(ws, { recursive: true });
const none = await getGitWorkspaceOverview(ws, { home });
expect(none.state).toBe('not-a-repo');
// ...but a project below the workspace is still found.
repoAt(join(ws, 'real-project'));
clearGitStatusCache();
const some = await getGitWorkspaceOverview(ws, { home, fresh: true });
expect(names(some)).toEqual(['real-project']);
});
it('identifies an unrelated repository above the workspace before its status runs, so a failure there cannot hide the repositories below', async () => {
repoAt(home);
const ws = join(home, 'case');
repoAt(join(ws, 'proj'));
const calls: Array<[string, string]> = [];
const spy: GitRunner = async (cwd, args) => {
calls.push([cwd, args[0]]);
if (cwd === ws && args[0] === 'status') throw Object.assign(new Error('timed out'), { killed: true });
return runGit(cwd, args);
};
const o = await getGitWorkspaceOverview(ws, { home, git: spy });
expect(o.state).toBe('ok');
expect(names(o)).toEqual(['proj']);
// The dotfiles repository costs one rev-parse, never a full status.
expect(calls.filter(([cwd]) => cwd === ws).map(([, verb]) => verb)).toEqual(['rev-parse']);
});
it('ignores a repository above the home folder (including a repo at the very top)', async () => {
repoAt(top); // contains `home`, so it is above it
const ws = join(home, 'case');
mkdir(ws, { recursive: true });
expect((await getGitWorkspaceOverview(ws, { home })).state).toBe('not-a-repo');
});
it('but a workspace that IS the repository root is the session’s repository, even when that is the home folder', async () => {
repoAt(home);
const o = await getGitWorkspaceOverview(home, { home });
expect(o.state).toBe('ok');
expect(o.repos[0].path).toBe('.');
});
it('accepts an enclosing repository that is below the home folder, and one outside the home folder entirely', async () => {
const r = repoAt(join(home, 'proj'));
mkdir(join(r, 'sub'), { recursive: true });
expect((await getGitWorkspaceOverview(join(r, 'sub'), { home })).state).toBe('ok');
const elsewhere = repoAt(join(top, 'elsewhere', 'proj'));
clearGitStatusCache();
expect((await getGitWorkspaceOverview(elsewhere, { home })).state).toBe('ok');
});
it('skips node_modules, dot-folders and symbolic links when looking for repositories', async () => {
const ws = join(home, 'case');
repoAt(join(ws, 'node_modules', 'pkg'));
repoAt(join(ws, '.hidden', 'secret'));
const outside = repoAt(join(top, 'outside'));
mkdir(ws, { recursive: true });
symlink(outside, join(ws, 'linked'));
repoAt(join(ws, 'real'));
const o = await getGitWorkspaceOverview(ws, { home });
expect(names(o)).toEqual(['real']);
});
it('counts a worktree (its .git is a file) as a repository', async () => {
const main = repoAt(join(top, 'main-repo'));
const ws = join(home, 'case');
mkdir(ws, { recursive: true });
git(main, 'worktree', 'add', '-q', '-b', 'feature', join(ws, 'wt'));
const o = await getGitWorkspaceOverview(ws, { home });
expect(names(o)).toEqual(['wt']);
expect(o.repos[0].status.branch).toBe('feature');
});
it('caps how many repositories it reports and says so', async () => {
const ws = join(home, 'case');
for (let i = 0; i < MAX_REPOS + 3; i++) repoAt(join(ws, `p${String(i).padStart(2, '0')}`));
const o = await getGitWorkspaceOverview(ws, { home });
expect(o.repos).toHaveLength(MAX_REPOS);
expect(o.reposTruncated).toBe(true);
expect(names(o)[0]).toBe('p00');
});
it('is not-a-repo when there is no repository here or below, and reports a git failure as an error', async () => {
const ws = join(home, 'empty');
mkdir(join(ws, 'a'), { recursive: true });
expect((await getGitWorkspaceOverview(ws, { home })).state).toBe('not-a-repo');
clearGitStatusCache(); // the answer above is cached for this folder
const broken: GitRunner = async () => {
throw Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' });
};
expect(await getGitWorkspaceOverview(ws, { home, git: broken })).toMatchObject({ state: 'error', repos: [] });
});
it('re-scans for repositories only every so often, unless a person asked for fresh', async () => {
let t = 1_000_000;
const ws = join(home, 'case');
mkdir(ws, { recursive: true });
repoAt(join(ws, 'one'));
const get = (fresh = false) => getGitWorkspaceOverview(ws, { home, now: () => t, fresh });
expect(names(await get())).toEqual(['one']);
repoAt(join(ws, 'two'));
t += 5000;
expect(names(await get())).toEqual(['one']); // list reused
expect(names(await get(true))).toEqual(['one', 'two']); // Refresh sees it
t += 60_000;
expect(names(await get())).toEqual(['one', 'two']); // and so does the next scan
});
it('discoverChildRepos never reads below a repository it found', async () => {
const ws = join(home, 'case');
repoAt(join(ws, 'outer'));
repoAt(join(ws, 'outer', 'inner'));
expect((await discoverChildRepos(ws)).dirs.map((d) => d.slice(ws.length + 1))).toEqual(['outer']);
});
it('isUnrelatedAncestor: home or above, but never the workspace root itself', async () => {
mkdir(join(home, 'proj'), { recursive: true });
expect(await isUnrelatedAncestor(home, join(home, 'proj'), home)).toBe(true);
expect(await isUnrelatedAncestor(top, join(home, 'proj'), home)).toBe(true);
expect(await isUnrelatedAncestor('/', join(home, 'proj'), home)).toBe(true);
expect(await isUnrelatedAncestor(join(home, 'proj'), join(home, 'proj', 'x'), home)).toBe(false);
expect(await isUnrelatedAncestor(home, home, home)).toBe(false);
});
});
import { MAX_DIFF_BYTES, getGitFileDiff, isSafeRepoRelativePath } from '../src/git-workspace-status.js';
describe('isSafeRepoRelativePath', () => {
it.each([
['a.txt', true],
['src/deep/x.ts', true],
['', false],
['-rf', true], // every operand follows `--`, so a leading dash is just a name
['-', true],
['/etc/passwd', false],
['../x', false],
['a/../../x', false],
['a\0b', false],
])('%j -> %s', (p, ok) => expect(isSafeRepoRelativePath(p)).toBe(ok));
});
describe('getGitFileDiff', () => {
it('refuses an unsafe path without running git', async () => {
const git = vi.fn(async () => '');
await expect(getGitFileDiff('/r', { path: '../x', kind: 'unstaged' }, { git })).rejects.toThrow('Invalid path');
expect(git).not.toHaveBeenCalled();
});
it('builds read-only, option-injection-safe commands per kind', async () => {
const git = vi.fn(async (_cwd: string, _args: string[]) => '');
await getGitFileDiff('/r', { path: 'a.txt', kind: 'unstaged' }, { git });
await getGitFileDiff('/r', { path: 'b.txt', origPath: 'old.txt', kind: 'staged' }, { git });
const [unstaged, staged] = git.mock.calls.map((c) => c[1]);
for (const args of [unstaged, staged]) {
expect(args).toContain('--no-ext-diff');
expect(args).toContain('--no-textconv');
expect(args.indexOf('--')).toBeGreaterThan(0);
}
expect(unstaged.slice(-2)).toEqual(['--', 'a.txt']);
expect(staged).toContain('--cached');
expect(staged.slice(-3)).toEqual(['--', 'old.txt', 'b.txt']);
});
it('treats --no-index exit 1 as the normal untracked result', async () => {
const git = vi.fn(async () => {
throw Object.assign(new Error('exit 1'), { code: 1, stdout: '+hello\n' });
});
await expect(getGitFileDiff('/r', { path: 'n.txt', kind: 'untracked' }, { git })).resolves.toMatchObject({
diff: '+hello\n',
});
const boom = vi.fn(async () => {
throw Object.assign(new Error('exit 128'), { code: 128, stdout: '' });
});
await expect(getGitFileDiff('/r', { path: 'n.txt', kind: 'untracked' }, { git: boom })).rejects.toThrow();
});
it('flags binary output and cuts an oversized diff at a line boundary', async () => {
const bin = await getGitFileDiff(
'/r',
{ path: 'x.png', kind: 'unstaged' },
{ git: async () => 'Binary files a/x.png and b/x.png differ\n' }
);
expect(bin.binary).toBe(true);
const big = ('+' + 'x'.repeat(99) + '\n').repeat(Math.ceil(MAX_DIFF_BYTES / 100) + 50);
const cut = await getGitFileDiff('/r', { path: 'big', kind: 'unstaged' }, { git: async () => big });
expect(cut.truncated).toBe(true);
expect(cut.diff.length).toBeLessThanOrEqual(MAX_DIFF_BYTES);
expect(cut.diff.endsWith('x')).toBe(true);
});
it('cuts a diff that overflowed the output bound instead of failing it', async () => {
const partial = ('+' + 'y'.repeat(99) + '\n').repeat(Math.ceil(MAX_DIFF_BYTES / 100) * 2);
const git = vi.fn(async () => {
throw Object.assign(new RangeError('stdout maxBuffer length exceeded'), {
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
stdout: partial,
});
});
for (const kind of ['unstaged', 'untracked'] as const) {
const d = await getGitFileDiff('/r', { path: 'huge.txt', kind }, { git });
expect(d.truncated).toBe(true);
expect(d.diff.length).toBeLessThanOrEqual(MAX_DIFF_BYTES);
expect(d.diff.endsWith('y')).toBe(true);
}
});
it('a real untracked file past the output bound comes back cut short, not as an error', async () => {
writeFileSync(join(repo, 'huge.txt'), ('z'.repeat(99) + '\n').repeat(100_000)); // ~10 MB, over runGit's 8 MB
const d = await getGitFileDiff(repo, { path: 'huge.txt', kind: 'untracked' });
expect(d.truncated).toBe(true);
expect(d.diff.startsWith('diff --git')).toBe(true);
expect(d.diff.length).toBeLessThanOrEqual(MAX_DIFF_BYTES);
}, 30_000);
});
describe('Docker case workspaces are never inspected', () => {
let top: string;
let home: string;
const repoAt = (p: string): string => {
mkdir(p, { recursive: true });
git(p, 'init', '-q', '-b', 'main');
writeFileSync(join(p, 'f.txt'), '1\n');
git(p, 'add', '-A');
git(p, 'commit', '-q', '-m', 'c');
return p;
};
/** A repository whose clean filter drops a marker file: proof that git ran on it. */
const booby = (p: string): string => {
repoAt(p);
git(p, 'config', 'filter.mark.clean', 'touch RAN; cat');
writeFileSync(join(p, '.gitattributes'), 'f.txt filter=mark\n');
// Same size as the committed '1\n', so git must read the content (running the filter) to see the change.
writeFileSync(join(p, 'f.txt'), 'x\n');
return p;
};
beforeEach(() => {
top = mkdtempSync(join(tmpdir(), 'git-docker-'));
home = join(top, 'home');
mkdir(home, { recursive: true });
clearGitStatusCache();
});
afterEach(() => rmSync(top, { recursive: true, force: true }));
it('control: without the exclusion git does run the repository’s clean filter', async () => {
const ws = join(home, 'case');
booby(join(ws, 'proj'));
await getGitWorkspaceOverview(ws, { home, git: undefined });
expect(existsSync(join(ws, 'proj', 'RAN'))).toBe(true);
});
it('drops a Docker workspace found below the folder, and runs nothing in it', async () => {
const ws = join(home, 'case');
booby(join(ws, 'sandbox'));
repoAt(join(ws, 'plain'));
const o = await getGitWorkspaceOverview(ws, { home, dockerWorkspaces: [join(ws, 'sandbox')] });
expect(o.repos.map((r) => r.path)).toEqual(['plain']);
expect(existsSync(join(ws, 'sandbox', 'RAN'))).toBe(false);
});
it('answers unsupported/docker for a folder at or inside a Docker workspace, before any git runs', async () => {
const dock = booby(join(home, 'dock'));
mkdir(join(dock, 'sub'));
for (const cwd of [dock, join(dock, 'sub')]) {
clearGitStatusCache();
const git = vi.fn(async () => '');
const o = await getGitWorkspaceOverview(cwd, { home, git, dockerWorkspaces: [dock] });
expect(o).toMatchObject({ state: 'unsupported', reason: 'docker', repos: [] });
expect(git).not.toHaveBeenCalled();
}
expect(existsSync(join(dock, 'RAN'))).toBe(false);
});
it('re-checks a cached list of repositories against the Docker workspaces as they are now', async () => {
let t = 1_000_000;
const ws = join(home, 'case');
repoAt(join(ws, 'a'));
repoAt(join(ws, 'b'));
const cwds: string[] = [];
const spy: GitRunner = (cwd, args) => {
cwds.push(cwd);
return runGit(cwd, args);
};
const first = await getGitWorkspaceOverview(ws, { home, git: spy, now: () => t });
expect(first.repos.map((r) => r.path)).toEqual(['a', 'b']);
t += 5000; // past the status cache, well inside the list's 30 s
cwds.length = 0;
const o = await getGitWorkspaceOverview(ws, {
home,
git: spy,
now: () => t,
dockerWorkspaces: [join(ws, 'a')],
});
expect(o.repos.map((r) => r.path)).toEqual(['b']);
expect(cwds).not.toContain(join(ws, 'a'));
});
it('sees through a symlink to the workspace', async () => {
const dock = booby(join(home, 'dock'));
const ws = join(home, 'case');
mkdir(ws, { recursive: true });
symlink(dock, join(ws, 'link'));
const o = await getGitWorkspaceOverview(join(ws, 'link'), { home, dockerWorkspaces: [dock] });
expect(o.state).toBe('unsupported');
expect(existsSync(join(dock, 'RAN'))).toBe(false);
});
it('a folder next to the workspace, and one whose name merely starts the same, are not excluded', async () => {
const dock = join(home, 'dock');
const sibling = repoAt(join(home, 'dock-two'));
mkdir(dock, { recursive: true });
const o = await getGitWorkspaceOverview(sibling, { home, dockerWorkspaces: [dock] });
expect(o.state).toBe('ok');
});
});
+12
View File
@@ -91,6 +91,18 @@ describe('custom display name and browser localization', () => {
dom.window.close();
});
it('keeps a quoted group name apart from the fixed "Move to" entries in zh-CN', () => {
const dom = makeDom('');
const api = dom.window.CodemanI18n;
api.configure({ language: 'zh-CN' });
const labels = ['Move to "New group"', 'Move to new group', 'Move to "ungrouped"', 'Move to Ungrouped'].map((label) =>
api.t(label)
);
expect(labels).toEqual(['移到“New group”', '移到新分组', '移到“ungrouped”', '移到未分组']);
expect(new Set(labels).size).toBe(4);
dom.window.close();
});
it('renders hostile-looking names as text rather than HTML', () => {
const dom = makeDom('<span class="logo">Codeman</span>');
const api = dom.window.CodemanI18n;
+504 -40
View File
@@ -13,7 +13,8 @@
* Strategy: stub a synthetic .tab-name node and a fake session entry, then
* drive the rename function directly via page.evaluate(). No real PTY/tmux.
*
* Port: 3164 (per MEMORY.md, ports 3150+ for tests)
* Ports: 3164, plus 3165 and 3192 for the two server-backed describes below
* (per MEMORY.md, ports 3150+ for tests)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -21,6 +22,8 @@ import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3164;
const ORDERING_PORT = 3165;
const LONG_PREFIX_PORT = 3192;
const BASE_URL = `http://localhost:${PORT}`;
describe('Inline rename input', () => {
@@ -581,54 +584,515 @@ describe('Inline rename input', () => {
expect(await input.evaluate((node) => node.getBoundingClientRect().width)).toBeGreaterThan(0);
});
it('Vertical rail paints typing in an unclamped editor and restores the clamp on cancel', async () => {
await resetState();
const id = 'vertical-live-input';
// The rail has two row variants, and the detailed one clamps the name to
// three lines instead of two: the editor must come out unclamped in both,
// and cancelling must put back the clamp of the variant it was opened in.
it.each([
{ detail: 'simple', restoredClamp: '2' },
{ detail: 'rich', restoredClamp: '3' },
])(
'Vertical rail ($detail rows) paints typing in an unclamped editor and restores the clamp on cancel',
async ({ detail, restoredClamp }) => {
await resetState();
const id = `vertical-live-input-${detail}`;
await page.evaluate((sessionId) => {
await page.evaluate(
({ sessionId, detail }) => {
const app = (
window as unknown as {
app: {
sessions: Map<string, { id: string; name: string }>;
startInlineRename: (id: string) => void;
};
}
).app;
document.documentElement.dataset.tabOrientation = 'vertical';
document.documentElement.dataset.tabRailDetail = detail;
const rail = document.getElementById('tabRail') as HTMLElement;
const tab = document.createElement('div');
tab.setAttribute('data-test-tab', '1');
tab.className = 'session-tab';
tab.innerHTML =
`<span class="tab-name" data-session-id="${sessionId}">` +
'<span class="tab-name-prefix">w9-case: </span>old</span>';
rail.appendChild(tab);
app.sessions.set(sessionId, { id: sessionId, name: 'w9-case: old' });
app.startInlineRename(sessionId);
},
{ sessionId: id, detail }
);
const label = page.locator(`.tab-name[data-session-id="${id}"]`);
const input = label.locator('input.tab-rename-input');
await input.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
await page.keyboard.type('edited title');
expect(await input.inputValue()).toBe('edited title');
expect(await input.evaluate((node) => document.activeElement === node)).toBe(true);
expect(await label.evaluate((node) => node.classList.contains('tab-name-renaming'))).toBe(true);
expect(await label.evaluate((node) => getComputedStyle(node).webkitLineClamp)).toBe('none');
expect(await input.evaluate((node) => node.getBoundingClientRect().width)).toBeGreaterThan(0);
const settled = await page.evaluate((sessionId) => {
const app = (window as unknown as { app: { _activeRename: { cancel: () => void } | null } }).app;
app._activeRename?.cancel();
const label = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`) as HTMLElement;
const result = {
classActive: label.classList.contains('tab-name-renaming'),
inputPresent: !!label.querySelector('input.tab-rename-input'),
webkitLineClamp: getComputedStyle(label).webkitLineClamp,
};
document.documentElement.dataset.tabOrientation = 'horizontal';
return result;
}, id);
expect(settled).toEqual({ classActive: false, inputPresent: false, webkitLineClamp: restoredClamp });
}
);
});
/**
* Two renames of one session can be in flight at once: commit, reopen the
* editor before the PUT answers, then commit or cancel again (or start a group
* rename, which cancels the session editor). The writes go out one at a time
* in the order they were made, and a confirmed write is applied locally even
* if the editor that made it has since been cancelled, so the tab never shows
* a name the server no longer holds.
*/
describe('Inline rename write ordering', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
type Pending = { body: string; resolve: (response: Response) => void };
beforeAll(async () => {
server = new WebServer(ORDERING_PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.goto(`http://localhost:${ORDERING_PORT}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
typeof (window as { app?: unknown }).app !== 'undefined' &&
!!(window as { app?: { sessions?: Map<string, unknown> } }).app?.sessions
);
}, 60000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
/** Mount a header-strip row for `id`, hold every PUT open, and open its editor. */
async function mount(id: string, name: string): Promise<void> {
await page.evaluate(
({ id, name }) => {
const w = window as unknown as {
app: {
_activeRename: { cancel: () => void } | null;
sessions: Map<string, { id: string; name: string; status: string }>;
sessionOrder: string[];
};
__pending: Array<{ body: string; resolve: (response: Response) => void }>;
__origFetch?: typeof window.fetch;
};
w.app._activeRename?.cancel();
w.app.sessions.clear();
document.querySelectorAll('[data-test-tab]').forEach((n) => n.remove());
w.app.sessions.set(id, { id, name, status: 'idle' });
w.app.sessionOrder = [id];
const tab = document.createElement('div');
tab.setAttribute('data-test-tab', '1');
tab.className = 'session-tab';
tab.dataset.id = id;
tab.innerHTML = `<span class="tab-info"><span class="tab-name" data-session-id="${id}">${name}</span></span>`;
(document.getElementById('sessionTabs') as HTMLElement).appendChild(tab);
w.__pending = [];
w.__origFetch ??= window.fetch;
const passThrough = w.__origFetch;
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method !== 'PUT' || !String(input).endsWith('/name')) return passThrough(input, init);
return new Promise<Response>((resolve) => {
w.__pending.push({ body: String(init?.body ?? ''), resolve });
});
}) as typeof window.fetch;
},
{ id, name }
);
}
async function restoreFetch(): Promise<void> {
await page.evaluate(() => {
const w = window as unknown as { __origFetch?: typeof window.fetch };
if (w.__origFetch) window.fetch = w.__origFetch;
});
}
async function commit(id: string, value: string | null): Promise<void> {
await page.evaluate(
async ({ id, value }) => {
const app = (window as unknown as { app: { startInlineRename: (id: string) => void } }).app;
if (!document.querySelector(`.tab-name[data-session-id="${id}"] input.tab-rename-input`)) {
app.startInlineRename(id);
}
const input = document.querySelector(
`.tab-name[data-session-id="${id}"] input.tab-rename-input`
) as HTMLInputElement;
if (value !== null) input.value = value;
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
},
{ id, value }
);
}
async function answer(index: number, name: string): Promise<void> {
await page.evaluate(
async ({ index, body }) => {
const w = window as unknown as { __pending: Pending[] };
w.__pending[index]?.resolve(
new Response(body, { status: 200, headers: { 'Content-Type': 'application/json' } })
);
await new Promise((resolve) => setTimeout(resolve, 30));
},
{ index, body: JSON.stringify({ success: true, data: { name } }) }
);
}
async function state(id: string) {
return page.evaluate((id) => {
const w = window as unknown as {
app: { sessions: Map<string, { name: string }>; _activeRename: unknown };
__pending: Pending[];
};
return {
bodies: w.__pending.map(({ body }) => JSON.parse(body).name),
mapName: w.app.sessions.get(id)?.name ?? null,
renameActive: !!w.app._activeRename,
};
}, id);
}
it('sends successive renames of one session one at a time, in the order they were made', async () => {
await mount('order', 'Old');
await commit('order', 'First');
await commit('order', 'Second');
expect((await state('order')).bodies).toEqual(['First']);
await answer(0, 'First');
expect((await state('order')).bodies).toEqual(['First', 'Second']);
await answer(1, 'Second');
await restoreFetch();
expect(await state('order')).toEqual({ bodies: ['First', 'Second'], mapName: 'Second', renameActive: false });
});
it('keeps a confirmed rename when the editor reopened over it is cancelled', async () => {
await mount('reopen', 'Old');
await commit('reopen', 'First');
await page.evaluate(() => {
const app = (
window as unknown as { app: { startInlineRename: (id: string) => void; _activeRename: { cancel: () => void } } }
).app;
app.startInlineRename('reopen');
app._activeRename.cancel();
});
await answer(0, 'First');
await restoreFetch();
expect(await state('reopen')).toEqual({ bodies: ['First'], mapName: 'First', renameActive: false });
});
it('reopens the editor on the name still in flight, so confirming it unchanged keeps the rename', async () => {
await mount('stale', 'Old');
await commit('stale', 'First');
// The PUT for "First" has not answered, so app.sessions still says "Old".
// The reopened editor must show "First", the user's last word, and an
// untouched confirm must not queue "Old" behind it.
const reopenedValue = await page.evaluate(() => {
(window as unknown as { app: { startInlineRename: (id: string) => void } }).app.startInlineRename('stale');
return (document.querySelector('.tab-name[data-session-id="stale"] input.tab-rename-input') as HTMLInputElement)
.value;
});
expect(reopenedValue).toBe('First');
await commit('stale', null);
await answer(0, 'First');
await restoreFetch();
expect(await state('stale')).toEqual({ bodies: ['First'], mapName: 'First', renameActive: false });
expect(
await page.evaluate(
() =>
(window as unknown as { app: { _inlineRenamePending?: Map<string, string> } }).app._inlineRenamePending?.has(
'stale'
) ?? false
)
).toBe(false);
});
it('shows the in-flight name when a reopened editor is confirmed unchanged, before the PUT lands', async () => {
await mount('shown', 'Old');
await commit('shown', 'First');
// Reopen while the PUT for "First" is held, then confirm it untouched. The
// label must read "First" now, not the "Old" the cancelled editor
// repainted from app.sessions.
await page.evaluate(() =>
(window as unknown as { app: { startInlineRename: (id: string) => void } }).app.startInlineRename('shown')
);
await commit('shown', null);
const label = await page.evaluate(
() => (document.querySelector('.tab-name[data-session-id="shown"]') as HTMLElement).textContent
);
expect((await state('shown')).bodies).toEqual(['First']);
expect(label).toBe('First');
await answer(0, 'First');
await restoreFetch();
expect(await state('shown')).toEqual({ bodies: ['First'], mapName: 'First', renameActive: false });
});
it('reports a failed write even after its editor is gone', async () => {
await mount('fail-late', 'Old');
await page.evaluate(() => {
const w = window as unknown as {
app: { showToast: (message: string, type?: string) => void };
__toasts: string[];
__origToast?: (message: string, type?: string) => void;
};
w.__toasts = [];
w.__origToast = w.app.showToast;
w.app.showToast = (message: string) => {
w.__toasts.push(message);
};
});
await commit('fail-late', 'First');
// Reopen and dismiss: the editor that made the write is gone.
await page.evaluate(() => {
const app = (
window as unknown as { app: { startInlineRename: (id: string) => void; _activeRename: { cancel: () => void } } }
).app;
app.startInlineRename('fail-late');
app._activeRename.cancel();
});
await page.evaluate(async () => {
const w = window as unknown as { __pending: Pending[] };
w.__pending[0]?.resolve(
new Response(JSON.stringify({ success: false, error: 'boom' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
);
await new Promise((resolve) => setTimeout(resolve, 30));
});
await restoreFetch();
const toasts = await page.evaluate(() => {
const w = window as unknown as {
app: { showToast: unknown };
__toasts: string[];
__origToast?: unknown;
};
w.app.showToast = w.__origToast;
return w.__toasts;
});
expect(toasts).toEqual(['Failed to rename']);
expect(await state('fail-late')).toEqual({ bodies: ['First'], mapName: 'Old', renameActive: false });
});
it('keeps sending a session renames after the work following a PUT throws', async () => {
await mount('throws', 'Old');
await page.evaluate(() => {
const w = window as unknown as {
app: { updateSubagentParentNames?: (id: string) => void };
__origParentNames?: (id: string) => void;
__throwOnce: boolean;
};
w.__origParentNames = w.app.updateSubagentParentNames;
w.__throwOnce = true;
w.app.updateSubagentParentNames = (id: string) => {
if (w.__throwOnce) {
w.__throwOnce = false;
throw new Error('forced');
}
w.__origParentNames?.call(w.app, id);
};
window.addEventListener('unhandledrejection', (event) => event.preventDefault(), { once: true });
});
await commit('throws', 'First');
await answer(0, 'First');
await commit('throws', 'Second');
await answer(1, 'Second');
await restoreFetch();
const leftover = await page.evaluate(() => {
const w = window as unknown as {
app: { updateSubagentParentNames?: unknown; _inlineRenameWrites?: Map<string, unknown> };
__origParentNames?: unknown;
};
w.app.updateSubagentParentNames = w.__origParentNames;
return w.app._inlineRenameWrites?.has('throws') ?? false;
});
expect(await state('throws')).toEqual({ bodies: ['First', 'Second'], mapName: 'Second', renameActive: false });
expect(leftover).toBe(false);
});
it('lets the header strip editor shrink (inline min-width 0)', async () => {
await mount('header-width', 'Old');
const minWidth = await page.evaluate(() => {
const app = (
window as unknown as {
app: { startInlineRename: (id: string) => void; _activeRename: { cancel: () => void } | null };
}
).app;
app.startInlineRename('header-width');
const input = document.querySelector(
'.tab-name[data-session-id="header-width"] input.tab-rename-input'
) as HTMLInputElement;
const value = input.style.minWidth;
app._activeRename?.cancel();
return value;
});
await restoreFetch();
expect(minWidth).toBe('0px');
});
it('keeps a confirmed session rename when a group rename takes over the editor', async () => {
await mount('to-group', 'Old');
await commit('to-group', 'Saved');
const groupStarted = await page.evaluate(() => {
const app = (
window as unknown as {
app: {
sessions: Map<string, { id: string; name: string }>;
startInlineRename: (id: string) => void;
tabLayout: unknown;
startTabGroupRename: (groupId: string) => boolean;
};
}
).app;
document.documentElement.dataset.tabOrientation = 'vertical';
const rail = document.getElementById('tabRail') as HTMLElement;
const tab = document.createElement('div');
tab.setAttribute('data-test-tab', '1');
tab.className = 'session-tab';
tab.innerHTML =
`<span class="tab-name" data-session-id="${sessionId}">` +
'<span class="tab-name-prefix">w9-case: </span>old</span>';
rail.appendChild(tab);
app.sessions.set(sessionId, { id: sessionId, name: 'w9-case: old' });
app.startInlineRename(sessionId);
}, id);
const section = document.createElement('section');
section.setAttribute('data-test-tab', '1');
section.innerHTML =
'<div class="tab-layout-group-header" data-tab-group-header="g1">' +
'<span class="tab-layout-group-name">Group</span></div>';
(document.getElementById('sessionTabs') as HTMLElement).appendChild(section);
(window as unknown as { __origLayout: unknown }).__origLayout = app.tabLayout;
app.tabLayout = { version: 1, groups: [{ id: 'g1', name: 'Group', refs: [] }], ungrouped: [] };
return app.startTabGroupRename('g1');
});
expect(groupStarted).toBe(true);
const label = page.locator(`.tab-name[data-session-id="${id}"]`);
const input = label.locator('input.tab-rename-input');
await input.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
await page.keyboard.type('edited title');
expect(await input.inputValue()).toBe('edited title');
expect(await input.evaluate((node) => document.activeElement === node)).toBe(true);
expect(await label.evaluate((node) => node.classList.contains('tab-name-renaming'))).toBe(true);
expect(await label.evaluate((node) => getComputedStyle(node).webkitLineClamp)).toBe('none');
expect(await input.evaluate((node) => node.getBoundingClientRect().width)).toBeGreaterThan(0);
const settled = await page.evaluate((sessionId) => {
const app = (window as unknown as { app: { _activeRename: { cancel: () => void } | null } }).app;
app._activeRename?.cancel();
const label = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`) as HTMLElement;
return {
classActive: label.classList.contains('tab-name-renaming'),
inputPresent: !!label.querySelector('input.tab-rename-input'),
webkitLineClamp: getComputedStyle(label).webkitLineClamp,
await answer(0, 'Saved');
const after = await page.evaluate(() => {
const w = window as unknown as {
app: {
sessions: Map<string, { name: string }>;
_inlineRenameActive: boolean;
_activeRename: { cancel: () => void } | null;
tabLayout: unknown;
};
__origLayout: unknown;
};
}, id);
const groupInput = document.querySelector('.tab-layout-group-rename-input');
const result = {
mapName: w.app.sessions.get('to-group')?.name ?? null,
groupEditorOpen: !!groupInput?.isConnected,
guardHeld: w.app._inlineRenameActive,
};
w.app._activeRename?.cancel();
w.app.tabLayout = w.__origLayout;
return result;
});
await restoreFetch();
expect(after).toEqual({ mapName: 'Saved', groupEditorOpen: true, guardHeld: true });
});
});
expect(settled).toEqual({ classActive: false, inputPresent: false, webkitLineClamp: '2' });
/**
* Real rows, rendered by the app from a live session: a long `w<n>-<case>`
* prefix must not push the editor (or the prefix itself) out of the row in any
* rail variant. The prefix gives way first, with an ellipsis, and the input
* always keeps a usable width.
*/
describe('Vertical rail rename editor with a long prefix', () => {
let server: WebServer;
let browser: Browser;
const port = LONG_PREFIX_PORT;
const NAME = 'w3-this_is_a_very_long_valid_prefix: charlie';
let sessionId = '';
beforeAll(async () => {
server = new WebServer(port, false, true);
await server.start();
const res = await fetch(`http://localhost:${port}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: NAME, mode: 'shell' }),
});
expect(res.ok).toBe(true);
const created = (await res.json()) as { data?: { id?: string; session?: { id?: string } } };
sessionId = created.data?.session?.id ?? created.data?.id ?? '';
expect(sessionId).not.toBe('');
browser = await chromium.launch({ headless: true });
}, 60000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
it.each([
{ variant: 'simple rows', settings: { tabOrientation: 'vertical', tabRailDetail: 'simple' }, compact: false },
{ variant: 'detailed rows', settings: { tabOrientation: 'vertical', tabRailDetail: 'rich' }, compact: false },
{ variant: 'compact rail', settings: { tabOrientation: 'vertical', tabRailWidth: 208 }, compact: true },
{ variant: 'sidebar', settings: { sessionListLayout: 'sidebar' }, compact: false },
{ variant: 'detailed sidebar', settings: { sessionListLayout: 'sidebar-rich' }, compact: false },
])('keeps the prefix and a usable input inside the row ($variant)', async ({ settings, compact }) => {
const context = await browser.newContext({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 });
try {
await context.addInitScript(
(value) => localStorage.setItem('codeman-app-settings', JSON.stringify(value)),
settings
);
const page = await context.newPage();
await page.goto(`http://localhost:${port}`, { waitUntil: 'domcontentloaded' });
// One #sessionTabs list, moved into the rail or the sidebar by the layout.
const row = page.locator(`#sessionTabs .session-tab[data-id="${sessionId}"]`);
await row.waitFor({ state: 'visible', timeout: 15000 });
expect(await page.evaluate(() => document.documentElement.classList.contains('tab-rail-compact'))).toBe(compact);
await row.click({ button: 'right' });
const input = row.locator('input.tab-rename-input');
await input.press('Control+A');
await page.keyboard.type('typed live text');
expect(await input.inputValue()).toBe('typed live text');
const geometry = await row.evaluate((tab) => {
const box = (el: Element) => el.getBoundingClientRect();
const within = (inner: DOMRect, outer: DOMRect) =>
inner.left >= outer.left - 0.5 &&
inner.right <= outer.right + 0.5 &&
inner.top >= outer.top - 0.5 &&
inner.bottom <= outer.bottom + 0.5;
const input = tab.querySelector('input.tab-rename-input') as HTMLInputElement;
const prefix = tab.querySelector('.tab-rename-prefix') as HTMLElement;
const info = tab.querySelector('.tab-info') as HTMLElement;
return {
focused: document.activeElement === input,
inputWidth: box(input).width,
prefixWidth: box(prefix).width,
inputInsideRow: within(box(input), box(info)),
prefixInsideRow: within(box(prefix), box(info)),
prefixEllipsis: getComputedStyle(prefix).textOverflow,
inlineMinWidth: input.style.minWidth,
};
});
expect(geometry.focused).toBe(true);
expect(geometry.inputWidth).toBeGreaterThanOrEqual(64);
expect(geometry.prefixWidth).toBeGreaterThanOrEqual(24);
expect(geometry.inputInsideRow).toBe(true);
expect(geometry.prefixInsideRow).toBe(true);
expect(geometry.prefixEllipsis).toBe('ellipsis');
// The floor is the editor's own inline style, not a stylesheet override.
expect(geometry.inlineMinWidth).toBe('4rem');
await input.press('Escape');
expect(await row.locator('input.tab-rename-input').count()).toBe(0);
} finally {
await context.close();
}
});
});
+43 -1
View File
@@ -17,7 +17,28 @@
* Port: N/A (app.inject).
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
// Unreachable-mount seam: `stat()` of a path under this root never settles (a hard
// network mount that went away), so the bounded path probe can be driven to its
// stall cap. Every other stat is the real one. The short timeout is read at import.
const deadMount = vi.hoisted(() => {
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '200';
return { root: '/mnt/codeman-clone-test-dead', releases: [] as Array<() => void> };
});
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
const stat = ((path: string, ...rest: unknown[]) => {
if (String(path).startsWith(deadMount.root + '/')) {
return new Promise((resolve) => deadMount.releases.push(() => resolve({} as never)));
}
return (actual.stat as (...a: unknown[]) => unknown)(path, ...rest);
}) as typeof actual.stat;
return { ...actual, stat, default: { ...actual, stat } };
});
afterAll(() => {
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
});
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { execFileSync } from 'node:child_process';
@@ -38,6 +59,8 @@ import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { isGitAvailable } from '../../src/git-clone.js';
import { probePath } from '../../src/utils/index.js';
import { MAX_STALLED_PATH_PROBES } from '../../src/config/path-probe.js';
const CASES_DIR = join(homedir(), 'codeman-cases');
const gitPresent = isGitAvailable();
@@ -252,6 +275,25 @@ describe.skipIf(!gitPresent)('POST /api/cases/clone — real clone', () => {
expect(body.data.warnings.join(' ')).toMatch(/ships its own \.claude/);
});
it('still warns about repo-supplied .claude settings while unrelated mounts are unreachable', async () => {
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `${deadMount.root}/nas-${i}/project`);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
// Engage the probe's stall cap: every new bounded probe is now refused.
expect(await Promise.all(dead.map((p) => probePath(p)))).toEqual(dead.map(() => 'unknown'));
created.push('warns-under-cap');
const res = await clone({ name: 'warns-under-cap', repository: origin });
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.warnings.join(' ')).toMatch(/ships its own \.claude/);
} finally {
deadMount.releases.splice(0).forEach((release) => release());
await new Promise((r) => setTimeout(r, 0));
warn.mockRestore();
}
});
it('installs Codeman hooks alongside whatever the repo shipped', async () => {
created.push('hooked');
await clone({ name: 'hooked', repository: origin });
+196
View File
@@ -0,0 +1,196 @@
/**
* @fileoverview POST /api/cases with a `path`: create a new case in a custom folder. Real
* filesystem under test/setup.ts's temp HOME (the path policy itself is in test/case-path.test.ts).
* Port: N/A (app.inject()).
*
* This suite deletes and rewrites the linked-cases registry. Under test/setup.ts that file lives in a
* throwaway HOME; a raw `npx vitest` (no setup) would reach the real ~/.codeman, so every test refuses
* to start there. Each test's folders sit in a fresh mkdtemp dir, and everything is removed through
* safeRmHomeTree, so a run can only ever delete what it created.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { homedir } from 'node:os';
import { basename, join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { dataPath } from '../../src/config/instance.js';
import { safeRmHomeTree } from '../mocks/index.js';
const LINKED = () => dataPath('linked-cases.json');
const CASES_DIR = () => join(homedir(), 'codeman-cases');
/** test/setup.ts's temp HOME (its prefix is pinned by test/test-env-isolation.test.ts). */
const sandboxed = () => basename(homedir()).startsWith('codeman-vitest-');
let workDir = '';
const work = () => workDir;
const linked = (): Record<string, string> => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {});
const create = (app: Awaited<ReturnType<typeof createRouteTestHarness>>['app'], payload: Record<string, unknown>) =>
app.inject({ method: 'POST', url: '/api/cases', payload });
beforeEach(() => {
if (!sandboxed()) {
throw new Error('case-custom-path-routes.test.ts deletes the linked-cases registry: run it via npm test');
}
// Recursive: the rollback tests turn the registry into a directory.
safeRmHomeTree(LINKED());
// Resolved, because the server answers with the symlink-resolved folder (macOS temp is under /private).
workDir = realpathSync(mkdtempSync(join(homedir(), 'case-custom-path-')));
});
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
if (workDir) safeRmHomeTree(workDir);
workDir = '';
if (sandboxed()) safeRmHomeTree(LINKED());
});
describe('POST /api/cases with a custom path', () => {
it('creates the folder, scaffolds it like a normal case, and registers it as a linked case', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(work(), 'my-app');
const res = await create(app, { name: 'my-app', description: 'A thing', path: target });
expect(res.statusCode).toBe(200);
expect(res.json().data.case).toEqual({ name: 'my-app', path: target });
expect(readFileSync(join(target, 'CLAUDE.md'), 'utf8')).toContain('my-app');
expect(existsSync(join(target, 'src'))).toBe(true);
expect(existsSync(join(target, '.claude', 'settings.local.json'))).toBe(true);
expect(linked()).toEqual({ 'my-app': target });
});
it('appears in GET /api/cases at its custom path', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(work(), 'listed');
await create(app, { name: 'listed', path: target });
const list = (await app.inject({ method: 'GET', url: '/api/cases' })).json();
const cases = Array.isArray(list) ? list : list.data;
expect(cases.find((c: { name: string }) => c.name === 'listed')).toMatchObject({ path: target });
});
it('expands ~ and fills an existing EMPTY folder', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(work(), 'empty-one'));
const res = await create(app, { name: 'empty-one', path: `~/${basename(work())}/empty-one` });
expect(res.statusCode).toBe(200);
expect(existsSync(join(work(), 'empty-one', 'CLAUDE.md'))).toBe(true);
});
it('leaves the cases directory alone: nothing is created under codeman-cases', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
await create(app, { name: 'elsewhere', path: join(work(), 'elsewhere') });
expect(existsSync(join(homedir(), 'codeman-cases', 'elsewhere'))).toBe(false);
});
it('refuses a folder that already has files (409) and touches nothing', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(work(), 'existing'));
writeFileSync(join(work(), 'existing', 'keep.txt'), 'mine');
const res = await create(app, { name: 'existing', path: join(work(), 'existing') });
expect(res.statusCode).toBe(409);
expect(res.json().error).toMatch(/Link Existing/);
expect(readdirSync(join(work(), 'existing'))).toEqual(['keep.txt']);
expect(linked()).toEqual({});
});
it.each([
['a system folder', () => '/etc/my-case', 400],
['a credential folder', () => join(homedir(), '.ssh', 'x'), 400],
['the home folder itself', () => homedir(), 400],
['a relative path', () => 'projects/x', 400],
['a path with traversal', () => `${work()}/../x`, 400],
['a missing parent', () => join(work(), 'nope', 'deep', 'app'), 404],
] as const)('refuses %s', async (_label, path, status) => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await create(app, { name: 'x', path: path() });
expect(res.statusCode).toBe(status);
expect(linked()).toEqual({});
});
it('refuses a duplicate case name, and a folder that is already a case, without creating anything', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
await create(app, { name: 'one', path: join(work(), 'one') });
const dupName = await create(app, { name: 'one', path: join(work(), 'two') });
expect(dupName.statusCode).toBe(409);
expect(existsSync(join(work(), 'two'))).toBe(false);
// Same folder under another name: the first case's folder now has files, which is refused earlier.
const dupPath = await create(app, { name: 'other', path: join(work(), 'one') });
expect(dupPath.statusCode).toBe(409);
expect(linked()).toEqual({ one: join(work(), 'one') });
});
it('validates the case name like a normal create', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await create(app, { name: '../evil', path: join(work(), 'x') });
expect(res.statusCode).toBe(400);
expect(existsSync(join(work(), 'x'))).toBe(false);
});
it('undoes what it created when registering fails (a new folder is removed entirely)', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(LINKED(), { recursive: true }); // writeFile onto a directory fails
const res = await create(app, { name: 'doomed', path: join(work(), 'doomed') });
expect(res.statusCode).toBe(500);
expect(existsSync(join(work(), 'doomed'))).toBe(false);
});
it('undoes only the scaffold inside an empty folder the user picked, leaving the folder', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(work(), 'picked'));
mkdirSync(LINKED(), { recursive: true });
const res = await create(app, { name: 'picked', path: join(work(), 'picked') });
expect(res.statusCode).toBe(500);
expect(existsSync(join(work(), 'picked'))).toBe(true);
expect(readdirSync(join(work(), 'picked'))).toEqual([]);
});
it('a request without a path still creates under the cases directory, as before', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await create(app, { name: 'plain-case' });
expect(res.statusCode).toBe(200);
expect(existsSync(join(homedir(), 'codeman-cases', 'plain-case', 'CLAUDE.md'))).toBe(true);
expect(linked()).toEqual({});
safeRmHomeTree(join(CASES_DIR(), 'plain-case'));
});
it('refuses a target in the cases directory (400): a case there is a plain create, never a linked one', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(CASES_DIR(), 'existing-empty'), { recursive: true });
// A link from the custom parent into the cases dir is judged on its resolved form too.
symlinkSync(CASES_DIR(), join(work(), 'into-cases'));
try {
for (const path of [
join(CASES_DIR(), 'bar'),
join(CASES_DIR(), 'existing-empty'),
join(work(), 'into-cases', 'via-link'),
]) {
const res = await create(app, { name: 'bar', path });
expect(res.statusCode, path).toBe(400);
expect(res.json().error, path).toMatch(/plain Create New/);
}
expect(existsSync(join(CASES_DIR(), 'bar'))).toBe(false);
expect(existsSync(join(CASES_DIR(), 'via-link'))).toBe(false);
expect(readdirSync(join(CASES_DIR(), 'existing-empty'))).toEqual([]);
expect(linked()).toEqual({});
} finally {
safeRmHomeTree(join(CASES_DIR(), 'existing-empty'));
}
});
it('multi-user: a non-admin is refused (403) and nothing is created; an admin is allowed', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const user = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } });
const denied = await create(user.app, { name: 'bobs', path: join(work(), 'bobs') });
expect(denied.statusCode).toBe(403);
expect(existsSync(join(work(), 'bobs'))).toBe(false);
expect(linked()).toEqual({});
const admin = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'root', role: 'admin' } });
expect((await create(admin.app, { name: 'roots', path: join(work(), 'roots') })).statusCode).toBe(200);
});
});
+137 -1
View File
@@ -13,13 +13,24 @@
* behavior matches production exactly).
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } 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 { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { probePath } from '../../src/utils/index.js';
import { MAX_STALLED_PATH_PROBES } from '../../src/config/path-probe.js';
// A short path-probe timeout keeps the unreachable-mount tests quick. Read when the
// probe's config module is first imported, so it is set before any import runs.
vi.hoisted(() => {
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '300';
});
afterAll(() => {
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
});
// Mock filesystem modules
vi.mock('node:fs', async (importOriginal) => {
@@ -35,6 +46,7 @@ vi.mock('node:fs', async (importOriginal) => {
vi.mock('node:fs/promises', () => ({
default: {
stat: vi.fn(),
readdir: vi.fn(async () => []),
readFile: vi.fn(async () => {
const err = new Error('ENOENT') as NodeJS.ErrnoException;
@@ -74,6 +86,7 @@ const mockedReaddirSync = vi.mocked(readdirSync);
const mockedReaddir = vi.mocked(fs.readdir);
const mockedReadFile = vi.mocked(fs.readFile);
const mockedWriteFile = vi.mocked(fs.writeFile);
const mockedStat = vi.mocked(fs.stat);
const mockedCheckRemoteTmux = vi.mocked(checkRemoteTmuxAvailable);
interface CaseRouteHarness {
@@ -127,6 +140,12 @@ describe('case-routes', () => {
// Default: existsSync returns false, readFile throws ENOENT
mockedExistsSync.mockReturnValue(false);
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
// Async stat (the bounded path probe) follows the mocked existsSync, so a
// test that sets up a path's presence via existsSync drives both the same way.
mockedStat.mockImplementation(async (path) => {
if (mockedExistsSync(path)) return { isDirectory: () => true } as never;
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
});
afterEach(async () => {
@@ -210,6 +229,53 @@ describe('case-routes', () => {
// Should have both regular and linked cases
expect(body.data.length).toBeGreaterThanOrEqual(1);
});
it('still answers promptly when a linked case sits on an unreachable mount', async () => {
// A hard network mount that went away: a synchronous probe blocks the
// thread (simulated by a busy-wait), and an async stat never settles.
const stalledPath = '/mnt/unreachable/linked-nfs';
const BLOCK_MS = 4_000;
mockedReaddir.mockResolvedValue([] as never);
mockedReadFile.mockResolvedValueOnce(JSON.stringify({ 'linked-nfs': stalledPath }) as never);
mockedExistsSync.mockImplementation((p) => {
if (String(p) !== stalledPath) return false;
const until = Date.now() + BLOCK_MS;
while (Date.now() < until) {
// spin: the event loop is frozen for as long as the mount does not answer
}
return true;
});
let release: (() => void) | undefined;
mockedStat.mockImplementation((p) => {
if (String(p) !== stalledPath) {
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
}
return new Promise((resolve) => {
release = () => resolve({ isDirectory: () => true } as never);
});
});
const started = Date.now();
const res = await harness.app.inject({ method: 'GET', url: '/api/cases' });
const elapsed = Date.now() - started;
release?.();
expect(res.statusCode).toBe(200);
expect(elapsed).toBeLessThan(BLOCK_MS - 1_000);
// The unreachable case is listed as such rather than holding the list
// hostage, or vanishing as though it had been deleted.
expect(JSON.parse(res.body).data).toEqual([
{
name: 'linked-nfs',
path: stalledPath,
hasClaudeMd: false,
linked: true,
location: 'linked-local',
unreachable: true,
},
]);
await new Promise((r) => setTimeout(r, 0)); // let the released stat clear its stall
});
});
describe('remote host and remote case routes', () => {
@@ -670,6 +736,66 @@ describe('case-routes', () => {
expect(body.data.name).toBe('regular-case');
});
it('answers a linked case on an unreachable mount with its registered path, not NOT_FOUND', async () => {
// The timeout path: the mount does not answer at all.
const stalledPath = '/mnt/unreachable/linked-get';
mockedReadFile.mockResolvedValue(JSON.stringify({ 'linked-get': stalledPath }) as never);
let release: (() => void) | undefined;
mockedStat.mockImplementation((p) => {
if (String(p) !== stalledPath) {
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
}
return new Promise((resolve) => {
release = () => resolve({ isDirectory: () => true } as never);
});
});
const res = await harness.app.inject({ method: 'GET', url: '/api/cases/linked-get' });
release?.();
await new Promise((r) => setTimeout(r, 0)); // let the released stat clear its stall
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data).toMatchObject({ name: 'linked-get', path: stalledPath, linked: true, unreachable: true });
});
it('still answers a healthy case while unrelated mounts are stalled past the cap', async () => {
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `/mnt/dead-${i}/linked`);
const releases: Array<() => void> = [];
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
mockedStat.mockImplementation((p) => {
if (dead.includes(String(p))) {
return new Promise((resolve) => releases.push(() => resolve({ isDirectory: () => true } as never)));
}
return Promise.resolve({ isDirectory: () => true } as never);
});
expect(await Promise.all(dead.map((p) => probePath(p)))).toEqual(dead.map(() => 'unknown'));
const res = await harness.app.inject({ method: 'GET', url: '/api/cases/healthy-local' });
releases.forEach((release) => release());
await new Promise((r) => setTimeout(r, 0));
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data).toMatchObject({ name: 'healthy-local' });
expect(JSON.parse(res.body).data.unreachable).toBeUndefined();
// Its CLAUDE.md is probed the same way as its folder, so it is not misreported missing.
expect(JSON.parse(res.body).data.hasClaudeMd).toBe(true);
});
it('answers a local case it cannot read with a non-NOT_FOUND error', async () => {
// A soft mount that gave up (EIO) is not proof the case is gone, and the Run
// button creates a case on NOT_FOUND.
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
mockedStat.mockRejectedValue(Object.assign(new Error('EIO'), { code: 'EIO' }));
const res = await harness.app.inject({ method: 'GET', url: '/api/cases/eio-case' });
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe('OPERATION_FAILED');
expect(res.statusCode).not.toBe(404);
});
it('returns error when case not found anywhere', async () => {
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
mockedExistsSync.mockReturnValue(false);
@@ -704,6 +830,16 @@ describe('case-routes', () => {
expect(body.data.todos).toEqual([]);
});
it('reports an unreadable fix plan as an error, not as "no plan"', async () => {
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
mockedStat.mockRejectedValue(Object.assign(new Error('EIO'), { code: 'EIO' }));
const res = await harness.app.inject({ method: 'GET', url: '/api/cases/my-case/fix-plan' });
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe('OPERATION_FAILED');
});
it('parses fix plan with todos and stats', async () => {
const fixPlanContent = [
'# Fix Plan',
+144
View File
@@ -0,0 +1,144 @@
/**
* @fileoverview GET /api/doctor: the `codeman doctor` report for Settings → System → Diagnostics.
* The route runs the probe out of process (the engine is synchronous), so every test injects the
* runner; the default runner's parsing is covered against a faked `execFile`, and the CLI contract
* it relies on is exercised for real in test/doctor-cli-json.test.ts.
*
* Port: N/A (app.inject()).
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createRouteTestHarness } from './_route-test-utils.js';
import { defaultDoctorRunner, registerDoctorRoutes, type DoctorRunner } from '../../src/web/routes/doctor-routes.js';
import type { DependencyReportJson } from '../../src/utils/dependency-report.js';
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }));
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
return { ...actual, execFile: execFileMock };
});
const REPORT: DependencyReportJson = {
platform: { environment: 'linux' },
summary: { ok: 1, requiredMissing: 1, optionalMissing: 0, exitCode: 1 },
tools: [
{ id: 'node', label: 'Node.js', category: 'core', required: true, usedBy: [], status: 'ok', version: '22.1.0' },
{ id: 'tmux', label: 'tmux', category: 'core', required: true, usedBy: [], status: 'missing' },
],
};
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
execFileMock.mockReset();
});
describe('GET /api/doctor', () => {
it('returns the runner’s report in the success envelope', async () => {
const runner = vi.fn<DoctorRunner>(async () => REPORT);
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner));
const res = await app.inject({ method: 'GET', url: '/api/doctor' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ success: true, data: REPORT });
expect(runner).toHaveBeenCalledWith(undefined);
});
it('passes a valid category through and rejects an unknown one without running anything', async () => {
const runner = vi.fn<DoctorRunner>(async () => REPORT);
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner));
expect((await app.inject({ method: 'GET', url: '/api/doctor?category=office' })).statusCode).toBe(200);
expect(runner).toHaveBeenLastCalledWith('office');
runner.mockClear();
const bad = await app.inject({ method: 'GET', url: '/api/doctor?category=%3Brm%20-rf' });
expect(bad.statusCode).toBe(400);
expect(bad.json().errorCode).toBe('INVALID_INPUT');
expect(runner).not.toHaveBeenCalled();
});
it('answers 500 with a message when the runner fails', async () => {
const runner: DoctorRunner = async () => {
throw new Error('spawn blew up');
};
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner));
const res = await app.inject({ method: 'GET', url: '/api/doctor' });
expect(res.statusCode).toBe(500);
expect(res.json().error).toContain('spawn blew up');
expect(res.json().errorCode).toBe('INTERNAL_ERROR');
});
it('single-flights: concurrent requests for a category share one run, and a later one runs again', async () => {
const releases: Array<(r: DependencyReportJson) => void> = [];
const runner = vi.fn<DoctorRunner>(() => new Promise<DependencyReportJson>((res) => releases.push(res)));
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner));
const first = app.inject({ method: 'GET', url: '/api/doctor' });
const second = app.inject({ method: 'GET', url: '/api/doctor' });
const other = app.inject({ method: 'GET', url: '/api/doctor?category=office' });
await vi.waitFor(() => expect(runner).toHaveBeenCalledTimes(2));
releases[0](REPORT);
expect((await first).statusCode).toBe(200);
expect((await second).statusCode).toBe(200);
expect(runner).toHaveBeenCalledTimes(2); // unfiltered (shared) + office
releases[1](REPORT);
await other;
runner.mockImplementation(async () => REPORT);
await app.inject({ method: 'GET', url: '/api/doctor' });
expect(runner).toHaveBeenCalledTimes(3);
});
it('multi-user: a non-admin is refused and nothing is probed', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const runner = vi.fn<DoctorRunner>(async () => REPORT);
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner), {
authUser: { username: 'bob', role: 'user' },
});
const res = await app.inject({ method: 'GET', url: '/api/doctor' });
expect(res.statusCode).toBe(403);
expect(runner).not.toHaveBeenCalled();
});
it('multi-user: an admin is allowed', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, async () => REPORT), {
authUser: { username: 'root', role: 'admin' },
});
expect((await app.inject({ method: 'GET', url: '/api/doctor' })).statusCode).toBe(200);
});
});
describe('defaultDoctorRunner', () => {
type Done = (err: Error | null, stdout: string) => void;
const respond = (err: Error | null, stdout: string) =>
execFileMock.mockImplementation((_bin: string, _args: string[], _opts: unknown, done: Done) => done(err, stdout));
it('runs `doctor --json` in a child of this same entry script, never in-process', async () => {
respond(null, JSON.stringify(REPORT));
await defaultDoctorRunner('core');
const [bin, args, opts] = execFileMock.mock.calls[0];
expect(bin).toBe(process.execPath);
expect(args.slice(-4)).toEqual(['doctor', '--json', '--category', 'core']);
expect(args).toContain(process.argv[1]);
expect((opts as { timeout: number }).timeout).toBeGreaterThan(0);
});
it('treats a non-zero exit with a valid report as a normal result (a missing required tool exits 1)', async () => {
respond(Object.assign(new Error('exit 1'), { code: 1 }), JSON.stringify(REPORT));
await expect(defaultDoctorRunner()).resolves.toEqual(REPORT);
});
it.each([
['empty output', ''],
['non-JSON output', 'Segmentation fault'],
['JSON of the wrong shape', '{"hello":"world"}'],
])('rejects %s', async (_label, stdout) => {
respond(null, stdout);
await expect(defaultDoctorRunner()).rejects.toThrow();
});
it('reports a killed child (the 30 s timeout) as a timeout, not the raw command line', async () => {
respond(Object.assign(new Error('Command failed: node doctor --json'), { killed: true, signal: 'SIGTERM' }), '');
await expect(defaultDoctorRunner()).rejects.toThrow('timed out after 30 s');
});
it('passes the child’s own error through when there is no report at all', async () => {
respond(new Error('ETIMEDOUT'), '');
await expect(defaultDoctorRunner()).rejects.toThrow('ETIMEDOUT');
});
});
+300
View File
@@ -0,0 +1,300 @@
/**
* @fileoverview GET /api/sessions/:id/git-status: the git snapshot behind the bottom-bar Git
* indicator. Real git for the happy path; an injected runner for the cases that must not run git at
* all (remote and Docker sessions). Port: N/A (app.inject()).
*/
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
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';
const ENV = {
...process.env,
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, stdio: 'ignore' });
let dir: string;
let session: Record<string, unknown>;
async function setup(
opts: { git?: GitRunner; authUser?: { username: string; role: 'admin' | 'user' }; dockerWorkspaces?: string[] } = {}
) {
const h = await createRouteTestHarness(
(app, ctx) => registerGitStatusRoutes(app, ctx, opts.git, async () => opts.dockerWorkspaces ?? []),
{
authUser: opts.authUser,
}
);
session = h.ctx._session as unknown as Record<string, unknown>;
session.workingDir = dir;
return h;
}
beforeEach(() => {
clearGitStatusCache();
dir = mkdtempSync(join(tmpdir(), 'git-status-route-'));
});
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
rmSync(dir, { recursive: true, force: true });
});
describe('GET /api/sessions/:id/git-status', () => {
it('returns the snapshot of the session workspace in the success envelope', async () => {
git(dir, 'init', '-q', '-b', 'main');
writeFileSync(join(dir, 'a.txt'), '1\n');
git(dir, 'add', '-A');
git(dir, 'commit', '-q', '-m', 'base');
writeFileSync(join(dir, 'a.txt'), '2\n');
writeFileSync(join(dir, 'new.txt'), 'n\n');
const { app } = await setup();
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.state).toBe('ok');
expect(body.data.repos).toHaveLength(1);
const repo = body.data.repos[0];
expect(repo).toMatchObject({ path: '.', status: { state: 'ok', branch: 'main' } });
expect(repo.status.counts).toMatchObject({ unstaged: 1, untracked: 1, uncommitted: 2 });
expect(repo.status.files.map((f: { path: string }) => f.path).sort()).toEqual(['a.txt', 'new.txt']);
});
it('reports each repository found below a folder that holds several projects', async () => {
for (const name of ['api', 'web']) {
mkdirSync(join(dir, name));
git(join(dir, name), 'init', '-q', '-b', 'main');
}
writeFileSync(join(dir, 'api', 'dirty.txt'), 'x');
const { app } = await setup();
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
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[0].status.counts.uncommitted).toBe(1);
expect(data.repos[1].status.counts.uncommitted).toBe(0);
});
it('answers not-a-repo for a folder that is not a repository', async () => {
const { app } = await setup();
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
expect(res.json().data.state).toBe('not-a-repo');
});
it('404s an unknown session', async () => {
const { app } = await setup();
const res = await app.inject({ method: 'GET', url: '/api/sessions/nope/git-status' });
expect(res.statusCode).toBe(404);
});
it('reuses a recent result for a poll, and recomputes for ?fresh=1', async () => {
// The workspace is the root of its repository, as real git would say.
const runner = vi.fn<GitRunner>(async (cwd, args) => (args[0] === 'rev-parse' ? `${cwd}\n` : ''));
const { app } = await setup({ git: runner });
const calls = (verb: string) => runner.mock.calls.filter(([, args]) => args[0] === verb).length;
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
const revParses = calls('rev-parse');
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
expect(calls('status')).toBe(1);
expect(calls('rev-parse')).toBe(revParses); // the enclosing repository is reused too
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status?fresh=1' });
expect(calls('status')).toBe(2);
});
it('runs git in the session working directory', async () => {
const runner = vi.fn<GitRunner>(async () => '');
const { app } = await setup({ git: runner });
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
expect(runner).toHaveBeenCalled();
for (const [cwd] of runner.mock.calls) expect(cwd).toBe(dir);
});
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) => {
const runner = vi.fn<GitRunner>(async () => '');
const { app } = await setup({ git: runner });
session[kind] = value;
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
expect(res.statusCode).toBe(200);
expect(res.json().data).toMatchObject({ state: 'unsupported', reason: kind });
expect(runner).not.toHaveBeenCalled();
});
it('multi-user: another user’s session is not found, and git is not run for it', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const runner = vi.fn<GitRunner>(async () => '');
const { app } = await setup({ git: runner, authUser: { username: 'bob', role: 'user' } });
session.owner = 'alice';
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
expect(res.statusCode).toBe(404);
expect(runner).not.toHaveBeenCalled();
});
it('multi-user: the owner and an admin can read it', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const runner = vi.fn<GitRunner>(async () => '');
for (const authUser of [
{ username: 'alice', role: 'user' as const },
{ username: 'root', role: 'admin' as const },
]) {
clearGitStatusCache();
const { app } = await setup({ git: runner, authUser });
session.owner = 'alice';
expect((await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' })).statusCode).toBe(
200
);
}
});
});
describe('GET /api/sessions/:id/git-diff', () => {
const url = (q: Record<string, string>) => `/api/sessions/test-session-1/git-diff?${new URLSearchParams(q)}`;
let root: string;
beforeEach(() => {
git(dir, 'init', '-q', '-b', 'main');
writeFileSync(join(dir, 'a.txt'), 'one\n');
writeFileSync(join(dir, 'b.txt'), 'bee\n');
git(dir, 'add', '-A');
git(dir, 'commit', '-q', '-m', 'base');
writeFileSync(join(dir, 'a.txt'), 'two\n');
writeFileSync(join(dir, 'b.txt'), 'staged\n');
git(dir, 'add', 'b.txt');
writeFileSync(join(dir, 'new.txt'), 'fresh\n');
root = realpathSync(dir);
});
it.each([
['unstaged', 'a.txt', ['-one', '+two']],
['staged', 'b.txt', ['-bee', '+staged']],
['untracked', 'new.txt', ['+fresh']],
])('returns the %s diff of %s', async (kind, path, lines) => {
const { app } = await setup();
const res = await app.inject({ method: 'GET', url: url({ repo: root, path, kind }) });
expect(res.statusCode).toBe(200);
const { diff, truncated, binary } = res.json().data;
for (const l of lines) expect(diff).toContain(l);
expect(truncated).toBe(false);
expect(binary).toBe(false);
});
it('answers 404 for a path or repo the status does not list, running no diff', async () => {
const runner = vi.fn<GitRunner>(async () => '');
const { app } = await setup({ git: runner });
for (const q of [
{ repo: root, path: '../../etc/passwd', kind: 'unstaged' },
{ repo: '/etc', path: 'a.txt', kind: 'unstaged' },
{ repo: root, path: 'a.txt', kind: 'staged' },
]) {
const res = await app.inject({ method: 'GET', url: url(q) });
expect(res.statusCode).toBe(404);
}
expect(runner.mock.calls.some(([, args]) => args[0] === 'diff')).toBe(false);
});
it('does not run git for remote and Docker sessions', async () => {
const runner = vi.fn<GitRunner>(async () => '');
const { app } = await setup({ git: runner });
session.remote = { host: 'h' };
const res = await app.inject({ method: 'GET', url: url({ repo: root, path: 'a.txt', kind: 'unstaged' }) });
expect(res.statusCode).toBe(400);
expect(runner).not.toHaveBeenCalled();
});
it('multi-user: another user’s session is not found', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await setup({ authUser: { username: 'bob', role: 'user' } });
session.owner = 'alice';
const res = await app.inject({ method: 'GET', url: url({ repo: root, path: 'a.txt', kind: 'unstaged' }) });
expect(res.statusCode).toBe(404);
});
it('diffs a staged rename against its old name, and a merge conflict as git’s combined diff (real git)', async () => {
// beforeEach left a.txt/b.txt modified; start this case from a clean tree.
git(dir, 'checkout', '-q', '--', '.');
git(dir, 'reset', '-q', '--hard');
git(dir, 'clean', '-fdq');
writeFileSync(join(dir, 'old.txt'), 'a\nb\nc\nd\ne\nf\ng\n');
git(dir, 'add', 'old.txt');
git(dir, 'commit', '-q', '-m', 'old');
// A real conflict on c.txt.
writeFileSync(join(dir, 'c.txt'), 'base\n');
git(dir, 'add', 'c.txt');
git(dir, 'commit', '-q', '-m', 'c');
git(dir, 'checkout', '-q', '-b', 'other');
writeFileSync(join(dir, 'c.txt'), 'theirs\n');
git(dir, 'commit', '-q', '-am', 'theirs');
git(dir, 'checkout', '-q', 'main');
writeFileSync(join(dir, 'c.txt'), 'ours\n');
git(dir, 'commit', '-q', '-am', 'ours');
try {
git(dir, 'merge', 'other');
} catch {
/* the conflict is the point */
}
// A staged rename, made once the merge has stopped on the conflict.
git(dir, 'mv', 'old.txt', 'new-name.txt');
writeFileSync(join(dir, 'new-name.txt'), 'a\nb\nc\nd\ne\nf\nCHANGED\n');
git(dir, 'add', 'new-name.txt');
const { app } = await setup();
const root = realpathSync(dir);
const rename = await app.inject({ method: 'GET', url: url({ repo: root, path: 'new-name.txt', kind: 'staged' }) });
expect(rename.statusCode).toBe(200);
expect(rename.json().data.diff).toContain('rename from old.txt');
expect(rename.json().data.diff).toContain('+CHANGED');
const conflict = await app.inject({ method: 'GET', url: url({ repo: root, path: 'c.txt', kind: 'conflicted' }) });
expect(conflict.statusCode).toBe(200);
expect(conflict.json().data.diff).toMatch(/<<<<<<<|\+\+<<<<<<</);
});
it('404s a repository inside a Docker case workspace without running git in it', async () => {
const runner = vi.fn<GitRunner>(async () => '');
const { app } = await setup({ git: runner, dockerWorkspaces: [realpathSync(dir)] });
const res = await app.inject({
method: 'GET',
url: url({ repo: realpathSync(dir), path: 'a.txt', kind: 'unstaged' }),
});
expect(res.statusCode).toBe(404);
expect(runner).not.toHaveBeenCalled();
});
});
describe('GET /api/sessions/:id/git-diff in a folder of several repositories', () => {
it('checks the repository against the listed ones and re-reads only that one', async () => {
for (const name of ['api', 'web']) {
const r = join(dir, name);
mkdirSync(r);
git(r, 'init', '-q', '-b', 'main');
writeFileSync(join(r, 'f.txt'), `${name}\n`);
}
const calls: Array<[string, string]> = [];
const runner: GitRunner = async (cwd, args) => {
calls.push([cwd, args[0]]);
return runGit(cwd, args);
};
const { app } = await setup({ git: runner });
const overview = (await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' })).json().data;
const api = overview.repos.find((r: { name: string }) => r.name === 'api').status.repoRoot;
calls.length = 0;
const q = new URLSearchParams({ repo: api, path: 'f.txt', kind: 'untracked' });
const res = await app.inject({ method: 'GET', url: `/api/sessions/test-session-1/git-diff?${q}` });
expect(res.statusCode).toBe(200);
expect(res.json().data.diff).toContain('+api');
expect(calls.filter(([, verb]) => verb === 'status').map(([cwd]) => cwd)).toEqual([api]);
});
});
@@ -0,0 +1,174 @@
/**
* @fileoverview Session creation must not freeze the server on a workspace whose
* network mount has gone away (`POST /api/sessions` with a `workingDir` on it, and
* `POST /api/quick-start` for a linked case that lives there), and must not treat
* "did not answer" as "does not exist" (quick-start would scaffold a fresh case
* over the top of where the real one is mounted).
*
* A hard mount that stopped answering is simulated two ways, matching how each
* API behaves on one: a synchronous probe (`existsSync`/`statSync`/`mkdirSync`)
* busy-waits, freezing the event loop, and an async `stat()` never settles.
*
* Uses app.inject(), so no real HTTP port is needed.
*/
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
const dead = vi.hoisted(() => {
// Short probe timeout so a stalled stat costs ~200 ms here. Read at import.
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '200';
return {
root: '/mnt/codeman-test-dead-mount',
blockMs: 3_000,
syncTouches: [] as string[],
releases: [] as Array<() => void>,
};
});
function onDeadMount(path: unknown): boolean {
const p = String(path);
return p === dead.root || p.startsWith(dead.root + '/');
}
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const freezeOn =
<T extends (...args: never[]) => unknown>(fn: T) =>
(...args: Parameters<T>): ReturnType<T> => {
if (onDeadMount(args[0])) {
dead.syncTouches.push(String(args[0]));
const until = Date.now() + dead.blockMs;
while (Date.now() < until) {
// spin: the event loop is frozen for as long as the mount does not answer
}
throw Object.assign(new Error('EIO'), { code: 'EIO' });
}
return fn(...args) as ReturnType<T>;
};
const existsSync = freezeOn(actual.existsSync);
const statSync = freezeOn(actual.statSync as (...args: never[]) => unknown);
const mkdirSync = freezeOn(actual.mkdirSync as (...args: never[]) => unknown);
return {
...actual,
existsSync,
statSync,
mkdirSync,
default: { ...actual, existsSync, statSync, mkdirSync },
};
});
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
const stat = ((path: string, ...rest: unknown[]) => {
if (onDeadMount(path)) {
return new Promise((_resolve, reject) => {
dead.releases.push(() => reject(Object.assign(new Error('EIO'), { code: 'EIO' })));
});
}
return (actual.stat as (...a: unknown[]) => unknown)(path, ...rest);
}) as typeof actual.stat;
return { ...actual, stat, default: { ...actual, stat } };
});
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createMockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { dataPath } from '../../src/config/instance.js';
describe('session creation on an unreachable mount', () => {
let app: FastifyInstance;
let scratch: string;
let warn: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
dead.syncTouches.length = 0;
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
scratch = await mkdtemp(join(tmpdir(), 'codeman-unreachable-create-'));
app = Fastify({ logger: false });
await app.register(fastifyCookie);
registerSessionRoutes(app, createMockRouteContext() as never);
installRouteErrorHandler(app);
await app.ready();
});
afterEach(async () => {
await app.close();
dead.releases.splice(0).forEach((release) => release());
await new Promise((r) => setTimeout(r, 0));
await rm(scratch, { recursive: true, force: true });
await rm(dataPath('linked-cases.json'), { force: true });
warn.mockRestore();
});
afterAll(() => {
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
});
it('POST /api/sessions answers promptly, and not as "does not exist", for a workingDir on a dead mount', async () => {
const started = Date.now();
const res = await app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'dead-mount', mode: 'shell', workingDir: `${dead.root}/project` },
});
const elapsed = Date.now() - started;
expect(elapsed).toBeLessThan(dead.blockMs - 1_000);
expect(dead.syncTouches).toEqual([]);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe('OPERATION_FAILED');
expect(body.error).toMatch(/not responding/i);
});
it('POST /api/sessions keeps INVALID_INPUT for a missing workingDir and for a file', async () => {
const file = join(scratch, 'a-file.txt');
await writeFile(file, 'x');
const missing = await app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'missing', mode: 'shell', workingDir: join(scratch, 'nope') },
});
expect(JSON.parse(missing.body)).toMatchObject({
success: false,
errorCode: 'INVALID_INPUT',
error: 'workingDir does not exist',
});
const notDir = await app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'file', mode: 'shell', workingDir: file },
});
expect(JSON.parse(notDir.body)).toMatchObject({
success: false,
errorCode: 'INVALID_INPUT',
error: 'workingDir is not a directory',
});
});
it('POST /api/quick-start refuses, promptly and without scaffolding, a linked case on a dead mount', async () => {
await writeFile(dataPath('linked-cases.json'), JSON.stringify({ 'nas-linked': `${dead.root}/linked` }));
const started = Date.now();
const res = await app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'nas-linked', mode: 'shell' },
});
const elapsed = Date.now() - started;
expect(elapsed).toBeLessThan(dead.blockMs - 1_000);
// Neither probed nor created synchronously on the dead mount.
expect(dead.syncTouches).toEqual([]);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe('OPERATION_FAILED');
expect(body.error).toMatch(/not responding/i);
});
});
+62
View File
@@ -1228,3 +1228,65 @@ describe('Grok quick start', () => {
expect(selected).toEqual(['sess-gk-0']);
});
});
describe('case lookup before a local launch', () => {
function loadLaunchHarness(caseAnswer: Record<string, unknown>) {
const elements: Record<string, any> = {
quickStartCase: { value: 'nas-case' },
shellCount: { value: '1' },
tabCount: { value: '1' },
};
const requests: Array<{ url: string; method?: string }> = [];
const written: string[] = [];
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: (id: string) => elements[id] ?? null },
fetch: async (url: string, init?: { method?: string }) => {
requests.push({ url, method: init?.method });
if (url === '/api/cases/nas-case') return { json: async () => caseAnswer };
if (url === '/api/cases' && init?.method === 'POST') {
return {
json: async () => ({
success: true,
data: { case: { name: 'nas-case', path: '/home/u/codeman-cases/nas-case' } },
}),
};
}
// Anything past the case lookup is out of scope here: stop the launch.
throw new Error(`stop: ${url}`);
},
console,
});
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
const app = new (CodemanApp as any)();
app.terminal = { clear: () => {}, writeln: (line: string) => written.push(line), focus: () => {} };
app.sessions = new Map();
app.cases = [];
app.getTerminalDimensions = () => null;
app._readTabCount = () => 1;
app.loadAppSettingsFromStorage = () => ({});
app.getCaseSettings = () => ({});
return { app, requests, written };
}
const unreachable = { success: false, error: 'Case folder is not responding', errorCode: 'OPERATION_FAILED' };
const missing = { success: false, error: 'Case not found', errorCode: 'NOT_FOUND' };
for (const launcher of ['runClaude', 'runShell'] as const) {
it(`${launcher} never creates a case when the lookup could not tell whether it exists`, async () => {
const { app, requests, written } = loadLaunchHarness(unreachable);
await app[launcher]();
expect(requests.some((r) => r.url === '/api/cases' && r.method === 'POST')).toBe(false);
expect(written.join('\n')).toContain('Case folder is not responding');
});
it(`${launcher} creates the case when the lookup says it does not exist`, async () => {
const { app, requests } = loadLaunchHarness(missing);
await app[launcher]();
expect(requests.some((r) => r.url === '/api/cases' && r.method === 'POST')).toBe(true);
});
}
});
+10 -2
View File
@@ -451,8 +451,16 @@ describe('browser wiring', () => {
expect(APP_SOURCE).toContain("[SSE_EVENTS.TAB_LAYOUT_CHANGED, '_onTabLayoutChanged']");
});
it('never writes the layout from the browser in this slice', () => {
expect(APP_SOURCE).not.toMatch(/['"`]PUT['"`][^\n]*tab-layout|tab-layout[^\n]*['"`]PUT['"`]/);
it('writes the layout only through the edit coordinator (and its keepalive twin)', () => {
// Exactly two PUT sites: _putTabLayout (the coordinator's transport) and the
// pagehide keepalive. Anything else would bypass serialization.
const writes = APP_SOURCE.split('\n').filter(
(line) => line.includes("'/api/tab-layout'") && !line.includes("_apiJson('/api/tab-layout')")
);
expect(writes).toHaveLength(2);
expect(APP_SOURCE).toContain("this._api('/api/tab-layout', { method: 'PUT', body })");
expect(APP_SOURCE).toContain("void fetch('/api/tab-layout', {");
// The pure module never does IO itself.
expect(SOURCE).not.toContain('fetch(');
});
});
+364
View File
@@ -0,0 +1,364 @@
/**
* @fileoverview Real-Chromium coverage for editing the grouped vertical rail.
*
* What DOM emulation cannot answer: a pointer drag (hit testing, capture, the
* click that ends a drag), whether the inline group editor actually paints
* inside the rail's nowrap/ellipsis header, and whether Escape on an open group
* menu reaches the menu first. The shipping app.js, tab-layout-browser.js,
* tab-rail-resize.js, api-client.js, webview-tabs.js and styles.css are loaded
* into a page; PUT /api/tab-layout is answered by a route that records bodies.
*
* Port: none (page.route on a fake origin, no server).
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const read = (name: string) => readFileSync(resolve(publicDir, name), 'utf8');
const LAYOUT = {
version: 4,
updatedAt: '2026-10-01T00:00:00.000Z',
groups: [
{
id: 'gx',
name: 'Core',
refs: [
{ kind: 'session', id: 'two' },
{ kind: 'webview', id: 'web' },
],
},
{ id: 'gy', name: 'Later', refs: [{ kind: 'session', id: 'three' }] },
],
ungrouped: [{ kind: 'session', id: 'one' }],
};
describe('grouped rail editing in Chromium', () => {
let browser: Browser;
let page: Page;
let puts: any[] = [];
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 });
await page.route('http://codeman.test/', (route) =>
route.fulfill({ contentType: 'text/html', body: '<!doctype html><html><body></body></html>' })
);
await page.route('http://codeman.test/api/tab-layout', async (route) => {
const request = route.request();
if (request.method() !== 'PUT') return route.fulfill({ status: 404, body: '' });
const body = JSON.parse(request.postData() || '{}');
puts.push(body);
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ success: true, data: { layout: { ...body.layout, version: body.baseVersion + 1 } } }),
});
});
await page.goto('http://codeman.test/');
await page.setContent(`<!doctype html>
<html data-tab-orientation="vertical" data-tab-rail-sort="manual">
<head><style>${read('styles.css')}</style></head>
<body>
<main class="main" style="width:100%;height:760px">
<aside class="tab-rail" id="tabRail" aria-label="Session navigation" style="width:280px"></aside>
<button id="elsewhere">elsewhere</button>
<textarea id="term" aria-label="stand-in for the terminal" style="position:absolute;left:700px;top:300px"></textarea>
</main>
</body>
</html>`);
await page.addScriptTag({
content:
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
read('constants.js') +
'\n' +
read('tab-layout-browser.js') +
'\n' +
read('app.js') +
'\nwindow.CodemanApp = CodemanApp; window.__setApp = (value) => { app = value; };',
});
await page.addScriptTag({ content: read('tab-rail-resize.js') });
await page.addScriptTag({ content: read('api-client.js') });
await page.addScriptTag({ content: read('webview-tabs.js') });
await page.evaluate(() => {
const w = window as any;
const app = Object.create(w.CodemanApp.prototype);
app.$ = (id: string) => document.getElementById(id);
app.sessions = new Map([
['one', { id: 'one', name: 'One', status: 'idle' }],
['two', { id: 'two', name: 'Two', status: 'idle' }],
['three', { id: 'three', name: 'Three', status: 'idle' }],
]);
app.sessionOrder = ['one', 'two', 'three'];
app.webviews = new Map([['web', { id: 'web', name: 'Web', url: 'https://example.test', icon: 'W' }]]);
app.webviewOrder = ['web'];
app.collapsedTabGroupIds = new Set();
app._hiddenTabGroupByRef = new Map();
app._inlineRenameActive = false;
app.tabAlerts = new Map();
app.terminalLoadStates = new Map();
app.minimizedSubagents = new Map();
app.hasTabDetachOverride = () => false;
app.renderSubagentTabBadge = () => '';
app.cancelHideSubagentDropdown = () => undefined;
app.updateTabOverflowMode = () => undefined;
app.updateConnectionLines = () => undefined;
app._applyTabEntrances = () => undefined;
app._scrollActiveTabIntoView = () => undefined;
app.applySidebarFilter = () => undefined;
app.isSessionSidebarActive = () => false;
app._startSidebarRichClock = () => undefined;
app._stopSidebarRichClock = () => undefined;
app.loadAppSettingsFromStorage = () => ({});
app.showToast = () => undefined;
app.closeAllPanels = () => {
w.__panelsClosed = true;
};
app.selectSession = (id: string) => {
w.__activation = `session:${id}`;
};
app.openWebview = (id: string) => {
w.__activation = `webview:${id}`;
};
w.__setApp(app);
w.__app = app;
// Escapes that reach the stand-in terminal (xterm listens on its textarea).
w.__termEscapes = 0;
document.getElementById('term')!.addEventListener('keydown', (e) => {
if (e.key === 'Escape') w.__termEscapes++;
});
});
});
afterAll(async () => browser.close());
beforeEach(async () => {
puts = [];
await page.evaluate((layout) => {
const w = window as any;
document.getElementById('sessionTabs')?.remove();
document
.getElementById('tabRail')!
.insertAdjacentHTML(
'afterbegin',
'<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs"></div>'
);
w.__app._tabKeydownHandler = null;
w.__app._tabLayoutEditor?.dispose();
w.__app._tabLayoutEditor = null;
w.__app.tabLayout = null;
w.__app.activeSessionId = 'one';
w.__app.activeWebviewId = null;
// The rail was just replaced: force the rebuild a skipped no-op would miss.
w.__app._lastTabGroupStructureKey = null;
w.__app._applyTabLayout(layout);
w.__activation = null;
w.__panelsClosed = false;
w.__termEscapes = 0;
}, LAYOUT);
await page.mouse.move(1, 1);
});
const box = async (selector: string) => (await page.locator(selector).boundingBox())!;
async function drag(from: string, to: string, yFraction = 0.5) {
const a = await box(from);
const b = await box(to);
await page.mouse.move(a.x + 20, a.y + a.height / 2);
await page.mouse.down();
await page.mouse.move(a.x + 24, a.y + a.height / 2 + 8, { steps: 3 });
await page.mouse.move(b.x + 30, b.y + b.height * yFraction, { steps: 6 });
await page.mouse.up();
}
const settled = async () => {
await page.waitForTimeout(80);
await page.evaluate(() => new Promise((r) => setTimeout(r, 20)));
};
it('drags a row from Ungrouped onto a group header: one PUT appending it there', async () => {
await drag('.session-tab[data-id="one"]', '[data-tab-group-header="gy"]');
await settled();
expect(puts).toHaveLength(1);
expect(puts[0].baseVersion).toBe(4);
expect(puts[0].layout.groups[1].refs).toEqual([
{ kind: 'session', id: 'three' },
{ kind: 'session', id: 'one' },
]);
expect(puts[0].layout.ungrouped).toEqual([]);
// The click that ends a drag neither selected the row nor toggled the header.
expect(await page.evaluate(() => (window as any).__activation)).toBeNull();
expect(await page.evaluate(() => (window as any).__app.collapsedTabGroupIds.size)).toBe(0);
expect(await page.locator('.tab-layout-dragging, .tab-layout-drop-into').count()).toBe(0);
});
it('drags a row between two rows of another group (upper half = before)', async () => {
await drag('.session-tab[data-id="three"]', '.session-tab[data-webview-id="web"]', 0.25);
await settled();
expect(puts).toHaveLength(1);
expect(puts[0].layout.groups[0].refs.map((r: any) => r.id)).toEqual(['two', 'three', 'web']);
expect(puts[0].layout.groups[1].refs).toEqual([]);
});
it('drags a group header above another group: a reorder', async () => {
await drag('[data-tab-group-header="gy"]', '[data-tab-group-header="gx"]');
await settled();
expect(puts).toHaveLength(1);
expect(puts[0].layout.groups.map((g: any) => g.id)).toEqual(['gy', 'gx']);
expect(await page.locator('.tab-layout-group').first().getAttribute('data-tab-group-id')).toBe('gy');
});
it('Escape mid-drag cancels it without a write; a plain click still selects', async () => {
const a = await box('.session-tab[data-id="one"]');
const b = await box('[data-tab-group-header="gy"]');
await page.mouse.move(a.x + 20, a.y + a.height / 2);
await page.mouse.down();
await page.mouse.move(b.x + 30, b.y + b.height / 2, { steps: 6 });
expect(await page.locator('.tab-layout-drop-into').count()).toBe(1);
await page.keyboard.press('Escape');
await page.mouse.up();
await settled();
expect(puts).toHaveLength(0);
await page.locator('.session-tab[data-id="two"] .tab-name').click();
expect(await page.evaluate(() => (window as any).__activation)).toBe('session:two');
});
it('a press released outside the rail leaves nothing behind, and Escape still reaches the terminal', async () => {
const a = await box('.session-tab[data-id="one"]');
// Press on a row and flick out of the rail in ONE move, releasing out there:
// the rail never sees the move or the release.
await page.mouse.move(a.x + 20, a.y + a.height / 2);
await page.mouse.down();
await page.mouse.move(760, 600);
await page.mouse.up();
// The release was heard on window: the press is gone before any hover.
expect(await page.evaluate(() => (window as any).__app._tabLayoutDrag)).toBeNull();
// Hovering back with no button down must not turn into a phantom drag.
const b = await box('[data-tab-group-header="gy"]');
await page.mouse.move(b.x + 30, b.y + b.height / 2, { steps: 6 });
expect(await page.locator('.tab-layout-dragging, .tab-layout-drop-into, .tab-layout-drag-active').count()).toBe(0);
expect(await page.evaluate(() => (window as any).__app._tabLayoutDrag)).toBeNull();
// A real drag after that, cancelled with Escape, then one more completed.
await page.mouse.move(a.x + 20, a.y + a.height / 2);
await page.mouse.down();
await page.mouse.move(b.x + 30, b.y + b.height / 2, { steps: 6 });
await page.keyboard.press('Escape');
await page.mouse.up();
await drag('.session-tab[data-id="one"]', '[data-tab-group-header="gy"]');
await settled();
expect(puts).toHaveLength(1);
// No drag listener is left in document capture swallowing Escape.
await page.locator('#term').focus();
await page.keyboard.press('Escape');
await page.keyboard.press('Escape');
expect(await page.evaluate(() => (window as any).__termEscapes)).toBe(2);
expect(await page.evaluate(() => (window as any).__app._tabLayoutDragKeydown)).toBeNull();
});
it('each guard holds on its own: buttons-up move, a second press, no stacked Escape listener', async () => {
// Synthetic pointer events reach the cases a real mouse cannot isolate (a
// release outside the WINDOW never reaches any listener of ours).
const result = await page.evaluate(() => {
const w = window as any;
const rail = document.getElementById('sessionTabs')!;
const at = (el: Element) => {
const r = el.getBoundingClientRect();
return { clientX: r.left + 20, clientY: r.top + r.height / 2 };
};
const fire = (target: Element, type: string, init: PointerEventInit) =>
target.dispatchEvent(
new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 7, pointerType: 'mouse', ...init })
);
const one = rail.querySelector('.session-tab[data-id="one"] .tab-name')!;
const three = rail.querySelector('.session-tab[data-id="three"] .tab-name')!;
const out: Record<string, unknown> = {};
// 1. A pending press, then a move with no button down: cancelled, no drag.
fire(one, 'pointerdown', { button: 0, buttons: 1, ...at(one) });
fire(three, 'pointermove', { buttons: 0, ...at(three) });
out.afterButtonsUp = w.__app._tabLayoutDrag;
out.dragClass = rail.querySelectorAll('.tab-layout-dragging').length;
// 2. An active drag whose release never arrived, then a new press.
fire(one, 'pointerdown', { button: 0, buttons: 1, ...at(one) });
fire(three, 'pointermove', { buttons: 1, ...at(three) });
out.firstActive = w.__app._tabLayoutDrag?.active === true;
const firstListener = w.__app._tabLayoutDragKeydown;
fire(three, 'pointerdown', { button: 0, buttons: 1, ...at(three) });
out.staleOrigin = rail.querySelectorAll('.tab-layout-dragging').length;
out.firstListenerKept = w.__app._tabLayoutDragKeydown === firstListener;
fire(one, 'pointermove', { buttons: 1, ...at(one) });
fire(one, 'pointerup', { button: 0, buttons: 0, ...at(one) });
out.leftover = w.__app._tabLayoutDragKeydown;
return out;
});
expect(result.afterButtonsUp).toBeNull();
expect(result.dragClass).toBe(0);
expect(result.firstActive).toBe(true);
expect(result.staleOrigin).toBe(0);
expect(result.firstListenerKept).toBe(false);
expect(result.leftover).toBeNull();
await page.locator('#term').focus();
await page.keyboard.press('Escape');
expect(await page.evaluate(() => (window as any).__termEscapes)).toBe(1);
await settled();
});
it('committing a group rename by clicking elsewhere leaves focus where the click put it', async () => {
await page.evaluate(() => (window as any).__app.startTabGroupRename('gy'));
const input = page.locator('.tab-layout-group-rename-input');
await expect.poll(() => input.evaluate((el) => el === document.activeElement)).toBe(true);
await page.keyboard.press('Control+A');
await page.keyboard.type('Elsewhere');
await page.locator('#term').click();
await settled();
expect(puts.at(-1).layout.groups[1].name).toBe('Elsewhere');
expect(await page.evaluate(() => document.activeElement?.id)).toBe('term');
// So Enter goes to the terminal, not to the header (which would collapse it).
await page.keyboard.press('Enter');
expect(await page.evaluate(() => (window as any).__app.collapsedTabGroupIds.size)).toBe(0);
});
it('paints the inline group editor as you type, then saves the trimmed name', async () => {
await page.evaluate(() => (window as any).__app.startTabGroupRename('gx'));
const input = page.locator('.tab-layout-group-rename-input');
await expect.poll(() => input.evaluate((el) => el === document.activeElement)).toBe(true);
await page.keyboard.press('Control+A');
await page.keyboard.type('Front end');
const paint = await input.evaluate((el: HTMLInputElement) => ({
value: el.value,
width: el.getBoundingClientRect().width,
label: getComputedStyle(el.parentElement!).display,
scrollWidth: el.scrollWidth,
}));
expect(paint.value).toBe('Front end');
// The editor gets the header's free width, not a collapsed ellipsis slot.
expect(paint.width).toBeGreaterThan(120);
expect(paint.label).toBe('flex');
await page.keyboard.press('Enter');
await settled();
expect(puts.at(-1).layout.groups[0].name).toBe('Front end');
expect(await page.locator('[data-tab-group-header="gx"] .tab-layout-group-name').textContent()).toBe('Front end');
expect(await page.evaluate(() => (document.activeElement as HTMLElement)?.dataset?.tabGroupHeader)).toBe('gx');
});
it('opens the group menu from the header glyph and Escape closes only it', async () => {
await page.locator('[data-tab-group-header="gy"]').hover();
await page.locator('[data-tab-group-header="gy"] .tab-layout-group-menu').click();
expect(await page.locator('.tab-layout-group-action-menu').count()).toBe(1);
// The glyph opened the menu without toggling the group.
expect(await page.evaluate(() => (window as any).__app.collapsedTabGroupIds.size)).toBe(0);
await page.keyboard.press('Escape');
expect(await page.locator('.tab-layout-group-action-menu').count()).toBe(0);
expect(await page.evaluate(() => (document.activeElement as HTMLElement)?.dataset?.tabGroupHeader)).toBe('gy');
await page.locator('[data-tab-group-header="gy"]').click({ button: 'right' });
expect(await page.locator('.tab-layout-group-action-menu').count()).toBe(1);
await page.locator('#elsewhere').click();
expect(await page.locator('.tab-layout-group-action-menu').count()).toBe(0);
});
});
File diff suppressed because it is too large Load Diff
+7
View File
@@ -727,10 +727,17 @@ describe('grouped rail tree semantics', () => {
row('s2').focus();
press('F10', { shiftKey: true });
expect(app.openTabRailActionMenu).toHaveBeenCalledWith(expect.objectContaining({ currentTarget: row('s2') }), 's2');
// A web tab's keys open its menu (settings + group moves); settings is one item.
row('w1').focus();
press('ContextMenu');
const settings = [...document.querySelectorAll<HTMLElement>('.tab-layout-group-action-menu button')].find(
(button) => button.textContent === 'Web tab settings'
)!;
settings.click();
expect(app.showWebviewModal).toHaveBeenCalledWith('w1');
row('w1').focus();
press('F10');
expect(document.querySelector('.tab-layout-group-action-menu')).toBeNull();
expect(app.showWebviewModal).toHaveBeenCalledTimes(1);
});
+135
View File
@@ -0,0 +1,135 @@
/**
* @fileoverview Static guard: the inline rename editor is never line-clamped.
*
* A vertical rail or sidebar row clamps its name (2 lines, 3 in the detailed
* rail), and one shared rule unclamps `.tab-name.tab-name-renaming` so the
* editor can lay out as a flex row. A clamping rule MORE specific than that
* shared rule wins over it, which is how the detailed rail shipped with its
* 3-line clamp around the editor (#534, fixed alongside #526). The behavioural
* check lives in test/inline-rename.test.ts, a browser suite the CI gate does
* not run, so this pins the cascade from the stylesheet itself: every rule that
* clamps a rail or sidebar `.tab-name` must be out-ranked by an unclamp rule,
* either the shared one or its own `.tab-name-renaming` twin.
*/
import { readFileSync } from 'node:fs';
import postcss, { type Rule } from 'postcss';
import { describe, expect, it } from 'vitest';
const STYLES_CSS = readFileSync(new URL('../src/web/public/styles.css', import.meta.url), 'utf-8');
type Specificity = [number, number, number];
function compare(a: Specificity, b: Specificity): number {
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] - b[i];
return 0;
}
const IDENT = /^-?(?:[\w-]|\\.)+/;
function closingParen(selector: string, open: number): number {
let depth = 0;
for (let i = open; i < selector.length; i++) {
if (selector[i] === '(') depth++;
else if (selector[i] === ')' && --depth === 0) return i;
}
return selector.length - 1;
}
/**
* Specificity of one complex selector, with :is()/:not()/:has() taking their
* most specific argument and :where() counting nothing. Hand-rolled because
* postcss (a dependency) splits selector lists but does not parse selectors;
* checked against postcss-selector-parser over every selector in styles.css.
*/
function specificityOf(selector: string): Specificity {
const total: Specificity = [0, 0, 0];
let i = 0;
while (i < selector.length) {
const ch = selector[i];
if (ch === '[') {
total[1]++;
i = selector.indexOf(']', i) + 1;
} else if (ch === '#' || ch === '.') {
total[ch === '#' ? 0 : 1]++;
i += 1 + (selector.slice(i + 1).match(IDENT)?.[0].length ?? 0);
} else if (ch === ':') {
const element = selector[i + 1] === ':';
const start = i + (element ? 2 : 1);
const name = selector.slice(start).match(IDENT)?.[0] ?? '';
i = start + name.length;
let args: string | null = null;
if (selector[i] === '(') {
const end = closingParen(selector, i);
args = selector.slice(i + 1, end);
i = end + 1;
}
if (element) total[2]++;
else if (name === 'where') continue;
else if (['is', 'not', 'has'].includes(name) && args !== null) {
const max = postcss.list
.comma(args)
.map(specificityOf)
.sort((a, b) => compare(b, a))[0] ?? [0, 0, 0];
for (let k = 0; k < 3; k++) total[k] += max[k];
} else total[1]++;
} else if (/[A-Za-z_]/.test(ch)) {
total[2]++;
i += selector.slice(i).match(IDENT)?.[0].length ?? 1;
} else i++;
}
return total;
}
type Entry = { selector: string; specificity: Specificity; order: number; rule: Rule };
/** Every complex selector in the stylesheet, flattened, with its source order. */
function entries(): Entry[] {
const out: Entry[] = [];
let order = 0;
postcss.parse(STYLES_CSS).walkRules((rule) => {
order++;
for (const selector of rule.selectors) {
out.push({ selector: selector.replace(/\s+/g, ' ').trim(), specificity: specificityOf(selector), order, rule });
}
});
return out;
}
function declares(rule: Rule, prop: string): string | null {
let value: string | null = null;
rule.walkDecls(prop, (decl) => {
value = decl.value.trim();
});
return value;
}
const unclamps = (rule: Rule) =>
['-webkit-line-clamp', 'line-clamp'].every((prop) => ['unset', 'none'].includes(declares(rule, prop) ?? ''));
describe('inline rename editor is never line-clamped', () => {
const all = entries();
const clamping = all.filter(
(e) =>
/\.tab-name$/.test(e.selector) &&
/\.tab-rail|\.session-sidebar/.test(e.selector) &&
/^\d+$/.test(declares(e.rule, '-webkit-line-clamp') ?? '')
);
const shared = all.filter((e) => e.selector.startsWith(':is(') && e.selector.endsWith('.tab-name.tab-name-renaming'));
it('finds the clamped rail rows and the shared unclamp rule', () => {
// The base rail row and the detailed rail card both clamp; if this drops to
// zero the selectors moved and the guard below is checking nothing.
expect(clamping.length).toBeGreaterThanOrEqual(2);
expect(shared).toHaveLength(1);
expect(unclamps(shared[0].rule)).toBe(true);
});
it.each(clamping.map((e) => [e.selector, e] as const))('%s is out-ranked while renaming', (_selector, clamp) => {
const twin = all.filter((e) => e.selector === `${clamp.selector}.tab-name-renaming` && unclamps(e.rule));
const winners = [...shared, ...twin].filter((u) => {
const byWeight = compare(u.specificity, clamp.specificity);
return byWeight > 0 || (byWeight === 0 && u.order > clamp.order);
});
expect(winners.map((w) => w.selector)).not.toEqual([]);
});
});
@@ -0,0 +1,181 @@
/**
* @fileoverview How the workspace hook and statusLine helpers in hooks-config.ts
* read an "unknown" answer from the bounded path probe. A dead network mount
* elsewhere on the machine (enough of them to engage the probe's stall cap) must
* not stop Codeman's hooks from being installed in a healthy workspace, and must
* not let the plan-usage exporter be injected over a user's own statusLine. A
* workspace that IS on the dead mount is skipped without hanging the caller.
*
* Real temp directories; only `stat()` of the chosen dead paths is made to hang.
* Port: none.
*/
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const probe = vi.hoisted(() => {
// Short probe timeout so the stalls below cost ~100 ms each, read at import.
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '100';
return { dead: new Set<string>(), releases: [] as Array<() => void> };
});
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
/** A call on a dead path never settles (a hard mount), until afterEach releases it. */
const hangOnDead = <F extends (...a: never[]) => unknown>(real: F): F =>
((path: string, ...rest: unknown[]) => {
for (const dead of probe.dead) {
if (String(path) === dead || String(path).startsWith(dead + '/')) {
return new Promise((resolve, reject) => {
probe.releases.push(() => reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })));
void resolve;
});
}
}
return (real as unknown as (...a: unknown[]) => unknown)(path, ...rest);
}) as unknown as F;
const hung = {
stat: hangOnDead(actual.stat),
lstat: hangOnDead(actual.lstat),
readFile: hangOnDead(actual.readFile),
realpath: hangOnDead(actual.realpath),
};
return { ...actual, ...hung, default: { ...actual, ...hung } };
});
import { applyWorkspaceHooks, resolveStatusLineCliCommand, stripCaseEnvKeys } from '../src/hooks-config.js';
import { probePath } from '../src/utils/index.js';
import { MAX_STALLED_PATH_PROBES } from '../src/config/path-probe.js';
const root = mkdtempSync(join(tmpdir(), 'codeman-unreachable-mount-'));
/** Stall `count` paths on unrelated "mounts" until afterEach releases them. */
async function stallUnrelatedMounts(count: number): Promise<void> {
const paths = Array.from({ length: count }, (_, i) => `/mnt/dead-nas-${i}/project`);
paths.forEach((p) => probe.dead.add(p));
expect(await Promise.all(paths.map((p) => probePath(p)))).toEqual(paths.map(() => 'unknown'));
}
let warn: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(async () => {
probe.dead.clear();
probe.releases.splice(0).forEach((release) => release());
await new Promise((r) => setTimeout(r, 0));
warn.mockRestore();
});
afterAll(() => {
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
});
describe('workspace helpers while other mounts are unreachable', () => {
it('installs hooks in a healthy workspace while the stall cap is engaged', async () => {
await stallUnrelatedMounts(MAX_STALLED_PATH_PROBES);
const workspace = join(root, 'healthy-a');
mkdirSync(workspace);
await applyWorkspaceHooks(workspace, true);
const settings = join(workspace, '.claude', 'settings.local.json');
expect(existsSync(settings)).toBe(true);
expect(readFileSync(settings, 'utf-8')).toContain('/api/hook-event');
});
it('keeps a deleted workspace deleted while the stall cap is engaged', async () => {
// The cap refuses the probe ("unknown" without a stat), which must not read as
// "go ahead": installing would mkdir -p the deleted repo back into existence.
await stallUnrelatedMounts(MAX_STALLED_PATH_PROBES);
const workspace = join(root, 'deleted-repo');
expect(await probePath(workspace)).toBe('unknown');
await applyWorkspaceHooks(workspace, true);
await applyWorkspaceHooks(workspace, false);
expect(existsSync(workspace)).toBe(false);
});
it('installs hooks in a healthy workspace while fewer unrelated paths are stalled than the cap', async () => {
await stallUnrelatedMounts(MAX_STALLED_PATH_PROBES - 1);
const workspace = join(root, 'healthy-b');
mkdirSync(workspace);
await applyWorkspaceHooks(workspace, true);
expect(existsSync(join(workspace, '.claude', 'settings.local.json'))).toBe(true);
});
it('skips, without hanging, a workspace that sits on the dead mount', async () => {
const workspace = '/mnt/dead-nas-x/project';
probe.dead.add('/mnt/dead-nas-x');
expect(await probePath(workspace)).toBe('unknown');
const started = Date.now();
await applyWorkspaceHooks(join('/mnt/dead-nas-x', 'project'), true);
expect(Date.now() - started).toBeLessThan(1_000);
expect(
warn.mock.calls.some(
(c: unknown[]) => /hooks/i.test(String(c[0])) && String(c[0]).includes('/mnt/dead-nas-x/project')
)
).toBe(true);
});
it('removes a superseded env key from a healthy workspace while the stall cap is engaged', async () => {
await stallUnrelatedMounts(MAX_STALLED_PATH_PROBES);
const workspace = join(root, 'strip-env');
mkdirSync(join(workspace, '.claude'), { recursive: true });
const settings = join(workspace, '.claude', 'settings.local.json');
writeFileSync(settings, JSON.stringify({ env: { CLAUDE_CODE_STALE: '1', USER_KEEP: '2' } }));
await stripCaseEnvKeys(workspace, ['CLAUDE_CODE_STALE']);
expect(JSON.parse(readFileSync(settings, 'utf-8')).env).toEqual({ USER_KEEP: '2' });
});
it("never injects the exporter over a user's own statusLine while the cap is engaged", async () => {
await stallUnrelatedMounts(MAX_STALLED_PATH_PROBES);
const workspace = join(root, 'own-statusline');
mkdirSync(join(workspace, '.claude'), { recursive: true });
writeFileSync(
join(workspace, '.claude', 'settings.local.json'),
JSON.stringify({ statusLine: { type: 'command', command: 'my-own-statusline' } })
);
expect(await resolveStatusLineCliCommand(workspace, true)).toBeUndefined();
});
it('still injects the exporter in a healthy workspace without one while the cap is engaged', async () => {
await stallUnrelatedMounts(MAX_STALLED_PATH_PROBES);
const workspace = join(root, 'no-statusline');
mkdirSync(workspace);
expect(await resolveStatusLineCliCommand(workspace, true)).toMatch(/statusline-exporter\.sh$/);
});
it('skips, within the probe timeout, dead workspaces whose probes the bulk cap refused', async () => {
// Two unrelated stalls engage the bulk cap, so probes of these two paths are
// refused without a stat. Their lstat and readFile hang too: a helper that
// touched them directly would never return and would hold a threadpool worker.
await stallUnrelatedMounts(MAX_STALLED_PATH_PROBES);
probe.dead.add('/mnt/dead-nas-c');
probe.dead.add('/mnt/dead-nas-d');
const within = <T>(work: Promise<T>) =>
Promise.race([work, new Promise<'hung'>((resolve) => setTimeout(() => resolve('hung'), 2_000))]);
expect(await within(applyWorkspaceHooks('/mnt/dead-nas-c/project', true))).toBeUndefined();
expect(await within(resolveStatusLineCliCommand('/mnt/dead-nas-d/project', true))).toBeUndefined();
expect(existsSync('/mnt/dead-nas-c/project')).toBe(false);
});
it('does not inject the exporter into a workspace on the dead mount', async () => {
probe.dead.add('/mnt/dead-nas-y');
expect(await probePath('/mnt/dead-nas-y/project')).toBe('unknown');
expect(await resolveStatusLineCliCommand('/mnt/dead-nas-y/project', true)).toBeUndefined();
});
});