- 原生 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>
228 lines
12 KiB
TypeScript
228 lines
12 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
import { runInNewContext } from "node:vm";
|
|
import { createElement, Fragment } from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import { NextRequest } from "next/server";
|
|
import { createMetadataElements } from "next/dist/lib/metadata/metadata-elements";
|
|
import {
|
|
unstable_doesMiddlewareMatch as doesProxyMatch,
|
|
} from "next/experimental/testing/server";
|
|
import nextConfig from "../next.config";
|
|
import manifest from "../src/app/manifest";
|
|
import { pwaConfig, pwaViewport as viewport } from "../src/lib/pwa/config";
|
|
import { config as proxyConfig, proxy } from "../src/proxy";
|
|
import { createContext } from "../src/server/context";
|
|
import { enrichSEO, toNextMetadata } from "../src/server/seo";
|
|
|
|
function pngDimensions(source: string): [number, number] {
|
|
assert.match(source, /^\/static\/pwa\/[\w-]+\.png$/);
|
|
const bytes = readFileSync(new URL("../public" + source, import.meta.url));
|
|
assert.deepEqual(bytes.subarray(0, 8), Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
|
|
assert.equal(bytes.toString("ascii", 12, 16), "IHDR");
|
|
return [bytes.readUInt32BE(16), bytes.readUInt32BE(20)];
|
|
}
|
|
|
|
test("native manifest installs the Chinese brand at the root with fullscreen and standalone support", () => {
|
|
const data = manifest();
|
|
assert.equal(data.name, "花魁短剧");
|
|
assert.equal(data.short_name, "花魁短剧");
|
|
assert.equal(data.lang, "zh-CN");
|
|
assert.equal(data.id, "/");
|
|
assert.equal(data.start_url, "/");
|
|
assert.equal(data.scope, "/");
|
|
assert.equal(data.display, "fullscreen");
|
|
assert.deepEqual(data.display_override, ["fullscreen", "standalone"]);
|
|
assert.equal(data.orientation, "any");
|
|
assert.equal(data.theme_color, pwaConfig.themeColor);
|
|
assert.equal(data.background_color, pwaConfig.themeColor);
|
|
assert.equal(pwaConfig.manifest, "/manifest.webmanifest");
|
|
assert.equal(pwaConfig.worker, "/sw.js");
|
|
});
|
|
|
|
test("installation icons are real 192/512 PNGs with an independent maskable asset and 180 Apple icon", () => {
|
|
const icons = manifest().icons || [];
|
|
for (const size of [192, 512]) {
|
|
const icon = icons.find((item) => item.sizes === `${size}x${size}` && item.purpose === "any");
|
|
assert.ok(icon, `${size}px icon`);
|
|
assert.equal(icon.type, "image/png");
|
|
assert.deepEqual(pngDimensions(icon.src), [size, size]);
|
|
}
|
|
const maskable = icons.find((item) => item.purpose === "maskable");
|
|
const regular = icons.find((item) => item.sizes === "512x512" && item.purpose === "any");
|
|
assert.ok(maskable && regular);
|
|
assert.equal(maskable.sizes, "512x512");
|
|
assert.equal(maskable.type, "image/png");
|
|
assert.deepEqual(pngDimensions(maskable.src), [512, 512]);
|
|
assert.notEqual(maskable.src, regular.src);
|
|
assert.notDeepEqual(
|
|
readFileSync(new URL("../public" + maskable.src, import.meta.url)),
|
|
readFileSync(new URL("../public" + regular.src, import.meta.url)),
|
|
);
|
|
assert.deepEqual(pngDimensions(pwaConfig.icons.apple), [180, 180]);
|
|
});
|
|
|
|
test("global viewport and Apple metadata cover the installed screen on public, private and error pages", () => {
|
|
assert.equal(viewport.width, "device-width");
|
|
assert.equal(viewport.initialScale, 1);
|
|
assert.equal(viewport.viewportFit, "cover");
|
|
assert.equal(viewport.themeColor, pwaConfig.themeColor);
|
|
const site = "https://canonical.drama.test";
|
|
const appConfig = { foreverUrlAddr: site, seoInfo: { webSiteName: "花魁短剧" } };
|
|
for (const [route, status] of [["/", 200], ["/drama/story/", 200], ["/account/settings/", 200], ["/missing/", 404]] as const) {
|
|
const data = enrichSEO(createContext(new Request(site + route)), { appConfig }, { status });
|
|
const metadata = toNextMetadata(data);
|
|
assert.equal(metadata.applicationName, "花魁短剧", route);
|
|
assert.equal(metadata.manifest, "/manifest.webmanifest", route);
|
|
assert.deepEqual(metadata.appleWebApp, {
|
|
capable: true,
|
|
title: "花魁短剧",
|
|
statusBarStyle: "black-translucent",
|
|
}, route);
|
|
assert.equal(metadata.other?.["apple-mobile-web-app-capable"], "yes", route);
|
|
// Exercise Next's actual output: appleWebApp emits the generic capability,
|
|
// while Safari still requires the separate Apple-prefixed capability.
|
|
const elements = createMetadataElements({
|
|
appleWebApp: metadata.appleWebApp,
|
|
other: metadata.other,
|
|
} as Parameters<typeof createMetadataElements>[0]);
|
|
const head = renderToStaticMarkup(createElement(Fragment, null, ...elements));
|
|
for (const name of ["mobile-web-app-capable", "apple-mobile-web-app-capable"]) {
|
|
assert.equal((head.match(new RegExp(`name="${name}"`, "g")) || []).length, 1, route + " " + name);
|
|
assert.match(head, new RegExp(`<meta name="${name}" content="yes"`));
|
|
}
|
|
assert.deepEqual((metadata.icons as { apple: unknown }).apple, {
|
|
url: pwaConfig.icons.apple,
|
|
sizes: "180x180",
|
|
type: "image/png",
|
|
}, route);
|
|
}
|
|
// Apple capability is generated once through Metadata, never duplicated by a player-only tag.
|
|
const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
|
|
assert.doesNotMatch(layout, /name\s*=\s*["']apple-mobile-web-app-capable["']/);
|
|
});
|
|
|
|
test("PWA metadata preserves canonical, indexing policy, discovery markup and configured verification tags", () => {
|
|
const site = "https://canonical.drama.test";
|
|
const appConfig = { foreverUrlAddr: site, seoInfo: { title: "花魁短剧", webSiteName: "花魁短剧" } };
|
|
const page = enrichSEO(createContext(new Request("http://internal:3000/dramas/all/0/1/page/2/?dc=discard")), {
|
|
appConfig,
|
|
pagination: { Page: 2, Visible: true, HasPrev: true, PrevURL: "/list/", HasNext: true, NextURL: "/list/all/0/1/page/3/" },
|
|
list: [{ id: 41, slug: "story", title: "故事" }, { id: 42, slug: "another", title: "另一部故事" }],
|
|
});
|
|
const metadata = toNextMetadata(page);
|
|
assert.equal(metadata.alternates?.canonical, site + "/dramas/all/0/1/page/2/");
|
|
assert.deepEqual(metadata.pagination, { previous: site + "/dramas/", next: site + "/dramas/all/0/1/page/3/" });
|
|
assert.equal(metadata.robots, "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1");
|
|
assert.equal(metadata.other?.googlebot, page.robots);
|
|
assert.equal(metadata.verification?.google, "Y_ldHPiw-yBVgVw_jZ4UG5WJw-xjO31mBMfhflqMGw8");
|
|
assert.deepEqual(metadata.verification?.yandex, ["56acf08532be3060", "acaf652efa6cd920"]);
|
|
assert.equal(metadata.verification?.other?.["msvalidate.01"], "57A5999E9B5167EE9039265EBCA2AE3A");
|
|
assert.ok(JSON.parse(page.jsonLD)["@graph"].some((node: { "@type": string }) => node["@type"] === "ItemList"));
|
|
|
|
for (const [route, extra, status, robots] of [
|
|
["/feedback/", {}, 200, "noindex,nofollow"],
|
|
["/navigation/", { isNavPage: true }, 200, "noindex,follow"],
|
|
["/missing/", {}, 404, "noindex,follow"],
|
|
["/dramas/", {}, 503, "noindex,follow"],
|
|
] as const) {
|
|
const ctx = createContext(new Request(site + route));
|
|
const data = enrichSEO(ctx, { appConfig, ...extra }, { status });
|
|
const result = toNextMetadata(data);
|
|
assert.equal(result.robots, robots, route);
|
|
assert.equal(result.alternates, undefined, route);
|
|
assert.equal(data.jsonLD, undefined, route);
|
|
assert.equal(ctx.responseHeaders.get("x-robots-tag"), robots.replace(/,/g, ", "), route);
|
|
if (route === "/navigation/") {
|
|
assert.equal(result.verification?.google, "Y_ldHPiw-yBVgVw_jZ4UG5WJw-xjO31mBMfhflqMGw8");
|
|
assert.deepEqual(result.verification?.yandex, ["f5f965a1c5d31a69", "acaf652efa6cd920"]);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("Next's actual matcher excludes installation assets while retaining normal pages, APIs and signed media", () => {
|
|
for (const url of ["/manifest.webmanifest", "/manifest.webmanifest?v=1", "/sw.js", "/sw.js?v=1", "/static/pwa/icon-192.png", "/_next/static/chunk.js"])
|
|
assert.equal(doesProxyMatch({ config: proxyConfig, nextConfig, url }), false, url);
|
|
for (const url of ["/", "/dramas/", "/account/login/", "/feedback/", "/api/auth/guest", "/h5/m3u8/story.m3u8?ticket=a%2Bb%3D", "/enkey?ticket=1", "/manifest.webmanifest/child", "/sw.js/child"])
|
|
assert.equal(doesProxyMatch({ config: proxyConfig, nextConfig, url }), true, url);
|
|
});
|
|
|
|
test("manifest and worker runtime bypass never create guest sessions, cookies or SSR handoffs, including navigation-only mode", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
const originalNavigation = process.env.NAVIGATION_ONLY;
|
|
let requests = 0;
|
|
globalThis.fetch = async () => {
|
|
requests++;
|
|
throw new Error("Installation assets must not contact the upstream");
|
|
};
|
|
try {
|
|
for (const navigation of ["false", "true"]) {
|
|
process.env.NAVIGATION_ONLY = navigation;
|
|
for (const path of ["/manifest.webmanifest?dc=pwa-test", "/sw.js?v=pwa-test"])
|
|
for (const method of ["GET", "HEAD"]) {
|
|
const request = new NextRequest("https://site.test" + path, { method });
|
|
const response = await proxy(request);
|
|
assert.equal(response.status, 200, navigation + path + method);
|
|
assert.equal(response.headers.get("x-middleware-next"), "1");
|
|
assert.deepEqual(response.headers.getSetCookie(), []);
|
|
assert.equal(response.headers.has("location"), false);
|
|
assert.equal(response.headers.has("x-middleware-rewrite"), false);
|
|
assert.equal(response.headers.has("x-middleware-override-headers"), false);
|
|
for (const name of response.headers.keys())
|
|
assert.equal(name.includes("x-hk-"), false, "Unexpected handoff: " + name);
|
|
assert.equal(request.headers.get("cookie"), null);
|
|
}
|
|
}
|
|
assert.equal(requests, 0);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
if (originalNavigation === undefined) delete process.env.NAVIGATION_ONLY;
|
|
else process.env.NAVIGATION_ONLY = originalNavigation;
|
|
}
|
|
});
|
|
|
|
test("the generated worker activates without fetch interception, network calls or Cache Storage access", async () => {
|
|
type Event = { waitUntil: (promise: Promise<unknown>) => void };
|
|
const listeners = new Map<string, (event: Event) => void>();
|
|
let waiting = 0;
|
|
let claimed = 0;
|
|
const worker = {
|
|
addEventListener(name: string, listener: (event: Event) => void) {
|
|
assert.equal(listeners.has(name), false, "Duplicate worker listener");
|
|
listeners.set(name, listener);
|
|
},
|
|
skipWaiting: async () => { waiting++; },
|
|
clients: { claim: async () => { claimed++; } },
|
|
};
|
|
const forbidden = (name: string) => () => {
|
|
assert.fail("The installation worker must not access " + name);
|
|
};
|
|
const sandbox = { self: worker };
|
|
for (const target of [worker, sandbox]) {
|
|
Object.defineProperty(target, "caches", { get: forbidden("Cache Storage") });
|
|
Object.defineProperty(target, "fetch", { get: forbidden("fetch") });
|
|
}
|
|
runInNewContext(readFileSync(new URL("../public/sw.js", import.meta.url), "utf8"), sandbox, { timeout: 1000 });
|
|
assert.deepEqual([...listeners.keys()].sort(), ["activate", "install"]);
|
|
for (const name of ["install", "activate"]) {
|
|
const promises: Promise<unknown>[] = [];
|
|
listeners.get(name)!({ waitUntil: (promise) => { promises.push(Promise.resolve(promise)); } });
|
|
assert.equal(promises.length, 1, name + " must wait for its lifecycle operation");
|
|
await Promise.all(promises);
|
|
}
|
|
assert.equal(waiting, 1);
|
|
assert.equal(claimed, 1);
|
|
});
|
|
|
|
test("the worker is served as JavaScript without persistent HTTP caching and with root scope", async () => {
|
|
const rules = await nextConfig.headers!();
|
|
const worker = rules.find((rule) => rule.source === "/sw.js");
|
|
assert.ok(worker);
|
|
const headers = new Headers(worker.headers.map(({ key, value }): [string, string] => [key, value]));
|
|
assert.match(headers.get("content-type") || "", /^(?:application|text)\/javascript\b/i);
|
|
assert.match(headers.get("cache-control") || "", /\bno-store\b/);
|
|
assert.match(headers.get("cache-control") || "", /\bno-cache\b/);
|
|
assert.equal(headers.get("service-worker-allowed"), "/");
|
|
});
|