Chore: Add ESLint rule to enforce describe() titles match test file names (#16319)

This commit is contained in:
Laurent Cozic authored and GitHub committed 2026-08-26 21:01:34 +01:00
1 parent b6889d3e7d
commit 8e9ffe3386
59 files changed
+210 -54

No files matched your search

+2 -1
View File
@@ -2119,6 +2119,7 @@ packages/tools/checkIgnoredFiles.js
packages/tools/checkLibPaths.test.js
packages/tools/checkLibPaths.js
packages/tools/convertThemesToCss.js
packages/tools/eslint-rules/describeFilename.test.js
packages/tools/fuzzer/ActionRunner.js
packages/tools/fuzzer/Fuzzer.js
packages/tools/fuzzer/cli.js
@@ -2196,8 +2197,8 @@ packages/tools/updateMarkdownDoc.js
packages/tools/utils/discourse.test.js
packages/tools/utils/discourse.js
packages/tools/utils/loadSponsors.js
packages/tools/utils/parsePluralLocalizationForm.test.js
packages/tools/utils/parsePluralLocalizationForm.js
packages/tools/utils/parsePlurallLocalizationForm.test.js
packages/tools/utils/translation.js
packages/tools/validateFilenames.js
packages/tools/website/build.js
+3 -1
View File
@@ -83,6 +83,7 @@ packages/lib/welcomeAssets.js
packages/editor/*/vendor/
packages/plugins/**/api
packages/plugins/**/dist
packages/plugin-repo-cli/dist/
packages/server/dist/
packages/utils/dist/
packages/transcribe/dist/
@@ -2144,6 +2145,7 @@ packages/tools/checkIgnoredFiles.js
packages/tools/checkLibPaths.test.js
packages/tools/checkLibPaths.js
packages/tools/convertThemesToCss.js
packages/tools/eslint-rules/describeFilename.test.js
packages/tools/fuzzer/ActionRunner.js
packages/tools/fuzzer/Fuzzer.js
packages/tools/fuzzer/cli.js
@@ -2221,8 +2223,8 @@ packages/tools/updateMarkdownDoc.js
packages/tools/utils/discourse.test.js
packages/tools/utils/discourse.js
packages/tools/utils/loadSponsors.js
packages/tools/utils/parsePluralLocalizationForm.test.js
packages/tools/utils/parsePluralLocalizationForm.js
packages/tools/utils/parsePlurallLocalizationForm.test.js
packages/tools/utils/translation.js
packages/tools/validateFilenames.js
packages/tools/website/build.js
+9
View File
@@ -14,6 +14,8 @@ const github = require('eslint-plugin-github');
const { fixupPluginRules, includeIgnoreFile } = require('@eslint/compat');
const js = require('@eslint/js');
const joplin = require('./packages/tools/eslint-rules');
const { join } = require('path');
// As of ESLint v9, .eslintignore is no longer supported and the presence of
@@ -246,6 +248,7 @@ module.exports = defineConfig([{
promise,
jest,
github: fixupPluginRules(github),
joplin,
},
}, {
files: [
@@ -257,6 +260,12 @@ module.exports = defineConfig([{
rules: {
'no-console': 'off',
},
}, {
files: ['**/*.test.ts', '**/*.test.tsx', '**/*.test.js', '**/*.test.jsx'],
rules: {
'joplin/describe-filename': ['error'],
},
}, {
// .jsx files should be linted with the default rules
files: ['**/*.jsx'],
@@ -5,7 +5,7 @@ import { flipNthCheckbox } from './useCheckboxToggle';
// `flipNthCheckbox` must count exactly the same subset so the Nth rendered
// checkbox maps to the Nth source-text checkbox.
describe('flipNthCheckbox', () => {
describe('useCheckboxToggle', () => {
test('flips an unchecked box to checked', () => {
expect(flipNthCheckbox('- [ ] todo', 0)).toBe('- [x] todo');
});
@@ -8,7 +8,7 @@ type Props = {
makeItemIndexVisible: (index: number)=> void;
};
describe('useAutoScroll', () => {
describe('UseAutoScroll', () => {
test('scrolls to the note when a new note is selected', () => {
const makeItemIndexVisible = jest.fn();
@@ -1,6 +1,6 @@
import PromptDialog from './PromptDialog';
describe('PromptDialog Escape key handling', () => {
describe('PromptDialog', () => {
const setupKeyHandler = (inputType: string, menuIsOpened: boolean) => {
const onCloseMock = jest.fn();
@@ -12,7 +12,7 @@ jest.mock('../../../services/bridge', () => ({
}),
}));
describe('mapStateToTitle', () => {
describe('showSpellCheckerMenu', () => {
test('should return null if spellchecker.enabled is false', () => {
@@ -1,7 +1,7 @@
import { extractVersionInfo, Release, Platform, Architecture, GitHubRelease, handleReleaseResponseError } from './checkForUpdatesUtils';
import { releases1, releases2 } from './checkForUpdatesUtilsTestData';
describe('checkForUpdates', () => {
describe('checkForUpdatesUtils', () => {
it('should extract version info and return the non-arm64 version', async () => {
const testCases: [GitHubRelease[], Platform, Architecture, boolean, Release][] = [
@@ -158,7 +158,7 @@ const setupNoteWithPanes = async (panes: string[], noteTitle = 'Test note') => {
return renderResult;
};
describe('screens/Note', () => {
describe('screens/Note/Note', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await setupDatabaseAndSynchronizer(0);
@@ -31,7 +31,7 @@ const WrappedSearchResults: React.FC<WrapperProps> = props => (
</TestProviderStack>
);
describe('SearchResult', () => {
describe('SearchResults', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
@@ -29,7 +29,7 @@ const ShareManagerWrapper: React.FC<WrapperProps> = props => {
);
};
describe('ShareManager', () => {
describe('ShareManager/index', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
@@ -48,7 +48,7 @@ const createEditorWithCallbacks = (callbacks: Partial<MainProcessApi>) => {
return createJsDrawEditor(allCallbacks, toolbarState, locale, localizations, editorOptions);
};
describe('imageEditor/contentScript/index', () => {
describe('contentScript/index', () => {
it('should trigger autosave callback every few minutes', async () => {
let calledAutosaveCount = 0;
@@ -3,7 +3,7 @@ import createTestEditor from '../../testing/createTestEditor';
import followLinkTooltip from './followLinkTooltipExtension';
import { EditorSelection } from '@codemirror/state';
describe('followLinkTooltip', () => {
describe('followLinkTooltipExtension', () => {
it('should show a clickable tooltip for a URL link', async () => {
const doc = '[link](http://example.com/)';
const onOpenLink = jest.fn();
@@ -4,7 +4,7 @@ import decoratorExtension from './markdownDecorationExtension';
jest.retryTimes(2);
describe('decoratorExtension', () => {
describe('markdownDecorationExtension', () => {
it('should highlight code blocks within tables', async () => {
// Regression test for https://github.com/laurent22/joplin/issues/9477
const editorText = `
@@ -9,7 +9,7 @@ const createEditorState = async (initialText: string, expectedTags: string[]): P
return (await createTestEditor(initialText, EditorSelection.cursor(0), expectedTags)).state;
};
describe('MarkdownFrontMatterExtension', () => {
describe('markdownFrontMatterExtension', () => {
jest.retryTimes(2);
@@ -9,7 +9,7 @@ const createEditorState = async (initialText: string, expectedTags: string[]): P
};
describe('MarkdownHighlightExtension', () => {
describe('markdownHighlightExtension', () => {
jest.retryTimes(2);
it.each([
@@ -9,7 +9,7 @@ const createEditorState = async (initialText: string, expectedTags: string[]): P
return (await createTestEditor(initialText, EditorSelection.cursor(0), expectedTags)).state;
};
describe('MarkdownMathExtension', () => {
describe('markdownMathExtension', () => {
jest.retryTimes(2);
@@ -12,7 +12,7 @@ const moveCursorToEnd = (editor: EditorView) => {
selectDocumentEnd(editor.state, editor.dispatch, editor);
};
describe('ProseMirror/commands', () => {
describe('commands', () => {
test('textBold should toggle bold formatting', () => {
const editor = createTestEditor({ html: '<p>Test</p>' });
@@ -30,7 +30,7 @@ const findEditorDialog = () => {
};
};
describe('showCreateEditorPrompt', () => {
describe('showCreateEditablePrompt', () => {
beforeEach(() => {
jest.useFakeTimers();
document.body.replaceChildren();
+1 -1
View File
@@ -1,6 +1,6 @@
import htmlUtils from './htmlUtils';
describe('htmlUtils', () => {
describe('htmlUtils2', () => {
+1 -1
View File
@@ -44,7 +44,7 @@ const compareOutputToExpected = (options: CompareOutputOptions) => {
}));
};
describe('EnexToHtml', () => {
describe('import-enex-html-gen', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
+1 -1
View File
@@ -1,6 +1,6 @@
import markdownUtils from './markdownUtils';
describe('Should detect list items', () => {
describe('markdownUtils', () => {
test('should detect `- lorem ipsum` as list item ', () => {
expect(markdownUtils.isListItem('- lorem ipsum')).toBe(true);
});
+1 -1
View File
@@ -2,7 +2,7 @@
import markdownUtils from './markdownUtils';
describe('markdownUtils', () => {
describe('markdownUtils2', () => {
+1 -1
View File
@@ -1,7 +1,7 @@
import Setting from '../models/Setting';
import time from '../time';
describe('dateFormats', () => {
describe('dateTimeFormats', () => {
+1 -1
View File
@@ -5,7 +5,7 @@ const sync = {
start: jest.fn().mockReturnValue({}),
};
describe('Registry', () => {
describe('registry', () => {
let originalSyncTarget: typeof reg.syncTarget;
beforeAll(() => {
+1 -1
View File
@@ -46,7 +46,7 @@ const registerSecondaryRuntime = (service: CommandService, commandName: string,
return service.registerRuntime(commandName, runtime, true);
};
describe('services_CommandService', () => {
describe('CommandService', () => {
beforeEach(async () => {
KeymapService.destroyInstance();
+1 -1
View File
@@ -4,7 +4,7 @@ import KeymapService, { KeymapItem } from '../services/KeymapService';
const keymapService = KeymapService.instance();
keymapService.initialize([]);
describe('services_KeymapService', () => {
describe('KeymapService', () => {
describe('validateAccelerator', () => {
it('should identify valid Accelerators', () => {
const testCases = {
+1 -1
View File
@@ -7,7 +7,7 @@ function setupStore() {
return store;
}
describe('services_KvStore', () => {
describe('KvStore', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
+1 -1
View File
@@ -5,7 +5,7 @@ import shim from '../../shim';
// goal of this test is to catch packaging/native-binary breakage early, the
// same way SqliteVec.test does for sqlite-vec.
describe('onnxruntime-node', () => {
describe('OnnxRuntime', () => {
it('is reachable via the shim', () => {
const ort = shim.onnxRuntime();
@@ -1,6 +1,6 @@
import { autoMerge } from './diffNotes';
describe('autoMerge', () => {
describe('diffNotes', () => {
test('should auto-apply non-conflicting changes on different lines', () => {
const base = 'one\ntwo\nthree';
@@ -9,7 +9,7 @@ import { setEncryptionEnabled } from '../synchronizer/syncInfoUtils';
let service: EncryptionService = null;
describe('services_EncryptionService', () => {
describe('EncryptionService', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
+1 -1
View File
@@ -3,7 +3,7 @@ import { PublicKeyAlgorithm } from '../types';
import { decryptPrivateKey, ppkDecryptMasterKeyContent, ppkGenerateMasterKey, ppkPasswordIsValid, mkReencryptFromPasswordToPublicKey, mkReencryptFromPublicKeyToPassword, generateKeyPairWithAlgorithm, supportsPpkAlgorithm } from './ppk';
import { runIntegrationTests } from './ppkTestUtils';
describe('e2ee/ppk', () => {
describe('ppk', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
@@ -87,7 +87,7 @@ function memoryExportModule() {
const oneNoteSimpleXpsPath = () => `${supportDir}/onenote/simple-xps.one`;
const skipByDefault = process.env.IS_CONTINUOUS_INTEGRATION ? it : it.skip;
describe('services_InteropService', () => {
describe('InteropService', () => {
const setupOneNoteDomParser = () => {
const jsdom = new JSDOM('<div></div>');
@@ -1,7 +1,7 @@
import { AppType } from '../../../../models/Setting';
import isCompatible from '../isCompatible';
describe('isCompatible', () => {
describe('isCompatible/index', () => {
test.each([
// Should support the case where no platform is provided
{
@@ -56,7 +56,7 @@ let engine: SearchEngine = null;
// return scores;
// };
describe('services/SearchEngine', () => {
describe('SearchEngine', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
@@ -23,7 +23,7 @@ const dateStringToTimestamp = (dateString: string) => {
return localTimestamp.getTime() + localTimestamp.getTimezoneOffset() * minutesToMilliseconds;
};
describe('services_SearchFilter', () => {
describe('SearchFilter', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
@@ -6,7 +6,7 @@ const makeTerm = (name: string, value: string, negated: boolean, quoted = false,
return { name, value, negated };
};
describe('filterParser should be correct filter for keyword', () => {
describe('filterParser', () => {
it('title', () => {
const searchString = 'title: something';
expect(filterParser(searchString)).toContainEqual(makeTerm('title', 'something', false));
@@ -143,7 +143,7 @@ async function testMigrationE2EE(migrationVersion: number, maxSyncVersion: numbe
let previousSyncTargetName = '';
describe('MigrationHandler', () => {
describe('synchronizer_MigrationHandler', () => {
beforeEach(async () => {
// Note that, for undocumented reasons, the timeout argument passed
+1 -1
View File
@@ -2,7 +2,7 @@ import { getDisplayParentId, getTrashFolderId } from '.';
import { ModelType } from '../../BaseModel';
import Folder from '../../models/Folder';
describe('services/trash', () => {
describe('trash/index', () => {
test.each([
[
+1 -1
View File
@@ -1,7 +1,7 @@
import checkProviderIsSupported from './webDAVUtils';
import Setting from '../models/Setting';
describe('checkProviderIsSupported', () => {
describe('webDAVUtils', () => {
describe('when no unsupported provider is already configured', () => {
beforeAll(() => {
Setting.setValue('sync.allowUnsupportedProviders', 0);
+1 -1
View File
@@ -20,7 +20,7 @@ function loadFile(filePath: string) {
});
}
describe('pdfData', () => {
describe('pdfSource', () => {
test('should have correct page count', async () => {
const file = await loadFile(pdfFilePath1);
@@ -18,7 +18,7 @@ const createMarkdownIt = (strict?: unknown) => {
return md;
};
describe('katex rule', () => {
describe('katex', () => {
let warnSpy: jest.SpiedFunction<typeof console.warn>;
@@ -32,7 +32,7 @@ const clearChanges = () => {
return truncateTables(db(), ['changes', 'changes_2']);
};
describe('ChangeModel/index', () => {
describe('ChangeModel', () => {
beforeAll(async () => {
await beforeAllDb('ChangeModel');
@@ -1,6 +1,6 @@
import { beforeAllDb, afterAllTests, beforeEachDb, models } from '../utils/testing/testUtils';
describe('UserItemModel', () => {
describe('UserItemModels', () => {
beforeAll(async () => {
await beforeAllDb('UserItemModel');
@@ -1,6 +1,6 @@
import { isHashedPassword } from '../../utils/auth';
describe('isHashedPassword', () => {
describe('user', () => {
// cSpell:disable
it('should be true if password starts with $2a$10', () => {
+1 -1
View File
@@ -1,7 +1,7 @@
import routeHandler from '../../middleware/routeHandler';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext } from '../../utils/testing/testUtils';
describe('api_ping', () => {
describe('ping', () => {
beforeAll(async () => {
await beforeAllDb('api_ping');
@@ -36,7 +36,7 @@ const fetchSpyOn = (response: FetchResponse) => {
);
};
describe('api_transcribe', () => {
describe('transcribe', () => {
beforeAll(async () => {
await beforeAllDb('api_transcribe', {
@@ -1,6 +1,6 @@
// Disabled for now
describe('index_changes', () => {
describe('changes', () => {
it('should pass', () => {
expect(true).toBe(true);
@@ -3,7 +3,7 @@ import { execRequest, execRequestC } from '../../utils/testing/apiUtils';
import { Uuid } from '../../services/database/types';
import { makeNoteSerializedBody } from '../../utils/testing/serializedItems';
describe('index_items', () => {
describe('index/items', () => {
beforeAll(async () => {
await beforeAllDb('index_items');
@@ -40,7 +40,7 @@ async function doLoginWithMFA(email: string, password: string, mfaCode: string,
return context;
}
describe('index_login', () => {
describe('login', () => {
beforeAll(async () => {
await beforeAllDb('index_login', {
@@ -2,7 +2,7 @@ import routeHandler from '../../middleware/routeHandler';
import { cookieGet } from '../../utils/cookies';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, models, createUserAndSession } from '../../utils/testing/testUtils';
describe('index_logout', () => {
describe('logout', () => {
beforeAll(async () => {
await beforeAllDb('index_logout');
@@ -3,7 +3,7 @@ import routeHandler from '../../middleware/routeHandler';
import { NotificationKey } from '../../models/NotificationModel';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, models, createUserAndSession } from '../../utils/testing/testUtils';
describe('index_notification', () => {
describe('notifications', () => {
beforeAll(async () => {
await beforeAllDb('index_notification');
@@ -8,7 +8,7 @@ import { beforeAllDb, afterAllTests, beforeEachDb, models } from '../../utils/te
import { uuidgen } from '@joplin/lib/uuid';
import { FormUser } from './signup';
describe('index_signup', () => {
describe('signup', () => {
beforeAll(async () => {
await beforeAllDb('index_signup');
+1 -1
View File
@@ -5,7 +5,7 @@ import { getLoginRequest } from './saml';
import { afterAllTests, beforeAllDb, beforeEachDb } from './testing/testUtils';
import { SamlRelayState } from './types';
describe('getLoginRequest', () => {
describe('saml', () => {
let dir: string;
beforeAll(async () => {
@@ -0,0 +1,90 @@
const { basename, sep } = require('path');
// Enforces that the top-level `describe()` title in a test file matches the
// file name, or a path suffix ending with that file name. For example, for
// `packages/lib/models/Tag.test.ts`, valid titles are "Tag", "models/Tag" or
// "lib/models/Tag".
const testFileSuffix = /\.test$/;
const filePathTitles = (filePath) => {
const parts = filePath.split(sep);
parts[parts.length - 1] = basename(parts[parts.length - 1]).replace(/\.[^.]+$/, '').replace(testFileSuffix, '');
const output = [];
for (let i = parts.length - 1; i >= 0; i--) {
output.push(parts.slice(i).join('/'));
}
return output;
};
// `describe.each` builds its title from the test data, so a printf placeholder
// such as "%j", or a "$variable" tag, may follow the file name.
const eachTitle = (title, allowed) => {
const match = /^(.*?)\s*[([]?[%$]/.exec(title);
return !!match && allowed.includes(match[1]);
};
const isDescribe = (callee) => {
if (callee.type === 'Identifier') return callee.name === 'describe';
// describe.each(...)(...) - the callee is itself a call expression, and
// describe.each`...`(...) - a tagged template expression.
if (callee.type === 'CallExpression') return isDescribe(callee.callee);
if (callee.type === 'TaggedTemplateExpression') return isDescribe(callee.tag);
if (callee.type === 'MemberExpression' && !callee.computed) {
return isDescribe(callee.object);
}
return false;
};
const isEach = (callee) => {
if (callee.type === 'CallExpression') return isEach(callee.callee);
if (callee.type === 'TaggedTemplateExpression') return isEach(callee.tag);
if (callee.type === 'MemberExpression' && !callee.computed) {
return callee.property.name === 'each' || isEach(callee.object);
}
return false;
};
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Require the top-level describe() title to match the test file name or a path suffix to it',
},
schema: [],
messages: {
mismatch: 'The describe() title should be "{{expected}}", or a longer path suffix to the file (eg. "{{alternative}}").',
dynamic: 'The describe() title should be a string literal matching the file name ("{{expected}}").',
},
},
create(context) {
const filePath = context.filename ?? context.getFilename();
if (!/\.test\.[jt]sx?$/.test(filePath)) return {};
const allowed = filePathTitles(filePath);
const data = { expected: allowed[0], alternative: allowed[1] ?? allowed[0] };
return {
// Only top-level describe() calls - nested ones are inside a function
// body so their ancestor chain includes a CallExpression.
'Program > ExpressionStatement > CallExpression'(node) {
if (!isDescribe(node.callee)) return;
const [title] = node.arguments;
if (!title) return;
if (title.type !== 'Literal' || typeof title.value !== 'string') {
context.report({ node: title, messageId: 'dynamic', data });
return;
}
if (allowed.includes(title.value)) return;
if (isEach(node.callee) && eachTitle(title.value, allowed)) return;
context.report({ node: title, messageId: 'mismatch', data });
},
};
},
};
@@ -0,0 +1,49 @@
import { RuleTester } from 'eslint';
const rule = require('./describeFilename');
// RuleTester emits its own describe/test blocks, so it must be called at the
// top level rather than from inside a test().
const ruleTester = new RuleTester({
languageOptions: { ecmaVersion: 2021, sourceType: 'module' },
});
const filename = '/repo/packages/lib/models/Tag.test.ts';
ruleTester.run('describe-filename', rule, {
valid: [
// The file name, or any path suffix ending with it.
{ code: 'describe(\'Tag\', () => {});', filename },
{ code: 'describe(\'models/Tag\', () => {});', filename },
{ code: 'describe(\'lib/models/Tag\', () => {});', filename },
// Supported describe variants.
{ code: 'describe.skip(\'Tag\', () => {});', filename },
{ code: 'describe.only(\'Tag\', () => {});', filename },
// describe.each builds the title from the test data, so a printf
// placeholder or a $tag may follow the file name.
{ code: 'describe.each([1])(\'Tag (%j)\', () => {});', filename },
{ code: 'describe.each([1])(\'Tag $name\', () => {});', filename },
{ code: 'describe.each`\\na\\n${1}\\n`(\'Tag $a\', () => {});', filename },
// Nested describes are unrestricted.
{ code: 'describe(\'Tag\', () => { describe(\'anything\', () => {}); });', filename },
// Non-test files are not checked.
{ code: 'describe(\'anything\', () => {});', filename: '/repo/packages/lib/models/Tag.ts' },
],
invalid: [
{ code: 'describe(\'something\', () => {});', filename, errors: [{ messageId: 'mismatch' }] },
{ code: 'describe(\'Tag rule\', () => {});', filename, errors: [{ messageId: 'mismatch' }] },
{ code: 'describe(\'services/Tag\', () => {});', filename, errors: [{ messageId: 'mismatch' }] },
{ code: 'describe.skip(\'nope\', () => {});', filename, errors: [{ messageId: 'mismatch' }] },
{ code: 'describe.each([1])(\'nope (%j)\', () => {});', filename, errors: [{ messageId: 'mismatch' }] },
{ code: 'describe.each`\\na\\n${1}\\n`(\'nope $a\', () => {});', filename, errors: [{ messageId: 'mismatch' }] },
// Dynamic titles cannot be validated, so they are reported.
{ code: 'describe(`Tag ${x}`, () => {});', filename, errors: [{ messageId: 'dynamic' }] },
{ code: 'describe(title, () => {});', filename, errors: [{ messageId: 'dynamic' }] },
],
});
+5
View File
@@ -0,0 +1,5 @@
module.exports = {
rules: {
'describe-filename': require('./describeFilename'),
},
};
+1 -1
View File
@@ -1,7 +1,7 @@
import { extractUrls } from './html';
import { Link } from './types';
describe('htmlUtils', () => {
describe('html', () => {
test.each([
[