Merge origin/master into relprep/pr-753

Conflict in src/session-cli-registry-bridge.ts: kept the PR's gateVersion() (gates measured against the gated CLI's own version) and added master's Discord startup hint engine value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 02:50:25 +01:00
commit 35ff4e886c
138 files changed
+17441 -502

No files matched your search

+2
View File
@@ -49,6 +49,8 @@ function bareServer(live: string[] = [], persisted: string[] = []): CleanupServe
stopTranscriptWatcher: () => {},
persistDeb: { cancelKey: () => {} },
sse: { cleanupSessionBatches: () => {} },
// A real delete also tells the Discord bridge, which frees its per-tab state and may archive the thread.
discordBridge: { sessionDeleted: async () => {}, forgetTab: () => {} },
broadcast: () => {},
});
return server;
+141
View File
@@ -0,0 +1,141 @@
// @vitest-environment node
/**
* @fileoverview `codeman notify`: what it sends, what it prints and the exit code, driven with a
* recording fake transport plus the real `postJson` against a local server. Port: ephemeral 127.0.0.1.
*/
import { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { postJson, runNotify, type JsonPost } from '../src/cli-notify.js';
const ENV = { CODEMAN_SESSION_ID: 'abc12345-full-id', CODEMAN_API_URL: 'http://127.0.0.1:3000' };
function fake(status: number, error?: string, delivery?: { ok: boolean; error?: string }) {
const post = vi.fn<JsonPost>(async () => ({ status, error, delivery }));
return post;
}
describe('runNotify', () => {
it('refuses outside a Codeman session, before any request', async () => {
const post = fake(200);
const r = await runNotify({ message: 'hi' }, {}, '/work', post);
expect(r.code).toBe(1);
expect(r.err).toMatch(/inside a Codeman session/);
expect(post).not.toHaveBeenCalled();
});
it('posts the message to this session with the session id URL-encoded', async () => {
const post = fake(200);
const r = await runNotify({ message: 'press the reset button' }, ENV, '/work', post);
expect(r).toEqual({ code: 0, out: 'Posted to Discord' });
expect(post).toHaveBeenCalledWith('http://127.0.0.1:3000', '/api/sessions/abc12345-full-id/discord-notify', {
message: 'press the reset button',
});
});
it('adds urgent, and turns a relative --file into an absolute path under the working folder', async () => {
const post = fake(200);
await runNotify({ message: 'log', urgent: true, file: 'out/run.log' }, ENV, '/work', post);
expect(post.mock.calls[0][2]).toEqual({ message: 'log', urgent: true, file: '/work/out/run.log' });
await runNotify({ message: 'log', file: '/abs/run.log' }, ENV, '/work', post);
expect(post.mock.calls[1][2]).toEqual({ message: 'log', file: '/abs/run.log' });
});
it('lets --session and --url override the environment', async () => {
const post = fake(200);
await runNotify({ message: 'x', session: 'other', url: 'http://h:1' }, ENV, '/work', post);
expect(post.mock.calls[0].slice(0, 2)).toEqual(['http://h:1', '/api/sessions/other/discord-notify']);
});
it('exits 1 when Codeman answered 200 but Discord refused the post, so a blocked agent knows nobody was told', async () => {
const r = await runNotify(
{ message: 'x' },
ENV,
'/w',
fake(200, undefined, { ok: false, error: 'The bot token was refused' })
);
expect(r).toEqual({ code: 1, err: 'Could not post to Discord: The bot token was refused' });
expect((await runNotify({ message: 'x' }, ENV, '/w', fake(200, undefined, { ok: true }))).code).toBe(0);
});
it('postJson gives up on a server that never answers, with status 0', async () => {
const server = createServer(() => undefined);
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
try {
const { port } = server.address() as AddressInfo;
const r = await postJson(`http://127.0.0.1:${port}`, '/x', {}, 100);
expect(r.status).toBe(0);
} finally {
server.closeAllConnections();
server.close();
}
});
it('postJson settles when the server sends headers and then stalls the body', async () => {
const server = createServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': '500' });
res.write('{"success":');
});
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
try {
const { port } = server.address() as AddressInfo;
expect((await postJson(`http://127.0.0.1:${port}`, '/x', {}, 100)).status).toBe(0);
} finally {
server.closeAllConnections();
server.close();
}
});
it('treats a disabled bridge (409) as success that says so', async () => {
const r = await runNotify({ message: 'x' }, ENV, '/work', fake(409));
expect(r.code).toBe(0);
expect(r.out).toMatch(/bridge is off/);
});
it('fails with the server error, or the status, or a plain message when nothing answered', async () => {
expect(
(await runNotify({ message: 'x' }, ENV, '/w', fake(400, 'The file must be inside the session folder'))).err
).toBe('Could not post to Discord: The file must be inside the session folder');
expect((await runNotify({ message: 'x' }, ENV, '/w', fake(429))).err).toMatch(/Too many messages/);
expect((await runNotify({ message: 'x' }, ENV, '/w', fake(500))).err).toBe('Could not post to Discord (HTTP 500)');
expect((await runNotify({ message: 'x' }, ENV, '/w', fake(0))).err).toBe('Could not post to Discord');
});
});
describe('postJson (real transport)', () => {
let server: Server;
afterEach(() => new Promise<void>((resolve) => server.close(() => resolve())));
async function serve(status: number, reply: unknown) {
const seen: { method?: string; url?: string; body: string; type?: string } = { body: '' };
server = createServer((req, res) => {
req.on('data', (c) => (seen.body += c));
req.on('end', () => {
seen.method = req.method;
seen.url = req.url;
seen.type = req.headers['content-type'];
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(reply));
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
return { seen, url: `http://127.0.0.1:${(server.address() as AddressInfo).port}` };
}
it('sends a JSON POST and reports the status and the server error text', async () => {
const { seen, url } = await serve(400, { success: false, error: 'nope' });
expect(await postJson(url, '/api/sessions/s1/discord-notify', { message: 'hi' })).toEqual({
status: 400,
error: 'nope',
});
expect(seen).toMatchObject({ method: 'POST', url: '/api/sessions/s1/discord-notify', type: 'application/json' });
expect(JSON.parse(seen.body)).toEqual({ message: 'hi' });
});
it('answers status 0 when nothing is listening', async () => {
const { url } = await serve(200, {});
await new Promise<void>((resolve) => server.close(() => resolve()));
expect((await postJson(url, '/x', {})).status).toBe(0);
server = createServer().listen(0); // so afterEach has something to close
});
});
+59
View File
@@ -55,6 +55,65 @@ describe('customModelInjection.launchModel', () => {
});
});
/** Mutates the shipped deepseek configDir recipe (the one real-home-linking entry) and expects rejection. */
function expectRejectedOnDeepSeek(mutate: (recipe: Record<string, unknown>) => void, because: string): void {
const entry = baseEntry('deepseek');
const recipe = (entry.capabilities as Record<string, unknown>).customModelInjection as Record<string, unknown>;
mutate(recipe);
const result = CliEntrySchema.safeParse(entry);
expect(result.success, `expected rejection: ${because}`).toBe(false);
}
describe('configDir real-home linking (linkFromRealHome / realHomeFallback)', () => {
it('rejects a realHomeFallback that is not a single relative path segment', () => {
// The consumer joins it under the user's home, so any of these escapes that home:
// and bare `.` IS that home: linking through it would expose the whole tree
// unshielded instead of just the declared segments.
for (const bad of ['../x', '.dsh/evil', '/.dsh', '..', '.']) {
expectRejectedOnDeepSeek((r) => {
r.realHomeFallback = bad;
}, `realHomeFallback ${JSON.stringify(bad)}`);
}
});
it('accepts a shipped-style dot-prefixed segment', () => {
const entry = baseEntry('deepseek');
expect(CliEntrySchema.safeParse(entry).success).toBe(true); // ships as `.dsh`
const recipe = (entry.capabilities as Record<string, unknown>).customModelInjection as Record<string, unknown>;
recipe.realHomeFallback = '.dsh-relocated';
expect(CliEntrySchema.safeParse(entry).success).toBe(true);
});
it('requires realHomeFallback whenever linkFromRealHome is declared', () => {
expectRejectedOnDeepSeek((r) => {
delete r.realHomeFallback;
}, 'without it the consumer would have to hardcode a per-CLI home default');
});
it('rejects a linked entry that collides with fileName', () => {
// A symlink at the generated file's own name would write THROUGH into the user's real config.
expectRejectedOnDeepSeek((r) => {
r.linkFromRealHome = ['cordis.patch.yml', 'profiles'];
}, 'linkFromRealHome containing fileName');
});
it('requires linkFromRealHomeDirs to be a subset of linkFromRealHome', () => {
// types.ts documents the rule; a name absent from the linked list names nothing,
// so the create-missing-directory behaviour would silently never apply to it.
expectRejectedOnDeepSeek((r) => {
r.linkFromRealHome = ['profiles', 'settings.yaml'];
r.linkFromRealHomeDirs = ['profiles', 'sessions'];
}, 'linkFromRealHomeDirs naming an unlinked entry');
// The shipped recipe is a valid subset (deepseek: profiles and sessions are dirs
// of the three linked entries) and keeps loading.
const entry = baseEntry('deepseek');
const recipe = (entry.capabilities as Record<string, unknown>).customModelInjection as Record<string, unknown>;
expect(recipe.linkFromRealHome).toEqual(['profiles', 'sessions', 'settings.yaml']);
expect(recipe.linkFromRealHomeDirs).toEqual(['profiles', 'sessions']);
expect(CliEntrySchema.safeParse(entry).success).toBe(true);
});
});
describe('the shipped catalog', () => {
it('validates every stock entry exactly as shipped', () => {
// If this fails, the catalog cannot load at all — every other test here is downstream.
+9
View File
@@ -171,6 +171,15 @@ describe('codex', () => {
expect(cx({ model: 'gpt-5', resumeSessionId: 'roll_42' })).toBe('codex --model gpt-5 resume roll_42');
});
it('carries an Ollama-style `qwen3:32b` model id, and still drops a leading-flag shape', () => {
// `model-plain-colon`: the custom-model feature forces the selected id onto this param,
// and ids like `qwen3:32b` carry a colon. The leading-alphanumeric rule is what keeps a
// flag from riding the flag, so `:x` and `-x` must still drop out.
expect(cx({ model: 'qwen3:32b' })).toBe('codex --model qwen3:32b');
expect(cx({ model: ':leading' })).toBe('codex');
expect(cx({ model: '-yolo' })).toBe('codex');
});
it('sends reasoning effort as one model_reasoning_effort config value, for every level', () => {
for (const level of CODEX_REASONING_EFFORTS) {
expect(cx({ reasoningEffort: level })).toBe(`codex --config model_reasoning_effort=${level}`);
@@ -481,6 +481,52 @@ describe('refreshAllCustomModelHosts: parallel-slot enrichment (llama.cpp splits
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelSlotCounts).toEqual({ a: 4 });
});
it('a round where only the listed figure answers resets the slot count a loaded round learned', async () => {
// The false-warning scenario, measured on a real llama-swap: round 1 has the model
// loaded under `--ctx-size 262144 --parallel 4`: a TOTAL window shared across slots,
// correctly stored with its count. Round 2 finds it unloaded and takes the LISTED
// `context_length`, which llama-swap documents as the PER-SLOT window. If the stored 4
// survived, the floor check would divide the per-slot figure a second time
// (65536/4 = 16384) and warn about a model that serves 65536 to every request.
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
fetchMock.mockImplementation(async (url: URL) => {
if (url.pathname === '/v1/models') {
return new Response(JSON.stringify({ data: [{ id: 'm', status: { value: 'loaded' } }] }), { status: 200 });
}
if (url.pathname === '/running') {
return new Response(
JSON.stringify({
running: [{ model: 'm', state: 'ready', cmd: 'llama-server -m /models/m.gguf --ctx-size 262144 --parallel 4' }],
}),
{ status: 200 }
);
}
throw new Error(`unexpected request: ${url.href}`); // the command answers both questions
});
await refreshAllCustomModelHosts();
let [round1] = await readCustomModelHosts(dir);
expect(round1.modelContextLengths).toEqual({ m: 262144 });
expect(round1.modelSlotCounts).toEqual({ m: 4 });
// Round 2: unloaded, no /running or /props probe in order, listing its per-slot figure.
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ data: [{ id: 'm', status: { value: 'unloaded' }, context_length: 65536 }] }), {
status: 200,
})
);
await refreshAllCustomModelHosts();
const [round2] = await readCustomModelHosts(dir);
expect(round2.modelContextLengths).toEqual({ m: 65536 });
// 1 slot, not the stale 4: 65536 per slot with 1 recorded slot is the honest pair, so
// the floor check reads 65536/request: exactly what the server gives a single request.
expect(round2.modelSlotCounts).toEqual({ m: 1 });
});
});
describe('refreshAllCustomModelHosts: model-size enrichment (parsed from /v1/models description)', () => {
+10 -6
View File
@@ -53,9 +53,12 @@ describe('applyCustomModelInjection: context length', () => {
expect(applied?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBeUndefined();
});
it('deepseek: has no contextLengthVar declared, so a passed-in length is a no-op', () => {
const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', 'sess-ctx-3', 16384);
expect(Object.keys(applied?.envOverrides ?? {}).sort()).toEqual(['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL']);
it('deepseek: passes the known context through its isolated model catalogue', () => {
const sessionId = 'sess-deepseek-context';
sessionsToClean.push(sessionId);
const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', sessionId, 32768);
expect(Object.keys(applied?.envOverrides ?? {}).sort()).toEqual(['CODEMAN_CUSTOM_MODEL_API_KEY', 'DSH_HOME']);
expect(readFileSync(join(applied!.configDir!, 'cordis.patch.yml'), 'utf8')).toContain('"contextWindow": 32768');
});
});
@@ -103,11 +106,12 @@ describe('applyCustomModelInjection: CLAUDE_CONFIG_DIR isolation', () => {
expect(applied?.envOverrides.HOME).toBe(customModelConfigDir(sessionId));
});
it('deepseek: no configDirVar declared, so no config dir is created at all', () => {
it('deepseek: creates an isolated profile patch', () => {
const sessionId = 'sess-cfgdir-deepseek';
sessionsToClean.push(sessionId);
const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', sessionId);
expect(applied?.configDir).toBeUndefined();
expect(existsSync(customModelConfigDir(sessionId))).toBe(false);
expect(applied?.configDir).toBe(customModelConfigDir(sessionId));
expect(existsSync(join(applied!.configDir!, 'cordis.patch.yml'))).toBe(true);
});
});
+10 -10
View File
@@ -188,18 +188,18 @@ describe('custom-model-injection contract (mock server)', () => {
expect(mock.requests[0].headers.authorization).toBe('Bearer contract-test-key');
});
it('deepseek: DEEPSEEK_BASE_URL already carries the /v1 suffix dsh itself never adds, reaching the mock at the real path dsh requests', async () => {
// Confirmed by reading dsh's own bundled source: it fetches
// `${DEEPSEEK_BASE_URL}/chat/completions` verbatim, no /v1 insertion of its own — so
// this call (unlike gemini's above) passes DEEPSEEK_BASE_URL to callOpenAiCompat
// UNMODIFIED, exactly mirroring what the real harness does, rather than the test
// helping it along.
it('deepseek: the session patch carries the /v1 base URL and exact model, reaching the mock at the real path', async () => {
// dsh fetches `${baseURL}/chat/completions` verbatim, so the patch's baseURL already
// carries /v1 and is passed to callOpenAiCompat unmodified.
const injection = buildCustomModelInjection(entryOrThrow('deepseek'), endpointFor(mock), 'qwen3');
if (injection.kind !== 'env') throw new Error('unreachable');
expect(Object.keys(injection.envOverrides).sort()).toEqual(['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL']);
expect(injection.envOverrides.DEEPSEEK_BASE_URL).toBe(`${mock.baseUrl}/v1`);
if (injection.kind !== 'configDir') throw new Error('unreachable');
expect(injection.dirEnvVar).toBe('DSH_HOME');
expect(Object.keys(injection.extraEnv ?? {})).toEqual(['CODEMAN_CUSTOM_MODEL_API_KEY']);
const provider = JSON.parse(injection.files[0].content)[0].config.providers['codeman-custom'];
expect(provider.baseURL).toBe(`${mock.baseUrl}/v1`);
expect(provider.models[0].id).toBe('qwen3');
await callOpenAiCompat(injection.envOverrides.DEEPSEEK_BASE_URL, injection.envOverrides.DEEPSEEK_API_KEY, 'qwen3');
await callOpenAiCompat(provider.baseURL, injection.extraEnv!.CODEMAN_CUSTOM_MODEL_API_KEY, 'qwen3');
expect(mock.requests[0].path).toBe('/v1/chat/completions');
expect(mock.requests[0].headers.authorization).toBe('Bearer contract-test-key');
+72 -17
View File
@@ -8,6 +8,7 @@
*/
import { describe, it, expect } from 'vitest';
import { parse as parseToml } from 'smol-toml';
import { getCli } from '../src/config/cli-registry/index.js';
import {
buildCustomModelInjection,
@@ -112,6 +113,31 @@ describe('buildCustomModelInjection', () => {
expect(parsed.provider.custom.models.qwen3).toEqual({});
});
it('opencode: threads a discovered context length into the model limit as the full window (issue #749)', () => {
const result = buildCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3', 16384);
if (result.kind !== 'env') throw new Error('unreachable');
const parsed = JSON.parse(result.envOverrides.OPENCODE_CONFIG_CONTENT);
// `limit.context` IS the full window to opencode (it reserves for the reply itself),
// so the discovered total passes through; `output` only caps the reply at a quarter.
expect(parsed.provider.custom.models.qwen3).toEqual({ limit: { context: 16384, output: 4096 } });
});
it('opencode: caps the response headroom reservation at 8192 for big windows', () => {
const result = buildCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3', 262144);
if (result.kind !== 'env') throw new Error('unreachable');
const parsed = JSON.parse(result.envOverrides.OPENCODE_CONFIG_CONTENT);
expect(parsed.provider.custom.models.qwen3).toEqual({ limit: { context: 262144, output: 8192 } });
});
it('opencode: never invents a limit for an unknown or non-finite context length (issue #749 regression)', () => {
for (const contextLength of [undefined, NaN]) {
const result = buildCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3', contextLength);
if (result.kind !== 'env') throw new Error('unreachable');
const parsed = JSON.parse(result.envOverrides.OPENCODE_CONFIG_CONTENT);
expect(parsed.provider.custom.models.qwen3).toEqual({});
}
});
it('codex: configDir writes an isolated config.toml with model/base_url, and the key rides as extraEnv (never a literal TOML field)', () => {
const result = buildCustomModelInjection(entryOrThrow('codex'), endpoint, 'qwen3');
expect(result.kind).toBe('configDir');
@@ -133,6 +159,30 @@ describe('buildCustomModelInjection', () => {
expect(result.files[0].content).toContain('model = "weird\\"model"');
});
it('codex: writes the discovered window as TOP-LEVEL model_context_window with a 75% compact limit (issue #750)', () => {
const result = buildCustomModelInjection(entryOrThrow('codex'), endpoint, 'qwen3', 16384);
if (result.kind !== 'configDir') throw new Error('unreachable');
const doc = parseToml(result.files[0].content) as Record<string, unknown>;
// Top-level is the shape codex reads: parked under [model_providers.custom] (its
// provider table) the same keys would be silently ignored by codex itself.
expect(doc.model_context_window).toBe(16384);
expect(doc.model_auto_compact_token_limit).toBe(12288); // 75% of the window, floored
const provider = (doc.model_providers as Record<string, Record<string, unknown>>).custom;
expect(provider.model_context_window).toBeUndefined();
expect(provider.model_auto_compact_token_limit).toBeUndefined();
});
it('codex: writes neither context line for an unknown, non-finite or zero context length', () => {
for (const contextLength of [undefined, NaN, 0]) {
const result = buildCustomModelInjection(entryOrThrow('codex'), endpoint, 'qwen3', contextLength);
if (result.kind !== 'configDir') throw new Error('unreachable');
const doc = parseToml(result.files[0].content) as Record<string, unknown>;
expect(doc.model_context_window, String(contextLength)).toBeUndefined();
expect(doc.model_auto_compact_token_limit, String(contextLength)).toBeUndefined();
expect(doc.model).toBe('qwen3'); // the rest of the file is still written
}
});
it('pi: configDir writes .pi/agent/models.json, redirected via HOME (verified live — PI_CONFIG_DIR does nothing for pi)', () => {
const result = buildCustomModelInjection(entryOrThrow('pi'), endpoint, 'qwen3');
if (result.kind !== 'configDir') throw new Error('unreachable');
@@ -179,29 +229,32 @@ describe('buildCustomModelInjection', () => {
expect(result.extraEnv).toEqual({ XAI_API_KEY: 'my-key' });
});
it('deepseek: env kind sets base URL (with a /v1 suffix appended) and key, no model var', () => {
// appendV1Suffix is REQUIRED here, not cosmetic: confirmed by reading dsh's own
// bundled source (@deepseek-ai/dsh-llm-deepseek) that it builds the request URL as
// `${DEEPSEEK_BASE_URL}/chat/completions` with no "/v1" of its own, while
// llama-swap/llama.cpp only serves "/v1/chat/completions" — without this, every
// request 404s (confirmed live; this is the fix for the originally-reported
// "dsh: HTTP_404: DeepSeek API error (HTTP 404)").
it('deepseek: isolates the selected model route and passes its key by reference', () => {
const result = buildCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3');
if (result.kind !== 'env') throw new Error('unreachable');
expect(result.envOverrides).toEqual({
DEEPSEEK_BASE_URL: 'http://192.168.1.50:8080/v1',
DEEPSEEK_API_KEY: 'my-key',
});
if (result.kind !== 'configDir') throw new Error('Expected isolated DeepSeek config');
expect(result.dirEnvVar).toBe('DSH_HOME');
expect(result.extraEnv).toEqual({ CODEMAN_CUSTOM_MODEL_API_KEY: 'my-key' });
expect(result.files[0].content).not.toContain('my-key');
const patch = JSON.parse(result.files[0].content);
// The patch references the DEDICATED var by name, never dsh's own DEEPSEEK_API_KEY:
// dsh's llm-deepseek/web-search rows read that too and would leak this key to
// api.deepseek.com on an in-session /model switch back to the official route.
expect(patch.find((row: { id: string }) => row.id === 'llm-pi-ai').config.providers['codeman-custom'].apiKeyEnv).toBe(
'CODEMAN_CUSTOM_MODEL_API_KEY'
);
expect(result.files[0].content).not.toContain('DEEPSEEK_API_KEY');
expect(patch.find((row: { id: string }) => row.id === 'dsh-tui').config.model).toBe('qwen3');
});
it('deepseek: appending the /v1 suffix is idempotent against a baseUrl that already ends in /v1', () => {
it('deepseek: preserves an existing version suffix', () => {
const result = buildCustomModelInjection(
entryOrThrow('deepseek'),
{ ...endpoint, baseUrl: 'http://192.168.1.50:8080/v1' },
{ ...endpoint, baseUrl: endpoint.baseUrl + '/v1' },
'qwen3'
);
if (result.kind !== 'env') throw new Error('unreachable');
expect(result.envOverrides.DEEPSEEK_BASE_URL).toBe('http://192.168.1.50:8080/v1');
if (result.kind !== 'configDir') throw new Error('Expected isolated DeepSeek config');
expect(result.files[0].content).toContain('http://192.168.1.50:8080/v1');
expect(result.files[0].content).not.toContain('/v1/v1');
});
it('antigravity: unsupported', () => {
@@ -232,7 +285,9 @@ describe('launchModel (the model launch param that selects the injected provider
});
it('CLIs whose config selects the model on its own declare none', () => {
for (const id of ['claude', 'opencode', 'codex', 'gemini', 'deepseek']) {
// codex now declares launchModel (selected-model launch override) and is covered by
// its own restart coverage, not here.
for (const id of ['claude', 'opencode', 'gemini', 'deepseek']) {
const result = buildCustomModelInjection(entryOrThrow(id), endpoint, 'qwen3');
if (result.kind === 'unsupported') throw new Error('unreachable');
expect(result.launchModel, id).toBeUndefined();
@@ -0,0 +1,91 @@
/**
* @fileoverview Boot recovery rebuilds a saved custom-model selection through the SAME
* context-length resolution the routes use: the model's discovered figure, falling back to
* the endpoint's `defaultContextLength` (`resolveCustomModelContextLength`). Before the
* shared helper, `_rebuildCustomModelEnv` read `modelContextLengths` alone, so a session
* on a model that only the endpoint default covers came back from a Codeman restart with
* `CLAUDE_CODE_MAX_CONTEXT_TOKENS` (or a config file's context figure) silently gone,
* re-arming exactly the window overflow the var exists to prevent.
*
* `_rebuildCustomModelEnv` reads no `this` state (it re-derives everything from the endpoint
* store and the Session it is handed), so it is driven straight off the prototype with an
* empty receiver: `restoreMuxSessions()` itself cannot be reached under vitest, the same
* constraint recorded in test/session-model-recovery.test.ts.
*/
import { afterEach, describe, expect, it } from 'vitest';
import { getDataDir } from '../src/config/instance.js';
import { writeCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js';
import { customModelConfigDir, removeConfigDir, type AppliedCustomModel } from '../src/custom-model-injection-apply.js';
import { Session } from '../src/session.js';
import { WebServer } from '../src/web/server.js';
import type { CustomModelBookkeeping } from '../src/types/session.js';
type RebuildFn = (session: Session, saved: CustomModelBookkeeping) => Promise<AppliedCustomModel | undefined>;
/** The real method, with an empty receiver, see the file header for why that is sound. */
function rebuildCustomModelEnv(session: Session, saved: CustomModelBookkeeping) {
const fn = (WebServer.prototype as unknown as { _rebuildCustomModelEnv: RebuildFn })._rebuildCustomModelEnv;
expect(typeof fn, '_rebuildCustomModelEnv vanished from WebServer.prototype').toBe('function');
return fn.call({}, session, saved);
}
function endpoint(overrides: Partial<CustomModelHost> = {}): CustomModelHost {
return {
id: 'ep',
label: 'Ep',
baseUrl: 'http://localhost:8080',
apiKey: 'recovery-probe-key',
models: ['qwen3'],
...overrides,
};
}
describe('_rebuildCustomModelEnv: context-length fallback on boot recovery', () => {
const configs: string[] = [];
afterEach(() => {
for (const id of configs.splice(0)) removeConfigDir(customModelConfigDir(id));
});
it('falls back to the endpoint defaultContextLength when discovery recorded no per-model figure', async () => {
await writeCustomModelHosts(getDataDir(), [endpoint({ defaultContextLength: 32768 })]);
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
configs.push(session.id);
const env = await rebuildCustomModelEnv(session, { endpointId: 'ep', modelId: 'qwen3', envKeys: [] });
expect(env?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe('32768');
});
it('a discovered per-model figure still wins over the endpoint default', async () => {
await writeCustomModelHosts(getDataDir(), [
endpoint({ modelContextLengths: { qwen3: 131072 }, defaultContextLength: 32768 }),
]);
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
configs.push(session.id);
const env = await rebuildCustomModelEnv(session, { endpointId: 'ep', modelId: 'qwen3', envKeys: [] });
expect(env?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe('131072');
});
it('states nothing rather than guessing when neither source knows a window', async () => {
await writeCustomModelHosts(getDataDir(), [endpoint()]);
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
configs.push(session.id);
const env = await rebuildCustomModelEnv(session, { endpointId: 'ep', modelId: 'qwen3', envKeys: [] });
expect(env?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBeUndefined();
});
it('drops the selection to undefined (kept for clearing) when the endpoint no longer exists', async () => {
await writeCustomModelHosts(getDataDir(), []);
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
configs.push(session.id);
await expect(rebuildCustomModelEnv(session, { endpointId: 'gone', modelId: 'qwen3', envKeys: [] })).resolves.toBe(
undefined
);
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* @fileoverview Boot recovery re-derives a saved custom-model selection through
* `_rebuildCustomModelEnv` and rebuilds the BOOKKEEPING from what the CURRENT recipe
* applied, not from the saved record. A selection persisted while deepseek still used
* the old env redirect comes back env-shaped (envKeys `DEEPSEEK_BASE_URL`/
* `DEEPSEEK_API_KEY`, no `configDir`) although the recipe now writes a per-session patch
* home: restoring the saved record unchanged left `DSH_HOME` on a generated config dir
* that clearing could never delete.
*
* Real-home resolution is NOT recovered from the session's env overrides any more:
* `getEnvOverridesForPersist()` keeps only `CLAUDE_CODE_*`/`CLAUDE_CONFIG_DIR`, so a
* session's own `DSH_HOME` never survives a real restart and the lookup only ever hit on
* the first apply in-process. Recovery falls back to the server's `DSH_HOME` or the
* recipe's `realHomeFallback` (~/.dsh), see `_rebuildCustomModelEnv`'s comment.
*
* `_restoreCustomModel` reads no `this` state beyond the rebuild helper (it re-derives
* everything from the endpoint store and the Session it is handed), so it is driven off
* the prototype with a minimal receiver: `restoreMuxSessions()` itself cannot be
* reached under vitest, the same constraint recorded in test/session-model-recovery.test.ts.
*/
import { mkdirSync, rmSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getDataDir } from '../src/config/instance.js';
import { writeCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js';
import { customModelConfigDir, removeConfigDir } from '../src/custom-model-injection-apply.js';
import { Session } from '../src/session.js';
import { WebServer } from '../src/web/server.js';
import type { CustomModelBookkeeping } from '../src/types/session.js';
/** The real pair, on a receiver that only carries the rebuild helper, see the header. */
async function restoreCustomModel(session: Session, saved: CustomModelBookkeeping): Promise<void> {
const proto = WebServer.prototype as unknown as {
_rebuildCustomModelEnv: (session: Session, saved: CustomModelBookkeeping) => Promise<unknown>;
_restoreCustomModel: (session: Session, saved: CustomModelBookkeeping) => Promise<void>;
};
expect(typeof proto._restoreCustomModel, '_restoreCustomModel vanished from WebServer.prototype').toBe('function');
await proto._restoreCustomModel.call({ _rebuildCustomModelEnv: proto._rebuildCustomModelEnv }, session, saved);
}
function endpoint(): CustomModelHost {
return {
id: 'ep',
label: 'Ep',
baseUrl: 'http://localhost:8080',
apiKey: 'recovery-probe-key',
models: ['qwen3'],
};
}
describe('_restoreCustomModel: bookkeeping rebuilt from the current recipe', () => {
const configs: string[] = [];
afterEach(() => {
for (const id of configs.splice(0)) removeConfigDir(customModelConfigDir(id));
vi.unstubAllEnvs();
});
it('carries the configDir and env-key union for a selection saved by the old env recipe', async () => {
// Saved exactly as 1.31.0-1.47.0 persisted a deepseek selection: the env recipe's two
// keys, no configDir. The rebuilt record must carry the per-session config dir the
// CURRENT recipe wrote (so clearing deletes it) while KEEPING the saved keys, which
// the tmux session may still carry `setenv`s for (the union is what a clear unsets).
const dshHome = join(homedir(), 'codeman-recovery-dsh-home');
mkdirSync(join(dshHome, 'profiles'), { recursive: true });
vi.stubEnv('DSH_HOME', dshHome);
await writeCustomModelHosts(getDataDir(), [endpoint()]);
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
configs.push(session.id);
await restoreCustomModel(session, {
endpointId: 'ep',
modelId: 'qwen3',
label: 'Ep',
envKeys: ['DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY'],
});
const persisted = session.getCustomModelForPersist();
expect(persisted?.configDir).toBe(customModelConfigDir(session.id));
expect(persisted?.launchModel).toBeUndefined();
expect(new Set(persisted?.envKeys)).toEqual(
new Set(['DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY', 'DSH_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY'])
);
rmSync(dshHome, { recursive: true, force: true });
});
it('restores an env-shaped record unchanged when the endpoint is gone, so a clear still works', async () => {
await writeCustomModelHosts(getDataDir(), []);
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
const saved: CustomModelBookkeeping = {
endpointId: 'gone',
modelId: 'qwen3',
label: 'Gone',
envKeys: ['DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY'],
};
await restoreCustomModel(session, saved);
expect(session.getCustomModelForPersist()).toEqual(saved);
});
});
+387 -7
View File
@@ -20,6 +20,10 @@ import { describe, expect, it, vi } from 'vitest';
const CONSTANTS_JS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
const SESSION_UI_JS = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
// Only for the [hidden] CSS guard below: the filter toggles the `hidden` property, and
// `.run-mode-option`'s own `display: flex` would silently defeat it without the
// `!important` re-assertion in styles.css.
const STYLES_CSS = readFileSync(new URL('../src/web/public/styles.css', import.meta.url), 'utf-8');
// Only for the real _showCenterStatus DOM tests below (`bootAppWithRealCenterStatus`) —
// every other test in this file stubs _showCenterStatus itself and has no need of it.
const PANELS_UI_JS = readFileSync(new URL('../src/web/public/panels-ui.js', import.meta.url), 'utf-8');
@@ -40,6 +44,8 @@ function bootApp(
cliAvailable?: (id: string) => boolean;
activeCase?: { location?: string } | null;
settingsEnabled?: boolean;
/** Live sessions for the "in use by a session" fallback, in `this.sessions` Map shape. */
sessions?: Array<Record<string, unknown>>;
} = {}
) {
const dom = new JSDOM(
@@ -55,6 +61,10 @@ function bootApp(
<div class="modal" id="customModelPickModal">
<h3 id="customModelPickTitle"></h3>
<p id="customModelPickHint"></p>
<input type="search" id="customModelPickSearch" aria-label="Search models"
oninput="app.filterCustomModelPick(this.value)">
<p id="customModelPickNoMatches" role="status" hidden>No models match</p>
<p id="customModelPickUnknown" role="status" hidden>Nothing is marked: this endpoint did not report which model it has loaded, and none of your running sessions uses one. Pick the model to launch.</p>
<div id="customModelPickList"></div>
</div>
<div class="modal" id="customModelSwapConfirmModal">
@@ -81,6 +91,10 @@ function bootApp(
app.cases = options.activeCase ? [{ name: 'testcase', ...options.activeCase }] : [{ name: 'testcase' }];
app.loadAppSettingsFromStorage = () => ({ customModelEndpointsEnabled: options.settingsEnabled ?? true });
app.isCliAvailable = options.cliAvailable ?? (() => true);
// The real app keeps live sessions in a Map keyed by id (app.js), which is what
// _getCustomModelInUseBySession walks; empty by default so a test that says nothing
// about sessions sees no promotion, exactly like a box with none running.
app.sessions = new Map((options.sessions || []).map((s, i) => [String(i), s]));
app.showToast = () => {};
// Real implementation lives in panels-ui.js, not evaluated into this harness (only
// constants.js + session-ui.js are — see below) — a no-op default handle matching its
@@ -315,6 +329,27 @@ describe('Custom Model Endpoint Profiles: the "which model" picker', () => {
expect(buttons[0].textContent).not.toContain('Currently loaded');
});
it('treats a remembered "last used" id the host no longer lists as nothing promoted: no tag, the line shows', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
});
const origApiJson = app._apiJson;
app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints/llama-box/running-status') return { isLlamaSwap: false, running: [] };
return origApiJson(path);
};
// The remembered model was deleted from this endpoint since the last launch.
win.localStorage.setItem('codeman:customModelLastUsed:claude:llama-box', 'gone-model');
await app.selectCustomModelEntry('claude', 'llama-box');
const list = win.document.getElementById('customModelPickList')!;
expect(list.textContent).not.toContain('Last used');
// Without a promoted row the probe really did answer "nothing is known", so the
// explanation line must show rather than leave two unmarked rows unexplained.
expect(win.document.getElementById('customModelPickUnknown')!.hidden).toBe(false);
});
it('prefers the currently-loaded model over a stale "last used" entry when both are present', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3', 'phi4'] }],
@@ -532,9 +567,349 @@ describe('Custom Model Endpoint Profiles: the "which model" picker', () => {
await app.selectCustomModelEntry('claude', 'llama-box');
expect(toastMessage).toMatch(/no models discovered/i);
});
it('resets and focuses the search box every time the picker opens', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
});
win.app = app;
await app.selectCustomModelEntry('claude', 'llama-box');
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
// The caret lands in the box, so the first keystroke filters with no click.
expect(win.document.activeElement).toBe(search);
// A stale query from a previous open must not survive into the next one.
search.value = 'zzz';
search.dispatchEvent(new win.Event('input'));
app.closeCustomModelPickModal();
await app.selectCustomModelEntry('claude', 'llama-box');
expect(search.value).toBe('');
expect(win.document.activeElement).toBe(search);
expect(win.document.getElementById('customModelPickNoMatches')!.hasAttribute('hidden')).toBe(true);
const visible = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')].filter(
(b) => !b.hasAttribute('hidden')
);
expect(visible).toHaveLength(2);
});
it('leaves the search box unfocused on touch devices so the OS keyboard stays off the list', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
});
win.app = app;
// The guard reads the global the browser defines (mobile-detection.js).
(win as unknown as { MobileDetection: { isTouchDevice: () => boolean } }).MobileDetection = {
isTouchDevice: () => true,
};
await app.selectCustomModelEntry('claude', 'llama-box');
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
expect(win.document.activeElement).not.toBe(search);
// Only the focus is skipped: the rows still render, unfiltered and functional.
const visible = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')].filter(
(b) => !b.hasAttribute('hidden')
);
expect(visible).toHaveLength(2);
});
it('filters rows case-insensitively on the model id, keeping the surviving row fully functional', async () => {
const { win, app } = bootApp({
hosts: [
{
id: 'llama-box',
label: 'llama.cpp',
baseUrl: 'http://x',
models: ['qwen3', 'llama3'],
defaultModelId: 'qwen3',
},
],
});
let launched: unknown[] | null = null;
app.runCustomModelEntry = async (...args: unknown[]) => {
launched = args;
};
win.app = app;
await app.selectCustomModelEntry('claude', 'llama-box');
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
search.value = 'QWEN';
search.dispatchEvent(new win.Event('input'));
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
expect(buttons.filter((b) => b.hasAttribute('hidden')).map((b) => b.textContent!.trim())).toEqual(['llama3']);
const [visible] = buttons.filter((b) => !b.hasAttribute('hidden'));
// The surviving row keeps its Default badge and its real, callable click handler.
expect(visible.textContent).toContain('qwen3');
expect(visible.textContent).toContain('Default');
(visible as unknown as HTMLButtonElement & { onclick: (e: unknown) => void }).onclick(
new (win as any).Event('click')
);
expect(launched).toEqual(['claude', 'llama-box', 'qwen3']);
});
it('shows the no-matches line only while a query matches nothing, and clears it with the query', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
});
win.app = app;
await app.selectCustomModelEntry('claude', 'llama-box');
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
const noMatches = win.document.getElementById('customModelPickNoMatches')!;
const isHidden = () =>
win.document.getElementById('customModelPickList')!.querySelectorAll('button[hidden]').length;
// Whitespace is not a query: everything stays visible, no message.
search.value = ' ';
search.dispatchEvent(new win.Event('input'));
expect(noMatches.hasAttribute('hidden')).toBe(true);
expect(isHidden()).toBe(0);
search.value = 'ghost';
search.dispatchEvent(new win.Event('input'));
expect(noMatches.hasAttribute('hidden')).toBe(false);
expect(isHidden()).toBe(2);
search.value = '';
search.dispatchEvent(new win.Event('input'));
expect(noMatches.hasAttribute('hidden')).toBe(true);
expect(isHidden()).toBe(0);
});
it('keeps an HTML-significant model id inert in the picker rows too, and filterable by its raw text', async () => {
// Same vector as the Run-menu row above, now via data-model-id: escapeHtml covers
// the double quote, and the filter reads the decoded raw id back off dataset.
const dangerousModel = '"><img src=x onerror=alert(1)>';
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', dangerousModel] }],
});
win.app = app;
await app.selectCustomModelEntry('claude', 'llama-box');
const list = win.document.getElementById('customModelPickList')!;
expect(list.querySelector('img')).toBeNull();
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
search.value = 'src=x';
search.dispatchEvent(new win.Event('input'));
const visible = list.querySelectorAll('button:not([hidden])');
expect(visible).toHaveLength(1);
expect(visible[0].getAttribute('data-model-id')).toBe(dangerousModel);
});
it('re-asserts display:none !important for hidden picker rows so the filter cannot be silently defeated', () => {
// `.run-mode-option` carries its own `display: flex` (styles.css, restated in
// mobile.css), which outranks the UA `[hidden]` rule: without this scoped
// !important re-assertion, toggling `hidden` would change nothing on screen.
const rule = STYLES_CSS.match(/#customModelPickModal \.run-mode-option\[hidden\]\s*\{[^}]*\}/);
expect(rule, 'picker rows need a [hidden] rule scoped to #customModelPickModal').toBeTruthy();
expect(rule![0]).toMatch(/display:\s*none\s*!important/);
});
it('pins "No models match" hidden with an empty query and shown only when a query hides every row, in CSS too', async () => {
// The visible symptom: the line carries `.form-hint`, whose `display: block`
// outranks the UA `[hidden]` rule, so `hidden` alone showed "No models match"
// while every model was listed with an empty search box. The attribute half
// was always right (session-ui.js's `filterCustomModelPick`); the CSS half
// needed the same `[hidden] { display: none !important }` re-assertion the
// rows above carry. This test pins both states AND the CSS that makes them
// actually render.
const rule = STYLES_CSS.match(/#customModelPickModal \.custom-model-pick-no-matches\[hidden\]\s*\{[^}]*\}/);
expect(rule, 'the no-matches line needs a [hidden] rule scoped to #customModelPickModal').toBeTruthy();
expect(rule![0]).toMatch(/display:\s*none\s*!important/);
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
});
win.app = app;
await app.selectCustomModelEntry('claude', 'llama-box');
const noMatches = win.document.getElementById('customModelPickNoMatches')!;
// Empty query, models listed: the attribute must be set, and the CSS rule
// above is what makes that attribute actually hide the line.
expect(noMatches.hasAttribute('hidden')).toBe(true);
// A non-empty query that hides every row: shown again.
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
search.value = 'ghost';
search.dispatchEvent(new win.Event('input'));
expect(noMatches.hasAttribute('hidden')).toBe(false);
});
});
describe('Custom Model Endpoint Profiles: _getCustomModelCurrentlyLoaded is client-side bounded', () => {
/**
* The reported bug: a gateway such as Bifrost (40 models, alphabetical, no llama-swap
* `/running` route of its own) showed "Choose a model" with nothing marked, so the user
* could not tell which model the endpoint was actually serving, while the same endpoint
* reached from the Codex CLI appeared to "default to alpha order" for every other CLI.
* The endpoint genuinely cannot answer, so the app falls back to what it does know: a
* live session it has itself pointed at that endpoint. And it says so plainly when it
* knows neither.
*/
describe('Custom Model Endpoint Profiles: gateways without /running fall back to a live session', () => {
const GATEWAY = {
id: 'bifrost',
label: 'bifrost',
baseUrl: 'https://bifrost.example',
models: ['llama-swap/alpha-3b', 'llama-swap/qwen3.8-flash-next-unsloth-ud-iq4_xs', 'llama-swap/zeta-70b'],
};
/** The gateway's exact shape: reachable, OpenAI-compatible, and not llama-swap. */
function gatewayApp(sessions: Array<Record<string, unknown>>) {
const booted = bootApp({ hosts: [GATEWAY], sessions });
const origApiJson = booted.app._apiJson;
booted.app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints/bifrost/running-status') return { isLlamaSwap: false, running: [] };
return origApiJson(path);
};
return booted;
}
const live = (modelId: string, overrides: Record<string, unknown> = {}) => ({
name: 'w9-Gateway',
status: 'busy',
pid: 4242,
createdAt: 1_700_000_000_000,
customModel: { endpointId: 'bifrost', modelId, label: 'bifrost' },
...overrides,
});
it('marks the model a live session is running, at the top, for EVERY CLI, not just claude', async () => {
const inUse = 'llama-swap/qwen3.8-flash-next-unsloth-ud-iq4_xs';
for (const mode of ['opencode', 'deepseek', 'codex', 'claude']) {
const { win, app } = gatewayApp([live(inUse)]);
await app._openCustomModelPickModal(mode, GATEWAY);
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
expect(buttons).toHaveLength(3);
expect(buttons[0].textContent, mode).toContain(inUse);
expect(buttons[0].textContent, mode).toContain('In use by a session');
// The other rows keep discovery order and carry no tag of their own.
expect(buttons[1].textContent).toContain('llama-swap/alpha-3b');
expect(buttons[2].querySelectorAll('.set-scope')).toHaveLength(0);
// Nothing was fabricated about the endpoint itself.
expect(win.document.getElementById('customModelPickList')!.textContent).not.toContain('Currently loaded');
expect(win.document.getElementById('customModelPickUnknown')!.hasAttribute('hidden')).toBe(true);
}
});
it('a live session outranks this device’s "last used" memory, and both rank above nothing', async () => {
const { win, app } = gatewayApp([live('llama-swap/zeta-70b')]);
win.localStorage.setItem('codeman:customModelLastUsed:opencode:bifrost', 'llama-swap/alpha-3b');
await app._openCustomModelPickModal('opencode', GATEWAY);
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
expect(buttons[0].textContent).toContain('llama-swap/zeta-70b');
expect(buttons[0].textContent).toContain('In use by a session');
expect(buttons[0].textContent).not.toContain('Last used');
// The remembered pick is still listed, just not promoted or double-tagged.
expect(buttons[1].textContent).toContain('llama-swap/alpha-3b');
expect(buttons[1].querySelectorAll('.set-scope')).toHaveLength(0);
});
it('a stopped session, an exited process, an exited pane, another endpoint or an undiscovered model mark nothing', async () => {
const inUse = 'llama-swap/qwen3.8-flash-next-unsloth-ud-iq4_xs';
const cases: Array<[string, Record<string, unknown>]> = [
['stopped', { status: 'stopped' }],
['process exited (pid null)', { pid: null }],
['pane exited', { paneExit: { at: 1, status: 0 } }],
['other endpoint', { customModel: { endpointId: 'other', modelId: inUse } }],
['no custom model', { customModel: undefined }],
['undiscovered model', { customModel: { endpointId: 'bifrost', modelId: 'llama-swap/retired-model' } }],
];
for (const [label, overrides] of cases) {
const { win, app } = gatewayApp([live(inUse, overrides)]);
await app._openCustomModelPickModal('opencode', GATEWAY);
const list = win.document.getElementById('customModelPickList')!;
expect(list.textContent, label).not.toContain('In use by a session');
expect(list.textContent, label).not.toContain('Last used');
// Discovery order untouched: nothing got dragged to the top.
expect(list.querySelectorAll('button')![0].textContent).toContain('llama-swap/alpha-3b');
}
});
it('the most recently launched live session wins when two share an endpoint', async () => {
const { win, app } = gatewayApp([
live('llama-swap/alpha-3b', { createdAt: 1 }),
live('llama-swap/zeta-70b', { createdAt: 2 }),
]);
await app._openCustomModelPickModal('opencode', GATEWAY);
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
expect(buttons[0].textContent).toContain('llama-swap/zeta-70b');
expect(buttons.filter((b) => b.textContent!.includes('In use by a session'))).toHaveLength(1);
});
it('says so plainly when nothing is known (a down endpoint answers the very same shape)', async () => {
const { win, app } = gatewayApp([]);
await app._openCustomModelPickModal('opencode', GATEWAY);
const unknown = win.document.getElementById('customModelPickUnknown')!;
expect(unknown.hasAttribute('hidden')).toBe(false);
expect(unknown.textContent).toContain('did not report which model it has loaded');
// What REALLY arrives here, not a fiction: `getLlamaSwapStatus` (custom-model-routes.ts)
// turns EVERY upstream failure into `isLlamaSwap: false` (a gateway with no
// `/running` route, a closed port, an unresolvable host, a 401), so this stub is
// also exactly what a DOWN endpoint produces, which is why the line's wording says
// only "did not report" and never why.
// The same class-of-bug trap as the no-matches line: `.form-hint`'s `display: block`
// outranks the UA `[hidden]` rule, so the attribute needs the CSS re-assertion.
const rule = STYLES_CSS.match(/#customModelPickModal \.custom-model-pick-unknown\[hidden\]\s*\{[^}]*\}/);
expect(rule, 'the unknown-endpoint line needs a [hidden] rule scoped to #customModelPickModal').toBeTruthy();
expect(rule![0]).toMatch(/display:\s*none\s*!important/);
});
it('hides that line whenever a row IS marked, and for a probe that never got any answer', async () => {
// Marked by a live session: nothing to explain.
const marked = gatewayApp([live('llama-swap/alpha-3b')]);
await marked.app._openCustomModelPickModal('opencode', GATEWAY);
expect(marked.win.document.getElementById('customModelPickUnknown')!.hasAttribute('hidden')).toBe(true);
// `null` (no answer at all) only happens when Codeman's OWN request to the route
// failed or the client-side bound won the race. The route itself never answers this
// way for an unreachable endpoint: every fetch failure arrives as `isLlamaSwap:
// false`, pinned by the test above. The box may merely be asleep, so stay silent.
const asleep = bootApp({ hosts: [GATEWAY] });
asleep.app._apiJson = async (path: string) => (path.includes('running-status') ? null : []);
await asleep.app._openCustomModelPickModal('opencode', GATEWAY);
expect(asleep.win.document.getElementById('customModelPickUnknown')!.hasAttribute('hidden')).toBe(true);
});
it('llama-swap’s own answer still wins over a session pin, and keeps its own tag', async () => {
const booted = bootApp({
hosts: [GATEWAY],
sessions: [live('llama-swap/alpha-3b')],
});
const origApiJson = booted.app._apiJson;
booted.app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints/bifrost/running-status') {
return { isLlamaSwap: true, running: [{ model: 'llama-swap/zeta-70b', state: 'ready' }] };
}
return origApiJson(path);
};
await booted.app._openCustomModelPickModal('opencode', GATEWAY);
const buttons = [...booted.win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
expect(buttons[0].textContent).toContain('llama-swap/zeta-70b');
expect(buttons[0].textContent).toContain('Currently loaded');
expect(buttons[0].textContent).not.toContain('In use by a session');
// The session's model is an ordinary row again: one promotion, not two.
expect(buttons.filter((b) => b.querySelector('.set-scope'))).toHaveLength(1);
});
it('never trusts displayModel: a session whose terminal merely printed a model marks nothing', async () => {
const booted = gatewayApp([
{ name: 'w9-Other', status: 'busy', createdAt: 1, displayModel: { model: 'llama-swap/zeta-70b', source: 'screen' } },
]);
await booted.app._openCustomModelPickModal('opencode', GATEWAY);
expect(booted.win.document.getElementById('customModelPickList')!.textContent).not.toContain('In use by a session');
});
});
describe('Custom Model Endpoint Profiles: _getCustomModelRunningStatus is client-side bounded', () => {
// `timeoutMs` driven in milliseconds rather than the real 800 — same reasoning as
// `_watchLlamaSwapLoading`'s own `pollIntervalMs` a few describe blocks down: this
// code runs inside the JSDOM window's own realm, whose setTimeout vi.useFakeTimers()
@@ -549,26 +924,31 @@ describe('Custom Model Endpoint Profiles: _getCustomModelCurrentlyLoaded is clie
// endpoint that is asleep or firewalled, distinct from one that answers an error.
app._apiJson = () => new Promise(() => {});
const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 5);
const status = await app._getCustomModelRunningStatus({ id: 'llama-box', models: ['qwen3'] }, 5);
expect(result).toBeNull();
// `null` means "no answer at all" (Codeman's own request failed or the client-side
// bound won): the picker keeps its "Nothing is marked" line hidden for exactly this
// case (pinned in the gateway describe above). An endpoint that is merely down is
// NOT here: the route turns every fetch failure into `isLlamaSwap: false`.
expect(status).toBeNull();
expect(app._pickReadyRunningModel(status, { id: 'llama-box', models: ['qwen3'] })).toBeNull();
});
it('an endpoint that answers well within the bound is unaffected by it', async () => {
const { app } = bootApp({});
app._apiJson = async () => ({ isLlamaSwap: true, running: [{ model: 'qwen3', state: 'ready' }] });
const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 5);
const status = await app._getCustomModelRunningStatus({ id: 'llama-box', models: ['qwen3'] }, 5);
expect(result).toBe('qwen3');
expect(app._pickReadyRunningModel(status, { id: 'llama-box', models: ['qwen3'] })).toBe('qwen3');
});
it('a rejected probe settles quietly to null rather than leaving an unhandled rejection once the timeout has already won the race', async () => {
const { app } = bootApp({});
app._apiJson = () => new Promise((_resolve, reject) => setTimeout(() => reject(new Error('boom')), 10));
const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 2);
expect(result).toBeNull();
const status = await app._getCustomModelRunningStatus({ id: 'llama-box', models: ['qwen3'] }, 2);
expect(status).toBeNull();
// Give the loser of the race a turn to actually reject and hit its own .catch —
// an unswallowed rejection here would surface as an "Unhandled Errors" failure
// for the whole test file, not a failed assertion in this test.
@@ -0,0 +1,68 @@
/**
* @fileoverview `guardSessionConfigDirHome` probes a session-supplied configDir home before
* the apply runs synchronous fs calls on it (the #516 rule): a home that does not answer is
* hidden from the apply, so it links from the server's own home instead and says so.
*
* The probe is mocked: a mount that never answers is the case under test, and a real one
* cannot be made here.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('../src/utils/bounded-path-probe.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/utils/bounded-path-probe.js')>();
return { ...actual, probePathKind: vi.fn() };
});
import { getCli } from '../src/config/cli-registry/registry.js';
import { probePathKind } from '../src/utils/bounded-path-probe.js';
import { guardSessionConfigDirHome } from '../src/web/routes/session-routes.js';
const probe = vi.mocked(probePathKind);
const deepseek = () => getCli('deepseek')!;
const env =
(values: Record<string, string>) =>
(name: string): string | undefined =>
values[name];
describe('guardSessionConfigDirHome', () => {
afterEach(() => {
probe.mockReset();
vi.restoreAllMocks();
});
it('hides only the home when it does not answer, and warns that the server home is used', async () => {
probe.mockResolvedValue('unknown');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const lookup = await guardSessionConfigDirHome(deepseek(), env({ DSH_HOME: '/mnt/dead/dsh', OTHER: 'kept' }));
expect(lookup('DSH_HOME')).toBeUndefined();
expect(lookup('OTHER')).toBe('kept');
expect(probe).toHaveBeenCalledWith('/mnt/dead/dsh', { pastCap: true });
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0][0])).toContain('/mnt/dead/dsh');
});
it('passes a home that answers straight through', async () => {
probe.mockResolvedValue('directory');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const base = env({ DSH_HOME: '/home/u/.dsh' });
expect(await guardSessionConfigDirHome(deepseek(), base)).toBe(base);
expect(warn).not.toHaveBeenCalled();
});
it('never probes a relative home: the apply refuses to join one', async () => {
const base = env({ DSH_HOME: 'relative/dsh' });
expect(await guardSessionConfigDirHome(deepseek(), base)).toBe(base);
expect(probe).not.toHaveBeenCalled();
});
it('never probes for a CLI with no linkFromRealHome recipe', async () => {
const base = env({ CODEX_HOME: '/mnt/dead/codex' });
expect(await guardSessionConfigDirHome(getCli('codex')!, base)).toBe(base);
expect(probe).not.toHaveBeenCalled();
});
});
+207
View File
@@ -0,0 +1,207 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getCli } from '../src/config/cli-registry/index.js';
import { buildCustomModelInjection } from '../src/custom-model-injection.js';
import {
applyCustomModelInjection,
removeConfigDir,
customModelConfigDir,
} from '../src/custom-model-injection-apply.js';
afterEach(() => {
removeConfigDir(customModelConfigDir('deepseek-probe'));
vi.unstubAllEnvs();
});
describe('DeepSeek custom endpoint isolation', () => {
it('uses the chosen model and limits without changing installed profiles or embedding the key', () => {
const realHome = join(homedir(), '.dsh');
const profile = join(realHome, 'profiles', 'dsh-tui');
mkdirSync(profile, { recursive: true });
mkdirSync(join(realHome, 'sessions'), { recursive: true });
writeFileSync(join(realHome, 'settings.yaml'), 'theme: dark');
const original = join(profile, 'cordis.patch.yml');
writeFileSync(original, 'original profile');
vi.stubEnv('DSH_HOME', realHome);
const entry = getCli('deepseek');
if (!entry) throw new Error('Missing DeepSeek entry');
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
const applied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
if (!applied?.configDir) throw new Error('Missing isolated profile');
const content = readFileSync(join(applied.configDir, 'cordis.patch.yml'), 'utf8');
expect(content).not.toContain(endpoint.apiKey);
// The dedicated key var, never DEEPSEEK_API_KEY: dsh's own llm-deepseek/web-search
// rows read that too, and an in-session /model switch back to the official route must
// not take the custom endpoint's key to api.deepseek.com with it.
expect(applied.envOverrides.CODEMAN_CUSTOM_MODEL_API_KEY).toBe(endpoint.apiKey);
expect(applied.envOverrides.DEEPSEEK_API_KEY).toBeUndefined();
const patch = JSON.parse(content);
const provider = patch.find((row: { id: string }) => row.id === 'llm-pi-ai').config.providers['codeman-custom'];
expect(provider.api).toBe('openai-completions');
expect(provider.baseURL).toBe('http://box:8080/v1');
expect(provider.models).toEqual([
{ id: 'chosen-model', name: 'chosen-model', contextWindow: 131072, maxTokens: 8192 },
]);
expect(patch.find((row: { id: string }) => row.id === 'dsh-tui').config).toEqual({
provider: 'codeman-custom',
model: 'chosen-model',
});
expect(realpathSync(join(applied.configDir, 'profiles'))).toBe(join(realHome, 'profiles'));
// The rest of the linkFromRealHome list, consumed generically: `sessions` is the
// transcript root removeConfigDir would otherwise delete, and the user's settings.yaml
// must keep applying through the redirect.
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(realHome, 'sessions'));
expect(realpathSync(join(applied.configDir, 'settings.yaml'))).toBe(join(realHome, 'settings.yaml'));
// Re-apply onto the same config dir (boot recovery re-runs this every restart): the
// lstat guard must skip every existing link, which the old existsSync check missed:
// recreating them would raise EEXIST.
const reapplied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
expect(reapplied?.envOverrides.DSH_HOME).toBe(applied.configDir);
removeConfigDir(applied.configDir);
expect(readFileSync(original, 'utf8')).toBe('original profile');
});
it('creates missing directory targets before linking, skips a missing file target, stays fine on re-apply', () => {
// A fresh install (or relocated DSH_HOME) has never run a turn, so the real home
// carries none of the linked entries. Linking a missing DIRECTORY left a dangling
// symlink whose every write dsh attempts THROUGH it failed with ENOENT on EVERY turn,
// and the lstat guard kept that dangling link forever. Directories are now created
// (0700, what dsh itself does on a first turn) before linking; a missing FILE
// (`settings.yaml`) is simply skipped. A stubbed HOME and CODEMAN_DATA_DIR keep both
// the fake real home and the generated dir off the developer's own dotfiles.
const sandbox = mkdtempSync(join(tmpdir(), 'dsh-fresh-home-'));
const realHome = join(sandbox, '.dsh');
mkdirSync(realHome, { recursive: true }); // exists, but no sessions/, no profiles/, no settings.yaml
vi.stubEnv('HOME', sandbox);
vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data'));
vi.stubEnv('DSH_HOME', ''); // force resolution down to the recipe's `realHomeFallback`
try {
const entry = getCli('deepseek');
if (!entry) throw new Error('Missing DeepSeek entry');
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
const applied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
if (!applied?.configDir) throw new Error('Missing isolated profile');
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(realHome, 'sessions'));
expect(realpathSync(join(applied.configDir, 'profiles'))).toBe(join(realHome, 'profiles'));
expect(statSync(join(realHome, 'sessions')).mode & 0o777).toBe(0o700);
// The missing FILE target is skipped outright, not a dangling link either: lstat
// must find nothing, so a later apply (once the file exists for real) links it.
expect(existsSync(join(realHome, 'settings.yaml'))).toBe(false);
expect(readlinkSafe(join(applied.configDir, 'settings.yaml'))).toBeUndefined();
// Re-apply stays fine (boot recovery re-runs it every restart): idempotent...
const reapplied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
expect(reapplied?.envOverrides.DSH_HOME).toBe(applied.configDir);
// ...and a target deleted AFTER linking keeps the existing link untouched (the
// lstat guard), rather than recreating it and throwing EEXIST.
rmSync(join(realHome, 'sessions'), { recursive: true, force: true });
expect(
applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072)?.envOverrides.DSH_HOME
).toBe(applied.configDir);
expect(readlinkSync(join(applied.configDir, 'sessions'))).toBe(join(realHome, 'sessions'));
} finally {
rmSync(sandbox, { recursive: true, force: true });
}
});
it('links from the SESSION-supplied DSH_HOME, not the server environment one', () => {
// Both web routes pass the session's own env-override lookup as the seventh
// argument; a user who relocated the dsh home via an allowlisted DSH_HOME
// override must get their state linked from THAT directory, not the server's.
const sandbox = mkdtempSync(join(tmpdir(), 'dsh-session-home-'));
const serverHome = join(sandbox, 'server-home');
const sessionHome = join(sandbox, 'session-home');
mkdirSync(join(sessionHome, 'sessions'), { recursive: true });
mkdirSync(join(sessionHome, 'profiles'), { recursive: true });
writeFileSync(join(sessionHome, 'settings.yaml'), 'theme: light');
vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data'));
vi.stubEnv('DSH_HOME', serverHome);
try {
const entry = getCli('deepseek');
if (!entry) throw new Error('Missing DeepSeek entry');
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
const applied = applyCustomModelInjection(
entry,
endpoint,
'chosen-model',
'deepseek-probe',
131072,
undefined,
(k) => (k === 'DSH_HOME' ? sessionHome : undefined)
);
if (!applied?.configDir) throw new Error('Missing isolated profile');
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(sessionHome, 'sessions'));
expect(realpathSync(join(applied.configDir, 'profiles'))).toBe(join(sessionHome, 'profiles'));
expect(realpathSync(join(applied.configDir, 'settings.yaml'))).toBe(join(sessionHome, 'settings.yaml'));
// The server-environment home was never consulted, let alone created.
expect(existsSync(serverHome)).toBe(false);
} finally {
rmSync(sandbox, { recursive: true, force: true });
}
});
it('never self-links when the session override IS its own config dir; falls through to the server home', () => {
// Boot recovery re-applies onto a session already holding a previous injection's
// DSH_HOME value; linking that value would point the isolated directory at itself.
const sandbox = mkdtempSync(join(tmpdir(), 'dsh-self-home-'));
const serverHome = join(sandbox, 'server-home');
mkdirSync(join(serverHome, 'sessions'), { recursive: true });
vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data'));
vi.stubEnv('DSH_HOME', serverHome);
try {
const entry = getCli('deepseek');
if (!entry) throw new Error('Missing DeepSeek entry');
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
const applied = applyCustomModelInjection(
entry,
endpoint,
'chosen-model',
'deepseek-probe',
131072,
undefined,
(k) => (k === 'DSH_HOME' ? customModelConfigDir('deepseek-probe') : undefined)
);
if (!applied?.configDir) throw new Error('Missing isolated profile');
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(serverHome, 'sessions'));
} finally {
rmSync(sandbox, { recursive: true, force: true });
}
});
it('skips a relative DSH_HOME rather than creating it under the server cwd', () => {
// A session may supply DSH_HOME; `work` would otherwise mkdir `work/sessions` in
// the SERVER's working directory and leave relative links that dangle (a relative
// home resolves against the CLI's cwd, which the server cannot know). Skipping it
// falls through to the recipe's `realHomeFallback`, and nothing is created under cwd.
const sandbox = mkdtempSync(join(tmpdir(), 'dsh-relative-home-'));
const fallbackHome = join(sandbox, '.dsh');
mkdirSync(fallbackHome, { recursive: true });
const relativeHome = `dsh-relative-probe-${process.pid}-${Date.now()}`;
vi.stubEnv('HOME', sandbox); // the fallback resolves under homedir()
vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data'));
vi.stubEnv('DSH_HOME', relativeHome);
try {
const entry = getCli('deepseek');
if (!entry) throw new Error('Missing DeepSeek entry');
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
const applied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
if (!applied?.configDir) throw new Error('Missing isolated profile');
expect(existsSync(join(process.cwd(), relativeHome))).toBe(false);
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(fallbackHome, 'sessions'));
} finally {
rmSync(sandbox, { recursive: true, force: true });
}
});
});
/** readlinkSync without the throw an absent link causes (ENOENT -> undefined). */
function readlinkSafe(path: string): string | undefined {
try {
return readlinkSync(path);
} catch {
return undefined;
}
}
File diff suppressed because it is too large. Load diff
+208
View File
@@ -0,0 +1,208 @@
// @vitest-environment node
/**
* @fileoverview The glue between WebServer and the Discord bridge: alerts reach it even with no
* Web Push subscription (with the rendered screen as the tail), and tab events reach it routed by
* kind. The bridge and routes are tested on their own; this pins the lines that make the feature
* exist. A real WebServer, never started, with the bridge replaced by a recorder.
*
* Port: N/A (no server start).
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { sendNotification } = vi.hoisted(() => ({ sendNotification: vi.fn(async () => undefined) }));
vi.mock('web-push', () => ({
default: { sendNotification, setVapidDetails: vi.fn(), generateVAPIDKeys: vi.fn() },
}));
import { WebServer } from '../src/web/server.js';
import { SseEvent } from '../src/web/sse-events.js';
type Internals = {
pushStore: { getAll: () => never[]; dispose: () => void };
discordBridge: Record<string, ReturnType<typeof vi.fn>>;
sessions: Map<string, unknown>;
mux: { capturePaneText?: (name: string) => string | null };
lastResponseFor?: (session: unknown, context?: string) => Promise<{ text: string }>;
sendPushNotifications: (event: string, data: Record<string, unknown>) => Promise<void>;
broadcast: (event: string, data: unknown) => void;
cleanupSession: (id: string, killMux?: boolean, reason?: string) => Promise<void>;
discardPartiallyBuiltSession: (id: string) => Promise<void>;
};
function server(): Internals {
const s = new WebServer(0, false, true, '127.0.0.1', 'box') as unknown as Internals;
s.pushStore = { getAll: () => [], dispose: () => undefined };
s.discordBridge = {
notify: vi.fn(async () => null),
sessionCreated: vi.fn(async () => undefined),
sessionRenamed: vi.fn(async () => undefined),
sessionDeleted: vi.fn(async () => undefined),
forgetTab: vi.fn(),
stop: vi.fn(),
};
return s;
}
beforeEach(() => sendNotification.mockClear());
describe('bridge deps', () => {
it('agentExited is true only for a session whose pane exit was observed', () => {
const s = new WebServer(0, false, true, '127.0.0.1', 'box') as unknown as Internals & {
discordBridge: { deps: { agentExited: (id: string) => boolean } };
};
s.sessions.set('dead', { paneExit: { observedAt: 1 } });
s.sessions.set('alive', {});
const { agentExited } = s.discordBridge.deps;
expect(agentExited('dead')).toBe(true);
expect(agentExited('alive')).toBe(false);
expect(agentExited('missing')).toBe(false);
});
it("types a reply as the person's own input, so it can name the tab", async () => {
const s = new WebServer(0, false, true, '127.0.0.1', 'box') as unknown as Internals & {
discordBridge: { deps: { sendInput: (id: string, text: string) => Promise<boolean> } };
};
const writeViaMux = vi.fn(async () => true);
s.sessions.set('s1', { writeViaMux });
expect(await s.discordBridge.deps.sendInput('s1', 'yes\r')).toBe(true);
expect(writeViaMux).toHaveBeenCalledWith('yes\r', { fromUser: true });
expect(await s.discordBridge.deps.sendInput('missing', 'yes\r')).toBe(false);
});
});
describe('sendPushNotifications -> Discord', () => {
it('posts a permission prompt with zero push subscriptions, with the rendered screen as the tail', async () => {
const s = server();
s.sessions.set('s1', { muxName: 'codeman-s1', textOutput: '', mode: 'claude' });
const capture = vi.fn(() => 'Which option?\n──────────\n 1. Cap GPUs\n\n 2. Cap CPU\n');
s.mux = { capturePaneText: capture };
await s.sendPushNotifications('hook:permission_prompt', {
sessionId: 's1',
sessionName: 'w1-app',
tool_name: 'Bash',
});
expect(s.discordBridge.notify).toHaveBeenCalledTimes(1);
const msg = s.discordBridge.notify.mock.calls[0][0];
expect(msg).toMatchObject({
event: 'hook:permission_prompt',
title: 'Permission Required',
body: '[w1-app] Tool: Bash',
urgency: 'critical',
sessionId: 's1',
sessionName: 'w1-app',
});
// A function, so the screen is read (a synchronous tmux exec) only once the bridge will post.
expect(typeof msg.tail).toBe('function');
expect(capture).not.toHaveBeenCalled();
expect(msg.tail()).toBe('Which option?\n 1. Cap GPUs\n 2. Cap CPU');
expect(capture).toHaveBeenCalledTimes(1);
expect(sendNotification).not.toHaveBeenCalled();
});
it('reads the whole last message from the transcript on a finished turn, and keeps the screen for a dialog', async () => {
const s = server();
s.sessions.set('s5', { muxName: 'codeman-s5', textOutput: '', mode: 'claude' });
const reader = vi.fn(async () => ({ text: 'The full answer.\n\n- one\n- two', timestamp: '2026-10-30T00:00:00Z' }));
s.lastResponseFor = reader;
s.mux = { capturePaneText: () => 'screen\n❯\n status' };
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's5', sessionName: 'w5' });
const idle = s.discordBridge.notify.mock.calls[0][0];
expect(await idle.fullText()).toEqual({
text: 'The full answer.\n\n- one\n- two',
version: '2026-10-30T00:00:00Z',
});
expect(idle.tail()).toBe('screen'); // the composer row and the status under it are cut
await s.sendPushNotifications('hook:permission_prompt', { sessionId: 's5', sessionName: 'w5' });
const dialog = s.discordBridge.notify.mock.calls[1][0];
expect(dialog.fullText).toBeUndefined();
expect(dialog.tail()).toBe('screen\n❯\n status');
});
it.each(['claude', 'codex', 'opencode', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp', 'copilot'])(
'a finished turn in a %s tab posts the whole last message',
async (mode) => {
const s = server();
s.sessions.set('s6', { muxName: 'codeman-s6', textOutput: '', mode });
s.lastResponseFor = vi.fn(async () => ({ text: '1. First?\n2. Second?\n3. Third?' }));
s.mux = { capturePaneText: () => 'screen' };
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's6', sessionName: 'w6' });
const msg = s.discordBridge.notify.mock.calls[0][0];
expect((await msg.fullText()).text).toBe('1. First?\n2. Second?\n3. Third?');
}
);
it('a shell tab has no turns, so it keeps the screen tail', async () => {
const s = server();
s.sessions.set('s7', { muxName: 'codeman-s7', textOutput: '', mode: 'shell' });
s.lastResponseFor = vi.fn(async () => ({ text: 'x' }));
s.mux = { capturePaneText: () => 'prompt$' };
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's7', sessionName: 'w7' });
const msg = s.discordBridge.notify.mock.calls[0][0];
expect(await msg.fullText()).toBeUndefined();
expect(msg.tail()).toBe('prompt$');
});
it('falls back to plain text output when there is no tmux pane, and to no tail for an unknown session', async () => {
const s = server();
s.sessions.set('s2', { muxName: null, textOutput: 'plain answer\n', mode: 'claude' });
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's2', sessionName: 'w2' });
expect(s.discordBridge.notify.mock.calls[0][0].tail()).toBe('plain answer');
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 'gone', sessionName: 'w3' });
expect(s.discordBridge.notify.mock.calls[1][0].tail).toBeUndefined();
});
it('sends nothing for an event Web Push does not carry', async () => {
const s = server();
await s.sendPushNotifications('session:created', { sessionId: 's1', sessionName: 'w1' });
expect(s.discordBridge.notify).not.toHaveBeenCalled();
});
});
describe('broadcast -> Discord tab lifecycle', () => {
it('routes created, updated and deleted by kind, reading both payload shapes', () => {
const s = server();
s.broadcast(SseEvent.SessionCreated, { session: { id: 's1', name: 'w1', createdAt: 123 } });
expect(s.discordBridge.sessionCreated).toHaveBeenCalledWith('s1', 'w1');
s.broadcast(SseEvent.SessionUpdated, { id: 's1', name: 'renamed' });
expect(s.discordBridge.sessionRenamed).toHaveBeenCalledWith('s1', 'renamed');
});
it('does not touch the thread on session:deleted, which a detach also fires', () => {
const s = server();
s.broadcast(SseEvent.SessionDeleted, { id: 's1' });
expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled();
});
it('tells the bridge about a real delete (killMux true) but not a detach (killMux false)', async () => {
const s = server();
await s.cleanupSession('gone', false);
expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled();
await s.cleanupSession('gone', true);
expect(s.discordBridge.sessionDeleted).toHaveBeenCalledWith('gone');
});
it('a discarded half-built session clears the per-tab state but keeps its thread', async () => {
const s = server();
s.sessions.set('rebuild', {
owner: 'u',
ralphTracker: { stopWatchingFixPlan: () => undefined, fullReset: () => undefined },
removeAllListeners: () => undefined,
stop: async () => undefined,
});
await s.discardPartiallyBuiltSession('rebuild');
expect(s.discordBridge.forgetTab).toHaveBeenCalledWith('rebuild');
expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled();
});
it('ignores an event with no session id', () => {
const s = server();
s.broadcast(SseEvent.SessionUpdated, {});
expect(s.discordBridge.sessionRenamed).not.toHaveBeenCalled();
});
it('closes the bridge when the server stops', async () => {
const s = server();
await (s as unknown as { stop: () => Promise<void> }).stop();
expect(s.discordBridge.stop).toHaveBeenCalledTimes(1);
});
});
+82
View File
@@ -0,0 +1,82 @@
// @vitest-environment node
/**
* @fileoverview The Discord startup hint reaches Claude as `--append-system-prompt`, only while the
* bridge and its `startupHint` setting are on, and never changes a launch that does not use it.
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getCli } from '../src/config/cli-registry/registry.js';
import { buildSpawnCommandFromRegistry } from '../src/session-cli-registry-bridge.js';
import {
DiscordBridge,
DISCORD_STARTUP_HINT,
getDiscordStartupHint,
writeDiscordConfig,
DEFAULT_DISCORD_CONFIG,
} from '../src/discord-bridge.js';
const SID = '0f9c2b14-1111-2222-3333-444455556666';
const BASE = 'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666"';
function launch(mode: 'claude' | 'codex'): string | undefined {
return buildSpawnCommandFromRegistry(getCli(mode)!, { mode, sessionId: SID, claudeCliVersion: null });
}
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'discord-hint-'));
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
function bridge() {
return new DiscordBridge({
configDir: dir,
fetchImpl: async () => new Response('{}'),
sendInput: async () => true,
socketFactory: () => ({ send() {}, close() {}, on() {} }),
});
}
describe('startup hint on the Claude launch', () => {
it('is absent by default, so a launch is byte-identical to before', () => {
expect(getDiscordStartupHint()).toBeUndefined();
expect(launch('claude')).toBe(BASE);
});
it('is appended, single-quoted, while the bridge is on', async () => {
await writeDiscordConfig(dir, {
...DEFAULT_DISCORD_CONFIG,
enabled: true,
token: 'tok',
guildId: '111111111111111111',
parentChannelId: '222222222222222222',
allowedUserIds: ['333333333333333333'],
});
const b = bridge();
await b.reload();
expect(getDiscordStartupHint()).toBe(DISCORD_STARTUP_HINT);
expect(launch('claude')).toBe(`${BASE} --append-system-prompt '${DISCORD_STARTUP_HINT}'`);
expect(DISCORD_STARTUP_HINT).not.toContain("'");
expect(launch('codex')).not.toContain('append-system-prompt');
b.stop();
expect(launch('claude')).toBe(BASE);
});
it('is absent when startupHint is switched off', async () => {
await writeDiscordConfig(dir, {
...DEFAULT_DISCORD_CONFIG,
enabled: true,
token: 'tok',
guildId: '111111111111111111',
parentChannelId: '222222222222222222',
allowedUserIds: ['333333333333333333'],
startupHint: false,
});
const b = bridge();
await b.reload();
expect(launch('claude')).toBe(BASE);
b.stop();
});
});
+16 -1
View File
@@ -26,7 +26,7 @@
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { readFileSync, mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
import { readFileSync, mkdtempSync, rmSync, writeFileSync, mkdirSync, chownSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@@ -309,6 +309,21 @@ describe('Update-Codeman.sh (the scripted major-update path — docker/README.md
const casesPath = join(dir, 'cases');
const socketPath = join(dir, 'docker.sock'); // deliberately NOT a real socket — see above
mkdirSync(appdataPath);
// Both scripts derive PUID:PGID from the appdata owner and REFUSE a
// root-owned one (correctly: the container must never run as root).
// mkdirSync gives it the runner's uid, so under a root runner (a CI or
// agent container) the update path stopped at that guard before its
// build. Give it the non-root owner docker/README.md's own
// `chown -R 99:100` example sets up, as a real deployment would have.
if (process.getuid?.() === 0) {
try {
chownSync(appdataPath, 99, 100);
} catch {
// No CAP_CHOWN (root with --cap-drop ALL, as Codeman's own Docker
// case containers run) or a single-uid user namespace: leave it
// root-owned and let the scripts' own guard refuse, as before.
}
}
mkdirSync(casesPath);
writeFileSync(socketPath, '');
+165
View File
@@ -0,0 +1,165 @@
/**
* Local edit-source reader against a temp projects dir: the transcript lookup, byte-offset
* reads of the main transcript and its subagents, and the image probe.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
findClaudeTranscript,
probeLocalPaths,
readClaudeEditSources,
resolveClaudeTranscript,
} from '../src/edit-history-local-source.js';
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'edit-src-'));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
function put(rel: string, content: string | Buffer, mtimeSec?: number): string {
const path = join(root, rel);
mkdirSync(join(path, '..'), { recursive: true });
writeFileSync(path, content);
if (mtimeSec) utimesSync(path, mtimeSec, mtimeSec);
return path;
}
describe('findClaudeTranscript', () => {
it('returns null when no transcript exists under any id', async () => {
put('p/other.jsonl', 'x\n');
expect(await findClaudeTranscript(['sid'], root)).toBeNull();
expect(await findClaudeTranscript(['sid'], join(root, 'missing'))).toBeNull();
});
it('takes the first id that has a transcript, the newest one across project dirs', async () => {
put('a/sid.jsonl', 'old\n', 1000);
const newest = put('b/sid.jsonl', 'new\n', 2000);
put('c/second.jsonl', 'x\n');
expect(await findClaudeTranscript(['bad id!', 'sid', 'second'], root)).toBe(newest);
});
});
describe('resolveClaudeTranscript', () => {
it('takes the watcher path (what the hooks reported) as is', async () => {
expect(await resolveClaudeTranscript('/hooked/conv.jsonl', ['sid'], join(root, 'cfg'))).toBe('/hooked/conv.jsonl');
});
it("without one, looks under the session's own claude config dir", async () => {
const path = put('cfg/projects/p/sid.jsonl', 'x\n');
expect(await resolveClaudeTranscript(null, ['sid'], join(root, 'cfg'))).toBe(path);
expect(await resolveClaudeTranscript(undefined, ['other'], join(root, 'cfg'))).toBeNull();
});
});
describe('readClaudeEditSources', () => {
it('returns null for a missing file or a path that is not an absolute <id>.jsonl', async () => {
const path = put('p/sid.jsonl', 'x\n');
expect(await readClaudeEditSources(join(root, 'p/gone.jsonl'), [], 100)).toBeNull();
expect(await readClaudeEditSources('p/sid.jsonl', [], 100)).toBeNull();
expect(await readClaudeEditSources(path.replace('.jsonl', '.txt'), [], 100)).toBeNull();
expect(await readClaudeEditSources(put('p/bad id.jsonl', 'x\n'), [], 100)).toBeNull();
});
it('names the conversation after the file', async () => {
const res = await readClaudeEditSources(put('p/conv-1.jsonl', 'x\n'), [], 100);
expect(res?.transcriptId).toBe('conv-1');
});
it('reads a new file from its end within the budget, a requested one from its offset', async () => {
const path = put('p/sid.jsonl', '0123456789');
const fresh = await readClaudeEditSources(path, [], 4);
expect(fresh?.files).toEqual([{ relPath: 'main', size: 10, startOffset: 6, chunk: Buffer.from('6789') }]);
const cont = await readClaudeEditSources(path, [{ relPath: 'main', offset: 3, len: 2 }], 100);
expect(cont?.files[0]).toMatchObject({ startOffset: 3, chunk: Buffer.from('34') });
});
it('clamps an offset past the end to the size (a replaced, shorter file)', async () => {
const path = put('p/sid.jsonl', 'abc');
const res = await readClaudeEditSources(path, [{ relPath: 'main', offset: 50 }], 100);
expect(res?.files[0]).toMatchObject({ size: 3, startOffset: 3, chunk: Buffer.alloc(0) });
});
it('lists subagents with their meta for new ones only, and shares the budget main first', async () => {
const path = put('p/sid.jsonl', 'MAIN');
put('p/sid/subagents/agent-a1.jsonl', 'AAAA');
put('p/sid/subagents/agent-a1.meta.json', '{"description":"Fix it"}');
put('p/sid/subagents/agent-b2.jsonl', 'BBBB');
put('p/sid/subagents/agent-b2.meta.json', '{"description":"Other"}');
put('p/sid/subagents/notes.txt', 'ignored');
const res = await readClaudeEditSources(path, [{ relPath: 'subagents/agent-b2.jsonl', offset: 0 }], 6);
expect(res?.files.map((f) => [f.relPath, f.startOffset, f.chunk.toString()])).toEqual([
['main', 0, 'MAIN'],
['subagents/agent-a1.jsonl', 2, 'AA'],
['subagents/agent-b2.jsonl', 0, ''],
]);
expect(res?.metas).toEqual({ a1: '{"description":"Fix it"}' });
});
it('ignores invalid requests', async () => {
const path = put('p/sid.jsonl', 'abcdef');
const res = await readClaudeEditSources(
path,
[
{ relPath: '../etc/passwd', offset: 0 },
{ relPath: 'main', offset: -1 },
],
3
);
// The invalid main request is dropped, so main is read like a new file.
expect(res?.files[0]).toMatchObject({ startOffset: 3, chunk: Buffer.from('def') });
});
it('keeps multibyte bytes intact (byte offsets, not string lengths)', async () => {
const path = put('p/sid.jsonl', Buffer.from('äöü\n', 'utf8'));
const res = await readClaudeEditSources(path, [{ relPath: 'main', offset: 2 }], 100);
expect(res?.files[0].chunk).toEqual(Buffer.from('äöü\n', 'utf8').subarray(2));
});
it('with onlyRequested, reads the requested files and nothing else', async () => {
const path = put('p/sid.jsonl', 'MAIN');
put('p/sid/subagents/agent-a1.jsonl', 'AAAA');
put('p/sid/subagents/agent-b2.jsonl', 'BBBB');
const res = await readClaudeEditSources(path, [{ relPath: 'subagents/agent-b2.jsonl', offset: 1, len: 2 }], 100, {
onlyRequested: true,
});
expect(res?.files.map((f) => [f.relPath, f.startOffset, f.chunk.toString()])).toEqual([
['subagents/agent-b2.jsonl', 1, 'BB'],
]);
});
it('reads the end of a block that does not fit the budget', async () => {
const path = put('p/sid.jsonl', '0123456789');
const res = await readClaudeEditSources(path, [{ relPath: 'main', offset: 2, len: 6 }], 3);
expect(res?.files[0]).toMatchObject({ startOffset: 5, chunk: Buffer.from('567') });
});
});
describe('probeLocalPaths', () => {
it('reports size and mtime of files, expands ~/ against home, null for missing and directories', async () => {
put('home/img.png', 'PNG!', 1_700_000_000);
mkdirSync(join(root, 'dir'));
const res = await probeLocalPaths(['~/img.png', join(root, 'dir'), join(root, 'nope.png')], join(root, 'home'));
expect(res[0]).toEqual({ realPath: join(root, 'home/img.png'), kind: 'file', size: 4, mtimeMs: 1_700_000_000_000 });
expect(res[1]).toBeNull();
expect(res[2]).toBeNull();
});
it('touches a path only after the bounded probe saw a file there', async () => {
put('a.png', 'PNG!', 1_700_000_000);
put('b.png', 'PNG!', 1_700_000_000);
const asked: string[] = [];
const res = await probeLocalPaths([join(root, 'a.png'), join(root, 'b.png'), 'rel.png'], root, async (p) => {
asked.push(p);
return p.endsWith('a.png') ? 'unknown' : 'file';
});
// a.png exists, but its filesystem did not answer the bounded probe: no unbounded stat.
expect(res.map((r) => r?.realPath ?? null)).toEqual([null, join(root, 'b.png'), null]);
expect(asked).toEqual([join(root, 'a.png'), join(root, 'b.png')]); // relative: never probed
});
});
+420
View File
@@ -0,0 +1,420 @@
/**
* Edit history parser: claude transcript lines → edit records and Bash image candidates.
* Fixture lines mirror real transcript records (Claude Code 2.1.300), trimmed to the
* fields the parser reads plus the bulky ones it must ignore (`originalFile`, `content`).
*/
import { describe, expect, it } from 'vitest';
import {
classifyEditPath,
extractImagePathCandidates,
MAX_HUNK_BYTES,
MAX_IMAGE_CANDIDATES,
MAX_PENDING_BASH,
MAX_PENDING_COMMAND_CHARS,
MAX_PENDING_FIELD_CHARS,
parseClaudeEditLines,
} from '../src/edit-history-parse.js';
const CWD = '/work/repo';
function toolResultLine(id: string, ts: string, toolUseResult: unknown, extra: Record<string, unknown> = {}): string {
return JSON.stringify({
type: 'user',
isSidechain: false,
timestamp: ts,
cwd: CWD,
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
toolUseResult,
...extra,
});
}
function bashUseLine(id: string, ts: string, command: string, extra: Record<string, unknown> = {}): string {
return JSON.stringify({
type: 'assistant',
isSidechain: false,
timestamp: ts,
cwd: CWD,
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] },
...extra,
});
}
const hunk = (lines: string[]) => ({ oldStart: 3, oldLines: 2, newStart: 3, newLines: 3, lines });
const editLine = toolResultLine('toolu_edit', '2026-10-15T19:01:28.880Z', {
filePath: '/work/repo/src/app.ts',
oldString: 'a',
newString: 'b',
originalFile: 'x'.repeat(5000),
structuredPatch: [hunk([' keep', '-old', '+new', '+added'])],
userModified: false,
replaceAll: false,
});
describe('parseClaudeEditLines: edits', () => {
it('turns an Edit result into a record without the whole-file fields', () => {
const { records } = parseClaudeEditLines(editLine + '\n');
expect(records).toEqual([
{
id: 'toolu_edit',
ts: Date.parse('2026-10-15T19:01:28.880Z'),
path: '/work/repo/src/app.ts',
kind: 'code',
tool: 'Edit',
created: false,
added: 2,
removed: 1,
hunks: [hunk([' keep', '-old', '+new', '+added'])],
truncated: false,
},
]);
expect(JSON.stringify(records)).not.toContain('xxxxx');
});
it('reads replaceAll edits like any other Edit', () => {
const line = toolResultLine('toolu_all', '2026-10-15T19:02:00.000Z', {
filePath: '/work/repo/a.ts',
oldString: 'x',
newString: 'y',
originalFile: '',
structuredPatch: [hunk(['-x', '+y']), { ...hunk(['-x', '+y']), oldStart: 9, newStart: 9 }],
userModified: false,
replaceAll: true,
});
const [rec] = parseClaudeEditLines(line).records;
expect(rec).toMatchObject({ tool: 'Edit', added: 2, removed: 2 });
expect(rec.hunks).toHaveLength(2);
});
it('marks a Write that created the file, and one that replaced it', () => {
const create = toolResultLine('toolu_c', '2026-10-15T19:03:00.000Z', {
type: 'create',
filePath: '/work/repo/new.md',
content: 'hello\nworld',
structuredPatch: [],
originalFile: null,
});
const update = toolResultLine('toolu_u', '2026-10-15T19:04:00.000Z', {
type: 'update',
filePath: '/work/repo/new.md',
content: 'hi',
structuredPatch: [hunk(['-hello', '-world', '+hi'])],
originalFile: 'hello\nworld',
});
const { records } = parseClaudeEditLines([create, update].join('\n'));
expect(records.map((r) => [r.tool, r.created, r.added, r.removed])).toEqual([
['Write', true, 2, 0],
['Write', false, 1, 2],
]);
expect(records[0].hunks).toEqual([
{ oldStart: 0, oldLines: 0, newStart: 1, newLines: 2, lines: ['+hello', '+world'] },
]);
});
it('a created file’s diff drops the trailing newline and is capped like any hunk', () => {
const big = 'x'.repeat(1000) + '\n';
const line = toolResultLine('toolu_big', '2026-10-15T19:03:30.000Z', {
type: 'create',
filePath: '/work/repo/big.txt',
content: big.repeat(100),
structuredPatch: [],
originalFile: null,
});
const [rec] = parseClaudeEditLines(line).records;
expect(rec.added).toBe(100);
expect(rec.truncated).toBe(true);
expect(rec.hunks[0].lines.length).toBeLessThan(100);
expect(rec.hunks[0].lines.every((l) => l === '+' + 'x'.repeat(1000))).toBe(true);
});
it('classifies an image written by Write as an image', () => {
const line = toolResultLine('toolu_svg', '2026-10-15T19:05:00.000Z', {
type: 'create',
filePath: '/work/repo/icon.SVG',
content: '<svg/>',
structuredPatch: [],
originalFile: null,
});
expect(parseClaudeEditLines(line).records[0].kind).toBe('image');
});
it('keeps a result of an unknown edit tool that has a path and a patch', () => {
const line = toolResultLine('toolu_nb', '2026-10-15T19:06:00.000Z', {
filePath: '/work/repo/n.ipynb',
structuredPatch: [hunk(['+cell'])],
});
expect(parseClaudeEditLines(line).records[0]).toMatchObject({ tool: 'edit', added: 1 });
});
it('skips failed tool results (a string), unrelated results and lines that do not parse', () => {
const failed = toolResultLine('toolu_err', '2026-10-15T19:07:00.000Z', 'Error: String to replace not found');
const read = toolResultLine('toolu_read', '2026-10-15T19:07:01.000Z', { type: 'text', file: { filePath: '/x' } });
const text = [failed, read, '{"type":"user",broken', '', 'not json'].join('\n');
expect(parseClaudeEditLines(text).records).toEqual([]);
});
it('skips a first line cut mid-record (a byte range starts inside it)', () => {
const cut = editLine.slice(40);
expect(parseClaudeEditLines(cut + '\n' + editLine).records).toHaveLength(1);
});
it('carries the subagent id of a sidechain line', () => {
const line = toolResultLine('toolu_sub', '2026-10-15T19:08:00.000Z', JSON.parse(editLine).toolUseResult, {
isSidechain: true,
agentId: 'a0038561ea4d57a42',
});
expect(parseClaudeEditLines(line).records[0].agentId).toBe('a0038561ea4d57a42');
});
it('caps an oversized patch and says so, counting lines from the whole patch', () => {
const big = Array.from({ length: 4000 }, (_, i) => `+line ${i} ${'y'.repeat(40)}`);
const line = toolResultLine('toolu_big', '2026-10-15T19:09:00.000Z', {
type: 'create',
filePath: '/work/repo/big.ts',
content: '',
structuredPatch: [hunk(big)],
originalFile: null,
});
const [rec] = parseClaudeEditLines(line).records;
expect(rec.truncated).toBe(true);
expect(rec.added).toBe(4000);
expect(JSON.stringify(rec.hunks).length).toBeLessThanOrEqual(MAX_HUNK_BYTES + 1024);
expect(rec.hunks[0].lines.length).toBeLessThan(4000);
});
});
describe('parseClaudeEditLines: Bash image candidates', () => {
const start = '2026-10-15T20:00:00.000Z';
const end = '2026-10-15T20:00:05.000Z';
it('pairs a Bash call with its result and collects image paths from command and output', () => {
const use = bashUseLine('toolu_b', start, 'python3 render.py --out out/frame.png');
const res = toolResultLine('toolu_b', end, {
stdout: 'Saved to: file:///tmp/gen/a b.png\nwrote /work/repo/out/thumb.webp.',
stderr: '',
interrupted: false,
isImage: false,
});
const { candidates, pending } = parseClaudeEditLines([use, res].join('\n'));
expect(pending).toEqual([]);
expect(candidates).toEqual([
{
toolUseId: 'toolu_b',
start: Date.parse(start),
end: Date.parse(end),
paths: ['/work/repo/out/frame.png', '/tmp/gen/a b.png', '/work/repo/out/thumb.webp'],
},
]);
});
it('yields no candidate for a Bash call without image paths', () => {
const use = bashUseLine('toolu_n', start, 'git status');
const res = toolResultLine('toolu_n', end, { stdout: 'clean', stderr: '', interrupted: false, isImage: false });
expect(parseClaudeEditLines([use, res].join('\n')).candidates).toEqual([]);
});
it('uses the tool_result text when the Bash call failed (toolUseResult is a string)', () => {
const use = bashUseLine('toolu_f', start, 'make render');
const res = JSON.stringify({
type: 'user',
timestamp: end,
cwd: CWD,
message: {
role: 'user',
content: [{ tool_use_id: 'toolu_f', type: 'tool_result', is_error: true, content: 'Exit 2\nwrote out/x.jpg' }],
},
toolUseResult: 'Error: Exit 2',
});
expect(parseClaudeEditLines([use, res].join('\n')).candidates[0].paths).toEqual(['/work/repo/out/x.jpg']);
});
it('hands an unanswered Bash call back as pending and resolves it in the next chunk', () => {
const first = parseClaudeEditLines(bashUseLine('toolu_p', start, 'convert a.svg out.png') + '\n');
expect(first.candidates).toEqual([]);
expect(first.pending).toEqual([
{ id: 'toolu_p', ts: Date.parse(start), command: 'convert a.svg out.png', cwd: CWD },
]);
const res = toolResultLine('toolu_p', end, { stdout: '', stderr: '', interrupted: false, isImage: false });
const second = parseClaudeEditLines(res, first.pending);
expect(second.pending).toEqual([]);
expect(second.candidates[0].paths).toEqual(['/work/repo/a.svg', '/work/repo/out.png']);
});
it('carries the subagent id onto the candidate', () => {
const use = bashUseLine('toolu_s', start, 'render > shot.png', { isSidechain: true, agentId: 'abc' });
const res = toolResultLine('toolu_s', end, { stdout: '', stderr: '', interrupted: false, isImage: false });
expect(parseClaudeEditLines([use, res].join('\n')).candidates[0].agentId).toBe('abc');
});
it('bounds the pending list, dropping the oldest', () => {
const lines = Array.from({ length: MAX_PENDING_BASH + 5 }, (_, i) =>
bashUseLine(`toolu_${i}`, start, 'sleep 1')
).join('\n');
const { pending } = parseClaudeEditLines(lines);
expect(pending).toHaveLength(MAX_PENDING_BASH);
expect(pending[0].id).toBe('toolu_5');
});
it(`keeps no call with an id, cwd or agent id over ${MAX_PENDING_FIELD_CHARS} characters (the store would refuse it on load)`, () => {
const long = 'x'.repeat(MAX_PENDING_FIELD_CHARS + 1);
const lines = [
bashUseLine(long, start, 'render > a.png'),
bashUseLine('toolu_cwd', start, 'render > a.png', { cwd: '/' + long }),
bashUseLine('toolu_agent', start, 'render > a.png', { agentId: long }),
bashUseLine('toolu_ok', start, 'render > a.png', { cwd: '/' + 'x'.repeat(MAX_PENDING_FIELD_CHARS - 1) }),
];
expect(parseClaudeEditLines(lines.join('\n')).pending.map((p) => p.id)).toEqual(['toolu_ok']);
});
it('keeps only the head of a long command (the pending list is persisted)', () => {
const command = 'render > out.png && ' + 'x'.repeat(MAX_PENDING_COMMAND_CHARS * 2);
const { pending } = parseClaudeEditLines(bashUseLine('toolu_long', start, command));
expect(pending[0].command).toBe(command.slice(0, MAX_PENDING_COMMAND_CHARS));
});
});
describe('parseClaudeEditLines: images sent to the user', () => {
it('takes the image attachments of a SendUserFile result, absolute paths only, without a window', () => {
const line = toolResultLine(
'toolu_send',
'2026-10-15T20:50:12.538Z',
{
caption: 'Vier Alternativen',
attachments: [
{ path: '/tmp/s/icons/bremse_alt1_pedal.png', file_uuid: 'x' },
{ path: '/tmp/s/icons/bremse_alt1_pedal.png' },
{ path: '/tmp/s/report.pdf' },
{ path: 'relative.png' },
{ nope: 1 },
],
},
{ agentId: 'a1' }
);
const { candidates, records } = parseClaudeEditLines(line);
expect(records).toEqual([]);
const at = Date.parse('2026-10-15T20:50:12.538Z');
expect(candidates).toEqual([
{
toolUseId: 'toolu_send',
start: at,
end: at,
paths: ['/tmp/s/icons/bremse_alt1_pedal.png'],
sent: true,
agentId: 'a1',
},
]);
});
});
describe('extractImagePathCandidates', () => {
it('keeps a colon inside a file:// path (timestamped names)', () => {
expect(extractImagePathCandidates('', 'Saved to file:///tmp/plot-12:30.png', '', CWD)).toEqual([
'/tmp/plot-12:30.png',
]);
});
it('resolves relative paths against cwd and keeps ~/ paths literal', () => {
expect(extractImagePathCandidates('cp a.png ../b.jpeg ~/shots/c.gif', '', '', CWD)).toEqual([
'/work/repo/a.png',
'/work/b.jpeg',
'~/shots/c.gif',
]);
});
it('reads quoted paths with spaces', () => {
expect(extractImagePathCandidates(`magick "in dir/x.png" 'out dir/y.avif'`, '', '', CWD)).toEqual([
'/work/repo/in dir/x.png',
'/work/repo/out dir/y.avif',
]);
});
it('ignores URLs, globs and names that only contain an image extension', () => {
const cmd = 'curl https://x.org/a.png; ls *.png out/frame-?.png; mv a.png.bak b.pngx; echo c.png';
expect(extractImagePathCandidates(cmd, '', '', CWD)).toEqual(['/work/repo/c.png']);
});
it('splits key=value and trailing punctuation off', () => {
expect(extractImagePathCandidates('render --out=out/a.png', 'done (out/b.png), see out/c.png.', '', CWD)).toEqual([
'/work/repo/out/a.png',
'/work/repo/out/b.png',
'/work/repo/out/c.png',
]);
});
it('deduplicates and caps the list', () => {
const many = Array.from({ length: MAX_IMAGE_CANDIDATES + 10 }, (_, i) => `f${i}.png`).join(' ');
const out = extractImagePathCandidates(`a.png a.png ${many}`, '', '', CWD);
expect(out).toHaveLength(MAX_IMAGE_CANDIDATES);
expect(out[0]).toBe('/work/repo/a.png');
expect(out[1]).toBe('/work/repo/f0.png');
});
it('drops relative paths when cwd is unknown', () => {
expect(extractImagePathCandidates('x.png /abs/y.png', '', '', '')).toEqual(['/abs/y.png']);
});
});
describe('extractImagePathCandidates: linear in the line length (#693 review)', () => {
// Claude Code keeps up to 30,000 characters of Bash output, all of it agent-printed, and the
// scan runs synchronously in the request. Before the fix one such line took ~2.7 s.
const N = 30_000;
// Generous: the gated suite runs at nice 19 next to the live service. Linear is ~1 ms.
const BUDGET_MS = 1000;
const timed = (text: string) => {
const t = performance.now();
const out = extractImagePathCandidates('render', text, '', CWD);
return { out, ms: performance.now() - t };
};
const base64 = Buffer.from(Array.from({ length: (N * 3) / 4 }, (_, i) => (i * 131 + 7) & 255)).toString('base64');
it.each([
...'#+%@![{^:/a.~-$'.split('').map((c) => [`30,000 × ${c}`, c.repeat(N)] as const),
['a base64 blob', base64] as const,
['repeated file:///', 'file:///'.repeat(N / 8)] as const,
['repeated file:///x', 'file:///x'.repeat(N / 9)] as const,
['repeated file:///a:', 'file:///a:'.repeat(N / 10)] as const,
['file:/// then 30,000 characters', 'file:///' + 'a'.repeat(N)] as const,
['an unclosed quote', '"' + 'a'.repeat(N)] as const,
['repeated =a', '=a'.repeat(N / 2)] as const,
])('%s stays under the time budget and still finds a path after it', (_label, line) => {
const { out, ms } = timed(`${line}\nsaved out/plot.png`);
expect(ms).toBeLessThan(BUDGET_MS);
expect(out).toContain('/work/repo/out/plot.png');
});
it('parses a chunk of 139 Bash results with a 30,000-character base64 blob each under budget', () => {
const lines: string[] = [];
for (let i = 0; i < 139; i++) {
lines.push(bashUseLine(`toolu_${i}`, '2026-10-15T20:00:00.000Z', 'cat img.b64'));
lines.push(
toolResultLine(`toolu_${i}`, '2026-10-15T20:00:01.000Z', {
stdout: base64,
stderr: '',
interrupted: false,
isImage: false,
})
);
}
const t = performance.now();
parseClaudeEditLines(lines.join('\n'));
expect(performance.now() - t).toBeLessThan(3000); // 6.6 s before the fix
});
});
describe('classifyEditPath', () => {
it.each([
['/a/b.png', 'image'],
['/a/b.JPG', 'image'],
['/a/b.jpeg', 'image'],
['/a/b.webp', 'image'],
['/a/b.gif', 'image'],
['/a/b.avif', 'image'],
['/a/b.svg', 'image'],
['/a/b.ts', 'code'],
['/a/png', 'code'],
['/a/b.png.orig', 'code'],
])('%s → %s', (path, kind) => {
expect(classifyEditPath(path)).toBe(kind);
});
});
+214
View File
@@ -0,0 +1,214 @@
/**
* Edit history end to end: the real reader (edit-history-local-source.ts), sync and store on
* temp transcripts: what the fakes in edit-history-sync.test.ts cannot show (file order,
* a new conversation next to the old one, every line read exactly once).
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { readClaudeEditSources } from '../src/edit-history-local-source.js';
import { EditHistoryStore } from '../src/edit-history-store.js';
import { EditHistorySync, MAX_FORWARD_PASSES, type EditHistorySession } from '../src/web/edit-history-sync.js';
let root: string;
const opened: EditHistoryStore[] = [];
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'edit-pipeline-'));
});
afterEach(async () => {
await Promise.all(opened.splice(0).map((s) => s.remove()));
rmSync(root, { recursive: true, force: true });
});
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
/** One edit result line of `pad` bytes or more, newline included. */
function editLine(id: string, i: number, pad = 0, agentId?: string): string {
const rec: Record<string, unknown> = {
type: 'user',
timestamp: new Date(T0 + i * 1000).toISOString(),
cwd: '/w',
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
toolUseResult: {
filePath: `/w/${id}.ts`,
oldString: 'a',
newString: 'b',
structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
},
pad: '',
};
if (agentId) rec.agentId = agentId;
rec.pad = 'p'.repeat(Math.max(0, pad - JSON.stringify(rec).length - 1));
return JSON.stringify(rec) + '\n';
}
function setup(budget?: number, base = root) {
const project = join(base, 'projects', 'p');
mkdirSync(project, { recursive: true });
const at = (transcriptId: string) => join(project, `${transcriptId}.jsonl`);
/** The transcript watcher's path (the hooks report it; `/clear` moves it). */
let current = at('convA');
const sync = new EditHistorySync({
transcriptPath: async () => (existsSync(current) ? current : null),
readSources: readClaudeEditSources,
probe: async (paths) => paths.map(() => null),
loadStore: async (id) => {
const store = await EditHistoryStore.load(id, { dir: join(base, 'store'), debounceMs: 10 });
opened.push(store);
return store;
},
deleteStore: (id) => EditHistoryStore.delete(id, { dir: join(base, 'store') }),
sweepStores: async () => 0,
budget,
});
const session: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'convA' };
const ids = async () =>
(await sync.getStore('s1'))
.list()
.map((e) => e.id)
.sort();
return {
project,
at,
sync,
session,
ids,
switchTo: (transcriptId: string) => (current = at(transcriptId)),
};
}
describe('a new conversation (/clear, resume)', () => {
it('reads the previous transcript to its end before the new one', async () => {
const { at, sync, session, ids, switchTo } = setup();
appendFileSync(at('convA'), editLine('a1', 1));
await sync.sync(session);
// The turn's last edits land, then /clear before the panel's debounced re-read.
appendFileSync(at('convA'), editLine('a2', 2) + editLine('a3', 3));
switchTo('convB');
appendFileSync(at('convB'), editLine('b1', 4));
await sync.sync(session);
expect(await ids()).toEqual(['a1', 'a2', 'a3', 'b1']);
const store = await sync.getStore('s1');
expect(store.getTranscriptId()).toBe('convB');
expect(store.knownSources()).toEqual(['main']);
});
it('a previous backlog larger than one sync reads on with the next ones; the new transcript follows', async () => {
const { at, sync, session, ids, switchTo } = setup(600);
appendFileSync(at('convA'), editLine('a0', 0));
await sync.sync(session);
const backlog = Array.from({ length: MAX_FORWARD_PASSES * 3 }, (_, i) => `a${i + 1}`);
appendFileSync(at('convA'), backlog.map((id, i) => editLine(id, i + 1, 500)).join(''));
switchTo('convB');
appendFileSync(at('convB'), editLine('b1', 99));
await sync.sync(session);
expect(await ids()).not.toContain('b1');
for (let i = 0; i < 5; i++) await sync.sync(session);
expect(await ids()).toEqual(['a0', ...backlog, 'b1'].sort());
});
it('subagents of the previous conversation are read to their end too', async () => {
const { project, at, sync, session, ids, switchTo } = setup();
const sub = join(project, 'convA', 'subagents');
mkdirSync(sub, { recursive: true });
appendFileSync(at('convA'), editLine('a1', 1));
appendFileSync(join(sub, 'agent-x.jsonl'), editLine('x1', 2, 0, 'x'));
await sync.sync(session);
appendFileSync(join(sub, 'agent-x.jsonl'), editLine('x2', 3, 0, 'x'));
switchTo('convB');
appendFileSync(at('convB'), editLine('b1', 4));
await sync.sync(session);
expect(await ids()).toEqual(['a1', 'b1', 'x1', 'x2']);
});
it('a previous transcript that is gone does not hold up the new one', async () => {
const { at, sync, session, ids, switchTo } = setup();
appendFileSync(at('convA'), editLine('a1', 1));
await sync.sync(session);
rmSync(at('convA'));
switchTo('convB');
appendFileSync(at('convB'), editLine('b1', 2));
await sync.sync(session);
expect(await ids()).toEqual(['a1', 'b1']);
});
it('"Load older" before the forward read moved on reads the stored conversation, not the new file', async () => {
const { at, sync, session, ids, switchTo } = setup(600);
const older = Array.from({ length: 4 }, (_, i) => `a${i}`);
appendFileSync(at('convA'), older.map((id, i) => editLine(id, i, 500)).join(''));
await sync.sync(session);
expect(await ids()).toEqual(['a3']);
switchTo('convB');
appendFileSync(at('convB'), editLine('b1', 99));
await sync.sync(session, { older: true });
expect(await ids()).toEqual(['a2', 'a3']);
});
});
describe('every line exactly once (seeded random transcripts)', () => {
/** Deterministic: a seed fixes budget, line lengths, subagents, partial writes and sync points. */
async function run(seed: number): Promise<string | null> {
let state = seed;
const rnd = () => {
state = (state * 1103515245 + 12345) & 0x7fffffff;
return state / 0x7fffffff;
};
const ri = (a: number, b: number) => a + Math.floor(rnd() * (b - a + 1));
const budget = ri(400, 1500);
const { project, at, sync, session } = setup(budget, mkdtempSync(join(root, 'run-')));
const main = at('convA');
const subDir = join(project, 'convA', 'subagents');
const expected = new Set<string>();
const tooLong = new Set<string>();
const agents: string[] = [];
const tails = new Map<string, string>();
let n = 0;
const write = () => {
if (rnd() < 0.15 && agents.length < 6) {
mkdirSync(subDir, { recursive: true });
agents.push(`a${agents.length}`);
}
const k = ri(0, agents.length);
const agent = k === 0 ? undefined : agents[k - 1];
const file = agent ? join(subDir, `agent-${agent}.jsonl`) : main;
const id = `e${n++}`;
// Some lines are longer than the budget: skipped, never shown, never blocking.
const line = editLine(id, n, rnd() < 0.12 ? ri(budget + 2, budget * 3) : ri(250, budget - 2), agent);
if (Buffer.byteLength(line) < budget) expected.add(id);
else if (Buffer.byteLength(line) > budget) tooLong.add(id);
// Sometimes half a line: claude is still writing it.
const cut = rnd() < 0.15 ? ri(1, line.length - 1) : line.length;
appendFileSync(file, (tails.get(file) ?? '') + line.slice(0, cut));
if (cut < line.length) tails.set(file, line.slice(cut));
else tails.delete(file);
};
for (let i = ri(0, 20); i > 0; i--) write(); // backlog before the panel opens
if (!existsSync(main)) appendFileSync(main, '');
for (let r = ri(5, 25); r > 0; r--) {
for (let i = ri(0, 6); i > 0; i--) write();
await sync.sync(session);
}
for (const [file, rest] of tails) appendFileSync(file, rest);
for (let i = 0; i < 40; i++) await sync.sync(session);
const store = await sync.getStore('s1');
for (let clicks = 0; store.backfillTruncated && clicks < 400; clicks++) await sync.sync(session, { older: true });
const got = new Set(store.list().map((e) => e.id));
const missing = [...expected].filter((id) => !got.has(id));
const extra = [...got].filter((id) => tooLong.has(id));
if (missing.length || extra.length || store.backfillTruncated) {
return `seed ${seed} (budget ${budget}): missing ${missing.join(',')} extra ${extra.join(',')}`;
}
return null;
}
it('reads every line shorter than the budget once, forward and back, and none longer', async () => {
const failures: string[] = [];
for (let seed = 1; seed <= 40; seed++) {
await Promise.all(opened.splice(0).map((s) => s.remove()));
const r = await run(seed);
if (r) failures.push(r);
}
expect(failures).toEqual([]);
});
});
+106
View File
@@ -0,0 +1,106 @@
/**
* Edit history image probe against a filesystem that stopped answering (#693 review): a Bash
* call printed a path on a dead hard mount, whose stat() never settles. The real sync with the
* real probe (`probeLocalPaths` → bounded `probePathKind`) must still finish, the next queued
* sync too, and `remove()` must not wait on it. Own file: the stalled probe stays recorded
* process-wide for the rest of the run.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const DEAD = '/mnt/dead-nas';
vi.hoisted(() => {
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '100';
});
vi.mock('node:fs/promises', async (importOriginal) => {
const real = await importOriginal<typeof import('node:fs/promises')>();
const hangs = (p: unknown) => String(p).startsWith('/mnt/dead-nas');
const stat = ((p: Parameters<typeof real.stat>[0], ...rest: unknown[]) =>
hangs(p) ? new Promise(() => {}) : (real.stat as (...a: unknown[]) => unknown)(p, ...rest)) as typeof real.stat;
const realpath = ((p: Parameters<typeof real.realpath>[0], ...rest: unknown[]) =>
hangs(p)
? new Promise(() => {})
: (real.realpath as (...a: unknown[]) => unknown)(p, ...rest)) as typeof real.realpath;
return { ...real, stat, realpath, default: { ...real, stat, realpath } };
});
import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { EditHistoryStore } from '../src/edit-history-store.js';
import { probeLocalPaths, type EditSources } from '../src/edit-history-local-source.js';
import { EditHistorySync, type EditHistorySession } from '../src/web/edit-history-sync.js';
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
const session: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'conv1' };
function bashPair(id: string, start: number, end: number, stdout: string): string {
const use = {
type: 'assistant',
timestamp: new Date(start).toISOString(),
cwd: '/w',
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command: 'render' } }] },
};
const res = {
type: 'user',
timestamp: new Date(end).toISOString(),
cwd: '/w',
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: stdout }] },
toolUseResult: { stdout, stderr: '', interrupted: false, isImage: false },
};
return JSON.stringify(use) + '\n' + JSON.stringify(res) + '\n';
}
/** Rejects after `ms`, so a hang fails the test instead of timing it out silently. */
function within<T>(p: Promise<T>, ms: number, what: string): Promise<T> {
return Promise.race([
p,
new Promise<T>((_, reject) => setTimeout(() => reject(new Error(`${what} still pending after ${ms} ms`)), ms)),
]);
}
let dir: string;
let stores: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'edit-stall-'));
stores = join(dir, 'stores');
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe('edit history image probe on an unresponsive mount', () => {
it('finishes sync, a queued sync and remove() while one printed path never answers', async () => {
const ok = join(dir, 'ok.png');
writeFileSync(ok, 'PNG!');
utimesSync(ok, new Date(T0 + 2000), new Date(T0 + 2000));
let main = Buffer.from(bashPair('b1', T0, T0 + 5000, `${DEAD}/plots/a.png\nsaved ${ok}\n`));
const sync = new EditHistorySync({
transcriptPath: async () => '/p/conv1.jsonl',
readSources: async (_path, requests, budget): Promise<EditSources> => {
const req = requests.find((r) => r.relPath === 'main');
const start = Math.min(req?.offset ?? 0, main.length);
const len = Math.min(budget, main.length - start);
return {
transcriptId: 'conv1',
files: [{ relPath: 'main', size: main.length, startOffset: start, chunk: main.subarray(start, start + len) }],
metas: {},
};
},
probe: (paths) => probeLocalPaths(paths),
loadStore: (id) => EditHistoryStore.load(id, { dir: stores, debounceMs: 0 }),
deleteStore: (id) => EditHistoryStore.delete(id, { dir: stores }),
sweepStores: async () => 0,
});
const first = sync.sync(session);
main = Buffer.concat([main, Buffer.from(bashPair('b2', T0 + 6000, T0 + 7000, `${DEAD}/plots/b.png\n`))]);
const second = sync.sync(session);
expect(await within(first, 1500, 'first sync')).toEqual({ state: 'ok' });
expect(await within(second, 1500, 'queued sync')).toEqual({ state: 'ok' });
expect((await sync.getStore('s1')).list().map((e) => e.path)).toEqual([ok]);
await within(sync.remove('s1'), 1500, 'remove()');
});
});
+704
View File
@@ -0,0 +1,704 @@
/**
* Edit history store: byte ranges, cursors, dedupe, cap, persistence.
* Transcript bytes are built from JSONL lines shaped like real claude records.
*/
import { mkdtempSync, rmSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
EditHistoryStore,
entryBytes,
MAIN_SOURCE,
MAX_AGENT_META_CHARS,
MAX_AGENTS,
MAX_ENTRIES,
MAX_FILE_BYTES,
MAX_STORE_BYTES,
parseAgentMeta,
READ_BUDGET_BYTES,
TMP_TAG,
} from '../src/edit-history-store.js';
import { MAX_HUNK_BYTES } from '../src/edit-history-parse.js';
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'edit-history-store-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
const T0 = Date.parse('2026-10-15T19:00:00.000Z');
const iso = (s: number) => new Date(T0 + s * 1000).toISOString();
function editLine(id: string, sec: number, path = `/work/${id}.ts`, extra: Record<string, unknown> = {}): string {
return JSON.stringify({
type: 'user',
timestamp: iso(sec),
cwd: '/work',
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
toolUseResult: {
filePath: path,
oldString: 'a',
newString: 'b',
originalFile: 'a',
structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
userModified: false,
replaceAll: false,
},
...extra,
});
}
/** An Edit whose diff carries `size` bytes of added text. */
function bigEditLine(id: string, sec: number, size: number): string {
const line = JSON.parse(editLine(id, sec));
line.toolUseResult.structuredPatch[0].lines = ['-a', '+' + 'x'.repeat(size)];
return JSON.stringify(line);
}
function bashUse(id: string, sec: number, command: string): string {
return JSON.stringify({
type: 'assistant',
timestamp: iso(sec),
cwd: '/work',
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] },
});
}
function bashResult(id: string, sec: number): string {
return JSON.stringify({
type: 'user',
timestamp: iso(sec),
cwd: '/work',
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: '' }] },
toolUseResult: { stdout: '', stderr: '', interrupted: false, isImage: false },
});
}
const jsonl = (lines: string[]) => Buffer.from(lines.map((l) => l + '\n').join(''));
/**
* Read like the reader (edit-history-local-source.ts): on from the cursor of the current
* conversation (clamped to the size), else the file's last `budget` bytes; then apply.
* Null when nothing is new.
*/
function syncForward(
store: EditHistoryStore,
file: Buffer,
budget = READ_BUDGET_BYTES,
source = MAIN_SOURCE,
tid = 'conv-1'
) {
const cursor = tid === store.getTranscriptId() ? store.getCursor(source) : undefined;
const offset = cursor ? Math.min(cursor.offset, file.length) : Math.max(0, file.length - budget);
const len = Math.min(budget, file.length - offset);
if (cursor && offset === cursor.offset && len === 0) return null;
const chunk = file.subarray(offset, offset + len);
return store.apply({ transcriptId: tid, source, startOffset: offset, chunk, size: file.length });
}
function syncOlder(store: EditHistoryStore, file: Buffer, budget?: number, source = MAIN_SOURCE, tid = 'conv-1') {
const range = store.olderRange(source, budget);
if (!range) return null;
const chunk = file.subarray(range.offset, range.offset + range.len);
return store.applyOlder({ transcriptId: tid, source, startOffset: range.offset, chunk, size: file.length });
}
const ids = (store: EditHistoryStore) => store.list().map((e) => e.id);
describe('EditHistoryStore: forward reads', () => {
it('reads a small file whole, then only what was appended', () => {
const store = new EditHistoryStore('s1', { dir });
let file = jsonl([editLine('e1', 1), editLine('e2', 2)]);
expect(syncForward(store, file)?.added).toBe(2);
expect(store.getCursor(MAIN_SOURCE)).toEqual({ offset: file.length, floor: 0 });
expect(syncForward(store, file)).toBeNull();
file = Buffer.concat([file, jsonl([editLine('e3', 3)])]);
expect(syncForward(store, file)?.added).toBe(1);
expect(ids(store)).toEqual(['e3', 'e2', 'e1']);
expect(store.backfillTruncated).toBe(false);
});
it('leaves a partial last line (still being written) for the next read', () => {
const store = new EditHistoryStore('s1', { dir });
const full = jsonl([editLine('e1', 1), editLine('e2', 2)]);
const firstLineEnd = full.indexOf(0x0a) + 1;
const partial = full.subarray(0, firstLineEnd + 30);
expect(syncForward(store, partial)?.added).toBe(1);
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(firstLineEnd);
expect(syncForward(store, full)?.added).toBe(1);
expect(ids(store)).toEqual(['e2', 'e1']);
});
it('deduplicates by tool_use_id', () => {
const store = new EditHistoryStore('s1', { dir });
const file = jsonl([editLine('e1', 1), editLine('e1', 1)]);
expect(syncForward(store, file)?.added).toBe(1);
expect(store.list()).toHaveLength(1);
});
it('starts a large file near its end, drops the cut line and keeps the floor there', () => {
const store = new EditHistoryStore('s1', { dir });
const lines = Array.from({ length: 20 }, (_, i) => editLine(`e${i}`, i));
const file = jsonl(lines);
const budget = Math.floor(file.length / 3);
syncForward(store, file, budget);
const cursor = store.getCursor(MAIN_SOURCE)!;
expect(cursor.offset).toBe(file.length);
expect(cursor.floor).toBeGreaterThan(file.length - budget);
expect(file[cursor.floor - 1]).toBe(0x0a);
expect(store.backfillTruncated).toBe(true);
expect(ids(store)[0]).toBe('e19');
expect(ids(store)).not.toContain('e0');
});
it('reads a backlog in budget-sized forward steps without losing a line', () => {
const store = new EditHistoryStore('s1', { dir });
const first = jsonl([editLine('e0', 0)]);
syncForward(store, first);
const more = Array.from({ length: 30 }, (_, i) => editLine(`e${i + 1}`, i + 1));
const file = Buffer.concat([first, jsonl(more)]);
const budget = 700;
for (let i = 0; i < 100 && syncForward(store, file, budget); i++);
expect(store.list()).toHaveLength(31);
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(file.length);
});
it('skips a line longer than the budget instead of re-reading it forever', () => {
const store = new EditHistoryStore('s1', { dir });
const huge = editLine('big', 1, '/work/big.ts', { pad: 'x'.repeat(5000) });
const first = jsonl([editLine('e0', 0)]);
syncForward(store, first);
const file = Buffer.concat([first, jsonl([huge, editLine('e2', 2)])]);
for (let i = 0; i < 50 && syncForward(store, file, 1000); i++);
expect(ids(store)).toEqual(['e2', 'e0']);
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(file.length);
});
it('reads the line after a skipped one whole, wherever it falls against the chunk grid', async () => {
const budget = 1000;
const first = jsonl([editLine('e0', 0)]);
const lost: number[] = [];
for (let pad = 0; pad < budget; pad++) {
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
syncForward(store, first);
const huge = JSON.stringify({ type: 'user', message: { content: 'h'.repeat(2400 + pad) } });
const next = editLine('next', 2, '/work/next.ts', { pad: 'p'.repeat(300) }); // < budget
const file = Buffer.concat([first, jsonl([huge, next, editLine('e3', 3)])]);
for (let i = 0; i < 50 && syncForward(store, file, budget); i++);
if (!store.get('next') || !store.get('e3')) lost.push(pad);
await store.remove();
}
expect(lost).toEqual([]);
});
it('keeps a multibyte character split by the chunk boundary intact', () => {
const store = new EditHistoryStore('s1', { dir });
const path = '/work/Größe – Ümlaut 😀.ts';
const file = jsonl([editLine('e1', 1), editLine('e2', 2, path)]);
const emoji = file.indexOf(Buffer.from('😀'));
// Cut inside the 4-byte emoji, then continue.
const cut = file.subarray(0, emoji + 2);
syncForward(store, cut);
syncForward(store, file);
expect(store.get('e2')?.path).toBe(path);
});
it('restarts a file that shrank (replaced) from its end, with the next read', () => {
const store = new EditHistoryStore('s1', { dir });
syncForward(store, jsonl([editLine('e1', 1), editLine('e2', 2)]));
const replaced = jsonl([editLine('n1', 3)]);
// The reader clamps the cursor to the size: an empty chunk at the end, and the cursor goes.
expect(syncForward(store, replaced)?.added).toBe(0);
expect(store.getCursor(MAIN_SOURCE)).toBeUndefined();
expect(syncForward(store, replaced)?.added).toBe(1);
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(replaced.length);
});
it('ignores a chunk that does not start at the cursor', () => {
const store = new EditHistoryStore('s1', { dir });
const file = jsonl([editLine('e1', 1), editLine('e2', 2)]);
syncForward(store, file);
const res = store.apply({ transcriptId: 'conv-1', source: MAIN_SOURCE, startOffset: 3, chunk: file, size: 999 });
expect(res.added).toBe(0);
});
});
describe('EditHistoryStore: conversations and sources', () => {
it('starts new cursors for a new transcriptId and keeps the old entries', () => {
const store = new EditHistoryStore('s1', { dir });
syncForward(store, jsonl([editLine('e1', 1)]));
const second = jsonl([editLine('f1', 5)]);
syncForward(store, second, undefined, MAIN_SOURCE, 'conv-2');
expect(store.getTranscriptId()).toBe('conv-2');
expect(ids(store)).toEqual(['f1', 'e1']);
});
it('keeps one cursor per source and the subagent id on its entries', () => {
const store = new EditHistoryStore('s1', { dir });
const main = jsonl([editLine('m1', 1)]);
const sub = jsonl([editLine('s1', 2, '/work/s.ts', { isSidechain: true, agentId: 'a1' })]);
const subSource = 'subagents/agent-a1.jsonl';
syncForward(store, main);
syncForward(store, sub, undefined, subSource);
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(main.length);
expect(store.getCursor(subSource)?.offset).toBe(sub.length);
expect(store.get('s1')?.agentId).toBe('a1');
});
it('records agent meta from meta.json text', () => {
const store = new EditHistoryStore('s1', { dir });
store.setAgentMeta(
'a1',
JSON.stringify({
agentType: 'coding',
description: 'Impl: fix',
worktreePath: '/w/.claude/worktrees/agent-a1',
worktreeBranch: 'worktree-agent-a1',
toolUseId: 'toolu_x',
})
);
store.setAgentMeta('a2', 'not json');
expect(store.getAgents()).toEqual({
a1: {
description: 'Impl: fix',
agentType: 'coding',
worktreePath: '/w/.claude/worktrees/agent-a1',
worktreeBranch: 'worktree-agent-a1',
},
});
expect(store.hasAgentMeta('a2')).toBe(false);
expect(parseAgentMeta('{"description":"d"}')).toEqual({ description: 'd', agentType: '' });
});
});
describe('EditHistoryStore: older blocks', () => {
it('loads older lines block by block down to the start, without duplicates at the seam', () => {
const store = new EditHistoryStore('s1', { dir });
const file = jsonl(Array.from({ length: 30 }, (_, i) => editLine(`e${i}`, i)));
const budget = Math.floor(file.length / 4);
syncForward(store, file, budget);
const before = store.list().length;
expect(before).toBeLessThan(30);
let rounds = 0;
while (syncOlder(store, file, budget) && rounds < 20) rounds++;
expect(rounds).toBeGreaterThan(1);
expect(store.list()).toHaveLength(30);
expect(store.getCursor(MAIN_SOURCE)?.floor).toBe(0);
expect(store.backfillTruncated).toBe(false);
expect(store.olderRange(MAIN_SOURCE, budget)).toBeNull();
});
it('skips a line longer than the budget going back and reaches the lines before it', () => {
const store = new EditHistoryStore('s1', { dir });
const huge = JSON.stringify({ type: 'user', message: { content: 'x'.repeat(6000) } });
const older = Array.from({ length: 10 }, (_, i) => editLine(`old${i}`, i));
const newer = Array.from({ length: 5 }, (_, i) => editLine(`new${i}`, 100 + i));
const file = jsonl([...older, huge, ...newer]);
const budget = 4000;
syncForward(store, file, budget);
expect(ids(store).sort()).toEqual(newer.map((_, i) => `new${i}`).sort());
let rounds = 0;
while (syncOlder(store, file, budget) && rounds < 30) rounds++;
expect(store.list()).toHaveLength(15);
expect(store.getCursor(MAIN_SOURCE)).toMatchObject({ floor: 0 });
expect(store.getCursor(MAIN_SOURCE)?.floorMidLine).toBeUndefined();
expect(store.backfillTruncated).toBe(false);
});
it('going back, reads the line before a skipped one whole, wherever it falls against the block grid', async () => {
const budget = 1000;
const huge = JSON.stringify({ type: 'user', message: { content: 'h'.repeat(2500) } });
const lost: number[] = [];
for (let pad = 0; pad < budget; pad++) {
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
const lines = [editLine('p0', 0), editLine('p1', 1), editLine('before', 2), huge];
for (let i = 0; i < 6; i++)
lines.push(editLine(`t${i}`, 10 + i, `/work/t${i}.ts`, i === 0 ? { pad: 'p'.repeat(pad) } : {}));
const file = jsonl(lines);
syncForward(store, file, budget);
for (let i = 0; i < 50 && syncOlder(store, file, budget); i++);
if (!store.get('before') || !store.get('p0')) lost.push(pad);
await store.remove();
}
expect(lost).toEqual([]);
});
it('a source whose file is gone (dropSource) offers no older lines and keeps its entries', () => {
const store = new EditHistoryStore('s1', { dir });
const sub = 'subagents/agent-a1.jsonl';
const file = jsonl(Array.from({ length: 10 }, (_, i) => editLine(`s${i}`, i, `/work/s${i}.ts`, { agentId: 'a1' })));
syncForward(store, file, 400, sub);
expect(store.sourcesWithOlder()).toEqual([sub]);
const kept = ids(store);
store.dropSource(sub);
expect(store.getCursor(sub)).toBeUndefined();
expect(store.backfillTruncated).toBe(false);
expect(ids(store)).toEqual(kept);
});
it('refuses an older chunk that does not end at the floor', () => {
const store = new EditHistoryStore('s1', { dir });
const file = jsonl(Array.from({ length: 10 }, (_, i) => editLine(`e${i}`, i)));
syncForward(store, file, 300);
const res = store.applyOlder({
transcriptId: 'conv-1',
source: MAIN_SOURCE,
startOffset: 0,
chunk: file.subarray(0, 10),
size: file.length,
});
expect(res.added).toBe(0);
});
});
describe('EditHistoryStore: Bash images', () => {
it('pairs a Bash call and its result across a chunk boundary', () => {
const store = new EditHistoryStore('s1', { dir });
const part1 = jsonl([bashUse('b1', 1, 'python render.py -o out/frame.png')]);
const file = Buffer.concat([part1, jsonl([bashResult('b1', 4)])]);
expect(syncForward(store, part1)?.candidates).toEqual([]);
const res = syncForward(store, file)!;
expect(res.candidates).toEqual([
{ toolUseId: 'b1', start: T0 + 1000, end: T0 + 4000, paths: ['/work/out/frame.png'] },
]);
});
it('adds confirmed images as entries without a diff, once', () => {
const store = new EditHistoryStore('s1', { dir });
const img = { toolUseId: 'b1', path: '/work/out/frame.png', ts: T0 + 4000, agentId: 'a1' };
expect(store.addImageEntries([img, img])).toBe(1);
expect(store.addImageEntries([img])).toBe(0);
expect(store.list()[0]).toEqual({
id: 'b1:/work/out/frame.png',
ts: T0 + 4000,
path: '/work/out/frame.png',
kind: 'image',
tool: 'bash',
created: false,
added: 0,
removed: 0,
hunks: [],
truncated: false,
agentId: 'a1',
});
});
});
describe('EditHistoryStore: cap', () => {
it(`keeps the newest ${MAX_ENTRIES} entries`, () => {
const store = new EditHistoryStore('s1', { dir });
const file = jsonl(Array.from({ length: MAX_ENTRIES + 20 }, (_, i) => editLine(`e${i}`, i)));
const res = syncForward(store, file, file.length);
expect(res?.added).toBe(MAX_ENTRIES);
expect(store.list()).toHaveLength(MAX_ENTRIES);
expect(store.get('e19')).toBeUndefined();
expect(store.get('e20')).toBeDefined();
// Older than everything kept: counts as nothing added.
expect(store.addImageEntries([{ toolUseId: 'old', path: '/a.png', ts: T0 - 1000 }])).toBe(0);
});
it('offers no older block once the cap is full (it would be dropped on read)', () => {
const store = new EditHistoryStore('s1', { dir });
const lines = Array.from({ length: MAX_ENTRIES + 20 }, (_, i) => editLine(`e${i}`, i));
const file = jsonl(lines);
const tail = jsonl(lines.slice(-MAX_ENTRIES)).length;
syncForward(store, file, tail + 10);
expect(store.list()).toHaveLength(MAX_ENTRIES);
expect(store.getCursor(MAIN_SOURCE)!.floor).toBeGreaterThan(0);
expect(store.backfillTruncated).toBe(false);
});
it(`keeps at most ${MAX_STORE_BYTES} bytes of diff text, dropping the oldest`, () => {
const store = new EditHistoryStore('s1', { dir });
const n = 300;
const file = jsonl(Array.from({ length: n }, (_, i) => bigEditLine(`e${i}`, i, 20_000)));
syncForward(store, file, file.length);
const kept = store.list();
expect(kept.length).toBeLessThan(n);
expect(kept.reduce((sum, e) => sum + entryBytes(e), 0)).toBeLessThanOrEqual(MAX_STORE_BYTES);
expect(store.get(`e${n - 1}`)).toBeDefined();
expect(store.get('e0')).toBeUndefined();
});
it('offers no older block once the byte cap is full', () => {
const store = new EditHistoryStore('s1', { dir });
const file = jsonl(Array.from({ length: 300 }, (_, i) => bigEditLine(`e${i}`, i, 20_000)));
syncForward(store, file, 5_000_000);
expect(store.list().length).toBeLessThan(MAX_ENTRIES);
expect(store.getCursor(MAIN_SOURCE)!.floor).toBeGreaterThan(0);
expect(store.backfillTruncated).toBe(false);
});
it('caps the agent meta strings', () => {
const meta = parseAgentMeta(JSON.stringify({ description: 'd'.repeat(5000), agentType: 'coding' }));
expect(meta?.description).toHaveLength(MAX_AGENT_META_CHARS);
expect(meta?.agentType).toBe('coding');
});
it(`keeps at most ${MAX_AGENTS} subagent labels, dropping the oldest no entry uses`, () => {
const store = new EditHistoryStore('s1', { dir });
syncForward(store, jsonl([editLine('e1', 1, '/work/a.ts', { agentId: 'a0' })]));
for (let i = 0; i <= MAX_AGENTS + 4; i++) store.setAgentMeta(`a${i}`, `{"description":"d${i}"}`);
const agents = store.getAgents();
expect(Object.keys(agents)).toHaveLength(MAX_AGENTS);
expect(agents.a0).toBeDefined(); // used by an entry
expect(agents[`a${MAX_AGENTS + 4}`]).toBeDefined(); // the newest
expect(agents.a1).toBeUndefined();
});
});
describe('EditHistoryStore: persistence', () => {
it('reloads entries, cursors, pending Bash calls and agents from disk', async () => {
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
const file = jsonl([editLine('e1', 1), bashUse('b1', 2, 'render > a.png')]);
syncForward(store, file);
store.setAgentMeta('a1', '{"description":"d","agentType":"coding"}');
await store.flush();
const loaded = await EditHistoryStore.load('s1', { dir });
expect(ids(loaded)).toEqual(['e1']);
expect(loaded.getCursor(MAIN_SOURCE)).toEqual(store.getCursor(MAIN_SOURCE));
expect(loaded.getTranscriptId()).toBe('conv-1');
expect(loaded.getAgents()).toEqual({ a1: { description: 'd', agentType: 'coding' } });
const done = Buffer.concat([file, jsonl([bashResult('b1', 3)])]);
expect(syncForward(loaded, done)?.candidates[0]?.paths).toEqual(['/work/a.png']);
});
it('writes the file 0600 in a 0700 directory, also over a wider existing file', async () => {
const sub = join(dir, 'edit-history');
const store = new EditHistoryStore('s1', { dir: sub, debounceMs: 60_000 });
syncForward(store, jsonl([editLine('e1', 1)]));
await store.flush();
expect(statSync(sub).mode & 0o777).toBe(0o700);
expect(statSync(join(sub, 's1.json')).mode & 0o777).toBe(0o600);
writeFileSync(join(sub, 's1.json'), '{}', { mode: 0o644 });
await store.flush();
expect(statSync(join(sub, 's1.json')).mode & 0o777).toBe(0o600);
});
it('applies the caps and drops malformed entries when loading', async () => {
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
syncForward(store, jsonl(Array.from({ length: MAX_ENTRIES }, (_, i) => editLine(`e${i}`, i))));
await store.flush();
const data = JSON.parse(readFileSync(join(dir, 's1.json'), 'utf-8'));
const extra = JSON.parse(editLine('late', MAX_ENTRIES + 1));
data.entries.push(
{ ...store.get('e0')!, id: 'newest', ts: T0 + 10_000_000 },
{ id: 'broken', path: 7, hunks: [] },
{ id: 'nohunks', path: '/a.ts' },
extra
);
writeFileSync(join(dir, 's1.json'), JSON.stringify(data));
const loaded = await EditHistoryStore.load('s1', { dir });
expect(loaded.list()).toHaveLength(MAX_ENTRIES);
expect(loaded.get('newest')).toBeDefined();
expect(loaded.get('e0')).toBeUndefined();
expect(loaded.get('broken')).toBeUndefined();
expect(loaded.get('nohunks')).toBeUndefined();
});
it('drops broken cursors, pending Bash calls and agent labels when loading (a damaged file)', async () => {
writeFileSync(
join(dir, 's1.json'),
JSON.stringify({
version: 1,
sessionId: 's1',
transcriptId: 'conv-1',
cursors: {
main: { offset: 1.5, floor: 0 },
'subagents/agent-x.jsonl': { offset: -5, floor: 0 },
'subagents/agent-y.jsonl': { offset: 10, floor: 20 },
'subagents/agent-z.jsonl': { offset: 10, floor: 5, floorMidLine: true },
},
pendingBash: [null, 7, { id: 'b1', ts: 1, command: 'render > a.png', cwd: '/work' }],
agents: { x: null, y: 'text', z: { description: 'd', agentType: 'coding' } },
entries: [{ id: 'e1', ts: 1, path: '/a.ts', hunks: [null] }],
})
);
const loaded = await EditHistoryStore.load('s1', { dir });
expect(loaded.knownSources()).toEqual(['subagents/agent-z.jsonl']);
expect(loaded.getCursor('subagents/agent-z.jsonl')).toEqual({ offset: 10, floor: 5, floorMidLine: true });
expect(loaded.getAgents()).toEqual({ z: { description: 'd', agentType: 'coding' } });
expect(loaded.list()).toEqual([]);
// The surviving pending call still pairs with its result, and nothing throws.
expect(syncForward(loaded, jsonl([bashResult('b1', 3)]))?.candidates[0]?.paths).toEqual(['/work/a.png']);
});
it('rebuilds loaded entries under the same bounds: known fields only, string diff lines, the per-edit cap', async () => {
const good = {
ts: 1,
path: '/a.ts',
kind: 'code',
tool: 'Edit',
created: false,
added: 1,
removed: 0,
truncated: false,
};
writeFileSync(
join(dir, 's1.json'),
JSON.stringify({
version: 1,
sessionId: 's1',
transcriptId: 'conv-1',
cursors: {},
pendingBash: [
{ id: 'b1', ts: 1, command: 'ls', cwd: '/work' },
{ id: 'b2', ts: 1, command: 'ls', cwd: '/' + 'c'.repeat(5000) },
{ id: 'p'.repeat(5000), ts: 1, command: 'ls', cwd: '/work' },
],
agents: { ['a'.repeat(MAX_AGENT_META_CHARS + 1)]: { description: 'd', agentType: 't' } },
entries: [
{ ...good, id: 'extra', hunks: [], blob: 'x'.repeat(1000) },
{ ...good, id: 'objlines', hunks: [{ oldStart: 1, lines: [{ k: 'y' }, '+kept'] }] },
{ ...good, id: 'bighunk', hunks: [{ lines: ['+' + 'q'.repeat(MAX_HUNK_BYTES)] }] },
{ ...good, id: 'badtool', tool: 'Rm', hunks: [] },
],
})
);
const loaded = await EditHistoryStore.load('s1', { dir, debounceMs: 60_000 });
expect(
loaded
.list()
.map((e) => e.id)
.sort()
).toEqual(['bighunk', 'extra', 'objlines']);
expect(loaded.get('extra')).not.toHaveProperty('blob');
expect(loaded.get('objlines')!.hunks[0].lines).toEqual(['+kept']);
expect(loaded.get('bighunk')).toMatchObject({ truncated: true, hunks: [{ lines: [] }] });
expect(loaded.getAgents()).toEqual({});
await loaded.flush();
expect(
JSON.parse(readFileSync(join(dir, 's1.json'), 'utf-8')).pendingBash.map((p: { id: string }) => p.id)
).toEqual(['b1']);
});
it("counts an entry's id against the byte cap", () => {
const e = {
id: 'i'.repeat(1000),
ts: 1,
path: '/a.ts',
kind: 'code',
tool: 'Edit',
created: false,
added: 0,
removed: 0,
hunks: [],
truncated: false,
} as const;
expect(entryBytes(e) - entryBytes({ ...e, id: 'i' })).toBe(999);
});
it(`does not parse a store file larger than ${MAX_FILE_BYTES} bytes`, async () => {
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
syncForward(store, jsonl([editLine('e1', 1)]));
await store.flush();
const text = readFileSync(join(dir, 's1.json'), 'utf-8');
expect((await EditHistoryStore.load('s1', { dir })).get('e1')).toBeDefined();
writeFileSync(join(dir, 's1.json'), text + ' '.repeat(MAX_FILE_BYTES)); // still valid JSON
expect((await EditHistoryStore.load('s1', { dir })).list()).toEqual([]);
});
it('sweep() deletes the histories of sessions that are gone and stale tmp files, nothing else', async () => {
for (const name of [
'live.json',
'gone.json',
`gone.json.${process.pid + 1}.tmp`,
// An earlier run that had this process's pid (a container's pid 1): stale as well.
`live.json.${process.pid}.tmp`,
`live.json.${process.pid}-0a1b2c3d.tmp`,
`live.json.${TMP_TAG}.tmp`,
'notes.txt',
]) {
writeFileSync(join(dir, name), '{}');
}
expect(await EditHistoryStore.sweep((id) => id === 'live', { dir })).toBe(1);
expect(readdirSync(dir).sort()).toEqual([`live.json.${TMP_TAG}.tmp`, 'live.json', 'notes.txt'].sort());
expect(await EditHistoryStore.sweep(() => false, { dir: join(dir, 'missing') })).toBe(0);
});
it('takes only a plain transcript id from the file (the sync builds a path from it)', async () => {
const cursors = { main: { offset: 10, floor: 0 } };
for (const transcriptId of ['../../etc/passwd', 'a/b', '', 7]) {
writeFileSync(join(dir, 's1.json'), JSON.stringify({ version: 1, transcriptId, cursors, entries: [] }));
const store = await EditHistoryStore.load('s1', { dir });
expect(store.getTranscriptId()).toBeNull();
expect(store.knownSources()).toEqual([]); // offsets into no known conversation
}
writeFileSync(join(dir, 's1.json'), JSON.stringify({ version: 1, transcriptId: 'conv-1.a_b', cursors }));
const store = await EditHistoryStore.load('s1', { dir });
expect(store.getTranscriptId()).toBe('conv-1.a_b');
expect(store.knownSources()).toEqual(['main']);
});
it('settle() writes pending changes and nothing for a store without any', async () => {
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
syncForward(store, jsonl([editLine('e1', 1)]));
await store.settle();
expect((await EditHistoryStore.load('s1', { dir })).get('e1')).toBeDefined();
const loaded = await EditHistoryStore.load('s1', { dir });
rmSync(join(dir, 's1.json'));
await loaded.settle();
await new EditHistoryStore('empty', { dir }).settle();
expect(readdirSync(dir)).toEqual([]);
});
it('delete() removes a stored history without parsing it', async () => {
writeFileSync(join(dir, 's1.json'), 'not json');
await EditHistoryStore.delete('s1', { dir });
await EditHistoryStore.delete('missing', { dir });
expect(readdirSync(dir)).toEqual([]);
});
it('ignores a file of an unknown version', async () => {
writeFileSync(join(dir, 'v9.json'), JSON.stringify({ version: 9, entries: [{ id: 'x' }] }));
expect((await EditHistoryStore.load('v9', { dir })).list()).toEqual([]);
});
it('writes debounced', async () => {
const store = new EditHistoryStore('s1', { dir, debounceMs: 20 });
syncForward(store, jsonl([editLine('e1', 1)]));
const path = join(dir, 's1.json');
expect(existsSync(path)).toBe(false);
await new Promise((r) => setTimeout(r, 80));
expect(JSON.parse(readFileSync(path, 'utf-8')).entries).toHaveLength(1);
});
it('starts empty from a missing or corrupt file', async () => {
expect((await EditHistoryStore.load('none', { dir })).list()).toEqual([]);
const store = new EditHistoryStore('bad', { dir });
await store.flush();
writeFileSync(join(dir, 'bad.json'), '{broken');
expect((await EditHistoryStore.load('bad', { dir })).list()).toEqual([]);
});
it('remove() deletes the file and stops later writes', async () => {
const store = new EditHistoryStore('s1', { dir, debounceMs: 10 });
syncForward(store, jsonl([editLine('e1', 1)]));
await store.flush();
await store.remove();
expect(existsSync(join(dir, 's1.json'))).toBe(false);
syncForward(store, jsonl([editLine('e1', 1), editLine('e2', 2)]));
await new Promise((r) => setTimeout(r, 40));
await store.flush();
expect(existsSync(join(dir, 's1.json'))).toBe(false);
expect(store.list()).toEqual([]); // the content is forgotten too
});
it('remove() during a write leaves nothing behind (the write cannot rename over the deleted file)', async () => {
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
syncForward(store, jsonl([editLine('e1', 1)]));
const writing = store.flush();
// Let the write get past its first check, so remove() meets it under way.
await new Promise((r) => setImmediate(r));
await Promise.all([writing, store.remove()]);
expect(readdirSync(dir)).toEqual([]);
});
it('defaults to the data directory', () => {
const store = new EditHistoryStore('s1');
expect((store as unknown as { file: string }).file).toMatch(/edit-history[\\/]s1\.json$/);
});
});
+699
View File
@@ -0,0 +1,699 @@
/**
* Edit history sync: reader, store, image confirmation, single-flight, failure states and the
* "reads only on request" wiring. Reader and probe are fakes; the store is real (temp dir).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { EditHistoryStore } from '../src/edit-history-store.js';
import type {
EditSourceReadOptions,
EditSourceRequest,
EditSources,
PathProbe,
} from '../src/edit-history-local-source.js';
import {
confirmImages,
EditHistorySync,
MAX_LOADED_STORES,
MAX_OLDER_SOURCES,
NO_TRANSCRIPT,
REMOVED,
type EditHistorySession,
type EditHistorySyncDeps,
unsupportedReason,
} from '../src/web/edit-history-sync.js';
let dir: string;
/** Stores the tests opened; removed before the temp dir, so no late save recreates it. */
const opened: EditHistoryStore[] = [];
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'edit-sync-'));
});
afterEach(async () => {
await Promise.all(opened.splice(0).map((s) => s.remove()));
rmSync(dir, { recursive: true, force: true });
});
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
function editLine(id: string, ts: number, path: string, extra: Record<string, unknown> = {}): string {
return (
JSON.stringify({
type: 'user',
timestamp: new Date(ts).toISOString(),
cwd: '/w',
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
toolUseResult: {
filePath: path,
oldString: 'a',
newString: 'b',
structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
},
...extra,
}) + '\n'
);
}
function bashPair(id: string, start: number, end: number, command: string): string {
const use = JSON.stringify({
type: 'assistant',
timestamp: new Date(start).toISOString(),
cwd: '/w',
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] },
});
const res = JSON.stringify({
type: 'user',
timestamp: new Date(end).toISOString(),
cwd: '/w',
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: '' }] },
toolUseResult: { stdout: '', stderr: '', interrupted: false, isImage: false },
});
return use + '\n' + res + '\n';
}
/** An in-memory transcript: files by relPath, served with the reader contract. */
class FakeHost {
transcriptId = 'conv1';
files = new Map<string, Buffer>();
metas: Record<string, string> = {};
calls: EditSourceRequest[][] = [];
paths: string[] = [];
/** What `transcriptPath` answers (the transcript watcher's path); null = none yet. */
get path(): string | null {
return this.files.size > 0 ? `/p/${this.transcriptId}.jsonl` : null;
}
read = async (
mainPath: string,
requests: readonly EditSourceRequest[],
budget: number,
opts: EditSourceReadOptions = {}
): Promise<EditSources | null> => {
this.calls.push([...requests]);
this.paths.push(mainPath);
if (this.files.size === 0) return null;
let remaining = budget;
const out: EditSources = { transcriptId: this.transcriptId, files: [], metas: {} };
for (const [relPath, buf] of this.files) {
const req = requests.find((r) => r.relPath === relPath);
if (!req && opts.onlyRequested) continue;
let start: number;
let len: number;
if (req?.len !== undefined) {
const blockEnd = Math.min(req.offset + req.len, buf.length);
len = Math.min(blockEnd - Math.min(req.offset, buf.length), remaining);
start = blockEnd - len;
} else if (req) {
start = Math.min(req.offset, buf.length);
len = Math.min(remaining, buf.length - start);
} else {
len = Math.min(buf.length, remaining);
start = buf.length - len;
const agent = /^subagents\/agent-(.+)\.jsonl$/.exec(relPath)?.[1];
if (agent && this.metas[agent]) out.metas[agent] = this.metas[agent];
}
remaining -= len;
out.files.push({ relPath, size: buf.length, startOffset: start, chunk: buf.subarray(start, start + len) });
}
return out;
};
append(relPath: string, text: string): void {
this.files.set(relPath, Buffer.concat([this.files.get(relPath) ?? Buffer.alloc(0), Buffer.from(text)]));
}
}
function setup(over: Partial<EditHistorySyncDeps> = {}) {
const host = new FakeHost();
const probes = new Map<string, PathProbe>();
const lookups: string[] = [];
/** Session ids in the order their stores were loaded. */
const loads: string[] = [];
const deps: EditHistorySyncDeps = {
transcriptPath: async (session) => {
lookups.push(session.id);
return host.path;
},
readSources: (path, req, budget, opts) => host.read(path, req, budget, opts),
probe: async (paths) => paths.map((p) => probes.get(p) ?? null),
loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 0 }),
deleteStore: (id) => EditHistoryStore.delete(id, { dir }),
sweepStores: async () => 0,
...over,
};
const load = deps.loadStore;
deps.loadStore = async (id) => {
loads.push(id);
const store = await load(id);
opened.push(store);
return store;
};
return { host, probes, lookups, loads, deps, sync: new EditHistorySync(deps) };
}
const local: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'conv1' };
const remote: EditHistorySession = {
id: 's2',
mode: 'claude',
remote: { host: 'huff' } as EditHistorySession['remote'],
claudeSessionId: 'conv1',
};
describe('unsupportedReason', () => {
it('accepts a local claude session, rejects remote, docker and non-claude CLIs', () => {
expect(unsupportedReason(local)).toBeUndefined();
expect(unsupportedReason(remote)).toBe('remote');
expect(unsupportedReason({ ...local, docker: { image: 'x' } })).toBe('docker');
expect(unsupportedReason({ ...local, mode: 'shell' })).toBe('mode');
});
});
describe('EditHistorySync', () => {
it('reads new edits forward from the stored offset', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
expect(await sync.sync(local)).toEqual({ state: 'ok' });
host.append('main', editLine('t2', T0 + 1000, '/w/b.ts'));
await sync.sync(local);
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t2', 't1']);
expect(host.calls[1]).toEqual([
{ relPath: 'main', offset: host.files.get('main')!.length - editLine('t2', T0 + 1000, '/w/b.ts').length },
]);
expect(host.paths).toEqual(['/p/conv1.jsonl', '/p/conv1.jsonl']);
});
it('reads subagent transcripts and records their meta', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
host.append('subagents/agent-a1.jsonl', editLine('t9', T0 + 5, '/w/sub.ts', { isSidechain: true, agentId: 'a1' }));
host.metas.a1 = '{"description":"Fix it","agentType":"coding"}';
await sync.sync(local);
const store = await sync.getStore('s1');
expect(store.get('t9')?.agentId).toBe('a1');
expect(store.getAgents().a1).toEqual({ description: 'Fix it', agentType: 'coding' });
});
it('reads a new conversation (/clear, resume) from its end, keeping the entries', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(local);
host.transcriptId = 'conv2';
host.files.set('main', Buffer.from(editLine('n1', T0 + 9, '/w/n.ts')));
await sync.sync(local);
expect(host.paths.at(-1)).toBe('/p/conv2.jsonl');
expect(host.calls.at(-1)).toEqual([]); // no offsets of the old file
const store = await sync.getStore('s1');
expect(store.getTranscriptId()).toBe('conv2');
expect(store.list().map((e) => e.id)).toEqual(['n1', 't1']);
});
it('keeps reading in passes when a file grew by more than one budget', async () => {
const { host, sync } = setup({ budget: 600 });
host.append('main', editLine('t0', T0, '/w/z.ts'));
await sync.sync(local);
for (let i = 1; i <= 3; i++) host.append('main', editLine(`t${i}`, T0 + i, `/w/${i}.ts`));
await sync.sync(local);
expect((await sync.getStore('s1')).list()).toHaveLength(4);
});
it('turns a Bash image into an entry only when its mtime lies in the call window', async () => {
const { host, probes, sync } = setup();
host.append('main', bashPair('b1', T0, T0 + 5000, 'render out/new.png old.png ~/x.png'));
probes.set('/w/out/new.png', { realPath: '/w/out/new.png', kind: 'file', size: 9, mtimeMs: T0 + 4000 });
probes.set('/w/old.png', { realPath: '/w/old.png', kind: 'file', size: 9, mtimeMs: T0 - 60_000 });
probes.set('~/x.png', { realPath: '/home/u/x.png', kind: 'file', size: 9, mtimeMs: T0 + 6500 });
await sync.sync(local);
expect((await sync.getStore('s1')).list().map((e) => [e.path, e.tool, e.kind])).toEqual([
['/w/out/new.png', 'bash', 'image'],
['/home/u/x.png', 'bash', 'image'],
]);
});
it('a removal during a read stops it: no further parsing, no probe of printed paths', async () => {
const probe = vi.fn(async (paths: readonly string[]) => paths.map(() => null));
let removal: Promise<void> | undefined;
const ctx = setup({
probe,
readSources: async (path, req, budget, opts) => {
const res = await ctx.host.read(path, req, budget, opts);
removal = ctx.sync.remove('s1'); // the session is deleted while this read is out
return res;
},
});
ctx.host.append('main', bashPair('b1', T0, T0 + 5000, 'render out/a.png'));
ctx.host.append('subagents/agent-a1.jsonl', bashPair('b2', T0, T0 + 5000, 'render out/b.png'));
await ctx.sync.sync(local);
await removal;
expect(probe).not.toHaveBeenCalled();
});
it('keeps the edits when only the image probe fails', async () => {
const { host, sync } = setup({
probe: async () => {
throw new Error('probe failed');
},
});
host.append('main', editLine('t1', T0, '/w/a.ts') + bashPair('b1', T0, T0 + 1, 'x out.png'));
expect(await sync.sync(local)).toEqual({ state: 'ok' });
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t1']);
});
it('reports a session without a transcript yet, then reads it once there is one', async () => {
const { host, sync } = setup();
expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: NO_TRANSCRIPT });
expect(host.calls).toEqual([]);
host.append('main', editLine('t1', T0, '/w/a.ts'));
expect(await sync.sync(local)).toEqual({ state: 'ok' });
expect((await sync.getStore('s1')).list()).toHaveLength(1);
});
it('reports a transcript that vanished (watcher path stale) the same way', async () => {
const { host, sync } = setup({ transcriptPath: async () => '/p/gone.jsonl' });
expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: NO_TRANSCRIPT });
expect(host.paths).toEqual(['/p/gone.jsonl']);
});
it('reports a read error as unavailable and keeps the stored entries', async () => {
const { host, sync, deps } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(local);
deps.readSources = async () => {
throw new Error('EACCES: permission denied');
};
const status = await sync.sync(local);
expect(status).toEqual({ state: 'unavailable', reason: 'EACCES: permission denied' });
expect((await sync.getStore('s1')).list()).toHaveLength(1);
});
it('marks remote, docker and non-claude sessions unsupported without looking for a transcript', async () => {
const { host, lookups, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
expect(await sync.sync({ ...local, mode: 'shell' })).toEqual({ state: 'unsupported', reason: 'mode' });
expect(await sync.sync(remote)).toEqual({ state: 'unsupported', reason: 'remote' });
expect(await sync.sync({ ...local, docker: {} })).toEqual({ state: 'unsupported', reason: 'docker' });
expect(lookups).toEqual([]);
expect(host.calls).toEqual([]);
});
it('runs one sync per session at a time and folds occasions into one more run', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const orig = host.read;
host.read = async (...a) => {
await gate;
return orig(...a);
};
const runs = [sync.sync(local), sync.sync(local), sync.sync(local)];
release();
await Promise.all(runs);
expect(host.calls).toHaveLength(2); // the first run + one folded follow-up
});
it('loads older blocks on request', async () => {
const { host, sync } = setup({ budget: 400 });
for (let i = 0; i < 4; i++) host.append('main', editLine(`t${i}`, T0 + i, `/w/${i}.ts`));
await sync.sync(local);
const store = await sync.getStore('s1');
const before = store.list().length;
expect(store.backfillTruncated).toBe(true);
await sync.sync(local, { older: true });
expect(store.list().length).toBeGreaterThan(before);
});
it('flushAll writes pending saves (shutdown) so a reload keeps the entries', async () => {
const { host, sync } = setup({ loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 60_000 }) });
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(local);
await sync.flushAll();
const reloaded = await EditHistoryStore.load('s1', { dir });
expect(reloaded.list().map((e) => e.id)).toEqual(['t1']);
});
it('remove deletes the stored history', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(local);
await (await sync.getStore('s1')).flush();
await sync.remove('s1');
expect(() => readFileSync(join(dir, 's1.json'))).toThrow();
});
it('neither a run in flight nor a request during the removal writes the deleted history again', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
let entered!: () => void;
const reading = new Promise<void>((r) => (entered = r));
const orig = host.read;
host.read = async (...a) => {
entered();
await gate;
return orig(...a);
};
const runs = [sync.sync(local), sync.sync(local)]; // one running, one queued
await reading;
const removing = sync.remove('s1');
// The removal waits for the read under way; a request meanwhile reads nothing.
expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: REMOVED });
release();
await Promise.all([...runs, removing]);
await new Promise((r) => setTimeout(r, 20)); // past the store's debounce
expect(readdirSync(dir)).toEqual([]);
expect(host.calls).toHaveLength(1); // the read under way; the queued run never read
});
it('the same conversation under an unnormalized path reads on from its cursors, nothing else', async () => {
const path = '/p/./conv1.jsonl';
const { host, sync } = setup({ transcriptPath: async () => path });
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(local);
host.append('main', editLine('t2', T0 + 1, '/w/a.ts'));
await sync.sync(local);
expect(host.paths).toEqual([path, path]);
expect(host.calls[1]).toEqual([
{ relPath: 'main', offset: host.files.get('main')!.length - editLine('t2', T0 + 1, '/w/a.ts').length },
]);
});
it('a session that comes back with the same id (reboot restore) reads its history afresh', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(local);
// Boot: the unpinned record is pruned as stale; then "restore" brings the session back.
await sync.remove('s1');
expect(await sync.sync(local)).toEqual({ state: 'ok' });
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t1']);
});
it('remove deletes a history it has not loaded without loading it', async () => {
const { sync, loads } = setup();
const other = new EditHistoryStore('s1', { dir });
other.addImageEntries([{ toolUseId: 'b1', path: '/w/a.png', ts: T0 }]);
await other.flush();
await sync.remove('s1');
expect(loads).toEqual([]);
expect(existsSync(join(dir, 's1.json'))).toBe(false);
});
it(`keeps at most ${MAX_LOADED_STORES} stores loaded: a dropped one is written and loads again whole`, async () => {
const { host, sync, loads } = setup({ loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 60_000 }) });
host.append('main', editLine('t1', T0, '/w/a.ts'));
const ids = Array.from({ length: MAX_LOADED_STORES + 2 }, (_, i) => `s${i}`);
for (const id of ids) await sync.sync({ ...local, id });
expect(loads).toEqual(ids);
// The two least recently used went: written (no debounced save ran), not kept in memory.
await sync.flushAll();
expect(existsSync(join(dir, 's0.json'))).toBe(true);
expect((await sync.getStore('s0')).list().map((e) => e.id)).toEqual(['t1']);
expect(loads.slice(ids.length)).toEqual(['s0']);
// The most recently used ones stay loaded.
await sync.getStore(ids[ids.length - 1]);
expect(loads.slice(ids.length)).toEqual(['s0']);
});
it('a dropped store that never changed writes nothing (no file for a session without edits)', async () => {
const { sync } = setup(); // no transcript: nothing to read
for (let i = 0; i <= MAX_LOADED_STORES; i++) await sync.sync({ ...local, id: `s${i}` });
await sync.flushAll();
expect(readdirSync(dir)).toEqual([]);
});
it('a store whose run is in flight is not dropped, and loads once', async () => {
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const { host, sync, loads } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
const orig = host.read;
let first = true;
let inRead!: () => void;
const reachedRead = new Promise<void>((r) => (inRead = r));
host.read = async (...a) => {
if (first) {
first = false;
inRead();
await gate;
}
return orig(...a);
};
const slow = sync.sync(local);
// Only once s1 holds the gate: the sessions load their stores independently, so a later one
// could reach the read first, take the gate and hang the loop below before `release()`.
await reachedRead;
for (let i = 1; i <= MAX_LOADED_STORES + 1; i++) await sync.sync({ ...local, id: `s1-${i}` });
release();
expect(await slow).toEqual({ state: 'ok' });
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t1']);
expect(loads.filter((id) => id === 's1')).toEqual(['s1']);
});
it('concurrent first callers share one store', async () => {
const { sync } = setup();
const [a, b] = await Promise.all([sync.getStore('s1'), sync.getStore('s1')]);
expect(a).toBe(b);
});
it('a forward request queued together with an older one still reads forward', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const orig = host.read;
host.read = async (...a) => {
const res = await orig(...a); // the first run's bytes are taken before the append
await gate;
return res;
};
const first = sync.sync(local);
await new Promise((r) => setTimeout(r, 0));
host.append('main', editLine('t2', T0 + 1, '/w/b.ts'));
const queued = [sync.sync(local), sync.sync(local, { older: true })];
release();
await Promise.all([first, ...queued]);
expect((await sync.getStore('s1')).get('t2')).toBeDefined();
});
it('load older moves every source back, reading only the blocks it asked for', async () => {
const { host, sync } = setup({ budget: 2000 });
for (let i = 0; i < 10; i++) host.append('main', editLine(`m${i}`, T0 + i, `/w/m${i}.ts`));
for (let i = 0; i < 10; i++) {
host.append('subagents/agent-ab.jsonl', editLine(`s${i}`, T0 + 100 + i, `/w/s${i}.ts`, { agentId: 'ab' }));
}
await sync.sync(local);
await sync.sync(local); // the subagent gets its first block once main is up to date
const store = await sync.getStore('s1');
const floors = () => [store.getCursor('main')!.floor, store.getCursor('subagents/agent-ab.jsonl')!.floor];
const [main0, sub0] = floors();
expect(main0).toBeGreaterThan(0);
expect(sub0).toBeGreaterThan(0);
await sync.sync(local, { older: true });
const [main1, sub1] = floors();
expect(main1).toBeLessThan(main0);
expect(sub1).toBeLessThan(sub0);
expect(
host.calls
.at(-1)!
.map((r) => r.relPath)
.sort()
).toEqual(['main', 'subagents/agent-ab.jsonl']);
for (let i = 0; i < 20 && store.backfillTruncated; i++) await sync.sync(local, { older: true });
expect(store.list()).toHaveLength(20);
});
it('a file the shared budget cut short is read in the next pass, not skipped as one long line', async () => {
const budget = 4000;
const { host, sync } = setup({ budget });
const sub = 'subagents/agent-a1.jsonl';
host.append('main', editLine('m0', T0, '/w/m0.ts'));
host.append(sub, editLine('s0', T0 + 1, '/w/s0.ts', { agentId: 'a1' }));
await sync.sync(local);
// main grows by almost a whole budget, leaving the subagent 50 bytes of this pass
const filler = JSON.stringify({ type: 'user', message: { content: '' } });
host.append(
'main',
JSON.stringify({ type: 'user', message: { content: 'z'.repeat(budget - 51 - filler.length) } }) + '\n'
);
host.append(sub, editLine('s1', T0 + 2, '/w/s1.ts', { agentId: 'a1', pad: 'p'.repeat(100) }));
host.append(sub, editLine('s2', T0 + 3, '/w/s2.ts', { agentId: 'a1' }));
await sync.sync(local);
const store = await sync.getStore('s1');
expect(
store
.list()
.map((e) => e.id)
.sort()
).toEqual(['m0', 's0', 's1', 's2']);
expect(store.getCursor(sub)?.midLine).toBeUndefined();
});
it('a first read reaches the subagents in a further pass when main fills the budget', async () => {
const { host, sync } = setup({ budget: 3000 });
for (let i = 0; i < 20; i++) host.append('main', editLine(`m${i}`, T0 + i, `/w/m${i}.ts`));
host.append('subagents/agent-a1.jsonl', editLine('s0', T0 + 100, '/w/s0.ts', { agentId: 'a1' }));
await sync.sync(local);
const store = await sync.getStore('s1');
expect(store.get('s0')?.agentId).toBe('a1');
expect(store.getCursor('subagents/agent-a1.jsonl')).toBeDefined();
});
it('load older reads a full budget per source: a line longer than the budget split over the sources is kept', async () => {
const budget = 2000;
const { host, sync } = setup({ budget });
host.append('main', editLine('m0', T0, '/w/m0.ts'));
host.append('main', editLine('big', T0 + 1, '/w/big.ts', { pad: 'p'.repeat(800) })); // > budget / 3
for (let i = 2; i < 12; i++) host.append('main', editLine(`m${i}`, T0 + i, `/w/m${i}.ts`));
for (const a of ['a1', 'a2']) {
for (let i = 0; i < 12; i++) {
host.append(
`subagents/agent-${a}.jsonl`,
editLine(`${a}-${i}`, T0 + 100 + i, `/w/${a}${i}.ts`, { agentId: a })
);
}
}
for (let i = 0; i < 3; i++) await sync.sync(local);
const store = await sync.getStore('s1');
expect(store.sourcesWithOlder()).toHaveLength(3);
for (let i = 0; i < 20 && store.backfillTruncated; i++) await sync.sync(local, { older: true });
expect(store.get('big')).toBeDefined();
expect(store.list()).toHaveLength(36);
});
it(`load older reads at most ${MAX_OLDER_SOURCES} sources per click, taking turns`, async () => {
const { host, sync } = setup({ budget: 600 });
const subs = ['a1', 'a2', 'a3', 'a4'].map((a) => `subagents/agent-${a}.jsonl`);
for (const relPath of ['main', ...subs]) {
for (let i = 0; i < 8; i++) host.append(relPath, editLine(`${relPath}-${i}`, T0 + i, `/w/${i}.ts`));
}
for (let i = 0; i < 3; i++) await sync.sync(local);
const store = await sync.getStore('s1');
expect(store.sourcesWithOlder()).toHaveLength(5);
const olderCall = () => host.calls.at(-1)!.map((r) => r.relPath);
await sync.sync(local, { older: true });
expect(olderCall()).toEqual(['main', subs[0], subs[1]]);
await sync.sync(local, { older: true });
expect(olderCall()).toEqual([subs[2], subs[3], 'main']);
for (let i = 0; i < 30 && store.backfillTruncated; i++) await sync.sync(local, { older: true });
expect(store.list()).toHaveLength(40);
});
it('a subagent transcript that vanished loses its cursor (no endless "Load older"), not its entries', async () => {
const { host, sync } = setup({ budget: 600 });
const sub = 'subagents/agent-a1.jsonl';
host.append('main', editLine('m0', T0, '/w/m0.ts'));
for (let i = 0; i < 8; i++) host.append(sub, editLine(`s${i}`, T0 + 10 + i, `/w/s${i}.ts`, { agentId: 'a1' }));
for (let i = 0; i < 2; i++) await sync.sync(local);
const store = await sync.getStore('s1');
expect(store.backfillTruncated).toBe(true);
const kept = store.list().length;
host.files.delete(sub);
await sync.sync(local, { older: true });
expect(store.getCursor(sub)).toBeUndefined();
expect(store.backfillTruncated).toBe(false);
expect(store.list()).toHaveLength(kept);
});
it('sweep keeps the sessions in use here and those that exist', async () => {
const asked: Record<string, boolean> = {};
const { sync } = setup({
sweepStores: async (keep) => {
for (const id of ['s1', 'kept', 'gone']) asked[id] = keep(id);
return 1;
},
});
await sync.getStore('s1');
expect(await sync.sweep((id) => id === 'kept')).toBe(1);
expect(asked).toEqual({ s1: true, kept: true, gone: false });
});
});
describe('confirmImages', () => {
it('ignores directories and missing files', () => {
const c = [{ toolUseId: 'b', start: T0, end: T0 + 1, paths: ['/d.png', '/m.png'] }];
const out = confirmImages(c, (p) =>
p === '/d.png' ? { realPath: p, kind: 'directory', size: 0, mtimeMs: T0 } : null
);
expect(out).toEqual([]);
});
it('lists a file sent to the user outside any window, once per file version', () => {
const png = { realPath: '/w/p.png', kind: 'file' as const, size: 9, mtimeMs: T0 };
const sent = { toolUseId: 's', start: T0 + 9000, end: T0 + 9000, paths: ['/w/p.png'], sent: true };
expect(confirmImages([sent], () => png)).toEqual([
{ toolUseId: 's', path: '/w/p.png', ts: T0 + 9000, tool: 'sent' },
]);
// The Bash call that wrote it was already listed: same version, not again.
expect(
confirmImages(
[sent],
() => png,
() => T0 + 500
)
).toEqual([]);
expect(confirmImages([sent], () => null)).toEqual([]);
});
it('skips a file version already recorded: the ls right after the render, a listing after a Write', () => {
const png = { realPath: '/w/p.png', kind: 'file' as const, size: 9, mtimeMs: T0 + 900 };
const render = { toolUseId: 'r', start: T0, end: T0 + 1000, paths: ['/w/p.png'] };
const ls = { toolUseId: 'l', start: T0 + 1500, end: T0 + 1600, paths: ['/w/p.png'] };
expect(confirmImages([ls, render], () => png).map((i) => i.toolUseId)).toEqual(['r']);
// A Write entry for the same path at T0 + 950 already covers it.
expect(
confirmImages(
[ls],
() => png,
() => T0 + 950
)
).toEqual([]);
// A later re-render (newer mtime, outside the slack) is a new version.
const again = { toolUseId: 'r2', start: T0 + 10_000, end: T0 + 11_000, paths: ['/w/p.png'] };
const newer = { ...png, mtimeMs: T0 + 10_500 };
expect(
confirmImages(
[again],
() => newer,
() => T0 + 1000
).map((i) => i.toolUseId)
).toEqual(['r2']);
});
});
describe('reads only on request (wiring guard)', () => {
const read = (file: string) => readFileSync(join(__dirname, '..', file), 'utf-8');
it('the server never starts a sync itself: it only hands the reader to the routes', () => {
const server = read('src/web/server.ts');
const uses = [...server.matchAll(/this\.editHistorySync\b(\.\w+)?/g)].map((m) => m[1] ?? '');
expect(uses.sort()).toEqual(['', '.flushAll', '.remove', '.sweep']);
expect(server).toMatch(/registerEditHistoryRoutes\(this\.app, ctx, this\.editHistorySync\)/);
});
it('no other module holds the reader, and the edit history modules run no timer or watcher', () => {
const root = join(__dirname, '..');
const holders = readdirSync(join(root, 'src'), { recursive: true, encoding: 'utf-8' })
.filter((f) => /\.(ts|js)$/.test(f))
.map((f) => join('src', f).split('\\').join('/'))
.filter((f) => /EditHistorySync|editHistorySync/.test(read(f)));
expect(holders.sort()).toEqual([
'src/web/edit-history-sync.ts',
'src/web/routes/edit-history-routes.ts',
'src/web/server.ts',
]);
for (const file of [
'src/web/edit-history-sync.ts',
'src/edit-history-store.ts',
'src/edit-history-local-source.ts',
'src/edit-history-parse.ts',
'src/web/routes/edit-history-routes.ts',
]) {
const src = read(file);
expect(src, file).not.toMatch(/setInterval|\bwatch\(|chokidar/);
}
// The store's only timer is its debounced save.
expect(read('src/web/edit-history-sync.ts')).not.toMatch(/setTimeout/);
});
});
vi.setConfig({ testTimeout: 10_000 });
+483
View File
@@ -0,0 +1,483 @@
/**
* @fileoverview Edit history button and panel, end to end in a real browser: real server, real
* Chromium. The edits API is mocked with `page.route` (the UI is built against its contract); the
* thumbnails (workspace `file-raw`, the attachment route outside it) and the transcript SSE
* events the open panel follows go through the real server.
*/
import { homedir } from 'node:os';
import { 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, type Route } from 'playwright';
import { WebServer } from '../src/web/server.js';
/** A 2×2 red PNG, so the thumbnail has real pixels to decode. */
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEElEQVR4nGP4z8AARAwQCgAf7gP9i18U1AAAAABJRU5ErkJggg==',
'base64'
);
type Entry = {
id: string;
ts: number;
path: string;
relPath: string;
kind: 'code' | 'image';
tool: string;
created: boolean;
added: number;
removed: number;
agentId?: string;
};
describe('Edit history panel in a real browser', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
let root: string;
/** A directory outside the session's workspace, for the attachment-route thumbnail. */
let ext: string;
let sessionA: string;
let sessionB: string;
const settingsPutStatuses: number[] = [];
/** Bodies of POST /api/sessions/:id/attachments (the file preview registering an external image). */
const attachmentPosts: unknown[] = [];
/** Every request to the mocked API: `METHOD url`. */
const calls: string[] = [];
/** The mocked server state per session id. */
const state = new Map<string, Record<string, unknown>>();
let diffs: Record<string, { diff: string; truncated: boolean }> = {};
let olderEntries: Entry[] = [];
const entry = (over: Partial<Entry> & { id: string; relPath: string }): Entry => ({
ts: Date.now(),
path: join(root, over.relPath),
kind: 'code',
tool: 'Edit',
created: false,
added: 1,
removed: 1,
...over,
});
const json = (route: Route, data: unknown) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ success: true, data }) });
const buttonVisible = () =>
page.evaluate(() => getComputedStyle(document.getElementById('editHistoryBtn')!).display !== 'none');
const bodyText = async () => (await page.textContent('#editHistoryBody')) ?? '';
const setSetting = async (on: boolean) => {
await page.evaluate(() => (window as any).app.openAppSettings());
if ((await page.isChecked('#appSettingsShowEditHistory')) !== on)
await page.click('label.set-chip:has(#appSettingsShowEditHistory)');
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);
const reload = () => page.evaluate(() => (window as any).app.refreshEditHistory());
const syncReads = (sid: string) => calls.filter((c) => c === `GET /api/sessions/${sid}/edits?sync=1`).length;
const toolEnd = (sessionId: string, toolName: string) =>
(server as any).broadcast('transcript:tool_end', { sessionId, toolName, isError: false, timestamp: Date.now() });
const rows = () => page.locator('#editHistoryBody .edit-history-entry');
beforeAll(async () => {
root = mkdtempSync(join(homedir(), 'edit-history-ui-'));
writeFileSync(join(root, 'shot.png'), PNG);
ext = mkdtempSync(join(homedir(), 'edit-history-ext-'));
writeFileSync(join(ext, 'render.png'), PNG);
server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
page.on('response', (r) => {
if (r.request().method() === 'PUT' && r.url().endsWith('/api/settings')) settingsPutStatuses.push(r.status());
});
page.on('request', (r) => {
if (r.method() === 'POST' && /\/api\/sessions\/[^/]+\/attachments$/.test(r.url()))
attachmentPosts.push(r.postDataJSON());
});
await page.route('**/api/sessions/*/edits**', async (route) => {
const req = route.request();
const url = new URL(req.url());
calls.push(`${req.method()} ${url.pathname}${url.search}`);
const m = url.pathname.match(/\/api\/sessions\/([^/]+)\/edits(?:\/(.+))?$/)!;
const sid = decodeURIComponent(m[1]);
const rest = m[2] ? decodeURIComponent(m[2]) : '';
const s = state.get(sid) ?? { state: 'unsupported', backfillTruncated: false, agents: {}, entries: [] };
if (req.method() === 'POST' && rest === 'older') {
const entries = [...(s.entries as Entry[]), ...olderEntries];
state.set(sid, { ...s, entries, backfillTruncated: false });
return json(route, state.get(sid));
}
if (req.method() === 'GET' && rest) {
const d = diffs[rest];
if (!d) return route.fulfill({ status: 404, contentType: 'application/json', body: '{"success":false}' });
return json(route, d);
}
return json(route, s);
});
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
sessionA = await createSession(root);
sessionB = await createSession(root);
await select(sessionA);
const t0 = Date.now();
state.set(sessionA, {
state: 'ok',
backfillTruncated: true,
agents: {
agent1: { description: 'Refactor the parser', agentType: 'general-purpose', worktreeBranch: 'wt/parser' },
},
entries: [
entry({ id: 'e4', ts: t0, relPath: 'src/a.ts', added: 3, removed: 1 }),
entry({ id: 'e3', ts: t0 - 1000, relPath: 'shot.png', kind: 'image', tool: 'bash', created: true }),
entry({
id: 'e2',
ts: t0 - 2000,
relPath: 'src/b.ts',
created: true,
tool: 'Write',
added: 10,
removed: 0,
agentId: 'agent1',
}),
entry({ id: 'e1', ts: t0 - 3000, relPath: 'src/a.ts', added: 2, removed: 2 }),
],
});
diffs = {
e4: { diff: '@@ -1,3 +1,3 @@\n context\n-old line\n+new line\n+another\n', truncated: true },
e1: { diff: '@@ -5 +5 @@\n-x\n+y\n', truncated: false },
};
olderEntries = [entry({ id: 'e0', ts: t0 - 86_400_000, relPath: 'src/old.ts', added: 7, removed: 0 })];
}, 120000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
rmSync(root, { recursive: true, force: true });
rmSync(ext, { recursive: true, force: true });
}, 60000);
it('is off by default: no button, and nothing asks the edits API', async () => {
await page.waitForTimeout(1000);
expect(await buttonVisible()).toBe(false);
expect(calls).toEqual([]);
});
it('turning it on through Settings shows the button and saves cleanly (the key never reaches the strict PUT)', async () => {
await setSetting(true);
expect(settingsPutStatuses.length).toBeGreaterThan(0);
expect(settingsPutStatuses.every((s) => s === 200)).toBe(true);
expect(await buttonVisible()).toBe(true);
// In the header next to Attachments, where the panel opens; no longer in the bottom bar.
expect(await page.evaluate(() => !!document.querySelector('#headerRight #editHistoryBtn'))).toBe(true);
expect(await page.evaluate(() => !!document.querySelector('.toolbar #editHistoryBtn'))).toBe(false);
expect(calls).toEqual([]); // no poll: nothing is read until the panel opens
});
it('opens a panel listing the entries newest first, with the subagent chip and an image thumbnail', async () => {
await page.click('#editHistoryBtn');
await page.waitForSelector('#editHistoryPanel.visible');
await page.waitForSelector('#editHistoryBody .edit-history-entry');
expect(calls[0]).toBe(`GET /api/sessions/${sessionA}/edits?sync=1`);
expect(await page.getAttribute('#editHistoryBtn', 'aria-expanded')).toBe('true');
expect(await rows().count()).toBe(4);
const paths = await page.locator('#editHistoryBody .edit-history-path').allTextContents();
expect(paths).toEqual(['src/a.ts', 'shot.png', 'src/b.ts', 'src/a.ts']);
expect(await rows().nth(0).locator('.edit-history-badge').textContent()).toBe('edit');
expect(await rows().nth(2).locator('.edit-history-badge').textContent()).toBe('new');
expect(await rows().nth(0).locator('.edit-history-stats').textContent()).toBe('+3−1');
// The image row: a symbol instead of a word, no +/− counts.
expect(await rows().nth(1).locator('.edit-history-badge--image svg').count()).toBe(1);
expect(await rows().nth(1).locator('.edit-history-stats').count()).toBe(0);
// The subagent chip: its description, and type + branch on hover.
const chip = rows().nth(2).locator('.edit-history-agent');
expect(await chip.textContent()).toBe('Refactor the parser');
expect(await chip.getAttribute('title')).toBe('general-purpose · wt/parser');
expect(await page.locator('#editHistoryBody .edit-history-agent').count()).toBe(1);
// The thumbnail is served by the real file-raw route and decodes.
const thumb = page.locator('#editHistoryBody img.edit-history-thumb');
expect(await thumb.count()).toBe(1);
expect(await thumb.getAttribute('src')).toBe(
`/api/sessions/${sessionA}/file-raw?path=${encodeURIComponent(join(root, 'shot.png'))}`
);
await page.waitForFunction(
() => (document.querySelector('img.edit-history-thumb') as HTMLImageElement)?.naturalWidth === 2
);
expect(await page.textContent('#editHistoryCount')).toBe('4');
});
it('a code entry opens its diff inline with the Git panel line classes, and closes again', async () => {
await rows().nth(0).locator('.edit-history-spacer').click();
await page.waitForSelector('#editHistoryBody .edit-history-diff pre.git-diff');
expect(calls).toContain(`GET /api/sessions/${sessionA}/edits/e4`);
const lines = await page.$$eval('#editHistoryBody pre.git-diff .git-diff-line', (els) =>
els.map((e) => [e.className.replace('git-diff-line', '').trim(), e.textContent])
);
expect(lines).toEqual([
['git-diff-line--hunk', '@@ -1,3 +1,3 @@\n'],
['', ' context\n'],
['git-diff-line--del', '-old line\n'],
['git-diff-line--add', '+new line\n'],
['git-diff-line--add', '+another\n'],
]);
expect(await page.textContent('#editHistoryBody .edit-history-diff')).toContain('Diff cut short');
expect(await rows().nth(0).getAttribute('aria-expanded')).toBe('true');
// A re-read (what an SSE update does) keeps the open diff without fetching it again.
const before = calls.filter((c) => c.endsWith('/edits/e4')).length;
await reload();
expect(await page.locator('#editHistoryBody pre.git-diff').count()).toBe(1);
expect(calls.filter((c) => c.endsWith('/edits/e4')).length).toBe(before);
await rows().nth(0).locator('.edit-history-spacer').click();
expect(await page.locator('#editHistoryBody pre.git-diff').count()).toBe(0);
});
it('a path and an image open the shared file preview with the absolute path', async () => {
await page.evaluate(() => {
const w = window as any;
w.__previewed = [];
w.__openFilePreview = w.app.openFilePreview;
w.app.openFilePreview = (p: string, sid: string) => w.__previewed.push([p, sid]);
});
await rows().nth(2).locator('.edit-history-path').click();
await page.locator('#editHistoryBody img.edit-history-thumb').click();
expect(await page.evaluate(() => (window as any).__previewed)).toEqual([
[join(root, 'src/b.ts'), sessionA],
[join(root, 'shot.png'), sessionA],
]);
// Clicking the path never toggles the row's diff.
expect(await page.locator('#editHistoryBody .edit-history-diff').count()).toBe(0);
await page.evaluate(() => {
const w = window as any;
w.app.openFilePreview = w.__openFilePreview;
});
});
it('"Main agent only" hides the subagent edits; "Group by file" puts edits under their file', async () => {
await page.check('#editHistoryMainOnlyToggle');
expect(await rows().count()).toBe(3);
expect(await page.locator('#editHistoryBody .edit-history-agent').count()).toBe(0);
expect(await bodyText()).not.toContain('src/b.ts');
await page.uncheck('#editHistoryMainOnlyToggle');
expect(await rows().count()).toBe(4);
await page.check('#editHistoryGroupToggle');
const groups = page.locator('#editHistoryBody .edit-history-group');
expect(await groups.count()).toBe(3);
expect(await groups.nth(0).locator('.edit-history-group-head .edit-history-path').textContent()).toBe('src/a.ts');
expect(await groups.nth(0).locator('.edit-history-entry').count()).toBe(2);
expect(await groups.nth(0).locator('.edit-history-group-head .edit-history-stats').textContent()).toBe('+5−3');
await groups.nth(0).locator('.edit-history-entry').nth(1).locator('.edit-history-spacer').click();
await page.waitForSelector('#editHistoryBody .git-diff-line--add');
expect(await page.textContent('#editHistoryBody .git-diff-line--add')).toBe('+y\n');
await page.uncheck('#editHistoryGroupToggle');
});
it('"Load older" asks the server with a POST and shows what it returns', async () => {
const older = page.locator('#editHistoryBody button.edit-history-older');
expect(await older.count()).toBe(1);
await older.click();
await page.waitForFunction(() =>
/src\/old\.ts/.test(document.getElementById('editHistoryBody')!.textContent ?? '')
);
expect(calls).toContain(`POST /api/sessions/${sessionA}/edits/older`);
expect(await rows().count()).toBe(5);
expect(await page.locator('#editHistoryBody button.edit-history-older').count()).toBe(0);
});
it('while open it follows the session: an edit-capable tool end or a turn end re-reads (debounced), other tools, sessions and keep-alive turns do not', async () => {
const before = syncReads(sessionA);
const s = state.get(sessionA)!;
state.set(sessionA, {
...s,
entries: [entry({ id: 'e5', relPath: 'src/fresh.ts', added: 4, removed: 0 }), ...(s.entries as Entry[])],
});
toolEnd(sessionA, 'Read');
toolEnd(sessionA, 'Grep');
toolEnd('someone-else', 'Edit');
await page.waitForTimeout(1000);
expect(syncReads(sessionA)).toBe(before);
toolEnd(sessionA, 'Edit');
toolEnd(sessionA, 'Bash');
await page.waitForFunction(() =>
/src\/fresh\.ts/.test(document.getElementById('editHistoryBody')!.textContent ?? '')
);
await page.waitForTimeout(800);
expect(syncReads(sessionA)).toBe(before + 1);
// The end of a turn re-reads too (a parallel tool call reports one tool end only, a background
// subagent writes later): `session:idle`, since claude's transcript has no `result` line.
// Not another session's, and not the end of Codeman's own keep-alive turn (it edits nothing).
(server as any).broadcast('session:idle', { id: 'someone-else' });
(server as any).broadcast('session:idle', { id: sessionA, keepAlive: true });
await page.waitForTimeout(1200);
expect(syncReads(sessionA)).toBe(before + 1);
(server as any).broadcast('session:idle', { id: sessionA });
await page.waitForTimeout(1200);
expect(syncReads(sessionA)).toBe(before + 2);
});
it('while the panel is closed, transcript events read nothing; opening reads once', async () => {
await page.click('#editHistoryBtn');
await page.waitForSelector('#editHistoryPanel:not(.visible)', { state: 'attached' });
const before = syncReads(sessionA);
toolEnd(sessionA, 'Edit');
(server as any).broadcast('session:idle', { id: sessionA });
await page.waitForTimeout(1200);
expect(syncReads(sessionA)).toBe(before);
expect(await page.$('#editHistoryUnread')).toBeNull(); // no unread badge: closed means idle
await page.click('#editHistoryBtn');
await page.waitForSelector('#editHistoryPanel.visible');
await page.waitForFunction(() => document.querySelectorAll('#editHistoryBody .edit-history-entry').length > 0);
expect(syncReads(sessionA)).toBe(before + 1);
});
it('an image outside the workspace registers nothing until clicked, then previews through the attachment route quietly', async () => {
const s = state.get(sessionA)!;
const outside = join(ext, 'render.png');
state.set(sessionA, {
...s,
entries: [
entry({ id: 'ext', path: outside, relPath: outside, kind: 'image', tool: 'bash', added: 0, removed: 0 }),
...(s.entries as Entry[]),
],
});
await reload();
const row = page.locator('#editHistoryBody .edit-history-entry', { hasText: 'render.png' });
await row.waitFor();
// No thumbnail for it (that would mint an attachment id per render), and no registration.
expect(
await page.evaluate(() =>
[...document.querySelectorAll('#editHistoryBody img.edit-history-thumb')].map((i) => i.getAttribute('src'))
)
).not.toContainEqual(expect.stringContaining('render.png'));
await reload();
expect(attachmentPosts).toEqual([]);
// A click opens the shared preview, which registers the file once, without a notification.
await row.click();
await page.waitForSelector('#filePreviewOverlay.visible');
expect(attachmentPosts).toEqual([{ path: outside, notify: false }]);
await page.evaluate(() => (window as any).app.closeFilePreview());
state.set(sessionA, s);
});
it('a long path outside the workspace keeps its file name readable, the folder is cut at the start', async () => {
const s = state.get(sessionA)!;
const long =
'/tmp/claude-1000/-mnt-caches-ComfyUI/07e259af-d4d2-4ac3-8a7e-95972e3547df/scratchpad/icons/bremse_alt1_pedal.png';
state.set(sessionA, {
...s,
entries: [
entry({ id: 'long', relPath: long, kind: 'image', tool: 'sent', added: 0, removed: 0 }),
...(s.entries as Entry[]),
],
});
await reload();
const row = rows().nth(0);
await row.waitFor();
expect(await row.locator('.edit-history-path').textContent()).toBe(long);
const name = row.locator('.edit-history-path-name');
expect(await name.textContent()).toBe('bremse_alt1_pedal.png');
// The name is shown whole; only the folder part is shortened.
expect(await name.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true);
expect(await row.locator('.edit-history-path-dir').evaluate((el) => el.scrollWidth > el.clientWidth)).toBe(true);
state.set(sessionA, s);
});
it('renders a hostile path as text, never as markup', async () => {
const s = state.get(sessionA)!;
const evil = '<script>window.__pwned=1</script><img src=x onerror="window.__pwned=2">.ts';
state.set(sessionA, {
...s,
agents: { agent1: { description: '<b>bold</b>', agentType: 'x', worktreeBranch: '' } },
entries: [entry({ id: 'evil', relPath: evil, agentId: 'agent1' }), ...(s.entries as Entry[])],
});
await reload();
await page.waitForFunction(() => /__pwned/.test(document.getElementById('editHistoryBody')!.textContent ?? ''));
expect(await rows().nth(0).locator('.edit-history-path').textContent()).toBe(evil);
expect(await rows().nth(0).locator('.edit-history-agent').textContent()).toBe('<b>bold</b>');
expect(await page.$('#editHistoryBody script')).toBeNull();
expect(await page.$('#editHistoryBody b')).toBeNull();
expect(
await page.$$eval(
'#editHistoryBody img',
(els) => els.filter((e) => !e.classList.contains('edit-history-thumb')).length
)
).toBe(0);
expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();
});
it('unavailable: the saved entries stay, with the reason on top', async () => {
const s = state.get(sessionA)!;
state.set(sessionA, { ...s, state: 'unavailable', reason: 'EACCES: permission denied' });
await reload();
await page.waitForSelector('#editHistoryBody .edit-history-note--warn');
expect(await page.textContent('#editHistoryBody .edit-history-note--warn')).toBe(
'Showing saved entries only: EACCES: permission denied'
);
expect(await rows().count()).toBeGreaterThan(0);
state.set(sessionA, { ...s, state: 'ok' });
});
it('no transcript yet: says it comes with the next prompt, and names CLAUDE_CONFIG_DIR', async () => {
const s = state.get(sessionA)!;
state.set(sessionA, { ...s, state: 'unavailable', reason: 'no-transcript', entries: [] });
await reload();
await page.waitForSelector('#editHistoryBody .edit-history-note--warn');
const note = (await page.textContent('#editHistoryBody .edit-history-note--warn')) ?? '';
expect(note).toContain('No transcript found for this session yet');
expect(note).toContain('CLAUDE_CONFIG_DIR');
state.set(sessionA, s);
await reload();
});
it('follows the active session, and says when a session is not a Claude session', async () => {
await select(sessionB);
await page.waitForFunction(() =>
/only available for local Claude sessions/.test(document.getElementById('editHistoryBody')!.textContent ?? '')
);
expect(calls).toContain(`GET /api/sessions/${sessionB}/edits?sync=1`);
expect(await rows().count()).toBe(0);
await select(sessionA);
await page.waitForSelector('#editHistoryBody .edit-history-entry');
});
it('drags by the header', async () => {
const before = await page.evaluate(() => document.getElementById('editHistoryPanel')!.getBoundingClientRect().left);
const box = (await page.locator('.edit-history-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('editHistoryPanel')!.getBoundingClientRect().left);
expect(after).toBeLessThan(before - 100);
});
it('turning the setting off hides the button and closes the panel; SSE then reads nothing', async () => {
await setSetting(false);
expect(await buttonVisible()).toBe(false);
expect(await page.isVisible('#editHistoryPanel')).toBe(false);
const before = calls.length;
toolEnd(sessionA, 'Edit');
await page.waitForTimeout(1200);
expect(calls.length).toBe(before);
});
});
+173 -4
View File
@@ -52,13 +52,13 @@ const { findFilePathLinks, resolveLinkedFilePath, terminalPathLine } = loadHelpe
const paths = (text: string) => findFilePathLinks(text).map((f) => f.path);
/** An xterm buffer stand-in: hard-broken rows (no `isWrapped`), as Claude Code emits them. */
function buffer(lines: string[], cols = 80) {
function buffer(lines: string[], cols = 80, wrappedRows = new Set<number>()) {
return {
length: lines.length,
getLine: (row: number) =>
row >= 0 && row < lines.length
? {
isWrapped: false,
isWrapped: wrappedRows.has(row),
translateToString: (trim?: boolean) => (trim ? lines[row].replace(/\s+$/, '') : lines[row].padEnd(cols)),
}
: undefined,
@@ -157,6 +157,10 @@ describe('terminalPathLine', () => {
// A path that already ended, and a next line opening a new rooted path.
expect(terminalPathLine(buffer(['see /tmp/a/b.png', 'c/d.png']), 0, 80)).toMatchObject({ endRow: 0 });
expect(terminalPathLine(buffer(['/home/joe/dir/', '/home/x.png']), 0, 80)).toMatchObject({ endRow: 0 });
expect(terminalPathLine(buffer(['see (/mnt/raid1/folder)', ' file.conf)']), 0, 80)).toMatchObject({ endRow: 0 });
expect(terminalPathLine(buffer(['see (/mnt/raid1/folder', ' /mnt/other/file.conf)']), 0, 80)).toMatchObject({
endRow: 0,
});
});
// `git ls-files`, `find` and `ls -R` print one path per row. An extensionless
@@ -237,13 +241,15 @@ function loadApp() {
app.sessions = new Map([['s1', { workingDir: '/mnt/build/neon_getaway-mgr-hebel' }]]);
app.openFilePreview = vi.fn();
app.openLogViewerWindow = vi.fn();
app._registerExternalPreview = vi.fn(async () => ({ attachmentId: 'att-fixture' }));
app.showToast = vi.fn();
app._isExternalPreviewPath = (p: string) => p.startsWith('~/');
return app;
}
function linksOn(lines: string[], row: number) {
function linksOn(lines: string[], row: number, cols = 80, wrappedRows = new Set<number>()) {
const app = loadApp();
app.terminal = { cols: 80, registerLinkProvider: vi.fn(), buffer: { active: buffer(lines) } };
app.terminal = { cols, registerLinkProvider: vi.fn(), buffer: { active: buffer(lines, cols, wrappedRows) } };
const provider = app.registerFilePathLinkProvider() as Provider;
let out: Link[] = [];
provider.provideLinks(row, (links) => {
@@ -253,6 +259,169 @@ function linksOn(lines: string[], row: number) {
}
describe('terminal link provider', () => {
it.each(['svg', 'markdown', 'avif', 'bmp', 'ico', 'zip'])(
'keeps the workspace opener for a hard-wrapped .%s rejected by the attachment registry',
async (extension) => {
for (const row of [1, 2]) {
const path = `/home/me/proj/out/system_architecture.${extension}`;
const { app, links } = linksOn(
['Saved the diagram (/home/me/proj/out/system_archi', ` tecture.${extension}).`],
row,
120
);
app.sessions = new Map([['s1', { workingDir: '/home/me/proj' }]]);
app._registerExternalPreview.mockResolvedValue({ status: 400, error: 'Unsupported attachment type' });
await links[0].activate({}, links[0].text);
const opener = extension === 'zip' ? app.openLogViewerWindow : app.openFilePreview;
expect(opener).toHaveBeenCalledWith(path, 's1');
expect(app._registerExternalPreview.mock.calls).toEqual([[path, 's1']]);
expect(app.showToast).not.toHaveBeenCalled();
}
}
);
it.each([
[
['Saved the screenshot (/home/me/proj/out/ghost1b_game', ' play.png).'],
'/home/me/proj/out/ghost1b_gameplay.png',
],
[
[' ⎿ Wrote (/mnt/build/neon/builds/captures/ghost1b_gamepl', ' ay.png) ok'],
'/mnt/build/neon/builds/captures/ghost1b_gameplay.png',
],
[
['Saved the screenshot (/home/me/proj/out/ghost1b_', ' very_long_game', ' play.png).'],
'/home/me/proj/out/ghost1b_very_long_gameplay.png',
],
])('preserves a parenthesised filename without spaces across hard wraps: %s', async (rows, path) => {
for (let row = 1; row <= rows.length; row++) {
const { app, links } = linksOn(rows, row, 120);
expect(links.map((link) => link.text)).toEqual([path]);
await links[0].activate({}, links[0].text);
expect(app.openFilePreview).toHaveBeenCalledWith(path, 's1');
expect(app._registerExternalPreview.mock.calls).toEqual([[path, 's1']]);
}
});
it('uses the spaced filename only when the unspaced hard-wrap target is missing', async () => {
const path = '/mnt/user/Scratch/Kobo/Set-KoboCovers/Kobo eReader.conf';
const rows = [
'Found it: I prepared a corrected configuration (/mnt/user/Scratch/Kobo/Set-KoboCovers/Kobo',
' eReader.conf).',
];
for (const row of [1, 2]) {
const { app, links } = linksOn(rows, row, 120);
const unspaced = path.replace('Kobo eReader', 'KoboeReader');
expect(links.map((link) => link.text)).toEqual([unspaced]);
expect(links[0].range.end).toEqual({ x: ' eReader.conf'.length + 1, y: 2 });
app._registerExternalPreview.mockImplementation(async (candidate: string) =>
candidate === path ? { attachmentId: 'att-fixture' } : { status: 404, error: 'File not found' }
);
await links[0].activate({}, links[0].text);
expect(app.openLogViewerWindow).toHaveBeenCalledWith(path, 's1');
expect(app._registerExternalPreview.mock.calls).toEqual([
[unspaced, 's1'],
[path, 's1'],
]);
}
});
it.each([403, 500, 503])('does not try another path after a guarded lookup returns HTTP %s', async (status) => {
const { app, links } = linksOn(['Saved (/mnt/data/Kobo', ' eReader.conf).'], 1, 120);
app._registerExternalPreview.mockResolvedValue({ status, error: 'Lookup refused' });
await links[0].activate({}, links[0].text);
expect(app._registerExternalPreview).toHaveBeenCalledTimes(1);
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1');
expect(app.showToast).not.toHaveBeenCalled();
});
it('keeps the original opener when the lookup cannot reach the server', async () => {
const { app, links } = linksOn(['Saved (/mnt/data/Kobo', ' eReader.conf).'], 1, 120);
app._registerExternalPreview.mockResolvedValue({ error: 'Connection lost' });
await links[0].activate({}, links[0].text);
expect(app._registerExternalPreview).toHaveBeenCalledTimes(1);
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1');
});
it('never looks up a spaced name for a hard-wrapped Bash( row, which holds a command', async () => {
const { app, links } = linksOn(['Bash(/mnt/data/Kobo', ' eReader.conf)'], 1, 120);
await links[0].activate({}, links[0].text);
expect(app._registerExternalPreview).not.toHaveBeenCalled();
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1');
});
it('uses the original opener when both hard-wrap filenames are missing', async () => {
for (const row of [1, 2]) {
const { app, links } = linksOn(['Saved (/mnt/data/Kobo', ' eReader.conf).'], row, 120);
app._registerExternalPreview.mockResolvedValue({ status: 404, error: 'Attachment file not found' });
await links[0].activate({}, links[0].text);
expect(app._registerExternalPreview.mock.calls).toEqual([
['/mnt/data/KoboeReader.conf', 's1'],
['/mnt/data/Kobo eReader.conf', 's1'],
]);
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1');
expect(app.showToast).not.toHaveBeenCalled();
}
});
it.each(['Path: ', '- ', '- Path: ', ' Path: '])(
'resolves the filename without its parenthesised prefix %j',
async (prefix) => {
for (const row of [1, 2]) {
const { app, links } = linksOn([`Saved the notes (${prefix}/home/me/proj/out/My`, ' Notes.md).'], row, 120);
app._registerExternalPreview
.mockResolvedValueOnce({ status: 404, error: 'Attachment file not found' })
.mockResolvedValueOnce({ attachmentId: 'att-spaced' });
expect(links.map((link) => link.text)).toEqual(['/home/me/proj/out/MyNotes.md']);
await links[0].activate({}, links[0].text);
expect(app._registerExternalPreview.mock.calls).toEqual([
['/home/me/proj/out/MyNotes.md', 's1'],
['/home/me/proj/out/My Notes.md', 's1'],
]);
expect(app.openFilePreview).toHaveBeenCalledWith('/home/me/proj/out/My Notes.md', 's1');
}
}
);
it.each([400, 403, 500, 503])('does not open an unregistered spaced alternative after HTTP %s', async (status) => {
const { app, links } = linksOn(['Saved (/mnt/data/Kobo', ' eReader.conf).'], 1, 120);
app._registerExternalPreview
.mockResolvedValueOnce({ status: 404, error: 'File not found' })
.mockResolvedValueOnce({ status, error: 'Alternative unavailable' });
await links[0].activate({}, links[0].text);
expect(app._registerExternalPreview.mock.calls).toEqual([
['/mnt/data/KoboeReader.conf', 's1'],
['/mnt/data/Kobo eReader.conf', 's1'],
]);
expect(app.openFilePreview).not.toHaveBeenCalled();
expect(app.openLogViewerWindow).not.toHaveBeenCalled();
expect(app.showToast).toHaveBeenCalledWith('Alternative unavailable', 'error');
});
it('keeps the session selected at click time while a path lookup is pending', async () => {
const { app, links } = linksOn(['Saved (/mnt/data/Kobo', ' eReader.conf).'], 1, 120);
const pending = links[0].activate({}, links[0].text);
app.activeSessionId = 'another-session';
await pending;
expect(app._registerExternalPreview).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1');
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1');
});
it('opens the complete parenthesised configuration path from either wrapped row', () => {
const path = '/mnt/user/Scratch/Kobo/Set-KoboCovers/Kobo eReader.conf';
const text = `Found it: I prepared a corrected configuration (${path}).`;
const split = text.indexOf(' eReader') + 1;
const rows = [text.slice(0, split), text.slice(split)];
for (const row of [1, 2]) {
const { app, links } = linksOn(rows, row, split, new Set([1]));
expect(links.map((link) => link.text)).toEqual([path]);
expect(links[0].range.start).toEqual({ x: text.indexOf(path) + 1, y: 1 });
expect(links[0].range.end).toEqual({ x: 'eReader.conf'.length + 1, y: 2 });
links[0].activate({}, links[0].text);
expect(app.openLogViewerWindow).toHaveBeenCalledWith(path, 's1');
}
});
it('links a broken path once, across both rows, and opens the whole path', () => {
const { app, links } = linksOn(BROKEN, 2);
expect(links.map((l) => l.text)).toEqual([BROKEN_PATH]);
+53
View File
@@ -212,6 +212,59 @@ function loadApp(fetchImpl: (url: string, init?: { body?: string }) => unknown)
return { app, elements, keyHandlers, fetch: context.fetch as ReturnType<typeof vi.fn> };
}
describe('guarded attachment registration results', () => {
it.each(['svg', 'markdown', 'avif', 'bmp', 'ico', 'zip'])(
'names a rejected workspace .%s path without describing it as external',
async (extension) => {
const { app } = loadApp(async () => ({
ok: false,
status: 400,
json: async () => ({ error: 'Unsupported attachment type' }),
}));
const path = `/home/joe/wiki/out/system architecture.${extension}`;
const result = await Object.getPrototypeOf(app)._registerExternalPreview.call(app, path, 's1');
expect(result.status).toBe(400);
expect(result.error).toContain(path);
expect(result.error).not.toContain('outside the session workspace');
}
);
it('retains the external-file explanation for a rejected external type', async () => {
const { app } = loadApp(async () => ({
ok: false,
status: 400,
json: async () => ({ error: 'Unsupported attachment type' }),
}));
const result = await Object.getPrototypeOf(app)._registerExternalPreview.call(
app,
'/mnt/data/system architecture.svg',
's1'
);
expect(result.error).toBe(
'Cannot preview .svg from outside the session workspace (images, video, audio, PDF, Office documents and text files only).'
);
});
it.each([404, 403, 500, 503])('preserves HTTP %s for the activation decision', async (status) => {
const { app, fetch } = loadApp(async () => ({
ok: false,
status,
json: async () => ({ error: 'Cannot read file' }),
}));
const result = await Object.getPrototypeOf(app)._registerExternalPreview.call(app, '/mnt/data/a.conf', 's1');
expect(result).toEqual({ error: 'Cannot read file', status });
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ path: '/mnt/data/a.conf', notify: false });
});
it('does not turn a connection failure into a missing file', async () => {
const { app } = loadApp(async () => {
throw new Error('Connection lost');
});
const result = await Object.getPrototypeOf(app)._registerExternalPreview.call(app, '/mnt/data/a.conf', 's1');
expect(result).toEqual({ error: 'Connection lost' });
});
});
const LISTING = {
folderId: 'fld_1',
path: '/mnt/raid1/styleframes',
+1
View File
@@ -97,6 +97,7 @@ describe('ImageWatcher', () => {
watcher.watchSession('session-1', '/home/user/project');
const [, opts] = vi.mocked(watch).mock.calls.at(-1) as unknown as [string, { ignored: (p: string) => boolean }];
expect(opts.ignored('/home/user/project/.codeman-uploads/paste-1-ab.pdf')).toBe(true);
expect(opts.ignored('/home/user/project/.codeman-discord/1234-report.pdf')).toBe(true);
expect(opts.ignored('/home/user/project/.claude-images/paste-1-ab.png')).toBe(true);
expect(opts.ignored('/home/user/project/docs/report.pdf')).toBe(false);
});
+9 -1
View File
@@ -38,7 +38,15 @@ const MOBILE_VISIBLE_ALLOWLIST = new Set<string>([]);
// that removes a hide rule fails loudly (not silently). The attachments button is
// NOT here: it's opt-in (default-hidden everywhere via its own --hidden marker), so
// it's excluded from the default-visible enumeration rather than mobile-hidden.
const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log', 'btn-session-manager', 'btn-file-viewer'];
// The edit history button is opt-in the same way, but it IS here: its panel is
// desktop-sized, so it stays off phones even when the setting is on.
const KNOWN_PHONE_HIDDEN = [
'btn-settings',
'btn-lifecycle-log',
'btn-session-manager',
'btn-file-viewer',
'btn-edit-history',
];
function attrOf(openTag: string, name: string): string {
const m = openTag.match(new RegExp(`${name}="([^"]*)"`));
+1
View File
@@ -64,6 +64,7 @@ export function createMockRouteContext(options?: {
forgetCaseDefaultGroup: vi.fn(async () => {}),
},
cleanupSession: vi.fn(async () => {}),
forgetEditHistory: vi.fn(),
setupSessionListeners: vi.fn(async () => {}),
persistSessionState: vi.fn(),
persistSessionStateNow: vi.fn(),
+10
View File
@@ -98,6 +98,13 @@ export class MockSession extends EventEmitter {
claudeSessionIdIsFirstHand: boolean = false;
/** Mirrors Session.claudeSessionChain — oldest first, current last. */
claudeSessionChain: string[] = [];
/** The per-session CLAUDE_CONFIG_DIR override that Session.claudeConfigDir() reads. */
claudeConfigDirOverride: string | undefined = undefined;
/** Mirrors Session.claudeConfigDir: the override, trimmed; undefined when unset. */
claudeConfigDir(): string | undefined {
return this.claudeConfigDirOverride?.trim() || undefined;
}
/** Mirrors Session.adoptClaudeSessionId, including the first-hand chain rule. */
adoptClaudeSessionId(newId: string, options: { firstHand?: boolean } = {}): void {
@@ -454,6 +461,9 @@ export class MockSession extends EventEmitter {
}
);
restartCli = vi.fn(async () => true);
/** Mirrors Session.envOverrideValue; no override map here, so the custom-model
* real-home lookup defaults to "session says nothing" (overridable per test). */
envOverrideValue = vi.fn((_key: string): string | undefined => undefined);
getCustomModelForPersist = vi.fn(() => this._mockCustomModel);
/** Mirrors Session.noteReportedModel: the statusline route records the CLI's model. */
reportedModel: { model: string; source: 'statusline' | 'screen' } | null = null;
+617
View File
@@ -0,0 +1,617 @@
/**
* @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 { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import vm from 'node:vm';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Session, rebasePath } from '../src/session.js';
import { WebServer } from '../src/web/server.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');
});
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;
};
}
).__proto;
const app = { sessions: new Map([['s1', session]]) };
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;
};
}
).__proto;
const app = { sessions: new Map([['s1', session]]) };
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');
});
});
+179
View File
@@ -0,0 +1,179 @@
/**
* @fileoverview PathPicker "New folder" (opt-in via `allowNewFolder`). Same jsdom harness as
* path-picker-hidden.test.ts (see its header for why the environment is built by hand).
*
* What can silently go wrong: the button showing for a picker that never asked for it (the
* container-workdir picker cannot create on the host), the folder made in the wrong place, the new
* folder not selected afterwards, and Escape closing the whole picker instead of just the form.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { JSDOM } from 'jsdom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const accessoryJs = readFileSync(resolve(PUBLIC, 'keyboard-accessory.js'), 'utf8');
const sessionUiJs = readFileSync(resolve(PUBLIC, 'session-ui.js'), 'utf8');
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://localhost/' });
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
const doc = jsdomWindow.document;
type Call = { url: string; init?: { method?: string; body?: string } };
function loadPathPicker(fetchImpl: (url: string, init?: Call['init']) => Promise<unknown>): any {
const factory = new Function(
'window',
'document',
'localStorage',
'fetch',
'MobileDetection',
`${accessoryJs}\nreturn PathPicker;`
);
return factory(jsdomWindow, doc, jsdomWindow.localStorage, fetchImpl, { isTouchDevice: () => false });
}
const listing = (path: string, names: string[]) => ({
json: async () => ({
success: true,
data: {
path,
parent: null,
root: '/home/dev',
roots: [{ label: 'Home', path: '/home/dev' }],
entries: names.map((name) => ({ name, type: 'directory', path: `${path}/${name}` })),
truncated: false,
},
}),
});
describe('PathPicker New folder', () => {
let PathPicker: any;
let calls: Call[];
let names: string[];
let mkdirReply: () => unknown;
beforeEach(() => {
jsdomWindow.localStorage.clear();
doc.body.replaceChildren();
calls = [];
names = ['src'];
mkdirReply = () => {
names.push('fresh');
return { json: async () => ({ success: true, data: { path: '/home/dev/project/fresh' } }) };
};
PathPicker = loadPathPicker(async (url, init) => {
calls.push({ url, init });
return url.startsWith('/api/filesystem/mkdir') ? mkdirReply() : listing('/home/dev/project', names);
});
});
afterEach(() => {
PathPicker?.close?.(false);
doc.body.replaceChildren();
});
const open = async (options: Record<string, unknown> = {}) => {
PathPicker.open({ onSelect: () => {}, directoriesOnly: true, ...options });
await vi.waitFor(() => expect(doc.querySelectorAll('.path-picker-item').length).toBeGreaterThan(0));
};
const button = () => doc.querySelector('.path-picker-newfolder') as HTMLButtonElement;
const form = () => doc.querySelector('.path-picker-newfolder-form') as HTMLFormElement;
const nameInput = () => doc.querySelector('.path-picker-newfolder-name') as HTMLInputElement;
const submit = (name: string) => {
button().click();
nameInput().value = name;
form().dispatchEvent(new jsdomWindow.Event('submit', { cancelable: true, bubbles: true }));
};
it('is hidden unless the caller opts in', async () => {
await open();
expect(button().hidden).toBe(true);
expect(form().hidden).toBe(true);
});
it('shows the button, and opens the name form on click, when allowed', async () => {
await open({ allowNewFolder: true });
expect(button().hidden).toBe(false);
expect(form().hidden).toBe(true);
button().click();
expect(form().hidden).toBe(false);
});
it('creates inside the folder being viewed, reloads it and selects the new folder', async () => {
await open({ allowNewFolder: true, sessionId: 'abc123' });
submit('fresh');
await vi.waitFor(() =>
expect(doc.querySelector('.path-picker-selection-value')?.textContent).toBe('/home/dev/project/fresh')
);
const post = calls.find((c) => c.url === '/api/filesystem/mkdir');
expect(post?.init?.method).toBe('POST');
expect(JSON.parse(post?.init?.body ?? '{}')).toEqual({
path: '/home/dev/project',
name: 'fresh',
sessionId: 'abc123',
});
expect(Array.from(doc.querySelectorAll('.path-picker-item-name')).map((e) => e.textContent)).toContain('fresh');
expect(form().hidden).toBe(true);
expect((doc.querySelector('.path-picker-confirm') as HTMLButtonElement).disabled).toBe(false);
});
it('shows the server reason and keeps the form open when the folder cannot be made', async () => {
mkdirReply = () => ({ json: async () => ({ success: false, error: '"fresh" already exists in this folder' }) });
await open({ allowNewFolder: true });
submit('fresh');
await vi.waitFor(() =>
expect(doc.querySelector('.path-picker-status')?.textContent).toBe('"fresh" already exists in this folder')
);
expect(doc.querySelector('.path-picker-status')?.classList.contains('error')).toBe(true);
expect(form().hidden).toBe(false);
});
it('forwards showHidden while the picker is showing hidden entries', async () => {
jsdomWindow.localStorage.setItem('codeman:pathPickerShowHidden', '1');
await open({ allowNewFolder: true });
submit('fresh');
await vi.waitFor(() => expect(calls.some((c) => c.url === '/api/filesystem/mkdir')).toBe(true));
const post = calls.find((c) => c.url === '/api/filesystem/mkdir');
expect(JSON.parse(post?.init?.body ?? '{}').showHidden).toBe('true');
});
it('disables Create while the request runs, so a double tap sends one', async () => {
let finish: (v: unknown) => void = () => {};
mkdirReply = () =>
new Promise((resolve) => {
finish = resolve;
});
await open({ allowNewFolder: true });
const create = doc.querySelector('.path-picker-newfolder-create') as HTMLButtonElement;
submit('fresh');
await vi.waitFor(() => expect(create.disabled).toBe(true));
finish({ json: async () => ({ success: true, data: { path: '/home/dev/project/fresh' } }) });
await vi.waitFor(() => expect(create.disabled).toBe(false));
});
it('sends nothing for a blank name', async () => {
await open({ allowNewFolder: true });
submit(' ');
await Promise.resolve();
expect(calls.some((c) => c.url === '/api/filesystem/mkdir')).toBe(false);
});
it('Escape in the name field closes the form, not the picker', async () => {
await open({ allowNewFolder: true });
button().click();
nameInput().dispatchEvent(new jsdomWindow.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(form().hidden).toBe(true);
expect(doc.querySelector('.path-picker-overlay')).not.toBeNull();
});
it('is on for Link Existing and off for the other session-ui pickers', () => {
const block = (name: string) => {
const start = sessionUiJs.indexOf(`${name}() {`);
return sessionUiJs.slice(start, sessionUiJs.indexOf('PathPicker.open', start) + 400);
};
expect(block('openLinkCasePathPicker')).toContain('allowNewFolder: true');
for (const other of ['openDockerWorkspacePathPicker', 'openDockerWorkdirPicker', 'openNewCasePathPicker']) {
expect(block(other), other).not.toContain('allowNewFolder');
}
});
});
+161
View File
@@ -0,0 +1,161 @@
/**
* @fileoverview Real-browser coverage for the press feedback on the phone toolbar
* and keyboard accessory keys (mobile.css, the `:active` transform + filter rules).
*
* The feedback is transform + filter because a background `:active` on the
* toolbar buttons lost to the skin block on every skin but og. What this pins is
* the cascade itself, on every skin: a later rule that sets transform or filter
* on these buttons at a higher specificity would silently take the press away
* again, and only a browser computing the cascade can see that.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
const mobileStyles = readFileSync(resolve(publicDir, 'mobile.css'), 'utf8');
const accessorySource = readFileSync(resolve(publicDir, 'keyboard-accessory.js'), 'utf8');
const DARK_SKINS = ['og', 'daylight-blue', 'daylight-green'] as const;
const LIGHT_SKINS = ['paper-gray', 'solarized-light', 'catppuccin-latte', 'rose-pine-dawn'] as const;
/** Every phone bar control the rules cover, pressed where a finger would land. */
const TARGETS = [
['Run', '.toolbar .btn-toolbar.btn-run'],
['Run gear', '.toolbar .btn-toolbar.btn-run-gear'],
['Stop', '.toolbar .btn-toolbar.btn-stop'],
['Enter', '.toolbar .btn-toolbar.btn-enter'],
['Case', '.toolbar .btn-toolbar.btn-case-mobile'],
['Voice', '.btn-voice-mobile'],
// The pick is a transparent file input laid over this button, so the press
// lands on the input and only the wrap is :active.
['Attach', '.btn-attach-mobile'],
['Settings', '.btn-settings-mobile'],
['Accessory Tab', '.accessory-btn[data-action="tab"]'],
['Accessory arrow', '.accessory-btn-arrow[data-action="scroll-up"]'],
['Accessory Compose', '.accessory-btn-compose'],
['Accessory dismiss', '.accessory-btn-dismiss'],
] as const;
const PRESSED_SCALE = 'matrix(0.94, 0, 0, 0.94, 0, 0)';
/** The press transition, read at rest: the 250ms hold below would also pass a slower one. */
const PRESS_TRANSITION = {
property: 'transform, filter, background-color, border-color',
duration: '0.08s, 0.08s, 0.08s, 0.08s',
};
function toolbarMarkup(): string {
const start = html.indexOf('<footer class="toolbar">');
const end = html.indexOf('</footer>', start);
if (start < 0 || end < 0) throw new Error('Toolbar markup not found');
return html.slice(start, end + '</footer>'.length);
}
function accessoryMarkup(): string {
const match = accessorySource.match(/_simpleButtons: `([\s\S]*?)`,/);
if (!match) throw new Error('Accessory bar template not found');
return `<div class="keyboard-accessory-bar visible">${match[1]}</div>`;
}
describe('phone toolbar and accessory press feedback in Chromium', () => {
let browser: Browser;
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
});
afterAll(async () => browser.close());
async function openBars(skin: string, reducedMotion: 'reduce' | 'no-preference'): Promise<[BrowserContext, Page]> {
// hasTouch makes Chromium match (hover: none) and (pointer: coarse), the gate the rules sit behind.
const context = await browser.newContext({
viewport: { width: 440, height: 956 },
isMobile: true,
hasTouch: true,
deviceScaleFactor: 1,
reducedMotion,
});
const page = await context.newPage();
await page.setContent(`<!doctype html><html data-skin="${skin}"><head>
<meta name="viewport" content="width=device-width, initial-scale=1"><style>${styles}</style>
<style>@media (max-width: 1023px) { ${mobileStyles} }</style></head>
<body>${toolbarMarkup()}${accessoryMarkup()}</body></html>`);
// Without the viewport meta a mobile context lays out at 980px and never reaches the phone layout.
expect(
await page.evaluate(() => ({
touch: matchMedia('(hover: none) and (pointer: coarse)').matches,
phone: matchMedia('(max-width: 599px)').matches,
}))
).toEqual({ touch: true, phone: true });
return [context, page];
}
/** Hold a press on the control and read its transform and filter once the transition has settled. */
async function pressed(page: Page, selector: string) {
const target = page.locator(selector).first();
const box = await target.boundingBox();
if (!box) throw new Error(`${selector} is not rendered in the phone layout`);
const read = () =>
target.evaluate((el) => ({ transform: getComputedStyle(el).transform, filter: getComputedStyle(el).filter }));
const rest = await read();
const transition = await target.evaluate((el) => ({
property: getComputedStyle(el).transitionProperty,
duration: getComputedStyle(el).transitionDuration,
}));
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.waitForTimeout(250);
const held = await read();
// Release off the control so its click handler never runs.
await page.mouse.move(0, 0);
await page.mouse.up();
await page.waitForTimeout(250);
return { rest, held, transition };
}
for (const [skins, brightness] of [
[DARK_SKINS, 'brightness(1.25)'],
[LIGHT_SKINS, 'brightness(0.9)'],
] as const) {
for (const skin of skins) {
it(`scales every control and applies ${brightness} on the ${skin} skin`, async () => {
const [context, page] = await openBars(skin, 'no-preference');
try {
for (const [name, selector] of TARGETS) {
expect({ name, ...(await pressed(page, selector)) }).toEqual({
name,
rest: { transform: 'none', filter: 'none' },
held: { transform: PRESSED_SCALE, filter: brightness },
transition: PRESS_TRANSITION,
});
}
} finally {
await context.close();
}
});
}
}
it.each([
['daylight-blue', 'brightness(1.25)'],
['paper-gray', 'brightness(0.9)'],
])('drops the scale but keeps the tint under prefers-reduced-motion on %s', async (skin, brightness) => {
const [context, page] = await openBars(skin, 'reduce');
try {
for (const [name, selector] of TARGETS) {
expect({ name, ...(await pressed(page, selector)) }).toEqual({
name,
rest: { transform: 'none', filter: 'none' },
held: { transform: 'none', filter: brightness },
transition: PRESS_TRANSITION,
});
}
} finally {
await context.close();
}
});
});
+5 -2
View File
@@ -70,8 +70,11 @@ describe('saveAppSettings wiring', () => {
);
// Stripped from serverSettings like the other per-device display keys.
expect(save).toMatch(/showPlanUsageLimits: _pul,/);
// Decided once against the device's prior settings, before they are overwritten.
expect(save).toMatch(/const _chipFlip = this\.planUsageCollectionFlip\(_prev, settings\.showPlanUsageLimits\);/);
// Decided once against the device's prior settings, before they are overwritten (or, after a
// save the server did not store, against the settings that save started from).
expect(save).toMatch(
/const _chipFlip = this\.planUsageCollectionFlip\(_prevServer, settings\.showPlanUsageLimits\);/
);
// And only a real flip reaches the PUT body.
expect(save).toMatch(/\.\.\.\(_chipFlip !== undefined \? \{ showPlanUsageLimits: _chipFlip \} : \{\}\),/);
});
+9
View File
@@ -78,6 +78,15 @@ function linkify(html: string): HTMLElement {
const paths = (root: HTMLElement) => Array.from(root.querySelectorAll('a.rv-path'));
describe('response viewer file-path linkifier', () => {
it('links a parenthesised configuration filename with spaces without changing the prose', () => {
const path = '/mnt/user/Scratch/Kobo/Set-KoboCovers/Kobo eReader.conf';
const text = `Prepared a corrected configuration (${path}).`;
const root = linkify(`<p>${text}</p>`);
expect(paths(root).map((a) => a.dataset.path)).toEqual([path]);
expect(paths(root)[0].dataset.folder).toBeUndefined();
expect(root.textContent).toBe(text);
});
it.each([
['- 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx', ['Design/Build-SIPOC.pptx']],
['- notes/a b/c.md', ['b/c.md']],
@@ -0,0 +1,389 @@
/**
* @fileoverview POST /api/cases/clone with a `path`: clone INTO that folder (an empty or not yet existing one,
* with no subfolder added) and register it as a linked case. Real filesystem under test/setup.ts's temp
* HOME and a REAL `git` cloning a local bare repo (the path policy itself is in test/case-path.test.ts).
* Port: N/A (app.inject()).
*
* Like case-custom-path-routes.test.ts this suite rewrites the linked-cases registry, so it refuses to
* start outside the sandboxed HOME (a raw `npx vitest` would reach the real ~/.codeman).
*/
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { execFileSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { 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 {
classifyGitFailure,
cloneRepository,
getActiveGitOperationCount,
getQueuedGitOperationCount,
isGitAvailable,
} from '../../src/git-clone.js';
import { isSandboxedHome, safeRmHomeTree } from '../mocks/index.js';
const LINKED = () => dataPath('linked-cases.json');
const linked = (): Record<string, string> => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {});
const clone = (app: Awaited<ReturnType<typeof createRouteTestHarness>>['app'], payload: Record<string, unknown>) =>
app.inject({ method: 'POST', url: '/api/cases/clone', payload });
describe.skipIf(!isGitAvailable())('POST /api/cases/clone with a custom folder', () => {
let root: string;
let origin: string;
let workDir = '';
const git = (args: string[], cwd: string) =>
execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'codeman-clone-custom-'));
origin = join(root, 'origin.git');
mkdirSync(origin);
git(['init', '--bare', '--quiet'], origin);
const work = join(root, 'work');
mkdirSync(work);
git(['init', '--quiet'], work);
git(['config', 'user.email', 'test@example.com'], work);
git(['config', 'user.name', 'Codeman Test'], work);
writeFileSync(join(work, 'README.md'), '# fixture\n');
git(['add', '.'], work);
git(['commit', '--quiet', '-m', 'initial'], work);
git(['branch', '-M', 'main'], work);
git(['remote', 'add', 'origin', origin], work);
git(['push', '--quiet', 'origin', 'main'], work);
git(['symbolic-ref', 'HEAD', 'refs/heads/main'], origin);
});
afterAll(() => rmSync(root, { recursive: true, force: true }));
beforeEach(() => {
if (!isSandboxedHome()) {
throw new Error('case-clone-custom-folder.test.ts rewrites the linked-cases registry: run it via npm test');
}
safeRmHomeTree(LINKED());
// Resolved, because the server answers with the symlink-resolved folder.
workDir = realpathSync(mkdtempSync(join(homedir(), 'clone-custom-')));
});
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
if (workDir) safeRmHomeTree(workDir);
workDir = '';
if (isSandboxedHome()) safeRmHomeTree(LINKED());
});
it('clones into the chosen folder, scaffolds it and registers it as a linked case', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
const target = join(workDir, 'my-clone');
const res = await clone(app, { name: 'my-clone', repository: origin, path: target });
expect(res.statusCode).toBe(200);
expect(res.json().data.case).toEqual({ name: 'my-clone', path: target });
expect(existsSync(join(target, 'README.md'))).toBe(true);
expect(existsSync(join(target, '.git'))).toBe(true);
expect(existsSync(join(target, '.claude', 'settings.local.json'))).toBe(true);
expect(linked()).toEqual({ 'my-clone': target });
expect(ctx.broadcast).toHaveBeenCalledWith('case:created', { name: 'my-clone', path: target });
// Nothing lands under the cases directory.
expect(existsSync(join(homedir(), 'codeman-cases', 'my-clone'))).toBe(false);
});
it('expands ~ in the folder', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const folder = workDir.slice(homedir().length + 1);
const res = await clone(app, { name: 'tilde', repository: origin, path: `~/${folder}/tilde` });
expect(res.statusCode).toBe(200);
expect(existsSync(join(workDir, 'tilde', 'README.md'))).toBe(true);
});
it('refuses a parent folder that does not exist, and creates nothing', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await clone(app, { name: 'orphan', repository: origin, path: join(workDir, 'nope', 'orphan') });
expect(res.statusCode).toBe(404);
expect(existsSync(join(workDir, 'nope'))).toBe(false);
expect(linked()).toEqual({});
});
it('refuses a target folder that already has files, and leaves them alone', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(workDir, 'busy');
mkdirSync(target);
writeFileSync(join(target, 'mine.txt'), 'keep me');
const res = await clone(app, { name: 'busy', repository: origin, path: target });
expect(res.statusCode).toBe(409);
expect(res.json().error).toMatch(/already has files/);
expect(readFileSync(join(target, 'mine.txt'), 'utf8')).toBe('keep me');
expect(linked()).toEqual({});
});
it('clones straight INTO the chosen empty folder, with no subfolder named after the case', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(workDir, 'picked');
mkdirSync(target);
const res = await clone(app, { name: 'a-different-name', repository: origin, path: target });
expect(res.statusCode).toBe(200);
expect(res.json().data.case).toEqual({ name: 'a-different-name', path: target });
expect(existsSync(join(target, 'README.md'))).toBe(true);
expect(existsSync(join(target, '.git'))).toBe(true);
expect(existsSync(join(target, 'a-different-name'))).toBe(false);
expect(linked()).toEqual({ 'a-different-name': target });
});
it('leaves the empty folder in place, and empty, when the clone fails', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(workDir, 'stays');
mkdirSync(target);
const res = await clone(app, { name: 'stays', repository: join(root, 'no-such-repo.git'), path: target });
expect(res.json().success).toBe(false);
expect(existsSync(target)).toBe(true);
expect(readdirSync(target)).toEqual([]);
expect(linked()).toEqual({});
});
it('two clones into one empty folder at once: exactly one wins and its files survive', async () => {
const target = join(workDir, 'raced');
mkdirSync(target);
const results = await Promise.all([
cloneRepository({ repository: origin, destination: target, intoEmptyDir: true }),
cloneRepository({ repository: origin, destination: target, intoEmptyDir: true }),
]);
expect(results.filter((r) => r.ok)).toHaveLength(1);
const loser = results.find((r) => !r.ok);
expect(loser && !loser.ok && loser.failure.code).toBe('DESTINATION_EXISTS');
expect(existsSync(join(target, 'README.md'))).toBe(true);
expect(existsSync(join(target, '.git'))).toBe(true);
});
it('a name linked while the clone ran is a 409 that keeps the folder and names it', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const [a, b] = await Promise.all([
clone(app, { name: 'twin', repository: origin, path: join(workDir, 'twin-a') }),
clone(app, { name: 'twin', repository: origin, path: join(workDir, 'twin-b') }),
]);
const loserDir = a.json().success ? join(workDir, 'twin-b') : join(workDir, 'twin-a');
const loser = a.json().success ? b : a;
expect([a.json().success, b.json().success].filter(Boolean)).toHaveLength(1);
expect(loser.statusCode).toBe(409);
expect(loser.json().errorCode).toBe('ALREADY_EXISTS');
expect(loser.json().error).toContain(loserDir);
expect(existsSync(join(loserDir, 'README.md'))).toBe(true);
expect(Object.keys(linked())).toEqual(['twin']);
});
it('a direct clone whose git-slot wait times out leaves what was written into the folder meanwhile', async () => {
const target = join(workDir, 'queued');
mkdirSync(target);
// Two slow ssh clones hold both git slots (no network needed: the ssh command just sleeps).
const prior = process.env.GIT_SSH_COMMAND;
process.env.GIT_SSH_COMMAND = 'sleep 4; true';
try {
const holders = [0, 1].map((i) =>
cloneRepository({
repository: 'ssh://localhost/x.git',
destination: join(workDir, `hold-${i}`),
timeoutMs: 10_000,
})
);
await vi.waitFor(() => expect(getActiveGitOperationCount()).toBe(2));
const queued = cloneRepository({
repository: origin,
destination: target,
intoEmptyDir: true,
timeoutMs: 1_000,
});
await vi.waitFor(() => expect(getQueuedGitOperationCount()).toBe(1));
// Written during the wait, so not this attempt's: a user copying files in, or another case created there.
writeFileSync(join(target, 'mine.txt'), 'keep me');
mkdirSync(join(target, 'src'));
const result = await queued;
expect(!result.ok && result.failure.code).toBe('TIMEOUT');
expect(readFileSync(join(target, 'mine.txt'), 'utf8')).toBe('keep me');
expect(existsSync(join(target, 'src'))).toBe(true);
await Promise.all(holders);
} finally {
if (prior === undefined) delete process.env.GIT_SSH_COMMAND;
else process.env.GIT_SSH_COMMAND = prior;
}
}, 30_000);
it('refuses a folder at or inside a Docker case workspace, and creates nothing', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const workspace = join(workDir, 'dockerws');
mkdirSync(workspace);
writeFileSync(
join(dataPath('docker-cases.json')),
JSON.stringify([{ name: 'box', type: 'docker', hostId: 'h', hostWorkspacePath: workspace, container: 'c' }])
);
try {
for (const path of [workspace, join(workspace, 'repo')]) {
const res = await clone(app, { name: 'inside', repository: origin, path });
expect(res.json().errorCode, path).toBe('INVALID_INPUT');
expect(res.json().error, path).toMatch(/Docker case workspace/);
}
expect(readdirSync(workspace)).toEqual([]);
expect(linked()).toEqual({});
// A sibling of the workspace is fine.
const ok = await clone(app, { name: 'beside', repository: origin, path: join(workDir, 'beside') });
expect(ok.statusCode).toBe(200);
} finally {
safeRmHomeTree(dataPath('docker-cases.json'));
}
});
it('fails closed when the Docker case list cannot be read', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
writeFileSync(dataPath('docker-cases.json'), '{not json');
try {
const res = await clone(app, { name: 'blind', repository: origin, path: join(workDir, 'blind') });
expect(res.statusCode).toBe(503);
expect(existsSync(join(workDir, 'blind'))).toBe(false);
} finally {
safeRmHomeTree(dataPath('docker-cases.json'));
}
});
it.each(['plain', 'openssl', 'rustls', 'does not exist'])(
'a refused folder named %s keeps what was written meanwhile and reports DESTINATION_EXISTS',
async (folder) => {
const target = join(workDir, folder);
mkdirSync(target);
const prior = process.env.GIT_SSH_COMMAND;
process.env.GIT_SSH_COMMAND = 'sleep 2; true';
try {
const holders = [0, 1].map((i) =>
cloneRepository({
repository: 'ssh://localhost/x.git',
destination: join(workDir, `hold-${i}`),
timeoutMs: 10_000,
})
);
await vi.waitFor(() => expect(getActiveGitOperationCount()).toBe(2));
// Queues behind the holders but has time to get a slot, so git runs and refuses the folder.
const queued = cloneRepository({
repository: origin,
destination: target,
intoEmptyDir: true,
timeoutMs: 15_000,
});
await vi.waitFor(() => expect(getQueuedGitOperationCount()).toBe(1));
writeFileSync(join(target, 'mine.txt'), 'keep me');
const result = await queued;
expect(!result.ok && result.failure.code).toBe('DESTINATION_EXISTS');
expect(readFileSync(join(target, 'mine.txt'), 'utf8')).toBe('keep me');
await Promise.all(holders);
} finally {
if (prior === undefined) delete process.env.GIT_SSH_COMMAND;
else process.env.GIT_SSH_COMMAND = prior;
}
},
30_000
);
it('classifies git stderr by what it says, not by words in a path it quotes', () => {
const refusal = (dir: string) => `fatal: destination path '${dir}' already exists and is not an empty directory.`;
for (const dir of ['/home/u/src/openssl', '/home/u/rustls', '/x/tls-test', '/x/repository not found']) {
expect(classifyGitFailure(refusal(dir), false).code, dir).toBe('DESTINATION_EXISTS');
}
expect(classifyGitFailure('fatal: unable to access: SSL certificate problem: self signed', false).code).toBe(
'HOST_UNREACHABLE'
);
expect(classifyGitFailure('fatal: TLS handshake failure', false).code).toBe('HOST_UNREACHABLE');
// `_` is a word character, so a bare \bssl\b misses OpenSSL's and LibreSSL's usual flaky-network error.
expect(
classifyGitFailure(
"fatal: unable to access 'https://github.com/o/r/': OpenSSL SSL_connect: SSL_ERROR_SYSCALL in connection to github.com:443",
false
).code
).toBe('HOST_UNREACHABLE');
expect(classifyGitFailure('fatal: could not resolve host: example.invalid', false).code).toBe('HOST_UNREACHABLE');
expect(classifyGitFailure("fatal: repository 'https://x/y.git/' not found", false).code).toBe('NOT_FOUND');
});
it('refuses a clone without a path into the cases folder when it is a Docker case workspace', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const casesDir = join(homedir(), 'codeman-cases');
mkdirSync(casesDir, { recursive: true });
writeFileSync(
dataPath('docker-cases.json'),
JSON.stringify([{ name: 'box', type: 'docker', hostId: 'h', hostWorkspacePath: casesDir, container: 'c' }])
);
try {
const res = await clone(app, { name: 'default-inside', repository: origin });
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(res.json().error).toMatch(/Docker case workspace/);
expect(existsSync(join(casesDir, 'default-inside'))).toBe(false);
} finally {
safeRmHomeTree(dataPath('docker-cases.json'));
}
});
it('points Clone Repo at itself, not Create New, for the cases folder and a folder directly in it', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const casesDir = join(homedir(), 'codeman-cases');
mkdirSync(casesDir, { recursive: true });
for (const path of [casesDir, join(casesDir, 'direct-child')]) {
const res = await clone(app, { name: 'direct-child', repository: origin, path });
expect(res.statusCode, path).toBe(400);
expect(res.json().error, path).toMatch(/clone there without a custom folder/);
expect(res.json().error, path).not.toMatch(/Create New/);
}
});
it('refuses a blocked tree such as ~/.ssh', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(homedir(), '.ssh'), { recursive: true });
const res = await clone(app, { name: 'sneaky', repository: origin, path: join(homedir(), '.ssh', 'sneaky') });
expect(res.statusCode).toBe(400);
expect(existsSync(join(homedir(), '.ssh', 'sneaky'))).toBe(false);
});
it('refuses a name already linked, before running git', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
writeFileSync(LINKED(), JSON.stringify({ taken: join(workDir, 'elsewhere') }));
const res = await clone(app, { name: 'taken', repository: origin, path: join(workDir, 'taken') });
// The harness has no preSerialization status mapping, so read the envelope's code.
expect(res.json().errorCode).toBe('ALREADY_EXISTS');
expect(existsSync(join(workDir, 'taken'))).toBe(false);
});
it('leaves no folder behind when the clone fails', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(workDir, 'ghost');
const res = await clone(app, { name: 'ghost', repository: join(root, 'no-such-repo.git'), path: target });
expect(res.json().errorCode).toBe('NOT_FOUND');
expect(existsSync(target)).toBe(false);
expect(linked()).toEqual({});
});
it('without a path the clone still goes under the cases directory and is not linked', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await clone(app, { name: 'custom-folder-default', repository: origin });
try {
expect(res.statusCode).toBe(200);
expect(res.json().data.case.path).toBe(join(homedir(), 'codeman-cases', 'custom-folder-default'));
expect(linked()).toEqual({});
} finally {
safeRmHomeTree(join(homedir(), 'codeman-cases', 'custom-folder-default'));
}
});
it('multi-user: a non-admin is refused (403) and nothing is cloned; an admin is allowed', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const user = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } });
const denied = await clone(user.app, { name: 'bobs', repository: origin, path: join(workDir, 'bobs') });
expect(denied.statusCode).toBe(403);
expect(existsSync(join(workDir, 'bobs'))).toBe(false);
expect(linked()).toEqual({});
const admin = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'root', role: 'admin' } });
const ok = await clone(admin.app, { name: 'roots', repository: origin, path: join(workDir, 'roots') });
expect(ok.statusCode).toBe(200);
});
});
+17 -1
View File
@@ -4,7 +4,7 @@
*
* ⚠️ test/setup.ts gives the whole FILE one temp HOME (and so one data dir). Port: N/A (inject()).
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
@@ -120,6 +120,22 @@ describe('/api/config-backups', () => {
}
});
it('a restored discord.json reaches the running bridge (it caches its config)', async () => {
const reloadDiscord = vi.fn(async () => undefined);
const { app } = await createRouteTestHarness((a) => registerConfigBackupRoutes(a, { reloadDiscord }));
writeFileSync(join(dirname(SETTINGS_PATH), 'discord.json'), '{"enabled":false}', { mode: 0o600 });
const id = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id;
writeFileSync(join(dirname(SETTINGS_PATH), 'discord.json'), '{"enabled":true}', { mode: 0o600 });
const res = await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` });
expect(res.json().data.restored).toContain('discord.json');
expect(reloadDiscord).toHaveBeenCalledTimes(1);
// A restore that does not carry the file leaves the bridge alone.
rmSync(join(dirname(SETTINGS_PATH), 'discord.json'), { force: true });
const id2 = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id;
await app.inject({ method: 'POST', url: `/api/config-backups/${id2}/restore` });
expect(reloadDiscord).toHaveBeenCalledTimes(1);
});
it('a restored intents.json is not reverted by the next captured prompt', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const dir = mkdtempSync(join(tmpdir(), 'cfg-backup-intent-'));
+164 -1
View File
@@ -11,7 +11,7 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { registerCustomModelRoutes } from '../../src/web/routes/custom-model-routes.js';
import { webviewFetch } from '../../src/web/webview-egress.js';
import { createRouteTestHarness } from './_route-test-utils.js';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
vi.mock('../../src/web/webview-egress.js', async () => {
const actual = await vi.importActual<typeof import('../../src/web/webview-egress.js')>(
@@ -417,3 +417,166 @@ describe('apiKey is never handed back to the browser', () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
describe('context-length discovery from the /v1/models listing', () => {
// The gateway path (a Bifrost in front of llama-swap, an OpenRouter-style server):
// no /running, no /props, just a per-model `context_length` or `context_window` in the
// model listing. Distinct ports per test keep the file's shared data dir unambiguous.
afterEach(() => {
fetchMock.mockReset();
});
type StoredHost = { id: string; modelContextLengths?: Record<string, number>; defaultContextLength?: number };
type HarnessApp = RouteTestHarness['app'];
async function endpointWith(id: string, port: number, extra: Record<string, unknown> = {}): Promise<HarnessApp> {
const { app } = await setup();
const res = await app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id, label: 'A', baseUrl: `http://localhost:${port}`, ...extra },
});
expect(res.statusCode).toBe(200);
return app;
}
async function storedHost(app: HarnessApp, id: string): Promise<StoredHost | undefined> {
const list = await app.inject({ method: 'GET', url: '/api/model-endpoints' });
return (list.json() as StoredHost[]).find((h) => h.id === id);
}
it('reads context_length (preferred) then context_window from each entry into modelContextLengths', async () => {
const app = await endpointWith('ep-ctx-listed', 8101);
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
data: [
{ id: 'both', context_length: 131072, context_window: 32768 },
{ id: 'window-only', context_window: 8192 },
{ id: 'no-figure' },
],
}),
{ status: 200 }
)
);
const discover = await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-listed/discover-models' });
expect(discover.json().success).toBe(true);
const stored = await storedHost(app, 'ep-ctx-listed');
expect(stored?.modelContextLengths).toEqual({ both: 131072, 'window-only': 8192 });
});
it('ignores non-numeric, zero, negative and absurd listed values; the schema bound itself is still accepted', async () => {
const app = await endpointWith('ep-ctx-bad', 8102);
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
data: [
{ id: 'string', context_length: '131072' },
{ id: 'zero', context_window: 0 },
{ id: 'negative', context_length: -10 },
{ id: 'over-bound', context_length: 100_000_001 },
{ id: 'fractional', context_window: 16384.5 },
{ id: 'at-bound', context_length: 100_000_000 },
],
}),
{ status: 200 }
)
);
await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-bad/discover-models' });
const stored = await storedHost(app, 'ep-ctx-bad');
expect(stored?.modelContextLengths).toEqual({ 'at-bound': 100_000_000 });
});
it('prefers the /running launch command over the listed figure for a loaded model', async () => {
const app = await endpointWith('ep-ctx-cmd', 8103);
fetchMock.mockImplementation(async (url: URL) => {
if (url.pathname === '/v1/models') {
return new Response(
JSON.stringify({ data: [{ id: 'cmd-wins', status: { value: 'loaded' }, context_length: 131072 }] }),
{ status: 200 }
);
}
if (url.pathname === '/running') {
return new Response(
JSON.stringify({ running: [{ model: 'cmd-wins', state: 'ready', cmd: 'llama-server --ctx-size 16384' }] }),
{ status: 200 }
);
}
if (url.pathname === '/props') return new Response('nope', { status: 404 });
throw new Error(`unexpected request in this test: ${url.href}`);
});
await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-cmd/discover-models' });
const stored = await storedHost(app, 'ep-ctx-cmd');
expect(stored?.modelContextLengths).toEqual({ 'cmd-wins': 16384 });
});
it('keeps a previously learned value the fresh listing says nothing about, and discovery never touches defaultContextLength', async () => {
const app = await endpointWith('ep-ctx-keep', 8104, {
models: ['re-listed', 'untouched'],
modelContextLengths: { 're-listed': 16384, untouched: 4096 },
defaultContextLength: 65536,
});
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({ data: [{ id: 're-listed', context_length: 32768 }, { id: 'untouched' }] }),
{ status: 200 }
)
);
await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-keep/discover-models' });
const stored = await storedHost(app, 'ep-ctx-keep');
expect(stored?.modelContextLengths).toEqual({ 're-listed': 32768, untouched: 4096 });
expect(stored?.defaultContextLength).toBe(65536);
});
});
describe('defaultContextLength (the user-set fallback discovery never overwrites)', () => {
afterEach(() => {
fetchMock.mockReset();
});
it('stores it on create, updates it on PUT, and a PUT without it clears it', async () => {
const { app } = await setup();
const create = await app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id: 'ep-defctx', label: 'A', baseUrl: 'http://localhost:8105', defaultContextLength: 131072 },
});
expect(create.json().data.host.defaultContextLength).toBe(131072);
const update = await app.inject({
method: 'PUT',
url: '/api/model-endpoints/ep-defctx',
payload: { label: 'A', baseUrl: 'http://localhost:8105', defaultContextLength: 32768 },
});
expect(update.json().data.host.defaultContextLength).toBe(32768);
const cleared = await app.inject({
method: 'PUT',
url: '/api/model-endpoints/ep-defctx',
payload: { label: 'A', baseUrl: 'http://localhost:8105' },
});
expect(cleared.json().data.host.defaultContextLength).toBeUndefined();
});
it('rejects zero, negative, fractional, over-the-bound and non-numeric values', async () => {
const { app } = await setup();
for (const defaultContextLength of [0, -1, 1.5, 100_000_001, '131072']) {
const res = await app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id: 'ep-defctx-bad', label: 'A', baseUrl: 'http://localhost:8106', defaultContextLength },
});
expect(res.json().success, String(defaultContextLength)).toBe(false);
expect(res.json().errorCode, String(defaultContextLength)).toBe('INVALID_INPUT');
}
});
});
@@ -0,0 +1,196 @@
/**
* @fileoverview The last message a CLI wrote, read through the response viewer's reader (the one the
* Discord bridge posts from), with a message that asks SEVERAL questions: every question must come
* back whole, in order, on its own line, and survive the split into Discord posts.
*
* Uses app.inject() with a temporary HOME and CODEX_HOME; no real ports or user transcripts.
* Port: N/A
*
* Covered here: claude (JSONL transcript), codex (rollout), the codex-shaped pane CLIs (opencode,
* gemini, antigravity, pi) and copilot (its own pane dialect). deepseek and omp read their own
* transcript formats and are covered by their reader's tests.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { splitForDiscord } from '../../src/discord-bridge.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
const QUESTIONS = [
'Should the sweep run daily or hourly?',
'Do you want archived threads deleted as well?',
'Is one channel per Codeman acceptable?',
];
const INTRO = 'I have three questions before I continue:';
const OUTRO = 'Once you answer, I will apply the change.';
/** The message as the CLI wrote it: an intro, a numbered list of questions, an outro. */
const MESSAGE = [INTRO, '', ...QUESTIONS.map((q, i) => `${i + 1}. ${q}`), '', OUTRO].join('\n');
/** Every question present, in order, each on a line of its own. */
function expectWholeQuestions(text: string): void {
let from = 0;
for (const q of QUESTIONS) {
const at = text.indexOf(q, from);
expect(at, `missing or out of order: ${q}`).toBeGreaterThanOrEqual(from);
from = at + q.length;
}
const lines = text.split('\n').map((l) => l.trim());
for (const [i, q] of QUESTIONS.entries()) expect(lines.some((l) => l.endsWith(q))).toBe(true);
expect(text).toContain(INTRO);
expect(text).toContain(OUTRO);
// What goes to Discord: the same questions after the split, joined back.
expectWholeQuestionsAfterSplit(text);
}
function expectWholeQuestionsAfterSplit(text: string): void {
const joined = splitForDiscord(text).join('\n');
for (const q of QUESTIONS) expect(joined).toContain(q);
}
async function createHarness(): Promise<{ app: FastifyInstance; ctx: MockRouteContext }> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx);
app.addHook('preSerialization', (req, _reply, payload: unknown, done) => {
if (!req.url.startsWith('/api') || payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown };
return done(null, p.success === undefined ? { success: true, data: payload } : payload);
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
describe('the last message with several questions, per CLI', () => {
let harness: { app: FastifyInstance; ctx: MockRouteContext };
let home: string;
let prevHome: string | undefined;
let prevCodexHome: string | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let session: any;
async function lastText(): Promise<string> {
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${session.id}/last-response` });
return JSON.parse(res.body).data.text as string;
}
beforeEach(async () => {
home = mkdtempSync(join(tmpdir(), 'codeman-discord-last-'));
prevHome = process.env.HOME;
prevCodexHome = process.env.CODEX_HOME;
process.env.HOME = home;
process.env.CODEX_HOME = join(home, '.codex');
harness = await createHarness();
session = harness.ctx._session;
});
afterEach(async () => {
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
if (prevCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = prevCodexHome;
rmSync(home, { recursive: true, force: true });
await harness.app.close();
});
it('claude: reads the whole message from the JSONL transcript', async () => {
const id = '40568a29-d4eb-4eb6-b671-8401428e4f39';
session.mode = 'claude';
session.claudeSessionId = id;
session.adoptClaudeSessionId = () => undefined;
const dir = join(home, '.claude', 'projects', '-workspace');
mkdirSync(dir, { recursive: true });
const row = (type: string, text: string, ts: string) => ({
type,
timestamp: ts,
message: { content: [{ type: 'text', text }] },
});
writeFileSync(
join(dir, `${id}.jsonl`),
[row('user', 'plan the cleanup', '2026-10-30T00:00:00Z'), row('assistant', MESSAGE, '2026-10-30T00:00:01Z')]
.map((e) => JSON.stringify(e))
.join('\n')
);
const text = await lastText();
expect(text).toBe(MESSAGE);
expectWholeQuestions(text);
});
it('codex: reads the whole message from the rollout', async () => {
const uuid = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const workdir = join(home, 'work');
mkdirSync(workdir, { recursive: true });
session.mode = 'codex';
session.workingDir = workdir;
session.codexConfig = { resumeSessionId: uuid };
const dir = join(home, '.codex', 'sessions', '2026', '10', '30');
mkdirSync(dir, { recursive: true });
const file = join(dir, `rollout-2026-10-30T00-00-00-${uuid}.jsonl`);
const entries = [
{
timestamp: '2026-10-30T00:00:00Z',
type: 'session_meta',
payload: { id: uuid, cwd: workdir, originator: 'codex_cli_rs' },
},
{
timestamp: '2026-10-30T00:00:01Z',
type: 'event_msg',
payload: { type: 'user_message', message: 'plan the cleanup' },
},
{
timestamp: '2026-10-30T00:00:02Z',
type: 'response_item',
payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: MESSAGE }] },
},
];
writeFileSync(file, entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
utimesSync(file, 1_750_000_000, 1_750_000_000);
const text = await lastText();
expectWholeQuestions(text);
});
for (const mode of ['opencode', 'gemini', 'antigravity', 'pi'] as const) {
it(`${mode}: reads the whole message from the pane`, async () => {
session.mode = mode;
session.terminalBuffer = [
'› plan the cleanup',
'',
'model · project · main · Ready · Context 100% left',
'',
'• Called write_file',
'',
MESSAGE,
].join('\n');
expectWholeQuestions(await lastText());
});
}
it('copilot: reads the whole message from the pane screen', async () => {
session.mode = 'copilot';
const screen = [
' ❯ plan the cleanup 10:01',
'',
` ● ${INTRO}`,
'',
...QUESTIONS.map((q, i) => ` ${i + 1}. ${q}`),
'',
` ${OUTRO}`,
'',
' ~/project Session: 1 AIC used',
'────────────────────────────────',
' ❯ ',
'────────────────────────────────',
' ← open sidebar · / commands · ? help',
].join('\n');
harness.ctx.mux = { ...(harness.ctx.mux as object), capturePaneText: () => screen } as typeof harness.ctx.mux;
expectWholeQuestions(await lastText());
});
});
+354
View File
@@ -0,0 +1,354 @@
/**
* @fileoverview /api/discord: the bot token is a bearer secret, so it is never returned and the
* settings routes are admin only in multi-user mode. Port: N/A (app.inject()).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { _resetNotifyBuckets, registerDiscordRoutes } from '../../src/web/routes/discord-routes.js';
import { DiscordBridge, discordConfigPath, readDiscordConfig, type DiscordFetch } from '../../src/discord-bridge.js';
const TOKEN = 'MTA1.secret.token-value-1234567890';
const FULL = {
enabled: true,
token: TOKEN,
guildId: '111111111111111111',
parentChannelId: '222222222222222222',
allowedUserIds: ['333333333333333333'],
};
let dir: string;
let fetchImpl: ReturnType<typeof vi.fn<DiscordFetch>>;
async function harness(authUser?: { username: string; role: 'admin' | 'user' }) {
const bridge = new DiscordBridge({
configDir: dir,
fetchImpl,
sendInput: async () => true,
socketFactory: () => ({ send() {}, close() {}, on() {} }),
});
const h = await createRouteTestHarness(
(app, ctx) => registerDiscordRoutes(app, { bridge, configDir: dir, ctx, hostTitle: () => 'codeman:test' }),
authUser ? { authUser } : undefined
);
return { ...h, bridge };
}
beforeEach(() => {
_resetNotifyBuckets();
dir = mkdtempSync(join(tmpdir(), 'discord-routes-'));
fetchImpl = vi.fn<DiscordFetch>(
async () => new Response(JSON.stringify({ id: '444444444444444444' }), { status: 200 })
);
});
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
rmSync(dir, { recursive: true, force: true });
});
describe('/api/discord', () => {
it('starts disabled with no token', async () => {
const { app } = await harness();
const data = (await app.inject({ method: 'GET', url: '/api/discord' })).json().data;
expect(data).toMatchObject({ enabled: false, hasToken: false, tokenMasked: '', gateway: 'off' });
expect(data).not.toHaveProperty('token');
});
it('saves, masks the token in every response and writes the file 0600', async () => {
const { app, bridge } = await harness();
const put = await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
expect(put.statusCode).toBe(200);
expect(put.body).not.toContain('secret');
expect(put.json().data).toMatchObject({ enabled: true, hasToken: true });
const get = await app.inject({ method: 'GET', url: '/api/discord' });
expect(get.body).not.toContain('secret');
expect(statSync(discordConfigPath(dir)).mode & 0o777).toBe(0o600);
expect((await readDiscordConfig(dir)).token).toBe(TOKEN);
bridge.stop();
});
it('keeps the token when only an option changes, and clears it on an empty string', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
await app.inject({
method: 'PUT',
url: '/api/discord',
payload: { pingMode: 'all', onSessionDelete: 'delete', staleThreads: 'archive' },
});
const cfg = await readDiscordConfig(dir);
expect(cfg).toMatchObject({ token: TOKEN, pingMode: 'all', onSessionDelete: 'delete', staleThreads: 'archive' });
const cleared = await app.inject({ method: 'PUT', url: '/api/discord', payload: { enabled: false, token: '' } });
expect(cleared.json().data.hasToken).toBe(false);
bridge.stop();
});
it('stores startupHint (default on) and returns it', async () => {
const { app, bridge } = await harness();
const first = (await app.inject({ method: 'GET', url: '/api/discord' })).json().data;
expect(first).toMatchObject({ startupHint: true });
const put = await app.inject({
method: 'PUT',
url: '/api/discord',
payload: { startupHint: false },
});
expect(put.json().data).toMatchObject({ startupHint: false });
bridge.stop();
});
it('refuses a bad id, an incomplete enable, and unknown fields', async () => {
const { app } = await harness();
const bad = await app.inject({ method: 'PUT', url: '/api/discord', payload: { guildId: 'not-a-number' } });
expect(bad.statusCode).toBe(400);
const incomplete = await app.inject({ method: 'PUT', url: '/api/discord', payload: { enabled: true } });
expect(incomplete.statusCode).toBe(400);
const extra = await app.inject({ method: 'PUT', url: '/api/discord', payload: { nope: 1 } });
expect(extra.statusCode).toBe(400);
});
it('POST /test needs a saved token, then posts to the parent channel', async () => {
const { app, bridge } = await harness();
expect((await app.inject({ method: 'POST', url: '/api/discord/test' })).statusCode).toBe(400);
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
const res = await app.inject({ method: 'POST', url: '/api/discord/test' });
expect(res.json().data.ok).toBe(true);
expect(String(fetchImpl.mock.calls.at(-1)![0])).toContain('/channels/222222222222222222/messages');
bridge.stop();
});
it('is admin only in multi-user mode', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await harness({ username: 'bob', role: 'user' });
expect((await app.inject({ method: 'GET', url: '/api/discord' })).statusCode).toBe(403);
expect((await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL })).statusCode).toBe(403);
expect((await app.inject({ method: 'POST', url: '/api/discord/test' })).statusCode).toBe(403);
});
});
describe('POST /api/sessions/:id/discord-notify', () => {
const url = '/api/sessions/test-session-1/discord-notify';
it('is refused while the bridge is off, and posts into the session thread once on', async () => {
const { app, bridge } = await harness();
const url = `/api/sessions/test-session-1/discord-notify`;
const off = await app.inject({ method: 'POST', url, payload: { message: 'press reset' } });
expect(off.statusCode).toBe(409);
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
const on = await app.inject({ method: 'POST', url, payload: { message: 'press reset', urgent: true } });
expect(on.json().data.ok).toBe(true);
const sent = fetchImpl.mock.calls.map((c) => String(c[1].body));
expect(sent.some((b) => b.includes('press reset') && b.includes('<@333333333333333333>'))).toBe(true);
bridge.stop();
});
it('accepts a unique 8-character session id prefix (Docker exports it cut), not an ambiguous or short one', async () => {
const { app, bridge, ctx } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
const ok = await app.inject({
method: 'POST',
url: '/api/sessions/test-ses/discord-notify',
payload: { message: 'hi' },
});
expect(ok.statusCode).toBe(200);
ctx.sessions.set('test-session-2', ctx.sessions.get('test-session-1')!);
const ambiguous = await app.inject({
method: 'POST',
url: '/api/sessions/test-ses/discord-notify',
payload: { message: 'hi' },
});
expect(ambiguous.statusCode).toBe(404);
const short = await app.inject({
method: 'POST',
url: '/api/sessions/test/discord-notify',
payload: { message: 'hi' },
});
expect(short.statusCode).toBe(404);
bridge.stop();
});
it("does not reveal another user's session through its id prefix (multi-user)", async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app, bridge, ctx } = await harness({ username: 'bob', role: 'user' });
(ctx.sessions.get('test-session-1') as unknown as { owner: string }).owner = 'alice';
const res = await app.inject({
method: 'POST',
url: '/api/sessions/test-ses/discord-notify',
payload: { message: 'hi' },
});
expect(res.statusCode).toBe(404);
expect(res.json().error).not.toContain('test-session-1');
bridge.stop();
});
it('lets two different deliberate messages from one tab through within 3 s (neither is lost)', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
const first = await app.inject({ method: 'POST', url, payload: { message: 'first thing' } });
const second = await app.inject({ method: 'POST', url, payload: { message: 'second thing' } });
expect(first.statusCode).toBe(200);
expect(second.statusCode).toBe(200);
expect(second.json().data.deduped).toBeUndefined();
const sent = fetchImpl.mock.calls.map((c) => String(c[1].body));
expect(sent.filter((b) => b.includes('first thing') || b.includes('second thing'))).toHaveLength(2);
bridge.stop();
});
it('answers 200 with data.ok false when Discord refuses the post, which the CLI turns into exit 1', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
fetchImpl.mockImplementation(async () => new Response(JSON.stringify({ code: 0 }), { status: 401 }));
const res = await app.inject({ method: 'POST', url, payload: { message: 'help' } });
expect(res.statusCode).toBe(200);
expect(res.json().data).toMatchObject({ ok: false, status: 401 });
bridge.stop();
});
it('answers 429 RATE_LIMITED once a tab posts more than its share in a minute', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
const codes: number[] = [];
for (let i = 0; i < 8; i++) {
codes.push(
(await app.inject({ method: 'POST', url, payload: { message: `ping ${i}`, urgent: true } })).statusCode
);
}
expect(codes.slice(0, 6)).toEqual([200, 200, 200, 200, 200, 200]);
expect(codes.slice(6)).toEqual([429, 429]);
const limited = await app.inject({ method: 'POST', url, payload: { message: 'again' } });
expect(limited.json().errorCode).toBe('RATE_LIMITED');
expect(limited.headers['retry-after']).toBe('60');
bridge.stop();
});
it('does not spend rate-limit tokens on a request refused for its file', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
for (let i = 0; i < 8; i++) {
const bad = await app.inject({ method: 'POST', url, payload: { message: 'x', file: 'no-such-file.txt' } });
expect(bad.statusCode).toBe(400);
}
expect((await app.inject({ method: 'POST', url, payload: { message: 'still allowed' } })).statusCode).toBe(200);
bridge.stop();
});
it('rejects an empty or oversized message', async () => {
const { app } = await harness();
const url = `/api/sessions/test-session-1/discord-notify`;
expect((await app.inject({ method: 'POST', url, payload: { message: '' } })).statusCode).toBe(400);
expect((await app.inject({ method: 'POST', url, payload: { message: 'x'.repeat(2000) } })).statusCode).toBe(400);
});
});
describe('discord-notify with a file', () => {
let work: string;
let outside: string;
const url = '/api/sessions/test-session-1/discord-notify';
async function ready() {
const h = await harness();
(h.ctx.sessions.get('test-session-1') as unknown as { workingDir: string }).workingDir = work;
await h.app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
return h;
}
const post = (app: Awaited<ReturnType<typeof harness>>['app'], file: string) =>
app.inject({ method: 'POST', url, payload: { message: 'log attached', file } });
beforeEach(() => {
work = mkdtempSync(join(tmpdir(), 'discord-work-'));
outside = mkdtempSync(join(tmpdir(), 'discord-outside-'));
writeFileSync(join(work, 'run.log'), 'hello');
writeFileSync(join(outside, 'secret.txt'), 'nope');
});
afterEach(() => {
rmSync(work, { recursive: true, force: true });
rmSync(outside, { recursive: true, force: true });
});
it('uploads a file inside the session folder as multipart', async () => {
const { app, bridge } = await ready();
const res = await post(app, join(work, 'run.log'));
expect(res.json().data.ok).toBe(true);
const form = fetchImpl.mock.calls.map((c) => c[1].body).find((b) => b instanceof FormData) as FormData;
expect((form.get('files[0]') as File).name).toBe('run.log');
bridge.stop();
});
it.each([
['a relative path', () => 'run.log'],
['a file outside the folder', () => join(outside, 'secret.txt')],
['the folder itself', () => work],
['a missing file', () => join(work, 'nope.log')],
])('refuses %s', async (_n, path) => {
const { app, bridge } = await ready();
const res = await post(app, path());
expect(res.statusCode).toBe(400);
expect(fetchImpl.mock.calls.some((c) => c[1].body instanceof FormData)).toBe(false);
bridge.stop();
});
it.each([
['a .env file', () => ['.env', 'TOKEN=x']],
['a private key under .ssh', () => ['.ssh/id_ed25519', 'key']],
['Codeman state with the bot token', () => ['.codeman/discord.json', '{}']],
])('refuses %s even inside the session folder, as with a home directory', async (_n, entry) => {
const [rel, body] = entry();
mkdirSync(dirname(join(work, rel)), { recursive: true });
writeFileSync(join(work, rel), body);
const { app, bridge } = await ready();
const res = await post(app, join(work, rel));
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/credentials/);
expect(fetchImpl.mock.calls.some((c) => c[1].body instanceof FormData)).toBe(false);
bridge.stop();
});
it('refuses a symlink inside the folder that points at a blocked file', async () => {
writeFileSync(join(work, '.env'), 'TOKEN=x');
symlinkSync(join(work, '.env'), join(work, 'notes.txt'));
const { app, bridge } = await ready();
const res = await post(app, join(work, 'notes.txt'));
expect(res.statusCode).toBe(400);
expect(fetchImpl.mock.calls.some((c) => c[1].body instanceof FormData)).toBe(false);
bridge.stop();
});
it('refuses to send when the session folder is not a directory it can probe', async () => {
const { app, ctx, bridge } = await ready();
(ctx.sessions.get('test-session-1') as unknown as { workingDir: string }).workingDir = join(work, 'run.log');
const res = await post(app, join(work, 'run.log'));
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/session folder/);
bridge.stop();
});
it('probes the file before realpath: a path that is not a regular file is refused', async () => {
const { app, bridge } = await ready();
mkdirSync(join(work, 'dir'));
expect((await post(app, join(work, 'dir'))).json().error).toMatch(/Not a regular file/);
bridge.stop();
});
it('refuses a symlink that points outside the folder', async () => {
const { app, bridge } = await ready();
mkdirSync(join(work, 'sub'));
symlinkSync(join(outside, 'secret.txt'), join(work, 'sub', 'link.txt'));
expect((await post(app, join(work, 'sub', 'link.txt'))).statusCode).toBe(400);
bridge.stop();
});
it('refuses a file over 8 MB', async () => {
const { app, bridge } = await ready();
writeFileSync(join(work, 'big.bin'), Buffer.alloc(8 * 1024 * 1024 + 1));
const res = await post(app, join(work, 'big.bin'));
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/8 MB/);
bridge.stop();
});
it('refuses a file for a remote session', async () => {
const { app, bridge, ctx } = await ready();
(ctx.sessions.get('test-session-1') as unknown as { remote: object }).remote = { label: 'box' };
expect((await post(app, join(work, 'run.log'))).statusCode).toBe(400);
bridge.stop();
});
});
+288
View File
@@ -0,0 +1,288 @@
/**
* @fileoverview The edit history panel's API (GET/POST /api/sessions/:id/edits…): a real
* EditHistorySync and store over a fake transcript reader. Port: N/A (app.inject()).
*/
import { mkdtempSync, rmSync } 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 { hunksToUnified, registerEditHistoryRoutes, relativeTo } from '../../src/web/routes/edit-history-routes.js';
import { EditHistorySync, NO_TRANSCRIPT, type EditHistorySyncDeps } from '../../src/web/edit-history-sync.js';
import { EditHistoryStore } from '../../src/edit-history-store.js';
import type { EditSources } from '../../src/edit-history-local-source.js';
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
let dir: string;
let session: Record<string, unknown>;
function editLine(id: string, ts: number, path: string, extra: Record<string, unknown> = {}): string {
return (
JSON.stringify({
type: 'user',
timestamp: new Date(ts).toISOString(),
cwd: '/w',
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
toolUseResult: {
filePath: path,
oldString: 'a',
newString: 'b',
structuredPatch: [{ oldStart: 4, oldLines: 1, newStart: 4, newLines: 2, lines: ['-a', '+b', '+c'] }],
},
...extra,
}) + '\n'
);
}
function sources(main: string, sub?: string): EditSources {
const files = [{ relPath: 'main', size: Buffer.byteLength(main), startOffset: 0, chunk: Buffer.from(main) }];
const metas: Record<string, string> = {};
if (sub) {
files.push({
relPath: 'subagents/agent-a1.jsonl',
size: Buffer.byteLength(sub),
startOffset: 0,
chunk: Buffer.from(sub),
});
metas.a1 = JSON.stringify({
description: 'Fix it',
agentType: 'coding',
worktreePath: '/w/.claude/worktrees/agent-a1',
worktreeBranch: 'worktree-agent-a1',
});
}
return { transcriptId: 'conv1', files, metas };
}
async function setup(
deps: Partial<EditHistorySyncDeps> = {},
opts: { authUser?: { username: string; role: 'admin' | 'user' } } = {}
) {
const read = vi.fn(async () => sources(editLine('t1', T0, '/w/src/a.ts')));
const sync = new EditHistorySync({
transcriptPath: async () => '/p/conv1.jsonl',
readSources: read,
probe: async (p) => p.map(() => null),
loadStore: async (id) => {
const store = await EditHistoryStore.load(id, { dir, debounceMs: 0 });
opened.push(store);
return store;
},
deleteStore: (id) => EditHistoryStore.delete(id, { dir }),
sweepStores: async () => 0,
...deps,
});
const h = await createRouteTestHarness((app, ctx) => registerEditHistoryRoutes(app, ctx, sync), {
authUser: opts.authUser,
});
session = h.ctx._session as unknown as Record<string, unknown>;
session.workingDir = '/w';
session.mode = 'claude';
session.claudeSessionId = 'conv1';
return { ...h, read, sync };
}
/** Stores the tests opened; removed before the temp dir, so no late save recreates it. */
const opened: EditHistoryStore[] = [];
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'edit-routes-'));
});
afterEach(async () => {
delete process.env.CODEMAN_MULTIUSER;
await Promise.all(opened.splice(0).map((s) => s.remove()));
rmSync(dir, { recursive: true, force: true });
});
const URL = '/api/sessions/test-session-1/edits';
describe('GET /api/sessions/:id/edits', () => {
it('syncs on ?sync=1 and lists entries without diffs, paths relative to the workspace', async () => {
const { app } = await setup();
const res = await app.inject({ method: 'GET', url: `${URL}?sync=1` });
expect(res.statusCode).toBe(200);
const data = res.json().data;
expect(data).toMatchObject({ state: 'ok', backfillTruncated: false, agents: {} });
expect(data.entries).toEqual([
expect.objectContaining({
id: 't1',
path: '/w/src/a.ts',
relPath: 'src/a.ts',
tool: 'Edit',
added: 2,
removed: 1,
}),
]);
expect(data.entries[0]).not.toHaveProperty('hunks');
});
it('starts the sync before it loads the store (a deletion right after the session check stops the sync)', async () => {
const { app, sync } = await setup();
const calls: string[] = [];
const syncFn = sync.sync.bind(sync);
const getStore = sync.getStore.bind(sync);
vi.spyOn(sync, 'sync').mockImplementation((...a) => (calls.push('sync'), syncFn(...a)));
vi.spyOn(sync, 'getStore').mockImplementation((...a) => (calls.push('getStore'), getStore(...a)));
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
await app.inject({ method: 'POST', url: `${URL}/older` });
expect(calls).toEqual(['sync', 'getStore', 'sync', 'getStore']);
});
it('without sync=1 answers from the store and does not read', async () => {
const { app, read } = await setup();
const res = await app.inject({ method: 'GET', url: URL });
expect(res.json().data).toMatchObject({ state: 'ok', entries: [] });
expect(read).not.toHaveBeenCalled();
});
it('labels subagent entries and shows their paths relative to the worktree', async () => {
const sub = editLine('t9', T0 + 5, '/w/.claude/worktrees/agent-a1/lib/x.ts', { isSidechain: true, agentId: 'a1' });
const read = vi.fn(async () => sources(editLine('t1', T0, '/w/src/a.ts'), sub));
const { app } = await setup({ readSources: read });
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
expect(data.agents).toEqual({
a1: { description: 'Fix it', agentType: 'coding', worktreeBranch: 'worktree-agent-a1' },
});
expect(data.entries.find((e: { id: string }) => e.id === 't9')).toMatchObject({
agentId: 'a1',
relPath: 'lib/x.ts',
});
});
it('a read error answers unavailable with the stored entries', async () => {
let fail = false;
const read = vi.fn(async () => {
if (fail) throw new Error('EACCES: permission denied');
return sources(editLine('t1', T0, '/w/src/a.ts'));
});
const { app } = await setup({ readSources: read });
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
fail = true;
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
expect(data).toMatchObject({ state: 'unavailable', reason: 'EACCES: permission denied' });
expect(data.entries).toHaveLength(1);
});
it('hands the reader the session ids and its own CLAUDE_CONFIG_DIR', async () => {
const seen: unknown[] = [];
const { app } = await setup({
transcriptPath: async (s) => {
seen.push({ id: s.id, claudeSessionId: s.claudeSessionId, claudeConfigDir: s.claudeConfigDir });
return '/p/conv1.jsonl';
},
});
session.claudeConfigDirOverride = '/cfg/alt ';
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
expect(seen).toEqual([{ id: 'test-session-1', claudeSessionId: 'conv1', claudeConfigDir: '/cfg/alt' }]);
});
it('a session without a transcript yet answers unavailable (no-transcript)', async () => {
const { app, read } = await setup({ transcriptPath: async () => null });
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
expect(data).toMatchObject({ state: 'unavailable', reason: NO_TRANSCRIPT, entries: [] });
expect(read).not.toHaveBeenCalled();
});
it.each([
['remote', { remote: { host: 'huff' } }],
['docker', { docker: { image: 'x' } }],
['mode', { mode: 'shell' }],
])('answers unsupported (%s) without reading or loading a store', async (reason, patch) => {
const loadStore = vi.fn(async (id: string) => EditHistoryStore.load(id, { dir, debounceMs: 0 }));
const { app, read } = await setup({ loadStore });
Object.assign(session, patch);
const empty = { state: 'unsupported', reason, backfillTruncated: false, agents: {}, entries: [] };
for (const url of [URL, `${URL}?sync=1`]) {
expect((await app.inject({ method: 'GET', url })).json().data).toEqual(empty);
}
expect((await app.inject({ method: 'POST', url: `${URL}/older` })).json().data).toEqual(empty);
expect((await app.inject({ method: 'GET', url: `${URL}/t1` })).statusCode).toBe(404);
expect(read).not.toHaveBeenCalled();
expect(loadStore).not.toHaveBeenCalled();
});
it('404s an unknown session', async () => {
const { app } = await setup();
expect((await app.inject({ method: 'GET', url: '/api/sessions/nope/edits' })).statusCode).toBe(404);
});
it('multi-user: another user’s session is not found and nothing is read', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app, read } = await setup({}, { authUser: { username: 'bob', role: 'user' } });
session.owner = 'alice';
const res = await app.inject({ method: 'GET', url: `${URL}?sync=1` });
expect(res.statusCode).toBe(404);
expect(read).not.toHaveBeenCalled();
});
});
describe('GET /api/sessions/:id/edits/:entryId', () => {
it('returns the diff as unified text', async () => {
const { app } = await setup();
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
const res = await app.inject({ method: 'GET', url: `${URL}/t1` });
expect(res.json().data).toEqual({ diff: '@@ -4,1 +4,2 @@\n-a\n+b\n+c', truncated: false });
});
it('404s an edit the session does not have', async () => {
const { app } = await setup();
expect((await app.inject({ method: 'GET', url: `${URL}/missing` })).statusCode).toBe(404);
});
it('multi-user: another user’s diff is not found', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await setup({}, { authUser: { username: 'bob', role: 'user' } });
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
session.owner = 'alice';
expect((await app.inject({ method: 'GET', url: `${URL}/t1` })).statusCode).toBe(404);
});
});
describe('POST /api/sessions/:id/edits/older', () => {
it('reads the next older block and lists the result', async () => {
const lines = [0, 1, 2, 3].map((i) => editLine(`t${i}`, T0 + i, `/w/${i}.ts`)).join('');
const full = Buffer.from(lines);
const read = vi.fn(
async (_path: string, requests: readonly { relPath: string; offset: number; len?: number }[], budget: number) => {
const req = requests.find((r) => r.relPath === 'main');
const start = req ? req.offset : full.length - budget;
const len = req?.len ?? full.length - start;
return {
transcriptId: 'conv1',
metas: {},
files: [{ relPath: 'main', size: full.length, startOffset: start, chunk: full.subarray(start, start + len) }],
};
}
);
const { app } = await setup({ readSources: read, budget: 400 });
const first = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
expect(first.backfillTruncated).toBe(true);
const older = (await app.inject({ method: 'POST', url: `${URL}/older` })).json().data;
expect(older.entries.length).toBeGreaterThan(first.entries.length);
});
it('answers unsupported for a remote session without reading', async () => {
const { app, read } = await setup();
session.remote = { host: 'huff' };
const data = (await app.inject({ method: 'POST', url: `${URL}/older` })).json().data;
expect(data).toMatchObject({ state: 'unsupported', reason: 'remote' });
expect(read).not.toHaveBeenCalled();
});
});
describe('helpers', () => {
it('relativeTo strips only a real directory prefix', () => {
expect(relativeTo('/w/a/b.ts', '/w')).toBe('a/b.ts');
expect(relativeTo('/w/a/b.ts', '/w/')).toBe('a/b.ts');
expect(relativeTo('/wx/b.ts', '/w')).toBe('/wx/b.ts');
expect(relativeTo('/w/b.ts', undefined)).toBe('/w/b.ts');
});
it('hunksToUnified writes one header per hunk', () => {
expect(
hunksToUnified([
{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-x', '+y'] },
{ oldStart: 9, oldLines: 0, newStart: 9, newLines: 1, lines: ['+z'] },
])
).toBe('@@ -1,1 +1,1 @@\n-x\n+y\n@@ -9,0 +9,1 @@\n+z');
});
});
+213
View File
@@ -0,0 +1,213 @@
/**
* @fileoverview POST /api/filesystem/mkdir: the path picker's "New folder". Real filesystem under
* test/setup.ts's temp HOME (a root the picker offers), so this proves what is on disk, not a mock.
* Port: N/A (app.inject()).
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
import { LinkCaseSchema } from '../../src/web/schemas.js';
import { isSandboxedHome, safeRmHomeTree } from '../mocks/index.js';
describe('POST /api/filesystem/mkdir', () => {
let workDir = '';
beforeEach(() => {
if (!isSandboxedHome()) throw new Error('filesystem-mkdir-routes.test.ts creates folders: run it via npm test');
workDir = realpathSync(mkdtempSync(join(homedir(), 'picker-mkdir-')));
});
afterEach(() => {
if (workDir) safeRmHomeTree(workDir);
workDir = '';
});
const mkdir = async (payload: Record<string, unknown>) => {
const { app } = await createRouteTestHarness(registerFileRoutes);
return app.inject({ method: 'POST', url: '/api/filesystem/mkdir', payload });
};
it('creates one folder inside the folder being viewed and answers its path', async () => {
const res = await mkdir({ path: workDir, name: 'fresh' });
expect(res.statusCode).toBe(200);
expect(res.json().data).toEqual({ path: join(workDir, 'fresh') });
expect(readdirSync(workDir)).toEqual(['fresh']);
});
it.each(['Kay app', 'project_v2', 'v1.2', 'my-folder', 'Café 2026'])(
'accepts %s, and Link Existing then accepts the folder it made',
async (name) => {
const res = await mkdir({ path: workDir, name });
expect(res.statusCode).toBe(200);
expect(LinkCaseSchema.safeParse({ name: 'x', path: res.json().data.path }).success).toBe(true);
}
);
it('refuses the cases directory as the parent (400): a folder there is already a case', async () => {
const casesDir = join(homedir(), 'codeman-cases');
mkdirSync(casesDir, { recursive: true });
try {
const res = await mkdir({ path: casesDir, name: 'myproj' });
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('Add Case > New Case');
expect(existsSync(join(casesDir, 'myproj'))).toBe(false);
// A folder inside a case is fine.
mkdirSync(join(casesDir, 'real-case'));
expect((await mkdir({ path: join(casesDir, 'real-case'), name: 'sub' })).statusCode).toBe(200);
} finally {
safeRmHomeTree(casesDir);
}
});
it('answers 404 for a parent that is gone, the same answer browse gives', async () => {
const res = await mkdir({ path: join(workDir, 'gone'), name: 'x' });
expect(res.statusCode).toBe(404);
});
it('refuses a parent that is a file (400), like browse', async () => {
writeFileSync(join(workDir, 'afile'), 'x');
const res = await mkdir({ path: join(workDir, 'afile'), name: 'x' });
expect(res.statusCode).toBe(400);
expect(res.json().error).toBe('The folder to create in must be a directory');
});
it('creates inside a hidden folder only when the picker is showing hidden entries', async () => {
mkdirSync(join(workDir, '.hid'));
const denied = await mkdir({ path: join(workDir, '.hid'), name: 'x' });
expect(denied.statusCode).toBe(403);
const ok = await mkdir({ path: join(workDir, '.hid'), name: 'x', showHidden: 'true' });
expect(ok.statusCode).toBe(200);
expect(existsSync(join(workDir, '.hid', 'x'))).toBe(true);
// A dot name is still refused there.
expect((await mkdir({ path: join(workDir, '.hid'), name: '.y', showHidden: 'true' })).statusCode).toBe(400);
});
it('trims the name', async () => {
const res = await mkdir({ path: workDir, name: ' spaced ' });
expect(res.json().data.path).toBe(join(workDir, 'spaced'));
});
it('refuses an existing folder (409) without touching it', async () => {
mkdirSync(join(workDir, 'taken'));
writeFileSync(join(workDir, 'taken', 'keep.txt'), 'mine');
const res = await mkdir({ path: workDir, name: 'taken' });
expect(res.statusCode).toBe(409);
expect(res.json().error).toContain('already exists');
expect(readdirSync(join(workDir, 'taken'))).toEqual(['keep.txt']);
});
it.each([
['a slash', 'a/b'],
['a backslash', 'a\\b'],
['a traversal', '..'],
['a dot name', '.'],
['a hidden name', '.secret'],
['a lone tilde', '~'],
['a leading tilde', '~tilde'],
['an empty name', ' '],
['an apostrophe', "Kay's app"],
['parentheses', 'project (v2)'],
['a plus', 'a+b'],
['a comma', 'notes, 2026'],
['a hash', 'c#'],
['brackets', '[draft]'],
['a double dot inside', 'v1..2'],
['a tab', 'a\tb'],
['an escape', 'a\x1b[31mb'],
['a bell', 'a\x07b'],
['a delete char', 'a\x7fb'],
['a name over 255 bytes', 'é'.repeat(200)],
])('refuses %s in the name (400) and creates nothing', async (_label, name) => {
const res = await mkdir({ path: workDir, name });
expect(res.statusCode).toBe(400);
expect(readdirSync(workDir)).toEqual([]);
});
it('refuses a parent that does not exist (404)', async () => {
const res = await mkdir({ path: join(workDir, 'nope'), name: 'x' });
expect(res.statusCode).toBe(404);
expect(existsSync(join(workDir, 'nope'))).toBe(false);
});
it('refuses a parent outside the browse roots (403)', async () => {
const res = await mkdir({ path: '/etc', name: 'x' });
expect(res.statusCode).toBe(403);
expect(existsSync('/etc/x')).toBe(false);
});
it('refuses a relative or traversing parent (400)', async () => {
expect((await mkdir({ path: 'relative', name: 'x' })).statusCode).toBe(400);
expect((await mkdir({ path: `${workDir}/../x`, name: 'x' })).statusCode).toBe(400);
});
describe('multi-user scoping', () => {
let prevMultiUser: string | undefined;
let prevSpaces: string | undefined;
let spaces = '';
beforeEach(() => {
prevMultiUser = process.env.CODEMAN_MULTIUSER;
prevSpaces = process.env.CODEMAN_USER_SPACES_DIR;
spaces = join(workDir, 'spaces');
mkdirSync(join(spaces, 'alice'), { recursive: true });
mkdirSync(join(spaces, 'bob'));
process.env.CODEMAN_MULTIUSER = '1';
process.env.CODEMAN_USER_SPACES_DIR = spaces;
});
afterEach(() => {
if (prevMultiUser === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = prevMultiUser;
if (prevSpaces === undefined) delete process.env.CODEMAN_USER_SPACES_DIR;
else process.env.CODEMAN_USER_SPACES_DIR = prevSpaces;
});
it("refuses a non-admin creating inside another user's space (403) and creates nothing", async () => {
const { app } = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
const res = await app.inject({
method: 'POST',
url: '/api/filesystem/mkdir',
payload: { path: join(spaces, 'alice'), name: 'x' },
});
expect(res.statusCode).toBe(403);
expect(readdirSync(join(spaces, 'alice'))).toEqual([]);
});
it("refuses a non-admin's own cases folder (400) but not the top of their space", async () => {
const { app } = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
const own = join(spaces, 'bob', 'cases');
mkdirSync(own, { recursive: true });
const res = await app.inject({
method: 'POST',
url: '/api/filesystem/mkdir',
payload: { path: own, name: 'myproj' },
});
expect(res.statusCode).toBe(400);
expect(existsSync(join(own, 'myproj'))).toBe(false);
});
it('lets a non-admin create inside their own space', async () => {
const { app } = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
const res = await app.inject({
method: 'POST',
url: '/api/filesystem/mkdir',
payload: { path: join(spaces, 'bob'), name: 'mine' },
});
expect(res.statusCode).toBe(200);
expect(existsSync(join(spaces, 'bob', 'mine'))).toBe(true);
});
it('404s a sessionId owned by another user, and adopts none of its folder', async () => {
const scoped = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
scoped.ctx._session.owner = 'alice';
const res = await scoped.app.inject({
method: 'POST',
url: '/api/filesystem/mkdir',
payload: { path: join(spaces, 'bob'), name: 'x', sessionId: scoped.ctx._sessionId },
});
expect(res.statusCode).toBe(404);
expect(existsSync(join(spaces, 'bob', 'x'))).toBe(false);
});
});
});
@@ -0,0 +1,37 @@
/**
* @fileoverview `POST /api/filesystem/mkdir` probes the folder bounded before the picker's
* resolver runs `realpathSync` on it (#516): a mount that stopped answering is refused, and
* the request never reaches a synchronous call that would freeze the server.
*
* The probe is mocked: a mount that never answers cannot be made here. Port: none (inject).
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../src/utils/bounded-path-probe.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/bounded-path-probe.js')>();
return { ...actual, probePathKind: vi.fn(actual.probePathKind) };
});
import { probePathKind } from '../../src/utils/bounded-path-probe.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
import { createRouteTestHarness } from './_route-test-utils.js';
describe('POST /api/filesystem/mkdir on an unresponsive mount', () => {
afterEach(() => vi.mocked(probePathKind).mockClear());
it('refuses before the picker resolver touches the path', async () => {
vi.mocked(probePathKind).mockResolvedValueOnce('unknown');
const { app } = await createRouteTestHarness(registerFileRoutes);
const res = await app.inject({
method: 'POST',
url: '/api/filesystem/mkdir',
payload: { path: '/mnt/dead/projects', name: 'fresh' },
});
expect(res.statusCode).toBe(422);
expect(res.json().errorCode).toBe('OPERATION_FAILED');
expect(res.json().error).toMatch(/not responding|not checked/);
expect(probePathKind).toHaveBeenCalledWith('/mnt/dead/projects', { pastCap: true });
});
});
+67 -2
View File
@@ -21,7 +21,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { rm, readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { createMockRouteContext, safeRmHomeTree, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
@@ -122,6 +122,9 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
const { sessionId } = res.json();
const session = ctx.sessions.get(sessionId) as unknown as Session;
expect(session.customModel?.endpointId).toBe('ep1');
// #750, pinned the same way as the pi/grok/omp one-shot paths below: the FIRST
// launch already carries the selected id, not just the file on disk.
expect(session.getCustomModelForPersist()?.launchModel).toBe('qwen3');
expect(restartSpy).not.toHaveBeenCalled();
const configDir = customModelConfigDir(sessionId);
@@ -192,7 +195,9 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
expect(res.json().errorCode).toBe('OPERATION_FAILED');
});
it('refuses a model id the CLI cannot carry on its command line, cleaning up any written config dir', async () => {
it('refuses a model id the CLI cannot carry on its command line, before writing anything', async () => {
const dirs = join(getDataDir(), 'custom-model-configs');
const before = existsSync(dirs) ? readdirSync(dirs).length : 0;
const res = await quickStart({
caseName: 'cm-badmodel',
mode: 'pi',
@@ -200,6 +205,7 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
});
expect(res.json().success).toBe(false);
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(existsSync(dirs) ? readdirSync(dirs).length : 0).toBe(before);
});
it('refuses customModel for a remote case', async () => {
@@ -226,6 +232,21 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
});
}
it('refuses a model id the CLI cannot launch before asking anyone to evict a model', async () => {
const other = ctx.sessions.get('test-session-1')!;
(other as unknown as { customModel: unknown }).customModel = { endpointId: 'ep1', modelId: 'llama3' };
mockRunning([{ model: 'llama3', state: 'ready' }]);
const res = await quickStart({
caseName: 'cm-badmodel-swap',
mode: 'codex',
customModel: { endpointId: 'ep1', modelId: 'org/model@q4_k_m' },
});
expect(res.json().requiresConfirmation).toBeUndefined();
expect(res.json().errorCode).toBe('INVALID_INPUT');
});
it('asks for confirmation instead of launching when another live session is using the currently loaded model', async () => {
const other = ctx.sessions.get('test-session-1')!;
(other as unknown as { customModel: unknown }).customModel = { endpointId: 'ep1', modelId: 'llama3' };
@@ -367,6 +388,50 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
expect(res.statusCode).toBe(200);
expect(res.json().requiresContextWarning).toBeUndefined();
});
it('warns using the endpoint defaultContextLength when discovery recorded no per-model figure', async () => {
const endpoint: CustomModelHost = {
id: 'ep-default',
label: 'silent gateway',
baseUrl: 'http://192.168.1.53:8080',
apiKey: 'k',
defaultContextLength: 16384,
};
await writeCustomModelHosts(getDataDir(), [ENDPOINT, endpoint]);
const res = await quickStart({
caseName: 'cm-default-ctx',
mode: 'claude',
customModel: { endpointId: 'ep-default', modelId: 'some-unknown-model' },
});
const body = res.json();
expect(body.requiresContextWarning).toBe(true);
expect(body.contextLength).toBe(16384);
expect(ctx.sessions.size).toBe(1); // nothing was created
});
it('a discovered per-model figure wins over the endpoint defaultContextLength', async () => {
const endpoint: CustomModelHost = {
id: 'ep-precedence',
label: 'mixed box',
baseUrl: 'http://192.168.1.54:8080',
apiKey: 'k',
modelContextLengths: { m: 131072 },
defaultContextLength: 16384,
};
await writeCustomModelHosts(getDataDir(), [ENDPOINT, endpoint]);
const res = await quickStart({
caseName: 'cm-precedence',
mode: 'claude',
customModel: { endpointId: 'ep-precedence', modelId: 'm' },
});
expect(res.statusCode).toBe(200);
expect(res.json().requiresContextWarning).toBeUndefined();
expect(ctx.sessions.size).toBe(2);
});
});
describe('triggering the actual llama-swap load (not just watching for it)', () => {
+118 -3
View File
@@ -9,7 +9,7 @@ import { createRouteTestHarness } from './_route-test-utils.js';
import { createMockSession } from '../mocks/index.js';
import { getDataDir } from '../../src/config/instance.js';
import { writeCustomModelHosts, type CustomModelHost } from '../../src/custom-model-hosts.js';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { existsSync, readFileSync, rmSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { webviewFetch } from '../../src/web/webview-egress.js';
@@ -203,6 +203,10 @@ describe('POST /api/sessions/:id/custom-model', () => {
const { app, ctx } = await setup();
const session = ctx.sessions.get('test-session-1')!;
session.mode = 'pi';
// Start from no dir at all (a sibling test's successful pi apply may have left one),
// so the existsSync below says something about THIS request.
const dir = join(getDataDir(), 'custom-model-configs', 'test-session-1');
rmSync(dir, { recursive: true, force: true });
const res = await app.inject({
method: 'POST',
@@ -214,8 +218,45 @@ describe('POST /api/sessions/:id/custom-model', () => {
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(session.setCustomModel).not.toHaveBeenCalled();
expect(session.restartCli).not.toHaveBeenCalled();
// The config dir written before the check is cleaned up again.
expect(existsSync(join(getDataDir(), 'custom-model-configs', 'test-session-1'))).toBe(false);
// The pattern check runs on the pure build BEFORE any write, so a refused id leaves
// no directory behind at all, nothing to clean up.
expect(existsSync(dir)).toBe(false);
});
it('codex: a refused forced launch id never touches the live config dir the session runs on as CODEX_HOME', async () => {
const { app, ctx } = await setup();
const session = ctx.sessions.get('test-session-1')!;
session.mode = 'codex';
// First apply succeeds: the session now runs codex on qwen3:32b, whose config.toml
// lives in the per-session dir the running CLI itself uses as CODEX_HOME.
const ok = await app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/custom-model',
payload: { endpointId: 'ep1', modelId: 'qwen3:32b' },
});
expect(ok.statusCode).toBe(200);
const dir = join(getDataDir(), 'custom-model-configs', 'test-session-1');
const written = join(dir, 'config.toml');
expect(existsSync(written)).toBe(true);
// `@` is outside codex's `model-plain-colon` pattern. The pattern check now runs on
// the pure build BEFORE any write, so the refusal must leave the dir, and the file
// the live CLI reads, exactly as they were, rather than replacing and deleting the
// CODEX_HOME of a session that stays on qwen3:32b.
const before = readFileSync(written, 'utf8');
const res = await app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/custom-model',
payload: { endpointId: 'ep1', modelId: 'org/model@q4_k_m' },
});
expect(res.json().success).toBe(false);
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
expect(session.restartCli).toHaveBeenCalledTimes(1);
expect(existsSync(dir)).toBe(true);
expect(readFileSync(written, 'utf8')).toBe(before);
});
it('clear removes the previous config dir the session reports', async () => {
@@ -298,6 +339,28 @@ describe('POST /api/sessions/:id/custom-model', () => {
expect(session.restartCli).not.toHaveBeenCalled();
});
it('refuses a model id the CLI cannot launch before asking anyone to evict a model', async () => {
const { app, ctx } = await setup();
const session = ctx.sessions.get('test-session-1')!;
session.mode = 'codex';
const other = createMockSession('other-session');
other.name = 'w2-otherbox';
other.customModel = { endpointId: 'ep1', modelId: 'llama3' };
ctx.sessions.set('other-session', other);
mockRunning([{ model: 'llama3', state: 'ready' }]);
const res = await app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/custom-model',
payload: { endpointId: 'ep1', modelId: 'org/model@q4_k_m' },
});
// Agreeing to the swap first would only lead to this same 400.
expect(res.json().requiresConfirmation).toBeUndefined();
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(session.setCustomModel).not.toHaveBeenCalled();
});
describe('multi-user: the confirm dialog must not name a session the caller cannot access', () => {
const saved: Record<string, string | undefined> = {};
@@ -449,6 +512,58 @@ describe('POST /api/sessions/:id/custom-model', () => {
expect(session.restartCli).not.toHaveBeenCalled();
});
it('the endpoint defaultContextLength stands in when discovery recorded no per-model figure', async () => {
const { app, ctx } = await setup();
// A gateway that states nothing per model: only the user's default is known.
const endpoint: CustomModelHost = {
id: 'ep-default',
label: 'silent gateway',
baseUrl: 'http://192.168.1.53:8080',
apiKey: 'k',
defaultContextLength: 16384,
};
await writeCustomModelHosts(getDataDir(), [CLAUDE_ENDPOINT, endpoint]);
const session = ctx.sessions.get('test-session-1')!;
session.mode = 'claude';
const res = await app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/custom-model',
payload: { endpointId: 'ep-default', modelId: 'some-unknown-model' },
});
const body = res.json();
expect(body.requiresContextWarning).toBe(true);
expect(body.contextLength).toBe(16384);
expect(session.setCustomModel).not.toHaveBeenCalled();
});
it('a discovered per-model figure wins over the endpoint defaultContextLength', async () => {
const { app, ctx } = await setup();
// The default is small enough to warn on; the discovered figure for THIS model is
// not, so the fallback must not leak into the per-model path.
const endpoint: CustomModelHost = {
id: 'ep-precedence',
label: 'mixed box',
baseUrl: 'http://192.168.1.54:8080',
apiKey: 'k',
modelContextLengths: { 'qwen3.8-27b-ud-q4_k_xl': 131072 },
defaultContextLength: 16384,
};
await writeCustomModelHosts(getDataDir(), [CLAUDE_ENDPOINT, endpoint]);
const session = ctx.sessions.get('test-session-1')!;
session.mode = 'claude';
const res = await app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/custom-model',
payload: { endpointId: 'ep-precedence', modelId: 'qwen3.8-27b-ud-q4_k_xl' },
});
expect(res.json().requiresContextWarning).toBeUndefined();
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
});
// llama-server splits its context across --parallel slots, and Claude Code sends several
// requests at once, so a context that clears the floor as a whole can still fail per slot.
describe('parallel slots share the context', () => {
+10
View File
@@ -1011,6 +1011,16 @@ describe('session-routes', () => {
// with no broadcast, so other open tabs kept showing the retired row until
// their next unrelated fetch.
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:deleted', { id: 'ghost-session' });
// Its stored edit history (diffs can hold secrets) goes with the record.
expect(harness.ctx.forgetEditHistory).toHaveBeenCalledWith('ghost-session');
});
it('keeps the edit history of a pinned persisted-only session whose record is only demoted', async () => {
vi.mocked(harness.ctx.store.getSession).mockReturnValueOnce({ id: 'pinned-ghost', owner: undefined } as never);
vi.mocked(harness.ctx.store.demoteOrRemoveSession).mockReturnValueOnce('preserved');
const res = await harness.app.inject({ method: 'DELETE', url: '/api/sessions/pinned-ghost' });
expect(res.statusCode).toBe(200);
expect(harness.ctx.forgetEditHistory).not.toHaveBeenCalled();
});
it('a persisted-only delete drops the agent inbox the detach kept', async () => {
+13
View File
@@ -420,6 +420,19 @@ describe('system-routes', () => {
agentInbox.resetForTests();
}
});
it('deletes the edit history of every session it cleaned (diffs can hold secrets)', async () => {
harness.ctx.tabLayouts.runStaleSessionCleanup = vi.fn(async () => ({
count: 2,
cleaned: [
{ id: 'stale-1', name: 'a' },
{ id: 'stale-2', name: 'b' },
],
})) as never;
const res = await harness.app.inject({ method: 'POST', url: '/api/cleanup-state' });
expect(JSON.parse(res.body).cleanedSessions).toBe(2);
expect(vi.mocked(harness.ctx.forgetEditHistory).mock.calls).toEqual([['stale-1'], ['stale-2']]);
});
});
// ========== GET /api/subagents ==========
+5 -18
View File
@@ -333,8 +333,7 @@ describe('the shipped security-notices.json', () => {
const nonBlank = (value: unknown) => typeof value === 'string' && value.trim().length > 0;
it('is a list of notices with unique, stable ids', () => {
expect(Array.isArray(notices)).toBe(true);
expect(notices.length).toBeGreaterThan(0);
expect(Array.isArray(notices)).toBe(true); // empty is fine: every notice has been retired
const all = notices.map((n) => n.id);
expect(new Set(all).size).toBe(all.length);
for (const id of all) expect(id).toMatch(/^[a-z0-9][a-z0-9-]*$/);
@@ -383,22 +382,10 @@ describe('the shipped security-notices.json', () => {
}
});
it('shows the OpenCode notice, with its title, only to OpenCode users', () => {
const breach = notices.find((n) => n.id === 'opencode-console-breach-2026-10');
expect(breach?.cli).toBe('opencode');
const [shown] = core.select(notices, { available: { opencode: true }, now: NOW });
expect(shown).toMatchObject({
id: 'opencode-console-breach-2026-10',
title: 'OpenCode Console breach: check your API keys',
label: "Read OpenCode's notice",
url: 'https://x.com/opencode/status/2111519637049319818',
});
expect(core.select(notices, { available: { opencode: true }, now: NOW, lang: 'zh-CN' })[0].title).toBe(
'OpenCode Console 遭入侵:请检查你的 API 密钥'
);
expect(core.select(notices, { available: { opencode: false, claude: true }, now: NOW })).toEqual([]);
expect(core.select(notices, { available: {}, now: NOW })).toEqual([]);
expect(core.select(notices, { available: { opencode: true }, now: at(2027, 1, 1) })).toEqual([]);
it('no longer ships the retired OpenCode Console notice, even to a fresh OpenCode install', () => {
// It stayed in the file past its use, and a new install has no seen list, so everyone got it.
expect(notices.map((n) => n.id)).not.toContain('opencode-console-breach-2026-10');
expect(core.select(notices, { available: { opencode: true }, seen: [], now: NOW })).toEqual([]);
});
it('is fetched by the page and never generated by the release script', () => {
+12
View File
@@ -89,6 +89,18 @@ describe('isSensitivePath', () => {
['codeman intent profiles', `${HOME}/.codeman/intents.json`],
['codeman intents on a named instance', `${HOME}/.codeman-beta/intents.json`],
['codeman agent inbox snapshot', `${HOME}/.codeman/agent-inbox.json`],
// The edit history stores hold every diff an agent wrote (keys and .env
// contents included): 0600 and kept out of /api/search like intents.json.
['codeman edit history store', `${HOME}/.codeman/edit-history/s1.json`],
['codeman edit history on a named instance', `${HOME}/.codeman-beta/edit-history/s1.json`],
// Credentials outside settings.json, and the backups that copy all of these.
['codeman Discord bot token', `${HOME}/.codeman/discord.json`],
['codeman Discord token on a named instance', `${HOME}/.codeman-beta/discord.json`],
['codeman webhook URL', `${HOME}/.codeman/webhook.json`],
['codeman custom endpoint API keys', `${HOME}/.codeman/custom-model-hosts.json`],
['a generated custom model config', `${HOME}/.codeman/custom-model-configs/s1/opencode.json`],
['a backed-up user list', `${HOME}/.codeman/backups/config/cfg-20261104-120000/users.json`],
['a backed-up settings file', `${HOME}/.codeman/backups/config/cfg-20261104-120000/settings.json`],
];
it.each(blocked)('blocks the %s', (_label, path) => {
+28
View File
@@ -27,6 +27,8 @@ import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Session } from '../src/session.js';
import { getCli } from '../src/config/cli-registry/index.js';
import { buildCustomModelInjection } from '../src/custom-model-injection.js';
import { TmuxManager } from '../src/tmux-manager.js';
import type { MuxSession, SessionMode } from '../src/types.js';
@@ -225,3 +227,29 @@ describe('launchModel reaches the CLI through its own launch param', () => {
expect(respawn.mock.calls[0][0].model).toBeUndefined();
});
});
describe('Codex custom endpoint model overrides', () => {
it('selects the local model over a native launch default and restores the default on clear', async () => {
const entry = getCli('codex');
if (!entry) throw new Error('Missing Codex entry');
const injection = buildCustomModelInjection(
entry,
{ id: 'ep', label: 'Local', baseUrl: 'http://box:8080' },
'qwen3'
);
if (injection.kind !== 'configDir') throw new Error('Expected isolated Codex config');
const { session, respawn } = liveSession('codex', {
codexConfig: { model: 'native-model', reasoningEffort: 'high' },
});
session.setCustomModel(
{ endpointId: 'ep', modelId: 'qwen3', envKeys: ['CODEX_HOME'], launchModel: injection.launchModel },
{ CODEX_HOME: '/isolated/codex' }
);
await session.restartCli();
expect(respawn.mock.calls[0][0].codexConfig).toEqual({ model: 'qwen3', reasoningEffort: 'high' });
session.setCustomModel(undefined);
respawn.mockClear();
await session.restartCli();
expect(respawn.mock.calls[0][0].codexConfig).toEqual({ model: 'native-model', reasoningEffort: 'high' });
});
});
+1
View File
@@ -95,6 +95,7 @@ const REAL = new Set([
'_keepServerSyncedSettings',
'_displaySettingKeySet',
'_noteSettingsWriteRefusal',
'_settingsWriteError',
'_applyOwnSettings',
'_readOwnSettings',
'_ownSettingsKey',
+536
View File
@@ -0,0 +1,536 @@
/**
* @fileoverview A browser write to settings.json that did not land must not say it did.
*
* PUT /api/settings can fail for reasons other than the two refusals the page already handles
* (the multi-user admin-only 403 FORBIDDEN and the tunnel's no-password 403): a 409 CONFLICT when
* settings.json does not parse (settings-writer.ts refuses to overwrite it), a 400, a 500, or a
* dropped connection, which `_apiPut` answers with null instead of throwing. Each of those used
* to end the App Settings save with "Settings saved" and a closed modal, and the run mode and
* respawn preset writes failed with no word at all. Now:
* - App Settings: a warning that carries the server's reason (else its HTTP status, else that the
* server could not be reached), the modal stays open, and nothing that assumes the server saved
* runs (Apply's refresh, the tunnel-starting toast, the gesture reload). A save with the tunnel
* switch on is no longer read as a tunnel refusal unless the server said 403.
* - Run mode and respawn presets: the same warning, naming what was not saved; they stay saved on
* this device as before.
* - The success path and both 403 paths are unchanged.
* - The new toasts read in Chinese under zh-CN.
*
* The real modules run inside a JSDOM window with the real index.html (the App Settings save) or a
* vm context (the background writes); `_apiPut` is the real api-client.js, and only `fetch` is
* mocked. Port: none.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = join(process.cwd(), 'src/web/public');
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
const INDEX = read('index.html');
const I18N = read('i18n.js');
const SETTINGS_UI = read('settings-ui.js');
const SESSION_UI = read('session-ui.js');
const RESPAWN_UI = read('respawn-ui.js');
const API_CLIENT = read('api-client.js');
const DESKTOP_KEY = 'codeman-app-settings';
type Settings = Record<string, unknown>;
/** What settings-writer.ts answers (409 CONFLICT) when settings.json does not parse. */
const TORN =
'/home/u/.codeman/settings.json is not a valid JSON object, so this change was not saved (saving would have replaced every other setting in it). Fix or delete the file, or restore it from a config backup, then try again.';
const INVALID = 'Invalid settings: codexModel: Invalid string';
/** A fetch answer: a JSON envelope, or a body that is not JSON (`body: null`). */
type Answer = { status: number; body: Settings | null } | 'unreachable';
const envelope = (status: number, error: string, errorCode: string): Answer => ({
status,
body: { success: false, error, errorCode },
});
const OK: Answer = { status: 200, body: { success: true, data: {} } };
const CONFLICT = envelope(409, TORN, 'CONFLICT');
const BAD_REQUEST = envelope(400, INVALID, 'INVALID_INPUT');
const SERVER_ERROR: Answer = { status: 500, body: null };
const FORBIDDEN = envelope(403, 'You do not have permission', 'FORBIDDEN');
function respond(answer: Answer) {
if (answer === 'unreachable') return Promise.reject(new TypeError('Failed to fetch'));
const make = (): any => ({
ok: answer.status >= 200 && answer.status < 300,
status: answer.status,
json: async () => {
if (answer.body === null) throw new SyntaxError('Unexpected token < in JSON');
return answer.body;
},
clone: make,
});
return Promise.resolve(make());
}
const warning = (text: string) => [text, 'warning', { duration: 0 }];
const errorToast = (text: string) => [text, 'error', { duration: 0 }];
const windows: { close(): void }[] = [];
afterEach(() => {
for (const win of windows.splice(0)) win.close();
});
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
/** Methods that run for real; every other prototype method is a no-op (panels this file is not about). */
const REAL = new Set([
'openAppSettings',
'saveAppSettings',
'applyAppSettings',
'loadAppSettingsFromServer',
'loadAppSettingsFromStorage',
'saveAppSettingsToStorage',
'getSettingsStorageKey',
'getDefaultSettings',
'tabCliLogosEnabled',
'resolveTabArrangement',
'resolveTabStateOrder',
'resolveHeaderStatsStyle',
'resolveSessionSidebarFontSize',
'planUsageChipEnabled',
'planUsageCollectionFlip',
'loadModelConfigForSettings',
'saveModelConfigFromSettings',
'_modelConfigFromSettingsForm',
'_settingsReadOnly',
'_captureSettingsFormBaseline',
'_keepServerSyncedSettings',
'_displaySettingKeySet',
'_noteSettingsWriteRefusal',
'_settingsWriteError',
'_confirmTunnelEnable',
'_handleTunnelEnableRefusal',
'_abandonTunnelEnable',
'_askUnauthTunnel',
'_webhookPending',
'_api',
'_apiPut',
]);
const ELSEWHERE = [
'_updateLocalEchoState',
'renderProjectInsightsPanel',
'updateSubagentWindowVisibility',
'showWelcome',
];
/**
* Boot index.html with the real modules, signed in as a single-user admin. `put` is what
* PUT /api/settings answers; `tunnelStatus` what GET /api/tunnel/status answers.
*/
async function boot(
put: Answer | Answer[],
tunnelStatus: Answer = { status: 200, body: { success: true, data: {} } },
confirmAnswer = false
) {
const putAnswers = Array.isArray(put) ? [...put] : null;
const dom = new JSDOM(INDEX, { url: 'http://localhost/', runScripts: 'outside-only' });
const win = dom.window as unknown as Window &
typeof globalThis & { eval(src: string): void; __App: any; __codemanUser: Settings; fetch: unknown };
windows.push(win);
if (win.document.readyState !== 'complete') await new Promise((resolve) => win.addEventListener('load', resolve));
win.setInterval = (() => 0) as unknown as typeof win.setInterval;
win.confirm = vi.fn(() => confirmAnswer);
win.localStorage.setItem(DESKTOP_KEY, JSON.stringify({}));
const fetchCalls: { url: string; method: string; body?: string }[] = [];
win.fetch = vi.fn((url: string, opts: { method?: string; body?: string } = {}) => {
const method = opts.method || 'GET';
fetchCalls.push({ url, method, body: opts.body });
if (url === '/api/settings' && method === 'PUT') return respond(putAnswers ? putAnswers.shift()! : (put as Answer));
if (url === '/api/tunnel/status') return respond(tunnelStatus);
if (url === '/api/execution/model-config' && method === 'GET') {
return respond({ status: 200, body: { success: true, data: { defaultModel: 'sonnet' } } });
}
return respond(OK);
});
win.eval(
[
`var MobileDetection = {
getDeviceType: () => 'desktop',
isHandheldDevice: () => false,
isMobile: () => false,
isTouchDevice: () => false,
};
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
var KeyboardAccessoryBar = { setMode() {} };
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
var DEFAULT_VOICE_KEYTERMS = '';`,
read('constants.js'),
read('app.js'),
SETTINGS_UI,
API_CLIENT,
'window.__App = CodemanApp;',
].join('\n')
);
win.__codemanUser = { username: 'admin', role: 'admin', multiUser: false };
const target = Object.create(win.__App.prototype);
target.sessions = new Map();
target.sessionOrder = [];
target.showToast = vi.fn();
target.closeAppSettings = vi.fn();
target._refreshSettingsAfterApply = vi.fn();
const noop = () => undefined;
for (const name of ELSEWHERE) target[name] = noop;
const app = new Proxy(target, {
get(t, key, receiver) {
const value = Reflect.get(t, key, receiver);
const stubbed =
typeof key === 'string' && typeof value === 'function' && !Object.hasOwn(t, key) && !REAL.has(key);
return stubbed ? noop : value;
},
}) as any;
await app.loadAppSettingsFromServer(Promise.resolve({}));
app.openAppSettings();
await tick();
await tick();
const doc = win.document;
const box = (id: string) => doc.getElementById(id) as HTMLInputElement;
const storedBlob = () => JSON.parse(win.localStorage.getItem(DESKTOP_KEY) || '{}') as Settings;
const settingsPuts = () => fetchCalls.filter((c) => c.url === '/api/settings' && c.method === 'PUT');
return { app, target, box, storedBlob, settingsPuts };
}
describe('App Settings save whose settings PUT did not land', () => {
const failures: [string, Answer, string][] = [
['409 CONFLICT (settings.json does not parse)', CONFLICT, TORN],
['400 INVALID_INPUT', BAD_REQUEST, INVALID],
['500 with a body that is not JSON', SERVER_ERROR, 'HTTP 500'],
['a dropped connection (_apiPut answers null)', 'unreachable', 'the server could not be reached'],
];
for (const [name, put, reason] of failures) {
it(`${name}: warns with the reason, keeps the modal open, keeps the change on this device`, async () => {
const { app, target, box, storedBlob, settingsPuts } = await boot(put);
box('appSettingsTabTwoRows').checked = true; // per-device
box('appSettingsAutoNameSessions').checked = true; // synced
target.showToast.mockClear();
target.closeAppSettings.mockClear();
await app.saveAppSettings();
expect(settingsPuts()).toHaveLength(1);
expect(target.showToast.mock.calls).toEqual([warning(`Saved on this device, not on the server: ${reason}`)]);
expect(target.closeAppSettings).not.toHaveBeenCalled();
expect(storedBlob().tabTwoRows).toBe(true);
expect(storedBlob().autoNameSessions).toBe(true);
});
}
it('Apply: the same warning, and the refresh that needs the saved flags does not run', async () => {
const { app, target } = await boot(CONFLICT);
target.showToast.mockClear();
target._refreshSettingsAfterApply.mockClear();
await app.applyAppSettings();
expect(target.showToast.mock.calls).toEqual([warning(`Saved on this device, not on the server: ${TORN}`)]);
expect(target._refreshSettingsAfterApply).not.toHaveBeenCalled();
expect(target.closeAppSettings).not.toHaveBeenCalled();
});
it('with the tunnel switch on: not read as a tunnel refusal, no "Tunnel starting", the switch stays on', async () => {
for (const put of [CONFLICT, 'unreachable'] as Answer[]) {
const { app, target, box, storedBlob } = await boot(put, {
status: 200,
body: { success: true, data: { running: true, loginConfigured: true } },
});
box('appSettingsTunnelEnabled').checked = true;
target.showToast.mockClear();
target.closeAppSettings.mockClear();
await app.saveAppSettings();
const reason = put === 'unreachable' ? 'the server could not be reached' : TORN;
expect(target.showToast.mock.calls, JSON.stringify(put)).toEqual([
warning(`Saved on this device, not on the server: ${reason}`),
]);
expect(target.closeAppSettings).not.toHaveBeenCalled();
expect(box('appSettingsTunnelEnabled').checked).toBe(true);
expect(storedBlob().tunnelEnabled).toBe(true);
}
});
it('a gesture-control change does not reload the page as if the server had stored it', async () => {
const { app, target, box } = await boot(CONFLICT);
box('appSettingsGestureControl').checked = true;
target.showToast.mockClear();
await app.saveAppSettings();
expect(target.showToast.mock.calls).toEqual([warning(`Saved on this device, not on the server: ${TORN}`)]);
});
it('a retried Save still sends the plan-usage flip and reloads for gesture control, once the server stores it', async () => {
const { app, target, box, settingsPuts } = await boot([CONFLICT, OK]);
box('appSettingsShowPlanUsageLimits').checked = false; // desktop default ON: a flip
box('appSettingsGestureControl').checked = true;
target.showToast.mockClear();
await app.saveAppSettings();
expect(target.showToast.mock.calls).toEqual([warning(`Saved on this device, not on the server: ${TORN}`)]);
// The user fixes the file and presses Save again; the form still shows the same choices.
target.showToast.mockClear();
await app.saveAppSettings();
const [first, second] = settingsPuts().map((p) => JSON.parse(p.body!));
expect(first.showPlanUsageLimits).toBe(false);
expect(second.showPlanUsageLimits).toBe(false);
expect(target.showToast.mock.calls).toEqual([
['Settings saved', 'success'],
['Enabling gesture control — reloading…', 'info'],
]);
expect(target.closeAppSettings).toHaveBeenCalledTimes(1);
});
});
describe('App Settings save: the paths that already worked', () => {
it('a saved PUT still says "Settings saved" and closes the modal', async () => {
const { app, target, box } = await boot(OK);
box('appSettingsAutoNameSessions').checked = true;
target.showToast.mockClear();
await app.saveAppSettings();
expect(target.showToast.mock.calls).toEqual([['Settings saved', 'success']]);
expect(target.closeAppSettings).toHaveBeenCalledTimes(1);
});
it('a 403 FORBIDDEN still ends with the one admin-only toast', async () => {
const { app, target } = await boot(FORBIDDEN);
target.showToast.mockClear();
await app.saveAppSettings();
expect(target.showToast.mock.calls).toEqual([
['Saved on this device. Server-wide settings can only be changed by an admin.', 'warning'],
]);
expect(target.closeAppSettings).toHaveBeenCalledTimes(1);
expect(app._settingsReadOnly()).toBe(true);
});
it("the tunnel's no-password 403 still goes to the tunnel prompt, and cancelling it turns the switch off", async () => {
// GET /api/tunnel/status unreadable: the PUT goes out unacknowledged and the server refuses it.
const { app, target, box, storedBlob } = await boot(
envelope(403, 'Refusing to start a public tunnel with no login', 'OPERATION_FAILED'),
'unreachable'
);
box('appSettingsTunnelEnabled').checked = true;
target.showToast.mockClear();
await app.saveAppSettings();
expect(target.showToast.mock.calls).toEqual([['Tunnel not enabled', 'info']]);
expect(target.closeAppSettings).toHaveBeenCalledTimes(1);
expect(box('appSettingsTunnelEnabled').checked).toBe(false);
expect(storedBlob().tunnelEnabled).toBe(false);
});
it('an accepted no-password retry sends the whole save again, so "Settings saved" is true', async () => {
const { app, target, box, settingsPuts } = await boot(
[envelope(403, 'Refusing to start a public tunnel with no login', 'OPERATION_FAILED'), OK],
'unreachable',
true
);
box('appSettingsTunnelEnabled').checked = true;
box('appSettingsAutoNameSessions').checked = true;
target.showToast.mockClear();
await app.saveAppSettings();
const [first, retry] = settingsPuts().map((p) => JSON.parse(p.body!));
expect(settingsPuts()).toHaveLength(2);
expect(first.autoNameSessions).toBe(true);
expect(first.acknowledgeUnauthTunnel).toBeUndefined();
expect(retry).toEqual({ ...first, acknowledgeUnauthTunnel: true });
expect(target.showToast.mock.calls).toEqual([
['Public tunnel enabling — no password set ⚠️', 'warning'],
['Settings saved', 'success'],
['Tunnel starting — QR code will appear when ready...', 'info'],
]);
expect(target.closeAppSettings).toHaveBeenCalledTimes(1);
});
it('Apply: an accepted retry refreshes the groups that read the saved flags', async () => {
const { app, target, box } = await boot(
[envelope(403, 'Refusing to start a public tunnel with no login', 'OPERATION_FAILED'), OK],
'unreachable',
true
);
box('appSettingsTunnelEnabled').checked = true;
target.showToast.mockClear();
await app.applyAppSettings();
expect(target.showToast.mock.calls).toEqual([
['Public tunnel enabling — no password set ⚠️', 'warning'],
['Settings applied', 'success'],
['Tunnel starting — QR code will appear when ready...', 'info'],
]);
expect(target._refreshSettingsAfterApply).toHaveBeenCalledTimes(1);
expect(target.closeAppSettings).not.toHaveBeenCalled();
});
});
// ─── Background writes (vm harness: settings-ui.js + session-ui.js + respawn-ui.js + api-client.js) ───
function loadBackground(put: Answer) {
const storage = new Map<string, string>();
const elements: Record<string, any> = {
runModeMenu: { classList: { remove: () => {} } },
runBtn: { className: '', nextElementSibling: { className: '' } },
runBtnLabel: { textContent: '' },
welcomeTunnelBtn: { classList: { contains: (c: string) => c === 'active' }, disabled: false },
};
const CodemanApp = function CodemanApp(this: any) {};
const fetchMock = vi.fn((url: string, opts: { method?: string } = {}) =>
url === '/api/settings' && opts.method === 'PUT' ? respond(put) : respond(OK)
);
const context = vm.createContext({
CodemanApp,
VoiceInput: {},
window: { __codemanUser: { username: 'admin', role: 'admin', multiUser: false } },
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
},
document: { getElementById: (id: string) => elements[id] ?? null, addEventListener: () => {} },
fetch: fetchMock,
console,
});
for (const [src, filename] of [
[SETTINGS_UI, 'settings-ui.js'],
[SESSION_UI, 'session-ui.js'],
[RESPAWN_UI, 'respawn-ui.js'],
[API_CLIENT, 'api-client.js'],
]) {
vm.runInContext(src, context, { filename });
}
const app = new (CodemanApp as any)();
app.showToast = vi.fn();
app._dismissTunnelConnecting = vi.fn();
app._updateWelcomeTunnelBtn = vi.fn();
app.closeTunnelPanel = vi.fn();
return { app, fetchMock, storage, elements };
}
/** Let the fire-and-forget PUT and its response handling settle. */
const settle = async () => {
for (let i = 0; i < 5; i++) await tick();
};
describe('the run mode and respawn preset writes', () => {
const failures: [string, Answer, string][] = [
['409 CONFLICT', CONFLICT, TORN],
['500 with a body that is not JSON', SERVER_ERROR, 'HTTP 500'],
['a dropped connection', 'unreachable', 'the server could not be reached'],
];
for (const [name, put, reason] of failures) {
it(`${name}: run mode warns with the reason and stays on this device`, async () => {
const b = loadBackground(put);
b.app.setRunMode('codex');
await settle();
expect(b.fetchMock).toHaveBeenCalledWith('/api/settings', expect.objectContaining({ method: 'PUT' }));
expect(b.app.showToast.mock.calls).toEqual([
warning(`Run mode saved on this device, not on the server: ${reason}`),
]);
expect(b.storage.get('codeman_runMode')).toBe('codex');
});
it(`${name}: respawn presets warn with the reason and stay on this device`, async () => {
const b = loadBackground(put);
b.app.saveRespawnPresets([
{ id: 'builtin', builtIn: true },
{ id: 'mine', name: 'mine' },
]);
await settle();
expect(b.app.showToast.mock.calls).toEqual([
warning(`Respawn presets saved on this device, not on the server: ${reason}`),
]);
expect(JSON.parse(b.storage.get('codeman-respawn-presets')!)).toEqual([{ id: 'mine', name: 'mine' }]);
});
}
it('a saved write says nothing', async () => {
const b = loadBackground(OK);
b.app.setRunMode('codex');
b.app.saveRespawnPresets([{ id: 'mine' }]);
await settle();
expect(b.fetchMock).toHaveBeenCalledTimes(2);
expect(b.app.showToast).not.toHaveBeenCalled();
});
it('a 403 FORBIDDEN stays the silent admin-only refusal that makes the page read-only', async () => {
const b = loadBackground(FORBIDDEN);
b.app.setRunMode('codex');
await settle();
expect(b.app.showToast).not.toHaveBeenCalled();
expect(b.app._settingsReadOnly()).toBe(true);
});
});
describe('stopping the tunnel (welcome screen and tunnel panel)', () => {
it('a stop the server did not store says the tunnel was not stopped, and leaves it showing on', async () => {
for (const put of [CONFLICT, SERVER_ERROR]) {
const reason = put === CONFLICT ? TORN : 'HTTP 500';
const welcome = loadBackground(put);
await welcome.app.toggleTunnelFromWelcome();
expect(welcome.app.showToast.mock.calls).toEqual([errorToast(`Tunnel not stopped: ${reason}`)]);
expect(welcome.app._updateWelcomeTunnelBtn).not.toHaveBeenCalled();
expect(welcome.app._dismissTunnelConnecting).not.toHaveBeenCalled();
expect(welcome.elements.welcomeTunnelBtn.disabled).toBe(false);
const panel = loadBackground(put);
await panel.app._tunnelPanelToggle(false);
expect(panel.app.showToast.mock.calls).toEqual([errorToast(`Tunnel not stopped: ${reason}`)]);
expect(panel.app.closeTunnelPanel).toHaveBeenCalledTimes(1);
}
});
it('a stored stop still says "Tunnel stopped"', async () => {
const welcome = loadBackground(OK);
await welcome.app.toggleTunnelFromWelcome();
expect(welcome.app.showToast.mock.calls).toEqual([['Tunnel stopped', 'info']]);
expect(welcome.app._updateWelcomeTunnelBtn).toHaveBeenCalledWith(false);
const panel = loadBackground(OK);
await panel.app._tunnelPanelToggle(false);
expect(panel.app.showToast.mock.calls).toEqual([['Tunnel stopped', 'info']]);
expect(panel.app.closeTunnelPanel).toHaveBeenCalledTimes(1);
});
});
// ─── zh-CN ───
describe('the new toasts in zh-CN', () => {
type Api = { t(s: string): string; configure(o: Record<string, string>): void };
function translator(language: string) {
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
runScripts: 'outside-only',
url: 'http://localhost/',
});
windows.push(dom.window);
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
api.configure({ language });
return api;
}
it('translates each, keeps the reason as it came, and leaves English as it is', () => {
const zh = translator('zh-CN');
const en = translator('en');
const cases: [string, string][] = [
[`Saved on this device, not on the server: ${TORN}`, `已保存到本设备,但未保存到服务器:${TORN}`],
[
'Run mode saved on this device, not on the server: HTTP 500',
'运行模式已保存到本设备,但未保存到服务器:HTTP 500',
],
[
'Respawn presets saved on this device, not on the server: the server could not be reached',
'重生预设已保存到本设备,但未保存到服务器:无法连接到服务器',
],
[
'Saved on this device, not on the server: the server could not be reached',
'已保存到本设备,但未保存到服务器:无法连接到服务器',
],
[`Tunnel not stopped: ${TORN}`, `隧道未停止:${TORN}`],
];
for (const [english, chinese] of cases) {
expect(zh.t(english)).toBe(chinese);
expect(en.t(english)).toBe(english);
}
});
});
+50
View File
@@ -28,6 +28,32 @@ const LONG =
'/mnt/c/Users/someone/OneDrive - Contoso/01_Projects/Other/kb/out/deliverables/fy27-messaging-market-story.docx';
describe('spaced file paths', () => {
it('keeps a parenthesised configuration filename whole inside a sentence', () => {
const path = '/mnt/user/Scratch/Kobo/Set-KoboCovers/Kobo eReader.conf';
const text = `Prepared a corrected configuration (${path}).`;
expect(findFilePathLinks(text)).toEqual([{ path, index: text.indexOf(path) }]);
});
it('keeps separate parenthesised paths and the surrounding prose separate', () => {
const first = '/mnt/data/Kobo/Kobo eReader.conf';
const second = '/home/me/My Docs/notes.md';
expect(paths(`Updated (${first}) and (${second}).`)).toEqual([first, second]);
expect(paths('Do not link (/etc/private files/settings.conf).')).toEqual([]);
expect(paths('Do not link (/mnt/data/../private files/settings.conf).')).toEqual([]);
});
it('does not take a comma-separated list inside parentheses as one spaced filename', () => {
expect(paths('Updated (/home/me/proj, config.json)')).toEqual(['/home/me/proj', 'config.json']);
});
it.each([
['/mnt/c/Users/me/Smith, John/report.docx', '/mnt/c/Users/me/Smith, John/report.docx'],
['- /home/me/Clients/Acme, Inc/contract.pdf', '/home/me/Clients/Acme, Inc/contract.pdf'],
['Saved to: /home/me/proj/Invoice 12, final.pdf', '/home/me/proj/Invoice 12, final.pdf'],
])('keeps commas in a non-parenthesised spaced path: %s', (line, path) => {
expect(findFilePathLinks(line)).toEqual([{ path, index: line.indexOf(path) }]);
});
it('links an own-line absolute path with spaces whole', () => {
expect(paths(LONG)).toEqual([LONG]);
expect(paths('/mnt/c/proj/My Big Docs/report.docx')).toEqual(['/mnt/c/proj/My Big Docs/report.docx']);
@@ -426,4 +452,28 @@ describe('terminal link provider with spaced paths', () => {
it('links nothing for an unlinkable spaced path', () => {
expect(linksFor('/etc/secret files/passwords.txt').links).toEqual([]);
});
// `Bash(` holds a command, so its parentheses bound no spaced path: master links the file
// (and the folder the executable sits in), never the whole command. The path tools do
// hold a path, a spaced filename included.
it.each([
['Bash(/tmp/a.out input.txt)', [{ path: 'input.txt' }]],
[
'Bash(/home/me/proj/build/mytool convert input.json)',
[{ path: '/home/me/proj/build/mytool', folder: true }, { path: 'input.json' }],
],
[
'Bash(/home/me/proj/target/release/app config.toml)',
[{ path: '/home/me/proj/target/release/app', folder: true }, { path: 'config.toml' }],
],
])('links %s the way master does, not as the whole command', (line, linked) => {
const found = findFilePathLinks(line).map(({ path, folder }) => (folder ? { path, folder } : { path }));
expect(found).toEqual(linked);
expect(found.map((hit) => hit.path)).not.toContain(line.slice(line.indexOf('(') + 1, -1));
});
it.each(['Read', 'Update', 'Write', 'Edit', 'NotBash'])('takes a spaced filename in %s( whole', (tool) => {
const path = '/mnt/data/Kobo/Kobo eReader.conf';
expect(paths(`${tool}(${path})`)).toEqual([path]);
});
});
+119 -4
View File
@@ -133,13 +133,128 @@ describe('L-shaped header tab strip (real Chromium)', () => {
await p.close();
});
it('keeps the plain wrap when the first tab is too wide for the room beside the toolbar', async () => {
/** Give tab `n` (1-based) a name long enough to be wider than the room beside the toolbar. */
const longName = (n: number) =>
p.evaluate((n) => {
const tab = document.querySelectorAll<HTMLElement>('#sessionTabs > .session-tab')[n - 1];
const name = tab.querySelector<HTMLElement>('.tab-name')!;
name.textContent = 'a-very-long-session-name '.repeat(12);
}, n);
/** Is the first tab beside the toolbar (not dropped below it), and does it carry the trim? */
const firstTab = () =>
p.evaluate(() => {
const tab = document.querySelector('#sessionTabs > .session-tab')!;
const r = tab.getBoundingClientRect();
const b = document.getElementById('headerRight')!.getBoundingClientRect();
return {
besideToolbar: r.top < b.bottom && r.right <= b.left,
trimmed: tab.classList.contains('tab-corner-fit'),
};
});
/** Each visible tab's top, in strip order: equal tops share a row. */
const rowTops = async () => (await visibleTabs()).map((r) => Math.round(r.top));
it('trims a first tab too wide for the room beside the toolbar instead of dropping the corner', async () => {
await open({ tabs: 40 });
await longName(1);
await relayout();
expect(await cornered()).toBe(true);
expect(await overlapping()).toEqual([]);
// Beside the toolbar, not dropped a row below it.
const [first] = await visibleTabs();
const b = await toolbar();
expect(first.right).toBeLessThanOrEqual(b.left);
expect(first.top).toBeLessThan(b.bottom);
// The rows below still run the full width, several tabs to a row.
const tops = await rowTops();
expect(tops[1]).toBe(tops[2]);
await p.close();
});
it('keeps the corner whichever tab is selected, though selecting widens the first tab past the room', async () => {
await open({ tabs: 40 });
// Real selection: the gear and close show only on the active tab. The first
// tab's name is grown until it fits the room unselected but not selected.
await p.evaluate(() => {
for (const tab of document.querySelectorAll('#sessionTabs > .session-tab')) {
tab.insertAdjacentHTML('beforeend', '<span class="tab-gear">⚙</span><span class="tab-close">×</span>');
}
});
await relayout();
await p.evaluate(() => {
const strip = document.getElementById('sessionTabs')!;
const room = parseFloat(strip.style.getPropertyValue('--tab-corner-first-max'));
const tab = strip.querySelector<HTMLElement>(':scope > .session-tab')!;
const name = tab.querySelector('.tab-name')!;
while (tab.offsetWidth < room - 10) name.textContent += 'x';
});
const select = async (n: number) => {
await p.evaluate((n) => {
document
.querySelectorAll('#sessionTabs > .session-tab')
.forEach((tab, i) => tab.classList.toggle('active', i === n - 1));
}, n);
await relayout();
return { cornered: await cornered(), overlapping: await overlapping(), first: await firstTab() };
};
expect(await select(3)).toEqual({
cornered: true,
overlapping: [],
first: { besideToolbar: true, trimmed: true },
});
expect(await select(1)).toEqual({ cornered: true, overlapping: [], first: { besideToolbar: true, trimmed: true } });
expect(await select(3)).toEqual({
cornered: true,
overlapping: [],
first: { besideToolbar: true, trimmed: true },
});
// selectSession() widens the tab at once and the corner sync is debounced: the
// cap has to be on already, or the first row drops below the toolbar until then.
await p.evaluate(() => {
document
.querySelectorAll('#sessionTabs > .session-tab')
.forEach((tab, i) => tab.classList.toggle('active', i === 0));
});
expect(await firstTab()).toEqual({ besideToolbar: true, trimmed: true });
await p.close();
});
it('re-trims the first tab when the room grows but is still too narrow for it', async () => {
await open({ tabs: 40 });
await longName(1);
// One pass must get it right: the toolbar's ResizeObserver would run a
// second one and hide a first pass that read the last trim as the width.
await p.evaluate(() => (window as any).__app._tabCornerObserver?.disconnect());
const toolbarWidth = async (px: string | null) => {
await p.evaluate((px) => {
const root = document.documentElement;
if (px === null) {
root.removeAttribute('data-header-right-width');
root.style.removeProperty('--header-right-width');
} else {
root.setAttribute('data-header-right-width', 'set');
root.style.setProperty('--header-right-width', px);
}
}, px);
await relayout();
};
await toolbarWidth('500px');
expect(await firstTab()).toEqual({ besideToolbar: true, trimmed: true });
await toolbarWidth('300px');
expect(await cornered()).toBe(true);
expect(await firstTab()).toEqual({ besideToolbar: true, trimmed: true });
expect(await overlapping()).toEqual([]);
await p.close();
});
it('keeps the plain wrap when the toolbar leaves no usable room beside it', async () => {
// Few enough that the strip does not scroll: at 40 the scroll fallback turns the
// corner off before the room check is reached, and this would pass at any threshold.
await open({ tabs: 8 });
await p.evaluate(() => {
const head = document.querySelector('.header')!.getBoundingClientRect();
const right = document.getElementById('headerRight')!.getBoundingClientRect();
const first = document.querySelector<HTMLElement>('#sessionTabs > .session-tab')!;
first.style.minWidth = first.style.width = `${Math.round(head.width - right.width / 2)}px`;
const right = document.getElementById('headerRight')!;
right.style.minWidth = `${Math.round(head.width - 150)}px`;
});
await relayout();
expect(await cornered()).toBe(false);
+3 -1
View File
@@ -236,7 +236,9 @@ describe.skipIf(!hasTmux)('TUI M-1..M-9 switch only the attaching client', () =>
await client.unbindSwitchKey('M-2', tty);
await client.disarmTuiSwitchGuard(tty);
expect(run(['list-keys', '-T', 'root'])).not.toContain(`switch-client -t ${beta}`);
expect(() => run(['show-options', '-gv', '@codeman-tui-pid'])).toThrow();
// The listing, not `show-options -gv`: an unset user option is an error on
// tmux 3.5 but empty output and exit 0 on 3.2a.
expect(run(['show-options', '-g'])).not.toContain('@codeman-tui-pid');
// A hook armed for a tty this one is a prefix of (pts/4 vs pts/41) is
// another TUI's, and this tty's disarm must leave it.
+133
View File
@@ -0,0 +1,133 @@
/** Real xterm clicks resolve the complete filename or report its lookup error from either wrapped row. */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser } from 'playwright';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const basePath = '/mnt/user/Scratch/Kobo/Set-KoboCovers/Kobo eReader.conf';
describe('wrapped configuration file links (Chromium)', () => {
let browser: Browser;
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
});
afterAll(async () => {
await browser.close();
});
it.each([
{ wrap: 'soft', originalExists: false, extension: 'conf', registrationStatus: 200, prefix: '' },
{ wrap: 'hard', originalExists: false, extension: 'conf', registrationStatus: 200, prefix: '' },
{ wrap: 'hard', originalExists: true, extension: 'conf', registrationStatus: 200, prefix: '' },
{ wrap: 'hard', originalExists: true, extension: 'svg', registrationStatus: 400, prefix: '' },
{ wrap: 'hard', originalExists: true, extension: 'markdown', registrationStatus: 400, prefix: '' },
{ wrap: 'hard', originalExists: false, extension: 'svg', registrationStatus: 400, prefix: '' },
{ wrap: 'hard', originalExists: false, extension: 'conf', registrationStatus: 404, prefix: '' },
{ wrap: 'hard', originalExists: false, extension: 'markdown', registrationStatus: 200, prefix: 'Path: ' },
])(
'handles .$extension links on both rows: $wrap, original exists: $originalExists, prefix: $prefix',
async ({ wrap, originalExists, extension, registrationStatus, prefix }) => {
const path = basePath.replace('.conf', `.${extension}`);
const original = path.replace('Kobo eReader', 'KoboeReader');
const expected = wrap === 'hard' && (originalExists || registrationStatus === 404) ? original : path;
const lookupFails = registrationStatus === 400 && !originalExists;
const lookups: string[] = [];
const page = await browser.newPage({ viewport: { width: 1920, height: 600 } });
try {
await page.route('http://fixture.test/', (route) =>
route.fulfill({
contentType: 'text/html',
body: '<div id="terminal"></div>',
})
);
await page.route('http://fixture.test/api/sessions/fixture/attachments', async (route) => {
const body = route.request().postDataJSON();
expect(body.notify).toBe(false);
lookups.push(body.path);
const exists = body.path === path || (originalExists && body.path === original);
const status = exists ? registrationStatus : 404;
await route.fulfill({
status,
json:
status === 200
? { success: true, data: { attachmentId: 'att-fixture' } }
: { success: false, error: status === 400 ? 'Unsupported attachment type' : 'File not found' },
});
});
await page.goto('http://fixture.test/');
await page.addStyleTag({ path: resolve(publicDir, '../../../node_modules/@xterm/xterm/css/xterm.css') });
await page.addScriptTag({ path: resolve(publicDir, '../../../node_modules/@xterm/xterm/lib/xterm.js') });
await page.addScriptTag({ content: 'window.CodemanApp = class CodemanApp {};' });
for (const file of ['constants.js', 'terminal-ui.js', 'panels-ui.js']) {
await page.addScriptTag({ content: readFileSync(resolve(publicDir, file), 'utf8') });
}
await page.evaluate(
async ({ path, wrap, workingDir, prefix }) => {
const w = window as any;
const boundary = path.lastIndexOf(' ');
const first = `Found it: prepared a corrected configuration (${prefix}${path.slice(0, boundary)}`;
const terminal = new w.Terminal({ cols: first.length + (wrap === 'soft' ? 1 : 20), rows: 6 });
terminal.open(document.getElementById('terminal'));
w.opened = [];
w.app = Object.assign(new w.CodemanApp(), {
terminal,
activeSessionId: 'fixture',
sessions: new Map([['fixture', { workingDir }]]),
openFilePreview: (file: string) => w.opened.push(file),
openLogViewerWindow: (file: string) => w.opened.push(`log:${file}`),
openFolderGrid: (file: string) => w.opened.push(`folder:${file}`),
showToast: (message: string) => w.opened.push(`error:${message}`),
});
w.app.registerFilePathLinkProvider();
const text = `${first}${wrap === 'soft' ? ' ' : '\r\n '}${path.slice(boundary + 1)}).`;
await new Promise<void>((done) => terminal.write(text, done));
await new Promise<void>((done) => requestAnimationFrame(() => requestAnimationFrame(() => done())));
},
{ path, wrap, workingDir: registrationStatus === 400 ? '/mnt/user/Scratch/Kobo' : '/workspace', prefix }
);
expect(lookups).toEqual([]);
for (const row of [0, 1]) {
const point = await page.evaluate(
async ({ path, row }) => {
const app = (window as any).app;
const terminal = app.terminal;
const links = await new Promise<any[]>((done) => app._terminalLinkProvider.provideLinks(row + 1, done));
const link = links.find((candidate) => candidate.text === path);
if (!link) throw new Error(`The complete filename is not linked on row ${row + 1}`);
const text = terminal.buffer.active.getLine(row).translateToString(true);
const col = row === 0 ? link.range.start.x : text.indexOf('eReader') + 1;
const box = terminal.element.querySelector('.xterm-screen').getBoundingClientRect();
return {
x: box.left + (col - 0.5) * (box.width / terminal.cols),
y: box.top + (row + 0.5) * (box.height / terminal.rows),
};
},
{ path: wrap === 'hard' ? original : path, row }
);
await page.mouse.move(point.x, point.y);
await page.waitForFunction(() => (window as any).app._linkHovered === true);
await page.mouse.click(point.x, point.y);
await page.waitForFunction((count) => (window as any).opened.length === count, row + 1);
const opened = await page.evaluate(() => (window as any).opened as string[]);
if (lookupFails) {
for (const message of opened) {
expect(message).toMatch(/^error:/);
expect(message).toContain(path);
expect(message).not.toContain('outside the session workspace');
}
} else {
expect(opened).toEqual(Array(row + 1).fill(expected));
}
await page.mouse.move(0, 0);
}
expect(lookups).toEqual(
wrap === 'soft' ? [] : originalExists ? [original, original] : [original, path, original, path]
);
} finally {
await page.close();
}
}
);
});