Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/__tests__/render-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ import {
const FIXTURES_DIR = path.join(__dirname, 'fixtures', 'render-parity');
const TRANSCRIPT_PATH = path.join(FIXTURES_DIR, 'transcript.jsonl');

// Exists and is not a git repository: git widgets fall back to "no git" and
// custom commands still inherit a valid working directory.
const NON_GIT_CWD = '/private/tmp';
// Exists on every platform and is not a git repository: git widgets fall back
// to "no git" and custom commands still inherit a valid working directory.
const NON_GIT_CWD = os.tmpdir();
// The cwd string carried in the golden payload (widgets print it verbatim).
const PAYLOAD_CWD = '/tmp/ccs-parity-fixture/project';

Expand Down
141 changes: 141 additions & 0 deletions src/daemon/__tests__/provider-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import {
describe,
expect,
it,
vi
} from 'vitest';

import {
RefreshGroup,
capMap
} from '../provider-scope';

describe('capMap', () => {
it('evicts oldest insertions beyond the cap', () => {
const map = new Map<string, number>();
map.set('a', 1);
map.set('b', 2);
map.set('c', 3);
capMap(map, 2);
expect([...map.keys()]).toEqual(['b', 'c']);
});

it('keeps a map at or below the cap untouched', () => {
const map = new Map<string, number>([['a', 1]]);
capMap(map, 4);
expect(map.size).toBe(1);
});
});

describe('RefreshGroup', () => {
it('dedups concurrent refreshes of the same key into one work run', async () => {
const group = new RefreshGroup();
const work = vi.fn((): Promise<string> => Promise.resolve('value'));

const first = group.refresh('git', work);
const second = group.refresh('git', work);

expect(work).toHaveBeenCalledTimes(1);
await expect(first.promise).resolves.toBe('value');
await expect(second.promise).resolves.toBe('value');
first.release();
second.release();
});

it('runs different keys independently', async () => {
const group = new RefreshGroup();
const work = vi.fn((): Promise<string> => Promise.resolve('ok'));

const a = group.refresh('cwd-a', work);
const b = group.refresh('cwd-b', work);
expect(group.size).toBe(2);

a.release();
await expect(a.promise).resolves.toBeDefined();
b.release();
await expect(b.promise).resolves.toBeDefined();
});

it('aborts the work when the last consumer releases before completion', async () => {
const group = new RefreshGroup();
let observedAbort = false;
const work = (signal: AbortSignal): Promise<string> => new Promise((resolve, reject) => {
signal.addEventListener('abort', () => {
observedAbort = true;
reject(new Error('aborted'));
});
});

const only = group.refresh('usage', work);
const outcome = only.promise.then(() => 'resolved', () => 'rejected');
only.release();
expect(observedAbort).toBe(true);
await expect(outcome).resolves.toBe('rejected');
// The settled job is unregistered on its own microtask.
await Promise.resolve();
expect(group.size).toBe(0);
});

it('does not abort while another consumer is still joined', async () => {
const group = new RefreshGroup();
const work = vi.fn((): Promise<string> => Promise.resolve('shared'));

const first = group.refresh('git', work);
const second = group.refresh('git', work);
first.release();

await expect(second.promise).resolves.toBe('shared');
second.release();
});

it('does not join a cancelled job: a later refresh starts fresh work', async () => {
const group = new RefreshGroup();
const resolvers: ((value: string) => void)[] = [];
const work = vi.fn((): Promise<string> => new Promise((resolve) => { resolvers.push(resolve); }));

const first = group.refresh('review', work);
first.release(); // Still pending -> aborts the job.
expect(work).toHaveBeenCalledTimes(1);

const second = group.refresh('review', work);
expect(work).toHaveBeenCalledTimes(2);
resolvers[1]?.('fresh');
await expect(second.promise).resolves.toBe('fresh');
second.release();
});

it('keeps the replacement job registered when the cancelled job settles late', async () => {
const group = new RefreshGroup();
const resolvers: ((value: string) => void)[] = [];
const work = vi.fn((): Promise<string> => new Promise((resolve) => { resolvers.push(resolve); }));

const first = group.refresh('review', work);
first.release(); // Aborts job 1 while pending.
group.refresh('review', work); // Replaces it in the group.

// Job 1 settles only after its replacement registered (#18 review):
// its cleanup must not unregister job 2.
resolvers[0]?.('late-first');
await new Promise(resolve => setTimeout(resolve, 0));

const third = group.refresh('review', work);
expect(work).toHaveBeenCalledTimes(2);
resolvers[1]?.('fresh');
await expect(third.promise).resolves.toBe('fresh');
third.release();
});

it('ignores a release after completion', async () => {
const group = new RefreshGroup();
const work = vi.fn((): Promise<string> => Promise.resolve('done'));

const only = group.refresh('git', work);
await only.promise;
only.release();
only.release();

const next = group.refresh('git', work);
await expect(next.promise).resolves.toBe('done');
next.release();
});
});
90 changes: 90 additions & 0 deletions src/daemon/__tests__/server-dedup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import {
afterEach,
describe,
expect,
it
} from 'vitest';

import type { LoadedSettings } from '../../utils/config';

import type { StartedTestDaemon } from './test-daemon';
import {
MODEL_ONLY_SETTINGS,
renderRequest,
startTestDaemon,
stopTestDaemon,
waitFor
} from './test-daemon';

// Render dedup at the daemon boundary (#18): identical display contexts join
// one in-flight render. Consumer-release cancellation semantics are covered
// deterministically in provider-scope.test.ts (RefreshGroup); the socket-level
// "last client left" wiring cannot be exercised under bun, which never
// surfaces client disconnects to node:http servers (verified against 1.3.13).

interface LoadSettingsGate {
loadSettings: (configPath: string) => Promise<LoadedSettings>;
readonly pending: number;
openAll: () => void;
}

function makeLoadSettingsGate(): LoadSettingsGate {
const resolvers: (() => void)[] = [];
return {
loadSettings: () => new Promise<LoadedSettings>((resolve) => {
resolvers.push(() => { resolve({ settings: { ...MODEL_ONLY_SETTINGS }, loadError: null }); });
}),
get pending() {
return resolvers.length;
},
openAll: () => {
while (resolvers.length > 0) {
resolvers.shift()?.();
}
}
};
}

const BODY_A = JSON.stringify({ model: { id: 'claude-dedup-a' }, cwd: '/tmp' });
const BODY_B = JSON.stringify({ model: { id: 'claude-dedup-b' }, cwd: '/tmp' });

describe('daemon render dedup (#18)', () => {
let started: StartedTestDaemon | undefined;

afterEach(async () => {
if (started) {
await stopTestDaemon(started);
started = undefined;
}
});

it('joins identical in-flight renders into one job', async () => {
const gate = makeLoadSettingsGate();
started = await startTestDaemon({ loadSettings: gate.loadSettings });

const first = renderRequest(started.daemon, started.daemon.token, BODY_A);
const second = renderRequest(started.daemon, started.daemon.token, BODY_A);
await waitFor(() => gate.pending === 1);
gate.openAll();

const [firstResponse, secondResponse] = await Promise.all([first, second]);
expect(firstResponse.status).toBe(200);
expect(secondResponse.text).toBe(firstResponse.text);
expect(started.daemon.counters.deduped).toBe(1);
// One render ran: one invocation snapshot built.
expect(started.dependencies.invocations).toHaveLength(1);
});

it('does not join renders with different payloads', async () => {
started = await startTestDaemon({});

const [firstResponse, secondResponse] = await Promise.all([
renderRequest(started.daemon, started.daemon.token, BODY_A),
renderRequest(started.daemon, started.daemon.token, BODY_B)
]);
expect(firstResponse.status).toBe(200);
expect(secondResponse.status).toBe(200);
expect(started.daemon.counters.deduped ?? 0).toBe(0);
expect(started.dependencies.invocations).toHaveLength(2);
});
});
13 changes: 10 additions & 3 deletions src/daemon/__tests__/test-daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,19 @@ export function hermeticDependencies(overrides: Partial<DaemonDependencies> = {}
});
return {
loadSettings,
resolveTerminalWidth: () => 120,
buildInvocation: (context, terminalWidth) => {
resolveTerminalWidth: (_sessionId, _ttlSeconds, env) => {
// Honor the request env the way the production dependency does:
// an explicit override wins, otherwise a fixed width.
if (env.CCSTATUSLINE_WIDTH !== undefined) {
return Number.parseInt(env.CCSTATUSLINE_WIDTH, 10) || 120;
}
return 120;
},
buildInvocation: (context, terminalWidth, env) => {
const invocation: RenderInvocation = {
configPath: '/tmp/hermetic-settings.json',
cwd: context.cwd ?? '/tmp',
env: { ...process.env },
env,
terminalWidth
};
invocations.push(invocation);
Expand Down
Loading
Loading