Fix flaky ESLint probe tests: use Node API instead of subprocess
Deploy / build-and-deploy (push) Failing after 6s
Deploy / build-and-deploy (push) Failing after 6s
The 10 ESLint boundary and restricted-imports probe tests spawned a fresh eslint subprocess per test (~2.7s each), causing timeout flakes under load. Replaced with ESLint's Node API (single instance reused across all tests in a file) — first test pays ~5s init, subsequent tests ~1.3s each. Added 30s timeout to accommodate the init cost.
This commit is contained in:
@@ -1,78 +1,71 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||||
import { execSync } from "node:child_process";
|
|
||||||
|
// 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 fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
const ROOT = path.resolve(import.meta.dirname, "../..");
|
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 {
|
function uid(): string {
|
||||||
return crypto.randomBytes(4).toString("hex");
|
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 {
|
function cleanupProbe(absPath: string): void {
|
||||||
try {
|
try {
|
||||||
fs.unlinkSync(absPath);
|
fs.unlinkSync(absPath);
|
||||||
} catch {
|
} catch { /* file may not exist */ }
|
||||||
/* file may not exist */
|
|
||||||
}
|
|
||||||
let dir = path.dirname(absPath);
|
let dir = path.dirname(absPath);
|
||||||
const srcDir = path.join(ROOT, "src");
|
const srcDir = path.join(ROOT, "src");
|
||||||
while (dir.length > srcDir.length) {
|
while (dir.length > srcDir.length) {
|
||||||
try {
|
try { fs.rmdirSync(dir); } catch { break; }
|
||||||
fs.rmdirSync(dir);
|
|
||||||
} catch {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
dir = path.dirname(dir);
|
dir = path.dirname(dir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
afterAll(() => {
|
||||||
* Creates a probe source file, optionally a target file, runs ESLint, and cleans up.
|
for (const f of probeFiles) cleanupProbe(f);
|
||||||
*/
|
});
|
||||||
function lintProbe(
|
|
||||||
|
async function lintProbe(
|
||||||
sourcePath: string,
|
sourcePath: string,
|
||||||
sourceContent: string,
|
sourceContent: string,
|
||||||
targetPath?: string,
|
targetPath?: string,
|
||||||
targetContent?: string,
|
targetContent?: string,
|
||||||
): string {
|
): Promise<string> {
|
||||||
const absSrc = path.join(ROOT, sourcePath);
|
const absSrc = path.join(ROOT, sourcePath);
|
||||||
const absTarget = targetPath ? path.join(ROOT, targetPath) : null;
|
const absTarget = targetPath ? path.join(ROOT, targetPath) : null;
|
||||||
|
|
||||||
fs.mkdirSync(path.dirname(absSrc), { recursive: true });
|
fs.mkdirSync(path.dirname(absSrc), { recursive: true });
|
||||||
fs.writeFileSync(absSrc, sourceContent, "utf8");
|
fs.writeFileSync(absSrc, sourceContent, "utf8");
|
||||||
|
probeFiles.push(absSrc);
|
||||||
|
|
||||||
if (absTarget && targetContent) {
|
if (absTarget && targetContent) {
|
||||||
fs.mkdirSync(path.dirname(absTarget), { recursive: true });
|
fs.mkdirSync(path.dirname(absTarget), { recursive: true });
|
||||||
fs.writeFileSync(absTarget, targetContent, "utf8");
|
fs.writeFileSync(absTarget, targetContent, "utf8");
|
||||||
|
probeFiles.push(absTarget);
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
execSync(`pnpm exec eslint "${absSrc}" --format json`, {
|
const results = await eslint.lintFiles([absSrc]);
|
||||||
cwd: ROOT,
|
const messages = results.flatMap((r) => r.messages);
|
||||||
encoding: "utf8",
|
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
if (messages.length === 0) return "PASS";
|
||||||
});
|
return JSON.stringify(messages.map((m) => ({ ruleId: m.ruleId, message: m.message })));
|
||||||
return "PASS";
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const error = err as { stdout?: string };
|
|
||||||
return error.stdout ?? "UNKNOWN_ERROR";
|
|
||||||
} finally {
|
|
||||||
cleanupProbe(absSrc);
|
|
||||||
if (absTarget) {
|
|
||||||
cleanupProbe(absTarget);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TARGET_CONTENT = "export const something = 1;\n";
|
const TARGET_CONTENT = "export const something = 1;\n";
|
||||||
|
|
||||||
describe("boundaries rules", () => {
|
describe("boundaries rules", () => {
|
||||||
it("features/ cannot import from routes/", () => {
|
it("features/ cannot import from routes/", async () => {
|
||||||
const id = uid();
|
const id = uid();
|
||||||
const result = lintProbe(
|
const result = await lintProbe(
|
||||||
`src/features/online-board/__bnd_probe_${id}__.ts`,
|
`src/features/online-board/__bnd_probe_${id}__.ts`,
|
||||||
`import { something } from "../../routes/__probe_${id}__/__target_probe__";\nexport const x = something;\n`,
|
`import { something } from "../../routes/__probe_${id}__/__target_probe__";\nexport const x = something;\n`,
|
||||||
`src/routes/__probe_${id}__/__target_probe__.ts`,
|
`src/routes/__probe_${id}__/__target_probe__.ts`,
|
||||||
@@ -81,9 +74,9 @@ describe("boundaries rules", () => {
|
|||||||
expect(result).toContain("boundaries/element-types");
|
expect(result).toContain("boundaries/element-types");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("features/ cannot import from mf/", () => {
|
it("features/ cannot import from mf/", async () => {
|
||||||
const id = uid();
|
const id = uid();
|
||||||
const result = lintProbe(
|
const result = await lintProbe(
|
||||||
`src/features/online-board/__bnd_probe_${id}__.ts`,
|
`src/features/online-board/__bnd_probe_${id}__.ts`,
|
||||||
`import { something } from "../../mf/expose/__target_probe_${id}__";\nexport const x = something;\n`,
|
`import { something } from "../../mf/expose/__target_probe_${id}__";\nexport const x = something;\n`,
|
||||||
`src/mf/expose/__target_probe_${id}__.ts`,
|
`src/mf/expose/__target_probe_${id}__.ts`,
|
||||||
@@ -92,36 +85,36 @@ describe("boundaries rules", () => {
|
|||||||
expect(result).toContain("boundaries/element-types");
|
expect(result).toContain("boundaries/element-types");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ui/ cannot import from features/", () => {
|
it("ui/ cannot import from features/", async () => {
|
||||||
const id = uid();
|
const id = uid();
|
||||||
const result = lintProbe(
|
const result = await lintProbe(
|
||||||
`src/ui/__probe_${id}__/__bnd_probe__.ts`,
|
`src/ui/__probe_${id}__/__bnd_probe__.ts`,
|
||||||
'import { something } from "../../features/online-board/index";\nexport const x = something;\n',
|
'import { something } from "../../features/online-board/index";\nexport const x = something;\n',
|
||||||
);
|
);
|
||||||
expect(result).toContain("boundaries/element-types");
|
expect(result).toContain("boundaries/element-types");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shared/ cannot import from features/", () => {
|
it("shared/ cannot import from features/", async () => {
|
||||||
const id = uid();
|
const id = uid();
|
||||||
const result = lintProbe(
|
const result = await lintProbe(
|
||||||
`src/shared/__probe_${id}__/__bnd_probe__.ts`,
|
`src/shared/__probe_${id}__/__bnd_probe__.ts`,
|
||||||
'import { something } from "../../features/online-board/index";\nexport const x = something;\n',
|
'import { something } from "../../features/online-board/index";\nexport const x = something;\n',
|
||||||
);
|
);
|
||||||
expect(result).toContain("boundaries/element-types");
|
expect(result).toContain("boundaries/element-types");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("observability/ cannot import from features/", () => {
|
it("observability/ cannot import from features/", async () => {
|
||||||
const id = uid();
|
const id = uid();
|
||||||
const result = lintProbe(
|
const result = await lintProbe(
|
||||||
`src/observability/logger/__bnd_probe_${id}__.ts`,
|
`src/observability/logger/__bnd_probe_${id}__.ts`,
|
||||||
'import { something } from "../../features/online-board/index";\nexport const x = something;\n',
|
'import { something } from "../../features/online-board/index";\nexport const x = something;\n',
|
||||||
);
|
);
|
||||||
expect(result).toContain("boundaries/element-types");
|
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 id = uid();
|
||||||
const result = lintProbe(
|
const result = await lintProbe(
|
||||||
`src/features/online-board/__bnd_probe_${id}__.ts`,
|
`src/features/online-board/__bnd_probe_${id}__.ts`,
|
||||||
'import { getEnv } from "../../env/index";\nexport const x = getEnv;\n',
|
'import { getEnv } from "../../env/index";\nexport const x = getEnv;\n',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,77 +1,76 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||||
import { execSync } from "node:child_process";
|
|
||||||
|
vi.setConfig({ testTimeout: 30_000 });
|
||||||
|
import { ESLint } from "eslint";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
const ROOT = path.resolve(import.meta.dirname, "../..");
|
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 {
|
function uid(): string {
|
||||||
return crypto.randomBytes(4).toString("hex");
|
return crypto.randomBytes(4).toString("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
function lintString(filePath: string, content: string): string {
|
afterAll(() => {
|
||||||
const absPath = path.join(ROOT, filePath);
|
for (const f of probeFiles) {
|
||||||
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
try { fs.unlinkSync(f); } catch { /* ok */ }
|
||||||
fs.writeFileSync(absPath, content, "utf8");
|
let dir = path.dirname(f);
|
||||||
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);
|
|
||||||
const srcDir = path.join(ROOT, "src");
|
const srcDir = path.join(ROOT, "src");
|
||||||
while (dir.length > srcDir.length) {
|
while (dir.length > srcDir.length) {
|
||||||
try {
|
try { fs.rmdirSync(dir); } catch { break; }
|
||||||
fs.rmdirSync(dir);
|
|
||||||
} catch {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
dir = path.dirname(dir);
|
dir = path.dirname(dir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function lintString(filePath: string, content: string): Promise<string> {
|
||||||
|
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", () => {
|
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 id = uid();
|
||||||
const result = lintString(
|
const result = await lintString(
|
||||||
`src/features/online-board/__ri_probe_${id}__.ts`,
|
`src/features/online-board/__ri_probe_${id}__.ts`,
|
||||||
'import { MeterProvider } from "@opentelemetry/sdk-metrics";\nexport const x = MeterProvider;\n',
|
'import { MeterProvider } from "@opentelemetry/sdk-metrics";\nexport const x = MeterProvider;\n',
|
||||||
);
|
);
|
||||||
expect(result).toContain("no-restricted-imports");
|
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 id = uid();
|
||||||
const result = lintString(
|
const result = await lintString(
|
||||||
`src/features/online-board/__ri_probe_${id}__.ts`,
|
`src/features/online-board/__ri_probe_${id}__.ts`,
|
||||||
'import { useTranslation } from "react-i18next";\nexport const x = useTranslation;\n',
|
'import { useTranslation } from "react-i18next";\nexport const x = useTranslation;\n',
|
||||||
);
|
);
|
||||||
expect(result).toContain("no-restricted-imports");
|
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 id = uid();
|
||||||
const result = lintString(
|
const result = await lintString(
|
||||||
`src/routes/__probe_${id}__/__ri_probe__.ts`,
|
`src/routes/__probe_${id}__/__ri_probe__.ts`,
|
||||||
'import { HubConnectionBuilder } from "@microsoft/signalr";\nexport const x = HubConnectionBuilder;\n',
|
'import { HubConnectionBuilder } from "@microsoft/signalr";\nexport const x = HubConnectionBuilder;\n',
|
||||||
);
|
);
|
||||||
expect(result).toContain("no-restricted-imports");
|
expect(result).toContain("no-restricted-imports");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks localStorage outside storage.ts", () => {
|
it("blocks localStorage outside storage.ts", async () => {
|
||||||
const id = uid();
|
const id = uid();
|
||||||
const result = lintString(
|
const result = await lintString(
|
||||||
`src/features/online-board/__ri_probe_${id}__.ts`,
|
`src/features/online-board/__ri_probe_${id}__.ts`,
|
||||||
'const x = localStorage.getItem("key");\nexport default x;\n',
|
'const x = localStorage.getItem("key");\nexport default x;\n',
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user