132 lines
6.1 KiB
TypeScript
132 lines
6.1 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import { createHash } from 'node:crypto';
|
|
import { topicEvents } from '../server/utils/events';
|
|
import { createTopicSnapshotStream } from '../server/utils/topic-snapshot-stream';
|
|
import { appendTextDelta, serializeTopicSnapshot } from '../utils/topic-sync';
|
|
|
|
const snapshot = () => ({
|
|
id: 'topic',
|
|
name: 'Example',
|
|
messages: [{
|
|
id: 'message',
|
|
activeChildId: null as string | null,
|
|
updatedAt: new Date('2026-09-27T00:00:00Z'),
|
|
generation: { status: 'pending', tokens: null as unknown },
|
|
parts: [{ id: 'part', type: 'text', content: 'hello', finished: false }],
|
|
}],
|
|
});
|
|
const fingerprint = (value: unknown) => createHash('sha256').update(serializeTopicSnapshot(value)).digest('hex');
|
|
const readEvent = async (reader: ReadableStreamDefaultReader<string>) => {
|
|
const { value } = await reader.read();
|
|
return JSON.parse(value!.slice(6));
|
|
};
|
|
|
|
describe('topic snapshot synchronization', () => {
|
|
test('matches database Dates and hydrated JSON regardless of object key order', () => {
|
|
const topic = snapshot();
|
|
const hydrated = JSON.parse(JSON.stringify(topic));
|
|
expect(fingerprint({ messages: hydrated.messages, name: hydrated.name, id: hydrated.id })).toBe(fingerprint(topic));
|
|
});
|
|
|
|
test('detects older-message, part, generation, variant, and topic changes without relying on count', () => {
|
|
const original = fingerprint(snapshot());
|
|
for (const change of [
|
|
(topic: ReturnType<typeof snapshot>) => topic.name = 'Renamed',
|
|
(topic: ReturnType<typeof snapshot>) => topic.messages[0]!.activeChildId = 'variant',
|
|
(topic: ReturnType<typeof snapshot>) => topic.messages[0]!.parts[0]!.content = 'edited',
|
|
(topic: ReturnType<typeof snapshot>) => topic.messages[0]!.parts[0]!.finished = true,
|
|
(topic: ReturnType<typeof snapshot>) => topic.messages[0]!.generation.tokens = { output: 123 },
|
|
]) {
|
|
const topic = snapshot();
|
|
change(topic);
|
|
expect(fingerprint(topic)).not.toBe(original);
|
|
}
|
|
});
|
|
|
|
test('matching state receives a small ready event, then live changes', async () => {
|
|
const topic = snapshot();
|
|
const subscription = createTopicSnapshotStream('match', fingerprint(topic));
|
|
const result = await subscription.open(async () => topic);
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
const reader = result.data.getReader();
|
|
try {
|
|
expect(await readEvent(reader)).toEqual({ type: 'synced', payload: { id: 'match' } });
|
|
await topicEvents.emit('match', { type: 'topic_updated', payload: { name: 'New' } });
|
|
expect((await readEvent(reader)).payload).toEqual({ name: 'New' });
|
|
} finally {
|
|
await reader.cancel();
|
|
}
|
|
});
|
|
|
|
test('stale or missing fingerprints receive a full replacement snapshot', async () => {
|
|
for (const offered of [undefined, fingerprint(snapshot())]) {
|
|
const topic = snapshot();
|
|
topic.messages[0]!.parts[0]!.content = 'changed';
|
|
const subscription = createTopicSnapshotStream('stale', offered);
|
|
const result = await subscription.open(async () => topic);
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) continue;
|
|
const reader = result.data.getReader();
|
|
try {
|
|
const event = await readEvent(reader);
|
|
expect(event.type).toBe('initial_state');
|
|
expect(event.payload.messages[0].parts[0].content).toBe('changed');
|
|
} finally {
|
|
await reader.cancel();
|
|
}
|
|
}
|
|
});
|
|
|
|
test('captures events during the DB query and includes text not yet persisted', async () => {
|
|
const id = 'query-race';
|
|
const initial = snapshot();
|
|
await topicEvents.emit(id, {
|
|
type: 'text-start', payload: { messageId: 'message', part: initial.messages[0]!.parts[0] },
|
|
});
|
|
// This delta predates subscription and has not reached the database yet.
|
|
await topicEvents.emit(id, {
|
|
type: 'text-delta', payload: { messageId: 'message', partId: 'part', content: ' world', offset: 5 },
|
|
});
|
|
const pending = Promise.withResolvers<ReturnType<typeof snapshot>>();
|
|
const subscription = createTopicSnapshotStream(id, fingerprint(initial));
|
|
const opening = subscription.open(() => pending.promise);
|
|
await topicEvents.emit(id, {
|
|
type: 'text-delta', payload: { messageId: 'message', partId: 'part', content: '!', offset: 11 },
|
|
});
|
|
pending.resolve(snapshot());
|
|
const result = await opening;
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
const reader = result.data.getReader();
|
|
try {
|
|
const state = await readEvent(reader);
|
|
expect(state.type).toBe('initial_state');
|
|
const text = state.payload.messages[0].parts[0].content;
|
|
expect(text).toBe('hello world!');
|
|
const replay = await readEvent(reader);
|
|
expect(replay.replay).toBe(true);
|
|
expect(appendTextDelta(text, replay.payload.content, replay.payload.offset)).toBe(text);
|
|
} finally {
|
|
await reader.cancel();
|
|
topicEvents.clearLiveText(id, 'message');
|
|
}
|
|
});
|
|
|
|
test('cancellation during snapshot loading does not establish a stream', async () => {
|
|
const pending = Promise.withResolvers<ReturnType<typeof snapshot>>();
|
|
const subscription = createTopicSnapshotStream('cancelled', undefined);
|
|
const opening = subscription.open(() => pending.promise);
|
|
subscription.close();
|
|
pending.resolve(snapshot());
|
|
expect((await opening).ok).toBe(false);
|
|
});
|
|
|
|
test('text offsets handle partial overlaps, duplicates, and gaps', () => {
|
|
expect(appendTextDelta('hello w', ' world', 5)).toBe('hello world');
|
|
expect(appendTextDelta('hello world', ' world', 5)).toBe('hello world');
|
|
expect(appendTextDelta('hello', '!', 5)).toBe('hello!');
|
|
expect(appendTextDelta('hello', '!', 10)).toBe('hello');
|
|
});
|
|
});
|