363 lines
16 KiB
TypeScript
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 });
|
|
}
|