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
241 lines
10 KiB
JavaScript
241 lines
10 KiB
JavaScript
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();
|
||
});
|
||
|
||
// 帮助函数:第一发 apiGet(view)固定返 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' });
|
||
|
||
// 第二发 apiGet(player/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');
|
||
});
|
||
});
|