rules.test.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. const { RuleTester } = require('eslint')
  2. const tsParser = require('@typescript-eslint/parser')
  3. const json = require('@eslint/json').default
  4. const noThrowInCallback = require('./no-throw-in-callback')
  5. const preferKebabUrl = require('./prefer-kebab-url')
  6. const noUnnecessaryTrans = require('./no-unnecessary-trans')
  7. const shouldUnescapeTrans = require('./should-unescape-trans')
  8. const noGeneratedEditorThemes = require('./no-generated-editor-themes')
  9. const viDoMockValidPath = require('./require-vi-doMock-valid-path')
  10. const requireCioSnakeCaseProperties = require('./require-cio-snake-case-properties')
  11. const noConsecutiveSpacesInLocales = require('./no-consecutive-spaces-in-locales')
  12. const noStraightApostrophesInLocales = require('./no-straight-apostrophes-in-locales')
  13. const frenchTypographyInLocales = require('./french-typography-in-locales')
  14. const sortedKeysInLocales = require('./sorted-keys-in-locales')
  15. const ruleTester = new RuleTester({
  16. languageOptions: {
  17. parser: tsParser,
  18. ecmaVersion: 'latest',
  19. parserOptions: { ecmaFeatures: { jsx: true } },
  20. },
  21. })
  22. const jsonRuleTester = new RuleTester({
  23. plugins: { json },
  24. language: 'json/json',
  25. })
  26. ruleTester.run('prefer-kebab-url', preferKebabUrl, {
  27. valid: [
  28. { code: `app.get('/foo-bar')` },
  29. { code: `app.get('/foo-bar/:id')` },
  30. { code: `router.post('/foo-bar')` },
  31. { code: `router.get('/foo-bar/:id/:name/:age')` },
  32. { code: `webRouter.get('/foo-bar/:user_id/(ProjectName)/get-info')` },
  33. { code: `webApp.post('/foo-bar/:user_id/(ProjectName)/get-info')` },
  34. {
  35. code: `router.get(/^\\/download\\/project\\/([^/]*)\\/output\\/output\\.pdf$/)`,
  36. },
  37. {
  38. code: `webRouter.get(/^\\/project\\/([^/]*)\\/user\\/([0-9a-f]+)\\/build\\/([0-9a-f-]+)\\/output\\/(.*)$/)`,
  39. },
  40. ],
  41. invalid: [
  42. {
  43. code: `app.get('/fooBar')`,
  44. errors: [
  45. { message: 'Route path should be in kebab-case.', suggestions: 1 },
  46. ],
  47. },
  48. {
  49. code: `app.get('/fooBar/:id')`,
  50. errors: [
  51. { message: 'Route path should be in kebab-case.', suggestions: 1 },
  52. ],
  53. },
  54. {
  55. code: `webRouter.get('/foo_bar/:id/FooBar/:name/fooBar')`,
  56. errors: [
  57. { message: 'Route path should be in kebab-case.', suggestions: 1 },
  58. ],
  59. },
  60. {
  61. code: `router.get(/^\\/downLoad\\/pro-ject\\/([^/]*)\\/OutPut\\/out-put\\.pdf$/)`,
  62. errors: [
  63. { message: 'Route path should be in kebab-case.', suggestions: 1 },
  64. ],
  65. },
  66. ],
  67. })
  68. ruleTester.run('no-unnecessary-trans', noUnnecessaryTrans, {
  69. valid: [
  70. { code: `<Trans i18nKey="test" components={{ strong: <strong/> }}/>` },
  71. ],
  72. invalid: [
  73. {
  74. code: `<Trans i18nKey="test" values={{ test: 'foo '}}/>`,
  75. errors: [{ message: `Use t('…') when there are no components` }],
  76. },
  77. {
  78. code: `<Trans i18nKey="test" />`,
  79. errors: [{ message: `Use t('…') when there are no components` }],
  80. output: `{t('test')}`,
  81. },
  82. ],
  83. })
  84. ruleTester.run('should-unescape-trans', shouldUnescapeTrans, {
  85. valid: [
  86. {
  87. code: `<Trans i18nKey="test" components={{ strong: <strong/> }}/>`,
  88. },
  89. {
  90. code: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} shouldUnescape tOptions={{ interpolation: { escapeValue: true } }}/>`,
  91. },
  92. ],
  93. invalid: [
  94. {
  95. code: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} />`,
  96. errors: [{ message: 'Trans with values must have shouldUnescape' }],
  97. output: `<Trans i18nKey="test" values={{ foo: 'bar' }}\nshouldUnescape components={{ strong: <strong/> }} />`,
  98. },
  99. {
  100. code: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} shouldUnescape />`,
  101. errors: [
  102. {
  103. message:
  104. 'Trans with shouldUnescape must have tOptions.interpolation.escapeValue',
  105. },
  106. ],
  107. output: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} shouldUnescape\ntOptions={{ interpolation: { escapeValue: true } }} />`,
  108. },
  109. ],
  110. })
  111. const noGeneratedEditorThemesError =
  112. 'EditorView.theme and EditorView.baseTheme each add CSS to the page for every instance of the theme. Store the theme in a variable and reuse it instead.'
  113. ruleTester.run('no-generated-editor-themes', noGeneratedEditorThemes, {
  114. valid: [
  115. {
  116. code: `EditorView.theme({ '.cm-editor': { color: 'black' } })`,
  117. },
  118. {
  119. code: `const theme = EditorView.theme({ '.cm-editor': { color: 'black' } })`,
  120. },
  121. ],
  122. invalid: [
  123. {
  124. code: `function createTheme() { return EditorView.theme({ '.cm-editor': { color: 'black' } }) }`,
  125. errors: [
  126. {
  127. message: noGeneratedEditorThemesError,
  128. },
  129. ],
  130. },
  131. {
  132. code: `() => EditorView.theme({ '.cm-editor': { color: 'black' } })`,
  133. errors: [
  134. {
  135. message: noGeneratedEditorThemesError,
  136. },
  137. ],
  138. },
  139. {
  140. code: `class Foo { createTheme() { return EditorView.theme({ '.cm-editor': { color: 'black' } }) } }`,
  141. errors: [
  142. {
  143. message: noGeneratedEditorThemesError,
  144. },
  145. ],
  146. },
  147. ],
  148. })
  149. ruleTester.run('domock-require-valid-path', viDoMockValidPath, {
  150. valid: [
  151. {
  152. code: 'vi.doMock("./require-vi-doMock-valid-path.js")',
  153. filename: __filename,
  154. },
  155. {
  156. code: 'const filename = "./require-vi-doMock-valid-path.js"; vi.doMock(filename);',
  157. filename: __filename,
  158. },
  159. ],
  160. invalid: [
  161. {
  162. code: "vi.doMock('./require-vi-doMock-valid-path2')",
  163. filename: __filename,
  164. errors: [
  165. {
  166. message:
  167. 'The path "./require-vi-doMock-valid-path2" in vi.doMock() cannot be resolved relative to the current file.',
  168. suggestions: [],
  169. },
  170. ],
  171. },
  172. {
  173. code: 'const filename = "./require-vi-doMock-valid-path2.js"; vi.doMock(filename);',
  174. filename: __filename,
  175. errors: [
  176. {
  177. message:
  178. 'The first argument of vi.doMock() must be (or resolve to) a string literal representing a path.',
  179. suggestions: [],
  180. },
  181. ],
  182. },
  183. ],
  184. })
  185. ruleTester.run(
  186. 'require-cio-snake-case-properties',
  187. requireCioSnakeCaseProperties,
  188. {
  189. valid: [
  190. // updateUserAttributes with snake_case keys
  191. {
  192. code: `CustomerIoHandler.updateUserAttributes(userId, { plan_type: 'free', group_size: 10 })`,
  193. },
  194. // Modules.promises.hooks.fire with snake_case keys
  195. {
  196. code: `Modules.promises.hooks.fire('setUserProperties', userId, { plan_type: 'free', last_active: 123 })`,
  197. },
  198. // Modules.hooks.fire with snake_case keys
  199. {
  200. code: `Modules.hooks.fire('setUserProperties', userId, { plan_type: 'free' })`,
  201. },
  202. // Single-word keys are valid snake_case
  203. {
  204. code: `CustomerIoHandler.updateUserAttributes(userId, { email: 'a@b.com', role: 'admin' })`,
  205. },
  206. // Computed/dynamic keys are skipped
  207. {
  208. code: `CustomerIoHandler.updateUserAttributes(userId, { [dynamicKey]: true })`,
  209. },
  210. // Spread elements are skipped
  211. {
  212. code: `CustomerIoHandler.updateUserAttributes(userId, { ...existingAttrs })`,
  213. },
  214. // Unrelated function calls are not checked
  215. {
  216. code: `SomeOtherHandler.updateUserAttributes(userId, { camelCase: true })`,
  217. },
  218. // fire() with a different event name is not checked
  219. {
  220. code: `Modules.promises.hooks.fire('someOtherEvent', userId, { camelCase: true })`,
  221. },
  222. ],
  223. invalid: [
  224. // camelCase key in updateUserAttributes
  225. {
  226. code: `CustomerIoHandler.updateUserAttributes(userId, { planType: 'free' })`,
  227. errors: [
  228. {
  229. message: `Customer.io attribute 'planType' must be in snake_case.`,
  230. },
  231. ],
  232. },
  233. // kebab-case string key
  234. {
  235. code: `CustomerIoHandler.updateUserAttributes(userId, { 'plan-type': 'free' })`,
  236. errors: [
  237. {
  238. message: `Customer.io attribute 'plan-type' must be in snake_case.`,
  239. },
  240. ],
  241. },
  242. // PascalCase key
  243. {
  244. code: `CustomerIoHandler.updateUserAttributes(userId, { PlanType: 'free' })`,
  245. errors: [
  246. {
  247. message: `Customer.io attribute 'PlanType' must be in snake_case.`,
  248. },
  249. ],
  250. },
  251. // camelCase in Modules.promises.hooks.fire
  252. {
  253. code: `Modules.promises.hooks.fire('setUserProperties', userId, { planType: 'free' })`,
  254. errors: [
  255. {
  256. message: `Customer.io attribute 'planType' must be in snake_case.`,
  257. },
  258. ],
  259. },
  260. // camelCase in Modules.hooks.fire
  261. {
  262. code: `Modules.hooks.fire('setUserProperties', userId, { planType: 'free' })`,
  263. errors: [
  264. {
  265. message: `Customer.io attribute 'planType' must be in snake_case.`,
  266. },
  267. ],
  268. },
  269. // Multiple invalid keys report multiple errors
  270. {
  271. code: `CustomerIoHandler.updateUserAttributes(userId, { planType: 'free', groupSize: 10, plan_term: 'annual' })`,
  272. errors: [
  273. {
  274. message: `Customer.io attribute 'planType' must be in snake_case.`,
  275. },
  276. {
  277. message: `Customer.io attribute 'groupSize' must be in snake_case.`,
  278. },
  279. ],
  280. },
  281. ],
  282. }
  283. )
  284. const noThrowInCallbackMessage =
  285. 'Pass the error to the callback instead of throwing in callback-based code.'
  286. ruleTester.run('no-throw-in-callback', noThrowInCallback, {
  287. valid: [
  288. // Calling the callback with an error is fine
  289. { code: `function foo(cb) { cb(new Error()) }` },
  290. // async functions may throw (they return a rejected promise)
  291. { code: `async function foo(cb) { throw new Error() }` },
  292. // Last param not a callback name — not a callback-style function
  293. { code: `function foo(data) { throw new Error() }` },
  294. // No params at all
  295. { code: `function foo() { throw new Error() }` },
  296. // throw inside a nested non-callback function is fine
  297. { code: `function foo(cb) { [1].map(function() { throw new Error() }) }` },
  298. // throw inside a nested async arrow is fine
  299. { code: `function foo(cb) { [1].map(async () => { throw new Error() }) }` },
  300. ],
  301. invalid: [
  302. {
  303. code: `function foo(cb) { throw new Error() }`,
  304. errors: [{ message: noThrowInCallbackMessage }],
  305. },
  306. {
  307. code: `function foo(callback) { throw new Error() }`,
  308. errors: [{ message: noThrowInCallbackMessage }],
  309. },
  310. {
  311. code: `function foo(done) { throw new Error() }`,
  312. errors: [{ message: noThrowInCallbackMessage }],
  313. },
  314. {
  315. code: `function foo(next) { throw new Error() }`,
  316. errors: [{ message: noThrowInCallbackMessage }],
  317. },
  318. {
  319. code: `function foo(data, cb) { throw new Error() }`,
  320. errors: [{ message: noThrowInCallbackMessage }],
  321. },
  322. {
  323. code: `const foo = (cb) => { throw new Error() }`,
  324. errors: [{ message: noThrowInCallbackMessage }],
  325. },
  326. // throw in a nested callback-style function inside another callback function
  327. {
  328. code: `function foo(cb) { bar(function(done) { throw new Error() }) }`,
  329. errors: [{ message: noThrowInCallbackMessage }],
  330. },
  331. ],
  332. })
  333. jsonRuleTester.run(
  334. 'no-consecutive-spaces-in-locales',
  335. noConsecutiveSpacesInLocales,
  336. {
  337. valid: [
  338. { code: '{ "key": "one space" }' },
  339. { code: '{ "key": "no whitespace" }' },
  340. ],
  341. invalid: [
  342. {
  343. code: '{ "key": "two spaces" }',
  344. errors: [{ messageId: 'consecutiveSpaces' }],
  345. output: '{ "key": "two spaces" }',
  346. },
  347. {
  348. code: '{ "key": "three spaces" }',
  349. errors: [{ messageId: 'consecutiveSpaces' }],
  350. output: '{ "key": "three spaces" }',
  351. },
  352. {
  353. // \t then space → two consecutive whitespace chars
  354. code: '{ "key": "tab\\t and" }',
  355. errors: [{ messageId: 'consecutiveSpaces' }],
  356. output: '{ "key": "tab and" }',
  357. },
  358. ],
  359. }
  360. )
  361. jsonRuleTester.run(
  362. 'no-straight-apostrophes-in-locales',
  363. noStraightApostrophesInLocales,
  364. {
  365. valid: [
  366. { code: '{ "key": "no apostrophe" }' },
  367. { code: '{ "key": "it’s curly" }' },
  368. ],
  369. invalid: [
  370. {
  371. code: `{ "key": "it's straight" }`,
  372. errors: [{ messageId: 'straightApostrophe' }],
  373. output: '{ "key": "it’s straight" }',
  374. },
  375. ],
  376. }
  377. )
  378. jsonRuleTester.run('sorted-keys-in-locales', sortedKeysInLocales, {
  379. valid: [
  380. { code: '{\n "a": "1",\n "b": "2"\n}\n' },
  381. { code: '{}' },
  382. { code: '{ "only": "one" }' },
  383. ],
  384. invalid: [
  385. {
  386. code: '{\n "b": "2",\n "a": "1"\n}\n',
  387. errors: [{ messageId: 'unsorted' }],
  388. output: '{\n "a": "1",\n "b": "2"\n}\n',
  389. },
  390. ],
  391. })
  392. jsonRuleTester.run('french-typography-in-locales', frenchTypographyInLocales, {
  393. valid: [
  394. { code: '{ "key": "Bonjour ?" }' },
  395. { code: '{ "key": "Liste :" }' },
  396. { code: '{ "key": "« contenu »" }' },
  397. { code: '{ "key": "abc 123" }' },
  398. { code: '{ "key": "10 %" }' },
  399. ],
  400. invalid: [
  401. {
  402. code: '{ "key": "Bonjour?" }',
  403. errors: 1,
  404. output: '{ "key": "Bonjour ?" }',
  405. },
  406. {
  407. code: '{ "key": "Bonjour ?" }',
  408. errors: 1,
  409. output: '{ "key": "Bonjour ?" }',
  410. },
  411. {
  412. code: '{ "key": "Liste:" }',
  413. errors: 1,
  414. output: '{ "key": "Liste :" }',
  415. },
  416. {
  417. code: '{ "key": "«contenu»" }',
  418. errors: 2,
  419. output: '{ "key": "« contenu »" }',
  420. },
  421. {
  422. code: '{ "key": "10%" }',
  423. errors: 1,
  424. output: '{ "key": "10 %" }',
  425. },
  426. {
  427. code: '{ "key": "sûr(e)" }',
  428. errors: [
  429. {
  430. message:
  431. 'expected point médian "·" instead of "(e)" for inclusive writing',
  432. suggestions: [
  433. {
  434. desc: 'Replace "(e)" with "·e"',
  435. output: '{ "key": "sûr·e" }',
  436. },
  437. ],
  438. },
  439. ],
  440. },
  441. ],
  442. })