Files
web_huakui_web/scripts/catalog-react-smoke.ts
T
2026-10-16 14:10:10 +07:00

363 lines
16 KiB
TypeScript

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.__navigation='';
Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async function(value){window.__copies.push(value)}}});
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 });
}