[Ai] feat: 花魁短剧 Next.js 站点初始提交

- 原生 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>
This commit is contained in:
谢宇宁
2026-10-16 11:33:31 +07:00
co-authored by Claude Opus 5.5
commit d2ddb1c321
423 changed files with 52152 additions and 0 deletions
+195
View File
@@ -0,0 +1,195 @@
import { chromium } from "@playwright/test";
import { mkdir, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
const origin = process.env.SMOKE_URL || "http://localhost:25131";
const output = process.env.SMOKE_OUTPUT_DIR || "/tmp/hkdj-browser-smoke";
await mkdir(output, { recursive: true });
const browser = await chromium.launch({
executablePath:
process.env.CHROME_PATH ||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
headless: true,
});
const results: Record<string, unknown>[] = [];
try {
for (const [name, viewport] of [
["desktop", { width: 1440, height: 1000 }],
["mobile", { width: 390, height: 844 }],
] as const) {
const context = await browser.newContext({
viewport,
isMobile: name === "mobile",
deviceScaleFactor: 1,
});
await context.addInitScript(() => localStorage.setItem("hg_age_ok", "1"));
await context.route("**/*", async (route) => {
const request = route.request();
const local = new URL(request.url()).origin === origin;
// Exercise UI without recording synthetic analytics, purchases or user actions.
if (
!local &&
(request.method() !== "GET" || request.resourceType() === "script")
)
return route.abort();
return route.continue();
});
const page = await context.newPage();
const errors: string[] = [];
const missingAssets: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
page.on("response", (response) => {
if (
response.status() >= 400 &&
response.url().startsWith(origin + "/static/")
)
missingAssets.push(new URL(response.url()).pathname);
});
for (const path of [
"/",
"/dramas/",
"/discover/search/",
]) {
const response = await page.goto(origin + path, {
waitUntil: "domcontentloaded",
timeout: 40000,
});
await page.waitForTimeout(500);
if (path !== "/discover/search/")
await page.waitForFunction(
() => typeof (window as unknown as Record<string, unknown>).tracker === "object",
null,
{ timeout: 15000 },
);
for (let index = 0; index < 12; index++) {
const close = page.locator(
"[data-dialog-ad].hk-is-open [data-dialog-ad-item]:not([hidden]) [data-dialog-ad-close]",
);
if (!(await close.count()) || !(await close.first().isVisible())) break;
await close.first().click({ force: true });
}
const layout = await page.evaluate(() => ({
viewport: innerWidth,
document: document.documentElement.scrollWidth,
heading: document.querySelector("h1")?.textContent?.trim() || "",
legacyScripts: Array.from(document.scripts).some((script) =>
/\/static\/js\//.test(script.src),
),
tracker:
typeof (window as unknown as Record<string, unknown>).tracker ===
"object",
}));
results.push({
device: name,
path,
status: response?.status(),
...layout,
});
if (response?.status() !== 200)
throw new Error(`${name} ${path} returned ${response?.status()}`);
if (layout.document > layout.viewport + 1)
throw new Error(
`${name} ${path} overflows horizontally: ${layout.document}`,
);
if (
layout.legacyScripts ||
(path !== "/discover/search/" && !layout.tracker)
)
throw new Error(`${name} ${path} missing shared client runtime`);
if (path === "/") {
const accountEntries = await page.locator(
"[data-login-open], [data-register-open], .hk-user-menu, a[href^='/account']",
).count();
if (accountEntries)
throw new Error(`${name} home still renders ${accountEntries} account entries`);
if (name === "desktop" && await page.locator("[data-hero]").count()) {
await page.hover("[data-hero]");
await page.waitForFunction(() => {
const slide = document.querySelector(".hk-home-hero__slide.hk-is-active");
const image = slide?.querySelector<HTMLImageElement>(".hk-home-hero__poster");
return !!image && image.complete && image.naturalWidth > 0 &&
!image.hasAttribute("data-decrypt-failed") &&
!image.currentSrc.includes("/static/brand/placeholder.svg") &&
getComputedStyle(image).opacity === "1" &&
getComputedStyle(slide!).opacity === "1";
}, null, { timeout: 45000 });
}
await page.screenshot({
path: resolve(output, name + "-home.png"),
fullPage: false,
animations: "disabled",
});
if (name === "mobile") {
await page.locator("[data-drawer-open]").click();
if (
!(await page
.locator("[data-drawer]")
.evaluate((node) => node.classList.contains("hk-is-open")))
)
throw new Error("Mobile drawer did not open");
await page.locator(".hk-drawer__close").click();
} else {
const input = page.locator("[data-header-search-input]");
const suggestionResponse = page.waitForResponse(
(response) => response.url().includes("/api/web/search/suggest"),
{ timeout: 15000 },
);
await input.fill("测试");
await suggestionResponse;
if (
!(await page.locator("[data-header-search-dropdown]").isVisible())
)
throw new Error("Search suggestions did not open");
const download = page.locator("[data-download-open]").first();
if (await download.isVisible()) {
await download.click();
if (
!(await page
.locator("[data-download-pop]")
.evaluate((node) => node.classList.contains("hk-is-open")))
)
throw new Error("Download dialog did not open");
await page.keyboard.press("Escape");
}
}
}
}
for (const removed of ["/account/", "/account/settings/", "/account/recharge/", "/login/"]) {
const response = await context.request.get(origin + removed, { maxRedirects: 0 });
if (response.status() !== 301 || response.headers().location !== "/")
throw new Error(`${name} ${removed} should 301 to /, got ${response.status()} ${response.headers().location}`);
}
const playURL = await page.evaluate(() =>
document
.querySelector<HTMLAnchorElement>('a[href^="/drama/"]')
?.getAttribute("href"),
);
if (playURL) {
await page.goto(origin + playURL, { waitUntil: "domcontentloaded" });
const player = page.locator("[data-play-player]");
results.push({
device: name,
path: "play",
player: await player.count(),
xgplayer: await page.locator(".xgplayer").count(),
video: await page.locator("video").count(),
});
}
if (errors.length || missingAssets.length)
throw new Error(JSON.stringify({ device: name, errors, missingAssets }));
await context.close();
}
await writeFile(
resolve(output, "results.json"),
JSON.stringify(results, null, 2),
);
console.log(
JSON.stringify(
{ pages: results.length, devices: 2, errors: 0, output },
null,
2,
),
);
} finally {
await browser.close();
}
+22
View File
@@ -0,0 +1,22 @@
import { build } from "esbuild";
import { readFile, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const output = new URL("../public/sw.js", import.meta.url);
const result = await build({
entryPoints: [fileURLToPath(new URL("../src/lib/pwa/service-worker.ts", import.meta.url))],
bundle: true,
format: "iife",
platform: "browser",
target: "es2020",
write: false,
banner: { js: "// Generated from src/lib/pwa/service-worker.ts; run npm run assets:pwa." },
});
const bytes = result.outputFiles[0].contents;
if (process.argv.includes("--check")) {
const existing = await readFile(output);
if (!existing.equals(Buffer.from(bytes))) throw new Error("PWA worker is stale. Run npm run assets:pwa.");
} else {
const existing = await readFile(output).catch(() => null);
if (!existing?.equals(Buffer.from(bytes))) await writeFile(output, bytes);
}
+364
View File
@@ -0,0 +1,364 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { readFile, mkdtemp, rm } from "node:fs/promises";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, extname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { build } from "esbuild";
import { chromium } from "@playwright/test";
import { decryptImageBytes, IMAGE_PLACEHOLDER } from "../src/lib/media/images";
import { createStyleFixture } from "./style-fixtures";
// Exercise the actual React components and API client against local mocks only.
const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."),
temporary = await mkdtemp(join(tmpdir(), "huakui-catalog-react-"));
const bundle = join(temporary, "catalog.js");
const heroStyles = await createStyleFixture(["base", "index"]);
const plainImage = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jEtQAAAAASUVORK5CYII=",
"base64",
);
const encryptedImage = decryptImageBytes(
plainImage.buffer.slice(plainImage.byteOffset, plainImage.byteOffset + plainImage.byteLength) as ArrayBuffer,
);
const imageReads = new Map<string, number>();
let releaseReplacement: (() => void) | undefined;
let replacementArrived: (() => void) | undefined;
const replacementStarted = new Promise<void>((done) => { replacementArrived = done; });
await build({
stdin: {
resolveDir: root,
loader: "tsx",
contents: `
import {createRoot} from 'react-dom/client';
import {useState} from 'react';
import {AppRouterContext} from 'next/dist/shared/lib/app-router-context.shared-runtime';
import {SiteProvider} from '@/components/providers/SiteProvider';
import {HeroReact} from '@/components/catalog/HeroReact';
import {SearchMobilePage} from '@/features/catalog/DiscoveryPages';
const router={back(){window.__navigation='back'},forward(){},refresh(){},push(path){window.__navigation=path},replace(path){window.__navigation=path},prefetch(){}};
const banners=[{id:1,slug:'first',playName:'第一部',cover:'/static/brand/header.svg',banner:'/static/brand/footer.svg',totalEpisode:8,countCollect:5,isCollect:2},{id:2,slug:'second',playName:'第二部',cover:'/static/brand/header.svg',totalEpisode:12,countCollect:7,isCollect:1},{id:3,slug:'third',playName:'第三部',cover:'/static/brand/header.svg',totalEpisode:2,countCollect:0,isCollect:2}];
const assetBanners=[{...banners[0],horizontalCover:'/fixture/encrypted-first.png',banner:'/fixture/encrypted-first.png'},{...banners[1],horizontalCover:'/fixture/encrypted-second.png',banner:'/fixture/encrypted-second.png'},{...banners[2],horizontalCover:'/fixture/broken-image.png',banner:'/fixture/encrypted-second.png'}];
function HeroAssets(){const [items,setItems]=useState(assetBanners);window.__replaceHeroPoster=source=>setItems(previous=>previous.map((item,index)=>index===0?{...item,horizontalCover:source}:item));return <HeroReact items={items}/>;}
createRoot(document.getElementById('root')).render(<AppRouterContext.Provider value={router}><SiteProvider data={{}} dialogs={false}>{location.pathname==='/hero-css/'?<HeroAssets/>:<><HeroReact items={banners}/><SearchMobilePage data={{query:'',trends:[{Rank:1,Title:'都市',Heat:'1 万',Badge:'热'}],recommended:[]}}/></>}</SiteProvider></AppRouterContext.Provider>);
`,
},
bundle: true,
platform: "browser",
format: "iife",
outfile: bundle,
tsconfig: join(root, "tsconfig.json"),
banner: { js: 'var process={env:{NODE_ENV:"development"}};' },
define: {
"process.env.NODE_ENV": '"development"',
"process.env.__NEXT_ROUTER_BASEPATH": '""',
},
});
const server = createServer(async (request, response) => {
try {
const path = new URL(request.url || "/", "http://local.test").pathname;
if (path === heroStyles.href) {
response.setHeader("Content-Type", "text/css;charset=utf-8");
response.end(heroStyles.css);
return;
}
if (path === "/") {
response.setHeader("Content-Type", "text/html");
response.end(
'<!doctype html><html><head><meta charset="utf-8"></head><body><div id="root"></div><script src="/catalog.js"></script></body></html>',
);
return;
}
if (path === "/hero-css/") {
response.setHeader("Content-Type", "text/html;charset=utf-8");
response.end(`<!doctype html><html data-hk-page-styles="${heroStyles.tokens}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="${heroStyles.href}"></head><body><div id="root"></div><script src="/catalog.js"></script></body></html>`);
return;
}
if (path.startsWith("/fixture/")) {
imageReads.set(path, (imageReads.get(path) || 0) + 1);
response.setHeader("Cache-Control", "no-store");
if (path === "/fixture/broken-image.png") {
response.writeHead(404).end();
return;
}
if (path === "/fixture/replacement.png") {
await new Promise<void>((done) => { releaseReplacement = done; replacementArrived?.(); });
}
response.setHeader("Content-Type", "application/octet-stream");
response.end(encryptedImage);
return;
}
if (path.startsWith("/search/")) {
response.setHeader("Content-Type", "text/html");
response.end(
"<!doctype html><html><head><title>隔离搜索结果</title></head><body class=\"hk-search-page\"><main data-search-results=\"\"></main></body></html>",
);
return;
}
if (path === "/catalog.js") {
response.setHeader("Content-Type", "application/javascript");
response.end(await readFile(bundle));
return;
}
if (path.startsWith("/static/")) {
const file = resolve(root, "public", "." + path);
if (!file.startsWith(join(root, "public") + "/")) {
response.writeHead(403).end();
return;
}
response.setHeader(
"Content-Type",
extname(file) === ".svg" ? "image/svg+xml" : extname(file) === ".css" ? "text/css" : "application/octet-stream",
);
response.end(await readFile(file));
return;
}
response.writeHead(404).end();
} catch {
response.writeHead(404).end();
}
});
await new Promise<void>((resolveListen) =>
server.listen(0, "127.0.0.1", resolveListen),
);
const address = server.address();
assert.ok(address && typeof address !== "string");
const origin = `http://127.0.0.1:${address.port}`;
const configuredChrome = process.env.CHROME_PATH,
macChrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined;
try {
browser = await chromium.launch({
headless: true,
...(configuredChrome || existsSync(macChrome)
? { executablePath: configuredChrome || macChrome }
: {}),
});
const context = await browser.newContext(),
page = await context.newPage(),
errors: string[] = [],
requests: {
path: string;
body: Record<string, unknown>;
headers: Record<string, string>;
}[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await context.addInitScript({
content: `
window.__copies=[];window.__events=[];window.__navigation='';
Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async function(value){window.__copies.push(value)}}});
window.tracker={getSDKCommonHeader:function(){return 'mock-common'},getCommonFields:function(){return {device_id:'mock-device',device:'OTHER',sid:'mock-sid'}}};
window.WebSDK={track:new Proxy({},{get:function(_,method){return function(payload){window.__events.push({method:String(method),payload:payload})}}})};
if(!localStorage.getItem('hgdj_search_history'))localStorage.setItem('hgdj_search_history',JSON.stringify(['历史词']));
`,
});
let suggestionMode: "normal" | "fail" = "normal";
await page.route("**/api/**", async (route) => {
const request = route.request(),
url = new URL(request.url()),
body = (request.postDataJSON() as Record<string, unknown>) || {};
requests.push({ path: url.pathname, body, headers: request.headers() });
if (url.pathname === "/api/auth/guest") {
await new Promise((resolveDelay) => setTimeout(resolveDelay, 100));
await route.fulfill({
json: {
code: 200,
data: { token: "mock-guest", userId: 12345, type: 0 },
},
});
return;
}
if (url.pathname === "/api/web/search/suggest") {
if (suggestionMode === "fail") {
await route.abort("failed");
return;
}
const keyword = url.searchParams.get("keyword") || "";
if (keyword === "旧")
await new Promise((resolveDelay) => setTimeout(resolveDelay, 200));
await route.fulfill({
json: {
code: 200,
keyword,
feature: {
title: keyword + "作品",
cover: "/static/brand/header.svg",
url: "/drama/suggest/",
meta: "全 8 集",
},
items: [
{
title: keyword,
query: keyword,
kind: "搜索",
url: "/search/" + encodeURIComponent(keyword) + "/",
},
],
},
});
return;
}
errors.push("Unexpected API: " + url.pathname);
await route.fulfill({ status: 500, json: { code: 500, data: null } });
});
await page.goto(origin);
await page
.waitForSelector(".hk-home-hero__share", { timeout: 10000 })
.catch((error) => {
throw new Error(
`React mount failed: ${errors.join("; ")}; ${String(error)}`,
);
});
await page.hover(".hk-home-hero");
// The account system was removed: the hero has no collection toggle or account entry.
assert.equal(await page.locator(".hk-home-hero__collect").count(), 0);
assert.equal(await page.locator("[data-login-open], [data-register-open]").count(), 0);
await page.locator(".hk-home-hero__share").first().click();
assert.deepEqual(
await page.evaluate(
() => (window as unknown as { __copies: string[] }).__copies,
),
[origin + "/drama/first/"],
);
await page.locator(".hk-home-hero__thumb").nth(1).click();
assert.equal(await page.locator("[data-hero-current]").textContent(), "02");
assert.equal(
await page.locator(".hk-home-hero__slide.hk-is-active .hk-home-hero__title").textContent(),
"第二部",
);
assert.equal(
await page.locator(".hk-mobile-search__chip").first().textContent(),
"历史词",
);
await page.getByRole("button", { name: "清除搜索历史", exact: true }).click();
assert.equal(
await page.locator(".hk-mobile-search__history").isVisible(),
false,
);
const input = page.locator(".hk-mobile-search__input");
await input.fill("都市");
await page.waitForSelector(".hk-mobile-search__suggestion-feature");
assert.equal(
await page.locator(".hk-mobile-search__feature-title").textContent(),
"都市作品",
);
await input.fill("旧");
await page.waitForTimeout(140);
await input.fill("新");
await page.waitForFunction(
() =>
document.querySelector(".hk-mobile-search__feature-title")?.textContent ===
"新作品",
);
await page.waitForTimeout(220);
assert.equal(
await page.locator(".hk-mobile-search__feature-title").textContent(),
"新作品",
);
suggestionMode = "fail";
await input.fill("无网");
await page.waitForSelector(".hk-mobile-search__suggestion");
assert.match(
(await page.locator(".hk-mobile-search__suggestion").getAttribute("href")) ||
"",
/\/search\/%E6%97%A0%E7%BD%91\//,
);
await input.fill("a/b");
const navigation = page.waitForURL(origin + "/search/a%2Fb/");
await input.press("Enter");
await navigation;
assert.equal(new URL(page.url()).pathname, "/search/a%2Fb/");
assert.equal(await page.locator("[data-search-results]").count(), 1);
assert.equal(await page.locator("body").getAttribute("class"), "hk-search-page");
assert.ok(
await page.evaluate(() =>
JSON.parse(localStorage.getItem("hgdj_search_history") || "[]").includes(
"a/b",
),
),
);
// The interaction fixture intentionally has no global CSS. Verify the actual
// desktop poster opacity contract separately so a loaded-but-transparent image fails.
const heroPage = await context.newPage();
await heroPage.setViewportSize({ width: 1440, height: 1000 });
heroPage.on("pageerror", (error) => errors.push(error.message));
const activePoster = ".hk-home-hero__slide.hk-is-active .hk-home-hero__poster";
const waitForPoster = async () => {
await heroPage.waitForFunction(() => {
const image = document.querySelector<HTMLImageElement>(".hk-home-hero__slide.hk-is-active .hk-home-hero__poster");
return !!image && image.complete && image.naturalWidth > 0 &&
image.getAttribute("data-hero-loaded") === "1" &&
Number(getComputedStyle(image).opacity) > 0.99;
});
return heroPage.locator(activePoster).evaluate((image) => ({
src: image.getAttribute("src"),
naturalWidth: (image as HTMLImageElement).naturalWidth,
opacity: Number(getComputedStyle(image).opacity),
}));
};
await heroPage.goto(origin + "/hero-css/");
await heroPage.hover(".hk-home-hero");
const firstPoster = await waitForPoster();
assert.match(firstPoster.src || "", /^blob:/);
assert.equal(firstPoster.naturalWidth, 1, "Encrypted PNG is really decoded by the browser");
await heroPage.locator(".hk-home-hero__thumb").nth(1).click();
const secondPoster = await waitForPoster();
assert.match(secondPoster.src || "", /^blob:/);
assert.notEqual(secondPoster.src, firstPoster.src);
const firstReads = imageReads.get("/fixture/encrypted-first.png");
const secondReads = imageReads.get("/fixture/encrypted-second.png");
for (const index of [0, 1, 0]) {
await heroPage.locator(".hk-home-hero__thumb").nth(index).click();
const revisited = await waitForPoster();
assert.equal(revisited.src, index === 0 ? firstPoster.src : secondPoster.src);
}
assert.equal(imageReads.get("/fixture/encrypted-first.png"), firstReads, "Revisiting a poster reuses its decoded cache");
assert.equal(imageReads.get("/fixture/encrypted-second.png"), secondReads);
await heroPage.locator(".hk-home-hero__thumb").nth(2).click();
const fallback = await waitForPoster();
assert.equal(fallback.src, IMAGE_PLACEHOLDER, "Failed poster shows the real shared brand placeholder");
assert.equal(fallback.naturalWidth, 480);
await heroPage.waitForFunction(() => document.querySelector(".hk-home-hero__slide.hk-is-active .hk-home-hero__poster")?.getAttribute("data-decrypt-failed") === "1");
await heroPage.locator(".hk-home-hero__thumb").first().click();
await waitForPoster();
await heroPage.evaluate((source) => {
(window as unknown as { __replaceHeroPoster(source: string): void }).__replaceHeroPoster(source);
}, "/fixture/replacement.png");
await replacementStarted;
await heroPage.waitForFunction(() => {
const image = document.querySelector<HTMLImageElement>(".hk-home-hero__slide.hk-is-active .hk-home-hero__poster");
return image?.getAttribute("src")?.includes("/static/brand/placeholder.svg") &&
image.getAttribute("data-hero-loaded") !== "1" &&
Number(getComputedStyle(image).opacity) < 0.01;
});
assert.equal(await heroPage.locator(".hk-home-hero__slide.hk-is-active .hk-home-hero__title").textContent(), "第一部", "The same item receives a new image source");
releaseReplacement?.();
const replacement = await waitForPoster();
assert.match(replacement.src || "", /^blob:/);
assert.notEqual(replacement.src, firstPoster.src, "A new source cannot keep the previous poster's readiness/blob");
await heroPage.reload();
await heroPage.hover(".hk-home-hero");
const refreshed = await waitForPoster();
assert.match(refreshed.src || "", /^blob:/);
assert.equal(await heroPage.locator("[data-hero-current]").textContent(), "01");
assert((imageReads.get("/fixture/encrypted-first.png") || 0) > (firstReads || 0), "A refreshed document loads and reveals its image again");
await heroPage.close();
assert.deepEqual(errors, []);
assert.deepEqual(
requests.filter((request) => request.path.startsWith("/api/playlet/collect")),
[],
"Removed collection API must not be called",
);
console.log(
"React catalog smoke passed: no collection/account entries, share, carousel, history, suggestions, stale requests, offline fallback, encoded search, encrypted hero CSS visibility, cached revisits, visible brand fallback, source replacement and refresh. All APIs mocked.",
);
await context.close();
} finally {
releaseReplacement?.();
await browser?.close();
server.closeAllConnections();
await new Promise<void>((resolveClose) => server.close(() => resolveClose()));
await rm(temporary, { recursive: true, force: true });
}
+45
View File
@@ -0,0 +1,45 @@
import { createHash } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
// Preserve the complete Figma SVG export as an image; never redraw or recolor its paths.
const brandFile = new URL('../public/static/brand/footer.svg', import.meta.url);
const outputFile = new URL('../public/static/brand/placeholder.svg', import.meta.url);
const manifestFile = new URL('../public/static/brand/placeholder.manifest.json', import.meta.url);
const brand = await readFile(brandFile);
const sha256 = (bytes: Buffer): string => createHash('sha256').update(bytes).digest('hex');
const width = 240;
const logoOpacity = 0.2;
const height = width * 44 / 124;
const x = (480 - width) / 2;
const y = (480 - height) / 2;
const svg = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="480" height="480" viewBox="0 0 480 480" role="img" aria-labelledby="placeholder-title">
<title id="placeholder-title">花魁短剧内容占位</title>
<rect width="480" height="480" fill="#26272f"/>
<image x="${x}" y="${y.toFixed(6)}" width="${width}" height="${height.toFixed(6)}" opacity="${logoOpacity}" preserveAspectRatio="xMidYMid meet" href="data:image/svg+xml;base64,${brand.toString('base64')}"/>
</svg>
`);
const manifest = Buffer.from(JSON.stringify({
file: 'public/static/brand/placeholder.svg',
width: 480, height: 480, sha256: sha256(svg),
source: 'Composition using the unmodified Figma brand export and the design panel background token.',
background: { color: '#26272f', source: 'Figma global panel token' },
composition: {
type: 'svg-container',
embeddedImage: {
file: 'public/static/brand/footer.svg',
source: 'https://www.figma.com/design/1vUq4wf5xOs8ZOlmt26Q3O/?node-id=1-706',
nodeId: '1:706', sha256: sha256(brand), encoding: 'base64', unmodified: true,
x, y: Number(y.toFixed(6)), width, height: Number(height.toFixed(6)), opacity: logoOpacity,
},
},
}, null, 2) + '\n');
if (process.argv.includes('--check')) {
const existing = await Promise.all([readFile(outputFile), readFile(manifestFile)]);
if (!existing[0].equals(svg) || !existing[1].equals(manifest)) {
throw new Error('Brand placeholder is out of date. Run tsx scripts/compose-placeholder.ts.');
}
console.log('Brand placeholder matches the complete unmodified Figma footer.svg export.');
} else {
await Promise.all([writeFile(outputFile, svg), writeFile(manifestFile, manifest)]);
console.log('Composed 480×480 brand placeholder from the complete unmodified Figma footer.svg export.');
}
+30
View File
@@ -0,0 +1,30 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import sharp from "sharp";
import { pwaConfig } from "../src/lib/pwa/config";
import { brandAssets } from "../src/lib/brand-assets";
// Rasterize the existing Figma favicon paths unchanged; maskable icons need a
// separate opaque canvas with the entire mark inside the central safe circle.
const source = await readFile(new URL("../public" + brandAssets.iconSvg, import.meta.url));
const outputs = [
{ file: pwaConfig.icons.small, size: 192, mark: 192 },
{ file: pwaConfig.icons.large, size: 512, mark: 512 },
{ file: pwaConfig.icons.maskable, size: 512, mark: 288 },
{ file: pwaConfig.icons.apple, size: 180, mark: 144 },
];
const directory = new URL("../public/static/pwa/", import.meta.url);
await mkdir(directory, { recursive: true });
for (const { file, size, mark } of outputs) {
const image = await sharp(source, { density: 384 }).resize(mark, mark).png().toBuffer();
const bytes = await sharp({ create: {
width: size, height: size, channels: 4, background: pwaConfig.themeColor,
} }).composite([{ input: image, gravity: "centre" }]).png().toBuffer();
const path = new URL("../public" + file, import.meta.url);
if (process.argv.includes("--check")) {
const existing = await readFile(path);
if (!existing.equals(bytes)) throw new Error(`${file} is stale. Run npm run assets:pwa.`);
} else {
await writeFile(path, bytes);
}
}
console.log("PWA icons match the existing Figma favicon.");
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Node 必须在进程启动前收到代理开关和额外 CA,放在 .env.local 中太晚。
if ! node -e 'process.exit(process.allowedNodeEnvironmentFlags.has("--use-env-proxy") ? 0 : 1)'; then
echo "[charles] 当前 $(node --version) 不支持原生环境代理,请先执行 nvm use 24(需要 Node >= 24.5 或 22.21)。" >&2
exit 1
fi
export NODE_EXTRA_CA_CERTS="${NODE_EXTRA_CA_CERTS:-$HOME/.charles-ca.pem}"
if [[ ! -r "$NODE_EXTRA_CA_CERTS" ]]; then
echo "[charles] 找不到可读的根证书:$NODE_EXTRA_CA_CERTS" >&2
echo "请在 Charles 的 Help → SSL Proxying → Save Charles Root Certificate 中导出 PEM,或通过 NODE_EXTRA_CA_CERTS 指定路径。" >&2
exit 1
fi
export NODE_USE_ENV_PROXY=1
export HTTP_PROXY="${CHARLES_PROXY_URL:-http://127.0.0.1:8888}"
export HTTPS_PROXY="$HTTP_PROXY"
export NO_PROXY="localhost,127.0.0.1,::1${NO_PROXY:+,$NO_PROXY}${no_proxy:+,$no_proxy}"
# 统一大小写变量,避免命中其他已导出的代理。
export http_proxy="$HTTP_PROXY"
export https_proxy="$HTTPS_PROXY"
export no_proxy="$NO_PROXY"
cd "$project_root"
echo "[charles] 已启用 Node 环境代理,请确认 Charles 已启动并开启目标域名的 SSL Proxying。"
# 复用普通开发入口,保留环境配置、客户端/模板编译和默认端口。
exec node --import tsx "$project_root/scripts/next-env.ts" development dev "$@"
+71
View File
@@ -0,0 +1,71 @@
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { pageRoutes } from '../src/server/pages/routes';
import { ROUTE_MIGRATIONS, toPublicPath } from '../src/lib/routes';
const labels: Record<string, string> = {
Index: '首页', SearchMobile: '移动端搜索', Search: '搜索结果',
About: '关于我们', Help: '帮助中心', Terms: '服务条款', Privacy: '隐私政策',
Compliance2257: '2257 声明', DMCA: '版权声明', DownloadApp: '下载 App',
Nav: '官方导航', HomeRoad: '回家的路', List: '短剧列表', MoreList: '短剧推荐栏目',
MoreSeriesList: '剧集推荐栏目', Series: '剧集列表', Topic: '专题列表',
TopicDetail: '专题详情', Rank: '排行榜', Tags: '标签详情',
RecentlyAdded: '最新更新', PopularRecommend: '热门推荐', Play: '短剧播放',
SeriesPlay: '剧集播放', Feedback: '意见反馈',
};
type CatalogRoute = { id: string; label: string; path: string; legacyPaths: string[]; selectable: boolean };
const routes = new Map<string, CatalogRoute>();
for (const [legacy, handler] of pageRoutes) {
if (!labels[handler]) throw new Error('Missing page label: ' + handler);
const path = toPublicPath(legacy);
let entry = routes.get(path);
if (!entry) {
entry = { id: handler + ':' + path, label: labels[handler], path, legacyPaths: [], selectable: !path.includes(':') };
routes.set(path, entry);
}
if (!entry.legacyPaths.includes(legacy)) entry.legacyPaths.push(legacy);
}
const catalog = JSON.stringify({ version: 1, routes: [...routes.values()] }, null, 2) + '\n';
const begin = '<!-- BEGIN PAGE_ROUTE_CATALOG -->';
const end = '<!-- END PAGE_ROUTE_CATALOG -->';
const inventory = [
begin,
'以下清单由 `npm run routes:export` 从实际路由注册生成,包含全部页面、筛选和分页模式。含 `:参数` 的路径需要填写真实值;后台导航下拉只展示可直接访问的固定入口,详情页和外链可手动输入。',
'',
'| 页面 | 原路径 | 当前路径 |',
'| --- | --- | --- |',
...[...routes.values()].map(item => `| ${item.label} | ${item.legacyPaths.map(path => '`' + path + '`').join('、')} | \`${item.path}\` |`),
'',
end,
].join('\n');
const root = resolve(import.meta.dirname, '..');
const args = process.argv.slice(2);
const check = args.includes('--check');
const adminIndex = args.indexOf('--admin');
for (let i = 0; i < args.length; i++) {
if (args[i] === '--admin') { i++; continue; }
if (args[i] !== '--check') throw new Error('Usage: routes:export [--check] [--admin <admin-repository>]');
}
if (adminIndex >= 0 && (!args[adminIndex + 1] || args[adminIndex + 1].startsWith('--'))) throw new Error('--admin requires the admin repository path');
const readmePath = resolve(root, 'README.md');
const readme = await readFile(readmePath, 'utf8');
const start = readme.indexOf(begin), finish = readme.indexOf(end);
if (start < 0 || finish < start) throw new Error('README route inventory markers are missing');
const updatedReadme = readme.slice(0, start) + inventory + readme.slice(finish + end.length);
const outputs: [string, string][] = [[resolve(root, 'src/lib/page-route-catalog.json'), catalog], [readmePath, updatedReadme]];
if (adminIndex >= 0) {
const adminRoot = resolve(args[adminIndex + 1]!);
const pkg = JSON.parse(await readFile(resolve(adminRoot, 'package.json'), 'utf8'));
if (pkg.name !== 'hkdj-web-admin') throw new Error('--admin must target the Huakui web admin repository');
outputs.push([resolve(adminRoot, 'src/constants/hkdj-web-routes.json'), catalog]);
}
for (const [path, content] of outputs) {
if (check) {
if (await readFile(path, 'utf8') !== content) throw new Error('Route catalog is stale: ' + path);
} else {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, content);
}
}
console.log(`${check ? 'Verified' : 'Exported'} ${ROUTE_MIGRATIONS.length} migrations, ${routes.size} page patterns, ${[...routes.values()].filter(item => item.selectable).length} navigation choices.`);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
import { readFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import { spawn } from "node:child_process";
import { parse } from "dotenv";
const [environment = "development", command = "dev", ...args] =
process.argv.slice(2);
if (
!["development", "test", "production"].includes(environment) ||
!["dev", "build", "start"].includes(command)
) {
throw new Error(
"Usage: tsx scripts/next-env.ts development|test|production dev|build|start",
);
}
const config = (file: string) =>
existsSync(file) ? parse(readFileSync(file)) : {};
const childEnv: NodeJS.ProcessEnv = {
...config(resolve(`config/${environment}.env`)),
...config(resolve(`config/${environment}.local.env`)),
...process.env,
APP_ENV: environment,
NODE_ENV: command === "dev" ? "development" : "production",
};
if (command !== "build" && !childEnv.UPSTREAM_API_URL?.trim()) {
throw new Error(
`UPSTREAM_API_URL is required for ${environment}. Set config/${environment}.local.env or the process environment.`,
);
}
// Turbopack 16.4 can repeatedly restart the root runtime chunk on this site's
// dynamic document routes. Keep stable development HMR and allow explicit opt-in.
if (command === "dev" || command === "build") await import("./build-pwa");
const devBundler =
command === "dev" &&
!args.some((arg) => ["--webpack", "--turbopack", "--turbo"].includes(arg))
? [childEnv.NEXT_DEV_BUNDLER === "turbopack" ? "--turbopack" : "--webpack"]
: [];
const child = spawn(
process.execPath,
[
resolve("node_modules/next/dist/bin/next"),
command,
...devBundler,
...(command === "build" ? [] : ["--port", childEnv.PORT || "25121"]),
...args,
],
{ stdio: "inherit", env: childEnv },
);
for (const signal of ["SIGINT", "SIGTERM"] as const)
process.on(signal, () => child.kill(signal));
child.on("exit", (code) => process.exit(code ?? 1));
+97
View File
@@ -0,0 +1,97 @@
import { cp, lstat, mkdir, readFile, readdir, realpath, rm, unlink, writeFile } from 'node:fs/promises';
import { dirname, isAbsolute, relative, resolve } from 'node:path';
import { parse } from 'dotenv';
const environment = process.argv[2] || 'production';
if (!['development', 'test', 'production'].includes(environment)) throw new Error('Unknown environment');
const buildDirectory = environment === 'production' ? '.next' : environment === 'test' ? '.next-test' : '.next-dev';
const destination = resolve('dist', environment);
const readConfig = async (file: string) => parse(await readFile(file).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return Buffer.alloc(0);
throw error;
}));
const config = { ...await readConfig(resolve('config', environment + '.env')), ...await readConfig(resolve('config', environment + '.local.env')), ...process.env };
const sitemapDirectory = resolve(config.SITEMAP_DIR?.trim() || './template/pages/sitemaps');
const inside = (base: string, target: string) => {
const suffix = relative(base, target);
return suffix === '' || (suffix !== '..' && !suffix.startsWith('../') && !isAbsolute(suffix));
};
// dist is replaceable build output. Never let packaging remove a backend writer's directory.
const canonicalSitemapDirectory = await realpath(sitemapDirectory).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return sitemapDirectory;
throw error;
});
if (inside(destination, sitemapDirectory) || inside(destination, canonicalSitemapDirectory))
throw new Error('SITEMAP_DIR must live outside the replaceable standalone package directory');
for (const directory of [resolve('dist'), destination]) {
const info = await lstat(directory).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return null;
throw error;
});
if (info && (!info.isDirectory() || info.isSymbolicLink() || await realpath(directory) !== directory))
throw new Error('Standalone output must be an ordinary build directory');
}
await readFile(resolve(buildDirectory, 'BUILD_ID'));
await rm(destination, { recursive: true, force: true });
await mkdir(destination, { recursive: true });
await cp(resolve(buildDirectory, 'standalone'), destination, { recursive: true });
// Next 16.4 may copy Node Proxy's trace without applying route tracing exclusions.
// Remove generated XML only from this copied release, never through a backend mount.
const copiedSitemaps: string[] = [];
const ordinaryDirectory = async (suffix: string) => {
let directory = destination;
for (const segment of ['', ...suffix.split(/[\\/]/).filter(Boolean)]) {
directory = resolve(directory, segment);
const info = await lstat(directory).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return null;
throw error;
});
if (!info) return false;
if (!info.isDirectory() || info.isSymbolicLink() || await realpath(directory) !== directory)
throw new Error('Sitemap cleanup requires ordinary directories inside the standalone package');
}
return true;
};
const stageRemoval = async (file: string) => {
const info = await lstat(file).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return null;
throw error;
});
if (!info) return;
if (!info.isFile() && !info.isSymbolicLink())
throw new Error('Packaged sitemap XML must be a file, not a directory or special device');
copiedSitemaps.push(file);
};
for (const suffix of ['template/pages/sitemaps', 'data/sitemaps']) {
if (!await ordinaryDirectory(suffix)) continue;
const directory = resolve(destination, suffix);
for (const name of await readdir(directory)) {
if (name === 'sitemap.xml' || /^sitemap-[a-z0-9-]+\.xml$/.test(name))
await stageRemoval(resolve(directory, name));
}
}
const fallback = resolve(destination, 'template/pages/sitemap.xml');
if (await ordinaryDirectory(relative(destination, dirname(fallback)))) await stageRemoval(fallback);
// unlink does not follow file symlinks and cannot recursively remove a directory.
for (const file of copiedSitemaps) await unlink(file);
await cp(resolve('public'), resolve(destination, 'public'), { recursive: true });
await cp(resolve(buildDirectory, 'static'), resolve(destination, buildDirectory, 'static'), { recursive: true });
await mkdir(resolve(destination, 'config'), { recursive: true });
await cp(resolve('config', environment + '.env'), resolve(destination, 'config', environment + '.env'));
await cp(resolve('node_modules/dotenv'), resolve(destination, 'node_modules/dotenv'), { recursive: true });
await writeFile(resolve(destination, 'start.mjs'), `import { readFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse } from 'dotenv';
process.chdir(dirname(fileURLToPath(import.meta.url)));
// Next checks the deprecated root static/ before public/static/. An old Go
// deployment in this directory would silently override the bundled assets.
if (existsSync(resolve('static'))) throw new Error('Standalone must run in an isolated directory without a legacy static/ folder; it overrides public/static/.');
const environment = ${JSON.stringify(environment)};
const read = file => existsSync(file) ? parse(readFileSync(file)) : {};
const config = { ...read(resolve('config', environment + '.env')), ...read(resolve('config', environment + '.local.env')), ...process.env, APP_ENV: environment, NODE_ENV: 'production' };
if (!config.UPSTREAM_API_URL?.trim()) throw new Error('UPSTREAM_API_URL is required');
Object.assign(process.env, config);
await import('./server.js');
`);
console.log(`Standalone package ready: ${destination}; run node start.mjs inside it.`);
+96
View File
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import { build } from 'esbuild';
import { chromium } from '@playwright/test';
import { fileURLToPath } from 'node:url';
import { existsSync } from 'node:fs';
import { presentations } from '../src/server/react/presentation';
import { createStyleFixture } from './style-fixtures';
// Actual React layout effects and the shared scroll-lock hook run in an isolated
// browser. Rerenders/remounts model incoming page payloads without reloading <html>.
const root = fileURLToPath(new URL('..', import.meta.url));
const bundle = await build({
stdin: {
resolveDir: root,
loader: 'tsx',
contents: `
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import {PagePresentation} from '@/components/layout/PagePresentation';
import {useScrollLock} from '@/components/ui/Dialog';
function Lock({active}) {useScrollLock(active);return null;}
const root=createRoot(document.getElementById('root'));
let view='about',locked=false,key='shared-page';
function render(){root.render(<StrictMode><PagePresentation key={key} view={view}/><Lock active={locked}/><main id="page-style-probe" className="hk-drama-browse-page hk-site-navigation-page"/></StrictMode>);}
window.__pagePresentation=(next,remount=false)=>{view=next;key=remount?next:'shared-page';render();};
window.__pageLock=(active)=>{locked=active;render();};
render();
`,
},
bundle: true,
platform: 'browser',
format: 'iife',
write: false,
define: { 'process.env.NODE_ENV': '"development"' },
tsconfig: fileURLToPath(new URL('../tsconfig.json', import.meta.url)),
});
const styles = await createStyleFixture(['base', 'list', 'nav']);
const initial = presentations.about!;
const chrome = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const browser = await chromium.launch({ headless: true, ...(existsSync(chrome) ? { executablePath: chrome } : {}) });
try {
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
const errors: string[] = [];
page.on('pageerror', error => errors.push(error.message));
await page.route('**/*', route => route.abort());
await page.setContent(`<!doctype html><html class="hk-pwa-standalone hk-age-gate-ok" data-hk-page-styles="${initial.styleTokens.join(' ')}" data-presentation-unrelated="keep"><head><style>${styles.css}</style></head><body class="${initial.bodyClass} hk-is-managing hk-drawer-open external-plugin-state" style="overflow:auto"><div id="root"></div></body></html>`);
const documentIdentity = await page.evaluate(() => {
const token = Math.random().toString(36);
(window as unknown as { __documentIdentity: string }).__documentIdentity = token;
return token;
});
await page.addScriptTag({ content: bundle.outputFiles[0]!.text });
const managed = [...new Set(Object.values(presentations).flatMap(({ bodyClass }) => bodyClass.split(/\s+/).filter(Boolean)))];
let mobile = false;
const verify = async (view: string, remount = false) => {
const expected = presentations[view] || presentations['404']!;
await page.evaluate(({ view, remount }) => {
(window as unknown as { __pagePresentation(view: string, remount: boolean): void }).__pagePresentation(view, remount);
}, { view, remount });
await page.waitForFunction(({ tokens, classes, managed }) => {
const active = new Set(classes.split(/\s+/).filter(Boolean));
return document.documentElement.getAttribute('data-hk-page-styles') === tokens &&
managed.every(name => document.body.classList.contains(name) === active.has(name));
}, { tokens: expected.styleTokens.join(' '), classes: expected.bodyClass, managed });
const state = await page.evaluate(() => ({
identity: (window as unknown as { __documentIdentity: string }).__documentIdentity,
htmlClasses: [...document.documentElement.classList],
other: document.documentElement.getAttribute('data-presentation-unrelated'),
classes: [...document.body.classList],
overflow: document.body.style.overflow,
probePadding: getComputedStyle(document.getElementById('page-style-probe')!).paddingTop,
}));
assert.equal(state.identity, documentIdentity, 'presentation updates must not reload the document');
assert.ok(state.htmlClasses.includes('hk-pwa-standalone'));
assert.ok(state.htmlClasses.includes('hk-age-gate-ok'));
assert.equal(state.other, 'keep');
for (const name of ['hk-is-managing', 'hk-drawer-open', 'external-plugin-state']) assert.ok(state.classes.includes(name), view + ' erased ' + name);
assert.equal(state.overflow, 'hidden', view + ' released a live scroll lock');
if (view === 'nav') assert.equal(state.probePadding, mobile ? '64px' : '132px');
if (view === 'list') assert.equal(state.probePadding, mobile ? '56px' : '72px');
};
await page.evaluate(() => (window as unknown as { __pageLock(active: boolean): void }).__pageLock(true));
await page.waitForFunction(() => document.body.style.overflow === 'hidden');
for (const removed of ['profile', 'setting', 'history', 'favorites', 'purchased', 'recharge', 'login', 'register']) assert.equal(presentations[removed], undefined, 'removed account view still has a presentation: ' + removed);
for (const view of Object.keys(presentations)) await verify(view);
for (const view of ['list', 'nav', 'help', 'privacy', 'about', '__missing-view', 'list']) await verify(view, true);
mobile = true;
await page.setViewportSize({ width: 375, height: 812 });
for (const view of ['nav', 'list', 'about']) await verify(view);
await page.evaluate(() => (window as unknown as { __pageLock(active: boolean): void }).__pageLock(false));
await page.waitForFunction(() => document.body.style.overflow === 'auto');
assert.deepEqual(errors, []);
console.log(`Page presentation browser regression passed: ${Object.keys(presentations).length} views, remount/back transitions, real scoped CSS, PWA/temporary class preservation and shared scroll-lock restoration.`);
} finally {
await browser.close();
}
+685
View File
@@ -0,0 +1,685 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, writeFile, mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, basename } from "node:path";
import { spawnSync } from "node:child_process";
import { createServer } from "node:http";
import { randomBytes } from "node:crypto";
import { build } from "esbuild";
import { chromium } from "@playwright/test";
import { decryptImageBytes } from "../src/lib/media/images";
import { createStyleFixture } from "./style-fixtures";
// Actual React components and official xgplayer/HLS, isolated from all production APIs.
const cwd = process.cwd(),
temp = await mkdtemp(join(tmpdir(), "huakui-playback-"));
const output = process.env.PLAYBACK_SMOKE_OUTPUT || "/tmp/hkdj-playback-smoke";
const playbackStyles = await createStyleFixture(["base", "downloadPop", "installGuide", "play", "playLinks"]);
await mkdir(output, { recursive: true });
const mp4 = join(temp, "fixture.mp4"),
key = randomBytes(16);
await writeFile(join(temp, "key.bin"), key);
await writeFile(
join(temp, "key.info"),
`/enkey?ticket=mock-key&keep=1\n${join(temp, "key.bin")}\n`,
);
function ffmpeg(args: string[]) {
const result = spawnSync(
"ffmpeg",
["-hide_banner", "-loglevel", "error", "-y", ...args],
{ encoding: "utf8" },
);
assert.equal(
result.status,
0,
result.stderr || "ffmpeg is required for real media playback validation",
);
}
ffmpeg([
"-f",
"lavfi",
"-i",
"testsrc2=size=320x180:rate=24",
"-f",
"lavfi",
"-i",
"sine=frequency=440:sample_rate=44100",
"-t",
"12",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-g",
"48",
"-c:a",
"aac",
"-movflags",
"+faststart",
mp4,
]);
ffmpeg([
"-i",
mp4,
"-c",
"copy",
"-hls_time",
"2",
"-hls_playlist_type",
"vod",
"-hls_key_info_file",
join(temp, "key.info"),
"-hls_segment_filename",
join(temp, "segment-%d.ts"),
join(temp, "signed.m3u8"),
]);
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jEtQAAAAASUVORK5CYII=",
"base64",
);
const encrypted = decryptImageBytes(
png.buffer.slice(
png.byteOffset,
png.byteOffset + png.byteLength,
) as ArrayBuffer,
);
const entry = join(temp, "fixture.tsx");
await writeFile(
entry,
`import {useState} from 'react';import {createRoot} from 'react-dom/client';import {SiteProvider} from '${cwd}/src/components/providers/SiteProvider';import {TrackerProvider} from '${cwd}/src/components/analytics/TrackerProvider';import {DramaWatchPage} from '${cwd}/src/features/playback/DramaWatchPage';import {SeriesWatchPage} from '${cwd}/src/features/playback/SeriesWatchPage';import {EncryptedImage} from '${cwd}/src/components/media/EncryptedImage';function Fixture(){const [current,setCurrent]=useState(window.fixtureData);window.changePlaybackFixture=setCurrent;return <TrackerProvider boot={{appId:'JHA-4001',encryptedConfig:'mock',uid:'mock_1001',pageKey:'play',pageName:'短剧播放页'}}><SiteProvider data={current} dialogs={false}>{current.series?<SeriesWatchPage key={current.fixtureKey} data={current}/>:<DramaWatchPage key={current.fixtureKey} data={current}/>}<EncryptedImage id="encrypted-image-test" src="/encrypted.png" eager style={{width:32,height:32}} onResolved={()=>window.imageResolved=(window.imageResolved||0)+1} onResolveError={()=>window.imageErrors=(window.imageErrors||0)+1}/><EncryptedImage id="failed-image-test" src="/missing-image" eager style={{width:32,height:32}} onResolved={()=>window.failedImageResolved=true} onResolveError={()=>window.failedImageError=true}/></SiteProvider></TrackerProvider>};createRoot(document.getElementById('root')).render(<Fixture/>);`,
);
const navStub = join(temp, "navigation.ts");
await writeFile(
navStub,
"export const useRouter=()=>({back:()=>history.back(),refresh:()=>{},push:url=>location.assign(url),replace:url=>location.replace(url)});export const usePathname=()=>location.pathname;export const useSearchParams=()=>new URLSearchParams(location.search);",
);
await build({
entryPoints: [entry],
bundle: true,
platform: "browser",
format: "iife",
outfile: join(temp, "app.js"),
tsconfig: join(cwd, "tsconfig.json"),
nodePaths: [join(cwd, "node_modules")],
define: { "process.env.NODE_ENV": '"production"' },
plugins: [
{
name: "next-router-test-context",
setup(builder) {
builder.onResolve({ filter: /^next\/navigation$/ }, () => ({
path: navStub,
}));
},
},
],
});
const requests: {
url: string;
method: string;
body?: Record<string, unknown>;
headers: Record<string, string | string[] | undefined>;
}[] = [];
let origin = "";
const allowedApis = new Set([
"/api/auth/session",
"/api/auth/guest",
"/api/playlet/guess-like/list",
"/api/playlet/chapter/watch/report",
]);
const unexpectedApis: string[] = [];
const baseData = () => ({
fixtureKey: "hls",
requestPath: "/drama/mock/101/",
isLoggedIn: true,
isRegistered: true,
userID: 1001,
userName: "测试用户",
avatar: "/static/brand/placeholder.svg",
appConfig: { appAddr: "https://download.example/real" },
canWatch: true,
mainURL: "/h5/m3u8/signed.m3u8?ticket=mock-play&keep=1",
previewURL: origin + "/fixture.mp4?preview=1",
hasPreview: true,
chapterID: 101,
episode: 1,
playlet: {
id: 10,
slug: "mock",
playName: "播放测试",
cover: "/static/brand/placeholder.svg",
horizontalCover: "/static/brand/placeholder.svg",
totalEpisode: 2,
updateStatus: 2,
countCollect: 4,
countLike: 3,
isLike: 2,
isCollect: 2,
playletChapterInfo: [
{ id: 101, currentEpisode: 1, canWatch: true },
{ id: 102, currentEpisode: 2, payType: 1, needUnlock: true },
],
},
trackerDrama: {
DramaID: 10,
DramaTitle: "播放测试",
VideoID: 101,
Episode: 1,
TotalEpisode: 2,
DramaDuration: 12,
DramaTypeID: "2",
DramaTypeName: "都市",
DramaTagKey: "3",
DramaTagName: "剧情",
},
hotList: [],
guessLike: [
{ id: 30, slug: "guess", playName: "猜你喜欢测试", cover: "/static/brand/placeholder.svg", totalEpisode: 3, updateStatus: 2 },
],
related: [],
advitise: { nineGridList: [], detailbannerList: [] },
});
const server = createServer(async (req, res) => {
try {
const url = new URL(req.url!, origin || "http://localhost");
let body = "";
for await (const part of req) body += part;
requests.push({
url: req.url!,
method: req.method!,
body: body ? JSON.parse(body) : undefined,
headers: req.headers,
});
if (url.pathname === playbackStyles.href) {
res.setHeader("content-type", "text/css;charset=utf-8");
res.end(playbackStyles.css);
return;
}
if (url.pathname === "/static/common/web-sdk-v1.2.0.js") {
res.setHeader("content-type", "application/javascript");
res.end(
'window.sdkEvents=[];window.WebSDK={init:options=>{window.sdkInit=options},track:new Proxy({},{get:(_,method)=>payload=>{window.sdkEvents.push({method,payload});return Promise.resolve({ok:true})}}),getCommonFields:()=>Promise.resolve({sid:"mock-session",device_id:"mock-device",device:"PC",device_fingerprint:"mock-fp",fp_version:"1"}),flush:()=>Promise.resolve(),setUid:()=>{}};',
);
return;
}
if (url.pathname.startsWith("/api/")) {
res.setHeader("content-type", "application/json");
let data: unknown = {};
if (url.pathname === "/api/auth/session")
data = { userId: 1001, isRegister: true, recharge: 12 };
else if (!allowedApis.has(url.pathname)) {
// Like/collect/comment/unlock/coin APIs were removed with the account system.
unexpectedApis.push(url.pathname);
res.statusCode = 500;
res.end(JSON.stringify({ code: 500, data: null }));
return;
}
res.end(JSON.stringify({ code: 200, data }));
return;
}
if (url.pathname === "/encrypted.png") {
res.setHeader("content-type", "application/octet-stream");
res.end(encrypted);
return;
}
if (url.pathname === "/missing-video.mp4") {
res.statusCode = 404;
res.end();
return;
}
if (url.pathname === "/missing-image") {
res.statusCode = 404;
res.end();
return;
}
if (url.pathname === "/enkey") {
res.setHeader("content-type", "application/octet-stream");
res.end(key);
return;
}
let file: string | undefined;
if (url.pathname.startsWith("/static/"))
file = join(cwd, "public", url.pathname);
else if (url.pathname.startsWith("/h5/m3u8/"))
file = join(temp, basename(url.pathname));
else if (["/app.js", "/app.css", "/fixture.mp4"].includes(url.pathname))
file = join(temp, basename(url.pathname));
if (file) {
const bytes = await readFile(file),
ext = file.split(".").pop()!;
res.setHeader(
"content-type",
(
{
js: "application/javascript",
css: "text/css",
svg: "image/svg+xml",
png: "image/png",
m3u8: "application/vnd.apple.mpegurl",
ts: "video/mp2t",
mp4: "video/mp4",
} as Record<string, string>
)[ext] || "application/octet-stream",
);
res.setHeader("accept-ranges", "bytes");
const range = /bytes=(\d+)-(\d*)/.exec(req.headers.range || "");
if (range) {
const start = Number(range[1]),
end = range[2] ? Number(range[2]) : bytes.length - 1;
res.statusCode = 206;
res.setHeader("content-range", `bytes ${start}-${end}/${bytes.length}`);
res.end(bytes.subarray(start, end + 1));
} else res.end(bytes);
return;
}
res.setHeader("content-type", "text/html;charset=utf-8");
res.end(
`<!DOCTYPE html><html data-hk-page-styles="${playbackStyles.tokens}"><head><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="/app.css"><link rel="stylesheet" href="${playbackStyles.href}"></head><body class="hk-page-layout-play"><div id="root"></div><script>window.fixtureData=` +
JSON.stringify(baseData()) +
'</script><script src="/app.js"></script></body></html>',
);
} catch (error) {
res.statusCode = 404;
res.end(String(error));
}
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
assert(address && typeof address !== "string");
origin = `http://127.0.0.1:${address.port}`;
const browser = await chromium.launch({
executablePath:
process.env.CHROME_PATH ||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
headless: true,
});
const results = [];
try {
for (const [device, viewport] of [
["desktop", { width: 1440, height: 900 }],
["mobile", { width: 375, height: 812 }],
] as const) {
const context = await browser.newContext({
viewport,
isMobile: device === "mobile",
...(device === "mobile"
? {
userAgent:
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36 Quark/7.0",
}
: {}),
});
await context.route("**/*", (route) =>
new URL(route.request().url()).origin === origin
? route.continue()
: route.abort(),
);
const page = await context.newPage(),
errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(origin, { waitUntil: "networkidle" });
await page.waitForFunction(() => !!document.querySelector("video"));
const video = page.locator("video");
await page.locator(".hk-playback-player__center").click();
await page.waitForFunction(
() => {
const video = document.querySelector("video");
return !!video && !video.paused && video.currentTime > 0.2;
},
{ timeout: 15000 },
);
assert(
requests.some(
(req) => req.url === "/h5/m3u8/signed.m3u8?ticket=mock-play&keep=1",
),
);
assert(requests.some((req) => req.url === "/enkey?ticket=mock-key&keep=1"));
assert(
requests.some(
(req) =>
req.url === "/api/playlet/chapter/watch/report" &&
req.body?.chapterId === 101,
),
);
await page.locator(".xgplayer-fullscreen").click({ force: true });
await page.waitForFunction(
() =>
!!document.fullscreenElement ||
!!document.querySelector(".xgplayer-is-cssfullscreen"),
);
const full = await page.locator(".hk-video-player").boundingBox();
assert.equal(Math.round(full!.width), viewport.width);
assert.equal(Math.round(full!.height), viewport.height);
// The native fullscreen transition resolves after the CSS state is visible.
await page.waitForTimeout(200);
await page.locator(".xgplayer-fullscreen").click({ force: true });
await page.waitForFunction(
() =>
!document.fullscreenElement &&
!document.querySelector(".xgplayer-is-cssfullscreen"),
);
await video.evaluate((node) => (node as HTMLVideoElement).pause());
await page.waitForFunction(() => document.querySelector("video")!.paused);
await video.evaluate((node) => {
(node as HTMLVideoElement).currentTime = 7;
});
await page.waitForFunction(
() => document.querySelector("video")!.currentTime >= 6.9,
);
await video.evaluate((node) => {
const video = node as HTMLVideoElement;
video.playbackRate = 1.5;
return video.play();
});
await page.waitForTimeout(650);
await video.evaluate((node) => (node as HTMLVideoElement).pause());
await video.evaluate((node) => {
const video = node as HTMLVideoElement;
video.currentTime = 11.8;
return video.play();
});
await page.waitForFunction(() => document.querySelector("video")!.ended);
const events = await page.evaluate(
() =>
(
window as unknown as {
sdkEvents: { method: string; payload: Record<string, unknown> }[];
}
).sdkEvents,
);
for (const behavior of [
"ep_view",
"ep_play",
"ep_pause",
"ep_forward",
"ep_complete",
])
assert(
events.some(
(event) =>
event.method === "dramaEvent" &&
event.payload.play_behavior_key === behavior,
),
behavior,
);
assert(
events.some(
(event) =>
event.method === "dramaEvent" && event.payload.play_speed === 150,
),
);
assert.equal(
await page
.locator("#failed-image-test")
.getAttribute("data-decrypt-failed"),
"1",
);
assert(
(
await page.locator("#encrypted-image-test").getAttribute("src")
)?.startsWith("blob:"),
);
assert.equal(
await page.evaluate(
() => (window as unknown as { imageResolved: number }).imageResolved,
),
1,
);
assert.equal(
await page.evaluate(
() =>
(window as unknown as { failedImageResolved?: boolean })
.failedImageResolved,
),
undefined,
);
assert.equal(
await page.evaluate(
() =>
(window as unknown as { failedImageError?: boolean })
.failedImageError,
),
true,
);
const beforePreview = events.filter(
(event) =>
event.method === "dramaEvent" &&
event.payload.play_behavior_key === "ep_play",
).length;
await page.getByRole("button", { name: "预告片", exact: true }).click();
await page.waitForFunction(() => {
const video = document.querySelector("video");
return !!video && !video.paused && video.currentTime > 0.1;
});
await page.waitForTimeout(300);
const afterPreview = await page.evaluate(
() =>
(
window as unknown as {
sdkEvents: { method: string; payload: Record<string, unknown> }[];
}
).sdkEvents.filter(
(event) =>
event.method === "dramaEvent" &&
event.payload.play_behavior_key === "ep_play",
).length,
);
assert.equal(afterPreview, beforePreview);
assert.equal(
await page.locator(".hk-playback-player").getAttribute("data-play-mode"),
"preview",
);
await page.getByRole("button", { name: "正片", exact: true }).click();
await page.waitForFunction(
() => document.querySelector("video")?.currentTime === 0,
);
await page.locator(".hk-playback-player__center").click();
await page.waitForFunction(() => !document.querySelector("video")!.paused);
// Like/collect actions and comments were removed; recommendations take the comment slot.
for (const name of ["点赞", "取消点赞", "收藏", "取消收藏", "发布"])
assert.equal(
await page.getByRole("button", { name, exact: true }).count(),
0,
name + " action must be removed",
);
assert.equal(await page.locator(".hk-playback-comments, .hk-playback-composer").count(), 0);
assert.deepEqual(
await page.locator(".hk-playback-dual > *").evaluateAll((nodes) => nodes.map((node) => node.className)),
["hk-playback-like", "hk-playback-hot"],
);
assert.match(
(await page.locator(".hk-playback-like").textContent()) || "",
/猜你喜欢测试/,
);
const watchReq = requests.find(
(req) => req.url === "/api/playlet/chapter/watch/report",
);
assert.equal(
watchReq?.headers["x-sdk-common"],
Buffer.from(
JSON.stringify({
sid: "mock-session",
device_id: "mock-device",
device: "PC",
device_fingerprint: "mock-fp",
fp_version: "1",
}),
).toString("base64"),
);
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({ path: join(output, device + "-player.png") });
const paid = {
...baseData(),
fixtureKey: "locked",
canWatch: false,
mainURL: "/h5/m3u8/paid-secret.m3u8?ticket=never-fetch",
hasPreview: true,
previewURL: origin + "/fixture.mp4?preview=locked",
};
await page.evaluate(
(data) =>
(
window as unknown as {
changePlaybackFixture: (data: unknown) => void;
}
).changePlaybackFixture(data),
paid,
);
await page.waitForFunction(() =>
document.querySelector('[data-play-mode="preview"]'),
);
await page.getByRole("button", { name: "正片", exact: true }).click();
assert.equal(await page.locator("video").count(), 0);
assert.equal(
await page.locator(".hk-playback-player__unlock-title").textContent(),
"本集请前往 App 观看",
);
// The locked overlay offers only the app download; no coin unlock or recharge.
assert.equal(
await page.locator(".hk-playback-player__unlock-actions > *").count(),
1,
);
assert.match(
(await page.locator(".hk-playback-player__unlock-btn").textContent()) || "",
/前往 App 观看/,
);
assert.equal(
await page.locator(".hk-playback-player__unlock").getByRole("button", { name: "去充值" }).count(),
0,
);
assert(!requests.some((req) => req.url.includes("paid-secret")));
assert.match(
(await page.locator(".hk-playback-actions__btn--play").textContent()) || "",
/去 App 看/,
);
// The play CTA opens the App-exclusive sheet; its download button and the
// overlay button both lead to the shared app download dialog.
await page.locator(".hk-playback-actions__btn--play").click();
const sheet = page.locator(".hk-playback-coin");
await sheet.waitFor({ state: "visible" });
assert.equal(await sheet.locator(".hk-playback-coin__title").textContent(), "本集请前往 App 观看");
await sheet.locator(".hk-playback-coin__skip").click();
await sheet.waitFor({ state: "detached" });
for (const entrance of [
".hk-playback-actions__btn--play",
".hk-playback-player__unlock-btn",
]) {
await page.locator(entrance).click();
if (entrance === ".hk-playback-actions__btn--play")
await page.locator(".hk-playback-coin__pay").click();
// Desktop shows the download dialog; mobile shows the installation guide.
const dialog = device === "desktop" ? "[data-download-pop]" : "[data-install-guide]";
await page.locator(dialog).waitFor({ state: "visible" });
assert.equal(await page.locator(".hk-playback-coin").count(), 0);
if (device === "desktop")
assert.equal(
await page.locator(".hk-app-download-dialog__download").getAttribute("href"),
"https://download.example/real",
);
assert.equal(
await page.evaluate(() => document.body.style.overflow),
"hidden",
);
if (entrance === ".hk-playback-player__unlock-btn")
await page.screenshot({
path: join(output, device + "-locked-download.png"),
});
await page.keyboard.press("Escape");
await page.waitForSelector(dialog, { state: "detached" });
assert.notEqual(
await page.evaluate(() => document.body.style.overflow),
"hidden",
);
}
assert.equal(await page.locator("[data-recharge-modal]").count(), 0);
assert.equal(await page.locator("video").count(), 0);
const failed = {
...baseData(),
fixtureKey: "retry",
mainURL: origin + "/missing-video.mp4",
hasPreview: false,
previewURL: "",
};
await page.evaluate(
(data) =>
(
window as unknown as {
changePlaybackFixture: (data: unknown) => void;
}
).changePlaybackFixture(data),
failed,
);
await page
.getByRole("button", { name: "重新加载" })
.waitFor({ state: "visible" });
const failures = requests.filter(
(req) => req.url === "/missing-video.mp4",
).length;
await page.getByRole("button", { name: "重新加载" }).click();
await page.waitForTimeout(500);
assert(
requests.filter((req) => req.url === "/missing-video.mp4").length >
failures,
);
const series = {
...baseData(),
fixtureKey: "series",
series: true,
canWatch: true,
videoID: 201,
videoURL: origin + "/fixture.mp4?main=series",
video: {
id: 201,
title: "影集测试",
coverImg: "/static/brand/placeholder.svg",
video: { url: origin + "/fixture.mp4", videoTime: 12 },
},
};
await page.evaluate(
(data) =>
(
window as unknown as {
changePlaybackFixture: (data: unknown) => void;
}
).changePlaybackFixture(data),
series,
);
await page.waitForFunction(
() => document.querySelector("h1")?.textContent === "影集测试",
);
assert.equal(await page.locator("video").count(), 1);
await page.locator(".hk-playback-player__center").click();
await page.waitForFunction(
() => document.querySelector("video")!.currentTime > 0.2,
);
assert(requests.some((req) => req.url === "/fixture.mp4?main=series"));
assert.deepEqual(errors, []);
assert.deepEqual(unexpectedApis, []);
results.push({
device,
officialXgplayer: true,
aes128Hls: true,
signedQueriesPreserved: true,
playPauseSeek: true,
previewExcluded: true,
paidNoFetch: true,
chapterDestroy: true,
fullscreen: true,
errorRetry: true,
realMp4: true,
recommendationsAndTracking: true,
encryptedImageAndFallback: true,
lockedAppDownloadOnly: true,
});
await context.close();
}
await writeFile(
join(output, "results.json"),
JSON.stringify(results, null, 2),
);
console.log(JSON.stringify({ output, results }, null, 2));
} finally {
await browser.close();
await new Promise<void>((resolve) => server.close(() => resolve()));
await rm(temp, { recursive: true, force: true });
}
+141
View File
@@ -0,0 +1,141 @@
import { cp, lstat, mkdtemp, open, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises';
import { basename, dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
const [arch = 'x64', libc = 'glibc', ...extra] = process.argv.slice(2);
if (!['x64', 'arm64'].includes(arch) || !['glibc', 'musl'].includes(libc) || extra.length) {
throw new Error('Usage: tsx scripts/prepare-production-runtime.ts [x64|arm64] [glibc|musl]');
}
const root = await realpath(resolve(dirname(fileURLToPath(import.meta.url)), '..'));
const dist = join(root, 'dist');
const source = join(dist, 'production');
const target = join(dist, `production-linux-${arch}-${libc}`);
if (dirname(target) !== dist || !/^production-linux-(x64|arm64)-(glibc|musl)$/.test(basename(target))) {
throw new Error('Runtime destination must be a fixed repository dist directory');
}
for (const directory of [dist, source]) {
const info = await lstat(directory);
if (info.isSymbolicLink() || !info.isDirectory() || await realpath(directory) !== directory) {
throw new Error(`Unsafe build directory: ${directory}`);
}
}
const oldTarget = await lstat(target).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
return null;
});
if (oldTarget?.isSymbolicLink()) throw new Error('Runtime destination cannot be a symbolic link');
async function requireFile(base: string, name: string) {
const path = join(base, name);
const info = await lstat(path);
if (!info.isFile() || info.size === 0 || !(await realpath(path)).startsWith(base + '/')) {
throw new Error(`Missing, empty or unsafe runtime file: ${name}`);
}
}
const required = ['start.mjs', 'server.js', '.next/BUILD_ID', 'public/sw.js',
'config/production.env', 'config/secrets/m3u8.key'];
for (const file of required) await requireFile(source, file);
for (const directory of ['.next/static', 'public']) {
if (!(await lstat(join(source, directory))).isDirectory()) throw new Error(`Missing ${directory}`);
}
for (const file of ['package.json', 'package-lock.json']) await requireFile(root, file);
const originalLock = JSON.parse(await readFile(join(root, 'package-lock.json'), 'utf8'));
for (const file of await matchingFiles(join(source, 'node_modules'), /^package\.json$/)) {
const locked = originalLock.packages[relative(source, dirname(file))];
if (locked && JSON.parse(await readFile(file, 'utf8')).version !== locked.version) {
throw new Error(`Traced dependency does not match the root lockfile: ${file}`);
}
}
const npmVersion = spawnSync('npm', ['--version'], { cwd: root, encoding: 'utf8' });
if (npmVersion.error || npmVersion.status !== 0) throw new Error('Cannot read the current npm version');
const version = npmVersion.stdout.trim();
const match = /^(\d+)\.(\d+)\.(\d+)(?:$|[-+])/.exec(version);
const [major = 0, minor = 0, patch = 0] = match ? match.slice(1).map(Number) : [];
if (major < 10 || major === 10 && (minor < 9 || minor === 9 && patch < 2)) {
throw new Error(`npm >=10.9.2 is required for cross-platform installation; found ${version}`);
}
// Only this validated, fixed dist destination can be removed; source and host dependencies stay intact.
const sharpPlatform = `${libc === 'musl' ? 'linuxmusl' : 'linux'}-${arch}`;
const packages: [string, RegExp][] = [
[`@img/sharp-${sharpPlatform}`, /\.node$/],
[`@img/sharp-libvips-${sharpPlatform}`, /libvips.*\.so(?:\.\d+)*$/],
];
await rm(target, { recursive: true, force: true });
await cp(source, target, {
recursive: true,
filter: async path => {
const name = basename(path);
if (name.startsWith('.env') || name.endsWith('.local.env') ||
name.startsWith('._') || name === '.DS_Store' || name.endsWith('.log')) return false;
const scope = basename(dirname(path));
if (scope === '@next' && name.startsWith('swc-')) return false;
if (scope === '@img' && name.startsWith('sharp-') && name !== 'sharp-wasm32' &&
!packages.some(([packageName]) => packageName === `@img/${name}`)) return false;
if ((await lstat(path)).isSymbolicLink()) throw new Error(`Runtime source contains a symbolic link: ${path}`);
return true;
},
});
for (const file of ['package.json', 'package-lock.json']) await cp(join(root, file), join(target, file));
// Preserve Next's traced JS tree. Install only the two platform packages, never the full app dependency graph.
const nativeDir = await mkdtemp(join(dist, '.production-native-'));
try {
const optionalDependencies: Record<string, string> = {}, records: Record<string, unknown> = {};
for (const [name] of packages) {
const entry = originalLock.packages[`node_modules/${name}`];
if (!entry?.version || !entry.resolved || !entry.integrity) throw new Error(`Missing locked native package: ${name}`);
optionalDependencies[name] = entry.version;
const record = { ...entry, optional: true };
delete record.dev;
delete record.devOptional;
records[`node_modules/${name}`] = record;
}
const manifest = { name: 'hkdj-native-runtime', version: '1.0.0', private: true, optionalDependencies };
await writeFile(join(nativeDir, 'package.json'), JSON.stringify(manifest));
await writeFile(join(nativeDir, 'package-lock.json'), JSON.stringify({ name: manifest.name,
version: manifest.version, lockfileVersion: 3, requires: true, packages: { '': manifest, ...records } }));
console.log(`Installing two locked Linux ${arch}/${libc} native packages with npm ${version}`);
const install = spawnSync('npm', ['ci', '--omit=dev', '--include=optional', '--ignore-scripts',
'--no-audit', '--no-fund', '--os=linux', `--cpu=${arch}`, `--libc=${libc}`], { cwd: nativeDir, stdio: 'inherit' });
if (install.error || install.status !== 0) throw new Error(`Native npm ci failed (${install.status ?? install.error?.message})`);
for (const [name, pattern] of packages) {
const directory = join(nativeDir, 'node_modules', name);
const metadata = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8'));
if (metadata.name !== name || metadata.version !== optionalDependencies[name] ||
!metadata.os?.includes('linux') || !metadata.cpu?.includes(arch)) throw new Error(`Incorrect native package: ${name}`);
const binaries = await matchingFiles(directory, pattern);
if (!binaries.length) throw new Error(`Native runtime binary is missing: ${name}`);
for (const binary of binaries) await verifyELF(binary);
await cp(directory, join(target, 'node_modules', name), { recursive: true });
}
} finally { await rm(nativeDir, { recursive: true, force: true }); }
for (const file of [...required, 'node_modules/dotenv/package.json', 'node_modules/sharp/package.json',
'node_modules/detect-libc/package.json', 'node_modules/semver/package.json', 'node_modules/@img/colour/package.json']) {
await requireFile(target, file);
}
const runtimeRequire = createRequire(join(target, 'package.json'));
if (typeof runtimeRequire('dotenv').parse !== 'function') throw new Error('Runtime dotenv is unavailable');
async function matchingFiles(directory: string, pattern: RegExp): Promise<string[]> {
const found: string[] = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
if (entry.isDirectory()) found.push(...await matchingFiles(path, pattern));
else if (entry.isFile() && pattern.test(entry.name)) found.push(path);
}
return found;
}
async function verifyELF(path: string) {
const file = await open(path, 'r');
const header = Buffer.alloc(64);
try { await file.read(header, 0, header.length, 0); } finally { await file.close(); }
if (!header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) ||
header[4] !== 2 || header[5] !== 1 || header.readUInt16LE(18) !== (arch === 'x64' ? 62 : 183)) {
throw new Error(`Native runtime is not a Linux ${arch} ELF binary: ${path}`);
}
}
// These public target details describe the package; environment values and credentials are never copied here.
await writeFile(join(target, 'runtime-target.json'), JSON.stringify({ platform: 'linux', arch, libc, packaging: 'standalone' }, null, 2) + '\n');
console.log(`Linux runtime ready: ${target}`);
+340
View File
@@ -0,0 +1,340 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createServer, type Server } from "node:http";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve, extname } from "node:path";
import { build } from "esbuild";
import { chromium, type Browser, type Page } from "@playwright/test";
import manifest from "../src/app/manifest";
import { createStyleFixture } from "./style-fixtures";
interface FixtureWindow extends Window {
setPwaMode(mode: "fullscreen" | "standalone", active: boolean): void;
setIosStandalone(active: boolean): void;
setNativeFullscreen(active: boolean): void;
mountPwaProvider(active: boolean): void;
}
// Real React provider, shared dialog/tabbar and official player; no production API or CDN.
const cwd = process.cwd();
const temp = await mkdtemp(join(tmpdir(), "huakui-pwa-"));
const output = process.env.PWA_SMOKE_OUTPUT || "/tmp/hkdj-pwa-smoke";
let browser: Browser | undefined;
let server: Server | undefined;
let origin = "";
let networkReads = 0;
const requests: string[] = [];
const results: Record<string, unknown>[] = [];
async function waitForInstalled(page: Page, expected: boolean) {
await page.waitForFunction(
(active) => document.documentElement.classList.contains("hk-pwa-standalone") === active,
expected,
);
}
async function fullscreen(page: Page, css: boolean, viewport: { width: number; height: number }) {
await page.locator(".xgplayer-fullscreen").click({ force: true });
await page.waitForFunction(
(useCss) => useCss
? !!document.querySelector(".xgplayer-is-cssfullscreen")
: !!document.fullscreenElement,
css,
);
await page.waitForFunction(
({ width, height }) => {
const rect = document.querySelector(".hk-video-player")!.getBoundingClientRect();
return Math.abs(rect.left) < 1 && Math.abs(rect.top) < 1 &&
Math.abs(rect.width - width) < 1 && Math.abs(rect.height - height) < 1;
},
viewport,
);
const measurements = await page.locator(".hk-video-player").evaluate((element) => {
const style = getComputedStyle(element);
const controls = getComputedStyle(element.querySelector(".xgplayer-controls")!);
return { height: style.height, left: controls.left, right: controls.right, bottom: controls.bottom };
});
assert.equal(Math.round(parseFloat(measurements.height)), viewport.height, "Fullscreen uses the complete dynamic viewport");
if (css) {
assert.equal(parseFloat(measurements.left), 20);
assert.equal(parseFloat(measurements.right), 20);
assert.equal(parseFloat(measurements.bottom), 34);
}
// XGPlayer's native transition can settle after its fullscreen class appears.
await page.waitForTimeout(250);
return measurements;
}
async function exitFullscreen(page: Page) {
await page.locator(".xgplayer-fullscreen").click({ force: true });
await page.waitForFunction(() => !document.fullscreenElement && !document.querySelector(".xgplayer-is-cssfullscreen"));
await page.waitForTimeout(200);
assert.notEqual(await page.evaluate(() => getComputedStyle(document.body).overflowY), "hidden", "Fullscreen exit restores page scrolling");
await page.evaluate(() => window.scrollTo(0, 180));
await page.waitForFunction(() => window.scrollY >= 179);
}
try {
await mkdir(output, { recursive: true });
const generated = spawnSync("ffmpeg", [
"-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i",
"testsrc2=size=320x180:rate=24", "-t", "8", "-c:v", "libx264",
"-pix_fmt", "yuv420p", "-movflags", "+faststart", join(temp, "fixture.mp4"),
], { encoding: "utf8" });
assert.equal(generated.status, 0, generated.stderr || "ffmpeg is required for real player validation");
const entry = join(temp, "fixture.tsx");
await writeFile(entry, `
import {useState} from 'react';
import {createRoot} from 'react-dom/client';
import {PwaProvider} from '${cwd}/src/components/pwa/PwaProvider';
import {VideoPlayer} from '${cwd}/src/components/player/VideoPlayer';
import {MobileTabbar} from '${cwd}/src/components/layout/MobileTabbar';
import {Dialog} from '${cwd}/src/components/ui/Dialog';
function Fixture(){
const [provider,setProvider]=useState(true),[dialog,setDialog]=useState(false);
window.mountPwaProvider=setProvider;
return <>{provider&&<PwaProvider/>}
<header className="hk-site-header hk-is-scrolled" id="fixed-header"><strong>花魁短剧</strong><span>PWA</span></header>
<main className="hk-playback-page" id="flow"><div id="flow-probe"/>
<div className="fixture-content"><section className="hk-playback-player"><VideoPlayer url={location.origin+'/fixture.mp4'} /></section>
<button id="open-modal" onClick={()=>setDialog(true)}>打开对话框</button>
<div className="fixture-scroll">独立播放器与安全区回归</div>
</div>
</main>
<MobileTabbar data={{requestPath:'/'}}/>
{dialog&&<Dialog className="hk-age-gate hk-is-open" label="PWA 测试对话框" onClose={()=>setDialog(false)}>
<div className="hk-age-gate__mask"/><div className="hk-age-gate__dialog" id="modal-panel">
<button onClick={()=>setDialog(false)}>关闭</button>
<h2>安全区对话框</h2>{Array.from({length:32},(_,i)=><p key={i}>第{i+1}项:内容可以滚动,关闭按钮可达。</p>)}
</div>
</Dialog>}
</>;
}
createRoot(document.getElementById('root')).render(<Fixture/>);
`);
await build({
entryPoints: [entry], bundle: true, platform: "browser", format: "iife",
outfile: join(temp, "app.js"), tsconfig: join(cwd, "tsconfig.json"),
nodePaths: [join(cwd, "node_modules")], define: { "process.env.NODE_ENV": '"production"' },
});
const styles = ["base", "header", "play", "mobileTabbar", "eighteen", "pwa"];
const pwaStyles = await createStyleFixture(styles);
server = createServer(async (request, response) => {
try {
const url = new URL(request.url!, origin || "http://localhost");
requests.push(url.pathname);
if (url.pathname === pwaStyles.href) {
response.setHeader("Content-Type", "text/css;charset=utf-8");
response.end(pwaStyles.css);
return;
}
if (url.pathname === "/network-check") {
response.setHeader("Content-Type", "application/json");
response.setHeader("Cache-Control", "no-store");
response.end(JSON.stringify({ count: ++networkReads }));
return;
}
if (url.pathname === "/manifest.webmanifest") {
response.setHeader("Content-Type", "application/manifest+json");
response.end(JSON.stringify(manifest()));
return;
}
let file: string | undefined;
if (url.pathname === "/sw.js") {
file = join(cwd, "public/sw.js");
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
} else if (url.pathname.startsWith("/static/")) {
file = resolve(cwd, "public", "." + url.pathname);
assert(file.startsWith(resolve(cwd, "public") + "/"));
} else if (["/app.js", "/app.css", "/fixture.mp4"].includes(url.pathname)) {
file = join(temp, url.pathname.slice(1));
}
if (file) {
const bytes = await readFile(file);
response.setHeader("Content-Type", ({ ".js": "application/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png", ".mp4": "video/mp4" } as Record<string, string>)[extname(file)] || "application/octet-stream");
const range = /^bytes=(\d+)-(\d*)$/.exec(request.headers.range || "");
if (range) {
const start = Number(range[1]);
const end = Math.min(range[2] ? Number(range[2]) : bytes.length - 1, bytes.length - 1);
response.writeHead(206, { "Content-Range": `bytes ${start}-${end}/${bytes.length}`, "Accept-Ranges": "bytes" });
response.end(bytes.subarray(start, end + 1));
} else response.end(bytes);
return;
}
if (url.pathname !== "/") {
response.writeHead(404).end();
return;
}
response.setHeader("Content-Type", "text/html;charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.end(`<!doctype html><html lang="zh-CN" data-hk-page-styles="${pwaStyles.tokens}"><head><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><link rel="manifest" href="/manifest.webmanifest"><link rel="stylesheet" href="/app.css"><link rel="stylesheet" href="${pwaStyles.href}"><style>.fixture-content{width:min(100%,960px);margin:auto}.fixture-scroll{height:1500px;padding:24px}#open-modal{margin:16px;padding:12px}#flow-probe{height:0}.hk-age-gate__dialog p{margin:12px 0;flex-shrink:0}</style></head><body><div id="root"></div><script src="/app.js"></script></body></html>`);
} catch (error) {
response.writeHead(500).end(String(error));
}
});
await new Promise<void>(done => server!.listen(0, "127.0.0.1", done));
const address = server.address();
assert(address && typeof address !== "string");
origin = `http://127.0.0.1:${address.port}`;
browser = await chromium.launch({ executablePath: process.env.CHROME_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", headless: true });
for (const [name, viewport] of [
["portrait", { width: 390, height: 844 }],
["landscape", { width: 844, height: 390 }],
["desktop", { width: 1440, height: 900 }],
] as const) {
const context = await browser.newContext({ viewport });
await context.route("**/*", route => new URL(route.request().url()).origin === origin ? route.continue() : route.abort());
await context.addInitScript(() => {
// tsx preserves callback names with this helper; serialized Playwright callbacks need it too.
Object.defineProperty(window, "__name", { value: Function("value", "return value"), configurable: true });
const fixture = window as unknown as FixtureWindow;
const originalMatchMedia = window.matchMedia.bind(window);
const states = new Map<string, { matches: boolean; target: EventTarget; list: MediaQueryList }>();
window.matchMedia = (query) => {
if (!/^\(display-mode: (fullscreen|standalone)\)$/.test(query)) return originalMatchMedia(query);
let state = states.get(query);
if (!state) {
const target = new EventTarget();
const list = Object.assign(target, { media: query, onchange: null,
addListener(listener: EventListener) { target.addEventListener("change", listener); },
removeListener(listener: EventListener) { target.removeEventListener("change", listener); },
}) as unknown as MediaQueryList;
state = { matches: false, target, list };
const current = state;
Object.defineProperty(list, "matches", { get: () => current.matches });
states.set(query, state);
}
return state.list;
};
fixture.setPwaMode = (mode, active) => {
const query = `(display-mode: ${mode})`;
window.matchMedia(query);
const state = states.get(query)!;
state.matches = active;
state.target.dispatchEvent(Object.assign(new Event("change"), { matches: active, media: query }));
};
let ios = false;
Object.defineProperty(navigator, "standalone", { configurable: true, get: () => ios });
fixture.setIosStandalone = active => {
ios = active;
fixture.setPwaMode("fullscreen", states.get("(display-mode: fullscreen)")?.matches || false);
};
let native = document.fullscreenEnabled;
Object.defineProperty(document, "fullscreenEnabled", { configurable: true, get: () => native });
Object.defineProperty(document, "webkitFullscreenEnabled", { configurable: true, get: () => false });
fixture.setNativeFullscreen = active => { native = active; };
});
const page = await context.newPage();
const errors: string[] = [];
page.on("pageerror", error => errors.push(error.message));
await page.goto(origin, { waitUntil: "networkidle" });
await page.waitForFunction(() => !!navigator.serviceWorker.controller && !!document.querySelector("video")).catch(async error => {
console.error({ errors, requests, state: await page.evaluate(async () => ({ secure: window.isSecureContext, video: !!document.querySelector("video"), controller: navigator.serviceWorker.controller?.scriptURL, registrations: (await navigator.serviceWorker.getRegistrations()).map(registration => ({ scope: registration.scope, active: registration.active?.state, installing: registration.installing?.state })) })) });
throw error;
});
await waitForInstalled(page, false);
const initial = await page.evaluate(() => {
const style = getComputedStyle(document.body);
const header = document.querySelector("#fixed-header")!.getBoundingClientRect();
return { borders: [style.borderTopWidth, style.borderRightWidth, style.borderBottomWidth, style.borderLeftWidth], headerTop: header.top, headerHeight: header.height, flowTop: document.querySelector("#flow-probe")!.getBoundingClientRect().top };
});
assert.deepEqual(initial.borders, ["0px", "0px", "0px", "0px"], `${name}: normal browser keeps zero safe-area borders`);
assert.equal(initial.headerTop, 0);
const sw = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
const a = await (await fetch("/network-check")).json();
const b = await (await fetch("/network-check")).json();
return { scope: registration.scope, updateViaCache: registration.updateViaCache, caches: await caches.keys(), a: a.count, b: b.count, script: navigator.serviceWorker.controller!.scriptURL };
});
assert.equal(sw.scope, origin + "/");
assert.equal(sw.script, origin + "/sw.js");
assert.equal(sw.updateViaCache, "none");
assert.deepEqual(sw.caches, []);
assert.equal(sw.b, sw.a + 1, "Controlled requests continue reaching the network");
for (const mode of ["standalone", "fullscreen"] as const) {
await page.evaluate(mode => (window as unknown as FixtureWindow).setPwaMode(mode, true), mode);
await waitForInstalled(page, true);
await page.evaluate(mode => (window as unknown as FixtureWindow).setPwaMode(mode, false), mode);
await waitForInstalled(page, false);
}
await page.evaluate(() => (window as unknown as FixtureWindow).setIosStandalone(true));
await waitForInstalled(page, true);
await page.evaluate(() => {
for (const [side, value] of Object.entries({ top: 44, right: 20, bottom: 34, left: 20 })) document.documentElement.style.setProperty("--pwa-safe-" + side, value + "px");
});
const safe = await page.evaluate(() => {
const rect = (selector: string) => { const r = document.querySelector(selector)!.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, bottom: r.bottom }; };
const tab = document.querySelector(".hk-mobile-tabbar")!;
const body = getComputedStyle(document.body);
return { header: rect("#fixed-header"), flow: rect("#flow-probe"), tab: rect(".hk-mobile-tabbar"), tabDisplay: getComputedStyle(tab).display, tabPadding: getComputedStyle(tab).paddingBottom, bodyBottom: body.paddingBottom, bodyBorderBottom: body.borderBottomWidth, bodyBorders: [body.borderTopWidth, body.borderRightWidth, body.borderLeftWidth], scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth };
});
assert.equal(safe.header.y, 44);
assert.equal(safe.header.height, initial.headerHeight, `${name}: header measurement excludes the top inset`);
assert.equal(safe.flow.y, initial.flowTop + 44, `${name}: safe-area added once to normal flow`);
assert(safe.flow.y >= safe.header.bottom, `${name}: fixed header never covers the flow boundary`);
assert.deepEqual(safe.bodyBorders, ["44px", "20px", "20px"]);
assert(safe.scrollWidth <= safe.clientWidth, `${name}: no horizontal overflow`);
if (viewport.width <= 768) {
// The account tab was removed with the login system: only 首页/短剧/剧集 remain.
assert.deepEqual(
await page.locator(".hk-mobile-tabbar__item").allTextContents(),
["首页", "短剧", "剧集"],
);
assert.equal(safe.tab.height, 90);
assert.equal(safe.tabPadding, "34px");
assert.equal(safe.bodyBottom, "90px");
assert.equal(safe.bodyBorderBottom, "0px", "Visible TabBar owns bottom safety without another body border");
assert.equal(safe.tab.bottom, viewport.height);
} else {
assert.equal(safe.tabDisplay, "none");
assert.equal(safe.bodyBorderBottom, "34px", "Hidden TabBar leaves bottom safety to the body border");
}
await page.locator("#open-modal").click();
await page.waitForSelector("#modal-panel");
const modal = await page.locator("#modal-panel").evaluate(element => {
const r = element.getBoundingClientRect();
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, scroll: element.scrollHeight, client: element.clientHeight, overflow: getComputedStyle(element).overflowY };
});
assert(modal.top >= 44 && modal.bottom <= viewport.height - 34, `${name}: modal stays within top/bottom safe areas`);
assert(modal.left >= 20 && modal.right <= viewport.width - 20, `${name}: modal stays within side safe areas`);
assert(modal.scroll > modal.client && modal.overflow === "auto", `${name}: tall modal remains scrollable`);
assert.equal(await page.evaluate(() => document.body.style.overflow), "hidden");
await page.keyboard.press("Escape");
await page.waitForSelector("#modal-panel", { state: "detached" });
assert.notEqual(await page.evaluate(() => getComputedStyle(document.body).overflowY), "hidden");
await page.evaluate(() => window.scrollTo(0, 0));
if (name !== "desktop") await page.screenshot({ path: join(output, name + "-safe-area.png") });
await page.locator("video").evaluate(async element => {
const video = element as HTMLVideoElement;
video.muted = true;
await video.play();
});
await page.waitForFunction(() => document.querySelector("video")!.currentTime > 0.1);
// Installed iOS without the document fullscreen API must use the CSS fallback.
await page.evaluate(() => (window as unknown as FixtureWindow).setNativeFullscreen(false));
const css = await fullscreen(page, true, viewport);
if (name === "portrait") await page.screenshot({ path: join(output, "portrait-css-fullscreen.png") });
await exitFullscreen(page);
await page.evaluate(() => { window.scrollTo(0, 0); (window as unknown as FixtureWindow).setNativeFullscreen(true); });
const native = await fullscreen(page, false, viewport);
if (name === "desktop") await page.screenshot({ path: join(output, "desktop-native-fullscreen.png") });
await exitFullscreen(page);
await page.evaluate(() => { (window as unknown as FixtureWindow).setIosStandalone(false); for (const side of ["top", "right", "bottom", "left"]) document.documentElement.style.removeProperty("--pwa-safe-" + side); });
await waitForInstalled(page, false);
assert.deepEqual(errors, [], `${name}: no browser runtime errors`);
results.push({ name, viewport, worker: true, modeChanges: true, safe, modal, cssFullscreen: css, nativeFullscreen: native, scrollRestored: true });
await context.close();
console.log(`${name}: provider modes, network-only SW, safe areas, modal, CSS/native fullscreen and scroll restoration passed`);
}
assert(!requests.some(path => path.startsWith("/api/")), "The PWA fixture never calls a production API");
await writeFile(join(output, "results.json"), JSON.stringify(results, null, 2) + "\n");
console.log(`PWA browser regression passed. Results: ${output}`);
} finally {
await browser?.close();
server?.closeAllConnections();
if (server?.listening) await new Promise<void>(done => server!.close(() => done()));
await rm(temp, { recursive: true, force: true });
}
+64
View File
@@ -0,0 +1,64 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { fileURLToPath } from "node:url";
// Legacy fixture keys stay inside test infrastructure. Production imports semantic CSS paths.
const sourceStyles: Record<string, string> = {
"about-mobile": "src/styles/pages/about-responsive.css",
"about": "src/styles/pages/about.css",
"ads": "src/styles/components/advertising-compat.css",
"base": "src/styles/foundations/base.css",
"dialogAd": "src/styles/components/advertisement-dialog.css",
"downloadPop": "src/styles/components/app-download-dialog.css",
"eighteen": "src/styles/components/age-gate.css",
"feedback": "src/styles/pages/feedback.css",
"footer": "src/styles/components/site-footer.css",
"header": "src/styles/components/site-header.css",
"help": "src/styles/pages/help.css",
"index": "src/styles/pages/home.css",
"installGuide": "src/styles/components/installation-guide.css",
"legal": "src/styles/pages/legal.css",
"list": "src/styles/pages/drama-list.css",
"mobile-search": "src/styles/pages/mobile-search.css",
"mobileTabbar": "src/styles/components/mobile-tabbar.css",
"moreList": "src/styles/pages/expanded-list.css",
"nav": "src/styles/pages/site-navigation.css",
"notFound": "src/styles/pages/not-found.css",
"pagination": "src/styles/components/pagination.css",
"play": "src/styles/pages/playback.css",
"playLinks": "src/styles/components/playback-links.css",
"popularRecommend": "src/styles/pages/popular-recommendations.css",
"promo-grid": "src/styles/components/promotional-grid.css",
"promo": "src/styles/components/promotional-banner.css",
"pwa": "src/styles/overrides/pwa-safe-area.css",
"rank": "src/styles/pages/rankings.css",
"recentlyAdded": "src/styles/pages/recently-added.css",
"search": "src/styles/pages/search-results.css",
"series": "src/styles/pages/series-list.css",
"tags": "src/styles/pages/tag-detail.css",
"toast": "src/styles/components/toast.css",
"topic": "src/styles/pages/topics.css",
"topicDetail": "src/styles/pages/topic-detail.css",
"player": "src/styles/components/video-player.css"
};
export async function createStyleFixture(keys: readonly string[]) {
const root = fileURLToPath(new URL("..", import.meta.url));
const layout = await readFile(join(root, "src/app/layout.tsx"), "utf8");
const order = [...layout.matchAll(/^import ["']@\/([^"']+\.css)["'];/gm)].map((match) => "src/" + match[1]);
const files = [...new Set(keys.map((key) => {
const file = sourceStyles[key];
if (!file) throw new Error("Unknown fixture stylesheet: " + key);
return file;
}))].sort((first, second) => order.indexOf(first) - order.indexOf(second));
for (const file of files) {
if (!order.includes(file)) throw new Error("Fixture stylesheet is not imported by the root layout: " + file);
}
const css = (await Promise.all(files.map((file) => readFile(join(root, file), "utf8")))).join("\n");
return {
css,
href: "/fixture-" + createHash("sha256").update(css).digest("hex").slice(0, 12) + ".css",
tokens: files.map((file) => basename(file, ".css")).join(" "),
};
}
+93
View File
@@ -0,0 +1,93 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { dirname, resolve } from 'node:path';
import { brandAssets } from '../src/lib/brand-assets';
// User-provided design. Credentials are read only from the local environment.
const fileKey = '1vUq4wf5xOs8ZOlmt26Q3O';
const token = process.env.FIGMA_TOKEN;
const assets = [
{ id: '1:414', name: 'header', file: 'public/static/brand/header.svg', width: 104, height: 36 },
{ id: '1:706', name: 'footer', file: 'public/static/brand/footer.svg', width: 124, height: 44 },
{ id: '3:20211', name: 'mobile', file: 'public/static/brand/mobile.svg', width: 88, height: 28 },
{ id: '3:15722', name: 'symbol', file: 'public/static/favicon.svg', width: 34, height: 34 },
] as const;
const manifestFile = resolve('public/static/brand/manifest.json');
const pngAssets = [
{ id: '1:414', name: 'header-png', file: 'public/static/logo.png', width: 208, height: 72 },
{ id: '1:706', name: 'footer-png', file: 'public/static/logo2.png', width: 248, height: 88 },
{ id: '3:21847', name: 'login-symbol', file: 'public/static/logo_icon.png', width: 128, height: 128 },
{ id: '3:15722', name: 'favicon-png', file: 'public/static/favicon.png', width: 68, height: 68 },
{ id: '3:15722', name: 'favicon-ico', file: 'public/static/favicon.ico', width: 68, height: 68 },
];
const alias = (asset: typeof pngAssets[number], url: string) => ({
...asset, name: `${asset.name}-versioned`, source: asset.file, file: `public${url}`,
});
const versionedAssets = [
alias(pngAssets[0]!, brandAssets.headerPng),
alias(pngAssets[1]!, brandAssets.navigationLogo),
alias(pngAssets[2]!, brandAssets.authLogo),
alias(assets[3], brandAssets.iconSvg),
alias(pngAssets[3]!, brandAssets.iconPng),
alias(pngAssets[4]!, brandAssets.faviconIco),
];
const sha256 = (bytes: Buffer): string => createHash('sha256').update(bytes).digest('hex');
if (process.argv.includes('--check')) {
const manifest = JSON.parse(await readFile(manifestFile, 'utf8')) as { assets: { file: string; sha256: string; width: number; height: number }[] };
for (const asset of manifest.assets) {
const bytes = await readFile(resolve(asset.file));
if (sha256(bytes) !== asset.sha256) throw new Error(`Brand asset checksum mismatch: ${asset.file}`);
if (asset.file.endsWith('.png') && (bytes.readUInt32BE(16) !== asset.width || bytes.readUInt32BE(20) !== asset.height)) throw new Error(`PNG dimensions mismatch: ${asset.file}`);
}
for (const asset of versionedAssets) {
if (!manifest.assets.some(entry => entry.file === asset.file)) throw new Error(`Versioned brand asset missing from manifest: ${asset.file}`);
if (!(await readFile(resolve(asset.file))).equals(await readFile(resolve(asset.source)))) throw new Error(`Versioned brand asset differs from source: ${asset.file}`);
}
const ico = await readFile(resolve('public/static/favicon.ico'));
const png = await readFile(resolve('public/static/favicon.png'));
if (!ico.subarray(22).equals(png)) throw new Error('ICO payload differs from exported PNG');
console.log(`Checked ${manifest.assets.length} Figma brand assets offline.`);
process.exit(0);
}
if (!token) throw new Error('FIGMA_TOKEN is required');
async function render(ids: readonly string[], format: 'svg' | 'png', scale = 1): Promise<Record<string, string>> {
const query = new URLSearchParams({ ids: ids.join(','), format, scale: String(scale), svg_outline_text: 'true' });
const response = await fetch(`https://api.figma.com/v1/images/${fileKey}?${query}`, { headers: { 'X-Figma-Token': token! } });
if (!response.ok) throw new Error(`Figma render returned HTTP ${response.status}`);
const result = await response.json() as { images: Record<string, string | null> };
for (const id of ids) if (!result.images[id]) throw new Error(`Figma returned no image for ${id}`);
return result.images as Record<string, string>;
}
async function download(url: string, file: string): Promise<Buffer> {
const response = await fetch(url);
if (!response.ok) throw new Error(`Asset download returned HTTP ${response.status}`);
const bytes = Buffer.from(await response.arrayBuffer());
if (!bytes.length) throw new Error('Empty Figma asset');
await mkdir(dirname(resolve(file)), { recursive: true });
await writeFile(resolve(file), bytes);
return bytes;
}
const svg = await render(assets.map(asset => asset.id), 'svg');
for (const asset of assets) await download(svg[asset.id], asset.file);
const png = await render(['1:414', '1:706', '3:21847', '3:15722'], 'png', 2);
await download(png['1:414'], 'public/static/logo.png');
await download(png['1:706'], 'public/static/logo2.png');
await download(png['3:21847'], 'public/static/logo_icon.png');
const icon = await download(png['3:15722'], 'public/static/favicon.png');
// ICO supports an unmodified PNG payload; no redraw, recoloring or raster resampling.
const icoHeader = Buffer.alloc(22);
icoHeader.writeUInt16LE(1, 2); icoHeader.writeUInt16LE(1, 4);
icoHeader[6] = 68; icoHeader[7] = 68;
icoHeader.writeUInt16LE(1, 10); icoHeader.writeUInt16LE(32, 12);
icoHeader.writeUInt32LE(icon.length, 14); icoHeader.writeUInt32LE(22, 18);
await writeFile(resolve('public/static/favicon.ico'), Buffer.concat([icoHeader, icon]));
for (const asset of versionedAssets) {
await mkdir(dirname(resolve(asset.file)), { recursive: true });
await writeFile(resolve(asset.file), await readFile(resolve(asset.source)));
}
const manifestAssets = await Promise.all([...assets, ...pngAssets, ...versionedAssets].map(async asset => ({ ...asset, sha256: sha256(await readFile(resolve(asset.file))) })));
await writeFile(manifestFile, JSON.stringify({ fileKey, source: `https://www.figma.com/design/${fileKey}/?node-id=0-1`, assets: manifestAssets }, null, 2) + '\n');
console.log(`Downloaded ${assets.length} SVG brand assets and matching PNG/ICO fallbacks from Figma; generated ${versionedAssets.length} versioned aliases.`);
+151
View File
@@ -0,0 +1,151 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
type Size = { width: number; height: number };
type IconAsset = {
file: string;
nodeId: string;
role: string;
sourcePath: string;
sourceSize: Size;
size: Size;
palette: string[];
sha256?: string;
usedBy?: string[];
};
type RetainedIcon = { file: string; size: Size; sha256: string; reason: string; usedBy?: string[] };
type IconManifest = { fileKey: string; assets: IconAsset[]; retained: RetainedIcon[] };
const manifestPath = resolve('scripts/figma-icon-manifest.json');
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as IconManifest;
const verifyOnly = process.argv.includes('--check');
const hash = (value: string | Uint8Array) => createHash('sha256').update(value).digest('hex');
async function refreshReferences(): Promise<void> {
async function files(directory: string): Promise<string[]> {
const entries = await readdir(directory, {withFileTypes: true});
return (await Promise.all(entries.map(entry => entry.isDirectory() ? files(directory + '/' + entry.name) : Promise.resolve([directory + '/' + entry.name])))).flat();
}
const paths = [...await files('src')].filter(path => /\.(?:tsx?|css)$/.test(path));
const sources = await Promise.all(paths.map(async path => ({path, text:await readFile(path,'utf8')})));
for (const asset of [...manifest.assets,...manifest.retained]) {
const url = asset.file.replace(/^public/, '');
const name = url.replace(/^\/static\/images\//, '').replace(/\.svg$/, '');
const literal = new RegExp('["\x27`]' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '["\x27`]');
asset.usedBy = sources.filter(source => source.text.includes(url) || literal.test(source.text)).map(source => source.path).sort();
}
await writeFile(manifestPath, JSON.stringify(manifest,null,2)+'\n');
console.log('Refreshed Figma icon references from native TS/TSX and CSS.');
}
function svgRoot(svg: string): string {
const root = svg.match(/<svg\b[^>]*>/i)?.[0];
if (!root || !/<\/(?:[\w-]+:)?svg\s*>/i.test(svg)) throw new Error('Invalid or empty SVG document');
return root;
}
function attribute(root: string, name: string): string {
return root.match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, 'i'))?.[1] ?? '';
}
function validateSVG(asset: IconAsset | RetainedIcon, bytes: Buffer, checkHash: boolean): void {
const svg = bytes.toString('utf8');
const root = svgRoot(svg);
if (!/<(?:path|rect|circle|ellipse|line|polyline|polygon|text|use)\b/i.test(svg)) throw new Error('SVG has no visible geometry');
for (const [name, value] of Object.entries(asset.size)) {
if (Number(attribute(root, name).replace(/px$/, '')) !== value) throw new Error(`Incorrect root ${name}`);
if ('sourceSize' in asset && Math.abs(asset.sourceSize[name as keyof Size] - value) > 0.01) throw new Error(`Root ${name} differs from the Figma node`);
}
const viewBox = attribute(root, 'viewBox').trim().split(/[\s,]+/).map(Number);
if (viewBox.length !== 4 || !viewBox.every(Number.isFinite) || viewBox[2] <= 0 || viewBox[3] <= 0) throw new Error('Invalid SVG viewBox');
if (Math.abs(viewBox[2] / viewBox[3] - asset.size.width / asset.size.height) > 0.00001) throw new Error('SVG aspect ratio would distort the icon');
if (checkHash && asset.sha256 && hash(bytes) !== asset.sha256) throw new Error('SVG differs from its recorded Figma export or retained original');
if ('palette' in asset) {
const colors = new Set((svg.match(/#[\da-f]{6}\b|#[\da-f]{3}\b/gi) ?? []).map(value => {
const lower = value.toLowerCase();
return lower.length === 4 ? '#' + [...lower.slice(1)].map(char => char + char).join('') : lower;
}));
if (/\b(?:fill|stroke)=["']white["']/i.test(svg)) colors.add('#ffffff');
for (const color of colors) if (!asset.palette.includes(color)) throw new Error(`Paint ${color} does not match the Figma node`);
}
}
async function verifyLocal(): Promise<void> {
for (const asset of [...manifest.assets, ...manifest.retained]) {
try {
validateSVG(asset, await readFile(resolve(asset.file)), true);
} catch (error) {
throw new Error(`${asset.file}: ${(error as Error).message}`);
}
}
console.log(`Verified ${manifest.assets.length} Figma SVG exports and ${manifest.retained.length} retained icons: geometry, palette, dimensions and hashes.`);
}
async function request(url: string, headers?: Record<string, string>): Promise<Response> {
for (let attempt = 0; attempt < 3; attempt++) {
let response: Response;
try {
response = await fetch(url, { headers, signal: AbortSignal.timeout(60_000) });
} catch {
if (attempt < 2) continue;
throw new Error('Figma asset request failed');
}
if (response.ok) return response;
if ((response.status === 429 || response.status >= 500) && attempt < 2) {
const delay = Math.min(30, Math.max(1, Number(response.headers.get('retry-after')) || 3));
await new Promise(resolveDelay => setTimeout(resolveDelay, delay * 1000));
continue;
}
throw new Error(`Figma asset request returned HTTP ${response.status}`);
}
throw new Error('Figma asset request failed');
}
async function syncIcons(): Promise<void> {
const token = process.env.FIGMA_TOKEN;
if (!token) throw new Error('FIGMA_TOKEN is required to synchronize icons; --check works offline');
const ids = [...new Set(manifest.assets.map(asset => asset.nodeId))];
const exported = new Map<string, Buffer>();
for (let offset = 0; offset < ids.length; offset += 30) {
const batch = ids.slice(offset, offset + 30);
const query = new URLSearchParams({
ids: batch.join(','), format: 'svg', svg_outline_text: 'true', use_absolute_bounds: 'true',
});
const response = await request(`https://api.figma.com/v1/images/${manifest.fileKey}?${query}`, { 'X-Figma-Token': token });
const result = await response.json() as { images?: Record<string, string | null> };
// Signed download URLs and credentials are deliberately never logged or saved.
await Promise.all(batch.map(async id => {
const url = result.images?.[id];
if (!url) throw new Error(`Figma returned no SVG for node ${id}`);
exported.set(id, Buffer.from(await (await request(url)).arrayBuffer()));
}));
console.log(`Downloaded Figma SVG batch ${Math.floor(offset / 30) + 1}: ${batch.length} nodes.`);
}
// Save complete REST response bytes: no root, whitespace, geometry or paint edits.
// The exported intrinsic size is recorded here; display size belongs to CSS/HTML.
const pending = manifest.assets.map(previous => {
const bytes = exported.get(previous.nodeId)!;
const root = svgRoot(bytes.toString('utf8'));
const asset = { ...previous, size: { width: Number(attribute(root, 'width').replace(/px$/, '')), height: Number(attribute(root, 'height').replace(/px$/, '')) } };
try {
validateSVG(asset, bytes, false);
} catch (error) {
throw new Error(`${asset.file} (node ${asset.nodeId}): ${(error as Error).message}`);
}
return { asset, bytes };
});
for (const { asset, bytes } of pending) {
await mkdir(dirname(resolve(asset.file)), { recursive: true });
await writeFile(resolve(asset.file), bytes);
asset.sha256 = hash(bytes);
}
manifest.assets = pending.map(({ asset }) => asset);
await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
await refreshReferences();
await verifyLocal();
}
if (process.argv.includes('--refresh-references')) await refreshReferences();
else if (verifyOnly) await verifyLocal();
else await syncIcons();
+223
View File
@@ -0,0 +1,223 @@
import { execFile } from "node:child_process";
import { copyFile, mkdir, mkdtemp, readFile, rename, rm, stat, utimes } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, posix, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { parse } from "dotenv";
export interface SitemapSyncConfig {
host: string;
port: number;
sourceDirectory: string;
destinationDirectory: string;
}
export type SitemapTransfer = (name: string, destination: string) => Promise<void>;
const shardName = /^sitemap-[a-z0-9-]+\.xml$/;
const execute = promisify(execFile);
function validated(config: SitemapSyncConfig): SitemapSyncConfig {
if (!/^(?:[A-Za-z_][A-Za-z0-9_.-]*@)?(?:[A-Za-z0-9][A-Za-z0-9.-]*|\[[A-Fa-f0-9:]+\])$/.test(config.host))
throw new Error("SITEMAP_SYNC_HOST must be an SSH host, optionally prefixed with a username");
if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535)
throw new Error("SITEMAP_SYNC_PORT must be an integer between 1 and 65535");
if (!/^\/(?:[A-Za-z0-9_.-]+\/)*[A-Za-z0-9_.-]+\/?$/.test(config.sourceDirectory) || config.sourceDirectory.split("/").some((part) => part === "." || part === ".."))
throw new Error("SITEMAP_SYNC_DIR must be an absolute remote directory without shell syntax or traversal");
if (!config.destinationDirectory || /[\0\r\n]/.test(config.destinationDirectory))
throw new Error("SITEMAP_DIR must identify a local directory");
const destinationDirectory = resolve(config.destinationDirectory);
if (dirname(destinationDirectory) === destinationDirectory)
throw new Error("SITEMAP_DIR cannot be a filesystem root");
return { ...config, sourceDirectory: posix.normalize(config.sourceDirectory), destinationDirectory };
}
/** This explicit developer command always loads development configuration; it is never run by a request handler. */
export async function readSitemapSyncConfig(root = process.cwd(), environment: Record<string, string | undefined> = process.env): Promise<SitemapSyncConfig> {
if (environment.APP_ENV && environment.APP_ENV !== "development")
throw new Error("sitemap:sync only supports APP_ENV=development; do not run inside a test or production service environment");
const settings = async (file: string) => {
try { return parse(await readFile(join(root, file))); }
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; throw error; }
};
const inherited = Object.fromEntries(Object.entries(environment).filter((entry) => entry[1] !== undefined));
const values = { ...await settings("config/development.env"), ...await settings("config/development.local.env"), ...inherited };
if (values.APP_ENV && values.APP_ENV !== "development")
throw new Error("sitemap:sync requires development configuration");
const port = (values.SITEMAP_SYNC_PORT || "22").trim();
if (!/^\d+$/.test(port)) throw new Error("SITEMAP_SYNC_PORT must be numeric");
return validated({
host: (values.SITEMAP_SYNC_HOST || "").trim(),
port: Number(port),
sourceDirectory: (values.SITEMAP_SYNC_DIR || "").trim(),
destinationDirectory: resolve(root, values.SITEMAP_DIR?.trim() || "./template/pages/sitemaps"),
});
}
interface XMLNode { name: string; children: XMLNode[]; text: string }
const xmlWhitespace = /^[ \t\r\n]*$/;
const xmlName = "[A-Za-z_][A-Za-z0-9_.-]*(?::[A-Za-z_][A-Za-z0-9_.-]*)?";
function decodeXML(value: string): string {
return value.replace(/&([^;]*);|&/g, (_all, entity: string | undefined) => {
const named: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
if (entity && Object.hasOwn(named, entity)) return named[entity]!;
const numeric = entity && /^(?:#\d+|#x[0-9a-fA-F]+)$/.test(entity)
? Number.parseInt(entity.slice(entity[1] === "x" ? 2 : 1), entity[1] === "x" ? 16 : 10) : NaN;
if (!Number.isInteger(numeric) || numeric > 0x10ffff || (numeric >= 0xd800 && numeric <= 0xdfff) || (numeric < 0x20 && ![9, 10, 13].includes(numeric)) || [0xfffe, 0xffff].includes(numeric))
throw new Error("Invalid XML entity");
return String.fromCodePoint(numeric);
});
}
/** Validate well-formed sitemap XML without a network-capable parser or external entities. Bytes are never serialized again. */
function document(bytes: Uint8Array, file: string): XMLNode {
try {
const source = new TextDecoder("utf-8", { fatal: true }).decode(bytes).replace(/^\ufeff/, "");
if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/u.test(source) || /<!DOCTYPE/i.test(source)) throw new Error("Unsupported XML declaration");
const stack: XMLNode[] = [];
let root: XMLNode | undefined, position = 0;
const text = (value: string, cdata = false) => {
if (!stack.length) { if (cdata || !xmlWhitespace.test(value)) throw new Error("XML text outside its root"); return; }
if (!cdata && value.includes("]]>")) throw new Error("Invalid XML text");
stack[stack.length - 1]!.text += cdata ? value : decodeXML(value);
};
while (position < source.length) {
const start = source.indexOf("<", position);
if (start < 0) { text(source.slice(position)); break; }
text(source.slice(position, start));
if (source.startsWith("<!--", start)) {
const end = source.indexOf("-->", start + 4);
const contents = source.slice(start + 4, end);
if (end < 0 || contents.includes("--") || contents.endsWith("-")) throw new Error("Invalid XML comment");
position = end + 3; continue;
}
if (source.startsWith("<![CDATA[", start)) {
const end = source.indexOf("]]>", start + 9);
if (end < 0) throw new Error("Unclosed XML CDATA");
text(source.slice(start + 9, end), true); position = end + 3; continue;
}
if (source.startsWith("<?", start)) {
const end = source.indexOf("?>", start + 2);
if (end < 0) throw new Error("Unclosed XML processing instruction");
const instruction = source.slice(start + 2, end);
const target = new RegExp(`^(${xmlName})(?:[ \\t\\r\\n]+[\\s\\S]*)?$`).exec(instruction)?.[1];
if (!target) throw new Error("Invalid XML processing instruction");
if (/^xml$/i.test(target) && (start !== 0 || !/^xml\s+version\s*=\s*(["'])1\.0\1(?:\s+encoding\s*=\s*(["'])UTF-8\2)?(?:\s+standalone\s*=\s*(["'])(?:yes|no)\3)?\s*$/i.test(instruction) || target !== "xml"))
throw new Error("Invalid XML declaration");
position = end + 2; continue;
}
let end = start + 1, quote = "";
for (; end < source.length; end++) {
const character = source[end]!;
if (quote) { if (character === quote) quote = ""; }
else if (character === '"' || character === "'") quote = character;
else if (character === ">") break;
}
if (end === source.length) throw new Error("Unclosed XML tag");
const tag = source.slice(start + 1, end);
if (tag.startsWith("/")) {
const match = new RegExp(`^/(${xmlName})[ \\t\\r\\n]*$`).exec(tag);
if (!match || stack.pop()?.name !== match[1]) throw new Error("Mismatched XML closing tag");
} else {
const selfClosing = tag.endsWith("/"), value = selfClosing ? tag.slice(0, -1) : tag;
const name = new RegExp(`^(${xmlName})`).exec(value);
if (!name) throw new Error("Invalid XML tag");
let attributes = value.slice(name[0].length);
const seen = new Set<string>();
while (!xmlWhitespace.test(attributes)) {
const attribute = new RegExp(`^[ \\t\\r\\n]+(${xmlName})[ \\t\\r\\n]*=[ \\t\\r\\n]*(?:"([^"<]*)"|'([^'<]*)')`).exec(attributes);
if (!attribute || seen.has(attribute[1]!)) throw new Error("Invalid XML attribute");
seen.add(attribute[1]!); decodeXML(attribute[2] ?? attribute[3] ?? "");
attributes = attributes.slice(attribute[0].length);
}
const node: XMLNode = { name: name[1]!, children: [], text: "" };
if (stack.length) stack[stack.length - 1]!.children.push(node);
else { if (root) throw new Error("Multiple XML roots"); root = node; }
if (!selfClosing) stack.push(node);
}
position = end + 1;
}
if (!root || stack.length) throw new Error("Incomplete XML document");
return root;
} catch { throw new Error(`${file} is not valid UTF-8 sitemap XML`); }
}
const localName = (node: XMLNode) => node.name.split(":").at(-1);
function location(item: XMLNode, file: string): URL {
const locations = item.children.filter((child) => localName(child) === "loc");
if (locations.length !== 1 || locations[0]!.children.length || !xmlWhitespace.test(item.text))
throw new Error(`${file} contains an invalid location entry`);
let url: URL;
try { url = new URL(locations[0]!.text.trim()); } catch { throw new Error(`${file} contains an invalid location URL`); }
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
throw new Error(`${file} contains an unsupported location URL`);
return url;
}
function shardNames(bytes: Uint8Array): string[] {
const root = document(bytes, "sitemap.xml");
if (localName(root) !== "sitemapindex" || !xmlWhitespace.test(root.text)) throw new Error("sitemap.xml must contain a sitemapindex");
const names = new Set<string>();
for (const item of root.children) {
if (localName(item) !== "sitemap") throw new Error("sitemap.xml contains an invalid sitemap entry");
const url = location(item, "sitemap.xml");
const name = /^\/sitemaps\/([^/]+)$/.exec(url.pathname)?.[1];
if (!name || !shardName.test(name) || !["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash)
throw new Error("sitemap.xml contains an unsupported shard path");
names.add(name);
}
return [...names];
}
function validateShard(bytes: Uint8Array, file: string): void {
const root = document(bytes, file);
if (localName(root) !== "urlset" || !xmlWhitespace.test(root.text)) throw new Error(`${file} must contain a urlset`);
for (const item of root.children) {
if (localName(item) !== "url") throw new Error(`${file} contains an invalid url entry`);
location(item, file);
}
}
function sshTransfer(config: SitemapSyncConfig): SitemapTransfer {
return async (name, destination) => {
if (name !== "sitemap.xml" && !shardName.test(name)) throw new Error("Invalid sitemap transfer filename");
try {
await execute("scp", ["-p", "-q", "-P", String(config.port), "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", "-o", "ConnectTimeout=15", "--", `${config.host}:${posix.join(config.sourceDirectory, name)}`, destination], { timeout: 120_000, maxBuffer: 1024 * 1024 });
} catch { throw new Error(`Unable to read remote ${name} over SSH`); }
};
}
/** Download and validate a complete generation before replacing any local XML; install its original index last. */
export async function syncSitemaps(settings: SitemapSyncConfig, transfer?: SitemapTransfer) {
const config = validated(settings), copy = transfer || sshTransfer(config);
const temporary = await mkdtemp(join(tmpdir(), "huakui-sitemap-sync-"));
let installation: string | undefined;
try {
await copy("sitemap.xml", join(temporary, "sitemap.xml"));
const index = await readFile(join(temporary, "sitemap.xml")), names = shardNames(index);
for (const name of names) {
await copy(name, join(temporary, name));
validateShard(await readFile(join(temporary, name)), name);
}
await copy("sitemap.xml", join(temporary, "index-check.xml"));
if (!index.equals(await readFile(join(temporary, "index-check.xml")))) throw new Error("Remote sitemap index changed during synchronization; retry the command");
await mkdir(config.destinationDirectory, { recursive: true });
installation = await mkdtemp(join(config.destinationDirectory, ".sitemap-sync-"));
const files = [...names, "sitemap.xml"];
for (const name of files) {
const source = join(temporary, name), destination = join(installation, name), info = await stat(source);
if (!info.isFile()) throw new Error(`${name} is not a regular file`);
await copyFile(source, destination);
await utimes(destination, info.atime, info.mtime);
}
for (const name of files) await rename(join(installation, name), join(config.destinationDirectory, name));
return { directory: config.destinationDirectory, files };
} finally {
if (installation) await rm(installation, { recursive: true, force: true });
await rm(temporary, { recursive: true, force: true });
}
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
if (process.argv.length > 2) throw new Error("Usage: npm run sitemap:sync (configure development.env, development.local.env or process environment)");
const result = await syncSitemaps(await readSitemapSyncConfig());
console.log(`Synchronized ${result.files.length} backend XML files to ${result.directory}; original bytes and modification times preserved.`);
} catch (error) { console.error(error instanceof Error ? error.message : "Sitemap synchronization failed"); process.exitCode = 1; }
}
+170
View File
@@ -0,0 +1,170 @@
import assert from "node:assert/strict";
import { createCipheriv, randomBytes } from "node:crypto";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { build } from "esbuild";
import { chromium } from "@playwright/test";
// Exercise the shipped SDK and React provider. Every report terminates in a
// browser route; synthetic configuration never reaches an analytics service.
const root = process.cwd();
const temp = await mkdtemp(join(tmpdir(), "huakui-tracker-"));
const sdk = await readFile(join(root, "public/static/common/web-sdk-v1.2.0.js"), "utf8");
const configKey = sdk.match(/_secondGcmKeyBase64="([^"\n]+)"/)?.[1];
assert.ok(configKey, "The SDK configuration encryption contract is available");
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", Buffer.from(configKey, "base64"), iv);
const encryptedConfig = Buffer.concat([
iv,
cipher.update(JSON.stringify({
batchUrls: ["https://analytics.fixture.invalid"],
eventCodes: ["app_page_view", "ad_impression", "page_click", "ad_click"],
signKey: "synthetic-tracker-fixture-key",
securityKeyVersion: "fixture",
})),
cipher.final(),
cipher.getAuthTag(),
]).toString("base64");
const appFile = join(temp, "app.js");
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined;
let server: ReturnType<typeof createServer> | undefined;
try {
await build({
stdin: {
contents: `import {createRoot} from 'react-dom/client';
import {TrackerProvider} from './src/components/analytics/TrackerProvider';
createRoot(document.getElementById('root')).render(
<TrackerProvider boot={window.__fixtureBoot}>
<a href="#fixture" data-track-ad="external" data-ad-id="fixture-ad"
data-ad-clickable="1" data-ad-type="banner"
style={{display:'block',width:300,height:100}}>广告验证</a>
</TrackerProvider>);`,
loader: "tsx",
resolveDir: root,
},
outfile: appFile,
bundle: true,
platform: "browser",
format: "iife",
tsconfig: join(root, "tsconfig.json"),
define: { "process.env.NODE_ENV": '"production"' },
});
const app = await readFile(appFile);
server = createServer((request, response) => {
const path = new URL(request.url || "/", "http://fixture").pathname;
if (path === "/app.js") {
response.setHeader("Content-Type", "application/javascript").end(app);
} else if (path === "/static/common/web-sdk-v1.2.0.js") {
response.setHeader("Content-Type", "application/javascript").end(sdk);
} else if (path === "/") {
response.setHeader("Content-Type", "text/html; charset=utf-8").end(
'<!doctype html><html><head><meta charset="utf-8"><title>Tracker fixture</title></head><body><div id="root"></div><script>window.__fixtureBoot=' +
JSON.stringify({
appId: "JHA-4001",
encryptedConfig,
appVersion: "1.0.0",
pageKey: "home",
pageName: "首页",
uid: "fixture_user",
}).replace(/</g, "\\u003c") +
'</script><script src="/app.js"></script></body></html>',
);
} else response.writeHead(404).end();
});
await new Promise<void>((done) => server!.listen(0, "127.0.0.1", done));
const address = server.address();
assert.ok(address && typeof address !== "string");
const origin = `http://127.0.0.1:${address.port}`;
browser = await chromium.launch({
executablePath: process.env.CHROME_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
headless: true,
});
const commonKeys = ["sid", "device_id", "device", "device_fingerprint", "fp_version"].sort();
const results: Record<string, unknown>[] = [];
for (const fixture of [
{
device: "iOS", system: "iOS", version: "18.0",
ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1",
},
{
device: "Android", system: "Android", version: "14",
ua: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/130.0.0.0 Mobile Safari/537.36",
},
{
device: "PC", system: "Windows", version: "10.0",
ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/130.0.0.0 Safari/537.36",
},
{
device: "PC", system: "macOS", version: "14.6",
ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6) AppleWebKit/605.1.15 Version/18.0 Safari/605.1.15",
},
]) {
const context = await browser.newContext({ userAgent: fixture.ua });
const reports: Record<string, unknown>[] = [];
const errors: string[] = [];
await context.route("**/*", async (route) => {
const request = route.request();
const url = new URL(request.url());
if (url.origin === origin) return route.continue();
if (url.hostname === "analytics.fixture.invalid") {
if (request.method() === "POST") {
const body: unknown = JSON.parse(request.postData() || "[]");
assert.ok(Array.isArray(body), "SDK reports use a top-level batch array");
for (const record of body) {
assert.ok(record && typeof record === "object");
reports.push(record as Record<string, unknown>);
}
}
return route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
}
return route.abort();
});
const page = await context.newPage();
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(origin, { waitUntil: "load" });
await page.waitForFunction(() => !!window.__trackerCommonFields, null, { timeout: 15000 });
await page.waitForFunction(() => !!window.tracker, null, { timeout: 15000 });
const common = await page.evaluate(async () => {
const fields = await window.WebSDK!.getCommonFields!(["device", "system_name", "system_version"]);
const header = await window.tracker!.getSDKCommonHeader();
return { fields, headerKeys: Object.keys(JSON.parse(atob(header))).sort() };
});
assert.equal(common.fields.device, fixture.device);
assert.equal(common.fields.system_name, fixture.system);
assert.equal(common.fields.system_version, fixture.version);
assert.deepEqual(common.headerKeys, commonKeys, "X-SDK-Common retains its five-field contract");
await page.waitForFunction(async () => {
await window.tracker!.flush();
return true;
});
await assertEventually(() => reports.some((r) => r.event === "app_page_view") &&
reports.some((r) => r.event === "ad_impression"));
for (const event of ["app_page_view", "ad_impression"]) {
const report = reports.find((record) => record.event === event);
assert.ok(report, `${fixture.system}: ${event} was sent through the real SDK`);
assert.equal(report.device, fixture.device);
assert.equal(report.system_name, fixture.system);
assert.equal(report.system_version, fixture.version);
assert.equal(report.app_id, "JHA-4001");
assert.ok(typeof report.event_sig === "string" && report.event_sig.length > 0, "Real SDK signs the report");
}
assert.deepEqual(errors, []);
results.push({ system: fixture.system, version: fixture.version, events: ["app_page_view", "ad_impression"], headerKeys: common.headerKeys.length });
await context.close();
}
console.log(JSON.stringify({ passed: true, cases: results }, null, 2));
} finally {
await browser?.close();
if (server) await new Promise<void>((done) => server!.close(() => done()));
await rm(temp, { recursive: true, force: true });
}
async function assertEventually(predicate: () => boolean) {
const deadline = Date.now() + 15000;
while (!predicate() && Date.now() < deadline)
await new Promise((done) => setTimeout(done, 50));
assert.ok(predicate(), "The SDK sent both the page view and visible ad impression");
}
+254
View File
@@ -0,0 +1,254 @@
import { chromium } from "@playwright/test";
import { mkdir, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
const origin = process.env.SMOKE_URL || "http://localhost:25131";
const output = process.env.UI_OUTPUT_DIR || "/tmp/hkdj-figma/implementation";
const allowEmptyContent = process.env.UI_ALLOW_EMPTY_CONTENT === "1";
await mkdir(output, { recursive: true });
const browser = await chromium.launch({
executablePath:
process.env.CHROME_PATH ||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
headless: true,
});
const results: Record<string, unknown>[] = [];
const failures: string[] = [];
const paths = [
"/",
"/dramas/",
"/series/",
"/collections/",
"/collection/dushi/",
"/rankings/",
"/tags/chitie/",
"/latest/",
"/featured/",
"/search/",
"/search/测试/",
"/discover/search/",
"/about/",
"/help/",
"/terms/",
"/privacy/",
"/feedback/",
"/2257/",
"/dmca/",
"/navigation/",
"/__ui_missing_page__/",
];
// The account system was removed; every former account page permanently returns home.
const removedAccountPaths = [
"/account/",
"/account/history/",
"/account/favorites/",
"/account/purchases/",
"/account/settings/",
"/account/recharge/",
"/account/login/",
"/account/register/",
"/login/",
"/profile/",
];
try {
for (const [device, viewport] of [
["desktop", { width: 1440, height: 1000 }],
["mobile", { width: 375, height: 812 }],
] as const) {
const context = await browser.newContext({
viewport,
isMobile: device === "mobile",
deviceScaleFactor: 1,
});
await context.addInitScript(() => localStorage.setItem("hg_age_ok", "1"));
await context.route("**/*", async (route) => {
const request = route.request();
if (
new URL(request.url()).origin !== origin &&
(request.method() !== "GET" || request.resourceType() === "script")
)
return route.abort();
if (
request.url().includes("/api/statistics/") ||
request.url().includes("/api/report/")
)
return route.fulfill({ json: { code: 200, data: {} } });
return route.continue();
});
for (const path of removedAccountPaths) {
const response = await context.request.get(origin + path, { maxRedirects: 0 });
if (response.status() !== 301 || response.headers().location !== "/")
failures.push(
`${device} ${path}: expected 301 to /, got ${response.status()} ${response.headers().location || ""}`,
);
}
const page = await context.newPage();
let play = "";
for (const path of paths) {
const errors: string[] = [];
const missing: string[] = [];
const onError = (error: Error): void => {
errors.push(error.message);
};
const onResponse = (
response: import("@playwright/test").Response,
): void => {
if (
response.status() >= 400 &&
response.url().startsWith(origin + "/static/")
)
missing.push(new URL(response.url()).pathname);
};
page.on("pageerror", onError);
page.on("response", onResponse);
try {
const response = await page.goto(origin + path, {
waitUntil: "domcontentloaded",
timeout: 40000,
});
await page.waitForTimeout(
path === "/" || path === "/dramas/" ? 1800 : 250,
);
for (let index = 0; index < 12; index++) {
const close = page.locator(
"[data-dialog-ad].hk-is-open [data-dialog-ad-item]:not([hidden]) [data-dialog-ad-close]",
);
if (!(await close.count()) || !(await close.first().isVisible()))
break;
await close.first().click({ force: true });
}
if (path === "/") {
const playLink = page.locator('a[href^="/drama/"]').first();
play = (await playLink.count())
? (await playLink.getAttribute("href")) || ""
: "";
if (!play && !allowEmptyContent)
failures.push(
`${device} ${path}: no playable content; populate the upstream or use UI_ALLOW_EMPTY_CONTENT=1 for empty-state checks`,
);
}
const layout = await page.evaluate(() => {
const boxes = Object.fromEntries(
Object.entries({
header: ".site-header",
logo: ".site-header .brand__logo",
footer: ".site-footer",
hero: ".hero",
listHead: ".list-hero",
listFilters: ".list-filters",
firstCard: ".grid--list > :first-child",
tabbar: ".mobile-tabbar",
}).map(([key, selector]) => {
const element = document.querySelector(selector);
const rect = element?.getBoundingClientRect();
return [
key,
rect
? {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
}
: null,
];
}),
);
return {
width: innerWidth,
scrollWidth: document.documentElement.scrollWidth,
title: document.title,
noindex: /(?:^|[,\s])noindex(?:$|[,\s])/.test(
document.querySelector<HTMLMetaElement>('meta[name="robots"]')
?.content || "",
),
background: getComputedStyle(document.body).backgroundColor,
accent: getComputedStyle(document.documentElement)
.getPropertyValue("--accent")
.trim(),
oldBrand: document.body.innerText.includes("黄果短剧"),
accountEntries: document.querySelectorAll(
"[data-login-open], [data-register-open], .hk-user-menu, a[href^='/account']",
).length,
...boxes,
};
});
const unavailableEntity =
allowEmptyContent &&
response?.status() === 404 &&
(path.startsWith("/collection/") || path.startsWith("/tags/"));
const expected =
path === "/__ui_missing_page__/" || unavailableEntity ? 404 : 200;
if (response?.status() !== expected)
failures.push(
`${device} ${path}: status ${response?.status()}, expected ${expected}`,
);
if (unavailableEntity && !layout.noindex)
failures.push(
`${device} ${path}: missing entity must remain noindex`,
);
if (layout.scrollWidth > layout.width + 1)
failures.push(
`${device} ${path}: horizontal overflow ${layout.scrollWidth}`,
);
if (layout.accountEntries)
failures.push(`${device} ${path}: removed account entries remain`);
if (layout.oldBrand)
failures.push(`${device} ${path}: former brand remains visible`);
if (errors.length || missing.length)
failures.push(
`${device} ${path}: ${JSON.stringify({ errors, missing })}`,
);
results.push({
device,
path,
status: response?.status(),
unavailableEntity,
...layout,
errors,
missing,
});
await page.screenshot({
path: resolve(
output,
`${device}-${path === "/" ? "home" : path.split("/").filter(Boolean).join("-")}.png`,
),
fullPage: true,
animations: "disabled",
});
} catch (error) {
failures.push(`${device} ${path}: ${String(error)}`);
} finally {
page.off("pageerror", onError);
page.off("response", onResponse);
}
}
if (play) {
await page.goto(origin + play, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(300);
results.push({
device,
path: play,
player: await page.locator("[data-play-player]").count(),
xgplayer: await page.locator(".xgplayer").count(),
video: await page.locator("video").count(),
});
await page.screenshot({
path: resolve(output, device + "-play.png"),
fullPage: true,
animations: "disabled",
});
}
await context.close();
}
await writeFile(
resolve(output, "results.json"),
JSON.stringify({ results, failures }, null, 2),
);
console.log(
JSON.stringify({ pages: results.length, failures, output }, null, 2),
);
if (failures.length) process.exitCode = 1;
} finally {
await browser.close();
}