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(/]*>([\s\S]*?)<\/main>/)?.[1] || ""; const decode = (text: string): string => text.replace( /&#(?:34|x27|39);|"|&|<|>/g, (entity) => ({ """: '"', "'": "'", "'": "'", """: '"', "&": "&", "<": "<", ">": ">", })[entity] || entity, ); const anchors = (html: string) => [...html.matchAll(/]*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, /花魁 & 第一部/); assert.match(actual, /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, /\{\{| { const html = renderToStaticMarkup( createElement(DramaCard, { item: { id: 1, playName: '', totalEpisode: 4 }, }), ); assert.match(html, /<script>alert\("x"\)<\/script>/); assert.doesNotMatch(html, /", }, ], }), ); 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(); const stylesheets = new Set(); 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( /]*\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); });