import { fileURLToPath } from 'node:url' import { RuleTester } from 'eslint' import tsParser from '@typescript-eslint/parser' import json from '@eslint/json' import noThrowInCallback from '../../no-throw-in-callback.js' import noRawReqAccess from '../../no-raw-req-access.js' import preferKebabUrl from '../../prefer-kebab-url.js' import noUnnecessaryTrans from '../../no-unnecessary-trans.js' import shouldUnescapeTrans from '../../should-unescape-trans.js' import noGeneratedEditorThemes from '../../no-generated-editor-themes.js' import viDoMockValidPath from '../../require-vi-doMock-valid-path.js' import requireCioSnakeCaseProperties from '../../require-cio-snake-case-properties.js' import requireRelForDocsLinks from '../../require-rel-for-docs-links.js' import noConsecutiveSpacesInLocales from '../../no-consecutive-spaces-in-locales.js' import noStraightApostrophesInLocales from '../../no-straight-apostrophes-in-locales.js' import frenchTypographyInLocales from '../../french-typography-in-locales.js' import sortedKeysInLocales from '../../sorted-keys-in-locales.js' import noMochaBefore from '../../no-mocha-before.js' const __filename = fileURLToPath(import.meta.url) const ruleTester = new RuleTester({ languageOptions: { parser: tsParser, ecmaVersion: 'latest', parserOptions: { ecmaFeatures: { jsx: true } }, }, }) const jsonRuleTester = new RuleTester({ plugins: { json }, language: 'json/json', }) ruleTester.run('prefer-kebab-url', preferKebabUrl, { valid: [ { code: `app.get('/foo-bar')` }, { code: `app.get('/foo-bar/:id')` }, { code: `router.post('/foo-bar')` }, { code: `router.get('/foo-bar/:id/:name/:age')` }, { code: `webRouter.get('/foo-bar/:user_id/(ProjectName)/get-info')` }, { code: `webApp.post('/foo-bar/:user_id/(ProjectName)/get-info')` }, { code: `router.get(/^\\/download\\/project\\/([^/]*)\\/output\\/output\\.pdf$/)`, }, { code: `webRouter.get(/^\\/project\\/([^/]*)\\/user\\/([0-9a-f]+)\\/build\\/([0-9a-f-]+)\\/output\\/(.*)$/)`, }, ], invalid: [ { code: `app.get('/fooBar')`, errors: [ { message: 'Route path should be in kebab-case.', suggestions: 1 }, ], }, { code: `app.get('/fooBar/:id')`, errors: [ { message: 'Route path should be in kebab-case.', suggestions: 1 }, ], }, { code: `webRouter.get('/foo_bar/:id/FooBar/:name/fooBar')`, errors: [ { message: 'Route path should be in kebab-case.', suggestions: 1 }, ], }, { code: `router.get(/^\\/downLoad\\/pro-ject\\/([^/]*)\\/OutPut\\/out-put\\.pdf$/)`, errors: [ { message: 'Route path should be in kebab-case.', suggestions: [ { desc: 'Change to kebab-case: /^\\/down-load\\/pro-ject\\/([^/]*)\\/out-put\\/out-put\\.pdf$/', output: `router.get(/^\\/down-load\\/pro-ject\\/([^/]*)\\/out-put\\/out-put\\.pdf$/)`, }, ], }, ], }, ], }) ruleTester.run('no-unnecessary-trans', noUnnecessaryTrans, { valid: [ { code: ` }}/>` }, ], invalid: [ { code: ``, errors: [{ message: `Use t('…') when there are no components` }], }, { code: ``, errors: [{ message: `Use t('…') when there are no components` }], output: `{t('test')}`, }, ], }) ruleTester.run('should-unescape-trans', shouldUnescapeTrans, { valid: [ { code: ` }}/>`, }, { code: ` }} shouldUnescape tOptions={{ interpolation: { escapeValue: true } }}/>`, }, ], invalid: [ { code: ` }} />`, errors: [{ message: 'Trans with values must have shouldUnescape' }], output: ` }} />`, }, { code: ` }} shouldUnescape />`, errors: [ { message: 'Trans with shouldUnescape must have tOptions.interpolation.escapeValue', }, ], output: ` }} shouldUnescape\ntOptions={{ interpolation: { escapeValue: true } }} />`, }, ], }) const noGeneratedEditorThemesError = '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.' ruleTester.run('no-generated-editor-themes', noGeneratedEditorThemes, { valid: [ { code: `EditorView.theme({ '.cm-editor': { color: 'black' } })`, }, { code: `const theme = EditorView.theme({ '.cm-editor': { color: 'black' } })`, }, ], invalid: [ { code: `function createTheme() { return EditorView.theme({ '.cm-editor': { color: 'black' } }) }`, errors: [ { message: noGeneratedEditorThemesError, }, ], }, { code: `() => EditorView.theme({ '.cm-editor': { color: 'black' } })`, errors: [ { message: noGeneratedEditorThemesError, }, ], }, { code: `class Foo { createTheme() { return EditorView.theme({ '.cm-editor': { color: 'black' } }) } }`, errors: [ { message: noGeneratedEditorThemesError, }, ], }, ], }) ruleTester.run('domock-require-valid-path', viDoMockValidPath, { valid: [ { code: 'vi.doMock("../../require-vi-doMock-valid-path.js")', filename: __filename, }, { code: 'const filename = "../../require-vi-doMock-valid-path.js"; vi.doMock(filename);', filename: __filename, }, ], invalid: [ { code: "vi.doMock('../../require-vi-doMock-valid-path2')", filename: __filename, errors: [ { message: 'The path "../../require-vi-doMock-valid-path2" in vi.doMock() cannot be resolved relative to the current file.', suggestions: [], }, ], }, { code: 'const filename = "../../require-vi-doMock-valid-path2.js"; vi.doMock(filename);', filename: __filename, errors: [ { message: 'The first argument of vi.doMock() must be (or resolve to) a string literal representing a path.', suggestions: [], }, ], }, ], }) ruleTester.run( 'require-cio-snake-case-properties', requireCioSnakeCaseProperties, { valid: [ // updateUserAttributes with snake_case keys { code: `CustomerIoHandler.updateUserAttributes(userId, { plan_type: 'free', group_size: 10 })`, }, // Modules.promises.hooks.fire with snake_case keys { code: `Modules.promises.hooks.fire('setUserProperties', userId, { plan_type: 'free', last_active: 123 })`, }, // Modules.hooks.fire with snake_case keys { code: `Modules.hooks.fire('setUserProperties', userId, { plan_type: 'free' })`, }, // Single-word keys are valid snake_case { code: `CustomerIoHandler.updateUserAttributes(userId, { email: 'a@b.com', role: 'admin' })`, }, // Computed/dynamic keys are skipped { code: `CustomerIoHandler.updateUserAttributes(userId, { [dynamicKey]: true })`, }, // Spread elements are skipped { code: `CustomerIoHandler.updateUserAttributes(userId, { ...existingAttrs })`, }, // Unrelated function calls are not checked { code: `SomeOtherHandler.updateUserAttributes(userId, { camelCase: true })`, }, // fire() with a different event name is not checked { code: `Modules.promises.hooks.fire('someOtherEvent', userId, { camelCase: true })`, }, ], invalid: [ // camelCase key in updateUserAttributes { code: `CustomerIoHandler.updateUserAttributes(userId, { planType: 'free' })`, errors: [ { message: `Customer.io attribute 'planType' must be in snake_case.`, }, ], }, // kebab-case string key { code: `CustomerIoHandler.updateUserAttributes(userId, { 'plan-type': 'free' })`, errors: [ { message: `Customer.io attribute 'plan-type' must be in snake_case.`, }, ], }, // PascalCase key { code: `CustomerIoHandler.updateUserAttributes(userId, { PlanType: 'free' })`, errors: [ { message: `Customer.io attribute 'PlanType' must be in snake_case.`, }, ], }, // camelCase in Modules.promises.hooks.fire { code: `Modules.promises.hooks.fire('setUserProperties', userId, { planType: 'free' })`, errors: [ { message: `Customer.io attribute 'planType' must be in snake_case.`, }, ], }, // camelCase in Modules.hooks.fire { code: `Modules.hooks.fire('setUserProperties', userId, { planType: 'free' })`, errors: [ { message: `Customer.io attribute 'planType' must be in snake_case.`, }, ], }, // Multiple invalid keys report multiple errors { code: `CustomerIoHandler.updateUserAttributes(userId, { planType: 'free', groupSize: 10, plan_term: 'annual' })`, errors: [ { message: `Customer.io attribute 'planType' must be in snake_case.`, }, { message: `Customer.io attribute 'groupSize' must be in snake_case.`, }, ], }, ], } ) const noThrowInCallbackMessage = 'Pass the error to the callback instead of throwing in callback-based code.' ruleTester.run('no-throw-in-callback', noThrowInCallback, { valid: [ // Calling the callback with an error is fine { code: `function foo(cb) { cb(new Error()) }` }, // async functions may throw (they return a rejected promise) { code: `async function foo(cb) { throw new Error() }` }, // Last param not a callback name — not a callback-style function { code: `function foo(data) { throw new Error() }` }, // No params at all { code: `function foo() { throw new Error() }` }, // throw inside a nested non-callback function is fine { code: `function foo(cb) { [1].map(function() { throw new Error() }) }` }, // throw inside a nested async arrow is fine { code: `function foo(cb) { [1].map(async () => { throw new Error() }) }` }, ], invalid: [ { code: `function foo(cb) { throw new Error() }`, errors: [{ message: noThrowInCallbackMessage }], }, { code: `function foo(callback) { throw new Error() }`, errors: [{ message: noThrowInCallbackMessage }], }, { code: `function foo(done) { throw new Error() }`, errors: [{ message: noThrowInCallbackMessage }], }, { code: `function foo(next) { throw new Error() }`, errors: [{ message: noThrowInCallbackMessage }], }, { code: `function foo(data, cb) { throw new Error() }`, errors: [{ message: noThrowInCallbackMessage }], }, { code: `const foo = (cb) => { throw new Error() }`, errors: [{ message: noThrowInCallbackMessage }], }, // throw in a nested callback-style function inside another callback function { code: `function foo(cb) { bar(function(done) { throw new Error() }) }`, errors: [{ message: noThrowInCallbackMessage }], }, ], }) ruleTester.run('no-raw-req-access', noRawReqAccess, { valid: [ // validated access goes through parseReq { code: `const { params, body } = parseReq(req, schema)` }, // allowlisted middleware reads raw input explicitly { code: `const { query } = getRawReqInput(req)` }, // writes stay allowed: body parsers and middleware assign req.body { code: `req.body = {}` }, { code: `req.query = parsed` }, // other request fields are not locked { code: `const ua = req.headers['user-agent']` }, { code: `const s = req.session` }, // other objects with the same field names are fine { code: `const b = options.body` }, { code: `const { body } = response` }, ], invalid: [ { code: `const id = req.params.project_id`, errors: [{ messageId: 'noRawReqAccess', data: { field: 'params' } }], }, { code: `if (req.query.force) { doIt() }`, errors: [{ messageId: 'noRawReqAccess', data: { field: 'query' } }], }, { code: `send(req.body)`, errors: [{ messageId: 'noRawReqAccess', data: { field: 'body' } }], }, // computed string access { code: `const b = req['body']`, errors: [{ messageId: 'noRawReqAccess', data: { field: 'body' } }], }, // destructuring reads { code: `const { body } = req`, errors: [{ messageId: 'noRawReqAccess', data: { field: 'body' } }], }, { code: `const { params, query } = req`, errors: [ { messageId: 'noRawReqAccess', data: { field: 'params' } }, { messageId: 'noRawReqAccess', data: { field: 'query' } }, ], }, // compound assignment reads before writing { code: `req.body ||= {}`, errors: [{ messageId: 'noRawReqAccess', data: { field: 'body' } }], }, // optional chaining { code: `const q = req?.query`, errors: [{ messageId: 'noRawReqAccess', data: { field: 'query' } }], }, ], }) ruleTester.run('require-rel-for-docs-links', requireRelForDocsLinks, { valid: [ { code: `x`, }, { code: `x`, }, { code: `x` }, { code: `x` }, { code: `x` }, { code: `x`, }, { code: `x` }, ], invalid: [ { code: `x`, errors: [ { message: 'Links to docs.overleaf.com with target="_blank" must have rel="noopener noreferrer" to prevent reverse-tabnabbing.', }, ], output: `x`, }, { code: `x`, errors: [ { message: 'Links to docs.overleaf.com with target="_blank" must have rel="noopener noreferrer" to prevent reverse-tabnabbing.', }, ], output: `x`, }, { code: `x`, errors: [ { message: 'Links to docs.overleaf.com with target="_blank" must have rel="noopener noreferrer" to prevent reverse-tabnabbing.', }, ], output: `x`, }, { code: `x`, errors: [ { message: 'Links to docs.overleaf.com with target="_blank" must have rel="noopener noreferrer" to prevent reverse-tabnabbing.', }, ], output: `x`, }, { code: `x`, errors: [ { message: 'Links to docs.overleaf.com with target="_blank" must have rel="noopener noreferrer" to prevent reverse-tabnabbing.', }, ], output: `x`, }, { // Boolean attribute (no value) - relAttr.value is null, must not crash the fixer. code: ``, errors: [ { message: 'Links to docs.overleaf.com with target="_blank" must have rel="noopener noreferrer" to prevent reverse-tabnabbing.', }, ], output: ``, }, ], }) jsonRuleTester.run( 'no-consecutive-spaces-in-locales', noConsecutiveSpacesInLocales, { valid: [ { code: '{ "key": "one space" }' }, { code: '{ "key": "no whitespace" }' }, ], invalid: [ { code: '{ "key": "two spaces" }', errors: [{ messageId: 'consecutiveSpaces' }], output: '{ "key": "two spaces" }', }, { code: '{ "key": "three spaces" }', errors: [{ messageId: 'consecutiveSpaces' }], output: '{ "key": "three spaces" }', }, { // \t then space → two consecutive whitespace chars code: '{ "key": "tab\\t and" }', errors: [{ messageId: 'consecutiveSpaces' }], output: '{ "key": "tab and" }', }, ], } ) jsonRuleTester.run( 'no-straight-apostrophes-in-locales', noStraightApostrophesInLocales, { valid: [ { code: '{ "key": "no apostrophe" }' }, { code: '{ "key": "it’s curly" }' }, ], invalid: [ { code: `{ "key": "it's straight" }`, errors: [{ messageId: 'straightApostrophe' }], output: '{ "key": "it’s straight" }', }, ], } ) jsonRuleTester.run('sorted-keys-in-locales', sortedKeysInLocales, { valid: [ { code: '{\n "a": "1",\n "b": "2"\n}\n' }, { code: '{}' }, { code: '{ "only": "one" }' }, ], invalid: [ { code: '{\n "b": "2",\n "a": "1"\n}\n', errors: [{ messageId: 'unsorted' }], output: '{\n "a": "1",\n "b": "2"\n}\n', }, ], }) jsonRuleTester.run('french-typography-in-locales', frenchTypographyInLocales, { valid: [ { code: '{ "key": "Bonjour ?" }' }, { code: '{ "key": "Liste :" }' }, { code: '{ "key": "« contenu »" }' }, { code: '{ "key": "abc 123" }' }, { code: '{ "key": "10 %" }' }, ], invalid: [ { code: '{ "key": "Bonjour?" }', errors: 1, output: '{ "key": "Bonjour ?" }', }, { code: '{ "key": "Bonjour ?" }', errors: 1, output: '{ "key": "Bonjour ?" }', }, { code: '{ "key": "Liste:" }', errors: 1, output: '{ "key": "Liste :" }', }, { code: '{ "key": "«contenu»" }', errors: 2, output: '{ "key": "« contenu »" }', }, { code: '{ "key": "10%" }', errors: 1, output: '{ "key": "10 %" }', }, { code: '{ "key": "sûr(e)" }', errors: [ { message: 'expected point médian "·" instead of "(e)" for inclusive writing', suggestions: [ { desc: 'Replace "(e)" with "·e"', output: '{ "key": "sûr·e" }', }, ], }, ], }, ], }) const noMochaBeforeOptions = [ { helperPath: 'server-ce/test/helpers/beforeWithReRunOnTestRetry' }, ] ruleTester.run('no-mocha-before', noMochaBefore, { valid: [ { code: `beforeEach(function () { setup() })`, options: noMochaBeforeOptions, }, { code: `import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry' beforeWithReRunOnTestRetry(function () { setup() })`, options: noMochaBeforeOptions, }, { // locally defined `before` is not the mocha hook code: `function before(fn) { fn() } before(function () { setup() })`, options: noMochaBeforeOptions, }, ], invalid: [ { // rename + import added after the last existing import filename: 'server-ce/test/foo.spec.ts', code: `import { login } from './helpers/login' before(async function () { this.timeout(1000) })`, options: noMochaBeforeOptions, errors: 1, output: `import { login } from './helpers/login' import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry' beforeWithReRunOnTestRetry(async function () { this.timeout(1000) })`, }, { // no imports yet: import inserted at the top filename: 'server-ce/test/foo.spec.ts', code: `before(function () { setup() })`, options: noMochaBeforeOptions, errors: 1, output: `import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry' beforeWithReRunOnTestRetry(function () { setup() })`, }, { // helper already imported: rename only filename: 'server-ce/test/foo.spec.ts', code: `import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry' before(function () { setup() })`, options: noMochaBeforeOptions, errors: 1, output: `import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry' beforeWithReRunOnTestRetry(function () { setup() })`, }, { // file inside helpers/: relative path has no directory prefix filename: 'server-ce/test/helpers/config.ts', code: `before(function () { setup() })`, options: noMochaBeforeOptions, errors: 1, output: `import { beforeWithReRunOnTestRetry } from './beforeWithReRunOnTestRetry' beforeWithReRunOnTestRetry(function () { setup() })`, }, { // two-argument mocha form: report without autofix filename: 'server-ce/test/foo.spec.ts', code: `before('named hook', function () { setup() })`, options: noMochaBeforeOptions, errors: 1, }, { // no helperPath configured: report without autofix filename: 'server-ce/test/foo.spec.ts', code: `before(function () { setup() })`, errors: 1, }, ], })