Files
wehub-resource-sync 9b395f5cc3
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
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

241 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
const { mockApiGet } = vi.hoisted(() => ({
mockApiGet: vi.fn(),
}));
vi.mock('./utils.js', async (importOriginal) => ({
...(await importOriginal()),
apiGet: mockApiGet,
}));
import { getRegistry } from '@jackwener/opencli/registry';
import './subtitle.js';
describe('bilibili subtitle', () => {
const command = getRegistry().get('bilibili/subtitle');
const page = {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn(),
};
beforeEach(() => {
mockApiGet.mockReset();
page.goto.mockClear();
page.evaluate.mockReset();
});
// 帮助函数:第一发 apiGetview)固定返 cid=123456 的 OK 响应
const mockViewOk = (cid = 123456) =>
mockApiGet.mockResolvedValueOnce({ code: 0, data: { bvid: 'BV1GbXPBeEZm', cid } });
it('throws AuthRequiredError when bilibili hides subtitles behind login', async () => {
mockViewOk();
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
need_login_subtitle: true,
subtitle: { subtitles: [] },
},
});
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toSatisfy(
(err) => err instanceof AuthRequiredError && /login|登录/i.test(err.message),
);
});
it('throws EmptyResultError when a video truly has no subtitles', async () => {
mockViewOk();
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
need_login_subtitle: false,
subtitle: { subtitles: [] },
},
});
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(EmptyResultError);
});
it('throws CommandExecutionError when view API returns non-zero code', async () => {
// 番剧/地区限制等场景下 view API 也会返非零;之前路径走 SELECTOR 错,现在统一走 view 错
mockApiGet.mockResolvedValueOnce({ code: -404, message: '啥都木有' });
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
});
it('wraps view API fetch/json exceptions as CommandExecutionError', async () => {
mockApiGet.mockRejectedValueOnce(new SyntaxError('Unexpected token <'));
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
});
it('throws CommandExecutionError when view API succeeds but lacks cid', async () => {
mockApiGet.mockResolvedValueOnce({ code: 0, data: { bvid: 'BV1GbXPBeEZm' /* no cid */ } });
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(/cid/);
});
it('throws CommandExecutionError when player subtitle payload is malformed', async () => {
mockViewOk();
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
need_login_subtitle: false,
subtitle: { subtitles: { lan: 'zh-CN' } },
},
});
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
});
it('throws CommandExecutionError when player API returns a non-object payload', async () => {
mockViewOk();
mockApiGet.mockResolvedValueOnce(null);
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
mockApiGet.mockReset();
mockViewOk();
mockApiGet.mockResolvedValueOnce([]);
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
});
it('throws AuthRequiredError only for explicit empty subtitle_url entries', async () => {
mockViewOk();
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
need_login_subtitle: false,
subtitle: { subtitles: [{ lan: 'zh-CN', subtitle_url: '' }] },
},
});
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(AuthRequiredError);
});
it('throws CommandExecutionError when subtitle entry lacks subtitle_url field', async () => {
mockViewOk();
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
need_login_subtitle: false,
subtitle: { subtitles: [{ lan: 'zh-CN' }] },
},
});
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
});
it('wraps subtitle file fetch exceptions as CommandExecutionError', async () => {
mockViewOk();
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
need_login_subtitle: false,
subtitle: { subtitles: [{ lan: 'zh-CN', subtitle_url: '//example.com/sub.json' }] },
},
});
page.evaluate.mockRejectedValueOnce(new Error('Failed to fetch'));
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
});
it('throws EmptyResultError when subtitle file has no cue rows', async () => {
mockViewOk();
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
need_login_subtitle: false,
subtitle: { subtitles: [{ lan: 'zh-CN', subtitle_url: '//example.com/sub.json' }] },
},
});
page.evaluate.mockResolvedValueOnce({ success: true, data: [] });
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(EmptyResultError);
});
it('throws CommandExecutionError when subtitle cue rows have malformed time ranges', async () => {
mockViewOk();
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
need_login_subtitle: false,
subtitle: { subtitles: [{ lan: 'zh-CN', subtitle_url: '//example.com/sub.json' }] },
},
});
page.evaluate.mockResolvedValueOnce({ success: true, data: [{ from: 'bad', to: 1.5, content: 'hello' }] });
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
});
it('uses the selected 分P part cid when --page is given', async () => {
// view 返回 pages 数组;--page 3 应改用 pages[2].cid,而非 data.cid(默认 P1
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
bvid: 'BV1h6V16SEpg',
cid: 1001, // P1 默认 cid
pages: [
{ cid: 1001, page: 1, part: '01' },
{ cid: 1002, page: 2, part: '02' },
{ cid: 1003, page: 3, part: '03 人生的价值' },
],
},
});
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
need_login_subtitle: false,
subtitle: { subtitles: [{ lan: 'zh-CN', subtitle_url: '//example.com/sub.json' }] },
},
});
page.evaluate.mockResolvedValueOnce({ success: true, data: [{ from: 0, to: 1, content: 'a' }] });
await command.func(page, { bvid: 'BV1h6V16SEpg', page: '3' });
// 第二发 apiGetplayer/wbi/v2)的 cid 必须是第 3 集的 1003
const playerCall = mockApiGet.mock.calls[1];
expect(playerCall[1]).toBe('/x/player/wbi/v2');
expect(playerCall[2]?.params?.cid).toBe(1003);
});
it('throws CommandExecutionError when --page is out of range', async () => {
mockApiGet.mockResolvedValueOnce({
code: 0,
data: { bvid: 'BV1h6V16SEpg', cid: 1001, pages: [{ cid: 1001, page: 1, part: '01' }] },
});
await expect(command.func(page, { bvid: 'BV1h6V16SEpg', page: '9' })).rejects.toThrow(CommandExecutionError);
});
it('rejects malformed --page before querying subtitle APIs', async () => {
await expect(command.func(page, { bvid: 'BV1h6V16SEpg', page: '1e2' })).rejects.toBeInstanceOf(ArgumentError);
expect(mockApiGet).not.toHaveBeenCalled();
});
it('works for bangumi-bound bvid (PGC content) — same code path, view API returns cid + redirect_url', async () => {
// 回归保护:以前 page.goto(/video/<bvid>) 对 bangumi 走重定向,
// window.__INITIAL_STATE__.videoData 不存在 → SELECTOR 错。view API 不依赖页面结构,bangumi 同样能拿 cid。
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
bvid: 'BV1Py4y1D781',
cid: 267270412,
redirect_url: 'https://www.bilibili.com/bangumi/play/ep371508',
title: '【纪录片】灭绝的真相',
},
});
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
need_login_subtitle: false,
subtitle: {
subtitles: [{ lan: 'zh-CN', subtitle_url: '//example.com/sub.json' }],
},
},
});
page.evaluate.mockResolvedValueOnce({
success: true,
data: [
{ from: 0, to: 1.5, content: 'hello' },
{ from: 1.5, to: 3.2, content: 'world' },
],
});
const out = await command.func(page, { bvid: 'BV1Py4y1D781' });
expect(out).toEqual([
{ index: 1, from: '0.00s', to: '1.50s', content: 'hello' },
{ index: 2, from: '1.50s', to: '3.20s', content: 'world' },
]);
// 关键:不再依赖 page.goto,所有 cid 解析走 apiGet
expect(page.goto).not.toHaveBeenCalled();
// 第一发 apiGet 一定是 view 端点
const firstCall = mockApiGet.mock.calls[0];
expect(firstCall[1]).toBe('/x/web-interface/view');
expect(firstCall[2]?.params?.bvid).toBe('BV1Py4y1D781');
});
});