Files
web_huakui_web/scripts/native-ssr-smoke.ts
T
2026-10-16 14:10:10 +07:00

1109 lines
42 KiB
TypeScript

import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { createHash, randomBytes } from "node:crypto";
import { createServer, type IncomingHttpHeaders } from "node:http";
import {
mkdir,
readFile,
readdir,
stat,
unlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { gunzipSync, gzipSync } from "node:zlib";
import { chromium, type Page } from "@playwright/test";
// Test the optimized App Router response, not a renderer or a development server.
// All authentication, content and mutations terminate at this loopback mock.
type RecordData = Record<string, unknown>;
interface UpstreamCall {
case: string;
path: string;
headers: IncomingHttpHeaders;
body: RecordData;
cookies: string[];
}
const output = process.env.NATIVE_SSR_OUTPUT || "/tmp/hkdj-native-ssr-smoke";
const calls: UpstreamCall[] = [];
const results: RecordData[] = [];
async function expectPageStyles(page: Page, required: string[], absent: string[] = []) {
const tokens = new Set((await page.locator("html").getAttribute("data-hk-page-styles") || "").split(/\s+/));
for (const token of required) assert.ok(tokens.has(token), `Missing page style scope: ${token}`);
for (const token of absent) assert.ok(!tokens.has(token), `Inactive page style scope: ${token}`);
assert.equal(await page.locator('link[rel="stylesheet"][href^="/static/css/"]').count(), 0);
assert.ok(await page.locator('link[rel="stylesheet"][href*="/_next/"][href$=".css"]').count() > 0, "Next serves compiled CSS chunks");
}
const canonical = "https://canonical.drama.test";
const userAgent =
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) NativeSSRSmoke/1.0";
const indexNow = "native_ssr_verification_20261010";
const verifyFilename = `native-ssr-${randomBytes(6).toString("hex")}.html`;
const verifyPath = resolve("public", verifyFilename);
const sitemapDir = join(output, "sitemaps");
const handoffDir = join(
tmpdir(),
"huakui-next-ssr",
createHash("sha256")
.update(process.cwd() + "|production")
.digest("hex")
.slice(0, 16),
);
let previewLogs = "";
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined;
let navigationPreview: ReturnType<typeof spawn> | undefined;
let fixtureSequence = 0;
let bigList = false;
let listUnavailable = false;
// The account system was removed; SSR and the BFF must never reach these upstream APIs.
const removedUpstream = [
"/api/app/auth/login",
"/api/app/auth/register",
"/api/app/auth/logout",
"/api/app/user/center/data",
"/api/app/playlet/history/list",
"/api/app/playlet/collect/list",
"/api/app/recharge/package/list",
"/api/app/comment/send",
"/api/app/comment/list",
"/api/app/coin/buy",
"/api/app/playlet/chapter/unlock",
];
const removedCalls = (caseName?: string) =>
calls.filter(
(call) =>
(!caseName || call.case === caseName) &&
removedUpstream.some((path) => call.path.split("?")[0] === path),
);
function user(token: string) {
const guest = token.startsWith("guest-");
const suffix = token.replace(/[^a-zA-Z0-9]/g, "");
return {
userId: guest ? 900 : token === "registered-B" ? 802 : 801,
token,
accountNo: "account_" + suffix,
nickName: "用户_" + suffix,
isRegister: !guest,
type: guest ? 0 : 1,
hasPassword: !guest,
recharge: guest ? 18 : 88,
avatar: "",
vipType: 0,
currentVipExpire: "",
};
}
function work(id = 41, title = "隔离短剧") {
return {
id,
slug: "isolated-" + id,
title,
playName: title,
cover: "/static/favicon.png",
totalEpisode: 2,
updateStatus: 2,
countBrowse: 48,
countLike: 4,
countCollect: 2,
status: 1,
themeNames: ["都市"],
themeIds: [1],
themeSlugs: ["city"],
tagNames: ["剧情"],
tagIds: [1],
tagSlugs: ["plot"],
};
}
function readToken(headers: IncomingHttpHeaders): string {
return String(headers.authorization || "");
}
function nameFor(token: string): string {
return user(token).nickName;
}
const mock = createServer(async (req, res) => {
try {
const url = new URL(req.url || "/", "http://upstream");
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(Buffer.from(chunk));
const body = JSON.parse(
Buffer.concat(chunks).toString() || "{}",
) as RecordData;
const caseName = String(req.headers["x-smoke-case"] || "unmarked");
const sequence = ++fixtureSequence;
const cookies = [
`wire_${sequence}=${caseName}; Path=/wire/${sequence}; HttpOnly; SameSite=Lax`,
`wire_root_${sequence}=ok; Path=/; Expires=Wed, 21 Oct 2037 07:28:00 GMT; SameSite=Lax`,
];
calls.push({
case: caseName,
path: url.pathname + url.search,
headers: { ...req.headers },
body,
cookies,
});
res.setHeader("Set-Cookie", cookies);
const send = (data: unknown = {}, code = 200, status = 200) => {
res.statusCode = status;
res.setHeader("Content-Type", "application/json");
res.end(
JSON.stringify({
code,
data,
tip: code === 200 ? "" : "隔离测试上游失败",
}),
);
};
const token = readToken(req.headers);
if (url.pathname === "/api/app/auth/guest") {
send(user("guest-" + caseName));
return;
}
if (url.pathname === "/api/app/auth/getInfo") {
send(user(token));
return;
}
if (url.pathname === "/api/app/config/info") {
send({
config: {
appAddr: "https://download.example.test/real-entry",
gmailAddr: "support@example.test",
foreverUrlAddr: canonical + "/ignored/path?tracking=1",
},
newAddressInfo: {},
seoInfo: {
title: "花魁短剧",
description: "隔离站点描述",
keywords: ["花魁短剧", "短剧"],
webSiteName: "花魁短剧",
canonicalUrl: canonical,
},
seoNavInfo: {},
navPageList: [],
advitise: {},
});
return;
}
if (url.pathname === "/api/app/translation/category/list") {
send({
categoryList: [
{ name: "首页", urlValue: "/", categoryType: 1 },
{ name: "短剧", urlValue: "/list/", categoryType: 1 },
],
});
return;
}
if (url.pathname === "/api/app/theme/list") {
send({
list: [{ id: 1, name: "都市", slug: "city", status: true, rank: 1 }],
});
return;
}
if (url.pathname === "/api/app/playlet/home") {
send({
seoInfo: {
title: "SSR首页标题",
description: "SSR首页描述",
keywords: ["首屏", "短剧"],
},
announcements: [],
homeBannerData: [],
homeConfigData: [],
advitise: {},
});
return;
}
if (url.pathname === "/api/app/playlet/hot-like") {
send({ hotSearch: [], guessLike: [] });
return;
}
if (url.pathname === "/api/app/playlet/tag/options") {
send({ list: [] });
return;
}
if (url.pathname === "/api/app/playlet/all/list") {
if (listUnavailable && Number(url.searchParams.get("pageSize")) === 24) {
send({}, 503, 503);
return;
}
const list =
bigList && Number(url.searchParams.get("pageSize")) === 24
? Array.from({ length: 24 }, (_, i) =>
work(
100 + i,
`大快照${i}_` + randomBytes(1100).toString("base64url"),
),
)
: [work(), work(42, "隔离短剧二")];
send({
list,
pageNum: Number(url.searchParams.get("pageNum") || 1),
pageSize: Number(url.searchParams.get("pageSize") || 24),
totalCount: 72,
seoInfo: {
title: "短剧列表专属标题",
description: "短剧列表专属描述",
keywords: ["列表测试"],
},
});
return;
}
if (url.pathname === "/api/app/playlet/detail") {
if (url.searchParams.get("slug") === "missing") {
send({});
return;
}
if (caseName === "recover" && token === "expired-content") {
send({}, 1002);
return;
}
send({
...work(),
slug: "isolated",
desc: "播放页真实剧情简介",
onlineAt: "2026-10-01 10:00:00",
preview: "/preview.mp4",
playletChapterInfo: [
{ id: 401, currentEpisode: 1, payType: 0, canWatch: true },
{ id: 402, currentEpisode: 2, payType: 0, canWatch: true },
],
relatedRecommendData: [],
seoInfo: {
title: "作品标题",
description: "作品描述",
keywords: ["作品测试"],
},
advitise: {},
});
return;
}
if (url.pathname === "/api/app/playlet/chapter/detail") {
send({
id: Number(url.searchParams.get("chapterId") || 401),
playletId: 41,
currentEpisode:
Number(url.searchParams.get("chapterId")) === 402 ? 2 : 1,
canWatch: true,
payType: 0,
videoUrl: "/signed/isolated.m3u8?token=preserved",
mediaTime: 12,
balance: 18,
countLike: 4,
countCollect: 2,
});
return;
}
if (url.pathname === "/api/app/playlet/search") {
send({
keyword: url.searchParams.get("keyword"),
list: [work(), work(42)],
totalCount: 2,
pageNum: 1,
pageSize: 25,
});
return;
}
send({ list: [], totalCount: 0, pageNum: 1, pageSize: 4 });
} catch (error) {
res.statusCode = 500;
res.end(JSON.stringify({ code: 500, tip: String(error) }));
}
});
await mkdir(sitemapDir, { recursive: true });
await writeFile(
join(sitemapDir, "sitemap.xml"),
`<?xml version="1.0"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><sitemap><loc>${canonical}/sitemaps/sitemap-content.xml</loc></sitemap></sitemapindex>\n<!-- ${"后端生成的 XML 按原字节输出。".repeat(96)} -->`,
);
await writeFile(
join(sitemapDir, "sitemap-content.xml"),
`<?xml version="1.0"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>${canonical}/drama/isolated/</loc></url></urlset>`,
);
await writeFile(verifyPath, "native-ssr-verification-file");
await new Promise<void>((done) => mock.listen(0, "127.0.0.1", done));
const bound = mock.address();
if (!bound || typeof bound === "string")
throw new Error("Mock address unavailable");
const reserve = createServer();
await new Promise<void>((done) => reserve.listen(0, "127.0.0.1", done));
const reserved = reserve.address();
if (!reserved || typeof reserved === "string")
throw new Error("Preview address unavailable");
const port = reserved.port;
await new Promise<void>((done) => reserve.close(() => done()));
const origin = `http://127.0.0.1:${port}`;
const preview = spawn(
process.execPath,
[
resolve("node_modules/next/dist/bin/next"),
"start",
"--port",
String(port),
"--hostname",
"127.0.0.1",
],
{
env: {
...process.env,
NODE_ENV: "production",
APP_ENV: "production",
UPSTREAM_API_URL: `http://127.0.0.1:${bound.port}`,
UPSTREAM_TIMEOUT_MS: "3000",
SEO_BAIDU_SITE_VERIFICATION: "native-ssr-baidu-code",
SEO_BAIDU_HTML_FILE: verifyFilename,
SEO_INDEXNOW_KEY: indexNow,
SITEMAP_DIR: sitemapDir,
NAVIGATION_ONLY: "false",
NEXT_TELEMETRY_DISABLED: "1",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
preview.stdout?.on("data", (chunk) => {
previewLogs += String(chunk);
});
preview.stderr?.on("data", (chunk) => {
previewLogs += String(chunk);
});
function headers(caseName: string, token = ""): Headers {
return new Headers({
"x-smoke-case": caseName,
"user-agent": userAgent,
"x-forwarded-for": "203.0.113.42, 198.51.100.7",
"x-user-agent": "DevType=SSR smoke;SysType=h5_ios;sid=original",
...(token
? {
cookie: `token=${token}; nickName=STALE_COOKIE; userId=1; isRegister=${token.startsWith("guest-") ? "0" : "1"}; channel=smoke-channel; client_cookie=must_forward`,
}
: {}),
});
}
function caseCalls(caseName: string): UpstreamCall[] {
return calls.filter((call) => call.case === caseName);
}
function cookiesForwarded(response: Response, caseName: string) {
const actual = response.headers.getSetCookie();
for (const call of caseCalls(caseName))
for (const cookie of call.cookies) {
const pair = cookie.split(";")[0];
assert.ok(
actual.some((item) => item.split(";")[0] === pair),
`${caseName}: missing upstream Set-Cookie ${pair}`,
);
}
assert.equal(
actual.some(
(cookie) =>
cookie.startsWith("wire_root_") && !cookie.includes("21 Oct 2037"),
),
false,
"Expires comma must not split cookies",
);
}
function noInternalLeak(response: Response, body: string) {
for (const name of response.headers.keys())
assert.equal(
/^x-hk-|^x-middleware-request-x-hk-/i.test(name),
false,
"Internal response header " + name,
);
assert.equal(
/x-hk-page(?:-id)?["':=]/i.test(body),
false,
"Internal snapshot serialized to HTML",
);
}
function noDuplicateBuild(caseName: string, allowedRecovery = false) {
const counts = new Map<string, number>();
for (const call of caseCalls(caseName))
counts.set(call.path, (counts.get(call.path) || 0) + 1);
for (const [path, count] of counts)
assert.equal(
count,
allowedRecovery && path.startsWith("/api/app/playlet/detail?") ? 2 : 1,
`${caseName}: duplicate upstream ${path}`,
);
}
function meta(html: string, name: string): string {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return (
html.match(
new RegExp(
`<meta[^>]+(?:name|property)="${escaped}"[^>]+content="([^"]*)"`,
"i",
),
)?.[1] || ""
);
}
function link(html: string, rel: string): string {
return (
html.match(
new RegExp(`<link[^>]+rel="${rel}"[^>]+href="([^"]*)"`, "i"),
)?.[1] || ""
);
}
function graphs(html: string): RecordData[] {
return [
...html.matchAll(
/<script[^>]+type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi,
),
].flatMap((match) => {
const parsed = JSON.parse(match[1]);
return parsed["@graph"] || [parsed];
});
}
async function request(
caseName: string,
path: string,
token = "",
init: RequestInit = {},
) {
const requestHeaders = headers(caseName, token);
new Headers(init.headers).forEach((value, key) =>
requestHeaders.set(key, value),
);
const response = await fetch(origin + path, {
...init,
headers: requestHeaders,
redirect: "manual",
});
const html = await response.text();
if (!["/robots.txt", "/llms.txt"].includes(path)) cookiesForwarded(response, caseName);
noInternalLeak(response, html);
return { response, html };
}
async function files(): Promise<string[]> {
try {
return (await readdir(handoffDir)).filter((name) =>
/^[a-f0-9]{32}\.json\.gz$/.test(name),
);
} catch {
return [];
}
}
try {
const deadline = Date.now() + 30000;
while (true) {
if (preview.exitCode !== null)
throw new Error("Next exited: " + previewLogs);
try {
const health = await fetch(origin + "/healthz");
if (health.ok) break;
} catch {}
if (Date.now() > deadline)
throw new Error("Next did not start: " + previewLogs);
await new Promise((done) => setTimeout(done, 150));
}
console.log("native SSR: initial guest and fresh registered context");
const guest = await request("guest", "/drama/isolated/?dc=smoke-first");
assert.equal(guest.response.status, 200);
assert.ok(guest.html.includes(nameFor("guest-guest")));
assert.equal(guest.html.includes("STALE_COOKIE"), false);
assert.equal(
caseCalls("guest").filter((call) => call.path === "/api/app/auth/guest")
.length,
1,
);
assert.ok(
guest.response.headers
.getSetCookie()
.some((cookie) => cookie.startsWith("token=guest-guest;")),
);
assert.ok(
guest.response.headers
.getSetCookie()
.some((cookie) => cookie.startsWith("channel=smoke-first;")),
);
noDuplicateBuild("guest");
assert.deepEqual(removedCalls("guest"), [], "Playback SSR no longer loads recharge packages");
const registered = await request("registered", "/dramas/", "registered-A");
assert.equal(registered.response.status, 200);
assert.ok(registered.html.includes(nameFor("registered-A")));
assert.equal(registered.html.includes("STALE_COOKIE"), false);
noDuplicateBuild("registered");
for (const call of caseCalls("registered")) {
assert.equal(call.headers["x-real-ip"], "203.0.113.42");
assert.equal(call.headers["user-agent"], userAgent);
assert.equal(
call.headers["x-user-agent"],
"DevType=SSR smoke;SysType=h5_ios;sid=original",
);
assert.equal(
Object.keys(call.headers).some((key) => key.startsWith("x-hk-")),
false,
);
const privateRequest = /\/auth\//.test(
call.path,
);
if (privateRequest) {
assert.equal(call.headers.authorization, "registered-A");
assert.ok(
String(call.headers.cookie).includes("client_cookie=must_forward"),
);
} else {
assert.equal(call.headers.authorization, undefined);
assert.equal(call.headers.cookie, undefined);
}
}
results.push({
initialGuest: true,
freshUser: true,
completeUpstreamCookies: true,
singleBuild: true,
headerForwarding: true,
publicAuthIsolation: true,
});
console.log("native SSR: content session recovery before first byte");
const recover = await request(
"recover",
"/drama/isolated/",
"expired-content",
);
assert.equal(recover.response.status, 200);
assert.ok(recover.html.includes(nameFor("guest-recover")));
assert.equal(recover.html.includes(nameFor("expired-content")), false);
assert.ok(
recover.response.headers
.getSetCookie()
.some((cookie) => cookie.startsWith("token=guest-recover;")),
);
assert.equal(
caseCalls("recover").filter((call) => call.path === "/api/app/auth/guest")
.length,
1,
);
noDuplicateBuild("recover", true);
const detailCalls = caseCalls("recover").filter((call) =>
call.path.startsWith("/api/app/playlet/detail?"),
);
assert.deepEqual(
detailCalls.map((call) => call.headers.authorization),
["expired-content", "guest-recover"],
);
const chapterCall = caseCalls("recover").find((call) =>
call.path.startsWith("/api/app/playlet/chapter/detail?"),
);
assert.equal(chapterCall?.headers.authorization, "guest-recover");
assert.ok(
String(chapterCall?.headers.cookie).includes("token=guest-recover"),
);
results.push({ contentGuestRecovery: true, recoveryCookiesBeforeSSR: true });
console.log(
"native SSR: large file handoff and concurrent account isolation",
);
const before = new Set(await files());
bigList = true;
const large = await request("large", "/dramas/", "registered-A");
bigList = false;
assert.equal(large.response.status, 200);
assert.ok(large.html.includes("大快照23_"));
noDuplicateBuild("large");
const created = (await files()).filter((name) => !before.has(name));
assert.ok(created.length > 0, "Large DTO must use file handoff");
const largeFile = created[0];
const payload = JSON.parse(
gunzipSync(await readFile(join(handoffDir, largeFile))).toString(),
);
assert.equal(payload.page.view, "list");
assert.equal(payload.page.data.userName, nameFor("registered-A"));
assert.equal((await stat(join(handoffDir, largeFile))).mode & 0o777, 0o600);
assert.equal((await stat(handoffDir)).mode & 0o777, 0o700);
const forged = gzipSync(
JSON.stringify({
v: 1,
expires: Date.now() + 60000,
binding: "fake",
page: { view: "list", data: { userName: "FORGED_PRIVATE_USER" } },
}),
).toString("base64url");
const concurrent = await Promise.all(
Array.from({ length: 12 }, (_, i) =>
request(
"parallel-" + i,
"/dramas/",
i % 2 ? "registered-B" : "registered-A",
{
headers: {
"x-hk-page-id": largeFile.slice(0, -8),
"x-hk-page": forged,
"x-hk-url": "https://attacker.test/private",
},
},
),
),
);
for (let i = 0; i < concurrent.length; i++) {
const expected = nameFor(i % 2 ? "registered-B" : "registered-A"),
other = nameFor(i % 2 ? "registered-A" : "registered-B");
assert.equal(concurrent[i].response.status, 200);
assert.ok(concurrent[i].html.includes(expected));
assert.equal(concurrent[i].html.includes(other), false);
assert.equal(concurrent[i].html.includes("FORGED_PRIVATE_USER"), false);
noDuplicateBuild("parallel-" + i);
}
results.push({
largeFileHandoff: true,
fileMode: "0600",
directoryMode: "0700",
parallelAccounts: 12,
forgedSnapshotRejected: true,
internalHeadersHidden: true,
});
const rsc = await request("rsc", "/dramas/?_rsc=", "registered-B", {
headers: { RSC: "1" },
});
assert.equal(
rsc.response.status,
200,
JSON.stringify(Object.fromEntries(rsc.response.headers)),
);
assert.ok(
rsc.response.headers.get("content-type")?.includes("text/x-component"),
);
assert.ok(rsc.html.includes(nameFor("registered-B")));
assert.equal(rsc.html.includes(nameFor("registered-A")), false);
noDuplicateBuild("rsc");
for (const call of caseCalls("rsc")) {
assert.equal(call.headers.rsc, undefined);
assert.equal(call.headers["next-router-prefetch"], undefined);
assert.equal(call.headers["next-url"], undefined);
}
const head = await request("head", "/dramas/", "registered-A", {
method: "HEAD",
});
assert.equal(head.response.status, 200);
assert.equal(head.html, "");
noDuplicateBuild("head");
results.push({ rscNavigationContext: true, headCookies: true });
console.log("native SSR: exact redirect, metadata and discovery contract");
for (const [caseName, path, method, status, location] of [
[
"redirect301",
"/dramas?filter=preserved",
"GET",
301,
origin + "/dramas/?filter=preserved",
],
[
"legacy-alias301",
"/play/story%2Fa?sign=a+b&sign=%2b",
"GET",
301,
origin + "/drama/story%2Fa/?sign=a+b&sign=%2b",
],
// Removed account pages (legacy and public) permanently return home without a query.
["legacy-account301", "/setting/", "HEAD", 301, origin + "/"],
["legacy-login301", "/login/?next=%2Fprofile", "GET", 301, origin + "/"],
["account301", "/account/", "GET", 301, origin + "/"],
["account-settings301", "/account/settings/", "GET", 301, origin + "/"],
["account-recharge301", "/account/recharge/?pkg=1", "HEAD", 301, origin + "/"],
["account-login301", "/account/login", "GET", 301, origin + "/"],
[
"redirect307",
"/api/feedback/add/?dc=preserved",
"POST",
307,
origin + "/api/feedback/add?dc=preserved",
],
["redirect302", "/?dc=attribution", "GET", 302, origin + "/"],
[
"download302",
"/app/download/",
"GET",
302,
"https://download.example.test/real-entry",
],
] as const) {
const { response } = await request(caseName, path, "", { method });
assert.equal(response.status, status);
assert.equal(
new URL(response.headers.get("location") || "", origin).href,
location,
);
if (caseName !== "download302") {
assert.equal(caseCalls(caseName).length, 0);
assert.equal(response.headers.getSetCookie().length, 0);
}
}
for (const [index, pathname] of [
"/api/auth/login",
"/api/auth/register",
"/api/auth/logout",
"/api/playlet/collect/set",
"/api/playlet/like/set",
"/api/comment/send",
"/api/comment/list",
"/api/playlet/chapter/unlock",
"/api/coin/buy",
].entries()) {
const removedAPI = await request("removed-api-" + index, pathname, "registered-A", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
assert.equal(removedAPI.response.status, 404, "Removed BFF route " + pathname);
const upstreamPath = "/api/app" + pathname.slice("/api".length);
assert.equal(
caseCalls("removed-api-" + index).some((call) => call.path.split("?")[0] === upstreamPath),
false,
"Removed BFF route " + pathname + " reached upstream",
);
assert.deepEqual(removedCalls("removed-api-" + index), []);
}
const home = await request("home", "/", "registered-A");
assert.equal(home.response.status, 200);
assert.equal(home.html.includes("继续观看"), false, "Home no longer renders viewing history");
assert.deepEqual(removedCalls("home"), [], "Home no longer fetches viewing history");
for (const marker of ["data-login-open", "hk-user-menu", 'href="/account/'])
assert.equal(home.html.includes(marker), false, "Home still renders account entry " + marker);
assert.ok(home.html.includes("<title>SSR首页标题 - 花魁短剧</title>"));
assert.equal(link(home.html, "canonical"), canonical + "/");
assert.equal(meta(home.html, "description"), "SSR首页描述");
assert.equal(meta(home.html, "og:locale"), "zh_CN");
assert.equal(meta(home.html, "twitter:card"), "summary_large_image");
assert.equal(
meta(home.html, "google-site-verification"),
"Y_ldHPiw-yBVgVw_jZ4UG5WJw-xjO31mBMfhflqMGw8",
);
assert.equal(
meta(home.html, "baidu-site-verification"),
"native-ssr-baidu-code",
);
assert.ok(graphs(home.html).some((node) => node["@type"] === "WebSite"));
assert.ok(graphs(home.html).some((node) => node["@type"] === "Organization"));
const list = await request(
"page2",
"/dramas/all/0/1/page/2/?dc=discard-canonical",
"registered-A",
);
assert.equal(list.response.status, 200);
assert.equal(
link(list.html, "canonical"),
canonical + "/dramas/all/0/1/page/2/",
);
assert.equal(link(list.html, "prev"), canonical + "/dramas/");
assert.equal(link(list.html, "next"), canonical + "/dramas/all/0/1/page/3/");
assert.ok(list.html.includes("第2页"));
assert.ok(graphs(list.html).some((node) => node["@type"] === "ItemList"));
noDuplicateBuild("page2");
const episode = await request(
"episode",
"/drama/isolated/402/",
"registered-A",
);
assert.equal(episode.response.status, 200);
assert.ok(meta(episode.html, "robots").includes("noindex"));
assert.equal(link(episode.html, "canonical"), "");
assert.equal(graphs(episode.html).length, 0);
for (const [caseName, path] of [
["missing", "/unrecognized-native-route/"],
["missing-work", "/drama/missing/"],
["bad-page", "/dramas/all/0/1/page/100/"],
["unknown-api", "/api/native-smoke-unknown"],
["unknown-media", "/h5/native-smoke-unknown/"],
] as const) {
const missing = await request(caseName, path, "registered-A");
assert.equal(missing.response.status, 404);
assert.ok(meta(missing.html, "robots").includes("noindex"));
assert.equal(link(missing.html, "canonical"), "");
assert.equal(graphs(missing.html).length, 0);
noDuplicateBuild(caseName);
}
const missingSitemap = await request("unknown-sitemap", "/sitemaps/sitemap-missing.xml", "registered-A");
assert.equal(missingSitemap.response.status, 404);
assert.match(missingSitemap.response.headers.get("x-robots-tag") || "", /noindex/);
assert.match(missingSitemap.response.headers.get("content-type") || "", /text\/html/);
assert.ok(meta(missingSitemap.html, "robots").includes("noindex"));
assert.equal(link(missingSitemap.html, "canonical"), "");
assert.equal(graphs(missingSitemap.html).length, 0);
noDuplicateBuild("unknown-sitemap");
listUnavailable = true;
const unavailable = await request("unavailable", "/dramas/", "registered-A");
listUnavailable = false;
assert.equal(
unavailable.response.status,
503,
"Temporary upstream failure must retain a 503 HTML status",
);
assert.ok(meta(unavailable.html, "robots").includes("noindex"));
assert.equal(link(unavailable.html, "canonical"), "");
assert.equal(graphs(unavailable.html).length, 0);
assert.ok(unavailable.response.headers.get("retry-after"));
noDuplicateBuild("unavailable");
const robots = await request("robots", "/robots.txt");
assert.equal(robots.response.status, 200);
assert.ok(robots.html.includes("Sitemap: " + canonical + "/sitemap.xml"));
assert.ok(robots.html.includes("Host: canonical.drama.test"));
assert.equal(/Disallow: \/(?:profile|search|login)/.test(robots.html), false);
assert.equal(robots.response.headers.getSetCookie().length, 0);
assert.equal(robots.response.headers.get("cache-control"), null);
assert.equal(robots.response.headers.get("content-length"), String(Buffer.byteLength(robots.html)));
const llms = await request("llms", "/llms.txt");
assert.equal(llms.response.status, 200);
assert.ok(llms.html.includes("# 花魁短剧"));
assert.ok(llms.html.includes(canonical + "/dramas/"));
assert.equal(llms.response.headers.getSetCookie().length, 0);
for (const path of ["/sitemap.xml", "/sitemaps/sitemap-content.xml"]) {
const xml = await request("sitemap" + path, path);
assert.equal(xml.response.status, 200);
assert.ok(
xml.response.headers.get("content-type")?.includes("text/xml"),
);
assert.ok(xml.response.headers.get("last-modified"));
assert.equal(xml.response.headers.get("accept-ranges"), "bytes");
assert.equal(xml.response.headers.get("content-length"), String(Buffer.byteLength(xml.html)));
assert.equal(xml.response.headers.get("cache-control"), null);
assert.equal(xml.response.headers.getSetCookie().length, 0);
assert.ok(xml.html.includes(canonical));
assert.equal(caseCalls("sitemap" + path).length, 0);
}
const originalXML = await readFile(join(sitemapDir, "sitemap.xml"), "utf8");
const fullXML = await request("sitemap-bytes", "/sitemap.xml?sig=a%2Bb%3D");
assert.equal(fullXML.html, originalXML, "Go serves generated XML unchanged");
const modified = fullXML.response.headers.get("last-modified")!;
const unchangedXML = await request("sitemap304", "/sitemap.xml", "", { headers: { "If-Modified-Since": modified } });
assert.equal(unchangedXML.response.status, 304);
assert.equal(unchangedXML.html, "");
assert.equal(unchangedXML.response.headers.get("content-type"), null);
const partXML = await request("sitemap206", "/sitemap.xml", "", { headers: { Range: "bytes=0-31" } });
assert.equal(partXML.response.status, 206);
assert.equal(partXML.html, originalXML.slice(0, 32));
assert.equal(partXML.response.headers.get("content-range"), `bytes 0-31/${Buffer.byteLength(originalXML)}`);
const multipartXML = await request("sitemap-multipart", "/sitemap.xml", "", { headers: { Range: "bytes=0-2,8-10" } });
assert.equal(multipartXML.response.status, 206);
assert.match(multipartXML.response.headers.get("content-type") || "", /multipart\/byteranges; boundary=/);
assert.ok(multipartXML.html.includes(originalXML.slice(0, 3)));
const invalidRange = await request("sitemap416", "/sitemap.xml", "", { headers: { Range: "bytes=999999-" } });
assert.equal(invalidRange.response.status, 416);
assert.equal(invalidRange.response.headers.get("content-range"), `bytes */${Buffer.byteLength(originalXML)}`);
const fileHEAD = await request("sitemap-head", "/sitemap.xml", "", { method: "HEAD" });
assert.equal(fileHEAD.response.status, 200);
assert.equal(fileHEAD.html, "");
assert.equal(fileHEAD.response.headers.get("content-length"), String(Buffer.byteLength(originalXML)));
const conditionalHEAD = await request("sitemap-head304", "/sitemap.xml", "", { method: "HEAD", headers: { "If-Modified-Since": modified } });
assert.equal(conditionalHEAD.response.status, 304);
assert.equal(conditionalHEAD.response.headers.get("content-length"), null);
for (const [index, pathname] of ["/site%6Daps/sitemap-content.xml", "/sitemaps%2Fsitemap-content.xml", "/sitemap%2Exml", "/robot%73.txt"].entries()) {
const encoded = await request("discovery-encoded-" + index, pathname);
assert.equal(encoded.response.status, 200, pathname);
assert.match(encoded.response.headers.get("content-type") || "", pathname.includes("robot") ? /text\/plain/ : /text\/xml/);
assert.equal(encoded.response.headers.getSetCookie().length, 0);
assert.equal(encoded.html, pathname.includes("robot") ? robots.html : await readFile(join(sitemapDir, pathname.includes("content") ? "sitemap-content.xml" : "sitemap.xml"), "utf8"));
}
for (const [index, pathname] of ["/sitemap.xml%2F", "/sitemaps/sitemap-content.xml%2F", "/site%6Daps/sitemap-content.xml/"].entries()) {
const redirected = await request("discovery-encoded-tail-" + index, pathname + "?sig=a%2Bb%3D");
assert.equal(redirected.response.status, 301, pathname);
assert.equal(new URL(redirected.response.headers.get("location")!, origin).href, origin + decodeURIComponent(pathname).slice(0, -1) + "?sig=a%2Bb%3D");
assert.equal(redirected.response.headers.getSetCookie().length, 0);
}
for (const [index, pathname] of ["/robots.txt", "/llms.txt", "/sitemap.xml", "/sitemaps/invalid.xml"].entries()) {
for (const [methodIndex, method] of ["POST", "PUT", "DELETE", "OPTIONS"].entries()) {
const failed = await request(`discovery-method-${index}-${methodIndex}`, pathname, "registered-A", { method, headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: "test=1" });
assert.equal(failed.response.status, 404, method + " " + pathname);
assert.equal(failed.response.headers.get("allow"), "GET");
assert.match(failed.response.headers.get("content-type") || "", /text\/html/);
assert.ok(meta(failed.html, "robots").includes("noindex"));
assert.equal(link(failed.html, "canonical"), "");
assert.equal(graphs(failed.html).length, 0);
}
}
for (const [index, pathname] of ["/robots.txt/", "/sitemap.xml/", "/sitemaps/invalid.xml/", "/sitemaps/sitemap-a%2F.xml", "/sitemaps/sitemap-content%252Exml"].entries()) {
const missing = await request("discovery-invalid-" + index, pathname, "registered-A", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: "test=1" });
assert.equal(missing.response.status, 404, pathname);
assert.match(missing.response.headers.get("content-type") || "", /text\/html/);
assert.equal(missing.response.headers.get("allow"), pathname.includes("%252E") ? "GET" : null);
assert.ok(meta(missing.html, "robots").includes("noindex"));
}
const verify = await request("verify", "/" + verifyFilename);
assert.equal(verify.response.status, 200);
assert.equal(verify.html, "native-ssr-verification-file");
const now = await request("indexnow", "/" + indexNow + ".txt");
assert.equal(now.response.status, 200);
assert.equal(now.html, indexNow);
// Go DHInit registers only navigation pages/static assets, including when files exist.
const navigationReserve = createServer();
await new Promise<void>(done => navigationReserve.listen(0, "127.0.0.1", done));
const navigationBound = navigationReserve.address();
if (!navigationBound || typeof navigationBound === "string") throw new Error("Navigation preview port unavailable");
const navigationOrigin = `http://127.0.0.1:${navigationBound.port}`;
await new Promise<void>(done => navigationReserve.close(() => done()));
navigationPreview = spawn(process.execPath, [resolve("node_modules/next/dist/bin/next"), "start", "--port", String(navigationBound.port), "--hostname", "127.0.0.1"], {
env: { ...process.env, APP_ENV: "production", NODE_ENV: "production", UPSTREAM_API_URL: `http://127.0.0.1:${bound.port}`, UPSTREAM_TIMEOUT_MS: "3000", SITEMAP_DIR: sitemapDir, NAVIGATION_ONLY: "true", NEXT_TELEMETRY_DISABLED: "1" },
stdio: ["ignore", "pipe", "pipe"],
});
navigationPreview.stderr?.on("data", chunk => { previewLogs += "[navigation] " + String(chunk); });
const navigationDeadline = Date.now() + 30000;
while (true) {
try { await fetch(navigationOrigin + "/robots.txt"); break; }
catch (error) { if (Date.now() >= navigationDeadline) throw error; await new Promise(done => setTimeout(done, 200)); }
}
for (const pathname of ["/robots.txt", "/robots.txt/", "/llms.txt", "/sitemap.xml", "/sitemaps/sitemap-content.xml"]) {
for (const method of ["GET", "HEAD", "POST"]) {
const response = await fetch(navigationOrigin + pathname, { method, headers: headers("navigation-discovery", "registered-A") });
assert.equal(response.status, 404, "DHInit " + method + " " + pathname);
assert.match(response.headers.get("content-type") || "", /text\/html/);
assert.equal(response.headers.get("allow"), null);
assert.match(response.headers.get("x-robots-tag") || "", /noindex/);
const html = await response.text();
if (method === "HEAD") assert.equal(html, "");
else assert.ok(meta(html, "robots").includes("noindex"));
}
}
navigationPreview.kill("SIGTERM");
navigationPreview = undefined;
results.push({
redirects: [301, 307, 302],
removedAccountRedirectHome: true,
removedAccountAPIs404: true,
canonical: true,
pagination: true,
jsonLD: true,
verification: true,
notFound: true,
unavailable503: true,
robots: true,
llms: true,
sitemaps: true,
indexNow: true,
fileConditionalRequests: [304, 206, 416],
discoveryMethods: "Go HTML 404 with Allow GET",
navigationDiscovery404: true,
});
console.log(
"native SSR: real document navigation updates page CSS and metadata",
);
browser = await chromium.launch({
headless: true,
executablePath:
process.env.CHROME_PATH ||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
});
const navigationErrors: string[] = [];
for (const mobile of [false, true]) {
const context = await browser.newContext({
viewport: mobile
? { width: 375, height: 812 }
: { width: 1440, height: 900 },
extraHTTPHeaders: {
"x-smoke-case": mobile ? "browser-mobile" : "browser-desktop",
},
});
await context.addCookies([
{ name: "token", value: "registered-B", url: origin },
]);
await context.addInitScript(() => localStorage.setItem("hg_age_ok", "1"));
await context.route("**/*", (route) =>
route.request().url().startsWith(origin)
? route.continue()
: route.abort(),
);
const page = await context.newPage();
page.on("pageerror", (error) => navigationErrors.push(error.message));
let documents = 0;
page.on("request", (request) => {
if (request.isNavigationRequest()) documents++;
});
if (!mobile) {
for (const path of ["/account/register/", "/account/login/", "/login/"]) {
await page.goto(origin + path, { waitUntil: "domcontentloaded" });
assert.equal(new URL(page.url()).pathname, "/");
assert.equal(await page.locator("body").getAttribute("class"), "");
await expectPageStyles(page, ["home"]);
assert.ok((await page.title()).includes("SSR首页标题"));
const settled = documents;
await page.waitForTimeout(800);
assert.equal(documents, settled, "Desktop removed-account redirect must settle");
assert.equal(
await page.locator("[data-login-pop], [data-register-pop], [data-login-open], [data-register-open], .hk-user-menu, .hk-user-chip, a[href^='/account']").count(),
0,
"Home renders no login dialog or account entry",
);
}
await page.screenshot({ path: join(output, "desktop-navigation.png") });
} else {
await page.goto(origin + "/discover/search/", { waitUntil: "networkidle" });
assert.equal(
await page.locator("body").getAttribute("class"),
"hk-mobile-search",
);
const input = page.locator(".hk-mobile-search__input");
await input.fill("a/b");
await Promise.all([
page.waitForURL(origin + "/search/a%2Fb/"),
input.press("Enter"),
]);
await page.locator("[data-search-results]").waitFor();
assert.equal(
await page.locator("body").getAttribute("class"),
"hk-search-page",
);
await expectPageStyles(page, ["search-results"], ["mobile-search"]);
assert.ok(
(
await page.locator('head meta[name="robots"]').getAttribute("content")
)?.includes("noindex"),
);
await page.screenshot({
path: join(output, "mobile-search-navigation.png"),
fullPage: true,
});
await page.goto(origin + "/account/", { waitUntil: "networkidle" });
assert.equal(new URL(page.url()).pathname, "/");
assert.equal(await page.locator("body").getAttribute("class"), "");
await expectPageStyles(page, ["home"], ["mobile-search", "search-results"]);
assert.deepEqual(
await page.locator(".hk-mobile-tabbar__item").allTextContents(),
["首页", "短剧", "剧集"],
"Mobile tabbar has no account tab",
);
await page.locator("[data-drawer-open]").click();
await page.locator("[data-drawer]").waitFor();
assert.equal(
await page.locator("[data-drawer] :is(.hk-drawer__user, a[href^='/account'], [data-login-open])").count(),
0,
"Drawer has no user block or account links",
);
}
await context.close();
}
assert.deepEqual(navigationErrors, []);
assert.deepEqual(removedCalls(), [], "No request reached a removed account upstream API");
results.push({
desktopRemovedAccountRedirect: true,
mobileSearchDocumentNavigation: true,
mobileRemovedAccountRedirectAndTabbar: true,
pageClassAndStylesRefresh: true,
browserErrors: 0,
});
await writeFile(
join(output, "results.json"),
JSON.stringify({ optimizedNext: true, results }, null, 2),
);
await writeFile(join(output, "home.html"), home.html);
console.log(
JSON.stringify({ optimizedNext: true, results, output }, null, 2),
);
} catch (error) {
await writeFile(join(output, "next.log"), previewLogs);
await writeFile(
join(output, "upstream.json"),
JSON.stringify(calls, null, 2),
);
throw error;
} finally {
navigationPreview?.kill("SIGTERM");
await browser?.close();
preview.kill("SIGTERM");
await new Promise<void>((done) => mock.close(() => done()));
await unlink(verifyPath).catch(() => {});
}