[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:
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
@@ -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.");
|
||||
@@ -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 "$@"
|
||||
@@ -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
@@ -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));
|
||||
@@ -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.`);
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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(" "),
|
||||
};
|
||||
}
|
||||
@@ -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.`);
|
||||
@@ -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();
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user