@jackwener/opencli 1.7.22 → 1.8.1
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 +35 -194
- package/README.zh-CN.md +42 -260
- package/cli-manifest.json +8160 -4392
- 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/_atlassian/shared.js +577 -0
- package/clis/_atlassian/shared.test.js +170 -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/comment.js +125 -0
- package/clis/bilibili/comment.test.js +153 -0
- package/clis/bilibili/comments.js +116 -21
- package/clis/bilibili/comments.test.js +77 -18
- package/clis/bilibili/subtitle.js +76 -31
- package/clis/bilibili/subtitle.test.js +156 -9
- package/clis/bilibili/summary.js +167 -0
- package/clis/bilibili/summary.test.js +210 -0
- package/clis/bilibili/utils.js +63 -5
- package/clis/bilibili/utils.test.js +45 -1
- package/clis/booking/booking.test.js +356 -0
- package/clis/booking/search.js +351 -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/chess/analyze.js +35 -0
- package/clis/chess/analyze.test.js +79 -0
- package/clis/chess/game.js +114 -0
- package/clis/chess/game.test.js +178 -0
- package/clis/chess/games.js +67 -0
- package/clis/chess/games.test.js +164 -0
- package/clis/chess/stats.js +32 -0
- package/clis/chess/stats.test.js +79 -0
- package/clis/chess/utils.js +170 -0
- package/clis/chess/utils.test.js +230 -0
- package/clis/confluence/commands.test.js +195 -0
- package/clis/confluence/create.js +39 -0
- package/clis/confluence/page.js +23 -0
- package/clis/confluence/search.js +34 -0
- package/clis/confluence/shared.js +173 -0
- package/clis/confluence/update.js +38 -0
- 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/hashtag.js +84 -23
- package/clis/douyin/hashtag.test.js +113 -0
- 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/geogebra/add-circle.js +46 -0
- package/clis/geogebra/add-line.js +35 -0
- package/clis/geogebra/add-point.js +27 -0
- package/clis/geogebra/add-polygon.js +25 -0
- package/clis/geogebra/eval.js +35 -0
- package/clis/geogebra/geogebra.test.js +175 -0
- package/clis/geogebra/hexagon.js +62 -0
- package/clis/geogebra/info.js +72 -0
- package/clis/geogebra/list.js +35 -0
- package/clis/geogebra/triangle.js +60 -0
- package/clis/geogebra/utils.js +271 -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/jira/attachments.js +28 -0
- package/clis/jira/commands.test.js +287 -0
- package/clis/jira/comments.js +28 -0
- package/clis/jira/issue.js +28 -0
- package/clis/jira/links.js +28 -0
- package/clis/jira/search.js +47 -0
- package/clis/jira/shared.js +256 -0
- 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/job-detail.js +167 -0
- package/clis/linkedin/job-detail.test.js +38 -0
- package/clis/linkedin/jobs-preferences.js +113 -0
- package/clis/linkedin/jobs-preferences.test.js +43 -0
- package/clis/linkedin/people-search.js +262 -0
- package/clis/linkedin/people-search.test.js +216 -0
- package/clis/linkedin/post-analytics.js +74 -0
- package/clis/linkedin/post-analytics.test.js +40 -0
- package/clis/linkedin/posts-core.js +241 -0
- package/clis/linkedin/posts.js +22 -0
- package/clis/linkedin/posts.test.js +40 -0
- package/clis/linkedin/profile-analytics.js +104 -0
- package/clis/linkedin/profile-analytics.test.js +67 -0
- package/clis/linkedin/profile-experience.js +671 -0
- package/clis/linkedin/profile-experience.test.js +152 -0
- package/clis/linkedin/profile-projects.js +311 -0
- package/clis/linkedin/profile-projects.test.js +111 -0
- package/clis/linkedin/profile-read.js +148 -0
- package/clis/linkedin/profile-read.test.js +77 -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/services-read.js +213 -0
- package/clis/linkedin/services-read.test.js +105 -0
- package/clis/linkedin/shared.js +124 -0
- package/clis/linkedin/thread-snapshot.js +214 -0
- package/clis/linkedin/thread-snapshot.test.js +89 -0
- package/clis/linkedin/timeline.js +14 -7
- 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/notebooklm/add-source.js +269 -0
- package/clis/notebooklm/add-source.test.js +97 -0
- package/clis/notebooklm/create.js +76 -0
- package/clis/notebooklm/create.test.js +58 -0
- package/clis/notebooklm/generate-audio.js +91 -0
- package/clis/notebooklm/generate-audio.test.js +63 -0
- package/clis/notebooklm/generate-slides.js +106 -0
- package/clis/notebooklm/generate-slides.test.js +75 -0
- package/clis/notebooklm/open.test.js +10 -10
- package/clis/notebooklm/rpc.js +20 -6
- package/clis/notebooklm/rpc.test.js +27 -1
- package/clis/notebooklm/utils.js +100 -24
- package/clis/notebooklm/utils.test.js +60 -1
- package/clis/notebooklm/write-note.js +103 -0
- package/clis/notebooklm/write-note.test.js +70 -0
- package/clis/pixiv/detail.js +41 -34
- package/clis/pixiv/detail.test.js +93 -0
- package/clis/pixiv/user.js +36 -31
- package/clis/pixiv/user.test.js +100 -0
- package/clis/pixiv/utils.js +56 -7
- 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 +231 -0
- package/clis/suno/generate.test.js +252 -0
- package/clis/suno/list.js +79 -0
- package/clis/suno/status.js +63 -0
- package/clis/suno/utils.js +549 -0
- package/clis/suno/utils.test.js +329 -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/followers.js +6 -2
- package/clis/twitter/followers.test.js +19 -1
- package/clis/twitter/following.js +14 -5
- package/clis/twitter/following.test.js +29 -0
- package/clis/twitter/likes.js +12 -4
- package/clis/twitter/likes.test.js +26 -1
- package/clis/twitter/list-add.js +1 -1
- package/clis/twitter/list-create.js +155 -0
- package/clis/twitter/list-create.test.js +169 -0
- package/clis/twitter/list-remove.js +13 -6
- 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/notifications.js +4 -4
- package/clis/twitter/post.js +62 -4
- package/clis/twitter/post.test.js +35 -3
- package/clis/twitter/profile.js +81 -28
- package/clis/twitter/profile.test.js +113 -2
- package/clis/twitter/quote.js +9 -4
- package/clis/twitter/reply.js +13 -10
- package/clis/twitter/reply.test.js +41 -0
- package/clis/twitter/search.js +7 -3
- package/clis/twitter/search.test.js +41 -0
- package/clis/twitter/shared.js +155 -0
- package/clis/twitter/shared.test.js +465 -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/twitter/utils.js +53 -16
- package/clis/upwork/detail.js +132 -0
- package/clis/upwork/feed.js +109 -0
- package/clis/upwork/search.js +115 -0
- package/clis/upwork/upwork.test.js +566 -0
- package/clis/upwork/utils.js +323 -0
- package/clis/weibo/delete.js +172 -0
- package/clis/weibo/delete.test.js +94 -0
- package/clis/weibo/publish.js +37 -14
- package/clis/weibo/publish.test.js +14 -5
- package/clis/weibo/user-posts.js +234 -0
- package/clis/weibo/user-posts.test.js +92 -0
- package/clis/weread/book-search.js +438 -0
- package/clis/weread/book-search.test.js +242 -0
- package/clis/weread/search-regression.test.js +98 -11
- package/clis/weread/search.js +32 -9
- 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 +166 -28
- package/clis/xiaohongshu/creator-note-detail.test.js +196 -36
- 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 +252 -2
- package/clis/xiaohongshu/creator-notes.test.js +90 -1
- 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/download.js +97 -39
- package/clis/xiaohongshu/download.test.js +201 -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 +280 -0
- package/clis/zhihu/answer-comments.test.js +287 -0
- package/clis/zhihu/answer-detail.js +2 -19
- package/clis/zhihu/answer-detail.test.js +8 -0
- package/clis/zhihu/collection.js +17 -16
- package/clis/zhihu/collection.test.js +50 -3
- package/clis/zhihu/download.js +1 -1
- package/clis/zhihu/question.js +42 -17
- package/clis/zhihu/question.test.js +113 -11
- package/clis/zhihu/search.js +195 -43
- package/clis/zhihu/search.test.js +198 -0
- package/clis/zhihu/text.js +29 -0
- package/clis/zhihu/text.test.js +24 -0
- package/dist/src/browser/errors.js +4 -2
- package/dist/src/browser/errors.test.js +6 -0
- package/dist/src/browser/network-cache.js +13 -1
- package/dist/src/browser/network-cache.test.js +17 -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/convention-audit.js +15 -8
- package/dist/src/convention-audit.test.js +21 -0
- package/dist/src/download/index.js +13 -1
- package/dist/src/download/index.test.js +23 -1
- 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 +112 -0
- package/dist/src/download/progress.js +2 -2
- package/dist/src/download/progress.test.js +12 -1
- 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 +8 -0
- package/dist/src/main.js +14 -2
- package/dist/src/output.js +11 -1
- package/dist/src/output.test.js +6 -0
- package/dist/src/registry.js +1 -0
- package/dist/src/registry.test.js +11 -0
- 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,188 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
|
|
4
|
+
const mocks = vi.hoisted(() => ({
|
|
5
|
+
ensureSunoSession: vi.fn(),
|
|
6
|
+
checkSunoCaptcha: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock('./utils.js', () => ({
|
|
10
|
+
STUDIO_API: 'https://studio-api-prod.suno.com',
|
|
11
|
+
SUNO_DOMAIN: 'suno.com',
|
|
12
|
+
SUNO_URL: 'https://suno.com',
|
|
13
|
+
ensureSunoSession: mocks.ensureSunoSession,
|
|
14
|
+
checkSunoCaptcha: mocks.checkSunoCaptcha,
|
|
15
|
+
requireNonNegativeInt: (value) => {
|
|
16
|
+
const n = Number(value);
|
|
17
|
+
if (!Number.isInteger(n) || n < 0) throw Object.assign(new Error('non-negative int required'), { code: 'ARGUMENT' });
|
|
18
|
+
return n;
|
|
19
|
+
},
|
|
20
|
+
requirePositiveInt: (value) => {
|
|
21
|
+
const n = Number(value);
|
|
22
|
+
if (!Number.isInteger(n) || n < 1) throw Object.assign(new Error('positive int required'), { code: 'ARGUMENT' });
|
|
23
|
+
return n;
|
|
24
|
+
},
|
|
25
|
+
unwrapEvaluateResult: (value) => value && typeof value === 'object' && 'session' in value && 'data' in value ? value.data : value,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const { statusCommand } = await import('./status.js');
|
|
29
|
+
const { listCommand } = await import('./list.js');
|
|
30
|
+
|
|
31
|
+
function createPage(evaluateImpl) {
|
|
32
|
+
return {
|
|
33
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
34
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
35
|
+
evaluate: evaluateImpl || vi.fn().mockResolvedValue(undefined),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
vi.restoreAllMocks();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('suno status', () => {
|
|
44
|
+
it('reports "Not logged in" when ensureSunoSession throws AuthRequiredError', async () => {
|
|
45
|
+
mocks.ensureSunoSession.mockRejectedValue(new AuthRequiredError('suno.com', 'Auth required'));
|
|
46
|
+
const out = await statusCommand.func(createPage());
|
|
47
|
+
expect(out).toEqual([{
|
|
48
|
+
Status: 'Not logged in',
|
|
49
|
+
Plan: '-',
|
|
50
|
+
Credits: '-',
|
|
51
|
+
Monthly: '-',
|
|
52
|
+
Captcha: '-',
|
|
53
|
+
}]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('does not hide non-auth status failures as logged-out rows', async () => {
|
|
57
|
+
mocks.ensureSunoSession.mockRejectedValue(new CommandExecutionError('malformed billing payload'));
|
|
58
|
+
await expect(statusCommand.func(createPage())).rejects.toMatchObject({
|
|
59
|
+
code: 'COMMAND_EXEC',
|
|
60
|
+
message: expect.stringContaining('malformed billing payload'),
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns plan / credits / captcha when the session is healthy', async () => {
|
|
65
|
+
mocks.ensureSunoSession.mockResolvedValue({
|
|
66
|
+
ok: true,
|
|
67
|
+
planKey: 'pro',
|
|
68
|
+
planId: '3eaebef3',
|
|
69
|
+
totalCreditsAvailable: 2095,
|
|
70
|
+
breakdown: { pack: 0, purchasedPacks: 0, monthlyRemaining: 2095, monthlyLimit: 2500, monthlyUsed: 405 },
|
|
71
|
+
deviceId: 'device-uuid',
|
|
72
|
+
});
|
|
73
|
+
mocks.checkSunoCaptcha.mockResolvedValue({ ok: true, required: false });
|
|
74
|
+
const out = await statusCommand.func(createPage());
|
|
75
|
+
expect(out[0]).toMatchObject({
|
|
76
|
+
Status: 'Connected',
|
|
77
|
+
Plan: 'pro',
|
|
78
|
+
Credits: '2095',
|
|
79
|
+
Monthly: '2095/2500',
|
|
80
|
+
Captcha: 'Not required',
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('reports captcha required when /api/c/check says so', async () => {
|
|
85
|
+
mocks.ensureSunoSession.mockResolvedValue({
|
|
86
|
+
ok: true, planKey: 'pro', planId: 'x', totalCreditsAvailable: 100,
|
|
87
|
+
breakdown: { pack: 0, purchasedPacks: 0, monthlyRemaining: 100, monthlyLimit: 2500, monthlyUsed: 2400 },
|
|
88
|
+
deviceId: 'device-uuid',
|
|
89
|
+
});
|
|
90
|
+
mocks.checkSunoCaptcha.mockResolvedValue({ ok: true, required: true });
|
|
91
|
+
const out = await statusCommand.func(createPage());
|
|
92
|
+
expect(out[0].Captcha).toContain('Required');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('suno list', () => {
|
|
97
|
+
it('returns clip rows with truncated id, title, and pagination-aware rank', async () => {
|
|
98
|
+
mocks.ensureSunoSession.mockResolvedValue({ deviceId: 'device-uuid' });
|
|
99
|
+
const page = createPage(vi.fn().mockResolvedValue({
|
|
100
|
+
ok: true,
|
|
101
|
+
clips: [
|
|
102
|
+
{ id: 'aaaaaaaa-1111-2222-3333-444444444444', title: 'Track One', status: 'complete', created_at: '2026-05-17T11:14:26.338Z' },
|
|
103
|
+
{ id: 'bbbbbbbb-1111-2222-3333-444444444444', title: 'Track Two', status: 'streaming', created_at: '2026-05-17T11:00:00.000Z' },
|
|
104
|
+
],
|
|
105
|
+
}));
|
|
106
|
+
const out = await listCommand.func(page, { limit: 10, page: 0 });
|
|
107
|
+
expect(out).toHaveLength(2);
|
|
108
|
+
expect(out[0]).toMatchObject({
|
|
109
|
+
rank: 1,
|
|
110
|
+
clip: 'aaaaaaaa',
|
|
111
|
+
title: 'Track One',
|
|
112
|
+
status: 'complete',
|
|
113
|
+
created: '2026-05-17 11:14:26',
|
|
114
|
+
link: 'https://suno.com/song/aaaaaaaa-1111-2222-3333-444444444444',
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('respects --limit (caller may receive more from feed and slice down)', async () => {
|
|
119
|
+
mocks.ensureSunoSession.mockResolvedValue({ deviceId: 'device-uuid' });
|
|
120
|
+
const page = createPage(vi.fn().mockResolvedValue({
|
|
121
|
+
ok: true,
|
|
122
|
+
clips: [
|
|
123
|
+
{ id: 'aaaaaaaa-1111-2222-3333-444444444444', title: 'A', status: 'complete', created_at: '2026-05-17T00:00:00Z' },
|
|
124
|
+
{ id: 'bbbbbbbb-1111-2222-3333-444444444444', title: 'B', status: 'complete', created_at: '2026-05-17T00:00:00Z' },
|
|
125
|
+
{ id: 'cccccccc-1111-2222-3333-444444444444', title: 'C', status: 'complete', created_at: '2026-05-17T00:00:00Z' },
|
|
126
|
+
],
|
|
127
|
+
}));
|
|
128
|
+
const out = await listCommand.func(page, { limit: 2, page: 0 });
|
|
129
|
+
expect(out).toHaveLength(2);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('rejects invalid --page instead of silently clamping to zero', async () => {
|
|
133
|
+
mocks.ensureSunoSession.mockResolvedValue({ deviceId: 'device-uuid' });
|
|
134
|
+
await expect(listCommand.func(createPage(), { limit: 2, page: -1 })).rejects.toMatchObject({
|
|
135
|
+
code: 'ARGUMENT',
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('unwraps Browser Bridge envelopes around feed rows', async () => {
|
|
140
|
+
mocks.ensureSunoSession.mockResolvedValue({ deviceId: 'device-uuid' });
|
|
141
|
+
const page = createPage(vi.fn().mockResolvedValue({
|
|
142
|
+
session: 'browser:default',
|
|
143
|
+
data: {
|
|
144
|
+
ok: true,
|
|
145
|
+
clips: [
|
|
146
|
+
{ id: 'aaaaaaaa-1111-2222-3333-444444444444', title: 'Track One', status: 'complete', created_at: '2026-05-17T11:14:26.338Z' },
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
}));
|
|
150
|
+
const out = await listCommand.func(page, { limit: 10, page: 0 });
|
|
151
|
+
expect(out[0].clip).toBe('aaaaaaaa');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('returns EmptyResultError for a valid empty library response', async () => {
|
|
155
|
+
mocks.ensureSunoSession.mockResolvedValue({ deviceId: 'device-uuid' });
|
|
156
|
+
const page = createPage(vi.fn().mockResolvedValue({ ok: true, clips: [] }));
|
|
157
|
+
await expect(listCommand.func(page, { limit: 10, page: 0 })).rejects.toMatchObject({
|
|
158
|
+
code: 'EMPTY_RESULT',
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('fails typed on malformed clip identity', async () => {
|
|
163
|
+
mocks.ensureSunoSession.mockResolvedValue({ deviceId: 'device-uuid' });
|
|
164
|
+
const page = createPage(vi.fn().mockResolvedValue({ ok: true, clips: [{ title: 'No id' }] }));
|
|
165
|
+
await expect(listCommand.func(page, { limit: 10, page: 0 })).rejects.toMatchObject({
|
|
166
|
+
code: 'COMMAND_EXEC',
|
|
167
|
+
message: expect.stringContaining('malformed clip identity'),
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('surfaces feed lookup HTTP failures', async () => {
|
|
172
|
+
mocks.ensureSunoSession.mockResolvedValue({ deviceId: 'device-uuid' });
|
|
173
|
+
const page = createPage(vi.fn().mockResolvedValue({ ok: false, status: 500 }));
|
|
174
|
+
await expect(listCommand.func(page, { limit: 5, page: 0 })).rejects.toMatchObject({
|
|
175
|
+
code: 'COMMAND_EXEC',
|
|
176
|
+
message: expect.stringContaining('HTTP 500'),
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('surfaces malformed feed payloads as typed failures', async () => {
|
|
181
|
+
mocks.ensureSunoSession.mockResolvedValue({ deviceId: 'device-uuid' });
|
|
182
|
+
const page = createPage(vi.fn().mockResolvedValue({ ok: false, error: 'malformed clips payload' }));
|
|
183
|
+
await expect(listCommand.func(page, { limit: 5, page: 0 })).rejects.toMatchObject({
|
|
184
|
+
code: 'COMMAND_EXEC',
|
|
185
|
+
message: expect.stringContaining('malformed clips payload'),
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `opencli suno download <clip-id>` — download an already-generated clip in
|
|
3
|
+
* one or more formats. Useful for grabbing assets from songs created via the
|
|
4
|
+
* Suno web UI or earlier opencli runs without re-generating.
|
|
5
|
+
*
|
|
6
|
+
* WAV downloads still trigger Suno's per-download billing (the same charge
|
|
7
|
+
* the web UI's "Download → WAV" flow makes).
|
|
8
|
+
*/
|
|
9
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
10
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
11
|
+
import {
|
|
12
|
+
STUDIO_API,
|
|
13
|
+
SUNO_DOMAIN,
|
|
14
|
+
SUNO_URL,
|
|
15
|
+
downloadSunoClip,
|
|
16
|
+
ensureSunoSession,
|
|
17
|
+
normalizeBooleanFlag,
|
|
18
|
+
parseFormats,
|
|
19
|
+
resolveSunoOutputDir,
|
|
20
|
+
unwrapEvaluateResult,
|
|
21
|
+
} from './utils.js';
|
|
22
|
+
|
|
23
|
+
import * as os from 'node:os';
|
|
24
|
+
|
|
25
|
+
function displayPath(filePath) {
|
|
26
|
+
if (!filePath) return '-';
|
|
27
|
+
const home = os.homedir();
|
|
28
|
+
return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseClipId(value) {
|
|
32
|
+
const raw = String(value || '').trim();
|
|
33
|
+
if (!raw) throw new ArgumentError('clip-id required (UUID or /song/<id> URL)');
|
|
34
|
+
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
35
|
+
if (uuidPattern.test(raw)) return raw.toLowerCase();
|
|
36
|
+
try {
|
|
37
|
+
const parsed = new URL(raw);
|
|
38
|
+
const pathMatch = parsed.pathname.match(/^\/song\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/?$/i);
|
|
39
|
+
if (parsed.protocol === 'https:' && parsed.hostname === 'suno.com' && pathMatch) {
|
|
40
|
+
return pathMatch[1].toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
} catch {}
|
|
43
|
+
throw new ArgumentError(`Invalid clip-id: ${raw}`, 'Pass a UUID or a https://suno.com/song/<uuid> URL.');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const downloadCommand = cli({
|
|
47
|
+
site: 'suno',
|
|
48
|
+
name: 'download',
|
|
49
|
+
access: 'write',
|
|
50
|
+
description: 'Download an existing Suno clip (MP3 + optional WAV/M4A/video) by id',
|
|
51
|
+
domain: SUNO_DOMAIN,
|
|
52
|
+
strategy: Strategy.COOKIE,
|
|
53
|
+
browser: true,
|
|
54
|
+
siteSession: 'persistent',
|
|
55
|
+
navigateBefore: false,
|
|
56
|
+
defaultFormat: 'plain',
|
|
57
|
+
args: [
|
|
58
|
+
{ name: 'clip', positional: true, required: true, help: 'Clip UUID or https://suno.com/song/<id> URL' },
|
|
59
|
+
{ name: 'formats', help: 'Comma-separated formats: mp3, m4a, wav, video, cover, metadata. Default: mp3,metadata' },
|
|
60
|
+
{ name: 'op', help: 'Output directory (default: ~/Music/suno)' },
|
|
61
|
+
{ name: 'confirm-paid', type: 'boolean', default: false, help: 'Required to allow paid downloads (wav). Without it, paid formats are skipped with a warning.' },
|
|
62
|
+
],
|
|
63
|
+
columns: ['status', 'clip', 'title', 'files', 'link'],
|
|
64
|
+
func: async (page, kwargs) => {
|
|
65
|
+
const clipId = parseClipId(kwargs.clip);
|
|
66
|
+
const requestedFormats = parseFormats(kwargs.formats);
|
|
67
|
+
const confirmPaid = normalizeBooleanFlag(kwargs['confirm-paid']);
|
|
68
|
+
const PAID_FORMATS = new Set(['wav']);
|
|
69
|
+
const skippedPaid = [];
|
|
70
|
+
const formats = requestedFormats.filter(f => {
|
|
71
|
+
if (PAID_FORMATS.has(f) && !confirmPaid) {
|
|
72
|
+
skippedPaid.push(f);
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
});
|
|
77
|
+
if (!formats.length) {
|
|
78
|
+
throw new ArgumentError('All requested formats require --confirm-paid true', 'Add --confirm-paid true or include a free format such as mp3 or metadata.');
|
|
79
|
+
}
|
|
80
|
+
const outputDir = resolveSunoOutputDir(kwargs.op);
|
|
81
|
+
|
|
82
|
+
const session = await ensureSunoSession(page);
|
|
83
|
+
const deviceId = session.deviceId;
|
|
84
|
+
|
|
85
|
+
// Pull the clip object from feed/v3 so audio_url/media_urls/has_stem are current.
|
|
86
|
+
const feedRes = unwrapEvaluateResult(await page.evaluate(`(async () => {
|
|
87
|
+
const browserToken = JSON.stringify({ token: btoa(JSON.stringify({ timestamp: Date.now() })) });
|
|
88
|
+
const res = await fetch('${STUDIO_API}/api/feed/v3', {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: {
|
|
91
|
+
'Authorization': 'Bearer ' + (await window.Clerk.session.getToken()),
|
|
92
|
+
'browser-token': browserToken,
|
|
93
|
+
'device-id': ${JSON.stringify(deviceId)},
|
|
94
|
+
'Content-Type': 'application/json',
|
|
95
|
+
},
|
|
96
|
+
body: JSON.stringify({ clip_ids: ['${clipId}'] }),
|
|
97
|
+
});
|
|
98
|
+
if (!res.ok) return { ok: false, error: 'HTTP ' + res.status };
|
|
99
|
+
const payload = await res.json().catch(() => null);
|
|
100
|
+
if (!payload || !Array.isArray(payload.clips)) return { ok: false, error: 'malformed clips payload' };
|
|
101
|
+
return { ok: true, clips: payload.clips };
|
|
102
|
+
})()`));
|
|
103
|
+
|
|
104
|
+
if (!feedRes?.ok) {
|
|
105
|
+
throw new CommandExecutionError(`Suno feed lookup failed: ${feedRes?.error || 'unknown'}`);
|
|
106
|
+
}
|
|
107
|
+
if (!Array.isArray(feedRes.clips)) {
|
|
108
|
+
throw new CommandExecutionError('Suno feed lookup returned malformed clips payload');
|
|
109
|
+
}
|
|
110
|
+
const clip = feedRes.clips.find(c => c.id === clipId);
|
|
111
|
+
if (!clip) {
|
|
112
|
+
throw new EmptyResultError('suno download', `Clip ${clipId} not found in your account. Confirm at ${SUNO_URL}/song/${clipId}.`);
|
|
113
|
+
}
|
|
114
|
+
if (clip.status !== 'complete') {
|
|
115
|
+
throw new CommandExecutionError(`Clip ${clipId} status is "${clip.status}" — not complete yet. Retry once generation finishes.`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const result = await downloadSunoClip(page, clip, outputDir, formats, deviceId);
|
|
119
|
+
if (!result.written.some(w => w.ok)) {
|
|
120
|
+
throw new CommandExecutionError(`Suno download wrote no files for clip ${clipId}`);
|
|
121
|
+
}
|
|
122
|
+
const link = `${SUNO_URL}/song/${clip.id}`;
|
|
123
|
+
const writtenSummary = result.written
|
|
124
|
+
.map(w => w.ok ? `${w.format}:${displayPath(w.file)}` : `${w.format}:✗(${w.reason})`)
|
|
125
|
+
.join(' | ');
|
|
126
|
+
const skippedSummary = skippedPaid.length
|
|
127
|
+
? ` | skipped(needs --confirm-paid):${skippedPaid.join(',')}`
|
|
128
|
+
: '';
|
|
129
|
+
const fileSummary = `${writtenSummary}${skippedSummary}`;
|
|
130
|
+
const anyFailed = result.written.some(w => !w.ok);
|
|
131
|
+
|
|
132
|
+
return [{
|
|
133
|
+
status: anyFailed ? '⚠ partial' : '✅ saved',
|
|
134
|
+
clip: clip.id.slice(0, 8),
|
|
135
|
+
title: clip.title || '(untitled)',
|
|
136
|
+
files: `📁 ${fileSummary}`,
|
|
137
|
+
link: `🔗 ${link}`,
|
|
138
|
+
}];
|
|
139
|
+
},
|
|
140
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({
|
|
4
|
+
ensureSunoSession: vi.fn(),
|
|
5
|
+
downloadSunoClip: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock('./utils.js', () => ({
|
|
9
|
+
STUDIO_API: 'https://studio-api-prod.suno.com',
|
|
10
|
+
SUNO_DOMAIN: 'suno.com',
|
|
11
|
+
SUNO_URL: 'https://suno.com',
|
|
12
|
+
ensureSunoSession: mocks.ensureSunoSession,
|
|
13
|
+
downloadSunoClip: mocks.downloadSunoClip,
|
|
14
|
+
normalizeBooleanFlag: (value, fallback = false) => {
|
|
15
|
+
if (typeof value === 'boolean') return value;
|
|
16
|
+
if (value == null || value === '') return fallback;
|
|
17
|
+
const s = String(value).trim().toLowerCase();
|
|
18
|
+
return s === 'true' || s === '1' || s === 'yes' || s === 'on';
|
|
19
|
+
},
|
|
20
|
+
parseFormats: (value) => {
|
|
21
|
+
if (!value) return ['mp3', 'metadata'];
|
|
22
|
+
return String(value).split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
23
|
+
},
|
|
24
|
+
resolveSunoOutputDir: (value) => value || '/tmp/suno-test',
|
|
25
|
+
unwrapEvaluateResult: (value) => value && typeof value === 'object' && 'session' in value && 'data' in value ? value.data : value,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const { downloadCommand } = await import('./download.js');
|
|
29
|
+
|
|
30
|
+
const okSession = { ok: true, deviceId: 'device-uuid' };
|
|
31
|
+
const okCompleteClip = { id: '11111111-2222-3333-4444-555555555555', status: 'complete', title: 'Probe', audio_url: 'https://cdn1.suno.ai/x.mp3' };
|
|
32
|
+
|
|
33
|
+
function createPageWithFeed(clips) {
|
|
34
|
+
return {
|
|
35
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
36
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
37
|
+
evaluate: vi.fn().mockResolvedValue({ ok: true, clips }),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
vi.restoreAllMocks();
|
|
43
|
+
mocks.ensureSunoSession.mockReset().mockResolvedValue(okSession);
|
|
44
|
+
mocks.downloadSunoClip.mockReset().mockResolvedValue({
|
|
45
|
+
slug: 'probe',
|
|
46
|
+
written: [{ format: 'mp3', file: '/tmp/probe.mp3', ok: true }, { format: 'metadata', file: '/tmp/probe.json', ok: true }],
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('suno download argument validation', () => {
|
|
51
|
+
it('parses bare UUID clip ids', async () => {
|
|
52
|
+
const id = okCompleteClip.id;
|
|
53
|
+
await downloadCommand.func(createPageWithFeed([okCompleteClip]), { clip: id, formats: 'mp3' });
|
|
54
|
+
expect(mocks.downloadSunoClip).toHaveBeenCalledWith(expect.anything(), okCompleteClip, '/tmp/suno-test', ['mp3'], 'device-uuid');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('extracts UUID from a full suno.com/song/<id> URL', async () => {
|
|
58
|
+
const id = okCompleteClip.id;
|
|
59
|
+
await downloadCommand.func(createPageWithFeed([okCompleteClip]), { clip: `https://suno.com/song/${id}`, formats: 'mp3' });
|
|
60
|
+
expect(mocks.downloadSunoClip).toHaveBeenCalledWith(expect.anything(), okCompleteClip, '/tmp/suno-test', ['mp3'], 'device-uuid');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('rejects off-domain URLs even if they contain a UUID', async () => {
|
|
64
|
+
const id = okCompleteClip.id;
|
|
65
|
+
await expect(downloadCommand.func(createPageWithFeed([]), { clip: `https://evil.example/song/${id}`, formats: 'mp3' })).rejects.toMatchObject({
|
|
66
|
+
code: 'ARGUMENT',
|
|
67
|
+
message: expect.stringContaining('Invalid clip-id'),
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('rejects non-UUID clip ids with ArgumentError', async () => {
|
|
72
|
+
await expect(downloadCommand.func(createPageWithFeed([]), { clip: 'not-a-uuid', formats: 'mp3' })).rejects.toMatchObject({
|
|
73
|
+
code: 'ARGUMENT',
|
|
74
|
+
message: expect.stringContaining('Invalid clip-id'),
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('suno download lookup and status handling', () => {
|
|
80
|
+
it('returns EmptyResultError when feed/v3 does not contain the clip', async () => {
|
|
81
|
+
await expect(downloadCommand.func(createPageWithFeed([]), { clip: okCompleteClip.id, formats: 'mp3' })).rejects.toMatchObject({
|
|
82
|
+
code: 'EMPTY_RESULT',
|
|
83
|
+
hint: expect.stringContaining('not found'),
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('unwraps Browser Bridge envelopes around feed lookup results', async () => {
|
|
88
|
+
const page = createPageWithFeed([]);
|
|
89
|
+
page.evaluate.mockResolvedValueOnce({ session: 'browser:default', data: { ok: true, clips: [okCompleteClip] } });
|
|
90
|
+
await downloadCommand.func(page, { clip: okCompleteClip.id, formats: 'mp3' });
|
|
91
|
+
expect(mocks.downloadSunoClip).toHaveBeenCalledWith(expect.anything(), okCompleteClip, '/tmp/suno-test', ['mp3'], 'device-uuid');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('fails typed when feed/v3 returns malformed clips payload', async () => {
|
|
95
|
+
const page = createPageWithFeed([]);
|
|
96
|
+
page.evaluate.mockResolvedValueOnce({ ok: true, clips: null });
|
|
97
|
+
await expect(downloadCommand.func(page, { clip: okCompleteClip.id, formats: 'mp3' })).rejects.toMatchObject({
|
|
98
|
+
code: 'COMMAND_EXEC',
|
|
99
|
+
message: expect.stringContaining('malformed clips payload'),
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('refuses to download when the clip is still streaming/queued', async () => {
|
|
104
|
+
await expect(downloadCommand.func(createPageWithFeed([{ ...okCompleteClip, status: 'streaming' }]), { clip: okCompleteClip.id, formats: 'mp3' })).rejects.toMatchObject({
|
|
105
|
+
code: 'COMMAND_EXEC',
|
|
106
|
+
message: expect.stringContaining('not complete yet'),
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('suno download paid-format guard', () => {
|
|
112
|
+
it('skips wav by default and surfaces the skip in the result row', async () => {
|
|
113
|
+
const out = await downloadCommand.func(createPageWithFeed([okCompleteClip]), {
|
|
114
|
+
clip: okCompleteClip.id,
|
|
115
|
+
formats: 'mp3,wav,metadata',
|
|
116
|
+
});
|
|
117
|
+
expect(mocks.downloadSunoClip).toHaveBeenCalledWith(expect.anything(), okCompleteClip, '/tmp/suno-test', ['mp3', 'metadata'], 'device-uuid');
|
|
118
|
+
expect(out[0].files).toContain('skipped(needs --confirm-paid):wav');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('rejects requests where every requested format is paid and unconfirmed', async () => {
|
|
122
|
+
await expect(downloadCommand.func(createPageWithFeed([okCompleteClip]), {
|
|
123
|
+
clip: okCompleteClip.id,
|
|
124
|
+
formats: 'wav',
|
|
125
|
+
})).rejects.toMatchObject({
|
|
126
|
+
code: 'ARGUMENT',
|
|
127
|
+
message: expect.stringContaining('All requested formats require'),
|
|
128
|
+
});
|
|
129
|
+
expect(mocks.downloadSunoClip).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('includes wav when --confirm-paid is true', async () => {
|
|
133
|
+
await downloadCommand.func(createPageWithFeed([okCompleteClip]), {
|
|
134
|
+
clip: okCompleteClip.id,
|
|
135
|
+
formats: 'mp3,wav',
|
|
136
|
+
'confirm-paid': true,
|
|
137
|
+
});
|
|
138
|
+
expect(mocks.downloadSunoClip).toHaveBeenCalledWith(expect.anything(), okCompleteClip, '/tmp/suno-test', ['mp3', 'wav'], 'device-uuid');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('fails typed when selected formats write no files', async () => {
|
|
142
|
+
mocks.downloadSunoClip.mockResolvedValue({ slug: 'probe', written: [{ format: 'mp3', file: null, ok: false, reason: 'HTTP 500' }] });
|
|
143
|
+
await expect(downloadCommand.func(createPageWithFeed([okCompleteClip]), {
|
|
144
|
+
clip: okCompleteClip.id,
|
|
145
|
+
formats: 'mp3',
|
|
146
|
+
})).rejects.toMatchObject({
|
|
147
|
+
code: 'COMMAND_EXEC',
|
|
148
|
+
message: expect.stringContaining('wrote no files'),
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|