Files
overleaf-cep/libraries/eslint-plugin/test/unit/rules.test.js
T
Jakob Ackermann 5425202247 [e2e] cut down flakeyness (#35896)
* [saas-e2e] conversion: wait for initial compile before looking at PDF

* [eslint-plugin] s/before/beforeWithReRunOnTestRetry/ in e2e tests

* [server-ce] remove filestore migration scripts and E2E tests

* [server-ce] test: trim down upgrading tests to 5.x -> latest

GitOrigin-RevId: 2426ec4a4f3d67a5e0e7850ea84a942ae4619f94
2026-09-17 11:38:44 +00:00

628 lines
20 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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: `<Trans i18nKey="test" components={{ strong: <strong/> }}/>` },
],
invalid: [
{
code: `<Trans i18nKey="test" values={{ test: 'foo '}}/>`,
errors: [{ message: `Use t('…') when there are no components` }],
},
{
code: `<Trans i18nKey="test" />`,
errors: [{ message: `Use t('…') when there are no components` }],
output: `{t('test')}`,
},
],
})
ruleTester.run('should-unescape-trans', shouldUnescapeTrans, {
valid: [
{
code: `<Trans i18nKey="test" components={{ strong: <strong/> }}/>`,
},
{
code: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} shouldUnescape tOptions={{ interpolation: { escapeValue: true } }}/>`,
},
],
invalid: [
{
code: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} />`,
errors: [{ message: 'Trans with values must have shouldUnescape' }],
output: `<Trans i18nKey="test" values={{ foo: 'bar' }}\nshouldUnescape components={{ strong: <strong/> }} />`,
},
{
code: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} shouldUnescape />`,
errors: [
{
message:
'Trans with shouldUnescape must have tOptions.interpolation.escapeValue',
},
],
output: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} 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('require-rel-for-docs-links', requireRelForDocsLinks, {
valid: [
{
code: `<a href="https://docs.overleaf.com/foo" target="_blank" rel="noopener noreferrer">x</a>`,
},
{
code: `<a href="https://docs.overleaf.com/foo" target="_blank" rel="noreferrer noopener">x</a>`,
},
{ code: `<a href="/learn/foo" target="_blank">x</a>` },
{ code: `<a href="https://docs.overleaf.com/foo">x</a>` },
{ code: `<a href="https://docs.overleaf.com/foo" target="_self">x</a>` },
{
code: `<OLButton href="https://docs.overleaf.com/foo" target="_blank" rel="noopener noreferrer">x</OLButton>`,
},
{ code: `<OLButton href="https://docs.overleaf.com/foo">x</OLButton>` },
],
invalid: [
{
code: `<a href="https://docs.overleaf.com/foo" target="_blank">x</a>`,
errors: [
{
message:
'Links to docs.overleaf.com with target="_blank" must have rel="noopener noreferrer" to prevent reverse-tabnabbing.',
},
],
output: `<a href="https://docs.overleaf.com/foo" target="_blank" rel="noopener noreferrer">x</a>`,
},
{
code: `<a href="https://docs.overleaf.com/foo" target="_BLANK">x</a>`,
errors: [
{
message:
'Links to docs.overleaf.com with target="_blank" must have rel="noopener noreferrer" to prevent reverse-tabnabbing.',
},
],
output: `<a href="https://docs.overleaf.com/foo" target="_BLANK" rel="noopener noreferrer">x</a>`,
},
{
code: `<a href="https://docs.overleaf.com/foo" target="_blank" rel="noopener">x</a>`,
errors: [
{
message:
'Links to docs.overleaf.com with target="_blank" must have rel="noopener noreferrer" to prevent reverse-tabnabbing.',
},
],
output: `<a href="https://docs.overleaf.com/foo" target="_blank" rel="noopener noreferrer">x</a>`,
},
{
code: `<a href="https://docs.overleaf.com/foo" target="_blank" rel="noreferrer">x</a>`,
errors: [
{
message:
'Links to docs.overleaf.com with target="_blank" must have rel="noopener noreferrer" to prevent reverse-tabnabbing.',
},
],
output: `<a href="https://docs.overleaf.com/foo" target="_blank" rel="noreferrer noopener">x</a>`,
},
{
code: `<OLButton href="https://docs.overleaf.com/foo" target="_blank">x</OLButton>`,
errors: [
{
message:
'Links to docs.overleaf.com with target="_blank" must have rel="noopener noreferrer" to prevent reverse-tabnabbing.',
},
],
output: `<OLButton href="https://docs.overleaf.com/foo" target="_blank" rel="noopener noreferrer">x</OLButton>`,
},
{
// Boolean attribute (no value) - relAttr.value is null, must not crash the fixer.
code: `<a href="https://docs.overleaf.com/foo" target="_blank" rel />`,
errors: [
{
message:
'Links to docs.overleaf.com with target="_blank" must have rel="noopener noreferrer" to prevent reverse-tabnabbing.',
},
],
output: `<a href="https://docs.overleaf.com/foo" target="_blank" rel="noopener noreferrer" />`,
},
],
})
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,
},
],
})