agent-ecosystem/test/renderer/hooks/useComposerDraft.test.ts
iliya 9bfcbb182c feat: enhance UI and functionality in team dialogs and components
- Improved lightbox toolbar button hit targets for better accessibility.
- Updated ActivityItem and ActivityTimeline components to support managed collapse states for messages.
- Refactored message collapsing logic to allow for user-controlled expansion in various components.
- Enhanced CreateTeamDialog and LaunchTeamDialog with improved loading indicators and layout adjustments.
- Increased maximum message length in SendMessageDialog to accommodate larger inputs.
- Added icons and visual enhancements in ProjectPathSelector and EffortLevelSelector for better user experience.
2026-03-06 23:21:56 +02:00

437 lines
15 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock idb-keyval before importing composerDraftStorage
const store = new Map<string, unknown>();
vi.mock('idb-keyval', () => ({
get: vi.fn((key: string) => Promise.resolve(store.get(key) ?? undefined)),
set: vi.fn((key: string, value: unknown) => {
store.set(key, value);
return Promise.resolve();
}),
del: vi.fn((key: string) => {
store.delete(key);
return Promise.resolve();
}),
keys: vi.fn(() => Promise.resolve([...store.keys()])),
}));
import {
composerDraftStorage,
type ComposerDraftSnapshot,
} from '@renderer/services/composerDraftStorage';
function makeSnapshot(
teamName: string,
overrides?: Partial<ComposerDraftSnapshot>
): ComposerDraftSnapshot {
return {
version: 1,
teamName,
text: 'hello',
chips: [],
attachments: [],
updatedAt: Date.now(),
...overrides,
};
}
describe('composerDraftStorage', () => {
beforeEach(() => {
store.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('saveSnapshot / loadSnapshot', () => {
it('should save and load a snapshot', async () => {
const snap = makeSnapshot('team-a');
await composerDraftStorage.saveSnapshot('team-a', snap);
const result = await composerDraftStorage.loadSnapshot('team-a');
expect(result).toEqual(snap);
});
it('should return null for non-existent snapshot', async () => {
const result = await composerDraftStorage.loadSnapshot('nonexistent');
expect(result).toBeNull();
});
it('should overwrite existing snapshot', async () => {
const snap1 = makeSnapshot('team-a', { text: 'first' });
const snap2 = makeSnapshot('team-a', { text: 'second' });
await composerDraftStorage.saveSnapshot('team-a', snap1);
await composerDraftStorage.saveSnapshot('team-a', snap2);
const result = await composerDraftStorage.loadSnapshot('team-a');
expect(result?.text).toBe('second');
});
it('should NOT have TTL — drafts persist indefinitely', async () => {
const snap = makeSnapshot('team-a', {
updatedAt: Date.now() - 7 * 24 * 60 * 60 * 1000, // 7 days ago
});
await composerDraftStorage.saveSnapshot('team-a', snap);
const result = await composerDraftStorage.loadSnapshot('team-a');
expect(result).toEqual(snap);
});
});
describe('deleteSnapshot', () => {
it('should delete a snapshot', async () => {
const snap = makeSnapshot('team-a');
await composerDraftStorage.saveSnapshot('team-a', snap);
await composerDraftStorage.deleteSnapshot('team-a');
const result = await composerDraftStorage.loadSnapshot('team-a');
expect(result).toBeNull();
});
it('should not throw when deleting non-existent snapshot', async () => {
await expect(composerDraftStorage.deleteSnapshot('nonexistent')).resolves.toBeUndefined();
});
});
describe('team isolation', () => {
it('should isolate drafts by teamName', async () => {
const snapA = makeSnapshot('team-a', { text: 'from team A' });
const snapB = makeSnapshot('team-b', { text: 'from team B' });
await composerDraftStorage.saveSnapshot('team-a', snapA);
await composerDraftStorage.saveSnapshot('team-b', snapB);
const resultA = await composerDraftStorage.loadSnapshot('team-a');
const resultB = await composerDraftStorage.loadSnapshot('team-b');
expect(resultA?.text).toBe('from team A');
expect(resultB?.text).toBe('from team B');
});
it('deleting one team draft should not affect another', async () => {
await composerDraftStorage.saveSnapshot('team-a', makeSnapshot('team-a'));
await composerDraftStorage.saveSnapshot('team-b', makeSnapshot('team-b'));
await composerDraftStorage.deleteSnapshot('team-a');
expect(await composerDraftStorage.loadSnapshot('team-a')).toBeNull();
expect(await composerDraftStorage.loadSnapshot('team-b')).not.toBeNull();
});
});
describe('legacy migration', () => {
it('should migrate text from old draft:compose:<teamName> key', async () => {
// Simulate old storage format
store.set('draft:compose:my-team', { value: 'old text', timestamp: Date.now() });
const result = await composerDraftStorage.migrateLegacy('my-team');
expect(result).not.toBeNull();
expect(result!.text).toBe('old text');
expect(result!.teamName).toBe('my-team');
// Legacy keys should be deleted
expect(store.has('draft:compose:my-team')).toBe(false);
// New snapshot key should exist
const loaded = await composerDraftStorage.loadSnapshot('my-team');
expect(loaded?.text).toBe('old text');
});
it('should migrate chips from old draft:compose:<teamName>:chips key', async () => {
const chips = [
{
id: 'c1',
filePath: '/test/file.ts',
fileName: 'file.ts',
fromLine: 1,
toLine: 10,
codeText: 'code',
language: 'typescript',
},
];
store.set('draft:compose:my-team:chips', {
value: JSON.stringify(chips),
timestamp: Date.now(),
});
const result = await composerDraftStorage.migrateLegacy('my-team');
expect(result).not.toBeNull();
expect(result!.chips).toHaveLength(1);
expect(result!.chips[0].id).toBe('c1');
// Legacy key should be cleaned up
expect(store.has('draft:compose:my-team:chips')).toBe(false);
});
it('should migrate attachments from old draft:compose:<teamName>:attachments key', async () => {
const attachments = [
{
id: 'a1',
filename: 'test.png',
mimeType: 'image/png',
size: 1024,
data: 'base64data',
},
];
store.set('draft:compose:my-team:attachments', {
value: JSON.stringify(attachments),
timestamp: Date.now(),
});
const result = await composerDraftStorage.migrateLegacy('my-team');
expect(result).not.toBeNull();
expect(result!.attachments).toHaveLength(1);
expect(result!.attachments[0].id).toBe('a1');
});
it('should return null when no legacy data exists', async () => {
const result = await composerDraftStorage.migrateLegacy('nonexistent');
expect(result).toBeNull();
});
it('should combine all three legacy sources into one snapshot', async () => {
store.set('draft:compose:my-team', { value: 'combined text', timestamp: Date.now() });
store.set('draft:compose:my-team:chips', {
value: JSON.stringify([
{
id: 'c1',
filePath: '/f.ts',
fileName: 'f.ts',
fromLine: 1,
toLine: 2,
codeText: 'x',
language: 'ts',
},
]),
timestamp: Date.now(),
});
store.set('draft:compose:my-team:attachments', {
value: JSON.stringify([
{ id: 'a1', filename: 'img.png', mimeType: 'image/png', size: 512, data: 'b64' },
]),
timestamp: Date.now(),
});
const result = await composerDraftStorage.migrateLegacy('my-team');
expect(result).not.toBeNull();
expect(result!.text).toBe('combined text');
expect(result!.chips).toHaveLength(1);
expect(result!.attachments).toHaveLength(1);
// All legacy keys cleaned up
expect(store.has('draft:compose:my-team')).toBe(false);
expect(store.has('draft:compose:my-team:chips')).toBe(false);
expect(store.has('draft:compose:my-team:attachments')).toBe(false);
});
it('should clean up empty legacy keys without creating a snapshot', async () => {
store.set('draft:compose:my-team', { value: '', timestamp: Date.now() });
const result = await composerDraftStorage.migrateLegacy('my-team');
expect(result).toBeNull();
expect(store.has('draft:compose:my-team')).toBe(false);
});
});
describe('emptySnapshot', () => {
it('should create an empty snapshot for given teamName', () => {
const snap = composerDraftStorage.emptySnapshot('test-team');
expect(snap.teamName).toBe('test-team');
expect(snap.text).toBe('');
expect(snap.chips).toEqual([]);
expect(snap.attachments).toEqual([]);
expect(snap.version).toBe(1);
});
});
describe('invalid data handling', () => {
it('should return null and discard invalid snapshot data', async () => {
store.set('composer:bad-team', { garbage: true });
const result = await composerDraftStorage.loadSnapshot('bad-team');
expect(result).toBeNull();
// Invalid data should be deleted
expect(store.has('composer:bad-team')).toBe(false);
});
it('should discard snapshot missing required fields', async () => {
store.set('composer:partial', { version: 1, teamName: 'partial', text: 'hi' });
const result = await composerDraftStorage.loadSnapshot('partial');
expect(result).toBeNull();
expect(store.has('composer:partial')).toBe(false);
});
});
describe('clear-on-send flow', () => {
it('should delete snapshot and return null on next load', async () => {
const snap = makeSnapshot('team-send', { text: 'about to send' });
await composerDraftStorage.saveSnapshot('team-send', snap);
// Simulate clear-on-send
await composerDraftStorage.deleteSnapshot('team-send');
const afterClear = await composerDraftStorage.loadSnapshot('team-send');
expect(afterClear).toBeNull();
});
it('should allow saving a new draft after clear', async () => {
const snap1 = makeSnapshot('team-send', { text: 'first message' });
await composerDraftStorage.saveSnapshot('team-send', snap1);
await composerDraftStorage.deleteSnapshot('team-send');
// New draft after clear
const snap2 = makeSnapshot('team-send', { text: 'second draft' });
await composerDraftStorage.saveSnapshot('team-send', snap2);
const result = await composerDraftStorage.loadSnapshot('team-send');
expect(result?.text).toBe('second draft');
});
});
describe('concurrent / rapid saves', () => {
it('should resolve to the last written snapshot', async () => {
const snaps = Array.from({ length: 5 }, (_, i) =>
makeSnapshot('team-rapid', { text: `iteration-${i}` })
);
// Fire all saves concurrently
await Promise.all(snaps.map((s) => composerDraftStorage.saveSnapshot('team-rapid', s)));
const result = await composerDraftStorage.loadSnapshot('team-rapid');
// Last save wins — the mock store is synchronous, so the last set() call wins
expect(result?.text).toBe('iteration-4');
});
it('should handle interleaved save and delete', async () => {
await composerDraftStorage.saveSnapshot('team-x', makeSnapshot('team-x', { text: 'v1' }));
// Delete then immediately save again
await composerDraftStorage.deleteSnapshot('team-x');
await composerDraftStorage.saveSnapshot('team-x', makeSnapshot('team-x', { text: 'v2' }));
const result = await composerDraftStorage.loadSnapshot('team-x');
expect(result?.text).toBe('v2');
});
});
describe('full data roundtrip', () => {
it('should preserve text, chips, and attachments together', async () => {
const snap = makeSnapshot('team-full', {
text: 'Hello @alice',
chips: [
{
id: 'chip-1',
filePath: '/src/index.ts',
fileName: 'index.ts',
fromLine: 1,
toLine: 10,
codeText: 'const x = 1;',
language: 'typescript',
},
],
attachments: [
{
id: 'att-1',
filename: 'screenshot.png',
mimeType: 'image/png',
size: 2048,
data: 'iVBORw0KGgo=',
},
],
});
await composerDraftStorage.saveSnapshot('team-full', snap);
const result = await composerDraftStorage.loadSnapshot('team-full');
expect(result).not.toBeNull();
expect(result!.text).toBe('Hello @alice');
expect(result!.chips).toHaveLength(1);
expect(result!.chips[0].filePath).toBe('/src/index.ts');
expect(result!.attachments).toHaveLength(1);
expect(result!.attachments[0].filename).toBe('screenshot.png');
expect(result!.attachments[0].size).toBe(2048);
});
});
describe('recovery after restart', () => {
it('should load draft saved in a previous session (simulated)', async () => {
// Simulate saving in "session 1"
const snap = makeSnapshot('team-persist', {
text: 'Unsent message from last session',
updatedAt: Date.now() - 3600_000, // 1 hour ago
});
await composerDraftStorage.saveSnapshot('team-persist', snap);
// Simulate "session 2" — load the same key
const result = await composerDraftStorage.loadSnapshot('team-persist');
expect(result).not.toBeNull();
expect(result!.text).toBe('Unsent message from last session');
});
it('should recover draft saved 30 days ago (no TTL)', async () => {
const snap = makeSnapshot('team-old', {
text: 'Ancient draft',
updatedAt: Date.now() - 30 * 24 * 3600_000,
});
await composerDraftStorage.saveSnapshot('team-old', snap);
const result = await composerDraftStorage.loadSnapshot('team-old');
expect(result).not.toBeNull();
expect(result!.text).toBe('Ancient draft');
});
});
});
describe('composerDraftStorage — IDB failure fallback', () => {
beforeEach(() => {
vi.resetModules();
store.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should fall back to in-memory store when IDB set throws', async () => {
// Make idb set throw to trigger fallback
const { set: idbSet } = await import('idb-keyval');
const mockSet = vi.mocked(idbSet);
mockSet.mockRejectedValueOnce(new Error('QuotaExceeded'));
// Re-import to get a fresh module with idbUnavailable = false
const { composerDraftStorage: freshStorage } = await import(
'@renderer/services/composerDraftStorage'
);
const snap: ComposerDraftSnapshot = {
version: 1,
teamName: 'fallback-team',
text: 'saved to memory',
chips: [],
attachments: [],
updatedAt: Date.now(),
};
// First save triggers the error → fallback kicks in
await freshStorage.saveSnapshot('fallback-team', snap);
// Subsequent load uses in-memory fallback
const result = await freshStorage.loadSnapshot('fallback-team');
expect(result).not.toBeNull();
expect(result!.text).toBe('saved to memory');
});
it('should allow delete from in-memory fallback', async () => {
const { set: idbSet } = await import('idb-keyval');
const mockSet = vi.mocked(idbSet);
mockSet.mockRejectedValueOnce(new Error('IDB broken'));
const { composerDraftStorage: freshStorage } = await import(
'@renderer/services/composerDraftStorage'
);
const snap: ComposerDraftSnapshot = {
version: 1,
teamName: 'del-team',
text: 'to delete',
chips: [],
attachments: [],
updatedAt: Date.now(),
};
await freshStorage.saveSnapshot('del-team', snap);
await freshStorage.deleteSnapshot('del-team');
const result = await freshStorage.loadSnapshot('del-team');
expect(result).toBeNull();
});
});