243 lines
8.1 KiB
TypeScript
243 lines
8.1 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);|"|&|<|>/g,
|
|
(entity) =>
|
|
({
|
|
""": '"',
|
|
"'": "'",
|
|
"'": "'",
|
|
""": '"',
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
})[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);
|
|
// The episode label moved from the cover link into the meta-row chip.
|
|
const coverEpisode = /^(?:全 \d+ 集|更新至 \d+ 集|连载中|已完结)$/;
|
|
assert.deepEqual(
|
|
anchors(actual),
|
|
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, /花魁 & 第一部/);
|
|
assert.match(actual, /<span class="hk-drama-card__meta-ep" data-status="done">完结 · 4 集<\/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, /<script>alert\("x"\)<\/script>/);
|
|
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="<unsafe>"/);
|
|
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);
|
|
});
|