cm_whatsapp_bot_v1/apps/web/src/test/no-button-wrapping-card.test.ts
yiekheng c8199f0bbf fix(web): switch dialog cards to transparent <button> overlay; add test guards
The remaining "Hydration failed" error came from passing a Card (a <div>)
as the asChild target of Radix's DialogTrigger. Radix's Slot then
injects button-specific props (type="button", aria-haspopup, …) onto
the underlying <div>, and React's SSR vs client trees diverge on those
attributes.

Same overlay pattern that already worked for the Pair card now applies
to every Dialog-card-trigger in the app:

- accounts list — Delete card per row
- account detail — Unpair card
- account detail — Delete card

The visible Card stays a <div>. A real <button type="button"> with no
children sits absolutely-positioned over the card surface and is the
DialogTrigger target. Click area is identical, HTML is valid, no Radix
prop-forwarding into the wrong element type.

Also fixed: edit-account-form.tsx had the original
  <button>...<Card>...</Card></button>
nesting (the new static guard caught it). Replaced with a Card that's
its own pressable region (onClick + onKeyDown + role=button on the
<div>; no nested button).

Test guards
-----------
+ src/test/no-render-warnings.test.tsx (6 tests)
  Renders AccountsListView, ThemeToggle, EditMessageForm via
  renderToString and asserts neither console.error nor console.warn
  was invoked. Also scans the produced HTML for any <button> region
  that contains a <div>/<p>/<h*> — invalid nesting that would cause
  a hydration mismatch in the browser.

+ src/test/no-button-wrapping-card.test.ts (2 tests)
  Walks every production .tsx file in src/ and fails if any contains
  a literal `<button` (lowercase) that wraps `<Card`/`<CardContent`/
  `<CardHeader`. Caught a real instance in edit-account-form.tsx that
  I missed in the earlier round.

Total tests: 100.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 09:22:30 +08:00

104 lines
3.7 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
/**
* Static guard: no `.tsx` file may contain a literal `<button` element
* wrapping a `<Card`, `<CardContent`, or `<CardHeader` (the shadcn
* primitives that render `<div>`).
*
* That nesting is invalid HTML — `<button>`'s content model is phrasing
* content, and `<div>` is flow content. Browsers auto-close the button
* when they hit the inner div; React 19 SSR doesn't, and the resulting
* tree mismatch raised "Hydration failed" in production. We use Card
* directly as the trigger now (DialogTrigger asChild forwards the
* click) or place a transparent submit button as a sibling.
*
* We scan for the lowercase `<button` only — `<Button>` (shadcn's
* styled component) is fine; it just renders a real `<button>` with no
* children. `<button>` followed by `<Card>` within a few hundred
* characters is the smell.
*/
const SRC_ROOT = join(__dirname, "..");
function listTsxFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
out.push(...listTsxFiles(full));
} else if (entry.endsWith(".tsx")) {
out.push(full);
}
}
return out;
}
interface Hit {
file: string;
line: number;
excerpt: string;
}
function findHits(content: string): Array<{ line: number; excerpt: string }> {
const hits: Array<{ line: number; excerpt: string }> = [];
// For every opening `<button` (lowercase), grab everything up to its
// matching `</button>` and look for a Card primitive opener inside.
const cardOpener = /<(?:Card|CardContent|CardHeader)\b/;
let cursor = 0;
while (cursor < content.length) {
const open = content.indexOf("<button", cursor);
if (open === -1) break;
// Skip JSX comments, attribute-substring matches like `<buttonish`, etc.
const next = content.charAt(open + "<button".length);
if (next && /[a-zA-Z0-9]/.test(next)) {
cursor = open + 1;
continue;
}
const close = content.indexOf("</button>", open);
if (close === -1) break;
const segment = content.slice(open, close);
if (cardOpener.test(segment)) {
const line = content.slice(0, open).split("\n").length;
hits.push({ line, excerpt: segment.slice(0, 160).replace(/\s+/g, " ") });
}
cursor = close + "</button>".length;
}
return hits;
}
describe("static guard: no <button> wrapping a Card primitive", () => {
const files = listTsxFiles(SRC_ROOT)
// Test files describe and document patterns; they may legitimately
// contain regex strings that mention `<button>` and `<Card>` together.
.filter((f) => !/\.test\.tsx?$/.test(f));
it("scans at least one source file (sanity)", () => {
expect(files.length).toBeGreaterThan(0);
});
it("finds no <button>…<Card> nesting in any production .tsx file", () => {
const allHits: Hit[] = [];
for (const file of files) {
const content = readFileSync(file, "utf8");
for (const h of findHits(content)) {
allHits.push({ file: relative(SRC_ROOT, file), ...h });
}
}
if (allHits.length > 0) {
const message = allHits
.map((h) => ` ${h.file}:${h.line}${h.excerpt}`)
.join("\n");
throw new Error(
`Invalid HTML nesting detected — <button> wraps a Card primitive:\n${message}\n` +
`Card renders a <div>, which is flow content and not allowed inside <button>.\n` +
`Use Card directly as DialogTrigger asChild's child, or place a transparent\n` +
`<button type="submit"> as a sibling that overlays the card.`,
);
}
expect(allHits).toEqual([]);
});
});