Files
web_huakui_web/scripts/ui-check.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

255 lines
8.5 KiB
TypeScript

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