mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user