Files
makelore/tests/unit/coding-chat-pressure.test.tsx

249 lines
9.6 KiB
TypeScript

import { act, render } from '@testing-library/react';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { Profiler } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { HostApiContext } from '../../electron/api/context';
import { handleCodingConversationRoutes } from '../../electron/api/routes/coding-conversations';
import { CodingProjectService } from '../../electron/coding-projects/project-service';
import { createCodingProjectAgent } from '../../electron/coding-projects/project-config';
import {
createCodingProjectStore,
createLocalCodingProject,
createMemoryCodingProjectStorage,
} from '../../electron/coding-projects/project-store';
import { CodingConversationService } from '../../electron/coding-runtime/conversation-service';
import { InMemoryConversationRuntime } from '../../electron/coding-runtime/in-memory-conversation-runtime';
import type {
CodingConversationPatchBatchEvent,
CodingConversationSnapshotEvent,
ConversationPatch,
ConversationPatchEnvelope,
} from '../../electron/coding-runtime/contracts';
import { CodingConversationTimeline } from '../../src/pages/Chat/CodingConversationTimeline';
import { codingConversationStore } from '../../src/stores/coding-conversations';
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
function percentile95(values: number[]): number {
const ordered = [...values].sort((left, right) => left - right);
return ordered[Math.ceil(ordered.length * 0.95) - 1] ?? Number.POSITIVE_INFINITY;
}
function summarize(values: number[]): { samples: number; p50: number; p95: number; max: number } {
const ordered = [...values].sort((left, right) => left - right);
return {
samples: ordered.length,
p50: ordered[Math.ceil(ordered.length * 0.5) - 1] ?? Number.POSITIVE_INFINITY,
p95: percentile95(ordered),
max: ordered.at(-1) ?? Number.POSITIVE_INFINITY,
};
}
function pressurePatch(seq: number): ConversationPatch {
if (seq === 1) {
return {
op: 'message.upsert',
node: {
kind: 'message',
id: 'assistant-pressure',
role: 'assistant',
status: 'streaming',
blocks: [
{ kind: 'text', id: 'answer-pressure', text: 'Mixed answer', status: 'complete' },
{ kind: 'thinking', id: 'thinking-pressure', text: '', status: 'streaming' },
],
},
};
}
if (seq === 2) {
return {
op: 'tool.upsert',
node: {
kind: 'tool',
id: 'tool-pressure',
toolCallId: 'call-pressure',
toolName: 'read',
title: 'Pressure fixture tool output',
inputText: 'fixture.txt',
status: 'complete',
output: [{
kind: 'text',
id: 'tool-pressure-output',
text: 'T'.repeat(4 * 1024),
status: 'complete',
}],
},
};
}
return {
op: 'message.block-delta',
messageId: 'assistant-pressure',
blockId: 'thinking-pressure',
delta: `${seq}:`.padEnd(1_100, 'x'),
};
}
describe('REN-008 coding timeline pressure', () => {
it('batches 100 KB of mixed output and keeps Main-to-React p95 within 50 ms', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-pressure-'));
roots.push(root);
const projectStore = createCodingProjectStore(createMemoryCodingProjectStorage(), {
createId: () => 'project-pressure',
now: () => '2026-08-24T00:00:00.000Z',
});
await createLocalCodingProject({
projectPath: root,
now: '2026-08-24T00:00:00.000Z',
}, projectStore);
await createCodingProjectAgent(root, {
id: 'builder',
avatarId: 'avatar-01',
roleName: '实现者',
name: 'Builder',
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
modelResolution: 'resolved',
responsibility: {
mission: 'Implement', owns: [], boundaries: [], collaborators: [], principles: [],
},
}, { now: '2026-08-24T00:00:00.000Z' });
const runtime = new InMemoryConversationRuntime();
const projects = new CodingProjectService(projectStore);
const conversations = new CodingConversationService(projects, runtime, {
deliveryBatchWindowMs: 24,
});
const conversation = await conversations.createConversation({
agentId: 'builder',
title: 'Pressure',
});
let publish: ((event: ConversationPatchEnvelope) => void) | undefined;
vi.spyOn(runtime, 'subscribe').mockImplementation((listener) => {
publish = listener;
return () => undefined;
});
const context = {
codingProducts: { projects, conversations, runtime },
} as unknown as HostApiContext;
const server = createServer((request, response) => {
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
void handleCodingConversationRoutes(request, response, url, context);
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Pressure server did not bind');
const controller = new AbortController();
const response = await fetch(
`http://127.0.0.1:${address.port}/api/coding/events?conversationId=${conversation.id}`,
{ signal: controller.signal },
);
const reader = response.body?.getReader();
if (!reader) throw new Error('Pressure SSE response has no body');
const decoder = new TextDecoder();
const encoder = new TextEncoder();
let buffered = '';
let wireBytes = 0;
let sseFrames = 0;
const nextFrame = async (): Promise<{ event: string; data: unknown }> => {
while (!buffered.includes('\n\n')) {
const next = await reader.read();
if (next.done) throw new Error('Pressure SSE ended early');
buffered += decoder.decode(next.value, { stream: true });
}
const boundary = buffered.indexOf('\n\n');
const frame = buffered.slice(0, boundary);
buffered = buffered.slice(boundary + 2);
wireBytes += encoder.encode(`${frame}\n\n`).byteLength;
sseFrames += 1;
const event = frame.split('\n').find((line) => line.startsWith('event: '))?.slice(7) ?? '';
const data = frame.split('\n').find((line) => line.startsWith('data: '))?.slice(6) ?? 'null';
return { event, data: JSON.parse(data) as unknown };
};
try {
const initial = await nextFrame();
expect(initial.event).toBe('snapshot');
codingConversationStore.getState().applySnapshotEvent(
initial.data as CodingConversationSnapshotEvent,
);
let rendererTransactions = 0;
const unsubscribe = codingConversationStore.subscribe(() => {
rendererTransactions += 1;
});
let reactCommits = 0;
const view = render(
<Profiler id="pressure-timeline" onRender={() => { reactCommits += 1; }}>
<CodingConversationTimeline conversationId={conversation.id} />
</Profiler>,
);
const initialCommits = reactCommits;
if (!publish) throw new Error('Pressure runtime subscriber was not installed');
const latencies: number[] = [];
let runtimePatchItems = 0;
let patchBatches = 0;
for (let burst = 0; burst < 20; burst += 1) {
const startedAt = performance.now();
for (let offset = 0; offset < 5; offset += 1) {
const seq = burst * 5 + offset + 1;
runtimePatchItems += 1;
publish({
conversationId: conversation.id,
workerGeneration: 0,
seq,
at: Date.now(),
patch: pressurePatch(seq),
});
}
const frame = await nextFrame();
expect(frame.event).toBe('patch-batch');
const batch = frame.data as CodingConversationPatchBatchEvent;
patchBatches += 1;
expect(batch.items).toHaveLength(5);
await act(async () => {
codingConversationStore.getState().applyPatchBatchEvent(batch);
});
latencies.push(performance.now() - startedAt);
}
const measuredReactCommits = reactCommits - initialCommits;
const mainToReactMs = summarize(latencies);
const metrics = {
runtimePatchItems,
patchBatches,
sseFrames,
rendererTransactions,
reactCommits: measuredReactCommits,
wireBytes,
mainToReactMs,
mainToReactP95Ms: mainToReactMs.p95,
};
console.info('REN-008 metrics', metrics);
expect(metrics.runtimePatchItems).toBe(100);
expect(metrics.wireBytes).toBeGreaterThan(100 * 1024);
expect(metrics.patchBatches).toBeLessThan(metrics.runtimePatchItems);
expect(metrics.rendererTransactions).toBe(metrics.patchBatches);
expect(metrics.reactCommits).toBeLessThan(metrics.runtimePatchItems);
expect(metrics.sseFrames).toBe(metrics.patchBatches + 1);
expect(metrics.mainToReactP95Ms).toBeLessThanOrEqual(50);
expect(codingConversationStore.getState()
.entriesByConversationId[conversation.id]?.reducer.snapshot?.cursor.seq).toBe(100);
view.unmount();
unsubscribe();
const reportPath = process.env.MAKELORE_PI_PERF_PRESSURE_FRAGMENT;
if (reportPath) await writeFile(reportPath, `${JSON.stringify(metrics, null, 2)}\n`);
} finally {
controller.abort();
await reader.cancel().catch(() => undefined);
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});