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(); let releaseReplacement: (() => void) | undefined; let replacementArrived: (() => void) | undefined; const replacementStarted = new Promise((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 ;} createRoot(document.getElementById('root')).render({location.pathname==='/hero-css/'?:<>}); `, }, 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( '
', ); return; } if (path === "/hero-css/") { response.setHeader("Content-Type", "text/html;charset=utf-8"); response.end(`
`); 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((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( "隔离搜索结果
", ); 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((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> | 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; headers: Record; }[] = []; 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) || {}; 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(".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(".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((resolveClose) => server.close(() => resolveClose())); await rm(temporary, { recursive: true, force: true }); }