Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

179 lines
6.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, describe, expect, it, vi } from 'vitest';
// localStorage stub (tests run in the node environment; no jsdom).
const lsStore: Record<string, string> = {};
const localStorageStub = {
getItem: (k: string) => (k in lsStore ? lsStore[k] : null),
setItem: (k: string, v: string) => {
lsStore[k] = String(v);
},
removeItem: (k: string) => {
delete lsStore[k];
},
clear: () => {
for (const k of Object.keys(lsStore)) delete lsStore[k];
},
key: (i: number) => Object.keys(lsStore)[i] ?? null,
get length() {
return Object.keys(lsStore).length;
},
};
vi.stubGlobal('localStorage', localStorageStub);
vi.stubGlobal('window', { localStorage: localStorageStub });
// In-memory fake of the Dexie agentEditSessions table.
interface Row {
id: string;
stageId: string;
updatedAt: number;
[k: string]: unknown;
}
const rows = new Map<string, Row>();
vi.mock('@/lib/utils/database', () => ({
db: {
// Run the callback directly — the in-memory map is synchronous, so this
// faithfully serializes the read-compare-write the same way Dexie's rw tx does.
transaction: vi.fn(async (_mode: string, _table: unknown, fn: () => Promise<void>) => fn()),
agentEditSessions: {
put: vi.fn(async (r: Row) => void rows.set(r.id, { ...r })),
get: vi.fn(async (id: string) => (rows.has(id) ? { ...rows.get(id) } : undefined)),
delete: vi.fn(async (id: string) => void rows.delete(id)),
where: vi.fn((idx: string) => ({
equals: (val: string) => ({
toArray: async () =>
[...rows.values()].filter((r) => (idx === 'stageId' ? r.stageId === val : false)),
}),
})),
},
},
}));
import {
createSession,
saveSession,
loadSession,
listSessions,
deleteSession,
migrateLegacyThread,
rememberActiveSession,
recallActiveSession,
} from '@/lib/agent/client/agent-thread-store';
import { MAX_SESSIONS_PER_STAGE } from '@/lib/agent/client/agent-edit-session-types';
const userMsg = (text: string) => ({
role: 'user' as const,
content: [{ type: 'text' as const, text }],
});
beforeEach(() => {
rows.clear();
localStorage.clear();
});
describe('agent edit session store', () => {
it('createSession returns an empty record not yet persisted', async () => {
const s = createSession('stage-a');
expect(s.stageId).toBe('stage-a');
expect(s.messages).toEqual([]);
expect(await loadSession(s.id)).toBeUndefined();
});
it('saveSession persists and loadSession round-trips', async () => {
const s = { ...createSession('stage-a'), title: 't', messages: [userMsg('hi')] };
await saveSession(s);
expect(await loadSession(s.id)).toMatchObject({ id: s.id, title: 't' });
});
it('listSessions returns a stages sessions newest-first', async () => {
await saveSession({ ...createSession('stage-a'), updatedAt: 100, messages: [userMsg('a')] });
await saveSession({ ...createSession('stage-a'), updatedAt: 300, messages: [userMsg('b')] });
await saveSession({ ...createSession('stage-b'), updatedAt: 200, messages: [userMsg('c')] });
const list = await listSessions('stage-a');
expect(list.map((r) => r.updatedAt)).toEqual([300, 100]);
});
it('deleteSession removes only that record', async () => {
const s = { ...createSession('stage-a'), messages: [userMsg('a')] };
await saveSession(s);
await deleteSession(s.id);
expect(await loadSession(s.id)).toBeUndefined();
});
it('saveSession ignores a stale (older updatedAt) write so a newer save is not clobbered', async () => {
const s = createSession('stage-a');
await saveSession({ ...s, updatedAt: 300, messages: [userMsg('newer')] });
// A late, older in-flight save for the same session must not overwrite.
await saveSession({ ...s, updatedAt: 100, messages: [userMsg('older')] });
const loaded = await loadSession(s.id);
expect(loaded?.updatedAt).toBe(300);
expect(loaded?.messages).toEqual([userMsg('newer')]);
});
it('saveSession preserves the original createdAt on update', async () => {
const s = {
...createSession('stage-a'),
createdAt: 100,
updatedAt: 100,
messages: [userMsg('a')],
};
await saveSession(s);
await saveSession({ ...s, createdAt: 999, updatedAt: 500, messages: [userMsg('a edited')] });
const loaded = await loadSession(s.id);
expect(loaded?.createdAt).toBe(100);
expect(loaded?.updatedAt).toBe(500);
});
it('deleteSession tombstones the id so a late save cannot resurrect it', async () => {
const s = { ...createSession('stage-a'), messages: [userMsg('a')] };
await saveSession(s);
await deleteSession(s.id);
// A stale in-flight save for the just-deleted session must be a no-op.
await saveSession({ ...s, updatedAt: 777, messages: [userMsg('late')] });
expect(await loadSession(s.id)).toBeUndefined();
expect((await listSessions('stage-a')).length).toBe(0);
});
it('saveSession prunes oldest beyond MAX_SESSIONS_PER_STAGE', async () => {
for (let i = 0; i < MAX_SESSIONS_PER_STAGE + 5; i++) {
await saveSession({
...createSession('stage-a'),
updatedAt: i + 1,
messages: [userMsg(`m${i}`)],
});
}
const list = await listSessions('stage-a');
expect(list.length).toBe(MAX_SESSIONS_PER_STAGE);
expect(Math.min(...list.map((r) => r.updatedAt))).toBe(6);
});
it('migrateLegacyThread imports a non-empty localStorage thread once', async () => {
localStorage.setItem(
'maic-agent-threads',
JSON.stringify({
state: { threads: { 'stage-a': { messages: [userMsg('legacy')], updatedAt: 42 } } },
version: 1,
}),
);
const migrated = await migrateLegacyThread('stage-a');
expect(migrated?.messages).toEqual([userMsg('legacy')]);
expect((await listSessions('stage-a')).length).toBe(1);
// idempotent: second call does nothing
expect(await migrateLegacyThread('stage-a')).toBeUndefined();
expect((await listSessions('stage-a')).length).toBe(1);
});
it('migrateLegacyThread ignores empty/absent threads', async () => {
expect(await migrateLegacyThread('nope')).toBeUndefined();
});
it('remembers and recalls the active session id per stage', () => {
expect(recallActiveSession('stage-a')).toBeUndefined();
rememberActiveSession('stage-a', 'sess-1');
rememberActiveSession('stage-b', 'sess-2');
expect(recallActiveSession('stage-a')).toBe('sess-1');
expect(recallActiveSession('stage-b')).toBe('sess-2');
rememberActiveSession('stage-a', 'sess-3');
expect(recallActiveSession('stage-a')).toBe('sess-3');
});
});