import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { createServer, type Server } from "node:http"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve, extname } from "node:path"; import { build } from "esbuild"; import { chromium, type Browser, type Page } from "@playwright/test"; import manifest from "../src/app/manifest"; import { createStyleFixture } from "./style-fixtures"; interface FixtureWindow extends Window { setPwaMode(mode: "fullscreen" | "standalone", active: boolean): void; setIosStandalone(active: boolean): void; setNativeFullscreen(active: boolean): void; mountPwaProvider(active: boolean): void; } // Real React provider, shared dialog/tabbar and official player; no production API or CDN. const cwd = process.cwd(); const temp = await mkdtemp(join(tmpdir(), "huakui-pwa-")); const output = process.env.PWA_SMOKE_OUTPUT || "/tmp/hkdj-pwa-smoke"; let browser: Browser | undefined; let server: Server | undefined; let origin = ""; let networkReads = 0; const requests: string[] = []; const results: Record[] = []; async function waitForInstalled(page: Page, expected: boolean) { await page.waitForFunction( (active) => document.documentElement.classList.contains("hk-pwa-standalone") === active, expected, ); } async function fullscreen(page: Page, css: boolean, viewport: { width: number; height: number }) { await page.locator(".xgplayer-fullscreen").click({ force: true }); await page.waitForFunction( (useCss) => useCss ? !!document.querySelector(".xgplayer-is-cssfullscreen") : !!document.fullscreenElement, css, ); await page.waitForFunction( ({ width, height }) => { const rect = document.querySelector(".hk-video-player")!.getBoundingClientRect(); return Math.abs(rect.left) < 1 && Math.abs(rect.top) < 1 && Math.abs(rect.width - width) < 1 && Math.abs(rect.height - height) < 1; }, viewport, ); const measurements = await page.locator(".hk-video-player").evaluate((element) => { const style = getComputedStyle(element); const controls = getComputedStyle(element.querySelector(".xgplayer-controls")!); return { height: style.height, left: controls.left, right: controls.right, bottom: controls.bottom }; }); assert.equal(Math.round(parseFloat(measurements.height)), viewport.height, "Fullscreen uses the complete dynamic viewport"); if (css) { assert.equal(parseFloat(measurements.left), 20); assert.equal(parseFloat(measurements.right), 20); assert.equal(parseFloat(measurements.bottom), 34); } // XGPlayer's native transition can settle after its fullscreen class appears. await page.waitForTimeout(250); return measurements; } async function exitFullscreen(page: Page) { await page.locator(".xgplayer-fullscreen").click({ force: true }); await page.waitForFunction(() => !document.fullscreenElement && !document.querySelector(".xgplayer-is-cssfullscreen")); await page.waitForTimeout(200); assert.notEqual(await page.evaluate(() => getComputedStyle(document.body).overflowY), "hidden", "Fullscreen exit restores page scrolling"); await page.evaluate(() => window.scrollTo(0, 180)); await page.waitForFunction(() => window.scrollY >= 179); } try { await mkdir(output, { recursive: true }); const generated = spawnSync("ffmpeg", [ "-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x180:rate=24", "-t", "8", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-movflags", "+faststart", join(temp, "fixture.mp4"), ], { encoding: "utf8" }); assert.equal(generated.status, 0, generated.stderr || "ffmpeg is required for real player validation"); const entry = join(temp, "fixture.tsx"); await writeFile(entry, ` import {useState} from 'react'; import {createRoot} from 'react-dom/client'; import {PwaProvider} from '${cwd}/src/components/pwa/PwaProvider'; import {VideoPlayer} from '${cwd}/src/components/player/VideoPlayer'; import {MobileTabbar} from '${cwd}/src/components/layout/MobileTabbar'; import {Dialog} from '${cwd}/src/components/ui/Dialog'; function Fixture(){ const [provider,setProvider]=useState(true),[dialog,setDialog]=useState(false); window.mountPwaProvider=setProvider; return <>{provider&&}
花魁短剧PWA
独立播放器与安全区回归
{dialog&&setDialog(false)}>
} ; } createRoot(document.getElementById('root')).render(); `); await build({ entryPoints: [entry], bundle: true, platform: "browser", format: "iife", outfile: join(temp, "app.js"), tsconfig: join(cwd, "tsconfig.json"), nodePaths: [join(cwd, "node_modules")], define: { "process.env.NODE_ENV": '"production"' }, }); const styles = ["base", "header", "play", "mobileTabbar", "eighteen", "pwa"]; const pwaStyles = await createStyleFixture(styles); server = createServer(async (request, response) => { try { const url = new URL(request.url!, origin || "http://localhost"); requests.push(url.pathname); if (url.pathname === pwaStyles.href) { response.setHeader("Content-Type", "text/css;charset=utf-8"); response.end(pwaStyles.css); return; } if (url.pathname === "/network-check") { response.setHeader("Content-Type", "application/json"); response.setHeader("Cache-Control", "no-store"); response.end(JSON.stringify({ count: ++networkReads })); return; } if (url.pathname === "/manifest.webmanifest") { response.setHeader("Content-Type", "application/manifest+json"); response.end(JSON.stringify(manifest())); return; } let file: string | undefined; if (url.pathname === "/sw.js") { file = join(cwd, "public/sw.js"); response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); } else if (url.pathname.startsWith("/static/")) { file = resolve(cwd, "public", "." + url.pathname); assert(file.startsWith(resolve(cwd, "public") + "/")); } else if (["/app.js", "/app.css", "/fixture.mp4"].includes(url.pathname)) { file = join(temp, url.pathname.slice(1)); } if (file) { const bytes = await readFile(file); response.setHeader("Content-Type", ({ ".js": "application/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png", ".mp4": "video/mp4" } as Record)[extname(file)] || "application/octet-stream"); const range = /^bytes=(\d+)-(\d*)$/.exec(request.headers.range || ""); if (range) { const start = Number(range[1]); const end = Math.min(range[2] ? Number(range[2]) : bytes.length - 1, bytes.length - 1); response.writeHead(206, { "Content-Range": `bytes ${start}-${end}/${bytes.length}`, "Accept-Ranges": "bytes" }); response.end(bytes.subarray(start, end + 1)); } else response.end(bytes); return; } if (url.pathname !== "/") { response.writeHead(404).end(); return; } response.setHeader("Content-Type", "text/html;charset=utf-8"); response.setHeader("Cache-Control", "no-store"); response.end(`
`); } catch (error) { response.writeHead(500).end(String(error)); } }); await new Promise(done => server!.listen(0, "127.0.0.1", done)); const address = server.address(); assert(address && typeof address !== "string"); origin = `http://127.0.0.1:${address.port}`; browser = await chromium.launch({ executablePath: process.env.CHROME_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", headless: true }); for (const [name, viewport] of [ ["portrait", { width: 390, height: 844 }], ["landscape", { width: 844, height: 390 }], ["desktop", { width: 1440, height: 900 }], ] as const) { const context = await browser.newContext({ viewport }); await context.route("**/*", route => new URL(route.request().url()).origin === origin ? route.continue() : route.abort()); await context.addInitScript(() => { // tsx preserves callback names with this helper; serialized Playwright callbacks need it too. Object.defineProperty(window, "__name", { value: Function("value", "return value"), configurable: true }); const fixture = window as unknown as FixtureWindow; const originalMatchMedia = window.matchMedia.bind(window); const states = new Map(); window.matchMedia = (query) => { if (!/^\(display-mode: (fullscreen|standalone)\)$/.test(query)) return originalMatchMedia(query); let state = states.get(query); if (!state) { const target = new EventTarget(); const list = Object.assign(target, { media: query, onchange: null, addListener(listener: EventListener) { target.addEventListener("change", listener); }, removeListener(listener: EventListener) { target.removeEventListener("change", listener); }, }) as unknown as MediaQueryList; state = { matches: false, target, list }; const current = state; Object.defineProperty(list, "matches", { get: () => current.matches }); states.set(query, state); } return state.list; }; fixture.setPwaMode = (mode, active) => { const query = `(display-mode: ${mode})`; window.matchMedia(query); const state = states.get(query)!; state.matches = active; state.target.dispatchEvent(Object.assign(new Event("change"), { matches: active, media: query })); }; let ios = false; Object.defineProperty(navigator, "standalone", { configurable: true, get: () => ios }); fixture.setIosStandalone = active => { ios = active; fixture.setPwaMode("fullscreen", states.get("(display-mode: fullscreen)")?.matches || false); }; let native = document.fullscreenEnabled; Object.defineProperty(document, "fullscreenEnabled", { configurable: true, get: () => native }); Object.defineProperty(document, "webkitFullscreenEnabled", { configurable: true, get: () => false }); fixture.setNativeFullscreen = active => { native = active; }; }); const page = await context.newPage(); const errors: string[] = []; page.on("pageerror", error => errors.push(error.message)); await page.goto(origin, { waitUntil: "networkidle" }); await page.waitForFunction(() => !!navigator.serviceWorker.controller && !!document.querySelector("video")).catch(async error => { console.error({ errors, requests, state: await page.evaluate(async () => ({ secure: window.isSecureContext, video: !!document.querySelector("video"), controller: navigator.serviceWorker.controller?.scriptURL, registrations: (await navigator.serviceWorker.getRegistrations()).map(registration => ({ scope: registration.scope, active: registration.active?.state, installing: registration.installing?.state })) })) }); throw error; }); await waitForInstalled(page, false); const initial = await page.evaluate(() => { const style = getComputedStyle(document.body); const header = document.querySelector("#fixed-header")!.getBoundingClientRect(); return { borders: [style.borderTopWidth, style.borderRightWidth, style.borderBottomWidth, style.borderLeftWidth], headerTop: header.top, headerHeight: header.height, flowTop: document.querySelector("#flow-probe")!.getBoundingClientRect().top }; }); assert.deepEqual(initial.borders, ["0px", "0px", "0px", "0px"], `${name}: normal browser keeps zero safe-area borders`); assert.equal(initial.headerTop, 0); const sw = await page.evaluate(async () => { const registration = await navigator.serviceWorker.ready; const a = await (await fetch("/network-check")).json(); const b = await (await fetch("/network-check")).json(); return { scope: registration.scope, updateViaCache: registration.updateViaCache, caches: await caches.keys(), a: a.count, b: b.count, script: navigator.serviceWorker.controller!.scriptURL }; }); assert.equal(sw.scope, origin + "/"); assert.equal(sw.script, origin + "/sw.js"); assert.equal(sw.updateViaCache, "none"); assert.deepEqual(sw.caches, []); assert.equal(sw.b, sw.a + 1, "Controlled requests continue reaching the network"); for (const mode of ["standalone", "fullscreen"] as const) { await page.evaluate(mode => (window as unknown as FixtureWindow).setPwaMode(mode, true), mode); await waitForInstalled(page, true); await page.evaluate(mode => (window as unknown as FixtureWindow).setPwaMode(mode, false), mode); await waitForInstalled(page, false); } await page.evaluate(() => (window as unknown as FixtureWindow).setIosStandalone(true)); await waitForInstalled(page, true); await page.evaluate(() => { for (const [side, value] of Object.entries({ top: 44, right: 20, bottom: 34, left: 20 })) document.documentElement.style.setProperty("--pwa-safe-" + side, value + "px"); }); const safe = await page.evaluate(() => { const rect = (selector: string) => { const r = document.querySelector(selector)!.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, bottom: r.bottom }; }; const tab = document.querySelector(".hk-mobile-tabbar")!; const body = getComputedStyle(document.body); return { header: rect("#fixed-header"), flow: rect("#flow-probe"), tab: rect(".hk-mobile-tabbar"), tabDisplay: getComputedStyle(tab).display, tabPadding: getComputedStyle(tab).paddingBottom, bodyBottom: body.paddingBottom, bodyBorderBottom: body.borderBottomWidth, bodyBorders: [body.borderTopWidth, body.borderRightWidth, body.borderLeftWidth], scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth }; }); assert.equal(safe.header.y, 44); assert.equal(safe.header.height, initial.headerHeight, `${name}: header measurement excludes the top inset`); assert.equal(safe.flow.y, initial.flowTop + 44, `${name}: safe-area added once to normal flow`); assert(safe.flow.y >= safe.header.bottom, `${name}: fixed header never covers the flow boundary`); assert.deepEqual(safe.bodyBorders, ["44px", "20px", "20px"]); assert(safe.scrollWidth <= safe.clientWidth, `${name}: no horizontal overflow`); if (viewport.width <= 768) { // The account tab was removed with the login system: only 首页/短剧/剧集 remain. assert.deepEqual( await page.locator(".hk-mobile-tabbar__item").allTextContents(), ["首页", "短剧", "剧集"], ); assert.equal(safe.tab.height, 90); assert.equal(safe.tabPadding, "34px"); assert.equal(safe.bodyBottom, "90px"); assert.equal(safe.bodyBorderBottom, "0px", "Visible TabBar owns bottom safety without another body border"); assert.equal(safe.tab.bottom, viewport.height); } else { assert.equal(safe.tabDisplay, "none"); assert.equal(safe.bodyBorderBottom, "34px", "Hidden TabBar leaves bottom safety to the body border"); } await page.locator("#open-modal").click(); await page.waitForSelector("#modal-panel"); const modal = await page.locator("#modal-panel").evaluate(element => { const r = element.getBoundingClientRect(); return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, scroll: element.scrollHeight, client: element.clientHeight, overflow: getComputedStyle(element).overflowY }; }); assert(modal.top >= 44 && modal.bottom <= viewport.height - 34, `${name}: modal stays within top/bottom safe areas`); assert(modal.left >= 20 && modal.right <= viewport.width - 20, `${name}: modal stays within side safe areas`); assert(modal.scroll > modal.client && modal.overflow === "auto", `${name}: tall modal remains scrollable`); assert.equal(await page.evaluate(() => document.body.style.overflow), "hidden"); await page.keyboard.press("Escape"); await page.waitForSelector("#modal-panel", { state: "detached" }); assert.notEqual(await page.evaluate(() => getComputedStyle(document.body).overflowY), "hidden"); await page.evaluate(() => window.scrollTo(0, 0)); if (name !== "desktop") await page.screenshot({ path: join(output, name + "-safe-area.png") }); await page.locator("video").evaluate(async element => { const video = element as HTMLVideoElement; video.muted = true; await video.play(); }); await page.waitForFunction(() => document.querySelector("video")!.currentTime > 0.1); // Installed iOS without the document fullscreen API must use the CSS fallback. await page.evaluate(() => (window as unknown as FixtureWindow).setNativeFullscreen(false)); const css = await fullscreen(page, true, viewport); if (name === "portrait") await page.screenshot({ path: join(output, "portrait-css-fullscreen.png") }); await exitFullscreen(page); await page.evaluate(() => { window.scrollTo(0, 0); (window as unknown as FixtureWindow).setNativeFullscreen(true); }); const native = await fullscreen(page, false, viewport); if (name === "desktop") await page.screenshot({ path: join(output, "desktop-native-fullscreen.png") }); await exitFullscreen(page); await page.evaluate(() => { (window as unknown as FixtureWindow).setIosStandalone(false); for (const side of ["top", "right", "bottom", "left"]) document.documentElement.style.removeProperty("--pwa-safe-" + side); }); await waitForInstalled(page, false); assert.deepEqual(errors, [], `${name}: no browser runtime errors`); results.push({ name, viewport, worker: true, modeChanges: true, safe, modal, cssFullscreen: css, nativeFullscreen: native, scrollRestored: true }); await context.close(); console.log(`${name}: provider modes, network-only SW, safe areas, modal, CSS/native fullscreen and scroll restoration passed`); } assert(!requests.some(path => path.startsWith("/api/")), "The PWA fixture never calls a production API"); await writeFile(join(output, "results.json"), JSON.stringify(results, null, 2) + "\n"); console.log(`PWA browser regression passed. Results: ${output}`); } finally { await browser?.close(); server?.closeAllConnections(); if (server?.listening) await new Promise(done => server!.close(() => done())); await rm(temp, { recursive: true, force: true }); }