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[] = []; 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('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(); }