- 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.
437 lines
15 KiB
TypeScript
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();
|
|
});
|
|
});
|