Files
web_huakui_web/tests/react-rendering.test.tsx
2026-10-23 15:07:10 +07:00

248 lines
8.4 KiB
TypeScript

import { strict as assert } from "node:assert";
import { readFileSync, readdirSync, existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { createRequire } from "node:module";
import { test } from "node:test";
import { createElement, type ComponentType } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { toPublicPath } from "../src/lib/routes";
import {
AppRouterContext,
type AppRouterInstance,
} from "next/dist/shared/lib/app-router-context.shared-runtime";
import { SiteProvider } from "../src/components/providers/SiteProvider";
import { catalogPages } from "../src/features/catalog";
import { ListPage, TagsPage } from "../src/features/catalog/BrowsePages";
import { DramaCard } from "../src/components/catalog/Cards";
import { AdBanners } from "../src/components/catalog/Promotions";
import {
adSlice,
allListURL,
cardTags,
externalProps,
moreListURL,
navHref,
seriesListURL,
} from "../src/features/catalog/model";
import type { Data } from "../src/server/services/model";
const fixture = (name: string): string =>
readFileSync(
new URL("./fixtures/templates/" + name, import.meta.url),
"utf8",
);
const router = {
back() {},
forward() {},
refresh() {},
push() {},
replace() {},
prefetch: async () => {},
} as unknown as AppRouterInstance;
const renderPage = (Component: ComponentType<{ data: Data }>, data: Data) =>
renderToStaticMarkup(
createElement(AppRouterContext.Provider, {
value: router,
children: createElement(SiteProvider, {
data,
dialogs: false,
children: createElement(Component, { data }),
}),
}),
);
const main = (html: string): string =>
html.match(/<main\b[^>]*>([\s\S]*?)<\/main>/)?.[1] || "";
const decode = (text: string): string =>
text.replace(
/&#(?:34|x27|39);|&quot;|&amp;|&lt;|&gt;/g,
(entity) =>
({
"&#34;": '"',
"&#x27;": "'",
"&#39;": "'",
"&quot;": '"',
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
})[entity] || entity,
);
const anchors = (html: string) =>
[...html.matchAll(/<a\b[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/g)].map(
(match) => ({
href: toPublicPath(decode(match[1])),
text: decode(
match[2]
.replace(/<[^>]*>/g, "")
.replace(/\s+/g, " ")
.trim(),
),
}),
);
// These fixtures came from native Go html/template. Compare the user-visible
// catalog/link contracts while Next now owns the document head and hydration.
test("React tags and list pages preserve the original Go catalog content and navigation", () => {
for (const [name, Component] of [
["tags", TagsPage],
["list", ListPage],
] as const) {
const data = JSON.parse(fixture(name + "-data.json")) as Data;
const actual = main(renderPage(Component, data)),
expected = main(fixture(name + "-go.html"));
assert.ok(actual, name);
// Both sites label episodes on the cover link, in different wording; compare those links without the label.
const coverEpisode = /^(?:全 \d+ 集|更新至 \d+ 集|连载中|已完结)$/,
coverBadge = /^(?:\d+ 集 · )?(?:连更|完结)$/;
assert.deepEqual(
anchors(actual).map((link) =>
link.href.startsWith("/drama/") && coverBadge.test(link.text)
? { ...link, text: "" }
: link,
),
anchors(expected).map((link) =>
link.href.startsWith("/drama/") && coverEpisode.test(link.text)
? { ...link, text: "" }
: link,
),
name + " links and labels",
);
assert.equal(
(actual.match(/class="hk-drama-card hk-drama-card--list"/g) || []).length,
data.list.length,
name,
);
assert.equal(
(actual.match(/rel="sponsored nofollow noopener"/g) || []).length,
(expected.match(/rel="sponsored nofollow noopener"/g) || []).length,
name + " ad placement",
);
assert.match(actual, /花魁 &amp; 第一部/);
assert.match(actual, /<span class="hk-drama-card__eps" data-status="done"><b>4<\/b> 集 · 完结<\/span>/);
}
});
test("all catalog page types render actual React markup without legacy business scripts", () => {
assert.equal(Object.keys(catalogPages).length, 13);
for (const [name, Component] of Object.entries(catalogPages)) {
const html = renderPage(Component, {});
assert.ok(html.length > 100, name);
assert.doesNotMatch(html, /\{\{|<script\b|data-hgdj-page/, name);
}
});
test("React text escaping and shared URL guards reject injected markup and ad protocols", () => {
const html = renderToStaticMarkup(
createElement(DramaCard, {
item: { id: 1, playName: '<script>alert("x")</script>', totalEpisode: 4 },
}),
);
assert.match(html, /&lt;script&gt;alert\(&quot;x&quot;\)&lt;\/script&gt;/);
assert.doesNotMatch(html, /<script\b/);
const ad = renderToStaticMarkup(
createElement(AdBanners, {
items: [
{
id: 1,
link: "javascript:alert(1)",
images: "/static/brand/header.svg",
title: "<unsafe>",
},
],
}),
);
assert.match(ad, /href="#"/);
assert.match(ad, /alt="&lt;unsafe&gt;"/);
assert.doesNotMatch(ad, /href="javascript:/);
});
test("shared React route, tag and advertising helpers retain the original contracts", () => {
assert.equal(allListURL("", 0, 0, 1, 1), "/dramas/");
assert.equal(
allListURL("都市", 1, 2, 3, 2),
"/dramas/%E9%83%BD%E5%B8%82/2/3/page/2/",
);
assert.equal(
moreListURL("热门", 1, 0, "", 0, 2),
"/curated/dramas/%E7%83%AD%E9%97%A8/page/2/",
);
assert.equal(seriesListURL(2, 3, 2, 2), "/series/anime/3/2/page/2/");
assert.equal(
navHref("/collections/热门?q=a"),
"/collection/%E7%83%AD%E9%97%A8/?q=a",
);
assert.deepEqual(
externalProps("https://example.com:8080/a", "http://example.com"),
{},
);
assert.deepEqual(externalProps("//external.example/a"), {
target: "_blank",
rel: "sponsored nofollow noopener",
});
assert.deepEqual(
cardTags({
tagNames: ["都市", "热播"],
tagIds: [1, 2],
themeNames: ["都市", "古装"],
themeIds: [3, 4],
}),
[
{ Name: "都市", URL: "/tags/1/" },
{ Name: "热播", URL: "/tags/2/" },
{ Name: "古装", URL: "/dramas/4/" },
],
);
assert.deepEqual(
adSlice([{ images: "" }, { images: "a" }, { images: "b" }], 1, 3),
[{ images: "b" }, { images: "a" }, { images: "b" }],
);
});
test("every static asset referenced by React components, CSS and media modules exists", () => {
function files(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) =>
entry.isDirectory()
? files(join(directory, entry.name))
: [join(directory, entry.name)],
);
}
const paths = [
...files("src"),
...files("public/static"),
].filter((path) => /\.(?:tsx?|css|js)$/.test(path));
const references = new Set<string>();
const stylesheets = new Set<string>();
const require = createRequire(import.meta.url);
for (const path of paths) {
const source = readFileSync(path, "utf8");
for (const match of source.matchAll(/\bimport\s+["']([^"']+\.css)["']/g)) {
const stylesheet = match[1].startsWith("@/")
? resolve("src", match[1].slice(2))
: match[1].startsWith(".")
? resolve(dirname(path), match[1])
: require.resolve(match[1], { paths: [resolve(dirname(path))] });
assert.ok(existsSync(stylesheet), `${path}: ${match[1]}`);
stylesheets.add(stylesheet);
}
for (const match of source.matchAll(
/\/static\/[A-Za-z0-9_./-]+\.(?:css|js|svg|png|jpg|jpeg|gif|webp|woff2?)/g,
))
references.add(match[0]);
for (const match of source.matchAll(
/<Icon\b[^>]*\bname="([A-Za-z0-9_./-]+)"/g,
))
references.add(
match[1].startsWith("/")
? match[1]
: "/static/images/" +
match[1] +
(/\.(?:svg|png|webp)$/.test(match[1]) ? "" : ".svg"),
);
}
assert.ok(references.size > 30, "static references are discovered");
assert.ok(stylesheets.size >= 36, "Application source imports the migrated stylesheets");
for (const stylesheet of files("src/styles").filter((path) => path.endsWith(".css")))
assert.ok(stylesheets.has(resolve(stylesheet)), `Unimported stylesheet: ${stylesheet}`);
for (const reference of references)
assert.ok(existsSync("public" + reference), reference);
});