kimi-code/packages/kap-server/test/sessionEventJournal.test.ts

121 lines
3.7 KiB
TypeScript

/**
* `SessionEventJournal` — seq assignment, durability, recovery, epoch rotation.
*/
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
type EventEnvelope,
SessionEventJournal,
} from '../src/transport/ws/v1/sessionEventJournal';
function envelope(seq: number): EventEnvelope {
return {
type: 'turn.started',
seq,
timestamp: new Date().toISOString(),
payload: { seq },
};
}
describe('SessionEventJournal', () => {
let dir: string;
let filePath: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'kimi-journal-test-'));
filePath = join(dir, 'sess_1.jsonl');
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it('assigns monotonic seq and reads back in order', async () => {
const j = await SessionEventJournal.open(filePath);
expect(j.epoch).toMatch(/^ep_/);
expect(j.seq).toBe(0);
j.append(j.nextSeq(), envelope(1));
j.append(j.nextSeq(), envelope(2));
j.append(j.nextSeq(), envelope(3));
expect(j.seq).toBe(3);
const all = await j.readSince(0, 100);
expect(all.map((e) => e.seq)).toEqual([1, 2, 3]);
await j.close();
});
it('recovers seq and epoch across reopen', async () => {
const j1 = await SessionEventJournal.open(filePath);
const epoch = j1.epoch;
j1.append(j1.nextSeq(), envelope(1));
j1.append(j1.nextSeq(), envelope(2));
await j1.close();
const j2 = await SessionEventJournal.open(filePath);
expect(j2.epoch).toBe(epoch);
expect(j2.seq).toBe(2);
expect(j2.nextSeq()).toBe(3);
await j2.close();
});
it('rotates to a fresh epoch when the header is corrupt', async () => {
const j1 = await SessionEventJournal.open(filePath);
const epoch = j1.epoch;
j1.append(j1.nextSeq(), envelope(1));
await j1.close();
// Corrupt the file: overwrite with a garbage first line (no header).
await writeFile(filePath, 'this is not json\n', 'utf8');
const j2 = await SessionEventJournal.open(filePath);
expect(j2.epoch).toMatch(/^ep_/);
expect(j2.epoch).not.toBe(epoch);
expect(j2.seq).toBe(0);
await j2.close();
});
it('readSince honors the exclusive lower bound and the limit', async () => {
const j = await SessionEventJournal.open(filePath);
for (let i = 1; i <= 5; i++) j.append(j.nextSeq(), envelope(i));
const page = await j.readSince(2, 2);
expect(page.map((e) => e.seq)).toEqual([3, 4]);
await j.close();
});
it('readSince on a missing file returns empty', async () => {
const j = await SessionEventJournal.open(filePath);
const out = await j.readSince(0, 100);
expect(out).toEqual([]);
await j.close();
});
it('flushes appends that arrive while a flush is in flight', async () => {
const j = await SessionEventJournal.open(filePath);
// The first append starts an in-flight flush; the rest land in the same
// synchronous burst (while it runs) and must be chained into a follow-up
// round — not parked until a later append or `close()`.
for (let i = 1; i <= 12; i++) j.append(j.nextSeq(), envelope(i));
// Poll the raw file: `readSince`/`close` force a flush themselves and
// would mask a missing chained round.
const deadline = Date.now() + 2000;
let lines = 0;
while (Date.now() < deadline) {
try {
lines = (await readFile(filePath, 'utf8')).trim().split('\n').length;
} catch {
lines = 0;
}
if (lines >= 13) break; // header + 12 events
await new Promise((resolve) => setTimeout(resolve, 10));
}
expect(lines).toBe(13);
await j.close();
});
});