mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
c17d6941be
* chore: migrate to vite plus
* Disable typeAware and typeCheck
* Update CI
* Fix CI
* Fix test
* Clean
* Run test with vp
* Try revert
* react: false In test
* Fix test
* Revert "Try revert"
This reverts commit 009da10473.
* Update
* Update
* Try revert ci changes
* revert
* Run vp migrate
* Disable typeAware and typeCheck for now
* Better resolve for test
* Use vp dev instead of vite
* Update expect
* Fix NormalizeManifestModuleId
* Try increase timeout
* Update to use vp
* Try new check
* Bring back npx vp
* Migrate CI
* Make next-intl resolvable
* Update
* Update
* Update
451 lines
14 KiB
TypeScript
451 lines
14 KiB
TypeScript
/**
|
|
* next/head shim unit tests.
|
|
*
|
|
* Mirrors test cases from Next.js test/unit/next-head-rendering.test.ts,
|
|
* plus comprehensive coverage for vinext's Head SSR collection, HTML
|
|
* generation, allowed tags, and escaping.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from "vite-plus/test";
|
|
import React from "react";
|
|
import ReactDOMServer from "react-dom/server";
|
|
import Head, {
|
|
resetSSRHead,
|
|
getSSRHeadHTML,
|
|
escapeAttr,
|
|
reduceHeadChildren,
|
|
} from "../packages/vinext/src/shims/head.js";
|
|
|
|
// ─── SSR rendering (mirrors Next.js test/unit/next-head-rendering.test.ts) ──
|
|
|
|
describe("Rendering next/head", () => {
|
|
beforeEach(() => {
|
|
resetSSRHead();
|
|
});
|
|
|
|
it("should render outside of Next.js without error", () => {
|
|
// Next.js test: renderToString(<><Head /><p>hello world</p></>)
|
|
// Verifies Head doesn't throw when used standalone
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
React.Fragment,
|
|
null,
|
|
React.createElement(Head, null),
|
|
React.createElement("p", null, "hello world"),
|
|
),
|
|
);
|
|
expect(html).toContain("hello world");
|
|
});
|
|
|
|
it("returns null (no rendered output in body)", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Head, null, React.createElement("title", null, "My Page")),
|
|
);
|
|
// Head always returns null — elements are collected, not rendered inline
|
|
expect(html).toBe("");
|
|
});
|
|
});
|
|
|
|
// ─── SSR head collection ────────────────────────────────────────────────
|
|
|
|
describe("Head SSR collection", () => {
|
|
beforeEach(() => {
|
|
resetSSRHead();
|
|
});
|
|
|
|
it("collects title element", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(Head, null, React.createElement("title", null, "My Page Title")),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain("<title");
|
|
expect(headHtml).toContain("My Page Title");
|
|
expect(headHtml).toContain("</title>");
|
|
expect(headHtml).toContain('data-vinext-head="true"');
|
|
});
|
|
|
|
it("collects meta elements as self-closing", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("meta", { name: "description", content: "A test page" }),
|
|
),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain('<meta name="description" content="A test page"');
|
|
expect(headHtml).toContain("/>"); // self-closing
|
|
expect(headHtml).not.toContain("</meta>");
|
|
});
|
|
|
|
it("collects link elements as self-closing", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("link", { rel: "stylesheet", href: "/styles.css" }),
|
|
),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain('<link rel="stylesheet" href="/styles.css"');
|
|
expect(headHtml).toContain("/>"); // self-closing
|
|
});
|
|
|
|
it("collects style elements", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(Head, null, React.createElement("style", null, "body { color: red; }")),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain("<style");
|
|
// Text content is HTML-escaped
|
|
expect(headHtml).toContain("body { color: red; }");
|
|
});
|
|
|
|
it("collects script elements", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("script", { src: "/analytics.js", async: true }),
|
|
),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain('<script src="/analytics.js" async');
|
|
expect(headHtml).toContain("</script>");
|
|
});
|
|
|
|
it("collects base element as self-closing", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("base", { href: "https://example.com/" }),
|
|
),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain('<base href="https://example.com/"');
|
|
expect(headHtml).toContain("/>"); // self-closing
|
|
});
|
|
|
|
it("collects noscript elements", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("noscript", null, "JavaScript is required"),
|
|
),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain("<noscript");
|
|
expect(headHtml).toContain("JavaScript is required");
|
|
expect(headHtml).toContain("</noscript>");
|
|
});
|
|
|
|
it("collects multiple head elements in order", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("title", null, "First"),
|
|
React.createElement("meta", { name: "viewport", content: "width=device-width" }),
|
|
React.createElement("link", { rel: "icon", href: "/favicon.ico" }),
|
|
),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain("First");
|
|
expect(headHtml).toContain("viewport");
|
|
expect(headHtml).toContain("favicon.ico");
|
|
});
|
|
|
|
it("resets head between renders", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(Head, null, React.createElement("title", null, "Page 1")),
|
|
);
|
|
expect(getSSRHeadHTML()).toContain("Page 1");
|
|
|
|
resetSSRHead();
|
|
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(Head, null, React.createElement("title", null, "Page 2")),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain("Page 2");
|
|
expect(headHtml).not.toContain("Page 1");
|
|
});
|
|
|
|
it("returns empty string when no head elements", () => {
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toBe("");
|
|
});
|
|
|
|
it("dedupes keyed tags across multiple Head instances and keeps the last one", () => {
|
|
// Next.js documents `key` as the dedupe mechanism for next/head tags:
|
|
// https://github.com/vercel/next.js/blob/canary/docs/02-pages/04-api-reference/01-components/head.mdx
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
React.Fragment,
|
|
null,
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("meta", {
|
|
property: "og:title",
|
|
content: "Original Title",
|
|
key: "og-title",
|
|
}),
|
|
),
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("meta", {
|
|
property: "og:title",
|
|
content: "Updated Title",
|
|
key: "og-title",
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain('content="Updated Title"');
|
|
expect(headHtml).not.toContain('content="Original Title"');
|
|
expect(headHtml.match(/property="og:title"/g)).toHaveLength(1);
|
|
});
|
|
|
|
it("dedupes keyed tags across Head instances when one Head has multiple children", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
React.Fragment,
|
|
null,
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("meta", {
|
|
property: "og:title",
|
|
content: "Title A",
|
|
key: "og-title",
|
|
}),
|
|
React.createElement("meta", {
|
|
name: "description",
|
|
content: "Desc A",
|
|
key: "desc",
|
|
}),
|
|
),
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("meta", {
|
|
property: "og:title",
|
|
content: "Title B",
|
|
key: "og-title",
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain('content="Title B"');
|
|
expect(headHtml).toContain('content="Desc A"');
|
|
expect(headHtml).not.toContain('content="Title A"');
|
|
expect(headHtml.match(/property="og:title"/g)).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("Head reduction", () => {
|
|
it("dedupes keyed tags and keeps the last matching element", () => {
|
|
const reduced = reduceHeadChildren([
|
|
React.createElement("meta", {
|
|
property: "og:title",
|
|
content: "Original Title",
|
|
key: "og-title",
|
|
}),
|
|
React.createElement("meta", {
|
|
property: "og:title",
|
|
content: "Updated Title",
|
|
key: "og-title",
|
|
}),
|
|
]);
|
|
|
|
expect(reduced).toHaveLength(1);
|
|
const dedupedMeta = reduced[0] as React.ReactElement<{ content?: string }> | undefined;
|
|
expect(dedupedMeta?.props.content).toBe("Updated Title");
|
|
});
|
|
|
|
it("dedupes meta[name] tags without explicit keys using the last value", () => {
|
|
const reduced = reduceHeadChildren([
|
|
[
|
|
React.createElement("meta", {
|
|
name: "description",
|
|
content: "Description A",
|
|
}),
|
|
React.createElement("meta", {
|
|
name: "description",
|
|
content: "Description B",
|
|
}),
|
|
],
|
|
]);
|
|
|
|
expect(reduced).toHaveLength(1);
|
|
const dedupedMeta = reduced[0] as React.ReactElement<{ content?: string }> | undefined;
|
|
expect(dedupedMeta?.props.content).toBe("Description B");
|
|
});
|
|
});
|
|
|
|
// ─── Disallowed tags ────────────────────────────────────────────────────
|
|
|
|
describe("Head disallowed tags", () => {
|
|
beforeEach(() => {
|
|
resetSSRHead();
|
|
});
|
|
|
|
it("ignores <div> tag (not allowed in head)", () => {
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(Head, null, React.createElement("div", null, "bad")),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).not.toContain("<div");
|
|
expect(headHtml).toBe("");
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it("ignores <iframe> tag (security concern)", () => {
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(Head, null, React.createElement("iframe", { src: "https://evil.com" })),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).not.toContain("<iframe");
|
|
expect(headHtml).toBe("");
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it("ignores component elements (non-string type)", () => {
|
|
function CustomComponent() {
|
|
return React.createElement("meta", { name: "custom" });
|
|
}
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(Head, null, React.createElement(CustomComponent)),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
// Component elements are ignored because child.type is not a string
|
|
expect(headHtml).toBe("");
|
|
});
|
|
|
|
it("keeps allowed tags while ignoring disallowed ones", () => {
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("title", null, "Good"),
|
|
React.createElement("div", null, "Bad"),
|
|
React.createElement("meta", { name: "good" }),
|
|
),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain("Good");
|
|
expect(headHtml).toContain('name="good"');
|
|
expect(headHtml).not.toContain("<div");
|
|
warn.mockRestore();
|
|
});
|
|
});
|
|
|
|
// ─── HTML/Attribute escaping ────────────────────────────────────────────
|
|
|
|
describe("Head escaping", () => {
|
|
beforeEach(() => {
|
|
resetSSRHead();
|
|
});
|
|
|
|
it("escapes HTML in text content", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("title", null, 'Page <script>alert("xss")</script>'),
|
|
),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain("<script>");
|
|
expect(headHtml).not.toContain("<script>alert");
|
|
});
|
|
|
|
it("escapes HTML in attribute values", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("meta", { name: 'test"value', content: "a<b>c&d" }),
|
|
),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain(""");
|
|
expect(headHtml).toContain("<");
|
|
expect(headHtml).toContain("&");
|
|
});
|
|
|
|
it("renders dangerouslySetInnerHTML raw on SSR", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("script", {
|
|
dangerouslySetInnerHTML: { __html: 'console.log("hello")' },
|
|
}),
|
|
),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain('console.log("hello")');
|
|
});
|
|
|
|
it("converts className to class attribute", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("style", { className: "critical" }, "body{}"),
|
|
),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain('class="critical"');
|
|
expect(headHtml).not.toContain("className");
|
|
});
|
|
|
|
it("renders boolean true attributes as bare attribute name", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("script", { src: "/app.js", async: true, defer: true }),
|
|
),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain(" async ");
|
|
expect(headHtml).toContain(" defer ");
|
|
});
|
|
});
|
|
|
|
// ─── escapeAttr utility ─────────────────────────────────────────────────
|
|
|
|
describe("escapeAttr", () => {
|
|
it("escapes ampersand", () => {
|
|
expect(escapeAttr("a&b")).toBe("a&b");
|
|
});
|
|
|
|
it("escapes double quotes", () => {
|
|
expect(escapeAttr('a"b')).toBe("a"b");
|
|
});
|
|
|
|
it("escapes angle brackets", () => {
|
|
expect(escapeAttr("a<b>c")).toBe("a<b>c");
|
|
});
|
|
|
|
it("returns safe strings unchanged", () => {
|
|
expect(escapeAttr("hello world")).toBe("hello world");
|
|
});
|
|
|
|
it("escapes all special chars together", () => {
|
|
expect(escapeAttr('&"<>')).toBe("&"<>");
|
|
});
|
|
});
|