@jackwener/opencli 1.7.21 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -148
- package/README.zh-CN.md +38 -211
- package/cli-manifest.json +6423 -4260
- package/clis/12306/me.js +73 -0
- package/clis/12306/orders.js +96 -0
- package/clis/12306/passengers.js +90 -0
- package/clis/12306/price.js +166 -0
- package/clis/12306/stations.js +66 -0
- package/clis/12306/train.js +91 -0
- package/clis/12306/trains.js +119 -0
- package/clis/12306/utils.js +272 -0
- package/clis/12306/utils.test.js +331 -0
- package/clis/36kr/article.js +6 -3
- package/clis/36kr/article.test.js +46 -0
- package/clis/apple-podcasts/commands.test.js +20 -0
- package/clis/apple-podcasts/search.js +2 -2
- package/clis/barchart/greeks.js +144 -56
- package/clis/barchart/greeks.test.js +138 -0
- package/clis/bilibili/summary.js +167 -0
- package/clis/bilibili/summary.test.js +210 -0
- package/clis/booking/booking.test.js +356 -0
- package/clis/booking/search.js +351 -0
- package/clis/boss/utils.js +17 -1
- package/clis/boss/utils.test.js +34 -0
- package/clis/chatgpt/envelope.test.js +108 -0
- package/clis/chatgpt/image.js +2 -2
- package/clis/chatgpt/image.test.js +6 -0
- package/clis/chatgpt/utils.js +148 -41
- package/clis/chatgpt/utils.test.js +92 -2
- package/clis/douyin/_shared/browser-fetch.js +44 -20
- package/clis/douyin/_shared/browser-fetch.test.js +22 -1
- package/clis/douyin/_shared/evaluate-result.js +16 -0
- package/clis/douyin/_shared/tos-upload.js +105 -69
- package/clis/douyin/_shared/vod-upload.js +212 -0
- package/clis/douyin/_shared/vod-upload.test.js +38 -0
- package/clis/douyin/delete.js +137 -4
- package/clis/douyin/delete.test.js +90 -1
- package/clis/douyin/publish-upload-id.test.js +170 -0
- package/clis/douyin/publish.js +88 -42
- package/clis/douyin/user-videos.js +9 -2
- package/clis/douyin/user-videos.test.js +43 -0
- package/clis/flomo/memos.js +228 -0
- package/clis/flomo/memos.test.js +144 -0
- package/clis/gitee/search.js +2 -2
- package/clis/gitee/search.test.js +65 -0
- package/clis/jike/post.js +27 -17
- package/clis/jike/read.test.js +86 -0
- package/clis/jike/topic.js +32 -19
- package/clis/jike/user.js +33 -20
- package/clis/lesswrong/comments.js +1 -1
- package/clis/lesswrong/curated.js +1 -1
- package/clis/lesswrong/frontpage.js +1 -1
- package/clis/lesswrong/frontpage.test.js +37 -0
- package/clis/lesswrong/new.js +1 -1
- package/clis/lesswrong/read.js +1 -1
- package/clis/lesswrong/sequences.js +1 -1
- package/clis/lesswrong/shortform.js +1 -1
- package/clis/lesswrong/tag.js +1 -1
- package/clis/lesswrong/top-month.js +1 -1
- package/clis/lesswrong/top-week.js +1 -1
- package/clis/lesswrong/top-year.js +1 -1
- package/clis/lesswrong/top.js +1 -1
- package/clis/linkedin/connect.js +401 -0
- package/clis/linkedin/connect.test.js +213 -0
- package/clis/linkedin/inbox.js +234 -0
- package/clis/linkedin/inbox.test.js +152 -0
- package/clis/linkedin/people-search.js +262 -0
- package/clis/linkedin/people-search.test.js +216 -0
- package/clis/linkedin/safe-send.js +357 -0
- package/clis/linkedin/safe-send.test.js +204 -0
- package/clis/linkedin/salesnav-inbox.js +210 -0
- package/clis/linkedin/salesnav-inbox.test.js +113 -0
- package/clis/linkedin/salesnav-message.js +360 -0
- package/clis/linkedin/salesnav-message.test.js +172 -0
- package/clis/linkedin/salesnav-search.js +186 -0
- package/clis/linkedin/salesnav-search.test.js +76 -0
- package/clis/linkedin/salesnav-thread.js +212 -0
- package/clis/linkedin/salesnav-thread.test.js +79 -0
- package/clis/linkedin/sent-invitations.js +92 -0
- package/clis/linkedin/sent-invitations.test.js +62 -0
- package/clis/linkedin/thread-snapshot.js +214 -0
- package/clis/linkedin/thread-snapshot.test.js +89 -0
- package/clis/linkedin-learning/course.js +138 -0
- package/clis/linkedin-learning/course.test.js +114 -0
- package/clis/linkedin-learning/search.js +155 -0
- package/clis/linkedin-learning/search.test.js +144 -0
- package/clis/linkedin-learning/trending.js +133 -0
- package/clis/linkedin-learning/trending.test.js +123 -0
- package/clis/powerchina/search.js +3 -3
- package/clis/powerchina/search.test.js +27 -1
- package/clis/reddit/extract-media.test.js +149 -0
- package/clis/reddit/frontpage.js +47 -9
- package/clis/reddit/frontpage.test.js +34 -0
- package/clis/reddit/home.js +31 -1
- package/clis/reddit/home.test.js +46 -3
- package/clis/reddit/hot.js +32 -1
- package/clis/reddit/hot.test.js +15 -1
- package/clis/reddit/popular.js +39 -1
- package/clis/reddit/popular.test.js +26 -0
- package/clis/reddit/saved.js +1 -1
- package/clis/reddit/search.js +38 -1
- package/clis/reddit/search.test.js +26 -0
- package/clis/reddit/subreddit.js +52 -7
- package/clis/reddit/subreddit.test.js +31 -0
- package/clis/reddit/subscribed.js +165 -0
- package/clis/reddit/subscribed.test.js +168 -0
- package/clis/reddit/upvoted.js +1 -1
- package/clis/suno/commands.test.js +188 -0
- package/clis/suno/download.js +140 -0
- package/clis/suno/download.test.js +151 -0
- package/clis/suno/generate.js +226 -0
- package/clis/suno/generate.test.js +243 -0
- package/clis/suno/list.js +79 -0
- package/clis/suno/status.js +62 -0
- package/clis/suno/utils.js +540 -0
- package/clis/suno/utils.test.js +223 -0
- package/clis/twitter/device-follow.js +193 -0
- package/clis/twitter/device-follow.test.js +287 -0
- package/clis/twitter/download.js +443 -73
- package/clis/twitter/download.test.js +457 -0
- package/clis/twitter/list-create.js +155 -0
- package/clis/twitter/list-create.test.js +169 -0
- package/clis/twitter/list-remove.js +12 -5
- package/clis/twitter/list-remove.test.js +74 -0
- package/clis/twitter/list-tweets.js +6 -2
- package/clis/twitter/list-tweets.test.js +41 -1
- package/clis/twitter/lists.js +31 -4
- package/clis/twitter/lists.test.js +152 -16
- package/clis/twitter/search.js +6 -2
- package/clis/twitter/search.test.js +6 -0
- package/clis/twitter/shared.js +144 -0
- package/clis/twitter/shared.test.js +429 -1
- package/clis/twitter/thread.js +10 -2
- package/clis/twitter/thread.test.js +58 -0
- package/clis/twitter/timeline.js +6 -2
- package/clis/twitter/timeline.test.js +2 -0
- package/clis/twitter/tweets.js +3 -2
- package/clis/twitter/tweets.test.js +1 -1
- package/clis/weibo/comments.js +3 -4
- package/clis/weibo/delete.js +172 -0
- package/clis/weibo/delete.test.js +94 -0
- package/clis/weibo/envelope.test.js +85 -0
- package/clis/weibo/favorites.js +4 -4
- package/clis/weibo/feed.js +3 -5
- package/clis/weibo/hot.js +3 -4
- package/clis/weibo/me.js +3 -5
- package/clis/weibo/post.js +3 -4
- package/clis/weibo/publish.js +37 -14
- package/clis/weibo/publish.test.js +14 -5
- package/clis/weibo/search.js +4 -3
- package/clis/weibo/user-posts.js +234 -0
- package/clis/weibo/user-posts.test.js +92 -0
- package/clis/weibo/user.js +3 -4
- package/clis/weibo/utils.js +34 -5
- package/clis/weibo/utils.test.js +36 -0
- package/clis/weread/search-regression.test.js +18 -11
- package/clis/weread/search.js +15 -7
- package/clis/weread-official/book.js +135 -0
- package/clis/weread-official/commands.test.js +385 -0
- package/clis/weread-official/discover.js +107 -0
- package/clis/weread-official/list-apis.js +95 -0
- package/clis/weread-official/notes.js +171 -0
- package/clis/weread-official/readdata.js +158 -0
- package/clis/weread-official/review.js +93 -0
- package/clis/weread-official/search.js +106 -0
- package/clis/weread-official/shelf.js +97 -0
- package/clis/weread-official/utils.js +293 -0
- package/clis/weread-official/utils.test.js +242 -0
- package/clis/wikipedia/trending.js +7 -3
- package/clis/wikipedia/trending.test.js +57 -0
- package/clis/xianyu/chat.js +24 -109
- package/clis/xianyu/chat.test.js +5 -0
- package/clis/xianyu/im.js +322 -0
- package/clis/xianyu/im.test.js +253 -0
- package/clis/xianyu/inbox.js +96 -0
- package/clis/xianyu/messages.js +91 -0
- package/clis/xianyu/reply.js +82 -0
- package/clis/xiaohongshu/creator-note-detail.js +2 -1
- package/clis/xiaohongshu/creator-note-detail.test.js +11 -0
- package/clis/xiaohongshu/creator-notes-summary.js +2 -1
- package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
- package/clis/xiaohongshu/creator-notes.js +2 -1
- package/clis/xiaohongshu/creator-notes.test.js +12 -0
- package/clis/xiaohongshu/creator-stats.js +2 -1
- package/clis/xiaohongshu/creator-stats.test.js +24 -0
- package/clis/xiaohongshu/delete-note.js +260 -0
- package/clis/xiaohongshu/delete-note.test.js +172 -0
- package/clis/xiaohongshu/publish.js +48 -8
- package/clis/xiaohongshu/publish.test.js +65 -10
- package/clis/xiaohongshu/user-helpers.test.js +41 -0
- package/clis/xiaohongshu/user.js +27 -4
- package/clis/xiaoyuzhou/download.js +1 -1
- package/clis/xiaoyuzhou/transcript.js +1 -1
- package/clis/youdao/note.js +258 -0
- package/clis/youdao/note.test.js +99 -0
- package/clis/youtube/transcript.js +397 -24
- package/clis/youtube/transcript.test.js +196 -6
- package/clis/zhihu/answer-comments.js +299 -0
- package/clis/zhihu/answer-comments.test.js +287 -0
- package/clis/zhihu/answer-detail.js +12 -0
- package/clis/zhihu/answer-detail.test.js +8 -0
- package/clis/zhihu/collection.js +15 -2
- package/clis/zhihu/collection.test.js +46 -0
- package/clis/zhihu/download.js +1 -1
- package/clis/zhihu/question.js +42 -9
- package/clis/zhihu/question.test.js +111 -9
- package/clis/zhihu/search.js +206 -43
- package/clis/zhihu/search.test.js +198 -0
- package/dist/src/browser/errors.js +4 -2
- package/dist/src/browser/errors.test.js +6 -0
- package/dist/src/browser/page.js +30 -4
- package/dist/src/browser/page.test.js +42 -0
- package/dist/src/browser/utils.d.ts +1 -1
- package/dist/src/cli-argv-preprocess.d.ts +26 -0
- package/dist/src/cli-argv-preprocess.js +138 -0
- package/dist/src/cli-argv-preprocess.test.js +79 -0
- package/dist/src/cli.js +1 -1
- package/dist/src/convention-audit.js +15 -8
- package/dist/src/convention-audit.test.js +21 -0
- package/dist/src/download/media-download.js +15 -2
- package/dist/src/download/media-download.test.d.ts +1 -0
- package/dist/src/download/media-download.test.js +110 -0
- package/dist/src/electron-apps.js +1 -1
- package/dist/src/electron-apps.test.js +7 -2
- package/dist/src/errors.d.ts +17 -0
- package/dist/src/errors.js +22 -0
- package/dist/src/external-clis.yaml +20 -0
- package/dist/src/external.d.ts +6 -1
- package/dist/src/external.test.js +19 -0
- package/dist/src/main.js +14 -2
- package/dist/src/utils.d.ts +43 -0
- package/dist/src/utils.js +97 -0
- package/dist/src/utils.test.d.ts +1 -0
- package/dist/src/utils.test.js +155 -0
- package/package.json +8 -2
- package/scripts/silent-column-drop-baseline.json +0 -52
- package/scripts/typed-error-lint-baseline.json +28 -380
- package/clis/slock/_utils.js +0 -12
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
+
|
|
4
|
+
const { mockApiGet, mockResolveBvid } = vi.hoisted(() => ({
|
|
5
|
+
mockApiGet: vi.fn(),
|
|
6
|
+
mockResolveBvid: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock('./utils.js', async (importOriginal) => ({
|
|
10
|
+
...(await importOriginal()),
|
|
11
|
+
apiGet: mockApiGet,
|
|
12
|
+
resolveBvid: mockResolveBvid,
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
16
|
+
import './summary.js';
|
|
17
|
+
|
|
18
|
+
describe('bilibili summary', () => {
|
|
19
|
+
const command = getRegistry().get('bilibili/summary');
|
|
20
|
+
const page = {};
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
mockApiGet.mockReset();
|
|
24
|
+
mockResolveBvid.mockReset();
|
|
25
|
+
mockResolveBvid.mockRejectedValue(new Error('short link not found'));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function mockView(data = { aid: 114, cid: 222, owner: { mid: 333 } }) {
|
|
29
|
+
mockApiGet.mockResolvedValueOnce({ code: 0, data });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function mockConclusion(modelResult) {
|
|
33
|
+
mockApiGet.mockResolvedValueOnce({
|
|
34
|
+
code: 0,
|
|
35
|
+
data: {
|
|
36
|
+
code: 0,
|
|
37
|
+
model_result: modelResult,
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
it('returns the summary plus timestamped outline rows', async () => {
|
|
43
|
+
mockView();
|
|
44
|
+
mockConclusion({
|
|
45
|
+
summary: '整体总结',
|
|
46
|
+
outline: [
|
|
47
|
+
{
|
|
48
|
+
title: '第一节',
|
|
49
|
+
timestamp: 0,
|
|
50
|
+
part_outline: [
|
|
51
|
+
{ timestamp: 12, content: '要点A' },
|
|
52
|
+
{ timestamp: 3725, content: '要点B' },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const result = await command.func(page, { bvid: 'BV1xxx' });
|
|
59
|
+
|
|
60
|
+
expect(mockApiGet).toHaveBeenNthCalledWith(1, page, '/x/web-interface/view', { params: { bvid: 'BV1xxx' } });
|
|
61
|
+
expect(mockApiGet).toHaveBeenNthCalledWith(2, page, '/x/web-interface/view/conclusion/get', {
|
|
62
|
+
params: { bvid: 'BV1xxx', cid: 222, up_mid: 333 },
|
|
63
|
+
signed: true,
|
|
64
|
+
});
|
|
65
|
+
expect(result).toEqual([
|
|
66
|
+
{ time: '', content: '整体总结' },
|
|
67
|
+
{ time: '00:00', content: '# 第一节' },
|
|
68
|
+
{ time: '00:12', content: '要点A' },
|
|
69
|
+
{ time: '1:02:05', content: '要点B' },
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('returns just the summary when the video has no outline', async () => {
|
|
74
|
+
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
|
|
75
|
+
mockConclusion({ summary: '只有总结', outline: [] });
|
|
76
|
+
|
|
77
|
+
await expect(command.func(page, { bvid: 'BV1xxx' })).resolves.toEqual([
|
|
78
|
+
{ time: '', content: '只有总结' },
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('parses model_result when Bilibili returns it as a JSON string', async () => {
|
|
83
|
+
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
|
|
84
|
+
mockConclusion(JSON.stringify({ summary: '字符串总结', outline: [] }));
|
|
85
|
+
|
|
86
|
+
await expect(command.func(page, { bvid: 'BV1xxx' })).resolves.toEqual([
|
|
87
|
+
{ time: '', content: '字符串总结' },
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('normalizes Bilibili video URLs before calling the APIs', async () => {
|
|
92
|
+
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
|
|
93
|
+
mockConclusion({ summary: 'URL 总结', outline: [] });
|
|
94
|
+
|
|
95
|
+
await command.func(page, {
|
|
96
|
+
bvid: 'https://www.bilibili.com/video/BV1abc12345/?spm_id_from=333.1007',
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(mockApiGet).toHaveBeenNthCalledWith(1, page, '/x/web-interface/view', { params: { bvid: 'BV1abc12345' } });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('resolves b23.tv short links through the shared resolver', async () => {
|
|
103
|
+
mockResolveBvid.mockResolvedValueOnce('BVshort12345');
|
|
104
|
+
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
|
|
105
|
+
mockConclusion({ summary: '短链总结', outline: [] });
|
|
106
|
+
|
|
107
|
+
await command.func(page, { bvid: 'https://b23.tv/abc' });
|
|
108
|
+
|
|
109
|
+
expect(mockResolveBvid).toHaveBeenCalledWith('https://b23.tv/abc');
|
|
110
|
+
expect(mockApiGet).toHaveBeenNthCalledWith(1, page, '/x/web-interface/view', { params: { bvid: 'BVshort12345' } });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('rejects invalid inputs before calling Bilibili APIs', async () => {
|
|
114
|
+
const cases = [
|
|
115
|
+
'',
|
|
116
|
+
'javascript:alert(1)',
|
|
117
|
+
'https://example.com/video/BV1abc12345',
|
|
118
|
+
'https://share.note.youdao.com/video/BV1abc12345',
|
|
119
|
+
'https://www.bilibili.com/read/cv12345',
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
for (const bvid of cases) {
|
|
123
|
+
await expect(command.func(page, { bvid })).rejects.toBeInstanceOf(ArgumentError);
|
|
124
|
+
}
|
|
125
|
+
expect(mockApiGet).not.toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('maps unresolved short-code inputs to ArgumentError without calling APIs', async () => {
|
|
129
|
+
await expect(command.func(page, { bvid: 'not-a-bv' })).rejects.toBeInstanceOf(ArgumentError);
|
|
130
|
+
|
|
131
|
+
expect(mockResolveBvid).toHaveBeenCalledWith('not-a-bv');
|
|
132
|
+
expect(mockApiGet).not.toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('throws EmptyResultError when Bilibili has not generated an AI summary for the video', async () => {
|
|
136
|
+
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
|
|
137
|
+
mockApiGet.mockResolvedValueOnce({ code: 0, data: { code: 1, model_result: {} } });
|
|
138
|
+
|
|
139
|
+
await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toBeInstanceOf(EmptyResultError);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('throws CommandExecutionError when the view payload is malformed', async () => {
|
|
143
|
+
mockApiGet.mockResolvedValueOnce({ code: 0, data: {} });
|
|
144
|
+
|
|
145
|
+
await expect(command.func(page, { bvid: 'BVbroken' })).rejects.toSatisfy(
|
|
146
|
+
(err) => err instanceof CommandExecutionError && /cid\/up_mid/.test(err.message),
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('throws CommandExecutionError when the view API returns a non-auth error', async () => {
|
|
151
|
+
mockApiGet.mockResolvedValueOnce({ code: -404, message: '啥都木有' });
|
|
152
|
+
|
|
153
|
+
await expect(command.func(page, { bvid: 'BVbroken' })).rejects.toSatisfy(
|
|
154
|
+
(err) => err instanceof CommandExecutionError && /啥都木有.*-404/.test(err.message),
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('maps conclusion auth or permission errors to AuthRequiredError', async () => {
|
|
159
|
+
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
|
|
160
|
+
mockApiGet.mockResolvedValueOnce({ code: -403, message: '访问权限不足' });
|
|
161
|
+
|
|
162
|
+
await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toBeInstanceOf(AuthRequiredError);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('maps conclusion non-auth API errors to CommandExecutionError', async () => {
|
|
166
|
+
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
|
|
167
|
+
mockApiGet.mockResolvedValueOnce({ code: -500, message: 'server error' });
|
|
168
|
+
|
|
169
|
+
await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toSatisfy(
|
|
170
|
+
(err) => err instanceof CommandExecutionError && /server error.*-500/.test(err.message),
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('throws CommandExecutionError for malformed conclusion API payloads', async () => {
|
|
175
|
+
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
|
|
176
|
+
mockApiGet.mockResolvedValueOnce(null);
|
|
177
|
+
|
|
178
|
+
await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('throws CommandExecutionError for malformed model_result JSON', async () => {
|
|
182
|
+
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
|
|
183
|
+
mockConclusion('{bad json');
|
|
184
|
+
|
|
185
|
+
await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toSatisfy(
|
|
186
|
+
(err) => err instanceof CommandExecutionError && /model_result JSON/.test(err.message),
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('throws CommandExecutionError for malformed outline shapes', async () => {
|
|
191
|
+
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
|
|
192
|
+
mockConclusion({ summary: '坏 outline', outline: {} });
|
|
193
|
+
|
|
194
|
+
await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toSatisfy(
|
|
195
|
+
(err) => err instanceof CommandExecutionError && /outline/.test(err.message),
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('throws CommandExecutionError for malformed part outline shapes', async () => {
|
|
200
|
+
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
|
|
201
|
+
mockConclusion({
|
|
202
|
+
summary: '坏 part_outline',
|
|
203
|
+
outline: [{ title: '段落', timestamp: 0, part_outline: {} }],
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toSatisfy(
|
|
207
|
+
(err) => err instanceof CommandExecutionError && /part outline/.test(err.message),
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
4
|
+
import './search.js';
|
|
5
|
+
import { __test__ } from './search.js';
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
normalizePositiveInt,
|
|
9
|
+
normalizeNonNegativeInt,
|
|
10
|
+
normalizeDate,
|
|
11
|
+
normalizeCurrency,
|
|
12
|
+
normalizeLang,
|
|
13
|
+
hasPositiveResultCount,
|
|
14
|
+
buildSearchUrl,
|
|
15
|
+
} = __test__;
|
|
16
|
+
|
|
17
|
+
describe('booking helpers — normalizePositiveInt (no silent clamp)', () => {
|
|
18
|
+
it('returns default when value is undefined/null/empty', () => {
|
|
19
|
+
expect(normalizePositiveInt(undefined, 2, 'adults', 30)).toBe(2);
|
|
20
|
+
expect(normalizePositiveInt(null, 2, 'adults', 30)).toBe(2);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('accepts integers in range', () => {
|
|
24
|
+
expect(normalizePositiveInt(1, 2, 'adults', 30)).toBe(1);
|
|
25
|
+
expect(normalizePositiveInt(30, 2, 'adults', 30)).toBe(30);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('rejects zero / negative / out-of-range / non-integer (no silent clamp)', () => {
|
|
29
|
+
expect(() => normalizePositiveInt(0, 2, 'adults', 30)).toThrow(ArgumentError);
|
|
30
|
+
expect(() => normalizePositiveInt(-1, 2, 'adults', 30)).toThrow(ArgumentError);
|
|
31
|
+
expect(() => normalizePositiveInt(31, 2, 'adults', 30)).toThrow(ArgumentError);
|
|
32
|
+
expect(() => normalizePositiveInt(1.5, 2, 'adults', 30)).toThrow(ArgumentError);
|
|
33
|
+
expect(() => normalizePositiveInt('abc', 2, 'adults', 30)).toThrow(ArgumentError);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('booking helpers — normalizeNonNegativeInt', () => {
|
|
38
|
+
it('accepts zero', () => {
|
|
39
|
+
expect(normalizeNonNegativeInt(0, 0, 'children', 10)).toBe(0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('rejects negative / out-of-range (no silent clamp)', () => {
|
|
43
|
+
expect(() => normalizeNonNegativeInt(-1, 0, 'children', 10)).toThrow(ArgumentError);
|
|
44
|
+
expect(() => normalizeNonNegativeInt(11, 0, 'children', 10)).toThrow(ArgumentError);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('booking helpers — normalizeDate', () => {
|
|
49
|
+
it('accepts YYYY-MM-DD', () => {
|
|
50
|
+
expect(normalizeDate('2026-06-15', 'checkin')).toBe('2026-06-15');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('rejects bad format / nonsense dates with ArgumentError', () => {
|
|
54
|
+
expect(() => normalizeDate('', 'checkin')).toThrow(ArgumentError);
|
|
55
|
+
expect(() => normalizeDate('06/15/2026', 'checkin')).toThrow(ArgumentError);
|
|
56
|
+
expect(() => normalizeDate('2026-13-40', 'checkin')).toThrow(ArgumentError);
|
|
57
|
+
expect(() => normalizeDate('2026-02-31', 'checkin')).toThrow(ArgumentError);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('booking helpers — normalizeCurrency', () => {
|
|
62
|
+
it('passes 3-letter codes uppercased', () => {
|
|
63
|
+
expect(normalizeCurrency('usd')).toBe('USD');
|
|
64
|
+
expect(normalizeCurrency('JPY')).toBe('JPY');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns empty for unset', () => {
|
|
68
|
+
expect(normalizeCurrency(undefined)).toBe('');
|
|
69
|
+
expect(normalizeCurrency('')).toBe('');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('rejects non-3-letter codes', () => {
|
|
73
|
+
expect(() => normalizeCurrency('US')).toThrow(ArgumentError);
|
|
74
|
+
expect(() => normalizeCurrency('US$')).toThrow(ArgumentError);
|
|
75
|
+
expect(() => normalizeCurrency('USDX')).toThrow(ArgumentError);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('booking helpers — normalizeLang whitelist', () => {
|
|
80
|
+
it('lowercases supported langs', () => {
|
|
81
|
+
expect(normalizeLang('EN-US')).toBe('en-us');
|
|
82
|
+
expect(normalizeLang('zh-cn')).toBe('zh-cn');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('rejects unknown langs', () => {
|
|
86
|
+
expect(() => normalizeLang('xx-yy')).toThrow(ArgumentError);
|
|
87
|
+
expect(() => normalizeLang('en')).toThrow(ArgumentError);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('booking helpers — buildSearchUrl', () => {
|
|
92
|
+
it('constructs canonical search URL with required params', () => {
|
|
93
|
+
const url = buildSearchUrl({
|
|
94
|
+
destination: 'Tokyo',
|
|
95
|
+
checkin: '2026-06-15',
|
|
96
|
+
checkout: '2026-06-17',
|
|
97
|
+
adults: 2,
|
|
98
|
+
rooms: 1,
|
|
99
|
+
children: 0,
|
|
100
|
+
offset: 0,
|
|
101
|
+
currency: 'USD',
|
|
102
|
+
lang: 'en-us',
|
|
103
|
+
});
|
|
104
|
+
expect(url).toContain('https://www.booking.com/searchresults.en-us.html');
|
|
105
|
+
expect(url).toContain('ss=Tokyo');
|
|
106
|
+
expect(url).toContain('checkin=2026-06-15');
|
|
107
|
+
expect(url).toContain('checkout=2026-06-17');
|
|
108
|
+
expect(url).toContain('group_adults=2');
|
|
109
|
+
expect(url).toContain('no_rooms=1');
|
|
110
|
+
expect(url).toContain('group_children=0');
|
|
111
|
+
expect(url).toContain('selected_currency=USD');
|
|
112
|
+
expect(url).not.toContain('offset=');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('omits lang file segment when lang is empty', () => {
|
|
116
|
+
const url = buildSearchUrl({
|
|
117
|
+
destination: 'Paris', checkin: '2026-06-15', checkout: '2026-06-17',
|
|
118
|
+
adults: 2, rooms: 1, children: 0, offset: 0, currency: '', lang: '',
|
|
119
|
+
});
|
|
120
|
+
expect(url).toMatch(/booking\.com\/searchresults\.html\?/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('emits offset only when > 0', () => {
|
|
124
|
+
const url = buildSearchUrl({
|
|
125
|
+
destination: 'Paris', checkin: '2026-06-15', checkout: '2026-06-17',
|
|
126
|
+
adults: 2, rooms: 1, children: 0, offset: 25, currency: '', lang: '',
|
|
127
|
+
});
|
|
128
|
+
expect(url).toContain('offset=25');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('booking helpers — hasPositiveResultCount', () => {
|
|
133
|
+
it('detects positive Booking result-count evidence', () => {
|
|
134
|
+
expect(hasPositiveResultCount('Tokyo: 1,234 properties found')).toBe(true);
|
|
135
|
+
expect(hasPositiveResultCount('1 stay found')).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('does not treat no-results text as positive evidence', () => {
|
|
139
|
+
expect(hasPositiveResultCount('No properties found')).toBe(false);
|
|
140
|
+
expect(hasPositiveResultCount('0 properties found')).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('booking adapter registry shape', () => {
|
|
145
|
+
it('search is registered as read with id-shaped column for round-trip', () => {
|
|
146
|
+
const search = getRegistry().get('booking/search');
|
|
147
|
+
expect(search).toBeDefined();
|
|
148
|
+
expect(search.access).toBe('read');
|
|
149
|
+
expect(search.browser).toBe(true);
|
|
150
|
+
// slug + country together form the round-trip identity (URL: /hotel/<country>/<slug>.html)
|
|
151
|
+
expect(search.columns).toContain('slug');
|
|
152
|
+
expect(search.columns).toContain('country');
|
|
153
|
+
expect(search.columns).toContain('url');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('search columns stay <= 12 to honor agent-native row shape', () => {
|
|
157
|
+
const search = getRegistry().get('booking/search');
|
|
158
|
+
expect(search.columns.length).toBeLessThanOrEqual(12);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('booking search — typed errors (no silent fallback)', () => {
|
|
163
|
+
const fakePage = { goto: () => { throw new Error('should not navigate'); } };
|
|
164
|
+
|
|
165
|
+
it('rejects empty destination with ArgumentError', async () => {
|
|
166
|
+
const search = getRegistry().get('booking/search');
|
|
167
|
+
await expect(search.func(fakePage, { destination: ' ', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(ArgumentError);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('rejects missing checkin/checkout with ArgumentError', async () => {
|
|
171
|
+
const search = getRegistry().get('booking/search');
|
|
172
|
+
await expect(search.func(fakePage, { destination: 'Tokyo' })).rejects.toThrow(ArgumentError);
|
|
173
|
+
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15' })).rejects.toThrow(ArgumentError);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('rejects checkout <= checkin with ArgumentError', async () => {
|
|
177
|
+
const search = getRegistry().get('booking/search');
|
|
178
|
+
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-17', checkout: '2026-06-15' })).rejects.toThrow(ArgumentError);
|
|
179
|
+
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-15' })).rejects.toThrow(ArgumentError);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('rejects out-of-range --limit with ArgumentError (no silent clamp to 100)', async () => {
|
|
183
|
+
const search = getRegistry().get('booking/search');
|
|
184
|
+
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17', limit: 999 })).rejects.toThrow(ArgumentError);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('rejects negative --offset with ArgumentError', async () => {
|
|
188
|
+
const search = getRegistry().get('booking/search');
|
|
189
|
+
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17', offset: -1 })).rejects.toThrow(ArgumentError);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('rejects unsupported --lang with ArgumentError', async () => {
|
|
193
|
+
const search = getRegistry().get('booking/search');
|
|
194
|
+
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17', lang: 'xx-yy' })).rejects.toThrow(ArgumentError);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('rejects malformed --currency with ArgumentError', async () => {
|
|
198
|
+
const search = getRegistry().get('booking/search');
|
|
199
|
+
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17', currency: 'US$' })).rejects.toThrow(ArgumentError);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('wraps browser navigation failures as CommandExecutionError', async () => {
|
|
203
|
+
const search = getRegistry().get('booking/search');
|
|
204
|
+
const downPage = { goto: () => Promise.reject(new Error('browser down')) };
|
|
205
|
+
await expect(search.func(downPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(CommandExecutionError);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('throws EmptyResultError when extractor returns no cards', async () => {
|
|
209
|
+
const search = getRegistry().get('booking/search');
|
|
210
|
+
const emptyPage = {
|
|
211
|
+
goto: async () => {},
|
|
212
|
+
wait: async () => {},
|
|
213
|
+
evaluate: async () => ({ ok: true, items: [], blocked: false, totalText: 'No properties found' }),
|
|
214
|
+
};
|
|
215
|
+
await expect(search.func(emptyPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(EmptyResultError);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('throws CommandExecutionError when result-count evidence exists but no cards were parsed', async () => {
|
|
219
|
+
const search = getRegistry().get('booking/search');
|
|
220
|
+
const driftPage = {
|
|
221
|
+
goto: async () => {},
|
|
222
|
+
wait: async () => {},
|
|
223
|
+
evaluate: async () => ({ ok: true, items: [], blocked: false, totalText: 'Tokyo: 1,234 properties found' }),
|
|
224
|
+
};
|
|
225
|
+
await expect(search.func(driftPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(CommandExecutionError);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('throws CommandExecutionError when captcha is detected', async () => {
|
|
229
|
+
const search = getRegistry().get('booking/search');
|
|
230
|
+
const blockedPage = {
|
|
231
|
+
goto: async () => {},
|
|
232
|
+
wait: async () => {},
|
|
233
|
+
evaluate: async () => ({ ok: true, items: [], blocked: true, totalText: 'Verify you are human' }),
|
|
234
|
+
};
|
|
235
|
+
await expect(search.func(blockedPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(CommandExecutionError);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('throws CommandExecutionError when extractor payload is malformed instead of treating it as empty', async () => {
|
|
239
|
+
const search = getRegistry().get('booking/search');
|
|
240
|
+
const malformedPage = {
|
|
241
|
+
goto: async () => {},
|
|
242
|
+
wait: async () => {},
|
|
243
|
+
evaluate: async () => ({ ok: true, blocked: false, totalText: 'Tokyo hotels' }),
|
|
244
|
+
};
|
|
245
|
+
await expect(search.func(malformedPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(CommandExecutionError);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('throws CommandExecutionError when rendered cards lack stable hotel URL identity', async () => {
|
|
249
|
+
const search = getRegistry().get('booking/search');
|
|
250
|
+
const driftPage = {
|
|
251
|
+
goto: async () => {},
|
|
252
|
+
wait: async () => {},
|
|
253
|
+
evaluate: async () => ({
|
|
254
|
+
ok: true,
|
|
255
|
+
blocked: false,
|
|
256
|
+
totalText: 'Tokyo hotels',
|
|
257
|
+
items: [{
|
|
258
|
+
name: 'Unlinked Hotel',
|
|
259
|
+
country: '',
|
|
260
|
+
slug: '',
|
|
261
|
+
url: '',
|
|
262
|
+
distance: '',
|
|
263
|
+
review_score: null,
|
|
264
|
+
review_count: null,
|
|
265
|
+
star_rating: null,
|
|
266
|
+
price_currency: '',
|
|
267
|
+
price_amount: null,
|
|
268
|
+
recommended_room: '',
|
|
269
|
+
}],
|
|
270
|
+
}),
|
|
271
|
+
};
|
|
272
|
+
await expect(search.func(driftPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(CommandExecutionError);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('unwraps {session, data} envelope from CDP bridge before validating', async () => {
|
|
276
|
+
const search = getRegistry().get('booking/search');
|
|
277
|
+
const envelopePage = {
|
|
278
|
+
goto: async () => {},
|
|
279
|
+
wait: async () => {},
|
|
280
|
+
evaluate: async () => ({
|
|
281
|
+
session: 1,
|
|
282
|
+
data: {
|
|
283
|
+
ok: true,
|
|
284
|
+
blocked: false,
|
|
285
|
+
totalText: '',
|
|
286
|
+
items: [{
|
|
287
|
+
name: 'Test Hotel',
|
|
288
|
+
country: 'jp',
|
|
289
|
+
slug: 'test-hotel',
|
|
290
|
+
url: 'https://www.booking.com/hotel/jp/test-hotel.html',
|
|
291
|
+
distance: '1 km from centre',
|
|
292
|
+
review_score: 8.6,
|
|
293
|
+
review_count: 100,
|
|
294
|
+
star_rating: 4,
|
|
295
|
+
price_currency: 'USD',
|
|
296
|
+
price_amount: 120,
|
|
297
|
+
recommended_room: 'Standard double',
|
|
298
|
+
}],
|
|
299
|
+
},
|
|
300
|
+
}),
|
|
301
|
+
};
|
|
302
|
+
const rows = await search.func(envelopePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' });
|
|
303
|
+
expect(rows).toHaveLength(1);
|
|
304
|
+
expect(rows[0].rank).toBe(1);
|
|
305
|
+
expect(rows[0].slug).toBe('test-hotel');
|
|
306
|
+
expect(rows[0].url).toBe('https://www.booking.com/hotel/jp/test-hotel.html');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('uses requested selected_currency as the output source when price is present', async () => {
|
|
310
|
+
const search = getRegistry().get('booking/search');
|
|
311
|
+
const currencyPage = {
|
|
312
|
+
goto: async () => {},
|
|
313
|
+
wait: async () => {},
|
|
314
|
+
evaluate: async () => ({
|
|
315
|
+
ok: true,
|
|
316
|
+
blocked: false,
|
|
317
|
+
totalText: '',
|
|
318
|
+
items: [{
|
|
319
|
+
name: 'Currency Hotel',
|
|
320
|
+
country: 'cn',
|
|
321
|
+
slug: 'currency-hotel',
|
|
322
|
+
url: 'https://www.booking.com/hotel/cn/currency-hotel.html',
|
|
323
|
+
distance: '',
|
|
324
|
+
review_score: null,
|
|
325
|
+
review_count: null,
|
|
326
|
+
star_rating: null,
|
|
327
|
+
price_currency: 'JPY',
|
|
328
|
+
price_amount: 880,
|
|
329
|
+
recommended_room: '',
|
|
330
|
+
}],
|
|
331
|
+
}),
|
|
332
|
+
};
|
|
333
|
+
const rows = await search.func(currencyPage, { destination: 'Shanghai', checkin: '2026-06-15', checkout: '2026-06-17', currency: 'CNY' });
|
|
334
|
+
expect(rows[0].price_currency).toBe('CNY');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('respects offset for rank numbering when paginating', async () => {
|
|
338
|
+
const search = getRegistry().get('booking/search');
|
|
339
|
+
const pagedPage = {
|
|
340
|
+
goto: async () => {},
|
|
341
|
+
wait: async () => {},
|
|
342
|
+
evaluate: async () => ({
|
|
343
|
+
ok: true,
|
|
344
|
+
blocked: false,
|
|
345
|
+
totalText: '',
|
|
346
|
+
items: [
|
|
347
|
+
{ name: 'A', country: 'jp', slug: 'a', url: 'https://www.booking.com/hotel/jp/a.html', distance: '', review_score: null, review_count: null, star_rating: null, price_currency: '', price_amount: null, recommended_room: '' },
|
|
348
|
+
{ name: 'B', country: 'jp', slug: 'b', url: 'https://www.booking.com/hotel/jp/b.html', distance: '', review_score: null, review_count: null, star_rating: null, price_currency: '', price_amount: null, recommended_room: '' },
|
|
349
|
+
],
|
|
350
|
+
}),
|
|
351
|
+
};
|
|
352
|
+
const rows = await search.func(pagedPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17', offset: 50 });
|
|
353
|
+
expect(rows[0].rank).toBe(51);
|
|
354
|
+
expect(rows[1].rank).toBe(52);
|
|
355
|
+
});
|
|
356
|
+
});
|