/**
* The group-caption rule, which only a structure can keep.
*
* `GroupLabel` reads its group's context in all three components that have one, so a caption
* written as a SIBLING of the group throws at render. A production build reports that as
* `Base UI error #31` for the menu, `#56` for select and `#18` for combobox, plus a link — a
* number to go look up, naming neither the file, nor the part, nor the component. Documenting
* that would not have stopped it. Two things do, and both are checked here.
*/
import { readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
// `fileURLToPath`, never `import.meta.dir` — invariant 11.
const here = dirname(fileURLToPath(import.meta.url));
/**
* Comments out, code in. A guard that reads its own explanation as a violation is a guard that
* teaches people to delete the explanation — and this rule is worth a paragraph that has to be
* free to name the part it forbids.
*/
function codeOf(name: string): string {
return readFileSync(join(here, name), "utf8")
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:])\/\/.*$/gm, "$1");
}
const count = (text: string, pattern: RegExp) => text.match(pattern)?.length ?? 0;
/** Every `…` body in a file, for one group kind. */
function bodiesOf(code: string, part: string): string[] {
const tag = `]*>[\\s\\S]*?`;
return code.match(new RegExp(tag, "g")) ?? [];
}
function misplacedCaptions(code: string): number {
const inside = ["Group", "RadioGroup"]
.flatMap((part) => bodiesOf(code, part))
.reduce((total, body) => total + count(body, / name.endsWith(".tsx"))
.map((name) => ({ name, code: codeOf(name) }));
describe("a group caption cannot be placed outside its group", () => {
// Folder-wide rather than menu-only: today `menu.tsx` is the one file that renders a
// caption, but `Select` and `Combobox` both have the part and would grow one the day either
// wrapper learns grouped options. That is exactly where this bug lands next.
it.each(sources)("$name keeps every GroupLabel inside a Group", ({ code }) => {
expect(misplacedCaptions(code)).toBe(0);
});
it("has a caption somewhere to check, so the rule above is not vacuous", () => {
const captions = sources.reduce(
(total, { code }) => total + count(code, / {
// Menu only, on purpose. `MenuGroup` composes the caption as a prop, so the raw parts have
// no job left and withholding them is free. Select and Combobox export theirs as the
// escape hatch for grouped options, a shape those wrappers do not express — see the note
// beside them in index.ts. Widening this assertion would forbid that on purpose.
const surface = codeOf("menu.tsx") + codeOf("index.ts");
expect(surface).not.toMatch(/=\s*Primitive\.(?:GroupLabel|Group|RadioGroup)\s*;/);
expect(surface).not.toMatch(/\bMenuGroupLabel\b/);
});
});