cm_whatsapp_bot_v1/apps/web/src/test/no-render-warnings.test.tsx
yiekheng c4d4f1dda7 feat(web): extract reminder name to its own edit section
The name input previously lived inside the message edit page. Now that
it's a required field — and one users may want to revise without
touching the message stack — it gets a dedicated card on the reminder
detail page and its own edit route at /reminders/[id]/edit/name.

EditMessageForm receives the name as a pass-through prop so saving
messages doesn't drop the existing name from the action payload.

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

195 lines
7.2 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { renderToString } from "react-dom/server";
import type { ReactNode, ReactElement } from "react";
/**
* Guard: server-rendering critical surfaces must not produce ANY
* console.error or console.warn from React. The most common sources are:
*
* - Invalid HTML nesting (e.g. <div> inside <button>) — this is exactly
* what produced the recent "Hydration failed" runtime error.
* - Two children with the same `key`.
* - "Encountered a script tag while rendering React component" — a
* React 19 warning when a `<script>` appears inside the React tree
* rather than `<head>`.
*
* We can't fully reproduce *runtime* hydration in a Node test, but
* `renderToString` runs React's SSR-time validation, which catches
* these structural issues during the same render that would later
* mismatch in the browser.
*/
// --- Mocks for next/link + the radix primitives we use --------------------
// These are deliberately TRANSPARENT (render children) so the underlying
// element tree we care about (button vs div nesting, etc.) reaches React's
// validator unchanged.
vi.mock("next/link", () => ({
default: ({ href, children, ...rest }: { href: string; children: ReactNode } & Record<string, unknown>) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ children }: { children: ReactNode }) => <>{children}</>,
DialogTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}</>,
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
DialogDescription: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
import { AccountsListView, type AccountsListAccount } from "@/components/accounts-list-view";
import { EditMessageForm } from "@/components/reminder-edit/edit-message-form";
// next-themes / radix dropdown mocks for ThemeToggle.
const useThemeReturn: { theme: string | undefined; setTheme: () => void } = {
theme: "system",
setTheme: () => {},
};
vi.mock("next-themes", () => ({
useTheme: () => useThemeReturn,
}));
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}</>,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({ children }: { children: ReactNode }) => (
<div role="menuitem">{children}</div>
),
}));
// next/navigation is touched by the edit form's useRouter().
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: () => {} }),
}));
// Mock the server action import inside EditMessageForm so importing it
// doesn't try to wire up the real action machinery.
vi.mock("@/actions/reminders", () => ({
updateReminderAction: () => Promise.resolve({ ok: true, reminderId: "r-1" }),
}));
import { ThemeToggle } from "@/components/theme-toggle";
// Helper: render with console.error / console.warn captured. Restores the
// originals via vi.restoreAllMocks at end of each test.
function renderQuiet(node: ReactElement): { html: string; errors: unknown[][]; warns: unknown[][] } {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const html = renderToString(node);
const errors = errorSpy.mock.calls;
const warns = warnSpy.mock.calls;
errorSpy.mockRestore();
warnSpy.mockRestore();
return { html, errors, warns };
}
const account: AccountsListAccount = {
id: "a-1",
label: "Personal",
status: "connected",
phoneNumber: "+60123456789",
lastConnectedAt: new Date("2026-05-01T10:00:00Z"),
};
describe("SSR render — no React errors or warnings", () => {
it("AccountsListView (populated) renders cleanly", () => {
const { errors, warns } = renderQuiet(
<AccountsListView accounts={[account]} />,
);
expect(errors).toEqual([]);
expect(warns).toEqual([]);
});
it("AccountsListView (empty) renders cleanly", () => {
const { errors, warns } = renderQuiet(
<AccountsListView accounts={[]} />,
);
expect(errors).toEqual([]);
expect(warns).toEqual([]);
});
it("ThemeToggle renders cleanly across all theme values", () => {
for (const t of ["light", "dark", "system", undefined]) {
useThemeReturn.theme = t;
const { errors, warns } = renderQuiet(<ThemeToggle />);
expect(errors, `console.error during theme=${t}`).toEqual([]);
expect(warns, `console.warn during theme=${t}`).toEqual([]);
}
});
it("EditMessageForm renders cleanly (text-only and media-attached)", () => {
const baseProps = {
reminderId: "r-1",
accountId: "acc-1",
groupIds: ["g-1"],
scheduledAtIso: "2026-05-13T09:00:00.000+08:00",
rrule: "FREQ=DAILY",
timezone: "Asia/Kuala_Lumpur",
name: "Existing name",
initialMessages: [
{ kind: "text" as const, textContent: "Hello", mediaId: null },
],
};
const a = renderQuiet(<EditMessageForm {...baseProps} />);
expect(a.errors).toEqual([]);
expect(a.warns).toEqual([]);
const b = renderQuiet(
<EditMessageForm
{...baseProps}
initialMessages={[
{ kind: "media", textContent: "caption text", mediaId: "m-1" },
]}
initialMediaInfo={{ "m-1": { filename: "f.pdf", mimeType: "application/pdf" } }}
/>,
);
expect(b.errors).toEqual([]);
expect(b.warns).toEqual([]);
});
});
describe("SSR markup — no <div> inside <button> (the bug we just fixed)", () => {
it("AccountsListView never nests a div inside a button", () => {
const { html } = renderQuiet(
<AccountsListView accounts={[account]} />,
);
// Naive but effective: scan each <button>...</button> region for any
// <div, <p, or <h-tag inside. Those are flow content and not allowed
// inside a button per the HTML spec — browsers auto-close the button
// and the SSR/client trees diverge.
const buttons = [...html.matchAll(/<button\b[^>]*>([\s\S]*?)<\/button>/g)];
for (const m of buttons) {
const inner = m[1] ?? "";
expect(
/<(?:div|p|h[1-6])\b/.test(inner),
`Invalid nesting inside <button>: ${inner.slice(0, 200)}`,
).toBe(false);
}
});
it("EditMessageForm never nests a div inside a button", () => {
const { html } = renderQuiet(
<EditMessageForm
reminderId="r-1"
accountId="acc-1"
groupIds={["g-1"]}
scheduledAtIso="2026-05-13T09:00:00.000+08:00"
rrule={null}
timezone="Asia/Kuala_Lumpur"
name="Existing name"
initialMessages={[{ kind: "text", textContent: "Hello", mediaId: null }]}
/>,
);
const buttons = [...html.matchAll(/<button\b[^>]*>([\s\S]*?)<\/button>/g)];
for (const m of buttons) {
const inner = m[1] ?? "";
expect(/<(?:div|p|h[1-6])\b/.test(inner)).toBe(false);
}
});
});