helpers.test.ts704 lines · main
1import { copyToClipboard } from 'ui'
2import { v4 as _uuidV4 } from 'uuid'
3import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
5import {
6 detectBrowser,
7 detectOS,
8 extractUrls,
9 formatBytes,
10 formatCurrency,
11 getDatabaseMajorVersion,
12 getDistanceLatLonKM,
13 getSemanticVersion,
14 getURL,
15 isValidHttpUrl,
16 makeRandomString,
17 minifyJSON,
18 pluckObjectFields,
19 pluralize,
20 prettifyJSON,
21 propsAreEqual,
22 removeCommentsFromSql,
23 removeJSONTrailingComma,
24 snakeToCamel,
25 stripMarkdownCodeBlocks,
26 tablesToSQL,
27 timeout,
28 tryParseInt,
29 tryParseJson,
30 uuidv4,
31} from './helpers'
32
33vi.mock('uuid', () => ({
34 v4: vi.fn(() => 'mocked-uuid'),
35}))
36
37describe('uuidv4', () => {
38 it('calls uuid.v4 and returns the result', () => {
39 const result = uuidv4()
40 expect(_uuidV4).toHaveBeenCalled()
41 expect(result).toBe('mocked-uuid')
42 })
43})
44
45describe('tryParseJson', () => {
46 it('should return the parsed JSON', () => {
47 const result = tryParseJson('{"test": "test"}')
48
49 expect(result).toEqual({ test: 'test' })
50 })
51})
52
53describe('minifyJSON', () => {
54 it('should return the minified JSON', () => {
55 const result = minifyJSON('{"test": "test"}')
56
57 expect(result).toEqual(`{"test":"test"}`)
58 })
59})
60
61describe('prettifyJSON', () => {
62 it('should return the prettified JSON', () => {
63 const result = prettifyJSON('{"test": "test"}')
64
65 expect(result).toEqual(`{
66 "test": "test"
67}`)
68 })
69})
70
71describe('removeJSONTrailingComma', () => {
72 it('should return the JSON without a trailing comma', () => {
73 const result = removeJSONTrailingComma('{"test":"test",}')
74
75 expect(result).toEqual('{"test":"test"}')
76 })
77})
78
79describe('timeout', () => {
80 it('resolves after given ms', async () => {
81 vi.useFakeTimers()
82 const spy = vi.fn()
83
84 timeout(1000).then(spy)
85
86 expect(spy).not.toHaveBeenCalled()
87
88 vi.advanceTimersByTime(1000)
89 await vi.runAllTimersAsync()
90
91 expect(spy).toHaveBeenCalled()
92 vi.useRealTimers()
93 })
94})
95
96describe('getURL', () => {
97 it('should return prod url by default', () => {
98 const result = getURL()
99
100 expect(result).toEqual('https://supabase.com/dashboard')
101 })
102})
103
104describe('makeRandomString', () => {
105 it('should return a random string of the given length', () => {
106 const result = makeRandomString(10)
107
108 expect(result).toHaveLength(10)
109 })
110})
111
112describe('pluckObjectFields', () => {
113 it('should return a new object with the specified fields', () => {
114 const result = pluckObjectFields({ a: 1, b: 2, c: 3 }, ['a', 'c'])
115
116 expect(result).toEqual({ a: 1, c: 3 })
117 })
118})
119
120describe('tryParseInt', () => {
121 it('should return the parsed integer', () => {
122 const result = tryParseInt('123')
123
124 expect(result).toEqual(123)
125 })
126
127 it('should return undefined if the string is not a number', () => {
128 const result = tryParseInt('not a number')
129
130 expect(result).toBeUndefined()
131 })
132})
133
134describe('propsAreEqual', () => {
135 it('should return true if the props are equal', () => {
136 const result = propsAreEqual({ a: 1, b: 2 }, { a: 1, b: 2 })
137
138 expect(result).toBe(true)
139 })
140
141 it('should return false if the props are not equal', () => {
142 propsAreEqual({ a: 1, b: 2 }, { a: 1, b: 3 })
143 })
144})
145
146describe('formatBytes', () => {
147 it('should return the formatted bytes', () => {
148 const result = formatBytes(1024)
149
150 expect(result).toEqual('1 KB')
151 })
152
153 it('should return the formatted bytes in MB', () => {
154 const result = formatBytes(1024 * 1024)
155
156 expect(result).toEqual('1 MB')
157 })
158})
159
160describe('snakeToCamel', () => {
161 it('should convert snake_case to camelCase', () => {
162 const result = snakeToCamel('snake_case')
163
164 expect(result).toEqual('snakeCase')
165 })
166})
167
168describe('copyToClipboard', () => {
169 let writeMock: any
170 let writeTextMock: any
171 let hasFocusMock: any
172
173 beforeEach(() => {
174 writeMock = vi.fn().mockResolvedValue(undefined)
175 writeTextMock = vi.fn().mockResolvedValue(undefined)
176 hasFocusMock = vi.fn().mockReturnValue(true)
177
178 vi.stubGlobal('navigator', {
179 clipboard: {
180 write: writeMock,
181 writeText: writeTextMock,
182 },
183 })
184
185 vi.stubGlobal('window', {
186 document: {
187 hasFocus: hasFocusMock,
188 },
189 })
190
191 // CopyToClipboard uses setTimeout to call the callback
192 vi.useFakeTimers()
193
194 // If ClipboardItem is used
195 vi.stubGlobal('ClipboardItem', function (items: any) {
196 return items
197 })
198
199 // Prevent toast errors
200 vi.stubGlobal('toast', { error: vi.fn() })
201 })
202
203 afterEach(() => {
204 vi.unstubAllGlobals()
205 vi.useRealTimers()
206 })
207
208 it('uses clipboard.write if available', async () => {
209 const promise = copyToClipboard('hello')
210 vi.runAllTimers()
211 await promise
212 expect(writeMock).toHaveBeenCalled()
213 })
214
215 it('falls back to writeText if clipboard.write not available', async () => {
216 ;(navigator.clipboard as any).write = undefined
217 await copyToClipboard('hello')
218 expect(writeTextMock).toHaveBeenCalledWith('hello')
219 })
220})
221
222describe('detectBrowser', () => {
223 const originalNavigator = global.navigator
224
225 const setUserAgent = (ua: string) => {
226 vi.stubGlobal('navigator', { userAgent: ua })
227 }
228
229 afterEach(() => {
230 vi.unstubAllGlobals()
231 global.navigator = originalNavigator
232 })
233
234 it('detects Chrome', () => {
235 setUserAgent('Mozilla/5.0 Chrome/90.0.0.0 Safari/537.36')
236 expect(detectBrowser()).toBe('Chrome')
237 })
238
239 it('detects Firefox', () => {
240 setUserAgent('Mozilla/5.0 Firefox/88.0')
241 expect(detectBrowser()).toBe('Firefox')
242 })
243
244 it('detects Safari', () => {
245 setUserAgent('Mozilla/5.0 Version/14.0 Safari/605.1.15')
246 expect(detectBrowser()).toBe('Safari')
247 })
248
249 it('returns undefined when navigator is not defined', () => {
250 vi.stubGlobal('navigator', undefined)
251 expect(detectBrowser()).toBeUndefined()
252 })
253})
254
255describe('detectOS', () => {
256 const mockUserAgent = (ua: string) => {
257 vi.stubGlobal('window', {
258 navigator: { userAgent: ua },
259 })
260 vi.stubGlobal('navigator', { userAgent: ua }) // some code may use both
261 }
262
263 afterEach(() => {
264 vi.unstubAllGlobals()
265 })
266
267 it('detects macOS', () => {
268 mockUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')
269 expect(detectOS()).toBe('macos')
270 })
271
272 it('detects Windows', () => {
273 mockUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')
274 expect(detectOS()).toBe('windows')
275 })
276
277 it('returns undefined for unknown OS', () => {
278 mockUserAgent('Mozilla/5.0 (X11; Linux x86_64)')
279 expect(detectOS()).toBeUndefined()
280 })
281
282 it('returns undefined if window is undefined', () => {
283 vi.stubGlobal('window', undefined)
284 expect(detectOS()).toBeUndefined()
285 })
286
287 it('returns undefined if navigator is undefined', () => {
288 vi.stubGlobal('window', {})
289 vi.stubGlobal('navigator', undefined)
290 expect(detectOS()).toBeUndefined()
291 })
292})
293
294describe('pluralize', () => {
295 it('should return the pluralized word', () => {
296 const result = pluralize(2, 'test', 'tests')
297
298 expect(result).toEqual('tests')
299 })
300})
301
302describe('isValidHttpUrl', () => {
303 it('should return true if the URL is valid', () => {
304 const result = isValidHttpUrl('https://supabase.com')
305
306 expect(result).toBe(true)
307 })
308
309 it('should return false if the URL is not valid', () => {
310 const result = isValidHttpUrl('not a url')
311
312 expect(result).toBe(false)
313 })
314})
315
316describe('extractUrls', () => {
317 it('should extract basic http URLs', () => {
318 const result = extractUrls('Visit http://example.com for more info')
319 expect(result).toEqual(['http://example.com'])
320 })
321
322 it('should extract basic https URLs', () => {
323 const result = extractUrls('Check out https://supabase.com')
324 expect(result).toEqual(['https://supabase.com'])
325 })
326
327 it('should extract URLs with ports', () => {
328 const result = extractUrls('Connect to http://localhost:3000')
329 expect(result).toEqual(['http://localhost:3000'])
330 })
331
332 it('should extract URLs with paths', () => {
333 const result = extractUrls('Go to https://example.com/path/to/page')
334 expect(result).toEqual(['https://example.com/path/to/page'])
335 })
336
337 it('should extract URLs with query parameters', () => {
338 const result = extractUrls('Visit https://example.com/search?q=test&page=1')
339 expect(result).toEqual(['https://example.com/search?q=test&page=1'])
340 })
341
342 it('should extract URLs with fragments', () => {
343 const result = extractUrls('See https://example.com/page#section')
344 expect(result).toEqual(['https://example.com/page#section'])
345 })
346
347 it('should extract URLs with complex paths, query params, and fragments', () => {
348 const result = extractUrls('Check https://example.com/api/v1/users?id=123&name=test#details')
349 expect(result).toEqual(['https://example.com/api/v1/users?id=123&name=test#details'])
350 })
351
352 it('should extract multiple URLs from text', () => {
353 const result = extractUrls('Visit http://example.com and https://supabase.com for more info')
354 expect(result).toEqual(['http://example.com', 'https://supabase.com'])
355 })
356
357 it('should remove trailing punctuation from URLs', () => {
358 const result = extractUrls('Visit https://example.com.')
359 expect(result).toEqual(['https://example.com'])
360 })
361
362 it('should remove multiple trailing punctuation marks', () => {
363 const result = extractUrls('Check https://example.com!!!')
364 expect(result).toEqual(['https://example.com'])
365 })
366
367 it('should remove trailing punctuation including parentheses', () => {
368 const result = extractUrls('See (https://example.com)')
369 expect(result).toEqual(['https://example.com'])
370 })
371
372 it('should handle URLs with trailing commas and periods', () => {
373 const result = extractUrls('Visit https://example.com, and https://supabase.com.')
374 expect(result).toEqual(['https://example.com', 'https://supabase.com'])
375 })
376
377 it('should handle URLs with subpath and markdown bolding', () => {
378 const result = extractUrls('Check out **https://example.com/subpath** for details')
379 expect(result).toEqual(['https://example.com/subpath'])
380 })
381
382 it('should return empty array when no URLs are found', () => {
383 const result = extractUrls('This is just plain text with no URLs')
384 expect(result).toEqual([])
385 })
386
387 it('should return empty array for empty string', () => {
388 const result = extractUrls('')
389 expect(result).toEqual([])
390 })
391
392 it('should handle URLs in parentheses', () => {
393 const result = extractUrls('Check out (https://example.com) for details')
394 expect(result).toEqual(['https://example.com'])
395 })
396
397 it('should be case insensitive for protocol', () => {
398 const result = extractUrls('Visit HTTP://EXAMPLE.COM and HTTPS://BRIVEN.COM')
399 expect(result).toEqual(['HTTP://EXAMPLE.COM', 'HTTPS://BRIVEN.COM'])
400 })
401
402 it('should handle URLs with special characters in path', () => {
403 const result = extractUrls('Visit https://example.com/path_with_underscores/file-name.txt')
404 expect(result).toEqual(['https://example.com/path_with_underscores/file-name.txt'])
405 })
406
407 it('should handle URLs with encoded characters', () => {
408 const result = extractUrls('Visit https://example.com/search?q=hello%20world')
409 expect(result).toEqual(['https://example.com/search?q=hello%20world'])
410 })
411
412 it('should handle URLs with subdomains', () => {
413 const result = extractUrls('Visit https://www.example.com and https://api.example.com')
414 expect(result).toEqual(['https://www.example.com', 'https://api.example.com'])
415 })
416
417 describe('with excludeCodeBlocks option', () => {
418 it('should exclude URLs in fenced code blocks', () => {
419 const text = 'Visit https://real.com\n```\nhttps://code.com\n```'
420 expect(extractUrls(text, { excludeCodeBlocks: true })).toEqual(['https://real.com'])
421 })
422
423 it('should exclude URLs in fenced code blocks with language specifier', () => {
424 const text = 'Visit https://real.com\n```sql\nSELECT * FROM https://code.com\n```'
425 expect(extractUrls(text, { excludeCodeBlocks: true })).toEqual(['https://real.com'])
426 })
427
428 it('should exclude URLs in inline code', () => {
429 const text = 'Use `https://code.com` for the endpoint, or visit https://real.com'
430 expect(extractUrls(text, { excludeCodeBlocks: true })).toEqual(['https://real.com'])
431 })
432
433 it('should handle multiple code blocks', () => {
434 const text =
435 'https://first.com\n```\nhttps://code1.com\n```\nhttps://second.com\n```\nhttps://code2.com\n```'
436 expect(extractUrls(text, { excludeCodeBlocks: true })).toEqual([
437 'https://first.com',
438 'https://second.com',
439 ])
440 })
441
442 it('should not exclude code blocks by default', () => {
443 const text = 'Visit https://real.com\n```\nhttps://code.com\n```'
444 expect(extractUrls(text)).toEqual(['https://real.com', 'https://code.com'])
445 })
446 })
447
448 describe('with excludeTemplates option', () => {
449 it('should not extract URLs with angle brackets in subdomain', () => {
450 // Angle brackets in subdomain prevent the URL from being extracted at all
451 const text = 'Visit https://real.com or https://<project-ref>.supabase.co'
452 expect(extractUrls(text, { excludeTemplates: true })).toEqual(['https://real.com'])
453 })
454
455 it('should exclude URLs truncated at angle brackets in path', () => {
456 // The regex stops at angle brackets - exclude the whole truncated URL
457 const text = 'Visit https://real.com or https://example.com/api/<project-id>/data'
458 expect(extractUrls(text, { excludeTemplates: true })).toEqual(['https://real.com'])
459 })
460
461 it('should keep URLs without angle brackets', () => {
462 const text = 'Visit https://example.com/path_with_underscores'
463 expect(extractUrls(text, { excludeTemplates: true })).toEqual([
464 'https://example.com/path_with_underscores',
465 ])
466 })
467 })
468
469 describe('with both options', () => {
470 it('should exclude both code blocks and template URLs', () => {
471 const text =
472 'Visit https://real.com\n```\nhttps://code.com\n```\nOr https://<project-ref>.supabase.co'
473 expect(extractUrls(text, { excludeCodeBlocks: true, excludeTemplates: true })).toEqual([
474 'https://real.com',
475 ])
476 })
477 })
478})
479
480describe('stripMarkdownCodeBlocks', () => {
481 it('should remove fenced code blocks', () => {
482 const text = 'Before\n```\ncode here\n```\nAfter'
483 expect(stripMarkdownCodeBlocks(text)).toBe('Before\n\nAfter')
484 })
485
486 it('should remove fenced code blocks with language specifier', () => {
487 const text = 'Before\n```typescript\nconst x = 1;\n```\nAfter'
488 expect(stripMarkdownCodeBlocks(text)).toBe('Before\n\nAfter')
489 })
490
491 it('should remove inline code', () => {
492 const text = 'Use `inline code` here'
493 expect(stripMarkdownCodeBlocks(text)).toBe('Use here')
494 })
495
496 it('should handle multiple code blocks', () => {
497 const text = '```js\ncode1\n```\ntext\n```ts\ncode2\n```'
498 expect(stripMarkdownCodeBlocks(text)).toBe('\ntext\n')
499 })
500
501 it('should preserve text without code blocks', () => {
502 const text = 'Just regular text here'
503 expect(stripMarkdownCodeBlocks(text)).toBe('Just regular text here')
504 })
505})
506
507describe('removeCommentsFromSql', () => {
508 it('should remove comments from SQL', () => {
509 const result = removeCommentsFromSql(`-- This is a comment
510SELECT * FROM users
511 `)
512
513 expect(result).toEqual(`
514SELECT * FROM users
515 `)
516 })
517})
518
519describe('getSemanticVersion', () => {
520 it('should return the semantic version', () => {
521 const result = getSemanticVersion('briven-postgres-14.1.0.88')
522
523 expect(result).toEqual(141088)
524 })
525})
526
527describe('getDatabaseMajorVersion', () => {
528 it('should return the database major version', () => {
529 const result = getDatabaseMajorVersion('briven-postgres-14.1.0.88')
530
531 expect(result).toEqual(14)
532 })
533})
534
535describe('getDistanceLatLonKM', () => {
536 it('should return the distance in kilometers', () => {
537 const result = getDistanceLatLonKM(37.774929, -122.419418, 37.774929, -122.419418)
538
539 expect(result).toEqual(0)
540 })
541})
542
543describe('formatCurrency', () => {
544 it('should return the formatted currency', () => {
545 const result = formatCurrency(1000)
546
547 expect(result).toEqual('$1,000.00')
548 })
549
550 it('should return the formatted currency with small values', () => {
551 const result = formatCurrency(0.001)
552
553 expect(result).toEqual('$0')
554 })
555
556 it('should return null if the value is undefined', () => {
557 const result = formatCurrency(undefined)
558
559 expect(result).toEqual(null)
560 })
561})
562
563describe('tablesToSQL', () => {
564 it('should return warning message for empty array', () => {
565 const result = tablesToSQL([])
566
567 expect(result).toContain('-- WARNING: This schema is for context only')
568 })
569
570 it('should return empty string for non-array input', () => {
571 const result = tablesToSQL(null as any)
572
573 expect(result).toBe('')
574 })
575
576 it('should generate SQL for a simple table', () => {
577 const mockTables = [
578 {
579 name: 'users',
580 schema: 'public',
581 columns: [
582 {
583 name: 'id',
584 data_type: 'integer',
585 is_nullable: false,
586 is_identity: true,
587 default_value: null,
588 is_unique: false,
589 check: null,
590 },
591 {
592 name: 'name',
593 data_type: 'text',
594 is_nullable: false,
595 is_identity: false,
596 default_value: null,
597 is_unique: false,
598 check: null,
599 },
600 ],
601 primary_keys: [{ name: 'id' }],
602 relationships: [],
603 },
604 ] as any
605
606 const result = tablesToSQL(mockTables)
607
608 expect(result).toContain('-- WARNING: This schema is for context only')
609 expect(result).toContain('CREATE TABLE public.users (')
610 expect(result).toContain('id integer GENERATED ALWAYS AS IDENTITY NOT NULL')
611 expect(result).toContain('name text NOT NULL')
612 expect(result).toContain('CONSTRAINT users_pkey PRIMARY KEY (id)')
613 })
614
615 it('should handle tables with various column properties', () => {
616 const mockTables = [
617 {
618 name: 'products',
619 schema: 'public',
620 columns: [
621 {
622 name: 'id',
623 data_type: 'uuid',
624 is_nullable: false,
625 is_identity: false,
626 default_value: 'gen_random_uuid()',
627 is_unique: true,
628 check: null,
629 },
630 {
631 name: 'price',
632 data_type: 'numeric',
633 is_nullable: true,
634 is_identity: false,
635 default_value: '0.00',
636 is_unique: false,
637 check: 'price >= 0',
638 },
639 ],
640 primary_keys: [],
641 relationships: [],
642 },
643 ] as any
644
645 const result = tablesToSQL(mockTables)
646
647 expect(result).toContain('id uuid NOT NULL DEFAULT gen_random_uuid() UNIQUE')
648 expect(result).toContain('price numeric DEFAULT 0.00 CHECK (price >= 0)')
649 })
650
651 it('should handle foreign key relationships', () => {
652 const mockTables = [
653 {
654 name: 'orders',
655 schema: 'public',
656 columns: [
657 {
658 name: 'user_id',
659 data_type: 'integer',
660 is_nullable: false,
661 is_identity: false,
662 default_value: null,
663 is_unique: false,
664 check: null,
665 },
666 ],
667 primary_keys: [],
668 relationships: [
669 {
670 constraint_name: 'fk_orders_user_id',
671 source_table_name: 'orders',
672 source_column_name: 'user_id',
673 target_table_schema: 'public',
674 target_table_name: 'users',
675 target_column_name: 'id',
676 },
677 ],
678 },
679 ] as any
680
681 const result = tablesToSQL(mockTables)
682
683 expect(result).toContain(
684 'CONSTRAINT fk_orders_user_id FOREIGN KEY (user_id) REFERENCES public.users(id)'
685 )
686 })
687
688 it('should handle tables with no columns', () => {
689 const mockTables = [
690 {
691 name: 'empty_table',
692 schema: 'public',
693 columns: null,
694 primary_keys: [],
695 relationships: [],
696 },
697 ] as any
698
699 const result = tablesToSQL(mockTables)
700
701 expect(result).toContain('-- WARNING: This schema is for context only')
702 expect(result).not.toContain('CREATE TABLE')
703 })
704})