Files
millionco__react-doctor/packages/react-doctor/tests/github-action-comment.test.ts
Aiden Bai cd9ca68faa NEW_RULES: Add rule analysis foundation and 56 rules (#1402)
* 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
2026-07-20 00:54:55 -07:00

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);
});
});