Skip to content

Commit 611ffba

Browse files
Copilothotlong
andcommitted
test: add tests for skeleton components, recent items, keyboard shortcuts, and file upload
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent d12f03e commit 611ffba

6 files changed

Lines changed: 365 additions & 3 deletions

File tree

apps/console/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@
5959
"lucide-react": "^0.563.0",
6060
"react": "^19.2.4",
6161
"react-dom": "^19.2.4",
62-
"react-router-dom": "^7.13.0"
62+
"react-router-dom": "^7.13.0",
63+
"sonner": "^2.0.7"
6364
},
6465
"devDependencies": {
6566
"@objectstack/cli": "^2.0.4",
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Tests for KeyboardShortcutsDialog component
3+
*/
4+
import { describe, it, expect, vi } from 'vitest';
5+
import { render, screen, fireEvent } from '@testing-library/react';
6+
import '@testing-library/jest-dom';
7+
import { KeyboardShortcutsDialog } from '../components/KeyboardShortcutsDialog';
8+
9+
// Mock @object-ui/components Dialog
10+
vi.mock('@object-ui/components', () => ({
11+
Dialog: ({ open, children }: any) => open ? <div data-testid="dialog">{children}</div> : null,
12+
DialogContent: ({ children }: any) => <div data-testid="dialog-content">{children}</div>,
13+
DialogHeader: ({ children }: any) => <div>{children}</div>,
14+
DialogTitle: ({ children }: any) => <h2>{children}</h2>,
15+
DialogDescription: ({ children }: any) => <p>{children}</p>,
16+
}));
17+
18+
describe('KeyboardShortcutsDialog', () => {
19+
it('renders without errors', () => {
20+
const { container } = render(<KeyboardShortcutsDialog />);
21+
// Dialog should be closed initially
22+
expect(container.querySelector('[data-testid="dialog"]')).toBeNull();
23+
});
24+
25+
it('opens when ? key is pressed', () => {
26+
render(<KeyboardShortcutsDialog />);
27+
28+
fireEvent.keyDown(document, { key: '?' });
29+
30+
expect(screen.getByTestId('dialog')).toBeInTheDocument();
31+
expect(screen.getByText('Keyboard Shortcuts')).toBeInTheDocument();
32+
});
33+
34+
it('shows shortcut categories', () => {
35+
render(<KeyboardShortcutsDialog />);
36+
fireEvent.keyDown(document, { key: '?' });
37+
38+
expect(screen.getByText('General')).toBeInTheDocument();
39+
expect(screen.getByText('Navigation')).toBeInTheDocument();
40+
expect(screen.getByText('Data Views')).toBeInTheDocument();
41+
expect(screen.getByText('Preferences')).toBeInTheDocument();
42+
});
43+
44+
it('does not open when ? is pressed in an input', () => {
45+
const { container } = render(
46+
<div>
47+
<input data-testid="input" />
48+
<KeyboardShortcutsDialog />
49+
</div>
50+
);
51+
52+
const input = screen.getByTestId('input');
53+
fireEvent.keyDown(input, { key: '?' });
54+
55+
expect(container.querySelector('[data-testid="dialog"]')).toBeNull();
56+
});
57+
});
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* Tests for useRecentItems hook
3+
*/
4+
import { describe, it, expect, beforeEach, vi } from 'vitest';
5+
import { renderHook, act } from '@testing-library/react';
6+
import { useRecentItems } from '../hooks/useRecentItems';
7+
8+
// Mock localStorage
9+
const localStorageMock = (() => {
10+
let store: Record<string, string> = {};
11+
return {
12+
getItem: vi.fn((key: string) => store[key] ?? null),
13+
setItem: vi.fn((key: string, value: string) => { store[key] = value; }),
14+
removeItem: vi.fn((key: string) => { delete store[key]; }),
15+
clear: vi.fn(() => { store = {}; }),
16+
};
17+
})();
18+
19+
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
20+
21+
describe('useRecentItems', () => {
22+
beforeEach(() => {
23+
localStorageMock.clear();
24+
vi.clearAllMocks();
25+
});
26+
27+
it('starts with empty items when localStorage is empty', () => {
28+
const { result } = renderHook(() => useRecentItems());
29+
expect(result.current.recentItems).toEqual([]);
30+
});
31+
32+
it('adds a recent item', () => {
33+
const { result } = renderHook(() => useRecentItems());
34+
35+
act(() => {
36+
result.current.addRecentItem({
37+
id: 'object:contact',
38+
label: 'Contacts',
39+
href: '/apps/crm/contact',
40+
type: 'object',
41+
});
42+
});
43+
44+
expect(result.current.recentItems).toHaveLength(1);
45+
expect(result.current.recentItems[0].id).toBe('object:contact');
46+
expect(result.current.recentItems[0].label).toBe('Contacts');
47+
expect(result.current.recentItems[0].visitedAt).toBeDefined();
48+
});
49+
50+
it('deduplicates items by id', () => {
51+
const { result } = renderHook(() => useRecentItems());
52+
53+
act(() => {
54+
result.current.addRecentItem({
55+
id: 'object:contact',
56+
label: 'Contacts',
57+
href: '/apps/crm/contact',
58+
type: 'object',
59+
});
60+
});
61+
62+
act(() => {
63+
result.current.addRecentItem({
64+
id: 'object:contact',
65+
label: 'Contacts Updated',
66+
href: '/apps/crm/contact',
67+
type: 'object',
68+
});
69+
});
70+
71+
expect(result.current.recentItems).toHaveLength(1);
72+
expect(result.current.recentItems[0].label).toBe('Contacts Updated');
73+
});
74+
75+
it('limits to max 8 items', () => {
76+
const { result } = renderHook(() => useRecentItems());
77+
78+
for (let i = 0; i < 10; i++) {
79+
act(() => {
80+
result.current.addRecentItem({
81+
id: `object:item-${i}`,
82+
label: `Item ${i}`,
83+
href: `/apps/crm/item-${i}`,
84+
type: 'object',
85+
});
86+
});
87+
}
88+
89+
expect(result.current.recentItems.length).toBeLessThanOrEqual(8);
90+
});
91+
92+
it('clears all items', () => {
93+
const { result } = renderHook(() => useRecentItems());
94+
95+
act(() => {
96+
result.current.addRecentItem({
97+
id: 'object:contact',
98+
label: 'Contacts',
99+
href: '/apps/crm/contact',
100+
type: 'object',
101+
});
102+
});
103+
104+
act(() => {
105+
result.current.clearRecentItems();
106+
});
107+
108+
expect(result.current.recentItems).toEqual([]);
109+
});
110+
111+
it('persists items to localStorage', () => {
112+
const { result } = renderHook(() => useRecentItems());
113+
114+
act(() => {
115+
result.current.addRecentItem({
116+
id: 'object:contact',
117+
label: 'Contacts',
118+
href: '/apps/crm/contact',
119+
type: 'object',
120+
});
121+
});
122+
123+
expect(localStorageMock.setItem).toHaveBeenCalledWith(
124+
'objectui-recent-items',
125+
expect.any(String),
126+
);
127+
});
128+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Tests for skeleton loading components
3+
*/
4+
import { describe, it, expect } from 'vitest';
5+
import { render, screen } from '@testing-library/react';
6+
import '@testing-library/jest-dom';
7+
import { SkeletonGrid } from '../components/skeletons/SkeletonGrid';
8+
import { SkeletonDashboard } from '../components/skeletons/SkeletonDashboard';
9+
import { SkeletonDetail } from '../components/skeletons/SkeletonDetail';
10+
11+
// Mock @object-ui/components Skeleton
12+
vi.mock('@object-ui/components', () => ({
13+
Skeleton: ({ className, ...props }: any) => (
14+
<div data-testid="skeleton" className={className} {...props} />
15+
),
16+
}));
17+
18+
describe('SkeletonGrid', () => {
19+
it('renders with default props', () => {
20+
const { container } = render(<SkeletonGrid />);
21+
const skeletons = container.querySelectorAll('[data-testid="skeleton"]');
22+
// Header (5) + 8 rows x 5 cols (40) + toolbar (4) + pagination (4) = 53
23+
expect(skeletons.length).toBeGreaterThan(0);
24+
});
25+
26+
it('renders correct number of rows', () => {
27+
const { container } = render(<SkeletonGrid rows={3} columns={2} />);
28+
// Should have skeletons for 3 rows x 2 columns in the table body
29+
const rowContainers = container.querySelectorAll('.border-b');
30+
expect(rowContainers.length).toBeGreaterThanOrEqual(3);
31+
});
32+
});
33+
34+
describe('SkeletonDashboard', () => {
35+
it('renders with default props', () => {
36+
const { container } = render(<SkeletonDashboard />);
37+
const skeletons = container.querySelectorAll('[data-testid="skeleton"]');
38+
expect(skeletons.length).toBeGreaterThan(0);
39+
});
40+
41+
it('renders correct number of widget cards', () => {
42+
const { container } = render(<SkeletonDashboard cards={3} />);
43+
// 3 widget cards, each with 3 skeletons + stats row (4 cards x 3 each) + header (2)
44+
const skeletons = container.querySelectorAll('[data-testid="skeleton"]');
45+
expect(skeletons.length).toBeGreaterThan(0);
46+
});
47+
});
48+
49+
describe('SkeletonDetail', () => {
50+
it('renders with default props', () => {
51+
const { container } = render(<SkeletonDetail />);
52+
const skeletons = container.querySelectorAll('[data-testid="skeleton"]');
53+
expect(skeletons.length).toBeGreaterThan(0);
54+
});
55+
56+
it('renders correct number of field rows', () => {
57+
const { container } = render(<SkeletonDetail fields={4} columns={1} />);
58+
const skeletons = container.querySelectorAll('[data-testid="skeleton"]');
59+
expect(skeletons.length).toBeGreaterThan(0);
60+
});
61+
});
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* Tests for ObjectStackAdapter file upload integration
3+
*/
4+
import { describe, it, expect, vi, beforeEach } from 'vitest';
5+
import { ObjectStackAdapter } from './index';
6+
7+
describe('ObjectStackAdapter File Upload', () => {
8+
let adapter: ObjectStackAdapter;
9+
10+
beforeEach(() => {
11+
adapter = new ObjectStackAdapter({
12+
baseUrl: 'http://localhost:3000',
13+
autoReconnect: false,
14+
});
15+
vi.clearAllMocks();
16+
});
17+
18+
describe('uploadFile', () => {
19+
it('should be a method on the adapter', () => {
20+
expect(typeof adapter.uploadFile).toBe('function');
21+
});
22+
23+
it('should call fetch with multipart form data when connected', async () => {
24+
const mockResponse = {
25+
ok: true,
26+
json: vi.fn().mockResolvedValue({
27+
id: 'file-1',
28+
filename: 'test.pdf',
29+
mimeType: 'application/pdf',
30+
size: 1024,
31+
url: 'http://localhost:3000/files/file-1',
32+
}),
33+
};
34+
35+
global.fetch = vi.fn().mockResolvedValue(mockResponse);
36+
37+
// Manually set connected state by accessing private field
38+
(adapter as any).connected = true;
39+
(adapter as any).connectionState = 'connected';
40+
41+
const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
42+
43+
const result = await adapter.uploadFile('documents', file, {
44+
recordId: 'rec-123',
45+
fieldName: 'attachment',
46+
});
47+
48+
expect(global.fetch).toHaveBeenCalledWith(
49+
expect.stringContaining('/api/data/documents/upload'),
50+
expect.objectContaining({
51+
method: 'POST',
52+
body: expect.any(FormData),
53+
}),
54+
);
55+
56+
expect(result.id).toBe('file-1');
57+
expect(result.filename).toBe('test.pdf');
58+
});
59+
60+
it('should throw on upload failure', async () => {
61+
const mockResponse = {
62+
ok: false,
63+
status: 413,
64+
statusText: 'Payload Too Large',
65+
json: vi.fn().mockResolvedValue({ message: 'File too large' }),
66+
};
67+
68+
global.fetch = vi.fn().mockResolvedValue(mockResponse);
69+
70+
// Manually set connected state
71+
(adapter as any).connected = true;
72+
(adapter as any).connectionState = 'connected';
73+
74+
const file = new File(['test'], 'large.bin', { type: 'application/octet-stream' });
75+
76+
await expect(adapter.uploadFile('documents', file)).rejects.toThrow('File too large');
77+
});
78+
});
79+
80+
describe('uploadFiles', () => {
81+
it('should be a method on the adapter', () => {
82+
expect(typeof adapter.uploadFiles).toBe('function');
83+
});
84+
85+
it('should upload multiple files', async () => {
86+
const mockResponse = {
87+
ok: true,
88+
json: vi.fn().mockResolvedValue([
89+
{ id: 'file-1', filename: 'a.pdf', mimeType: 'application/pdf', size: 100, url: '/files/1' },
90+
{ id: 'file-2', filename: 'b.pdf', mimeType: 'application/pdf', size: 200, url: '/files/2' },
91+
]),
92+
};
93+
94+
global.fetch = vi.fn().mockResolvedValue(mockResponse);
95+
96+
// Manually set connected state
97+
(adapter as any).connected = true;
98+
(adapter as any).connectionState = 'connected';
99+
100+
const files = [
101+
new File(['content1'], 'a.pdf', { type: 'application/pdf' }),
102+
new File(['content2'], 'b.pdf', { type: 'application/pdf' }),
103+
];
104+
105+
const results = await adapter.uploadFiles('documents', files);
106+
107+
expect(results).toHaveLength(2);
108+
expect(results[0].id).toBe('file-1');
109+
expect(results[1].id).toBe('file-2');
110+
});
111+
});
112+
});

pnpm-lock.yaml

Lines changed: 5 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)