Files
millionco__react-doctor/packages/react-doctor/tests/resolve-cli-inspect-options.test.ts
Aiden Bai 0b0b5ac523 feat(rules): add deterministic UI, accessibility, and animation diagnostics (#1337)
* feat(rules): add 10 motion, accessibility, and design lint rules

Derived from a cross-resource design reference (every principle from the
design knowledge base), deduped against the full existing rule inventory.

Motion: extend no-transition-all to the Tailwind `transition-all` class
(was inline-only) and add no-tailwind-layout-transition for arbitrary
`transition-[<layout-prop>]`.

Accessibility: no-autoplay-without-muted, no-uninformative-aria-label,
no-target-blank-without-rel, and no-low-contrast-inline-style (computes the
real WCAG 2.1 ratio from co-located inline color + backgroundColor).

Design/Tailwind hygiene: no-redundant-display-class, prefer-truncate-shorthand,
no-full-viewport-width, and no-svg-currentcolor-with-fill-class.

All register via codegen (388 rules); 77 co-located tests pass; typecheck,
lint, and format clean.

* fix(rules): correct two false positives found in rule-validate

- no-low-contrast-inline-style: when the inline style has no `fontSize`,
  the text may be sized large via a class (`text-5xl`), so fall back to the
  lenient 3:1 large-text threshold instead of 4.5:1 to avoid flagging large
  text that only needs 3:1. Stricter 4.5:1 applies only when the size is a
  visible normal size.
- no-redundant-display-class: `<li>` defaults to `display: list-item`, not
  `block`, so `block` on an `<li>` is meaningful — removed `li` from the
  block-default set.

Adds regression tests for both.

* fix(rules): resolve Cursor Bugbot findings

- no-low-contrast-inline-style: use a solid `background` shorthand color as
  the background (was skipped entirely, missing low-contrast pairs); skip only
  gradients/images/ambiguous (both backgroundColor + background). Also treat a
  string `fontWeight: "700"` as bold for the large-text threshold.
- no-transition-all: match `transition-all` as a whole Tailwind token (segment
  after variant prefixes) so compound classes like `transition-all-custom` are
  not falsely flagged.
- no-svg-currentcolor-with-fill-class: exclude stroke-WIDTH utilities
  (`stroke-2`, `stroke-[1.5]`) — they set thickness, not color — so they no
  longer false-conflict with `stroke="currentColor"`.

Adds regression tests for each.

* feat(rules): add 3 Tailwind-canonicalization rules from ui.sh guidance

- no-deprecated-tailwind-class: flag Tailwind v4 renames (bg-gradient-* →
  bg-linear-*, flex-shrink-* → shrink-*, flex-grow-* → grow-*,
  overflow-ellipsis → text-ellipsis). Gated on a new `tailwind:4` capability.
- no-arbitrary-px-font-size: `text-[13px]` → rem so text scales with the
  user's root font size (px stays fine for border-*/outline-*).
- prefer-dvh-over-vh: `h-screen`/`min-h-screen`/`h-[100vh]` → dvh (tailwind:3.4).

Adds `tailwind:4` capability to @react-doctor/core and extracts a shared
`get-class-name-tokens` util (variant-aware token splitting), reused by
no-transition-all and no-svg-currentcolor-with-fill-class.

* fix(rules): adversarial-review false positives in the new design rules

- no-tailwind-layout-transition: match transition-[…] property names exactly
  (comma-split + set) so SVG `stroke-width` / `border-width` — which merely
  contain "width" — are no longer flagged as HTML layout thrash.
- no-full-viewport-width: drop `max-w-*` / `maxWidth` (a defensive cap, not the
  overflow footgun); keep `w-screen`/`w-[100vw]`/`width`/`minWidth`.
- prefer-dvh-over-vh: drop `max-h-*` / `maxHeight` (a valid height cap, e.g. a
  scrollable modal); keep `h-screen`/`min-h-screen`/`height`/`minHeight`.
- no-deprecated-tailwind-class: only rename `bg-gradient-to-*` → `bg-linear-to-*`
  (v4 radial/conic are `bg-radial`/`bg-conic`, not `bg-linear-radial`).

Adds regression tests for each false positive.

* fix(rules): resolve thermo-review false positives

- no-svg-currentcolor-with-fill-class: only treat UNPREFIXED `fill-*`/`stroke-*`
  color classes as conflicts; `hover:fill-blue-600` / `dark:fill-white` (state-
  gated, no static conflict with the base `fill="currentColor"`) are no longer
  flagged — a very common icon pattern.
- no-low-contrast-inline-style: reject 4-arg `rgb(0,0,0,0.5)` / slash-form alpha
  (was judged opaque), and bail when the style object contains a `{...spread}`
  that could override the colors. Tag `test-noise` to match sibling no-tiny-text.
- no-full-viewport-width: drop the redundant `category: "Architecture"` override
  (it's the design-bucket default).
- Extract `has-jsx-spread-attribute` util; use it in the two a11y rules.

Adds regression tests for the variant-prefixed svg classes, rgb-alpha, opaque
rgb, and the style-spread bail.

* refactor(rules): deslop the design/motion/a11y rule additions

Consolidate duplication surfaced by an adversarial deslop pass; no behavior
change (the same diagnostics fire for the same inputs):

- no-autoplay-without-muted: fold isStaticallyTrue/isStaticallyFalse into one
  tri-state resolveStaticBoolean, mirroring the existing parseJsxValue idiom
- no-svg-currentcolor-with-fill-class: collapse the duplicated fill/stroke
  branches into a single loop over the two paint attributes
- no-low-contrast-inline-style: hoist the repeated `properties` fallback
- no-uncontrolled-input: drop the local hasJsxSpreadAttribute copy in favor of
  the branch's new shared utils/has-jsx-spread-attribute, completing the extraction

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(rules): no-deprecated-tailwind-class stops flagging overflow-clip

`overflow-clip` is a current Tailwind utility for `overflow: clip` (v3.1+,
still present in v4) — it was never renamed to `text-clip`, which sets the
unrelated `text-overflow: clip`. The stray mapping produced an incorrect
"renamed in v4" suggestion (Cursor Bugbot). Only `overflow-ellipsis ->
text-ellipsis` is a real rename; keep that. Adds a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(capabilities): require a confirmed Tailwind major for the tailwind:4 gate

`tailwind:4` gates no-deprecated-tailwind-class, which tells users a class was
renamed/removed in v4. The optimistic-on-null policy (assume latest for an
unparseable spec like `workspace:*`) is right for the suggestion rule behind
`tailwind:3.4`, but for a deprecation rule it surfaces confidently-wrong
"renamed in v4" warnings on a v3 project. Require a CONFIRMED parsed major >= 4
for tailwind:4 — favor a false negative over a false positive (Cursor Bugbot).
`tailwind:3.4` stays optimistic. Adds an unparseable-version regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(rules): resolve Bugbot/Devin review findings on design/a11y rules

- no-full-viewport-width: match variant-prefixed `md:w-screen` / `lg:min-w-screen`, mirroring the sibling `prefer-dvh-over-vh` regex (overflow happens at every breakpoint).
- no-target-blank-without-rel: resolve `rel={'noopener'}` expression-container literals instead of treating them as dynamic, so reverse-tabnabbing isn't silently missed; share one literal resolver with the `target` check.
- Extract the bare `16` root-font-size divisor into `ROOT_FONT_SIZE_PX` per the magic-number convention.

* fix(rules): no-uninformative-aria-label resolves braced string literals

Extracts no-target-blank-without-rel's inline getStringLiteralAttributeValue
into a shared util and uses it in no-uninformative-aria-label, so a static
braced literal like aria-label={'icon'} is judged instead of silently skipped
(getJsxPropStringValue only handles plain string literals). Adds regression
tests for the braced uninformative and braced descriptive cases.

Addresses Bugbot review on #850.

* test(rules): add liveness fixtures after rebase

* fix(rules): address automated review findings

* fix(rules): handle leading-dot stroke widths

* fix(rules): skip dynamic background overrides

* fix(rules): address target blank and transition reviews

* fix(rules): skip svg layout transitions

* fix(rules): preserve font line height suggestion

* fix(rules): match complete transition utilities

* fix(rules): share layout transition properties

* fix(rules): bound deprecated utility prefixes

* fix(rules): clear dynamically overridden colors

* fix(rules): tighten style and svg precision

* fix(rules): preserve competing display utilities

* fix(rules): preserve animation property scopes

* feat(rules): expand deterministic design checks

* feat(rules): add structural design diagnostics

* feat(rules): complete source-auditable design checks

* fix(rules): skip unresolved input types

* fix(rules): tighten layout and boundary detection

* fix(rules): close motion and padding gaps

* fix(rules): tighten surface and line-height proofs

* fix(rules): evaluate opaque hsl contrast

* fix(rules): honor effective static values

* fix(rules): honor final inline style declarations

* fix(rules): respect variants and share surface checks

* fix(rules): traverse fragments and preserve variant scope

* feat(rules): deepen UI quality diagnostics

* feat(rules): add deterministic animation diagnostics

* fix(rules): retain in-house accessibility checks

* feat(rules): expand deterministic UI diagnostics

* feat(rules): detect generated interface patterns

* fix(rules): respect dynamic style overrides

* feat(rules): expand deterministic design diagnostics

* fix(rules): close design precision gaps

* feat(rules): expand deterministic design and accessibility checks

* feat(rules): detect unsafe Motion API usage

* test(fuzz): remove duplicate module snippets

* feat(cli): add focused design command

* fix(design): include the complete rule bucket

* fix(core): align opt-in rule gates

* feat(rules): expand deterministic motion and interaction checks

* feat(rules): harden deterministic design diagnostics

* fix(rules): harden design expansion diagnostics

* fix(rules): apply large-corpus precision review

* fix(rules): suppress large-corpus accessibility noise

* fix(core): detect compiler in rstack configs

* fix(a11y): avoid responsive navigation false positives

* fix(rules): reduce design corpus false positives

* fix(rules): restore will-change build after rebase

* fix(rules): harden all-rules corpus precision

---------

Co-authored-by: Rayhan Noufal Arayilakath <me@rayhanadev.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 01:09:01 -07:00

174 lines
6.7 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import { resolveCliInspectOptions } from "../src/cli/utils/resolve-cli-inspect-options.js";
const CI_ENVIRONMENT_VARIABLES = ["CI", "GITHUB_ACTIONS", "GITLAB_CI", "CIRCLECI"] as const;
describe("resolveCliInspectOptions: CI behavior (issue #302)", () => {
let savedEnvironment: Record<string, string | undefined>;
beforeEach(() => {
savedEnvironment = {};
for (const envVariable of CI_ENVIRONMENT_VARIABLES) {
savedEnvironment[envVariable] = process.env[envVariable];
delete process.env[envVariable];
}
});
afterEach(() => {
for (const envVariable of CI_ENVIRONMENT_VARIABLES) {
const previousValue = savedEnvironment[envVariable];
if (previousValue === undefined) {
delete process.env[envVariable];
} else {
process.env[envVariable] = previousValue;
}
}
});
it("does not auto-disable scoring in CI; the score path still runs", () => {
process.env.GITHUB_ACTIONS = "true";
const resolved = resolveCliInspectOptions({}, null);
// Undefined (not `false`) so `inspect()`'s merge layer can inherit
// `userConfig.noScore` from the per-project config — the default with no
// config remains "scoring on".
expect(resolved.noScore).toBeUndefined();
expect(resolved.isCi).toBe(true);
});
it("respects an explicit user opt-out in CI, deferring a config opt-out to the merge layer", () => {
process.env.GITHUB_ACTIONS = "true";
expect(resolveCliInspectOptions({ score: false }, null).noScore).toBe(true);
// Config-driven opt-out is applied by `inspect()`'s merge (per-project
// config included), not eagerly collapsed here from the root config.
expect(resolveCliInspectOptions({}, { noScore: true }).noScore).toBeUndefined();
});
it("leaves isCi false outside CI", () => {
expect(resolveCliInspectOptions({}, null).isCi).toBe(false);
});
it("detects GITHUB_ACTIONS, GITLAB_CI, and CIRCLECI", () => {
for (const envVariable of ["GITHUB_ACTIONS", "GITLAB_CI", "CIRCLECI"] as const) {
process.env[envVariable] = "true";
expect(resolveCliInspectOptions({}, null).isCi).toBe(true);
delete process.env[envVariable];
}
});
});
describe("resolveCliInspectOptions: warnings vs --blocking", () => {
it("leaves warnings unset by default (shown via the inspect() default)", () => {
expect(resolveCliInspectOptions({}, null).warnings).toBeUndefined();
});
it("forces warnings on for --blocking warning (flag or config) so the gate can fire", () => {
expect(resolveCliInspectOptions({ blocking: "warning" }, null).warnings).toBe(true);
expect(resolveCliInspectOptions({}, { blocking: "warning" }).warnings).toBe(true);
});
it("honors the deprecated failOn alias for the warning gate", () => {
expect(resolveCliInspectOptions({ failOn: "warning" }, null).warnings).toBe(true);
expect(resolveCliInspectOptions({}, { failOn: "warning" }).warnings).toBe(true);
});
it("does not set warnings when blocking on errors", () => {
expect(resolveCliInspectOptions({ blocking: "error" }, null).warnings).toBeUndefined();
});
it("forces warnings on for --blocking warning even over an explicit --no-warnings", () => {
// The gate wins: you can't block on warnings you've hidden. (Previously this
// silently no-op'd the gate — REACT-DOCTOR footgun.)
expect(resolveCliInspectOptions({ blocking: "warning", warnings: false }, null).warnings).toBe(
true,
);
});
it("respects --no-warnings when the gate is not warning-level", () => {
expect(resolveCliInspectOptions({ blocking: "error", warnings: false }, null).warnings).toBe(
false,
);
});
it("respects an explicit --warnings", () => {
expect(resolveCliInspectOptions({ warnings: true }, null).warnings).toBe(true);
});
});
describe("resolveCliInspectOptions: --no-telemetry alias", () => {
it("opts out of scoring via --no-telemetry (flags.telemetry === false), like --no-score", () => {
expect(resolveCliInspectOptions({ telemetry: false }, null).noScore).toBe(true);
expect(resolveCliInspectOptions({ score: false }, null).noScore).toBe(true);
});
it("keeps scoring on by default (undefined defers to the merge layer's `false`)", () => {
expect(resolveCliInspectOptions({}, null).noScore).toBeUndefined();
});
});
describe("resolveCliInspectOptions: category filtering", () => {
it("canonicalizes a single category case-insensitively", () => {
expect(resolveCliInspectOptions({ category: "security" }, null).categoryFilters).toEqual([
"Security",
]);
});
it("supports repeated and comma-separated categories", () => {
expect(
resolveCliInspectOptions({ category: ["security", "Performance, accessibility"] }, null)
.categoryFilters,
).toEqual(["Security", "Performance", "Accessibility"]);
});
it("deduplicates repeated categories while keeping order", () => {
expect(
resolveCliInspectOptions({ category: ["Security", "security", "Performance"] }, null)
.categoryFilters,
).toEqual(["Security", "Performance"]);
});
it("rejects an unknown category with the known category list", () => {
expect(() => resolveCliInspectOptions({ category: "correctness" }, null)).toThrow(
'Unknown category "correctness". Expected one of: Security, Bugs, Performance, Accessibility, Maintainability.',
);
});
});
describe("resolveCliInspectOptions: scan-phase flags", () => {
// Passed through as scan options (not folded into config) so the flag wins
// over per-project config in `inspect()`'s merge, matching lint/deadCode.
it("passes lint / deadCode / supplyChain through unchanged", () => {
const off = resolveCliInspectOptions(
{ lint: false, deadCode: false, supplyChain: false },
null,
);
expect(off.lint).toBe(false);
expect(off.deadCode).toBe(false);
expect(off.supplyChain).toBe(false);
const unset = resolveCliInspectOptions({}, null);
expect(unset.supplyChain).toBeUndefined();
expect(resolveCliInspectOptions({ supplyChain: true }, null).supplyChain).toBe(true);
});
});
describe("resolveCliInspectOptions: design scan", () => {
it("selects design rules while skipping unrelated analyzers and scoring", () => {
const resolved = resolveCliInspectOptions(
{ design: true, deadCode: true, supplyChain: true, score: true },
null,
);
expect(resolved.includedTags).toEqual(new Set(["design"]));
expect(resolved.includeTagDefaults).toBe(true);
expect(resolved.deadCode).toBe(false);
expect(resolved.supplyChain).toBe(false);
expect(resolved.noScore).toBe(true);
expect(resolved.scoreDisabledMessage).toBe(
"Design scans do not affect the React health score.",
);
});
});