Files
wehub-resource-sync 9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

420 lines
17 KiB
TypeScript

/**
* E2E tests for core browser commands (bilibili, zhihu, v2ex, IMDb).
* These launch a headless Chromium in CI.
*
* NOTE: Some sites may block headless browsers with bot detection.
* Tests are wrapped with tryBrowserCommand() which allows graceful failure.
*/
import { describe, it, expect } from 'vitest';
import { runCli, parseJsonOutput, type CliResult } from './helpers.js';
const BROWSER_UNAVAILABLE_ENV = { OPENCLI_BROWSER_CONNECT_TIMEOUT: '5' };
async function tryBrowserCommand(args: string[]): Promise<any[] | null> {
const { stdout, code } = await runCli(args, { timeout: 60_000, env: BROWSER_UNAVAILABLE_ENV });
if (code !== 0) return null;
try {
const data = parseJsonOutput(stdout);
return Array.isArray(data) ? data : null;
} catch {
return null;
}
}
function expectDataOrSkip(data: any[] | null, label: string) {
if (data === null || data.length === 0) {
console.warn(`${label}: skipped — no data returned (likely bot detection or geo-blocking)`);
return;
}
expect(data.length).toBeGreaterThanOrEqual(1);
}
function isImdbChallenge(result: CliResult): boolean {
const text = `${result.stderr}\n${result.stdout}`;
return /IMDb blocked this request|Robot Check|Are you a robot|verify that you are human|captcha/i.test(text);
}
function isBrowserBridgeUnavailable(result: CliResult): boolean {
const text = `${result.stderr}\n${result.stdout}`;
return /Browser Bridge.*not connected|Extension.*not connected|Browser profile .*not connected/i.test(text);
}
function isBaiduChallengeText(text: string): boolean {
return /百度安全验证|安全验证|请完成验证|captcha/i.test(text);
}
function isBaiduChallenge(result: CliResult): boolean {
const text = `${result.stderr}\n${result.stdout}`;
return isBaiduChallengeText(text);
}
function isTransientBrowserDetach(result: CliResult): boolean {
const text = `${result.stderr}\n${result.stdout}`;
return /Detached while handling command|No tab with id|Debugger is not attached to the tab/i.test(text);
}
async function runCliWithTransientRetry(args: string[], timeout: number): Promise<CliResult> {
let result = await runCli(args, { timeout, env: BROWSER_UNAVAILABLE_ENV });
if (result.code !== 0 && isTransientBrowserDetach(result)) {
result = await runCli(args, { timeout, env: BROWSER_UNAVAILABLE_ENV });
}
return result;
}
async function runJsonCliOrThrow(args: string[], label: string, timeout: number, opts: { retryTransient?: boolean } = {}): Promise<any[] | null> {
const result = opts.retryTransient
? await runCliWithTransientRetry(args, timeout)
: await runCli(args, { timeout, env: BROWSER_UNAVAILABLE_ENV });
if (result.code !== 0) {
if (isBrowserBridgeUnavailable(result)) {
console.warn(`${label}: skipped — Browser Bridge extension is unavailable in this environment`);
return null;
}
if (isBaiduChallenge(result)) {
console.warn(`${label}: skipped — Baidu challenge page detected`);
return null;
}
throw new Error(`${label} failed:\n${result.stderr || result.stdout}`);
}
const data = parseJsonOutput(result.stdout);
if (!Array.isArray(data)) {
throw new Error(`${label} returned non-array JSON:\n${result.stdout.slice(0, 500)}`);
}
return data;
}
function normalizeTiebaTitle(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
function hasTiebaMainPost(data: any[] | null): boolean {
return Array.isArray(data) && data.some((item: any) => Number(item.floor) === 1);
}
function expectNonEmptyDataOrSkipEnv(data: any[] | null, label: string): data is any[] {
if (data === null) {
console.warn(`${label}: skipped — environment is unavailable for browser assertions`);
return false;
}
expect(data.length).toBeGreaterThanOrEqual(1);
return true;
}
function countTiebaReplies(data: any[] | null): number {
if (!Array.isArray(data)) return 0;
return data.filter((item: any) => Number(item.floor) > 1).length;
}
function maxTiebaFloor(data: any[] | null): number {
if (!Array.isArray(data) || !data.length) return 0;
return Math.max(...data.map((item: any) => Number(item.floor) || 0));
}
function getTiebaReplyFloors(data: any[] | null): number[] {
if (!Array.isArray(data)) return [];
return data
.map((item: any) => Number(item.floor) || 0)
.filter((floor) => floor > 1);
}
function countTiebaReplyFloorOverlap(left: any[] | null, right: any[] | null): number {
const rightFloors = new Set(getTiebaReplyFloors(right));
return getTiebaReplyFloors(left).filter((floor) => rightFloors.has(floor)).length;
}
function pickTiebaReadCandidates(
posts: any[] | null,
minReplies: number,
): Array<{ threadId: string; title: string; replies: number }> {
if (!Array.isArray(posts) || !posts.length) return [];
return [...posts]
.filter((item: any) => item?.id)
.map((item: any) => ({
threadId: String(item.id || '').trim(),
title: normalizeTiebaTitle(String(item.title || '')),
replies: Number(item.replies) || 0,
}))
.filter((item) => item.threadId && item.title && item.replies >= minReplies)
.sort((left, right) => right.replies - left.replies);
}
/**
* Pick a live thread that actually exposes enough visible replies for the read assertions.
*/
async function getTiebaReadCandidateOrSkip(
label: string,
options: { minRepliesOnPage1?: number; requirePage2?: boolean } = {},
): Promise<{ threadId: string; title: string; replies: number } | null> {
const minRepliesOnPage1 = Math.max(1, Number(options.minRepliesOnPage1 || 1));
const requirePage2 = options.requirePage2 === true;
const posts = await runJsonCliOrThrow(['tieba', 'posts', '李毅', '--limit', '10', '-f', 'json'], `${label} setup`, 90_000, {
retryTransient: true,
});
if (posts === null) {
return null;
}
if (!Array.isArray(posts) || !posts.length) {
console.warn(`${label}: skipped — could not resolve Tieba posts for setup`);
return null;
}
const minReplies = requirePage2 ? Math.max(minRepliesOnPage1, 50) : minRepliesOnPage1;
const candidates = pickTiebaReadCandidates(posts, minReplies).slice(0, 5);
if (!candidates.length) {
console.warn(`${label}: skipped — could not find a Tieba thread with enough replies from posts metadata`);
return null;
}
for (const candidate of candidates) {
const page1Preview = await runJsonCliOrThrow(
['tieba', 'read', candidate.threadId, '--page', '1', '--limit', String(Math.max(minRepliesOnPage1, 2)), '-f', 'json'],
`${label} preview page 1`,
90_000,
{ retryTransient: true },
);
if (page1Preview === null) {
return null;
}
if (!hasTiebaMainPost(page1Preview) || countTiebaReplies(page1Preview) < minRepliesOnPage1) {
continue;
}
if (requirePage2) {
const page2Preview = await runJsonCliOrThrow(
['tieba', 'read', candidate.threadId, '--page', '2', '--limit', '1', '-f', 'json'],
`${label} preview page 2`,
90_000,
{ retryTransient: true },
);
if (page2Preview === null) {
return null;
}
if (hasTiebaMainPost(page2Preview) || countTiebaReplies(page2Preview) < 1) {
continue;
}
}
return candidate;
}
console.warn(`${label}: skipped — could not find a Tieba thread with enough visible replies`);
return null;
}
describe('tieba e2e helper guards', () => {
it('does not treat generic empty-result errors as a Baidu challenge', () => {
expect(isBaiduChallengeText('tieba posts returned no data\n→ The page structure may have changed — this adapter may be outdated.')).toBe(false);
});
it('still recognizes actual Baidu challenge text', () => {
expect(isBaiduChallengeText('百度安全验证,请完成验证后继续')).toBe(true);
});
it('counts partial overlap between read pages', () => {
expect(countTiebaReplyFloorOverlap(
[{ floor: 1 }, { floor: 23 }, { floor: 27 }, { floor: 28 }, { floor: 29 }, { floor: 30 }],
[{ floor: 27 }, { floor: 28 }, { floor: 31 }],
)).toBe(2);
});
it('picks read fixtures from posts metadata in descending reply order', () => {
expect(pickTiebaReadCandidates([
{ id: '1', title: '普通帖', replies: 2 },
{ id: '2', title: '大帖', replies: 120 },
{ id: '', title: '无效帖', replies: 999 },
], 50)).toEqual([{
threadId: '2',
title: '大帖',
replies: 120,
}]);
expect(pickTiebaReadCandidates([{ id: '1', title: '普通帖', replies: 2 }], 50)).toEqual([]);
});
});
async function expectImdbDataOrChallengeSkip(args: string[], label: string): Promise<any[] | null> {
const result = await runCli(args, { timeout: 60_000, env: BROWSER_UNAVAILABLE_ENV });
if (result.code !== 0) {
if (isImdbChallenge(result)) {
console.warn(`${label}: skipped — IMDb challenge page detected`);
return null;
}
if (isBrowserBridgeUnavailable(result)) {
console.warn(`${label}: skipped — Browser Bridge extension is unavailable in this environment`);
return null;
}
throw new Error(`${label} failed:\n${result.stderr || result.stdout}`);
}
const data = parseJsonOutput(result.stdout);
if (!Array.isArray(data)) {
throw new Error(`${label} returned non-array JSON:\n${result.stdout.slice(0, 500)}`);
}
if (data.length === 0) {
throw new Error(`${label} returned an empty result`);
}
return data;
}
describe('browser public-data commands E2E', () => {
// ── bilibili ──
it('bilibili hot returns trending videos', async () => {
const data = await tryBrowserCommand(['bilibili', 'hot', '--limit', '5', '-f', 'json']);
expectDataOrSkip(data, 'bilibili hot');
if (data?.length) {
expect(data[0]).toHaveProperty('title');
}
}, 60_000);
it('bilibili ranking returns ranked videos', async () => {
const data = await tryBrowserCommand(['bilibili', 'ranking', '--limit', '5', '-f', 'json']);
expectDataOrSkip(data, 'bilibili ranking');
}, 60_000);
it('bilibili search returns results', async () => {
const data = await tryBrowserCommand(['bilibili', 'search', 'typescript', '--limit', '3', '-f', 'json']);
expectDataOrSkip(data, 'bilibili search');
}, 60_000);
// ── zhihu ──
it('zhihu hot returns trending questions', async () => {
const data = await tryBrowserCommand(['zhihu', 'hot', '--limit', '5', '-f', 'json']);
expectDataOrSkip(data, 'zhihu hot');
if (data?.length) {
expect(data[0]).toHaveProperty('title');
}
}, 60_000);
it('zhihu search returns results', async () => {
const data = await tryBrowserCommand(['zhihu', 'search', 'playwright', '--limit', '3', '-f', 'json']);
expectDataOrSkip(data, 'zhihu search');
}, 60_000);
// ── v2ex ──
it('v2ex daily returns topics', async () => {
const data = await tryBrowserCommand(['v2ex', 'daily', '--limit', '3', '-f', 'json']);
expectDataOrSkip(data, 'v2ex daily');
}, 60_000);
// ── tieba ──
it('tieba hot returns trending topics', async () => {
const data = await runJsonCliOrThrow(['tieba', 'hot', '--limit', '5', '-f', 'json'], 'tieba hot', 60_000, { retryTransient: true });
if (expectNonEmptyDataOrSkipEnv(data, 'tieba hot')) {
expect(data[0]).toHaveProperty('title');
expect(data[0]).toHaveProperty('discussions');
}
}, 60_000);
it('tieba posts returns forum threads', async () => {
const data = await runJsonCliOrThrow(['tieba', 'posts', '李毅', '--limit', '20', '-f', 'json'], 'tieba posts', 90_000, { retryTransient: true });
if (expectNonEmptyDataOrSkipEnv(data, 'tieba posts')) {
expect(data[0]).toHaveProperty('title');
expect(String(data[0].id || '')).toMatch(/^\d+$/);
expect(String(data[0].url || '')).toContain('/p/');
expect(Number.isFinite(Number(data[0].replies))).toBe(true);
expect(data.length).toBeLessThanOrEqual(20);
}
}, 90_000);
it('tieba posts page 2 returns a different forum slice', async () => {
const data1 = await runJsonCliOrThrow(['tieba', 'posts', '李毅', '--page', '1', '--limit', '5', '-f', 'json'], 'tieba posts page 1', 60_000, { retryTransient: true });
const data2 = await runJsonCliOrThrow(['tieba', 'posts', '李毅', '--page', '2', '--limit', '5', '-f', 'json'], 'tieba posts page 2', 60_000, { retryTransient: true });
if (expectNonEmptyDataOrSkipEnv(data1, 'tieba posts page 1') && expectNonEmptyDataOrSkipEnv(data2, 'tieba posts page 2')) {
const ids1 = data1.map((item: any) => String(item.id || '')).filter(Boolean);
const ids2 = data2.map((item: any) => String(item.id || '')).filter(Boolean);
const newIds = ids2.filter((id) => !ids1.includes(id));
expect(newIds.length).toBeGreaterThan(0);
}
}, 90_000);
it('tieba search returns results', async () => {
const data = await runJsonCliOrThrow(['tieba', 'search', '编程', '--limit', '20', '-f', 'json'], 'tieba search', 90_000, { retryTransient: true });
if (expectNonEmptyDataOrSkipEnv(data, 'tieba search')) {
expect(data[0]).toHaveProperty('title');
expect(String(data[0].id || '')).toMatch(/^\d+$/);
expect(String(data[0].url || '')).toContain('/p/');
expect(data.length).toBeLessThanOrEqual(20);
}
}, 90_000);
it('tieba search rejects unsupported pages above 1', async () => {
const result = await runCli(['tieba', 'search', '编程', '--page', '2', '--limit', '3', '-f', 'json'], {
timeout: 60_000,
});
expect(result.code).toBe(2);
expect(`${result.stderr}\n${result.stdout}`).toContain('Argument "page" must be one of: 1');
}, 60_000);
it('tieba read returns thread content', async () => {
const fixture = await getTiebaReadCandidateOrSkip('tieba read');
if (!fixture) {
return;
}
const data = await runJsonCliOrThrow(['tieba', 'read', fixture.threadId, '--limit', '5', '-f', 'json'], 'tieba read', 90_000, { retryTransient: true });
if (expectNonEmptyDataOrSkipEnv(data, 'tieba read')) {
expect(data[0]).toHaveProperty('floor');
expect(data[0]).toHaveProperty('content');
expect(data.some((item: any) => Number(item.floor) === 1)).toBe(true);
expect(normalizeTiebaTitle(String(data[0].content || ''))).toContain(fixture.title);
}
}, 90_000);
it('tieba read page 2 omits the main post', async () => {
const fixture = await getTiebaReadCandidateOrSkip('tieba read page', { requirePage2: true });
if (!fixture) {
return;
}
const data1 = await runJsonCliOrThrow(['tieba', 'read', fixture.threadId, '--page', '1', '--limit', '5', '-f', 'json'], 'tieba read page 1', 90_000, { retryTransient: true });
const data2 = await runJsonCliOrThrow(['tieba', 'read', fixture.threadId, '--page', '2', '--limit', '5', '-f', 'json'], 'tieba read page 2', 90_000, { retryTransient: true });
if (expectNonEmptyDataOrSkipEnv(data1, 'tieba read page 1') && expectNonEmptyDataOrSkipEnv(data2, 'tieba read page 2')) {
const overlap = countTiebaReplyFloorOverlap(data1, data2);
expect(normalizeTiebaTitle(String(data1[0].content || ''))).toContain(fixture.title);
expect(hasTiebaMainPost(data1)).toBe(true);
expect(hasTiebaMainPost(data2)).toBe(false);
expect(overlap).toBe(0);
expect(maxTiebaFloor(data2)).toBeGreaterThan(maxTiebaFloor(data1));
}
}, 90_000);
it('tieba read limit counts replies instead of consuming the main post slot', async () => {
const fixture = await getTiebaReadCandidateOrSkip('tieba read limit semantics', { minRepliesOnPage1: 2 });
if (!fixture) {
return;
}
const data = await runJsonCliOrThrow(['tieba', 'read', fixture.threadId, '--page', '1', '--limit', '2', '-f', 'json'], 'tieba read limit semantics', 90_000, { retryTransient: true });
if (expectNonEmptyDataOrSkipEnv(data, 'tieba read limit semantics')) {
expect(normalizeTiebaTitle(String(data[0].content || ''))).toContain(fixture.title);
expect(hasTiebaMainPost(data)).toBe(true);
expect(countTiebaReplies(data)).toBe(2);
}
}, 90_000);
// ── imdb ──
it('imdb top returns chart data', async () => {
const data = await expectImdbDataOrChallengeSkip(['imdb', 'top', '--limit', '3', '-f', 'json'], 'imdb top');
if (data?.length) {
expect(data[0]).toHaveProperty('title');
}
}, 60_000);
it('imdb search returns results', async () => {
const data = await expectImdbDataOrChallengeSkip(['imdb', 'search', 'inception', '--limit', '3', '-f', 'json'], 'imdb search');
if (data?.length) {
expect(data[0]).toHaveProperty('id');
expect(data[0]).toHaveProperty('title');
}
}, 60_000);
it('imdb title returns movie details', async () => {
const data = await expectImdbDataOrChallengeSkip(['imdb', 'title', 'tt1375666', '-f', 'json'], 'imdb title');
if (data?.length) {
expect(data[0]).toHaveProperty('field');
expect(data[0]).toHaveProperty('value');
}
}, 60_000);
});