609 lines
21 KiB
TypeScript
609 lines
21 KiB
TypeScript
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 {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 <SiteProvider data={current} dialogs={false}>{current.series?<SeriesWatchPage key={current.fixtureKey} data={current}/>:<DramaWatchPage key={current.fixtureKey} data={current}/>}<EncryptedImage id="encrypted-image-test" src="/encrypted.png" eager style={{width:32,height:32}} onResolved={()=>window.imageResolved=(window.imageResolved||0)+1} onResolveError={()=>window.imageErrors=(window.imageErrors||0)+1}/><EncryptedImage id="failed-image-test" src="/missing-image" eager style={{width:32,height:32}} onResolved={()=>window.failedImageResolved=true} onResolveError={()=>window.failedImageError=true}/></SiteProvider>};createRoot(document.getElementById('root')).render(<Fixture/>);`,
|
|
);
|
|
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<string, unknown>;
|
|
headers: Record<string, string | string[] | undefined>;
|
|
}[] = [];
|
|
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 },
|
|
],
|
|
},
|
|
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.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<string, string>
|
|
)[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(
|
|
`<!DOCTYPE html><html data-hk-page-styles="${playbackStyles.tokens}"><head><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="/app.css"><link rel="stylesheet" href="${playbackStyles.href}"></head><body class="hk-page-layout-play"><div id="root"></div><script>window.fixtureData=` +
|
|
JSON.stringify(baseData()) +
|
|
'</script><script src="/app.js"></script></body></html>',
|
|
);
|
|
} catch (error) {
|
|
res.statusCode = 404;
|
|
res.end(String(error));
|
|
}
|
|
});
|
|
await new Promise<void>((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);
|
|
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,
|
|
);
|
|
await page.getByRole("button", { name: "预告片", exact: true }).click();
|
|
await page.waitForFunction(() => {
|
|
const video = document.querySelector("video");
|
|
return !!video && !video.paused && video.currentTime > 0.1;
|
|
});
|
|
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(watchReq, "watch report request");
|
|
assert.equal(watchReq.headers["x-sdk-common"], undefined);
|
|
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,
|
|
recommendations: 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<void>((resolve) => server.close(() => resolve()));
|
|
await rm(temp, { recursive: true, force: true });
|
|
}
|