import assert from "node:assert/strict"; import { mkdtemp, readFile, writeFile, mkdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, basename } from "node:path"; import { spawnSync } from "node:child_process"; import { createServer } from "node:http"; import { randomBytes } from "node:crypto"; import { build } from "esbuild"; import { chromium } from "@playwright/test"; import { decryptImageBytes } from "../src/lib/media/images"; import { createStyleFixture } from "./style-fixtures"; // Actual React components and official xgplayer/HLS, isolated from all production APIs. const cwd = process.cwd(), temp = await mkdtemp(join(tmpdir(), "huakui-playback-")); const output = process.env.PLAYBACK_SMOKE_OUTPUT || "/tmp/hkdj-playback-smoke"; const playbackStyles = await createStyleFixture(["base", "downloadPop", "installGuide", "play", "playLinks"]); await mkdir(output, { recursive: true }); const mp4 = join(temp, "fixture.mp4"), key = randomBytes(16); await writeFile(join(temp, "key.bin"), key); await writeFile( join(temp, "key.info"), `/enkey?ticket=mock-key&keep=1\n${join(temp, "key.bin")}\n`, ); function ffmpeg(args: string[]) { const result = spawnSync( "ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", ...args], { encoding: "utf8" }, ); assert.equal( result.status, 0, result.stderr || "ffmpeg is required for real media playback validation", ); } ffmpeg([ "-f", "lavfi", "-i", "testsrc2=size=320x180:rate=24", "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=44100", "-t", "12", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-g", "48", "-c:a", "aac", "-movflags", "+faststart", mp4, ]); ffmpeg([ "-i", mp4, "-c", "copy", "-hls_time", "2", "-hls_playlist_type", "vod", "-hls_key_info_file", join(temp, "key.info"), "-hls_segment_filename", join(temp, "segment-%d.ts"), join(temp, "signed.m3u8"), ]); const png = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jEtQAAAAASUVORK5CYII=", "base64", ); const encrypted = decryptImageBytes( png.buffer.slice( png.byteOffset, png.byteOffset + png.byteLength, ) as ArrayBuffer, ); const entry = join(temp, "fixture.tsx"); await writeFile( entry, `import {useState} from 'react';import {createRoot} from 'react-dom/client';import {SiteProvider} from '${cwd}/src/components/providers/SiteProvider';import {TrackerProvider} from '${cwd}/src/components/analytics/TrackerProvider';import {DramaWatchPage} from '${cwd}/src/features/playback/DramaWatchPage';import {SeriesWatchPage} from '${cwd}/src/features/playback/SeriesWatchPage';import {EncryptedImage} from '${cwd}/src/components/media/EncryptedImage';function Fixture(){const [current,setCurrent]=useState(window.fixtureData);window.changePlaybackFixture=setCurrent;return {current.series?:}window.imageResolved=(window.imageResolved||0)+1} onResolveError={()=>window.imageErrors=(window.imageErrors||0)+1}/>window.failedImageResolved=true} onResolveError={()=>window.failedImageError=true}/>};createRoot(document.getElementById('root')).render();`, ); const navStub = join(temp, "navigation.ts"); await writeFile( navStub, "export const useRouter=()=>({back:()=>history.back(),refresh:()=>{},push:url=>location.assign(url),replace:url=>location.replace(url)});export const usePathname=()=>location.pathname;export const useSearchParams=()=>new URLSearchParams(location.search);", ); 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"' }, plugins: [ { name: "next-router-test-context", setup(builder) { builder.onResolve({ filter: /^next\/navigation$/ }, () => ({ path: navStub, })); }, }, ], }); const requests: { url: string; method: string; body?: Record; headers: Record; }[] = []; let origin = ""; const allowedApis = new Set([ "/api/auth/session", "/api/auth/guest", "/api/playlet/guess-like/list", "/api/playlet/chapter/watch/report", ]); const unexpectedApis: string[] = []; const baseData = () => ({ fixtureKey: "hls", requestPath: "/drama/mock/101/", isLoggedIn: true, isRegistered: true, userID: 1001, userName: "测试用户", avatar: "/static/brand/placeholder.svg", appConfig: { appAddr: "https://download.example/real" }, canWatch: true, mainURL: "/h5/m3u8/signed.m3u8?ticket=mock-play&keep=1", previewURL: origin + "/fixture.mp4?preview=1", hasPreview: true, chapterID: 101, episode: 1, playlet: { id: 10, slug: "mock", playName: "播放测试", cover: "/static/brand/placeholder.svg", horizontalCover: "/static/brand/placeholder.svg", totalEpisode: 2, updateStatus: 2, countCollect: 4, countLike: 3, isLike: 2, isCollect: 2, playletChapterInfo: [ { id: 101, currentEpisode: 1, canWatch: true }, { id: 102, currentEpisode: 2, payType: 1, needUnlock: true }, ], }, trackerDrama: { DramaID: 10, DramaTitle: "播放测试", VideoID: 101, Episode: 1, TotalEpisode: 2, DramaDuration: 12, DramaTypeID: "2", DramaTypeName: "都市", DramaTagKey: "3", DramaTagName: "剧情", }, hotList: [], guessLike: [ { id: 30, slug: "guess", playName: "猜你喜欢测试", cover: "/static/brand/placeholder.svg", totalEpisode: 3, updateStatus: 2 }, ], related: [], advitise: { nineGridList: [], detailbannerList: [] }, }); const server = createServer(async (req, res) => { try { const url = new URL(req.url!, origin || "http://localhost"); let body = ""; for await (const part of req) body += part; requests.push({ url: req.url!, method: req.method!, body: body ? JSON.parse(body) : undefined, headers: req.headers, }); if (url.pathname === playbackStyles.href) { res.setHeader("content-type", "text/css;charset=utf-8"); res.end(playbackStyles.css); return; } if (url.pathname === "/static/common/web-sdk-v1.2.0.js") { res.setHeader("content-type", "application/javascript"); res.end( 'window.sdkEvents=[];window.WebSDK={init:options=>{window.sdkInit=options},track:new Proxy({},{get:(_,method)=>payload=>{window.sdkEvents.push({method,payload});return Promise.resolve({ok:true})}}),getCommonFields:()=>Promise.resolve({sid:"mock-session",device_id:"mock-device",device:"PC",device_fingerprint:"mock-fp",fp_version:"1"}),flush:()=>Promise.resolve(),setUid:()=>{}};', ); return; } if (url.pathname.startsWith("/api/")) { res.setHeader("content-type", "application/json"); let data: unknown = {}; if (url.pathname === "/api/auth/session") data = { userId: 1001, isRegister: true, recharge: 12 }; else if (!allowedApis.has(url.pathname)) { // Like/collect/comment/unlock/coin APIs were removed with the account system. unexpectedApis.push(url.pathname); res.statusCode = 500; res.end(JSON.stringify({ code: 500, data: null })); return; } res.end(JSON.stringify({ code: 200, data })); return; } if (url.pathname === "/encrypted.png") { res.setHeader("content-type", "application/octet-stream"); res.end(encrypted); return; } if (url.pathname === "/missing-video.mp4") { res.statusCode = 404; res.end(); return; } if (url.pathname === "/missing-image") { res.statusCode = 404; res.end(); return; } if (url.pathname === "/enkey") { res.setHeader("content-type", "application/octet-stream"); res.end(key); return; } let file: string | undefined; if (url.pathname.startsWith("/static/")) file = join(cwd, "public", url.pathname); else if (url.pathname.startsWith("/h5/m3u8/")) file = join(temp, basename(url.pathname)); else if (["/app.js", "/app.css", "/fixture.mp4"].includes(url.pathname)) file = join(temp, basename(url.pathname)); if (file) { const bytes = await readFile(file), ext = file.split(".").pop()!; res.setHeader( "content-type", ( { js: "application/javascript", css: "text/css", svg: "image/svg+xml", png: "image/png", m3u8: "application/vnd.apple.mpegurl", ts: "video/mp2t", mp4: "video/mp4", } as Record )[ext] || "application/octet-stream", ); res.setHeader("accept-ranges", "bytes"); const range = /bytes=(\d+)-(\d*)/.exec(req.headers.range || ""); if (range) { const start = Number(range[1]), end = range[2] ? Number(range[2]) : bytes.length - 1; res.statusCode = 206; res.setHeader("content-range", `bytes ${start}-${end}/${bytes.length}`); res.end(bytes.subarray(start, end + 1)); } else res.end(bytes); return; } res.setHeader("content-type", "text/html;charset=utf-8"); res.end( `
', ); } catch (error) { res.statusCode = 404; res.end(String(error)); } }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); assert(address && typeof address !== "string"); origin = `http://127.0.0.1:${address.port}`; const browser = await chromium.launch({ executablePath: process.env.CHROME_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", headless: true, }); const results = []; try { for (const [device, viewport] of [ ["desktop", { width: 1440, height: 900 }], ["mobile", { width: 375, height: 812 }], ] as const) { const context = await browser.newContext({ viewport, isMobile: device === "mobile", ...(device === "mobile" ? { userAgent: "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36 Quark/7.0", } : {}), }); await context.route("**/*", (route) => new URL(route.request().url()).origin === origin ? route.continue() : route.abort(), ); const page = await context.newPage(), errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); await page.goto(origin, { waitUntil: "networkidle" }); await page.waitForFunction(() => !!document.querySelector("video")); const video = page.locator("video"); await page.locator(".hk-playback-player__center").click(); await page.waitForFunction( () => { const video = document.querySelector("video"); return !!video && !video.paused && video.currentTime > 0.2; }, { timeout: 15000 }, ); assert( requests.some( (req) => req.url === "/h5/m3u8/signed.m3u8?ticket=mock-play&keep=1", ), ); assert(requests.some((req) => req.url === "/enkey?ticket=mock-key&keep=1")); assert( requests.some( (req) => req.url === "/api/playlet/chapter/watch/report" && req.body?.chapterId === 101, ), ); await page.locator(".xgplayer-fullscreen").click({ force: true }); await page.waitForFunction( () => !!document.fullscreenElement || !!document.querySelector(".xgplayer-is-cssfullscreen"), ); const full = await page.locator(".hk-video-player").boundingBox(); assert.equal(Math.round(full!.width), viewport.width); assert.equal(Math.round(full!.height), viewport.height); // The native fullscreen transition resolves after the CSS state is visible. await page.waitForTimeout(200); await page.locator(".xgplayer-fullscreen").click({ force: true }); await page.waitForFunction( () => !document.fullscreenElement && !document.querySelector(".xgplayer-is-cssfullscreen"), ); await video.evaluate((node) => (node as HTMLVideoElement).pause()); await page.waitForFunction(() => document.querySelector("video")!.paused); await video.evaluate((node) => { (node as HTMLVideoElement).currentTime = 7; }); await page.waitForFunction( () => document.querySelector("video")!.currentTime >= 6.9, ); await video.evaluate((node) => { const video = node as HTMLVideoElement; video.playbackRate = 1.5; return video.play(); }); await page.waitForTimeout(650); await video.evaluate((node) => (node as HTMLVideoElement).pause()); await video.evaluate((node) => { const video = node as HTMLVideoElement; video.currentTime = 11.8; return video.play(); }); await page.waitForFunction(() => document.querySelector("video")!.ended); const events = await page.evaluate( () => ( window as unknown as { sdkEvents: { method: string; payload: Record }[]; } ).sdkEvents, ); for (const behavior of [ "ep_view", "ep_play", "ep_pause", "ep_forward", "ep_complete", ]) assert( events.some( (event) => event.method === "dramaEvent" && event.payload.play_behavior_key === behavior, ), behavior, ); assert( events.some( (event) => event.method === "dramaEvent" && event.payload.play_speed === 150, ), ); assert.equal( await page .locator("#failed-image-test") .getAttribute("data-decrypt-failed"), "1", ); assert( ( await page.locator("#encrypted-image-test").getAttribute("src") )?.startsWith("blob:"), ); assert.equal( await page.evaluate( () => (window as unknown as { imageResolved: number }).imageResolved, ), 1, ); assert.equal( await page.evaluate( () => (window as unknown as { failedImageResolved?: boolean }) .failedImageResolved, ), undefined, ); assert.equal( await page.evaluate( () => (window as unknown as { failedImageError?: boolean }) .failedImageError, ), true, ); const beforePreview = events.filter( (event) => event.method === "dramaEvent" && event.payload.play_behavior_key === "ep_play", ).length; await page.getByRole("button", { name: "预告片", exact: true }).click(); await page.waitForFunction(() => { const video = document.querySelector("video"); return !!video && !video.paused && video.currentTime > 0.1; }); await page.waitForTimeout(300); const afterPreview = await page.evaluate( () => ( window as unknown as { sdkEvents: { method: string; payload: Record }[]; } ).sdkEvents.filter( (event) => event.method === "dramaEvent" && event.payload.play_behavior_key === "ep_play", ).length, ); assert.equal(afterPreview, beforePreview); assert.equal( await page.locator(".hk-playback-player").getAttribute("data-play-mode"), "preview", ); await page.getByRole("button", { name: "正片", exact: true }).click(); await page.waitForFunction( () => document.querySelector("video")?.currentTime === 0, ); await page.locator(".hk-playback-player__center").click(); await page.waitForFunction(() => !document.querySelector("video")!.paused); // Like/collect actions and comments were removed; recommendations take the comment slot. for (const name of ["点赞", "取消点赞", "收藏", "取消收藏", "发布"]) assert.equal( await page.getByRole("button", { name, exact: true }).count(), 0, name + " action must be removed", ); assert.equal(await page.locator(".hk-playback-comments, .hk-playback-composer").count(), 0); assert.deepEqual( await page.locator(".hk-playback-dual > *").evaluateAll((nodes) => nodes.map((node) => node.className)), ["hk-playback-like", "hk-playback-hot"], ); assert.match( (await page.locator(".hk-playback-like").textContent()) || "", /猜你喜欢测试/, ); const watchReq = requests.find( (req) => req.url === "/api/playlet/chapter/watch/report", ); assert.equal( watchReq?.headers["x-sdk-common"], Buffer.from( JSON.stringify({ sid: "mock-session", device_id: "mock-device", device: "PC", device_fingerprint: "mock-fp", fp_version: "1", }), ).toString("base64"), ); await page.evaluate(() => window.scrollTo(0, 0)); await page.screenshot({ path: join(output, device + "-player.png") }); const paid = { ...baseData(), fixtureKey: "locked", canWatch: false, mainURL: "/h5/m3u8/paid-secret.m3u8?ticket=never-fetch", hasPreview: true, previewURL: origin + "/fixture.mp4?preview=locked", }; await page.evaluate( (data) => ( window as unknown as { changePlaybackFixture: (data: unknown) => void; } ).changePlaybackFixture(data), paid, ); await page.waitForFunction(() => document.querySelector('[data-play-mode="preview"]'), ); await page.getByRole("button", { name: "正片", exact: true }).click(); assert.equal(await page.locator("video").count(), 0); assert.equal( await page.locator(".hk-playback-player__unlock-title").textContent(), "本集请前往 App 观看", ); // The locked overlay offers only the app download; no coin unlock or recharge. assert.equal( await page.locator(".hk-playback-player__unlock-actions > *").count(), 1, ); assert.match( (await page.locator(".hk-playback-player__unlock-btn").textContent()) || "", /前往 App 观看/, ); assert.equal( await page.locator(".hk-playback-player__unlock").getByRole("button", { name: "去充值" }).count(), 0, ); assert(!requests.some((req) => req.url.includes("paid-secret"))); assert.match( (await page.locator(".hk-playback-actions__btn--play").textContent()) || "", /去 App 看/, ); // The play CTA opens the App-exclusive sheet; its download button and the // overlay button both lead to the shared app download dialog. await page.locator(".hk-playback-actions__btn--play").click(); const sheet = page.locator(".hk-playback-coin"); await sheet.waitFor({ state: "visible" }); assert.equal(await sheet.locator(".hk-playback-coin__title").textContent(), "本集请前往 App 观看"); await sheet.locator(".hk-playback-coin__skip").click(); await sheet.waitFor({ state: "detached" }); for (const entrance of [ ".hk-playback-actions__btn--play", ".hk-playback-player__unlock-btn", ]) { await page.locator(entrance).click(); if (entrance === ".hk-playback-actions__btn--play") await page.locator(".hk-playback-coin__pay").click(); // Desktop shows the download dialog; mobile shows the installation guide. const dialog = device === "desktop" ? "[data-download-pop]" : "[data-install-guide]"; await page.locator(dialog).waitFor({ state: "visible" }); assert.equal(await page.locator(".hk-playback-coin").count(), 0); if (device === "desktop") assert.equal( await page.locator(".hk-app-download-dialog__download").getAttribute("href"), "https://download.example/real", ); assert.equal( await page.evaluate(() => document.body.style.overflow), "hidden", ); if (entrance === ".hk-playback-player__unlock-btn") await page.screenshot({ path: join(output, device + "-locked-download.png"), }); await page.keyboard.press("Escape"); await page.waitForSelector(dialog, { state: "detached" }); assert.notEqual( await page.evaluate(() => document.body.style.overflow), "hidden", ); } assert.equal(await page.locator("[data-recharge-modal]").count(), 0); assert.equal(await page.locator("video").count(), 0); const failed = { ...baseData(), fixtureKey: "retry", mainURL: origin + "/missing-video.mp4", hasPreview: false, previewURL: "", }; await page.evaluate( (data) => ( window as unknown as { changePlaybackFixture: (data: unknown) => void; } ).changePlaybackFixture(data), failed, ); await page .getByRole("button", { name: "重新加载" }) .waitFor({ state: "visible" }); const failures = requests.filter( (req) => req.url === "/missing-video.mp4", ).length; await page.getByRole("button", { name: "重新加载" }).click(); await page.waitForTimeout(500); assert( requests.filter((req) => req.url === "/missing-video.mp4").length > failures, ); const series = { ...baseData(), fixtureKey: "series", series: true, canWatch: true, videoID: 201, videoURL: origin + "/fixture.mp4?main=series", video: { id: 201, title: "影集测试", coverImg: "/static/brand/placeholder.svg", video: { url: origin + "/fixture.mp4", videoTime: 12 }, }, }; await page.evaluate( (data) => ( window as unknown as { changePlaybackFixture: (data: unknown) => void; } ).changePlaybackFixture(data), series, ); await page.waitForFunction( () => document.querySelector("h1")?.textContent === "影集测试", ); assert.equal(await page.locator("video").count(), 1); await page.locator(".hk-playback-player__center").click(); await page.waitForFunction( () => document.querySelector("video")!.currentTime > 0.2, ); assert(requests.some((req) => req.url === "/fixture.mp4?main=series")); assert.deepEqual(errors, []); assert.deepEqual(unexpectedApis, []); results.push({ device, officialXgplayer: true, aes128Hls: true, signedQueriesPreserved: true, playPauseSeek: true, previewExcluded: true, paidNoFetch: true, chapterDestroy: true, fullscreen: true, errorRetry: true, realMp4: true, recommendationsAndTracking: true, encryptedImageAndFallback: true, lockedAppDownloadOnly: true, }); await context.close(); } await writeFile( join(output, "results.json"), JSON.stringify(results, null, 2), ); console.log(JSON.stringify({ output, results }, null, 2)); } finally { await browser.close(); await new Promise((resolve) => server.close(() => resolve())); await rm(temp, { recursive: true, force: true }); }