mirror of
https://github.com/millionco/react-doctor.git
synced 2026-09-14 20:00:24 +08:00
cd9ca68faa
* NEW_RULES: Reapply rule analysis foundation Reapply the complete reviewed NEW_RULES stack on top of current main after the rollback. * fix(rules): harden new diagnostics against false positives * fix(rules): preserve dynamic context providers * fix(core): resolve styled-components workspace versions * fix(rules): harden new diagnostics after deep audit
567 lines
20 KiB
TypeScript
567 lines
20 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import * as fs from "node:fs";
|
|
import os from "node:os";
|
|
import * as path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vite-plus/test";
|
|
import type { JsonReport } from "@react-doctor/core";
|
|
import type { Diagnostic, JsonReportProjectEntry, ProjectInfo } from "@react-doctor/core";
|
|
|
|
const REPOSITORY_ROOT = path.resolve(import.meta.dirname, "..", "..", "..");
|
|
const RENDER_SCRIPT_PATH = path.join(REPOSITORY_ROOT, "scripts/render-github-action-comment.mjs");
|
|
|
|
const tempDirectories: string[] = [];
|
|
|
|
const setupTempDirectory = (): string => {
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-comment-"));
|
|
tempDirectories.push(directory);
|
|
return directory;
|
|
};
|
|
|
|
const buildProject = (overrides: Partial<ProjectInfo> = {}): ProjectInfo => ({
|
|
rootDirectory: "/repo",
|
|
projectName: "web",
|
|
reactVersion: "^19.0.0",
|
|
reactMajorVersion: 19,
|
|
tailwindVersion: null,
|
|
framework: "unknown",
|
|
hasTypeScript: true,
|
|
hasReactCompiler: false,
|
|
hasI18nLibrary: false,
|
|
tanstackQueryVersion: null,
|
|
mobxVersion: null,
|
|
styledComponentsVersion: null,
|
|
preactVersion: null,
|
|
preactMajorVersion: null,
|
|
nextjsVersion: null,
|
|
nextjsMajorVersion: null,
|
|
hasReactNativeWorkspace: false,
|
|
expoVersion: null,
|
|
shopifyFlashListVersion: null,
|
|
shopifyFlashListMajorVersion: null,
|
|
hasReanimated: false,
|
|
isPreES2023Target: false,
|
|
sourceFileCount: 2,
|
|
...overrides,
|
|
});
|
|
|
|
const buildProjectEntry = (
|
|
diagnostics: Diagnostic[],
|
|
overrides: Partial<JsonReportProjectEntry> = {},
|
|
): JsonReportProjectEntry => ({
|
|
directory: "/repo",
|
|
project: buildProject(),
|
|
diagnostics,
|
|
score: { score: 81, label: "Good" },
|
|
skippedChecks: [],
|
|
scannedFileCount: 2,
|
|
elapsedMilliseconds: 123,
|
|
...overrides,
|
|
});
|
|
|
|
const buildDiagnostics = (): Diagnostic[] => [
|
|
{
|
|
plugin: "react-doctor",
|
|
rule: "no-derived-state",
|
|
severity: "error",
|
|
category: "correctness",
|
|
title: "Derived value copied into state",
|
|
message:
|
|
"Derived value is copied into state, so React can render stale UI before the duplicate state catches up.",
|
|
help: "Compute the value during render from the source state, or memoize it only if the computation is expensive.",
|
|
filePath: "src/App.tsx",
|
|
line: 10,
|
|
column: 3,
|
|
},
|
|
{
|
|
plugin: "react-doctor",
|
|
rule: "no-derived-state",
|
|
severity: "error",
|
|
category: "correctness",
|
|
title: "Derived value copied into state",
|
|
message:
|
|
"Derived value is copied into state, so React can render stale UI before the duplicate state catches up.",
|
|
help: "Compute the value during render from the source state, or memoize it only if the computation is expensive.",
|
|
filePath: "src/Profile.tsx",
|
|
line: 4,
|
|
column: 8,
|
|
},
|
|
];
|
|
|
|
const buildReport = (overrides: Partial<JsonReport> = {}): JsonReport => {
|
|
const diagnostics = buildDiagnostics();
|
|
return {
|
|
schemaVersion: 1,
|
|
version: "0.0.0-test",
|
|
ok: true,
|
|
directory: "/repo",
|
|
mode: "diff",
|
|
diff: {
|
|
baseBranch: "main",
|
|
currentBranch: "feature",
|
|
changedFileCount: 2,
|
|
isCurrentChanges: false,
|
|
},
|
|
projects: [buildProjectEntry(diagnostics)],
|
|
diagnostics,
|
|
summary: {
|
|
errorCount: 2,
|
|
warningCount: 0,
|
|
affectedFileCount: 2,
|
|
totalDiagnosticCount: 2,
|
|
score: 81,
|
|
scoreLabel: "Good",
|
|
},
|
|
elapsedMilliseconds: 123,
|
|
error: null,
|
|
...overrides,
|
|
};
|
|
};
|
|
|
|
const runRenderer = (report: JsonReport, envOverrides: Record<string, string> = {}) => {
|
|
const tempDirectory = setupTempDirectory();
|
|
const reportPath = path.join(tempDirectory, "report.json");
|
|
const commentPath = path.join(tempDirectory, "comment.md");
|
|
const outputPath = path.join(tempDirectory, "outputs.txt");
|
|
fs.writeFileSync(reportPath, `${JSON.stringify(report)}\n`);
|
|
|
|
const env: Record<string, string | undefined> = {
|
|
...process.env,
|
|
GITHUB_OUTPUT: outputPath,
|
|
GITHUB_RUN_URL: "https://github.com/millionco/react-doctor/actions/runs/123",
|
|
GITHUB_REPOSITORY: "millionco/react-doctor",
|
|
GITHUB_SERVER_URL: "https://github.com",
|
|
REACT_DOCTOR_HEAD_SHA: "cfc8878abcdef0123456789",
|
|
...envOverrides,
|
|
};
|
|
// Drop the ambient value so the workflow-file notice is deterministic under
|
|
// CI (where GITHUB_WORKFLOW_REF is set) unless a test injects its own.
|
|
if (!("GITHUB_WORKFLOW_REF" in envOverrides)) delete env.GITHUB_WORKFLOW_REF;
|
|
|
|
execFileSync(process.execPath, [RENDER_SCRIPT_PATH, reportPath, commentPath], { env });
|
|
|
|
return {
|
|
comment: fs.readFileSync(commentPath, "utf8"),
|
|
outputs: fs.readFileSync(outputPath, "utf8"),
|
|
};
|
|
};
|
|
|
|
describe("render-github-action-comment", () => {
|
|
afterEach(() => {
|
|
for (const directory of tempDirectories.splice(0)) {
|
|
fs.rmSync(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("renders a sticky markdown summary and action outputs from a JSON report", () => {
|
|
const { comment, outputs } = runRenderer(buildReport());
|
|
|
|
expect(comment).toContain("<!-- react-doctor:summary -->");
|
|
// The brand image header, the "Generated by React Doctor" footer, and the
|
|
// "View workflow run" link were removed from the comment body.
|
|
expect(comment).not.toContain("react-doctor-og-banner.svg");
|
|
expect(comment).not.toContain('alt="React Doctor"');
|
|
expect(comment).not.toContain("[View workflow run]");
|
|
expect(comment).not.toContain("Generated by [React Doctor]");
|
|
expect(comment).not.toContain("founders@million.dev");
|
|
// Small-font attribution footer with the reviewed commit + inline-fix hint.
|
|
expect(comment).toContain(
|
|
"<sub>Reviewed by [React Doctor](https://react.doctor) for commit `cfc8878`. See inline comments for fixes.</sub>",
|
|
);
|
|
// The metrics table is gone — its data is now a single active-voice line.
|
|
expect(comment).not.toContain("| Score |");
|
|
expect(comment).toContain(
|
|
"**React Doctor** found **2 issues** in 2 files · 2 errors · score 81 / 100 (Good) · vs `main`",
|
|
);
|
|
// Errors render expanded, each naming a linkable file:line + rule.
|
|
expect(comment).toContain("**Errors**");
|
|
expect(comment).toContain("❌ [`src/App.tsx:10`]");
|
|
expect(comment).toContain(
|
|
"https://github.com/millionco/react-doctor/blob/cfc8878abcdef0123456789/src/App.tsx#L10",
|
|
);
|
|
expect(comment).toContain("`no-derived-state`");
|
|
expect(outputs).toContain("score=81");
|
|
expect(outputs).toContain("total-issues=2");
|
|
expect(outputs).toContain("error-count=2");
|
|
expect(outputs).toContain("affected-files=2");
|
|
});
|
|
|
|
it("warns when a compare run degraded to listing every changed-file issue", () => {
|
|
const { comment } = runRenderer(buildReport({ baselineDegraded: true }));
|
|
|
|
// The counts now include pre-existing issues, so the comment must flag the
|
|
// misconfigured workflow and hand back the fix as a checkout diff.
|
|
expect(comment).toContain(
|
|
"<details><summary>⚠️ Warning: this workflow is configured incorrectly. See below to fix.</summary>",
|
|
);
|
|
expect(comment).toContain("compares against `main`");
|
|
// The fix is a diff with surrounding context so the reader can locate the step.
|
|
expect(comment).toContain("```diff");
|
|
expect(comment).toContain(" - uses: actions/checkout@v5");
|
|
expect(comment).toContain("+ fetch-depth: 0");
|
|
expect(comment).toContain(" - uses: millionco/react-doctor@v2");
|
|
// The notice sits at the bottom: after the findings, just above the footer.
|
|
expect(comment.indexOf("configured incorrectly")).toBeGreaterThan(
|
|
comment.indexOf("**Errors**"),
|
|
);
|
|
expect(comment.indexOf("configured incorrectly")).toBeLessThan(
|
|
comment.indexOf("Reviewed by [React Doctor]"),
|
|
);
|
|
// The notice points at the opt-out.
|
|
expect(comment).toContain("set `silence-missing-baseline-warning: true`");
|
|
});
|
|
|
|
it("omits the warning when silencing is opted into", () => {
|
|
const { comment } = runRenderer(buildReport({ baselineDegraded: true }), {
|
|
REACT_DOCTOR_SILENCE_MISSING_BASELINE_WARNING: "true",
|
|
});
|
|
// The findings still render; only the config warning is silenced.
|
|
expect(comment).not.toContain("configured incorrectly");
|
|
expect(comment).toContain("**Errors**");
|
|
});
|
|
|
|
it("names the workflow file in the degraded notice when the ref is available", () => {
|
|
const { comment } = runRenderer(buildReport({ baselineDegraded: true }), {
|
|
GITHUB_WORKFLOW_REF: "millionco/same/.github/workflows/react-doctor.yml@refs/heads/main",
|
|
});
|
|
// The filename uses a `<code>` tag, not a backtick span — GitHub doesn't
|
|
// render inline markdown inside a `<summary>`.
|
|
expect(comment).toContain(
|
|
"<details><summary>⚠️ Warning: <code>.github/workflows/react-doctor.yml</code> is configured incorrectly. See below to fix.</summary>",
|
|
);
|
|
expect(comment).toContain("in `.github/workflows/react-doctor.yml` so the checkout");
|
|
});
|
|
|
|
it("omits the baseline-degraded warning on a healthy run", () => {
|
|
const { comment } = runRenderer(buildReport());
|
|
expect(comment).not.toContain("configured incorrectly");
|
|
});
|
|
|
|
const emptySummary = {
|
|
errorCount: 0,
|
|
warningCount: 0,
|
|
affectedFileCount: 0,
|
|
totalDiagnosticCount: 0,
|
|
score: null,
|
|
scoreLabel: null,
|
|
};
|
|
|
|
it("treats a degraded run that scanned no React files as a normal skip, not a warning", () => {
|
|
// `baselineDegraded` is set on any `changed` run whose base can't be reached
|
|
// — including a PR that changed no React-eligible files. That's a normal skip,
|
|
// not a misconfiguration to warn about (the warning only fires once files are
|
|
// actually scanned and compared). See the two Bugbot findings on PR #1019.
|
|
const { comment, outputs } = runRenderer(
|
|
buildReport({
|
|
baselineDegraded: true,
|
|
diagnostics: [],
|
|
projects: [buildProjectEntry([], { scannedFileCount: 0 })],
|
|
summary: emptySummary,
|
|
}),
|
|
);
|
|
expect(comment).not.toContain("configured incorrectly");
|
|
expect(comment).toContain("skipped this pull request");
|
|
expect(outputs).toContain("skipped=true");
|
|
});
|
|
|
|
it("renders a baseline report with the new-issue count, fixed count, and commit footer", () => {
|
|
const diagnostics = buildDiagnostics();
|
|
const { comment } = runRenderer(
|
|
buildReport({
|
|
schemaVersion: 2,
|
|
mode: "baseline",
|
|
baseline: { baseRef: "abc1234def5678", newCount: 2, fixedCount: 3, baseTotalCount: 5 },
|
|
}),
|
|
);
|
|
|
|
// Baseline lead reads "N new issues" and folds the fixed count + base scope
|
|
// into the one-liner (no separate table or "untouched" sentence).
|
|
expect(comment).toContain(
|
|
"**React Doctor** found **2 new issues** in 2 files · 2 errors · score 81 / 100 (Good) · 3 fixed · vs `main`",
|
|
);
|
|
expect(comment).not.toContain("| Score | New |");
|
|
expect(comment).toContain(
|
|
"<sub>Reviewed by [React Doctor](https://react.doctor) for commit `cfc8878`. See inline comments for fixes.</sub>",
|
|
);
|
|
expect(diagnostics.length).toBe(2);
|
|
});
|
|
|
|
it("renders a clean baseline report with no new issues", () => {
|
|
const { comment } = runRenderer(
|
|
buildReport({
|
|
schemaVersion: 2,
|
|
mode: "baseline",
|
|
diagnostics: [],
|
|
projects: [buildProjectEntry([], { diagnostics: [] })],
|
|
baseline: { baseRef: "abc1234def5678", newCount: 0, fixedCount: 1, baseTotalCount: 4 },
|
|
summary: {
|
|
errorCount: 0,
|
|
warningCount: 0,
|
|
affectedFileCount: 0,
|
|
totalDiagnosticCount: 0,
|
|
score: 100,
|
|
scoreLabel: "Great",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(comment).toContain("**React Doctor** found no new issues. 🎉");
|
|
// No issues → no inline-fix hint in the footer.
|
|
expect(comment).toContain(
|
|
"<sub>Reviewed by [React Doctor](https://react.doctor) for commit `cfc8878`.</sub>",
|
|
);
|
|
expect(comment).not.toContain("See inline comments for fixes");
|
|
});
|
|
|
|
it("renders completed scans with no findings as a clean result", () => {
|
|
const { comment } = runRenderer(
|
|
buildReport({
|
|
diagnostics: [],
|
|
projects: [buildProjectEntry([], { diagnostics: [] })],
|
|
summary: {
|
|
errorCount: 0,
|
|
warningCount: 0,
|
|
affectedFileCount: 0,
|
|
totalDiagnosticCount: 0,
|
|
score: 100,
|
|
scoreLabel: "Great",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(comment).toContain("**React Doctor** found no issues. 🎉");
|
|
expect(comment).not.toContain("| Score |");
|
|
});
|
|
|
|
it("marks a diff scan that changed no React-eligible source files as skipped", () => {
|
|
const { comment, outputs } = runRenderer(
|
|
buildReport({
|
|
projects: [],
|
|
diagnostics: [],
|
|
summary: {
|
|
errorCount: 0,
|
|
warningCount: 0,
|
|
affectedFileCount: 0,
|
|
totalDiagnosticCount: 0,
|
|
score: null,
|
|
scoreLabel: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
// A PR that touched nothing React Doctor lints isn't a finding and isn't a
|
|
// "clean pass" worth a comment — it's a no-op. The body reads "skipped" (for
|
|
// the job summary), and `skipped=true` tells the Action to suppress the
|
|
// sticky PR comment + mark the commit status "Skipped".
|
|
expect(comment).toContain("React Doctor skipped this pull request");
|
|
expect(comment).not.toContain("No React Doctor issues found");
|
|
expect(comment).not.toContain("found no issues");
|
|
expect(outputs).toContain("skipped=true");
|
|
});
|
|
|
|
it("marks a diff scan whose changed files examined zero eligible files as skipped", () => {
|
|
const { comment, outputs } = runRenderer(
|
|
buildReport({
|
|
diagnostics: [],
|
|
// The PR changed a `.ts` file with no JSX — scanned, but the JSX include
|
|
// filter left nothing for the linter (`scannedFileCount: 0`).
|
|
projects: [buildProjectEntry([], { diagnostics: [], scannedFileCount: 0, score: null })],
|
|
summary: {
|
|
errorCount: 0,
|
|
warningCount: 0,
|
|
affectedFileCount: 0,
|
|
totalDiagnosticCount: 0,
|
|
score: null,
|
|
scoreLabel: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(comment).toContain("React Doctor skipped this pull request");
|
|
expect(outputs).toContain("skipped=true");
|
|
});
|
|
|
|
it("does NOT skip a clean scan of real React changes (eligible files examined)", () => {
|
|
const { comment, outputs } = runRenderer(
|
|
buildReport({
|
|
diagnostics: [],
|
|
// `scannedFileCount: 2` (the fixture default) → React Doctor examined
|
|
// real React files and they were clean. Still earns a "no issues" comment.
|
|
projects: [buildProjectEntry([], { diagnostics: [] })],
|
|
summary: {
|
|
errorCount: 0,
|
|
warningCount: 0,
|
|
affectedFileCount: 0,
|
|
totalDiagnosticCount: 0,
|
|
score: 100,
|
|
scoreLabel: "Great",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(comment).toContain("**React Doctor** found no issues. 🎉");
|
|
expect(comment).not.toContain("skipped this pull request");
|
|
expect(outputs).toContain("skipped=false");
|
|
});
|
|
|
|
it("does NOT skip a full-scope scan with no projects (clean success, not a no-op)", () => {
|
|
const { comment, outputs } = runRenderer(
|
|
buildReport({
|
|
mode: "full",
|
|
diff: null,
|
|
projects: [],
|
|
diagnostics: [],
|
|
summary: {
|
|
errorCount: 0,
|
|
warningCount: 0,
|
|
affectedFileCount: 0,
|
|
totalDiagnosticCount: 0,
|
|
score: null,
|
|
scoreLabel: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
// `scope: full` is an explicit "scan everything" request, so an empty result
|
|
// is a clean pass worth reporting — not the diff-mode "nothing changed" skip.
|
|
expect(comment).toContain("No React Doctor issues found. 🎉");
|
|
expect(comment).not.toContain("skipped this pull request");
|
|
expect(outputs).toContain("skipped=false");
|
|
});
|
|
|
|
it("renders incomplete checks as an explicit caveat", () => {
|
|
const { comment } = runRenderer(
|
|
buildReport({
|
|
diagnostics: [],
|
|
projects: [
|
|
buildProjectEntry([], {
|
|
diagnostics: [],
|
|
score: null,
|
|
skippedChecks: ["lint"],
|
|
skippedCheckReasons: {
|
|
lint: "oxlint did not return within 300s.",
|
|
},
|
|
}),
|
|
],
|
|
summary: {
|
|
errorCount: 0,
|
|
warningCount: 0,
|
|
affectedFileCount: 0,
|
|
totalDiagnosticCount: 0,
|
|
score: null,
|
|
scoreLabel: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(comment).toContain("**React Doctor** found no issues, but some checks were incomplete.");
|
|
expect(comment).toContain("### Incomplete Checks");
|
|
expect(comment).toContain("`lint`: oxlint did not return within 300s.");
|
|
});
|
|
|
|
it("renders partial-check reasons even without a skipped check entry", () => {
|
|
const { comment } = runRenderer(
|
|
buildReport({
|
|
projects: [
|
|
buildProjectEntry(buildDiagnostics(), {
|
|
skippedCheckReasons: {
|
|
"lint:partial": "1 file exceeded the oxlint budget.",
|
|
},
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
|
|
expect(comment).toContain("### Incomplete Checks");
|
|
expect(comment).toContain("`lint:partial`: 1 file exceeded the oxlint budget.");
|
|
});
|
|
|
|
it("renders scan errors instead of pretending the run succeeded", () => {
|
|
const { comment, outputs } = runRenderer(
|
|
buildReport({
|
|
ok: false,
|
|
mode: "full",
|
|
diff: null,
|
|
diagnostics: [],
|
|
summary: {
|
|
errorCount: 0,
|
|
warningCount: 0,
|
|
affectedFileCount: 0,
|
|
totalDiagnosticCount: 0,
|
|
score: null,
|
|
scoreLabel: null,
|
|
},
|
|
error: {
|
|
name: "ReactDoctorActionError",
|
|
message: "react-doctor exited with status 1 before producing a JSON report.",
|
|
chain: [],
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(comment).toContain("React Doctor could not complete this scan.");
|
|
expect(comment).not.toContain("react-doctor-og-banner.svg");
|
|
expect(comment).toContain("react-doctor exited with status 1");
|
|
expect(comment).toContain("[Report this bug]");
|
|
expect(comment).toContain("issues/new?");
|
|
expect(comment).toContain("React+Doctor+Action+failed");
|
|
expect(comment).not.toContain("Generated by [React Doctor]");
|
|
expect(comment).not.toContain("founders@million.dev");
|
|
// No Sentry event id on this error -> no reference line.
|
|
expect(comment).not.toContain("Sentry reference:");
|
|
expect(outputs).toContain("score=");
|
|
expect(outputs).toContain("total-issues=0");
|
|
});
|
|
|
|
it("includes the Sentry reference in the error comment when the crash reported one", () => {
|
|
const { comment } = runRenderer(
|
|
buildReport({
|
|
ok: false,
|
|
mode: "full",
|
|
diff: null,
|
|
diagnostics: [],
|
|
summary: {
|
|
errorCount: 0,
|
|
warningCount: 0,
|
|
affectedFileCount: 0,
|
|
totalDiagnosticCount: 0,
|
|
score: null,
|
|
scoreLabel: null,
|
|
},
|
|
error: {
|
|
name: "TypeError",
|
|
message: "Cannot read properties of undefined",
|
|
chain: [],
|
|
sentryEventId: "abc123def456",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Surfaced both as a quotable line in the comment and in the prefilled issue.
|
|
expect(comment).toContain("Sentry reference: `abc123def456`");
|
|
expect(comment).toContain("Sentry+reference%3A+abc123def456");
|
|
});
|
|
|
|
it("skips the comment without throwing when the report path is empty", () => {
|
|
const tempDirectory = setupTempDirectory();
|
|
const commentPath = path.join(tempDirectory, "comment.md");
|
|
|
|
expect(() =>
|
|
execFileSync(process.execPath, [RENDER_SCRIPT_PATH, "", commentPath], { stdio: "pipe" }),
|
|
).not.toThrow();
|
|
expect(fs.existsSync(commentPath)).toBe(false);
|
|
});
|
|
|
|
it("skips the comment without throwing when the report file is missing", () => {
|
|
const tempDirectory = setupTempDirectory();
|
|
const reportPath = path.join(tempDirectory, "missing-report.json");
|
|
const commentPath = path.join(tempDirectory, "comment.md");
|
|
|
|
expect(() =>
|
|
execFileSync(process.execPath, [RENDER_SCRIPT_PATH, reportPath, commentPath], {
|
|
stdio: "pipe",
|
|
}),
|
|
).not.toThrow();
|
|
expect(fs.existsSync(commentPath)).toBe(false);
|
|
});
|
|
});
|