diff --git a/tests/eslint/boundaries.test.ts b/tests/eslint/boundaries.test.ts index 72e0152a..70596bce 100644 --- a/tests/eslint/boundaries.test.ts +++ b/tests/eslint/boundaries.test.ts @@ -1,78 +1,71 @@ -import { describe, expect, it } from "vitest"; -import { execSync } from "node:child_process"; +import { afterAll, describe, expect, it, vi } from "vitest"; + +// ESLint Node API init is slow on first lint (~5-8s). Increase timeout +// so the first test in this file doesn't flake under load. +vi.setConfig({ testTimeout: 30_000 }); +import { ESLint } from "eslint"; import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; const ROOT = path.resolve(import.meta.dirname, "../.."); +const eslint = new ESLint({ cwd: ROOT }); + +/** Probe files created during this test run, cleaned up in afterAll. */ +const probeFiles: string[] = []; -/** Short random suffix to avoid collisions when tests run in parallel. */ function uid(): string { return crypto.randomBytes(4).toString("hex"); } -/** - * Removes a file and any empty parent directories up to (but not including) the src/ tree root. - */ function cleanupProbe(absPath: string): void { try { fs.unlinkSync(absPath); - } catch { - /* file may not exist */ - } + } catch { /* file may not exist */ } let dir = path.dirname(absPath); const srcDir = path.join(ROOT, "src"); while (dir.length > srcDir.length) { - try { - fs.rmdirSync(dir); - } catch { - break; - } + try { fs.rmdirSync(dir); } catch { break; } dir = path.dirname(dir); } } -/** - * Creates a probe source file, optionally a target file, runs ESLint, and cleans up. - */ -function lintProbe( +afterAll(() => { + for (const f of probeFiles) cleanupProbe(f); +}); + +async function lintProbe( sourcePath: string, sourceContent: string, targetPath?: string, targetContent?: string, -): string { +): Promise { const absSrc = path.join(ROOT, sourcePath); const absTarget = targetPath ? path.join(ROOT, targetPath) : null; + fs.mkdirSync(path.dirname(absSrc), { recursive: true }); fs.writeFileSync(absSrc, sourceContent, "utf8"); + probeFiles.push(absSrc); + if (absTarget && targetContent) { fs.mkdirSync(path.dirname(absTarget), { recursive: true }); fs.writeFileSync(absTarget, targetContent, "utf8"); + probeFiles.push(absTarget); } - try { - execSync(`pnpm exec eslint "${absSrc}" --format json`, { - cwd: ROOT, - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - }); - return "PASS"; - } catch (err: unknown) { - const error = err as { stdout?: string }; - return error.stdout ?? "UNKNOWN_ERROR"; - } finally { - cleanupProbe(absSrc); - if (absTarget) { - cleanupProbe(absTarget); - } - } + + const results = await eslint.lintFiles([absSrc]); + const messages = results.flatMap((r) => r.messages); + + if (messages.length === 0) return "PASS"; + return JSON.stringify(messages.map((m) => ({ ruleId: m.ruleId, message: m.message }))); } const TARGET_CONTENT = "export const something = 1;\n"; describe("boundaries rules", () => { - it("features/ cannot import from routes/", () => { + it("features/ cannot import from routes/", async () => { const id = uid(); - const result = lintProbe( + const result = await lintProbe( `src/features/online-board/__bnd_probe_${id}__.ts`, `import { something } from "../../routes/__probe_${id}__/__target_probe__";\nexport const x = something;\n`, `src/routes/__probe_${id}__/__target_probe__.ts`, @@ -81,9 +74,9 @@ describe("boundaries rules", () => { expect(result).toContain("boundaries/element-types"); }); - it("features/ cannot import from mf/", () => { + it("features/ cannot import from mf/", async () => { const id = uid(); - const result = lintProbe( + const result = await lintProbe( `src/features/online-board/__bnd_probe_${id}__.ts`, `import { something } from "../../mf/expose/__target_probe_${id}__";\nexport const x = something;\n`, `src/mf/expose/__target_probe_${id}__.ts`, @@ -92,36 +85,36 @@ describe("boundaries rules", () => { expect(result).toContain("boundaries/element-types"); }); - it("ui/ cannot import from features/", () => { + it("ui/ cannot import from features/", async () => { const id = uid(); - const result = lintProbe( + const result = await lintProbe( `src/ui/__probe_${id}__/__bnd_probe__.ts`, 'import { something } from "../../features/online-board/index";\nexport const x = something;\n', ); expect(result).toContain("boundaries/element-types"); }); - it("shared/ cannot import from features/", () => { + it("shared/ cannot import from features/", async () => { const id = uid(); - const result = lintProbe( + const result = await lintProbe( `src/shared/__probe_${id}__/__bnd_probe__.ts`, 'import { something } from "../../features/online-board/index";\nexport const x = something;\n', ); expect(result).toContain("boundaries/element-types"); }); - it("observability/ cannot import from features/", () => { + it("observability/ cannot import from features/", async () => { const id = uid(); - const result = lintProbe( + const result = await lintProbe( `src/observability/logger/__bnd_probe_${id}__.ts`, 'import { something } from "../../features/online-board/index";\nexport const x = something;\n', ); expect(result).toContain("boundaries/element-types"); }); - it("features/ CAN import from env/ (allowed direction)", () => { + it("features/ CAN import from env/ (allowed direction)", async () => { const id = uid(); - const result = lintProbe( + const result = await lintProbe( `src/features/online-board/__bnd_probe_${id}__.ts`, 'import { getEnv } from "../../env/index";\nexport const x = getEnv;\n', ); diff --git a/tests/eslint/restricted-imports.test.ts b/tests/eslint/restricted-imports.test.ts index 84f2db54..9ea4e42c 100644 --- a/tests/eslint/restricted-imports.test.ts +++ b/tests/eslint/restricted-imports.test.ts @@ -1,77 +1,76 @@ -import { describe, expect, it } from "vitest"; -import { execSync } from "node:child_process"; +import { afterAll, describe, expect, it, vi } from "vitest"; + +vi.setConfig({ testTimeout: 30_000 }); +import { ESLint } from "eslint"; import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; const ROOT = path.resolve(import.meta.dirname, "../.."); +const eslint = new ESLint({ cwd: ROOT }); + +const probeFiles: string[] = []; -/** Short random suffix to avoid collisions when tests run in parallel. */ function uid(): string { return crypto.randomBytes(4).toString("hex"); } -function lintString(filePath: string, content: string): string { - const absPath = path.join(ROOT, filePath); - fs.mkdirSync(path.dirname(absPath), { recursive: true }); - fs.writeFileSync(absPath, content, "utf8"); - try { - execSync(`pnpm exec eslint "${absPath}" --format json`, { - cwd: ROOT, - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - }); - return "PASS"; - } catch (err: unknown) { - const error = err as { stdout?: string }; - return error.stdout ?? "UNKNOWN_ERROR"; - } finally { - fs.unlinkSync(absPath); - // Remove empty parent directories created for probes - let dir = path.dirname(absPath); +afterAll(() => { + for (const f of probeFiles) { + try { fs.unlinkSync(f); } catch { /* ok */ } + let dir = path.dirname(f); const srcDir = path.join(ROOT, "src"); while (dir.length > srcDir.length) { - try { - fs.rmdirSync(dir); - } catch { - break; - } + try { fs.rmdirSync(dir); } catch { break; } dir = path.dirname(dir); } } +}); + +async function lintString(filePath: string, content: string): Promise { + const absPath = path.join(ROOT, filePath); + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + fs.writeFileSync(absPath, content, "utf8"); + probeFiles.push(absPath); + + const results = await eslint.lintFiles([absPath]); + const messages = results.flatMap((r) => r.messages); + + if (messages.length === 0) return "PASS"; + return JSON.stringify(messages.map((m) => ({ ruleId: m.ruleId, message: m.message }))); } describe("no-restricted-imports rules", () => { - it("blocks @opentelemetry/sdk-metrics outside otel.ts", () => { + it("blocks @opentelemetry/sdk-metrics outside otel.ts", async () => { const id = uid(); - const result = lintString( + const result = await lintString( `src/features/online-board/__ri_probe_${id}__.ts`, 'import { MeterProvider } from "@opentelemetry/sdk-metrics";\nexport const x = MeterProvider;\n', ); expect(result).toContain("no-restricted-imports"); }); - it("blocks react-i18next outside provider.tsx", () => { + it("blocks react-i18next outside provider.tsx", async () => { const id = uid(); - const result = lintString( + const result = await lintString( `src/features/online-board/__ri_probe_${id}__.ts`, 'import { useTranslation } from "react-i18next";\nexport const x = useTranslation;\n', ); expect(result).toContain("no-restricted-imports"); }); - it("blocks @microsoft/signalr in routes/ (SSR bundle)", () => { + it("blocks @microsoft/signalr in routes/ (SSR bundle)", async () => { const id = uid(); - const result = lintString( + const result = await lintString( `src/routes/__probe_${id}__/__ri_probe__.ts`, 'import { HubConnectionBuilder } from "@microsoft/signalr";\nexport const x = HubConnectionBuilder;\n', ); expect(result).toContain("no-restricted-imports"); }); - it("blocks localStorage outside storage.ts", () => { + it("blocks localStorage outside storage.ts", async () => { const id = uid(); - const result = lintString( + const result = await lintString( `src/features/online-board/__ri_probe_${id}__.ts`, 'const x = localStorage.getItem("key");\nexport default x;\n', );