Files
frontend/scripts/test-eslint.mjs
T
codex-bot df05c8c7db
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 7m33s
build: use native openapi-typescript --check (#60) (#77)
2026-10-13 21:32:33 +03:00

396 lines
15 KiB
JavaScript

import react from "@eslint-react/eslint-plugin";
import stylistic from "@stylistic/eslint-plugin";
import query from "@tanstack/eslint-plugin-query";
import vitest from "@vitest/eslint-plugin";
import { ESLint } from "eslint";
import { importX } from "eslint-plugin-import-x";
import jestDom from "eslint-plugin-jest-dom";
import noUnsanitized from "eslint-plugin-no-unsanitized";
import perfectionist from "eslint-plugin-perfectionist";
import hooks from "eslint-plugin-react-hooks";
import refresh from "eslint-plugin-react-refresh";
import regexp from "eslint-plugin-regexp";
import security from "eslint-plugin-security";
import sonarjs from "eslint-plugin-sonarjs";
import storybook from "eslint-plugin-storybook";
import testingLibrary from "eslint-plugin-testing-library";
import unicorn from "eslint-plugin-unicorn";
import assert from "node:assert/strict";
import { globSync } from "node:fs";
import { test } from "node:test";
import tseslint from "typescript-eslint";
test("ESLint enforces the frontend profile and excludes generated artifacts", async () => {
const eslint = new ESLint();
for (const [filePath, code, rule] of [
[
"src/shared/api/problem-details.ts",
"export function read(value: string) { return value?.trim(); }",
"@typescript-eslint/no-unnecessary-condition",
],
[
"src/shared/api/problem-details.ts",
"export function read(value?: string) { return value!.trim(); }",
"@typescript-eslint/no-non-null-assertion",
],
[
"src/shared/api/problem-details.ts",
"import { ReactNode } from \"react\"; export type Content = ReactNode;",
"@typescript-eslint/consistent-type-imports",
],
[
"src/shared/api/problem-details.ts",
"export function run() { Promise.resolve(); }",
"@typescript-eslint/no-floating-promises",
],
[
"packages/ui/src/EntryCard/EntryCard.tsx",
"import { useState } from \"react\"; export function Example({ active }: { active: boolean }) { if (active) useState(0); return null; }",
"react-hooks/rules-of-hooks",
],
[
"src/shared/api/problem-details.ts",
"export const values = Array(3).fill({});",
"unicorn/no-array-fill-with-reference-type",
],
[
"src/shared/api/problem-details.ts",
"export default 1;",
"import-x/no-default-export",
],
[
"src/shared/api/problem-details.ts",
"new Error(\"forgot to throw\");",
"sonarjs/no-unthrown-error",
],
[
"src/shared/api/problem-details.ts",
"export const pattern = /[aab]/;",
"regexp/no-dupe-characters-character-class",
],
[
"src/shared/api/problem-details.ts",
"export type Status = \"z\" | \"a\";",
"perfectionist/sort-union-types",
],
[
"src/shared/api/problem-details.ts",
"//missing space\nexport const ready = true;",
"@stylistic/spaced-comment",
],
[
"src/shared/api/problem-details.ts",
"export function pattern(input: string) { return new RegExp(input); }",
"security/detect-non-literal-regexp",
],
[
"scripts/test-pre-commit.mjs",
"import { exec } from \"node:child_process\"; exec(process.argv[2]);",
"security/detect-child-process",
],
[
"src/shared/api/problem-details.ts",
"export function renderHtml(element: HTMLElement, html: string) { element.innerHTML = html; }",
"no-unsanitized/property",
],
[
"src/shared/api/problem-details.ts",
"export function renderHtml(element: HTMLElement, html: string) { element.insertAdjacentHTML(\"beforeend\", html); }",
"no-unsanitized/method",
],
[
"packages/ui/src/EntryCard/EntryCard.test.tsx",
"import { expect } from \"vitest\"; expect(document.activeElement).toBe(document.body);",
"jest-dom/prefer-focus",
],
[
"src/shared/api/client.test.ts",
"import { it } from \"vitest\"; it.only(\"focused\", () => {});",
"vitest/no-focused-tests",
],
[
"packages/ui/src/EntryCard/EntryCard.test.tsx",
"import { screen } from \"@testing-library/react\"; screen.findByText(\"ready\");",
"testing-library/await-async-queries",
],
[
"packages/ui/src/EntryCard/EntryCard.tsx",
"import { QueryClient } from \"@tanstack/react-query\"; export function Example() { const client = new QueryClient(); return <div>{String(client)}</div>; }",
"@tanstack/query/stable-query-client",
],
]) {
const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0, filePath);
assert.ok(
result.messages.some(message => message.ruleId === rule),
rule,
);
}
for (const file of [
"src/shared/api/generated/schema.d.ts",
"packages/ui/dist/index.js",
"storybook-static/index.js",
"coverage/index.js",
])
assert.equal(await eslint.isPathIgnored(file), true, file);
const story = await eslint.calculateConfigForFile(
"src/shared/ui/Button.stories.tsx",
);
assert.equal(story.rules["import-x/no-default-export"][0], 0);
assert.ok(story.rules["storybook/default-exports"][0] > 0);
assert.equal(story.rules["react-refresh/only-export-components"], undefined);
});
test("recommended plugin rules retain the explicit null-contract exception", async () => {
const eslint = new ESLint();
const source = "src/shared/api/problem-details.ts";
const component = "packages/ui/src/EntryCard/EntryCard.tsx";
const unit = "packages/ui/src/EntryCard/EntryCard.test.tsx";
for (const [preset, filePath] of [
[tseslint.configs.recommendedTypeChecked, source],
[[importX.flatConfigs.recommended, importX.flatConfigs.typescript], source],
[unicorn.configs.recommended, source],
[sonarjs.configs.recommended, source],
[regexp.configs.recommended, source],
[security.configs.recommended, source],
[noUnsanitized.configs.recommended, source],
[perfectionist.configs["recommended-natural"], source],
[stylistic.configs.recommended, component],
[react.configs["recommended-type-checked"], component],
[hooks.configs.flat.recommended, component],
[refresh.configs.vite, component],
[query.configs["flat/recommended"], component],
[vitest.configs.recommended, unit],
[testingLibrary.configs["flat/react"], unit],
[jestDom.configs["flat/recommended"], unit],
...storybook.configs["flat/recommended"]
.filter(config => config.rules)
.map(config => [
config,
config.name.endsWith("main-rules")
? ".storybook/main.ts"
: "src/shared/ui/Button.stories.tsx",
]),
]) {
const configs = [preset].flat();
const expected = Object.assign(
{},
...configs.map(config => config.rules),
);
const config = await eslint.calculateConfigForFile(filePath);
const actual = new Map(Object.entries(config.rules));
for (const [rule, setting] of Object.entries(expected)) {
const severity = Array.isArray(setting) ? setting[0] : setting;
if (severity === "off" || severity === 0 || !rule.includes("/")) continue;
if (rule === "unicorn/no-null") {
assert.equal(actual.get(rule)?.[0], 0);
continue;
}
assert.ok(actual.get(rule)?.[0] > 0, `${filePath}: ${rule}`);
}
}
});
test("ESLint formatting is idempotent and preserves TSX generic syntax", async () => {
const eslint = new ESLint({ fix: true });
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
const [fixed] = await eslint.lintText(
"//identity\nconst identity = <T,>(value: T): T => value;\nexport {identity};",
{ filePath },
);
assert.equal(fixed.fatalErrorCount, 0);
assert.equal(fixed.errorCount, 0);
assert.ok(fixed.output);
const [again] = await eslint.lintText(fixed.output, { filePath });
assert.equal(again.errorCount, 0);
assert.equal(again.output, undefined);
});
test("ESLint formatting preserves spaces around JSX text and interpolations", async () => {
const eslint = new ESLint({ fix: true });
const [fixed] = await eslint.lintText(
"const Example = () => {\nconst value = 'value';\nreturn <span>Amount: {value} / {value}</span>;\n};\nexport { Example };",
{ filePath: "packages/ui/src/EntryCard/EntryCard.tsx" },
);
assert.equal(fixed.errorCount, 0);
assert.ok(fixed.output?.includes("Amount: {value} / {value}"));
});
// Single-use codes must be caught too; fixture literals remain independent wire assertions.
test("machine codes require enum members without banning ordinary text or Prisma selects", async () => {
const eslint = new ESLint();
const filePath = "src/shared/api/problem-details.ts";
for (const code of [
"export function check(code: string) { return code === \"one-off-code\"; }",
"export const result = `MOEX_UNAVAILABLE`;",
"export const result = { code: \"one-off-code\" };",
"export const result = { \"errorCode\": \"one-off-code\" };",
"export const result = { code: `dynamic-${Date.now()}` };",
"export const result = \"MOEX_UNAVAILABLE\";",
"export const result = [\"INSTRUMENT_ID_TYPE_FIGI\"];",
"export const result = { OPERATION_TYPE_COUPON: \"income\" };",
]) {
const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0, code);
assert.ok(
result.messages.some(({ ruleId }) => ruleId === "no-restricted-syntax"),
code,
);
}
for (const code of [
"enum Code { Unavailable = \"MOEX_UNAVAILABLE\" } export const result = { code: Code.Unavailable };",
"export const select = { code: true, errorCode: true };",
"export const label = \"Please try again\"; export const key = \"JWT_SECRET\";",
]) {
const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0, code);
assert.ok(
result.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"),
code,
);
}
const [fixture] = await eslint.lintText(
"export const result = { code: \"MOEX_UNAVAILABLE\" };",
{ filePath: "src/shared/api/client.test.ts" },
);
assert.ok(
fixture.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"),
);
const [comparison] = await eslint.lintText(
"enum Code { Ready = \"ready\" } export function check(code: Code) { return code === \"ready\"; }",
{ filePath },
);
assert.ok(
comparison.messages.some(
({ ruleId }) => ruleId === "@typescript-eslint/no-unsafe-enum-comparison",
),
);
});
test("readability enforces syntax without banning VM and schema patterns", async () => {
const eslint = new ESLint();
const component = "src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx";
const model = "src/entities/income/api/useIncome.ts";
const invalid = [
[component, "const Example = () => <svg><path d=\"M0 0\" /></svg>;", "frontend/readability"],
[component, "const Example = () => <button onClick={() => alert(\"go\")} />;", "no-restricted-syntax"],
[component, "const Example = () => <button title={true ? \"yes\" : \"no\"} />;", "no-restricted-syntax"],
[component, "const Example = () => { const helper = () => 1; return <span>{helper()}</span>; };", "frontend/readability"],
[component, "import { z } from \"zod\"; const schema = z.string();", "frontend/readability"],
[component, "const schema = otherSchema.superRefine(() => {});", "frontend/readability"],
[model, "function useExample(accountId: string | undefined) { return accountId; }", "frontend/readability"],
[model, "type Example = { state: \"one\" } | { state: \"two\" };", "frontend/readability"],
[component, "const Example = () => <div>\n<span />\n<span />\n</div>; export { Example };", "frontend/jsx-spacing"],
[model, `const excessivelyLongDeclarationName = ${"1234567890".repeat(9)};`, "@stylistic/max-len"],
];
for (const [filePath, code, rule] of invalid) {
const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0, code);
assert.ok(result.messages.some(message => message.ruleId === rule), `${rule}: ${code}`);
}
for (const [filePath, code] of [
["src/shared/lib/calendar.schema.ts", "import { z } from \"zod\"; const calendar = z.iso.date();"],
["packages/icons/src/svg/AddIcon.tsx", "const Icon = () => <svg><path d=\"M0 0\" /></svg>;"],
[model, "function useExample(accountId?: string) { const onClick = () => accountId; return { onClick }; }"],
[model, "function example(value: string | undefined, required: string) { return value ?? required; }"],
[model, "type Example =\n | { state: \"one\" }\n | { state: \"two\" };"],
[component, "const Example = (props: { onClick: () => void }) => <button onClick={props.onClick} />;"],
]) {
const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0, code);
assert.ok(result.messages.every(message => !["frontend/readability", "no-restricted-syntax"].includes(message.ruleId)), code);
}
assert.deepEqual(globSync("src/**/*.svg"), []);
});
test("JSX spacing fixes preserve text interpolation and are stable", async () => {
const eslint = new ESLint({ fix: true });
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
const [fixed] = await eslint.lintText("const Example = () => <div>\n<span />\n<span />\n</div>; export { Example };", { filePath });
const output = fixed.output;
assert.ok(output);
const [again] = await eslint.lintText(output, { filePath });
assert.equal(again.output, undefined);
assert.equal(again.errorCount, 0);
});
test("views accept presentation inputs but reject direct data hooks", async () => {
const eslint = new ESLint();
const filePath = "src/features/instrument-details/ui/InstrumentEvents.view.tsx";
for (const code of [
"import { useStock } from \"@/entities/stock\";",
"import { useAuth } from \"@/features/auth\";",
"import { useNavigate } from \"react-router\";",
"import { apiClient } from \"@/shared/api\";",
"import { useQuery } from \"@tanstack/react-query\";",
]) {
const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0);
assert.ok(result.messages.some(message =>
message.ruleId === "no-restricted-imports"));
}
for (const code of [
"import { Link } from \"react-router\";",
"import { apiErrorCode } from \"@/shared/api\";",
"import type { StockPosition } from \"@/entities/stock\";",
"import type { QueryKey } from \"@tanstack/react-query\";",
"import { useTranslation } from \"react-i18next\";",
"import { useFormContext } from \"react-hook-form\";",
]) {
const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0);
assert.ok(result.messages.every(message =>
message.ruleId !== "no-restricted-imports"));
}
const [vm] = await eslint.lintText(
"import { useStock } from \"@/entities/stock\";",
{ filePath: "src/pages/stock/ui/StockPage/StockPage.vm.ts" },
);
assert.ok(vm.messages.every(message =>
message.ruleId !== "no-restricted-imports"));
});
test("compact JSX and contract nulls need no local suppressions", async () => {
const eslint = new ESLint({ fix: true });
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
const code = "import type { FC } from 'react'; type Props = { active: boolean }; const Example: FC<Props> = (props) => props.active ? <span /> : <div />;\nexport { Example };";
const [fixed] = await eslint.lintText(code, { filePath });
assert.equal(fixed.errorCount, 0);
assert.match(fixed.output ?? code, /\? <span \/> : <div \/>/);
const [nullable] = await eslint.lintText(
"export const value: string | null = null;",
{ filePath: "src/shared/api/problem-details.ts" },
);
assert.equal(nullable.errorCount, 0);
});