Files
Codeman/test/pane-cwd-tracking.test.ts
T
Michael GrundbergandClaude Opus 5.5 e2359aa0e8 test(files): cover the review's two remaining outside-worktree cases
The symlinked .git folder test now also runs from a session in the main
checkout, as well as from a linked worktree. A new pane-watcher test
checks that an unparseable docker-cases.json follows nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 21:13:45 +01:00

871 lines
37 KiB
TypeScript

/**
* @fileoverview A relative path printed in the terminal resolves against the
* directory the pane is actually in, not only the session's `workingDir`.
*
* `workingDir` is recorded once at create time and never follows a `cd`, so in
* a shell session that moved to a subdirectory or a sibling repo every relative
* path resolved against the wrong root, and the preview reported "File not
* found" for a file sitting right there.
*
* Pinned here:
* 1. Parsing: the pane-exit watcher's batched `list-panes -a` read already
* carries `#{pane_current_path}`; `derivePaneCurrentPaths()` turns it into
* one directory per session (single-pane sessions only, absolute paths
* only).
* 2. The manager keeps the last authoritative answer, so a failed read or a
* blank field never blanks a known directory, while a read that lists the
* session as split, or not at all, clears it.
* 3. Scoping: remote and Docker sessions never carry one (their local pane
* runs the ssh / docker-exec CLIENT), and neither does a direct-PTY or a
* rediscovered session.
* 4. Wiring: the reading reaches `toState()` through the existing
* `paneExitsUpdated` event, with no new tmux call and no new SSE event.
* 5. Resolution order in the browser: live cwd, then `workingDir`. The
* resolved path still goes through the same preview routing, so a target
* outside the workspace takes the attachment route like any other.
* 6. Spelling: tmux reports the RESOLVED directory, so a workspace reached
* through a symlink has its reading written back in `workingDir`'s
* spelling, or every relative link would route as out-of-workspace.
*
* Port: ephemeral
*/
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import vm from 'node:vm';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Session, rebasePath } from '../src/session.js';
import { WebServer } from '../src/web/server.js';
import { dataPath } from '../src/config/instance.js';
import { createUser } from '../src/user-store.js';
import type { SessionDocker, SessionRemote } from '../src/types.js';
import type { MuxSession, TerminalMultiplexer } from '../src/mux-interface.js';
import { TmuxManager, derivePaneCurrentPaths, parsePaneRows, type PaneRow } from '../src/tmux-manager.js';
const remote: SessionRemote = { hostId: 'h1', label: 'box', host: 'box', user: 'dev' } as SessionRemote;
const docker: SessionDocker = { hostId: 'd1', label: 'ctr', containerName: 'ctr' } as SessionDocker;
describe('derivePaneCurrentPaths', () => {
it("maps each single-pane session to its pane's directory", () => {
const rows = parsePaneRows(['codeman-a|100|0|||/work/proj/sub', 'codeman-b|200|0|||/srv/app'].join('\n'));
const paths = derivePaneCurrentPaths(rows);
expect(paths.get('codeman-a')).toBe('/work/proj/sub');
expect(paths.get('codeman-b')).toBe('/srv/app');
expect(paths.size).toBe(2);
});
it('keeps a separator that belongs to the directory name', () => {
const paths = derivePaneCurrentPaths(parsePaneRows('codeman-a|100|0|||/home/u/weird|dir/sub'));
expect(paths.get('codeman-a')).toBe('/home/u/weird|dir/sub');
});
it('answers nothing for a session split into several panes', () => {
// Codeman creates one pane per session; which of a hand-split session's
// panes "the" directory belongs to is a guess, so none is made.
const rows = parsePaneRows(['codeman-a|100|0|||/one', 'codeman-a|101|0|||/two'].join('\n'));
expect(derivePaneCurrentPaths(rows).has('codeman-a')).toBe(false);
});
it('drops a missing or relative directory', () => {
const rows = parsePaneRows(['codeman-a|100|0|||', 'codeman-b|200|0|||rel/ative'].join('\n'));
expect(derivePaneCurrentPaths(rows).size).toBe(0);
});
it('carries the previous directory forward for a blank field on a single-pane session', () => {
// A blank `#{pane_current_path}` is a tmux that could not read the process
// cwd that tick, not a pane that has no directory.
const previous = new Map([['codeman-a', '/work/proj/sub']]);
const paths = derivePaneCurrentPaths(parsePaneRows('codeman-a|100|0|||'), previous);
expect(paths.get('codeman-a')).toBe('/work/proj/sub');
});
it('carries nothing forward for a split session, an unlisted one or a relative field', () => {
// Those are authoritative absences: the read answered, and its answer is
// "no single directory".
const previous = new Map([
['codeman-split', '/one'],
['codeman-gone', '/two'],
['codeman-rel', '/three'],
]);
const rows = parsePaneRows(
['codeman-split|100|0|||', 'codeman-split|101|0|||', 'codeman-rel|300|0|||rel/ative'].join('\n')
);
expect(derivePaneCurrentPaths(rows, previous).size).toBe(0);
});
});
describe('TmuxManager pane directory bookkeeping', () => {
class TestManager extends TmuxManager {
rows: PaneRow[] = [];
protected override async readPaneRows(): Promise<PaneRow[]> {
return this.rows;
}
}
const localSession = (sessionId: string): MuxSession =>
({
sessionId,
muxName: `codeman-${sessionId}`,
pid: 100,
createdAt: 0,
workingDir: '/work/proj',
mode: 'shell',
attached: true,
}) as MuxSession;
it('records the directory from the same read the pane-exit watcher makes', async () => {
const manager = new TestManager();
manager.registerSession(localSession('s1'));
manager.rows = parsePaneRows('codeman-s1|100|0|||/work/proj/sub');
await manager.refreshPaneExits();
expect(manager.getPaneCurrentPath('codeman-s1')).toBe('/work/proj/sub');
});
it('keeps the last known directory when a read comes back empty', async () => {
const manager = new TestManager();
manager.registerSession(localSession('s1'));
manager.rows = parsePaneRows('codeman-s1|100|0|||/work/proj/sub');
await manager.refreshPaneExits();
manager.rows = [];
await manager.refreshPaneExits();
expect(manager.getPaneCurrentPath('codeman-s1')).toBe('/work/proj/sub');
});
it('keeps the last directory across a blank field on a single-pane read', async () => {
const manager = new TestManager();
manager.registerSession(localSession('s1'));
manager.rows = parsePaneRows('codeman-s1|100|0|||/work/proj/sub');
await manager.refreshPaneExits();
manager.rows = parsePaneRows('codeman-s1|100|0|||');
await manager.refreshPaneExits();
expect(manager.getPaneCurrentPath('codeman-s1')).toBe('/work/proj/sub');
});
it('forgets the directory once the session is split into several panes', async () => {
const manager = new TestManager();
manager.registerSession(localSession('s1'));
manager.rows = parsePaneRows('codeman-s1|100|0|||/work/proj/sub');
await manager.refreshPaneExits();
manager.rows = parsePaneRows(['codeman-s1|100|0|||/work/proj/sub', 'codeman-s1|101|0|||/work/proj'].join('\n'));
await manager.refreshPaneExits();
expect(manager.getPaneCurrentPath('codeman-s1')).toBeUndefined();
});
it('records the directory even when a respawn races the read', async () => {
// A respawn bumps the generation and discards the read's EXIT observation;
// the directory the pane was in at read time is still the best answer.
class RacingManager extends TestManager {
protected override async readPaneRows(): Promise<PaneRow[]> {
this.clearPaneExit('codeman-s1');
return this.rows;
}
}
const manager = new RacingManager();
manager.registerSession(localSession('s1'));
manager.rows = parsePaneRows('codeman-s1|100|1|0||/work/proj/sub');
await manager.refreshPaneExits();
expect(manager.getPaneCurrentPath('codeman-s1')).toBe('/work/proj/sub');
expect(manager.getPaneExit('codeman-s1')).toBeUndefined();
});
it('makes no read, and keeps no directory, when only remote or docker sessions exist', async () => {
const manager = new TestManager();
const read = vi.spyOn(manager as unknown as { readPaneRows: () => Promise<PaneRow[]> }, 'readPaneRows');
manager.registerSession({ ...localSession('s1'), remote } as MuxSession);
manager.registerSession({ ...localSession('s2'), docker } as MuxSession);
manager.rows = parsePaneRows('codeman-s1|100|0|||/tmp');
await manager.refreshPaneExits();
expect(read).not.toHaveBeenCalled();
expect(manager.getPaneCurrentPath('codeman-s1')).toBeUndefined();
});
it('forgets a session that an authoritative read no longer lists', async () => {
const manager = new TestManager();
manager.registerSession(localSession('s1'));
manager.rows = parsePaneRows('codeman-s1|100|0|||/work/proj/sub');
await manager.refreshPaneExits();
manager.rows = parsePaneRows('codeman-other|300|0|||/elsewhere');
await manager.refreshPaneExits();
expect(manager.getPaneCurrentPath('codeman-s1')).toBeUndefined();
});
});
const stubMux = () => ({ isAvailable: () => true }) as unknown as TerminalMultiplexer;
const stubMuxSession = (muxName = 'codeman-aaaa') => ({ muxName, sessionId: 'aaaa' }) as unknown as MuxSession;
function localMuxSession(extra: Record<string, unknown> = {}) {
return new Session({
workingDir: '/work/proj',
mode: 'shell',
useMux: true,
mux: stubMux(),
muxSession: stubMuxSession(),
...extra,
});
}
describe('Session.setPaneCwd scoping', () => {
it('records an absolute directory for a local pane and reports the change once', () => {
const session = localMuxSession();
expect(session.setPaneCwd('/work/proj/sub')).toBe(true);
expect(session.paneCwd).toBe('/work/proj/sub');
expect(session.setPaneCwd('/work/proj/sub')).toBe(false);
expect(session.toState().paneCwd).toBe('/work/proj/sub');
});
it('clears the directory when the reading goes away', () => {
const session = localMuxSession();
session.setPaneCwd('/work/proj/sub');
expect(session.setPaneCwd(undefined)).toBe(true);
expect(session.paneCwd).toBeUndefined();
});
it('refuses a relative directory', () => {
const session = localMuxSession();
expect(session.setPaneCwd('rel/ative')).toBe(false);
expect(session.paneCwd).toBeUndefined();
});
it('refuses a directory for a remote session, whose local pane runs the ssh client', () => {
const session = localMuxSession({ remote });
expect(session.setPaneCwd('/home/dev')).toBe(false);
expect(session.toState().paneCwd).toBeUndefined();
});
it('refuses a directory for a docker case, whose local pane runs docker exec', () => {
const session = localMuxSession({ docker });
expect(session.setPaneCwd('/work/proj')).toBe(false);
expect(session.toState().paneCwd).toBeUndefined();
});
it('refuses a directory for a direct-PTY session and a rediscovered one', () => {
const direct = new Session({ workingDir: '/work/proj', mode: 'shell', useMux: false });
expect(direct.setPaneCwd('/work/proj/sub')).toBe(false);
const discovered = localMuxSession({ discoveredMuxSession: true });
expect(discovered.setPaneCwd('/work/proj/sub')).toBe(false);
});
});
describe('a pane read reaching the session record', () => {
let server: WebServer | null = null;
afterEach(async () => {
await server?.stop?.();
server = null;
});
const build = () => {
const web = new WebServer(0, false, true);
server = web;
const mux = (web as unknown as { mux: Record<string, unknown> & { emit: (e: string) => void } }).mux;
const sessions = (web as unknown as { sessions: Map<string, Session> }).sessions;
return { mux, sessions, web };
};
it("publishes the pane's directory on the existing paneExitsUpdated tick", () => {
const { mux, sessions, web } = build();
const session = localMuxSession();
sessions.set(session.id, session);
mux.getPaneExit = () => undefined;
mux.getPaneCurrentPath = (muxName: string) => (muxName === 'codeman-aaaa' ? '/work/proj/sub' : undefined);
mux.emit('paneExitsUpdated');
expect(session.toState().paneCwd).toBe('/work/proj/sub');
// What the browser actually receives: a future explicit field list must not drop it.
const state = (
web as unknown as { getSessionStateWithRespawn: (s: Session) => { paneCwd?: string } }
).getSessionStateWithRespawn(session);
expect(state.paneCwd).toBe('/work/proj/sub');
// A resolution base only: the confinement root is untouched.
expect(session.toState().workingDir).toBe('/work/proj');
});
describe('liveWorktree (#765)', () => {
let base: string;
let repo: string;
let worktree: string;
beforeEach(() => {
base = mkdtempSync(join(tmpdir(), 'pane-live-worktree-'));
repo = join(base, 'repo');
worktree = join(repo, '.claude', 'worktrees', 'probe');
mkdirSync(worktree, { recursive: true });
writeFileSync(join(worktree, '.git'), `gitdir: ${repo}/.git/worktrees/probe\n`);
});
afterEach(() => {
rmSync(base, { recursive: true, force: true });
});
const inRepo = () => {
const { mux, sessions, web } = build();
const session = localMuxSession({ workingDir: repo });
sessions.set(session.id, session);
const broadcast = vi.fn();
(web as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced =
broadcast;
mux.getPaneExit = () => undefined;
return { mux, session, web, broadcast };
};
it('publishes the worktree the pane moved into, and clears it when the pane leaves', async () => {
const { mux, session, web, broadcast } = inRepo();
let dir = worktree;
mux.getPaneCurrentPath = () => dir;
mux.emit('paneExitsUpdated');
await vi.waitFor(() => expect(session.liveWorktree).toEqual({ name: 'probe', path: worktree }));
// What the browser receives, and what the file routes read: never the confinement root.
const state = (
web as unknown as {
getSessionStateWithRespawn: (s: Session) => { liveWorktree?: unknown; outsideWorktree?: unknown };
}
).getSessionStateWithRespawn(session);
expect(state.liveWorktree).toEqual({ name: 'probe', path: worktree });
expect(state.outsideWorktree).toBeUndefined();
expect(session.toState().workingDir).toBe(repo);
const broadcasts = broadcast.mock.calls.length;
dir = repo;
mux.emit('paneExitsUpdated');
await vi.waitFor(() => expect(session.liveWorktree).toBeUndefined());
expect(broadcast.mock.calls.length).toBeGreaterThan(broadcasts);
});
it('does nothing for a pane that has not moved', async () => {
const { mux, session } = inRepo();
mux.getPaneCurrentPath = () => repo;
const lookup = vi.spyOn(session, 'setLiveWorktree');
mux.emit('paneExitsUpdated');
await vi.waitFor(() => expect(lookup).toHaveBeenCalledTimes(1));
mux.emit('paneExitsUpdated');
await new Promise((resolve) => setTimeout(resolve, 20));
expect(lookup).toHaveBeenCalledTimes(1);
});
it('drops an answer for a directory the pane has already left', async () => {
const { web, session } = inRepo();
session.setPaneCwd(worktree);
const refresh = (
web as unknown as { refreshLiveWorktree: (s: Session) => Promise<void> }
).refreshLiveWorktree.call(web, session);
session.setPaneCwd(repo);
await refresh;
expect(session.liveWorktree).toBeUndefined();
});
});
describe('liveWorktree outside the session folder (#765 follow-up)', () => {
let base: string;
let repo: string;
let sibling: string;
const git = (cwd: string, ...args: string[]): void => {
execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { cwd, stdio: 'ignore' });
};
beforeEach(() => {
base = realpathSync(mkdtempSync(join(tmpdir(), 'pane-outside-worktree-')));
repo = join(base, 'myapp');
mkdirSync(repo);
git(repo, 'init', '-q');
writeFileSync(join(repo, 'README.md'), 'main');
git(repo, 'add', 'README.md');
git(repo, 'commit', '-q', '-m', 'init');
sibling = join(base, 'myapp-feature');
git(repo, 'worktree', 'add', '-q', sibling);
});
afterEach(() => {
rmSync(base, { recursive: true, force: true });
});
it('publishes a sibling worktree of the session repository as outside, and clears it on the way back', async () => {
const { mux, sessions, web } = build();
const session = localMuxSession({ workingDir: repo });
sessions.set(session.id, session);
(web as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced =
vi.fn();
mux.getPaneExit = () => undefined;
let dir = sibling;
mux.getPaneCurrentPath = () => dir;
// A reading outside `workingDir` waits until the workspace's real path is known, so tick
// until it lands.
await vi.waitFor(() => {
mux.emit('paneExitsUpdated');
expect(session.liveWorktree).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
});
expect(session.toState().workingDir).toBe(repo);
// On the wire an outside answer rides `outsideWorktree`; `liveWorktree` stays inside-only.
const state = session.toState();
expect(state.outsideWorktree).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
expect(state.liveWorktree).toBeUndefined();
dir = repo;
mux.emit('paneExitsUpdated');
await vi.waitFor(() => expect(session.liveWorktree).toBeUndefined());
});
/**
* A session in `repo` whose pane sits in the sibling worktree, ticked until the lookup has
* answered. Returns the answer, so a test can tell "followed" from "refused".
*/
const paneInSibling = async (sessionExtra: Record<string, unknown> = {}) => {
const { mux, sessions, web } = build();
const session = localMuxSession({ workingDir: repo, ...sessionExtra });
sessions.set(session.id, session);
(web as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced =
vi.fn();
mux.getPaneExit = () => undefined;
mux.getPaneCurrentPath = () => sibling;
const lookup = vi.spyOn(session, 'setLiveWorktree');
// A reading outside `workingDir` waits until the workspace's real path is known, so tick
// until a lookup has run.
await vi.waitFor(() => {
mux.emit('paneExitsUpdated');
expect(lookup).toHaveBeenCalled();
});
// The lookup answers asynchronously; let the answer land before it is read.
await new Promise((resolve) => setTimeout(resolve, 50));
return session.liveWorktree;
};
/** Sets env vars for one test and restores them afterwards. */
const withEnv = async (env: Record<string, string>, run: () => Promise<void>): Promise<void> => {
const before = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]]));
Object.assign(process.env, env);
try {
await run();
} finally {
for (const [key, value] of Object.entries(before)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
};
it('follows nothing while attachment workspace confinement is on', async () => {
await withEnv({ CODEMAN_ATTACHMENT_CONFINE: '1' }, async () => {
expect(await paneInSibling()).toBeUndefined();
});
expect(await paneInSibling()).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
});
it('follows nothing at a worktree that is itself a sensitive folder such as .ssh', async () => {
const keys = join(base, 'keys');
mkdirSync(keys);
const ssh = join(keys, '.ssh');
git(repo, 'worktree', 'add', '-q', '-b', 'keys-branch', ssh);
sibling = ssh;
expect(await paneInSibling()).toBeUndefined();
});
it('follows nothing at, inside or around an attachment blocked tree', async () => {
// Around the worktree: its parent folder is blocked.
await withEnv({ CODEMAN_ATTACHMENT_BLOCKED_PATHS: base }, async () => {
expect(await paneInSibling()).toBeUndefined();
});
// Inside the worktree: a blocked tree below its root.
mkdirSync(join(sibling, 'private'));
await withEnv({ CODEMAN_ATTACHMENT_BLOCKED_PATHS: join(sibling, 'private') }, async () => {
expect(await paneInSibling()).toBeUndefined();
});
// A blocked tree elsewhere changes nothing.
await withEnv({ CODEMAN_ATTACHMENT_BLOCKED_PATHS: join(base, 'elsewhere') }, async () => {
expect(await paneInSibling()).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
});
});
it('follows nothing in a Docker case workspace, around or inside it', async () => {
const casesFile = dataPath('docker-cases.json');
mkdirSync(dirname(casesFile), { recursive: true });
try {
for (const hostWorkspacePath of [sibling, base, join(sibling, 'src')]) {
writeFileSync(casesFile, JSON.stringify([{ hostWorkspacePath }]));
expect(await paneInSibling()).toBeUndefined();
}
writeFileSync(casesFile, JSON.stringify([{ hostWorkspacePath: join(base, 'elsewhere') }]));
expect(await paneInSibling()).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
} finally {
rmSync(casesFile, { force: true });
}
});
it('follows nothing while docker-cases.json cannot be parsed', async () => {
const casesFile = dataPath('docker-cases.json');
mkdirSync(dirname(casesFile), { recursive: true });
try {
// A read that lands while the writer is rewriting the file.
writeFileSync(casesFile, '[{"hostWorkspacePath": "/hom');
expect(await paneInSibling()).toBeUndefined();
writeFileSync(casesFile, '[]');
expect(await paneInSibling()).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
} finally {
rmSync(casesFile, { force: true });
}
});
it('applies the owner folder rule in multi-user mode', async () => {
const spaces = join(base, 'spaces');
mkdirSync(spaces);
await createUser({ username: 'pane-admin', role: 'admin', password: 'correct horse battery' });
await createUser({ username: 'pane-user', role: 'user', password: 'correct horse battery' });
await withEnv({ CODEMAN_MULTIUSER: '1', CODEMAN_USER_SPACES_DIR: spaces }, async () => {
// A regular user's space does not hold the sibling, and neither does the session folder.
expect(await paneInSibling({ owner: 'pane-user' })).toBeUndefined();
// An admin may follow any worktree the repository lists.
expect(await paneInSibling({ owner: 'pane-admin' })).toEqual({
name: 'myapp-feature',
path: sibling,
outside: true,
});
});
});
});
it('broadcasts a directory change once, and nothing on an unchanged tick', () => {
const { mux, sessions } = build();
const session = localMuxSession();
sessions.set(session.id, session);
const broadcast = vi.fn();
(server as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced =
broadcast;
const persist = vi.fn();
(server as unknown as { persistSessionState: (s: Session) => void }).persistSessionState = persist;
let dir = '/work/proj/sub';
mux.getPaneExit = () => undefined;
mux.getPaneCurrentPath = () => dir;
mux.emit('paneExitsUpdated');
expect(broadcast).toHaveBeenCalledTimes(1);
expect(broadcast).toHaveBeenCalledWith(session.id);
mux.emit('paneExitsUpdated');
expect(broadcast).toHaveBeenCalledTimes(1);
dir = '/work/proj/other';
mux.emit('paneExitsUpdated');
expect(broadcast).toHaveBeenCalledTimes(2);
expect(session.toState().paneCwd).toBe('/work/proj/other');
// Runtime-only: a directory change alone is never written to state.json.
expect(persist).not.toHaveBeenCalled();
});
/**
* Drive the server's REAL TmuxManager: its parsing, its derivation and its
* `getPaneCurrentPath()`. Only the tmux exec seam (`readPaneRows`) is faked,
* with raw `list-panes -a` output, as the bookkeeping tests above do.
*/
const buildWithRealManager = () => {
const { sessions } = build();
const mux = (server as unknown as { mux: TmuxManager }).mux;
expect(mux).toBeInstanceOf(TmuxManager);
let output = '';
(mux as unknown as { readPaneRows: () => Promise<PaneRow[]> }).readPaneRows = async () => parsePaneRows(output);
mux.registerSession({
sessionId: 'aaaa',
muxName: 'codeman-aaaa',
pid: 100,
createdAt: 0,
workingDir: '/work/proj',
mode: 'shell',
attached: true,
} as MuxSession);
const session = localMuxSession();
sessions.set(session.id, session);
const broadcast = vi.fn();
(server as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced =
broadcast;
const tick = async (listPanes: string) => {
output = listPanes;
await mux.refreshPaneExits();
mux.emit('paneExitsUpdated');
};
return { session, broadcast, tick };
};
it('clears the directory once the session is split into several panes', async () => {
const { session, broadcast, tick } = buildWithRealManager();
await tick('codeman-aaaa|100|0|||/work/proj/sub');
expect(session.toState().paneCwd).toBe('/work/proj/sub');
expect(broadcast).toHaveBeenCalledTimes(1);
await tick(['codeman-aaaa|100|0|||/work/proj/sub', 'codeman-aaaa|101|0|||/work/proj'].join('\n'));
// An absent cwd is what makes the browser fall back to workingDir.
expect(session.toState().paneCwd).toBeUndefined();
expect(broadcast).toHaveBeenCalledTimes(2);
// The clear is announced once, not on every later tick that agrees.
await tick(['codeman-aaaa|100|0|||/work/proj/sub', 'codeman-aaaa|101|0|||/work/proj'].join('\n'));
expect(broadcast).toHaveBeenCalledTimes(2);
});
it('clears the directory once a read no longer lists the session', async () => {
const { session, broadcast, tick } = buildWithRealManager();
await tick('codeman-aaaa|100|0|||/work/proj/sub');
expect(session.toState().paneCwd).toBe('/work/proj/sub');
await tick('codeman-other|300|0|||/elsewhere');
expect(session.toState().paneCwd).toBeUndefined();
expect(broadcast).toHaveBeenCalledTimes(2);
await tick('codeman-other|300|0|||/elsewhere');
expect(broadcast).toHaveBeenCalledTimes(2);
});
it('keeps the directory across a blank field and across a failed read', async () => {
const { session, broadcast, tick } = buildWithRealManager();
await tick('codeman-aaaa|100|0|||/work/proj/sub');
// tmux could not read the process cwd for one tick.
await tick('codeman-aaaa|100|0|||');
expect(session.toState().paneCwd).toBe('/work/proj/sub');
// A failed read arrives as no rows at all.
await tick('');
expect(session.toState().paneCwd).toBe('/work/proj/sub');
expect(broadcast).toHaveBeenCalledTimes(1);
});
it('publishes nothing for a docker case', () => {
const { mux, sessions } = build();
const session = localMuxSession({ docker });
sessions.set(session.id, session);
mux.getPaneExit = () => undefined;
mux.getPaneCurrentPath = () => '/work/proj';
mux.emit('paneExitsUpdated');
expect(session.toState().paneCwd).toBeUndefined();
});
it('publishes nothing for a remote session', () => {
const { mux, sessions } = build();
const session = localMuxSession({ remote });
sessions.set(session.id, session);
mux.getPaneExit = () => undefined;
mux.getPaneCurrentPath = () => '/home/dev';
mux.emit('paneExitsUpdated');
expect(session.toState().paneCwd).toBeUndefined();
});
});
describe('browser resolution order: live cwd, then workingDir', () => {
const read = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8');
const loadProto = () => {
const context = vm.createContext({
console,
CodemanApp: class CodemanApp {},
document: { addEventListener: vi.fn() },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
});
vm.runInContext(
`${read('constants.js')}\n${read('terminal-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`,
context
);
return (context as unknown as { __proto: { _resolveLinkedFilePath: (p: string, id: string) => string } }).__proto;
};
const resolveFor = (session: Record<string, unknown>, filePath: string) => {
const proto = loadProto();
return proto._resolveLinkedFilePath.call({ sessions: new Map([['s1', session]]) }, filePath, 's1');
};
it("resolves against the pane's live directory after a cd into a subdirectory", () => {
expect(resolveFor({ workingDir: '/work/proj', paneCwd: '/work/proj/sub' }, 'note.md')).toBe(
'/work/proj/sub/note.md'
);
});
it('resolves outside the workspace when the pane cd-ed to a sibling repo', () => {
// Routing is unchanged: an absolute target outside the workspace is what
// `_isExternalPreviewPath` already sends through the attachment routes.
expect(resolveFor({ workingDir: '/work/proj', paneCwd: '/work/other' }, 'src/a.js')).toBe('/work/other/src/a.js');
});
it('falls back to workingDir when no live directory is known', () => {
expect(resolveFor({ workingDir: '/work/proj' }, 'note.md')).toBe('/work/proj/note.md');
});
it('ignores a live directory on a remote or docker session', () => {
expect(resolveFor({ workingDir: '/work/proj', paneCwd: '/tmp/x', remote: {} }, 'note.md')).toBe(
'/work/proj/note.md'
);
expect(resolveFor({ workingDir: '/work/proj', paneCwd: '/tmp/x', docker: {} }, 'note.md')).toBe(
'/work/proj/note.md'
);
});
it('ignores a relative live directory', () => {
expect(resolveFor({ workingDir: '/work/proj', paneCwd: 'sub' }, 'note.md')).toBe('/work/proj/note.md');
});
it('leaves absolute and home paths alone', () => {
const session = { workingDir: '/work/proj', paneCwd: '/work/proj/sub' };
expect(resolveFor(session, '/etc/hosts.md')).toBe('/etc/hosts.md');
expect(resolveFor(session, '~/x.md')).toBe('~/x.md');
});
});
describe('confinement is unchanged by the live cwd', () => {
// The live cwd only picks the base. Whether the preview reads a path through
// the workspace-confined file routes or registers it through the attachment
// routes is still decided against `workingDir` by `_isExternalPreviewPath`.
const read = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8');
const loadApp = (session: Record<string, unknown>) => {
const context = vm.createContext({
console,
CodemanApp: class CodemanApp {},
document: { addEventListener: vi.fn(), getElementById: () => null },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
});
vm.runInContext(
`${read('constants.js')}\n${read('terminal-ui.js')}\n${read('panels-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`,
context
);
const proto = (
context as unknown as {
__proto: {
_resolveLinkedFilePath: (p: string, id: string) => string;
_isExternalPreviewPath: (p: string, id: string) => boolean;
_isOutsideWorktreePath: (p: string, id: string) => boolean;
};
}
).__proto;
const app = {
sessions: new Map([['s1', session]]),
_isOutsideWorktreePath: proto._isOutsideWorktreePath,
};
return {
target: (p: string) => proto._resolveLinkedFilePath.call(app, p, 's1'),
external: (p: string) => proto._isExternalPreviewPath.call(app, p, 's1'),
};
};
it('keeps a target inside the workspace on the workspace route', () => {
const app = loadApp({ workingDir: '/work/proj', paneCwd: '/work/proj/sub' });
expect(app.external(app.target('note.md'))).toBe(false);
});
it('sends a target outside the workspace through the attachment route', () => {
const app = loadApp({ workingDir: '/work/proj', paneCwd: '/work/other' });
expect(app.target('src/a.js')).toBe('/work/other/src/a.js');
expect(app.external(app.target('src/a.js'))).toBe(true);
});
});
describe('a workspace reached through a symlink', () => {
// tmux reads `/proc/<pgrp>/cwd`, always the resolved path, while `workingDir`
// keeps the spelling the case was created with. Without the rewrite, the
// browser's lexical routing check would call every relative link external.
let root: string;
let physical: string;
let link: string;
const read = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8');
const routingFor = (session: Record<string, unknown>) => {
const context = vm.createContext({
console,
CodemanApp: class CodemanApp {},
document: { addEventListener: vi.fn(), getElementById: () => null },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
});
vm.runInContext(
`${read('constants.js')}\n${read('terminal-ui.js')}\n${read('panels-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`,
context
);
const proto = (
context as unknown as {
__proto: {
_resolveLinkedFilePath: (p: string, id: string) => string;
_isExternalPreviewPath: (p: string, id: string) => boolean;
_isOutsideWorktreePath: (p: string, id: string) => boolean;
};
}
).__proto;
const app = {
sessions: new Map([['s1', session]]),
_isOutsideWorktreePath: proto._isOutsideWorktreePath,
};
return (p: string) => proto._isExternalPreviewPath.call(app, proto._resolveLinkedFilePath.call(app, p, 's1'), 's1');
};
/** Feed a reading until the background resolution has settled and it is taken. */
const settle = async (session: Session, reading: string) => {
await vi.waitFor(() => {
session.setPaneCwd(reading);
expect(session.paneCwd).toBeDefined();
});
};
afterEach(() => rmSync(root, { recursive: true, force: true }));
const setup = () => {
root = realpathSync(mkdtempSync(join(tmpdir(), 'pane-cwd-link-')));
physical = join(root, 'data', 'app');
mkdirSync(join(physical, 'src'), { recursive: true });
mkdirSync(join(root, 'other'), { recursive: true });
link = join(root, 'cases-app');
symlinkSync(physical, link);
};
it('writes a reading of the resolved workspace back in workingDir spelling', async () => {
setup();
const session = localMuxSession({ workingDir: link });
await settle(session, physical);
expect(session.paneCwd).toBe(link);
expect(session.setPaneCwd(join(physical, 'src'))).toBe(true);
expect(session.paneCwd).toBe(join(link, 'src'));
});
it('keeps an in-workspace relative link on the workspace route from the first reading', async () => {
setup();
const session = localMuxSession({ workingDir: link });
await settle(session, physical);
const external = routingFor(session.toState() as unknown as Record<string, unknown>);
expect(external('src/index.ts')).toBe(false);
});
it('still routes a cd outside the workspace through the attachment route', async () => {
setup();
const session = localMuxSession({ workingDir: link });
await settle(session, join(root, 'other'));
expect(session.paneCwd).toBe(join(root, 'other'));
const external = routingFor(session.toState() as unknown as Record<string, unknown>);
expect(external('notes.md')).toBe(true);
});
it('withholds an out-of-workspace reading until the resolution settles', () => {
setup();
const session = localMuxSession({ workingDir: link });
// The first call starts the resolution; a physical reading cannot be told
// apart from a real cd yet, so it is not stored this tick.
expect(session.setPaneCwd(physical)).toBe(false);
expect(session.paneCwd).toBeUndefined();
// A reading lexically inside workingDir needs no translation and is taken.
expect(session.setPaneCwd(join(link, 'src'))).toBe(true);
});
it('leaves the reading alone when workingDir has no symlink in it', async () => {
setup();
const session = localMuxSession({ workingDir: physical });
await settle(session, join(physical, 'src'));
expect(session.paneCwd).toBe(join(physical, 'src'));
});
it('takes readings untranslated once a missing workingDir has settled', async () => {
setup();
const session = localMuxSession({ workingDir: join(root, 'gone') });
await settle(session, join(root, 'other'));
expect(session.paneCwd).toBe(join(root, 'other'));
});
});
describe('rebasePath', () => {
it('rewrites the prefix itself and paths under it, and nothing else', () => {
expect(rebasePath('/mnt/data/app', '/mnt/data/app', '/home/u/app')).toBe('/home/u/app');
expect(rebasePath('/mnt/data/app/src', '/mnt/data/app', '/home/u/app')).toBe('/home/u/app/src');
expect(rebasePath('/mnt/data/application', '/mnt/data/app', '/home/u/app')).toBe('/mnt/data/application');
expect(rebasePath('/mnt/other', '/mnt/data/app', '/home/u/app')).toBe('/mnt/other');
});
});