Files
web_huakui_web/scripts/playback-smoke.ts
T
谢宇宁andClaude Opus 5.5 d2ddb1c321 [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>
2026-10-16 11:33:31 +07:00

686 lines
24 KiB
TypeScript

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 });
}