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>
195 lines
7.2 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|