- 原生 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>
97 lines
5.9 KiB
TypeScript
97 lines
5.9 KiB
TypeScript
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();
|
|
}
|