mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
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:
commit
35ff4e886c
138 files changed
+17441
-502
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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, '');
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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()');
|
||||
});
|
||||
});
|
||||
@@ -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$/);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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}="([^"]*)"`));
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 \} : \{\}\),/);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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-'));
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 ==========
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -95,6 +95,7 @@ const REAL = new Set([
|
||||
'_keepServerSyncedSettings',
|
||||
'_displaySettingKeySet',
|
||||
'_noteSettingsWriteRefusal',
|
||||
'_settingsWriteError',
|
||||
'_applyOwnSettings',
|
||||
'_readOwnSettings',
|
||||
'_ownSettingsKey',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user