@svadmin/core 0.25.1 → 0.25.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,388 +0,0 @@
1
- // Unit tests for ChatProvider types and ChatContext logic
2
- // Since chatProvider.svelte.ts uses $state runes (requires Svelte compiler),
3
- // we test the pure type shapes and serialization here — matching the project's
4
- // pattern in i18n.test.ts of testing extracted logic.
5
-
6
- import { describe, test, expect } from 'bun:test';
7
-
8
- /** Mirror of ChatAction from chatProvider.svelte.ts */
9
- interface ChatAction {
10
- label: string;
11
- variant?: 'default' | 'destructive' | 'outline';
12
- payload?: Record<string, unknown>;
13
- }
14
-
15
- /** Mirror of ChatMessage from chatProvider.svelte.ts */
16
- interface ChatMessage {
17
- id: string;
18
- role: 'user' | 'assistant' | 'system';
19
- content: string;
20
- timestamp: number;
21
- actions?: ChatAction[];
22
- }
23
-
24
- /** Mirror of ChatContext from chatProvider.svelte.ts */
25
- interface ChatContext {
26
- currentResource?: string;
27
- selectedRecordId?: string;
28
- currentView?: 'list' | 'edit' | 'create' | 'show';
29
- pathname?: string;
30
- }
31
-
32
- /** Build context system message (mirrors logic in ChatDialog.svelte) */
33
- function buildContextSystemMessage(ctx: ChatContext): ChatMessage | null {
34
- if (!ctx.currentResource) return null;
35
-
36
- const parts: string[] = [];
37
- parts.push(`Resource: ${ctx.currentResource}`);
38
- if (ctx.currentView) parts.push(`View: ${ctx.currentView}`);
39
- if (ctx.selectedRecordId) parts.push(`Selected Record ID: ${ctx.selectedRecordId}`);
40
- if (ctx.pathname) parts.push(`Path: ${ctx.pathname}`);
41
-
42
- return {
43
- id: 'ctx-system',
44
- role: 'system',
45
- content: `[Admin Context] ${parts.join(' | ')}`,
46
- timestamp: Date.now(),
47
- };
48
- }
49
-
50
- describe('ChatMessage with actions', () => {
51
- test('message with actions serializes to JSON and back', () => {
52
- const actions: ChatAction[] = [
53
- { label: 'Delete Record', variant: 'destructive', payload: { id: '42' } },
54
- { label: 'View Details' },
55
- ];
56
- const msg: ChatMessage = {
57
- id: 'msg-1',
58
- role: 'assistant',
59
- content: 'I found a record to delete.',
60
- timestamp: 1711500000000,
61
- actions,
62
- };
63
-
64
- const serialized = JSON.stringify(msg);
65
- const deserialized = JSON.parse(serialized) as ChatMessage;
66
-
67
- expect(deserialized.actions).toHaveLength(2);
68
- expect(deserialized.actions![0].label).toBe('Delete Record');
69
- expect(deserialized.actions![0].variant).toBe('destructive');
70
- expect(deserialized.actions![0].payload).toEqual({ id: '42' });
71
- expect(deserialized.actions![1].variant).toBeUndefined();
72
- });
73
-
74
- test('message without actions has undefined actions', () => {
75
- const msg: ChatMessage = {
76
- id: 'msg-2',
77
- role: 'user',
78
- content: 'Hello',
79
- timestamp: 1711500000000,
80
- };
81
- expect(msg.actions).toBeUndefined();
82
- });
83
-
84
- test('system message persists correctly', () => {
85
- const msg: ChatMessage = {
86
- id: 'ctx-1',
87
- role: 'system',
88
- content: '[Admin Context] Resource: posts | View: edit',
89
- timestamp: 1711500000000,
90
- };
91
- const parsed = JSON.parse(JSON.stringify(msg)) as ChatMessage;
92
- expect(parsed.role).toBe('system');
93
- expect(parsed.content).toContain('posts');
94
- });
95
- });
96
-
97
- describe('buildContextSystemMessage', () => {
98
- test('returns null when no resource', () => {
99
- expect(buildContextSystemMessage({})).toBeNull();
100
- });
101
-
102
- test('builds message with resource only', () => {
103
- const msg = buildContextSystemMessage({ currentResource: 'users' });
104
- expect(msg).not.toBeNull();
105
- expect(msg!.role).toBe('system');
106
- expect(msg!.content).toContain('Resource: users');
107
- });
108
-
109
- test('includes all context fields', () => {
110
- const msg = buildContextSystemMessage({
111
- currentResource: 'posts',
112
- currentView: 'edit',
113
- selectedRecordId: '42',
114
- pathname: '/posts/edit/42',
115
- });
116
- expect(msg!.content).toContain('Resource: posts');
117
- expect(msg!.content).toContain('View: edit');
118
- expect(msg!.content).toContain('Selected Record ID: 42');
119
- expect(msg!.content).toContain('Path: /posts/edit/42');
120
- });
121
-
122
- test('omits undefined fields', () => {
123
- const msg = buildContextSystemMessage({
124
- currentResource: 'users',
125
- currentView: 'list',
126
- });
127
- expect(msg!.content).not.toContain('Selected Record ID');
128
- expect(msg!.content).not.toContain('Path');
129
- });
130
- });
131
-
132
- describe('ChatContext shape', () => {
133
- test('empty context is valid', () => {
134
- const ctx: ChatContext = {};
135
- expect(ctx.currentResource).toBeUndefined();
136
- });
137
-
138
- test('full context preserves all fields', () => {
139
- const ctx: ChatContext = {
140
- currentResource: 'orders',
141
- selectedRecordId: '99',
142
- currentView: 'show',
143
- pathname: '/orders/show/99',
144
- };
145
- expect(ctx.currentResource).toBe('orders');
146
- expect(ctx.selectedRecordId).toBe('99');
147
- expect(ctx.currentView).toBe('show');
148
- expect(ctx.pathname).toBe('/orders/show/99');
149
- });
150
- });
151
-
152
- describe('localStorage persistence simulation', () => {
153
- test('messages array round-trips through JSON', () => {
154
- const messages: ChatMessage[] = [
155
- { id: '1', role: 'user', content: 'Hello', timestamp: 1000 },
156
- {
157
- id: '2', role: 'assistant', content: 'Hi!', timestamp: 1001,
158
- actions: [{ label: 'Create Post', variant: 'default', payload: { resource: 'posts' } }],
159
- },
160
- ];
161
- const json = JSON.stringify(messages);
162
- const restored = JSON.parse(json) as ChatMessage[];
163
-
164
- expect(restored).toHaveLength(2);
165
- expect(restored[0].role).toBe('user');
166
- expect(restored[1].actions![0].label).toBe('Create Post');
167
- });
168
- });
169
-
170
- // ─── AgentProvider Types ───────────────────────────────────────
171
-
172
- /** Mirror of AdminToolParameter from chatProvider.svelte.ts */
173
- interface AdminToolParameter {
174
- type: 'string' | 'number' | 'boolean' | 'object' | 'array';
175
- description?: string;
176
- enum?: string[];
177
- required?: boolean;
178
- }
179
-
180
- /** Mirror of AdminTool from chatProvider.svelte.ts */
181
- interface AdminTool {
182
- name: string;
183
- description: string;
184
- parameters: Record<string, AdminToolParameter>;
185
- needsApproval?: boolean;
186
- execute(args: Record<string, unknown>): Promise<{ success: boolean; data?: unknown; error?: string }>;
187
- }
188
-
189
- /** Mirror of AgentEvent from chatProvider.svelte.ts */
190
- type AgentEvent =
191
- | { type: 'text'; content: string }
192
- | { type: 'tool_call'; tool: string; args: Record<string, unknown> }
193
- | { type: 'approval_request'; id: string; tool: string; args: Record<string, unknown>; description: string }
194
- | { type: 'tool_result'; tool: string; result: { success: boolean; data?: unknown; error?: string } }
195
- | { type: 'component'; name: string; props: Record<string, unknown> }
196
- | { type: 'done' };
197
-
198
- describe('AdminTool shape', () => {
199
- test('tool with needsApproval serializes correctly', () => {
200
- const tool: AdminTool = {
201
- name: 'deletePosts',
202
- description: 'Delete posts matching a filter',
203
- parameters: {
204
- status: { type: 'string', description: 'Filter by status', enum: ['draft', 'archived'] },
205
- },
206
- needsApproval: true,
207
- execute: async (args) => ({ success: true, data: { deleted: args.ids } }),
208
- };
209
-
210
- expect(tool.name).toBe('deletePosts');
211
- expect(tool.needsApproval).toBe(true);
212
- expect(tool.parameters.status.enum).toEqual(['draft', 'archived']);
213
- });
214
-
215
- test('tool without approval defaults to undefined', () => {
216
- const tool: AdminTool = {
217
- name: 'getStats',
218
- description: 'Fetch dashboard statistics',
219
- parameters: {},
220
- execute: async () => ({ success: true, data: { users: 100 } }),
221
- };
222
-
223
- expect(tool.needsApproval).toBeUndefined();
224
- });
225
-
226
- test('tool execute returns ToolResult', async () => {
227
- const tool: AdminTool = {
228
- name: 'createPost',
229
- description: 'Create a new post',
230
- parameters: {
231
- title: { type: 'string', required: true },
232
- },
233
- execute: async (args) => ({
234
- success: true,
235
- data: { id: '1', title: args.title },
236
- }),
237
- };
238
-
239
- const result = await tool.execute({ title: 'Hello World' });
240
- expect(result.success).toBe(true);
241
- expect(result.data).toEqual({ id: '1', title: 'Hello World' });
242
- });
243
-
244
- test('tool execute can return error', async () => {
245
- const tool: AdminTool = {
246
- name: 'failingTool',
247
- description: 'A tool that fails',
248
- parameters: {},
249
- execute: async () => ({
250
- success: false,
251
- error: 'Permission denied',
252
- }),
253
- };
254
-
255
- const result = await tool.execute({});
256
- expect(result.success).toBe(false);
257
- expect(result.error).toBe('Permission denied');
258
- });
259
- });
260
-
261
- describe('AgentEvent stream', () => {
262
- test('events can be collected from async generator', async () => {
263
- async function* mockAgent(): AsyncGenerator<AgentEvent, void, unknown> {
264
- yield { type: 'text', content: 'Searching...' };
265
- yield { type: 'tool_call', tool: 'getList', args: { resource: 'posts' } };
266
- yield { type: 'tool_result', tool: 'getList', result: { success: true, data: [{ id: 1 }] } };
267
- yield { type: 'text', content: 'Found 1 post.' };
268
- yield { type: 'done' };
269
- }
270
-
271
- const events: AgentEvent[] = [];
272
- for await (const event of mockAgent()) {
273
- events.push(event);
274
- }
275
-
276
- expect(events).toHaveLength(5);
277
- expect(events[0].type).toBe('text');
278
- expect(events[1].type).toBe('tool_call');
279
- expect(events[2].type).toBe('tool_result');
280
- expect(events[4].type).toBe('done');
281
- });
282
-
283
- test('approval_request event serializes correctly', () => {
284
- const event: AgentEvent = {
285
- type: 'approval_request',
286
- id: 'req-42',
287
- tool: 'deletePosts',
288
- args: { ids: ['1', '2', '3'] },
289
- description: 'Delete 3 draft posts',
290
- };
291
-
292
- const serialized = JSON.stringify(event);
293
- const parsed = JSON.parse(serialized) as AgentEvent;
294
-
295
- expect(parsed.type).toBe('approval_request');
296
- if (parsed.type === 'approval_request') {
297
- expect(parsed.id).toBe('req-42');
298
- expect(parsed.tool).toBe('deletePosts');
299
- expect(parsed.description).toBe('Delete 3 draft posts');
300
- }
301
- });
302
-
303
- test('component event carries name and props', () => {
304
- const event: AgentEvent = {
305
- type: 'component',
306
- name: 'SalesChart',
307
- props: { period: 'monthly', data: [100, 200, 150] },
308
- };
309
-
310
- expect(event.type).toBe('component');
311
- if (event.type === 'component') {
312
- expect(event.name).toBe('SalesChart');
313
- expect(event.props.period).toBe('monthly');
314
- }
315
- });
316
- });
317
-
318
- describe('Approval flow', () => {
319
- test('registerApproval and resolveApproval work together', () => {
320
- // Simulate the approval flow without importing rune-based module
321
- const pending = new Map<string, (approved: boolean) => void>();
322
-
323
- function register(id: string, cb: (approved: boolean) => void) {
324
- pending.set(id, cb);
325
- }
326
-
327
- function resolve(id: string, approved: boolean): boolean {
328
- const cb = pending.get(id);
329
- if (!cb) return false;
330
- cb(approved);
331
- pending.delete(id);
332
- return true;
333
- }
334
-
335
- let approvalResult = false;
336
-
337
- register('req-1', (approved) => {
338
- approvalResult = approved;
339
- });
340
-
341
- expect(pending.size).toBe(1);
342
-
343
- // Resolve with approval
344
- const resolved = resolve('req-1', true);
345
- expect(resolved).toBe(true);
346
- expect(approvalResult).toEqual(true);
347
- expect(pending.size).toBe(0);
348
- });
349
-
350
- test('resolveApproval returns false for unknown id', () => {
351
- const pending = new Map<string, (approved: boolean) => void>();
352
-
353
- function resolve(id: string, approved: boolean): boolean {
354
- const cb = pending.get(id);
355
- if (!cb) return false;
356
- cb(approved);
357
- pending.delete(id);
358
- return true;
359
- }
360
-
361
- expect(resolve('nonexistent', true)).toBe(false);
362
- });
363
-
364
- test('rejection flow works', () => {
365
- const pending = new Map<string, (approved: boolean) => void>();
366
-
367
- function register(id: string, cb: (approved: boolean) => void) {
368
- pending.set(id, cb);
369
- }
370
-
371
- function resolve(id: string, approved: boolean): boolean {
372
- const cb = pending.get(id);
373
- if (!cb) return false;
374
- cb(approved);
375
- pending.delete(id);
376
- return true;
377
- }
378
-
379
- let wasApproved = true;
380
-
381
- register('req-2', (approved) => {
382
- wasApproved = approved;
383
- });
384
-
385
- resolve('req-2', false);
386
- expect(wasApproved).toEqual(false);
387
- });
388
- });
@@ -1,118 +0,0 @@
1
- // Unit tests for CSV parsing and data-transfer utilities
2
- import { describe, test, expect } from 'bun:test';
3
-
4
- // We test parseCSVLine directly by re-implementing the exported logic
5
- // since the function is not exported. We test the CSV format contract.
6
-
7
- function parseCSVLine(line: string): string[] {
8
- const result: string[] = [];
9
- let current = '';
10
- let inQuotes = false;
11
-
12
- for (let i = 0; i < line.length; i++) {
13
- const ch = line[i];
14
- if (inQuotes) {
15
- if (ch === '"' && line[i + 1] === '"') {
16
- current += '"';
17
- i++;
18
- } else if (ch === '"') {
19
- inQuotes = false;
20
- } else {
21
- current += ch;
22
- }
23
- } else {
24
- if (ch === '"') {
25
- inQuotes = true;
26
- } else if (ch === ',') {
27
- result.push(current.trim());
28
- current = '';
29
- } else {
30
- current += ch;
31
- }
32
- }
33
- }
34
- result.push(current.trim());
35
- return result;
36
- }
37
-
38
- describe('parseCSVLine', () => {
39
- test('simple comma-separated values', () => {
40
- expect(parseCSVLine('a,b,c')).toEqual(['a', 'b', 'c']);
41
- });
42
-
43
- test('values with whitespace are trimmed', () => {
44
- expect(parseCSVLine(' hello , world , test ')).toEqual(['hello', 'world', 'test']);
45
- });
46
-
47
- test('quoted values preserve commas', () => {
48
- expect(parseCSVLine('"hello, world",b,c')).toEqual(['hello, world', 'b', 'c']);
49
- });
50
-
51
- test('escaped quotes inside quoted values', () => {
52
- expect(parseCSVLine('"say ""hello""",b')).toEqual(['say "hello"', 'b']);
53
- });
54
-
55
- test('empty values', () => {
56
- expect(parseCSVLine('a,,c')).toEqual(['a', '', 'c']);
57
- });
58
-
59
- test('single value', () => {
60
- expect(parseCSVLine('hello')).toEqual(['hello']);
61
- });
62
-
63
- test('empty line', () => {
64
- expect(parseCSVLine('')).toEqual(['']);
65
- });
66
-
67
- test('quoted value with newline character', () => {
68
- expect(parseCSVLine('"line1\nline2",b')).toEqual(['line1\nline2', 'b']);
69
- });
70
-
71
- test('mixed quoted and unquoted', () => {
72
- expect(parseCSVLine('1,"John Doe","admin@test.com",true')).toEqual([
73
- '1', 'John Doe', 'admin@test.com', 'true'
74
- ]);
75
- });
76
- });
77
-
78
- // CSV row formatting (escape logic from useExport)
79
- function escapeCSVField(val: unknown): string {
80
- const str = val === null || val === undefined ? '' : String(val);
81
- return str.includes(',') || str.includes('"') || str.includes('\n')
82
- ? `"${str.replace(/"/g, '""')}"`
83
- : str;
84
- }
85
-
86
- describe('escapeCSVField', () => {
87
- test('simple string passes through', () => {
88
- expect(escapeCSVField('hello')).toBe('hello');
89
- });
90
-
91
- test('null becomes empty string', () => {
92
- expect(escapeCSVField(null)).toBe('');
93
- });
94
-
95
- test('undefined becomes empty string', () => {
96
- expect(escapeCSVField(undefined)).toBe('');
97
- });
98
-
99
- test('string with comma is quoted', () => {
100
- expect(escapeCSVField('hello, world')).toBe('"hello, world"');
101
- });
102
-
103
- test('string with double quote is escaped', () => {
104
- expect(escapeCSVField('say "hi"')).toBe('"say ""hi"""');
105
- });
106
-
107
- test('string with newline is quoted', () => {
108
- expect(escapeCSVField('line1\nline2')).toBe('"line1\nline2"');
109
- });
110
-
111
- test('number is converted to string', () => {
112
- expect(escapeCSVField(42)).toBe('42');
113
- });
114
-
115
- test('boolean is converted to string', () => {
116
- expect(escapeCSVField(true)).toBe('true');
117
- });
118
- });
@@ -1,83 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { useForm } from './form-hooks.svelte';
3
- import { flushSync } from 'svelte';
4
- import { QueryClient } from '@tanstack/svelte-query';
5
-
6
- vi.mock('./context.svelte', () => ({
7
- useDataProvider: () => ({
8
- getOne: vi.fn(),
9
- create: vi.fn().mockResolvedValue({ data: { id: 1 } }),
10
- update: vi.fn().mockResolvedValue({ data: { id: 1 } })
11
- }),
12
- useResource: () => ({ name: 'posts' }),
13
- getResource: () => ({ name: 'posts', primaryKey: 'id' }),
14
- useRouterContext: () => ({ navigate: vi.fn() }),
15
- useTranslate: () => (key: string) => key,
16
- useNotification: () => ({ open: vi.fn(), close: vi.fn() })
17
- }));
18
-
19
- vi.mock('@tanstack/svelte-query', async (importOriginal) => {
20
- const actual = await importOriginal();
21
- return {
22
- ...actual as any,
23
- useQueryClient: () => new QueryClient(),
24
- createQuery: () => ({ data: { data: { id: 1, title: 'Original' } }, isPending: false }),
25
- createMutation: (opts: any) => ({
26
- mutate: vi.fn((vars, callbacks) => {
27
- if (opts.mutationFn) opts.mutationFn(vars);
28
- if (callbacks?.onSuccess) callbacks.onSuccess({ data: {} });
29
- }),
30
- isPending: false
31
- })
32
- };
33
- });
34
-
35
- describe('useForm - Headless Svelte 5 Compatibility', () => {
36
-
37
- it('preserves reactive proxy data bindings safely across Svelte boundaries', () => {
38
- let form: ReturnType<typeof useForm>;
39
-
40
- const cleanup = $effect.root(() => {
41
- form = useForm({
42
- action: 'create',
43
- resource: 'posts'
44
- });
45
- });
46
-
47
- flushSync();
48
-
49
- // Properly use setFieldValue which guarantees state sync in svadmin forms
50
- form!.setFieldValue('title', 'Reactive Test');
51
-
52
- flushSync();
53
-
54
- // The internal $state proxy absorbs this mutation
55
- expect(form!.values.title).toBe('Reactive Test');
56
-
57
- cleanup();
58
- });
59
-
60
- it('handles autoSave queuing correctly across reactive boundaries', () => {
61
- let form: ReturnType<typeof useForm>;
62
-
63
- const cleanup = $effect.root(() => {
64
- form = useForm({
65
- action: 'edit',
66
- resource: 'posts',
67
- id: 1,
68
- autoSave: { enabled: true, debounce: 0 }
69
- });
70
- });
71
-
72
- flushSync();
73
-
74
- form!.setFieldValue('title', 'Saved Title');
75
-
76
- flushSync();
77
-
78
- expect(form!.mutation).toBeDefined();
79
- expect((form!.mutation as any).isPending).toBeDefined();
80
-
81
- cleanup();
82
- });
83
- });