helpers.test.ts 20 KB

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