- 原生 Next.js App Router + 严格 TypeScript 实现花魁短剧 PC/H5 页面,按 src/features/{catalog,content,playback} 组织,保留完整 SEO(metadata、JSON-LD、canonical、robots、llms、sitemap、IndexNow)。
- 主域名通过 SITE_URL=https://huakuidj.com 强制覆盖后端 foreverUrlAddr(config/*.env、src/server/config.ts、src/server/seo/shared.ts、src/server/services/shared.ts),支持邮箱改为 support@huakuidj.com。
- 移除登录与账户体系:删除 /account 全部页面、登录/注册/充值弹窗、收藏点赞、评论、金币解锁及对应 BFF 接口;旧账户地址 GET/HEAD 统一 301 到首页(src/lib/routes.ts REMOVED_ACCOUNT_PATHS),保留游客会话、反馈、图片上传、观看上报与搜索联想。
- 播放页:评论位改为同类推荐 + 热播榜单,锁定集改为 App 观看遮罩与底部弹层,新增简介标签、分集图例与独立视觉皮肤(src/features/playback、src/styles/pages/playback.css)。
- 卡片:短剧/视频卡状态与时长移至信息行胶囊标签,封面角标移至左上角(src/components/catalog/Cards.tsx、src/styles/foundations/base.css)。
- 同步更新单元测试、浏览器冒烟脚本、路由清单与 README。
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1119 lines
42 KiB
TypeScript
1119 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 sdkCommon = JSON.stringify({
|
|
sid: "ssr-sdk-session",
|
|
device_id: "ssr-sdk-device",
|
|
device: "iOS",
|
|
device_fingerprint: "ssr-fingerprint",
|
|
fp_version: "2",
|
|
});
|
|
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: {},
|
|
reportSdkConfig: "",
|
|
});
|
|
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",
|
|
"x-sdk-common": sdkCommon,
|
|
...(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(call.headers["x-sdk-common"], sdkCommon);
|
|
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(() => {});
|
|
}
|