@jackwener/opencli 1.4.0 → 1.4.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.
Files changed (209) hide show
  1. package/.github/actions/setup-chrome/action.yml +5 -4
  2. package/.github/workflows/ci.yml +17 -3
  3. package/.github/workflows/e2e-headed.yml +16 -3
  4. package/CHANGELOG.md +23 -0
  5. package/PRIVACY.md +57 -0
  6. package/README.md +1 -1
  7. package/README.zh-CN.md +1 -1
  8. package/SKILL.md +101 -2
  9. package/dist/cli-manifest.json +720 -32
  10. package/dist/clis/apple-podcasts/search.js +2 -1
  11. package/dist/clis/arxiv/search.js +2 -2
  12. package/dist/clis/bbc/news.js +0 -1
  13. package/dist/clis/ctrip/search.js +0 -1
  14. package/dist/clis/douyin/_shared/browser-fetch.d.ts +10 -0
  15. package/dist/clis/douyin/_shared/browser-fetch.js +30 -0
  16. package/dist/clis/douyin/_shared/browser-fetch.test.d.ts +1 -0
  17. package/dist/clis/douyin/_shared/browser-fetch.test.js +31 -0
  18. package/dist/clis/douyin/_shared/creation-id.d.ts +1 -0
  19. package/dist/clis/douyin/_shared/creation-id.js +5 -0
  20. package/dist/clis/douyin/_shared/creation-id.test.d.ts +1 -0
  21. package/dist/clis/douyin/_shared/creation-id.test.js +22 -0
  22. package/dist/clis/douyin/_shared/imagex-upload.d.ts +20 -0
  23. package/dist/clis/douyin/_shared/imagex-upload.js +53 -0
  24. package/dist/clis/douyin/_shared/imagex-upload.test.d.ts +1 -0
  25. package/dist/clis/douyin/_shared/imagex-upload.test.js +87 -0
  26. package/dist/clis/douyin/_shared/sts2.d.ts +8 -0
  27. package/dist/clis/douyin/_shared/sts2.js +15 -0
  28. package/dist/clis/douyin/_shared/text-extra.d.ts +18 -0
  29. package/dist/clis/douyin/_shared/text-extra.js +15 -0
  30. package/dist/clis/douyin/_shared/text-extra.test.d.ts +1 -0
  31. package/dist/clis/douyin/_shared/text-extra.test.js +37 -0
  32. package/dist/clis/douyin/_shared/timing.d.ts +2 -0
  33. package/dist/clis/douyin/_shared/timing.js +22 -0
  34. package/dist/clis/douyin/_shared/timing.test.d.ts +1 -0
  35. package/dist/clis/douyin/_shared/timing.test.js +28 -0
  36. package/dist/clis/douyin/_shared/tos-upload-short-read.test.d.ts +11 -0
  37. package/dist/clis/douyin/_shared/tos-upload-short-read.test.js +83 -0
  38. package/dist/clis/douyin/_shared/tos-upload.d.ts +53 -0
  39. package/dist/clis/douyin/_shared/tos-upload.js +295 -0
  40. package/dist/clis/douyin/_shared/tos-upload.test.d.ts +1 -0
  41. package/dist/clis/douyin/_shared/tos-upload.test.js +229 -0
  42. package/dist/clis/douyin/_shared/transcode.d.ts +27 -0
  43. package/dist/clis/douyin/_shared/transcode.js +45 -0
  44. package/dist/clis/douyin/_shared/transcode.test.d.ts +1 -0
  45. package/dist/clis/douyin/_shared/transcode.test.js +93 -0
  46. package/dist/clis/douyin/_shared/types.d.ts +26 -0
  47. package/dist/clis/douyin/_shared/types.js +1 -0
  48. package/dist/clis/douyin/activities.d.ts +1 -0
  49. package/dist/clis/douyin/activities.js +20 -0
  50. package/dist/clis/douyin/activities.test.d.ts +1 -0
  51. package/dist/clis/douyin/activities.test.js +22 -0
  52. package/dist/clis/douyin/collections.d.ts +1 -0
  53. package/dist/clis/douyin/collections.js +22 -0
  54. package/dist/clis/douyin/collections.test.d.ts +1 -0
  55. package/dist/clis/douyin/collections.test.js +23 -0
  56. package/dist/clis/douyin/delete.d.ts +1 -0
  57. package/dist/clis/douyin/delete.js +18 -0
  58. package/dist/clis/douyin/delete.test.d.ts +1 -0
  59. package/dist/clis/douyin/delete.test.js +11 -0
  60. package/dist/clis/douyin/draft.d.ts +14 -0
  61. package/dist/clis/douyin/draft.js +237 -0
  62. package/dist/clis/douyin/draft.test.d.ts +1 -0
  63. package/dist/clis/douyin/draft.test.js +11 -0
  64. package/dist/clis/douyin/drafts.d.ts +1 -0
  65. package/dist/clis/douyin/drafts.js +23 -0
  66. package/dist/clis/douyin/drafts.test.d.ts +1 -0
  67. package/dist/clis/douyin/drafts.test.js +11 -0
  68. package/dist/clis/douyin/hashtag.d.ts +1 -0
  69. package/dist/clis/douyin/hashtag.js +45 -0
  70. package/dist/clis/douyin/hashtag.test.d.ts +1 -0
  71. package/dist/clis/douyin/hashtag.test.js +25 -0
  72. package/dist/clis/douyin/location.d.ts +1 -0
  73. package/dist/clis/douyin/location.js +24 -0
  74. package/dist/clis/douyin/location.test.d.ts +1 -0
  75. package/dist/clis/douyin/location.test.js +23 -0
  76. package/dist/clis/douyin/profile.d.ts +1 -0
  77. package/dist/clis/douyin/profile.js +28 -0
  78. package/dist/clis/douyin/profile.test.d.ts +1 -0
  79. package/dist/clis/douyin/profile.test.js +11 -0
  80. package/dist/clis/douyin/publish.d.ts +14 -0
  81. package/dist/clis/douyin/publish.js +288 -0
  82. package/dist/clis/douyin/publish.test.d.ts +1 -0
  83. package/dist/clis/douyin/publish.test.js +38 -0
  84. package/dist/clis/douyin/stats.d.ts +1 -0
  85. package/dist/clis/douyin/stats.js +27 -0
  86. package/dist/clis/douyin/stats.test.d.ts +1 -0
  87. package/dist/clis/douyin/stats.test.js +22 -0
  88. package/dist/clis/douyin/update.d.ts +1 -0
  89. package/dist/clis/douyin/update.js +31 -0
  90. package/dist/clis/douyin/update.test.d.ts +1 -0
  91. package/dist/clis/douyin/update.test.js +11 -0
  92. package/dist/clis/douyin/videos.d.ts +1 -0
  93. package/dist/clis/douyin/videos.js +34 -0
  94. package/dist/clis/douyin/videos.test.d.ts +1 -0
  95. package/dist/clis/douyin/videos.test.js +11 -0
  96. package/dist/clis/hackernews/search.yaml +1 -1
  97. package/dist/clis/instagram/search.yaml +2 -1
  98. package/dist/clis/linux-do/search.yaml +3 -1
  99. package/dist/clis/medium/search.js +1 -1
  100. package/dist/clis/reuters/search.js +0 -1
  101. package/dist/clis/twitter/search.js +5 -3
  102. package/dist/clis/twitter/search.test.js +54 -2
  103. package/dist/clis/weibo/comments.d.ts +1 -0
  104. package/dist/clis/weibo/comments.js +53 -0
  105. package/dist/clis/weibo/feed.d.ts +1 -0
  106. package/dist/clis/weibo/feed.js +56 -0
  107. package/dist/clis/weibo/hot.js +0 -1
  108. package/dist/clis/weibo/me.d.ts +1 -0
  109. package/dist/clis/weibo/me.js +76 -0
  110. package/dist/clis/weibo/post.d.ts +1 -0
  111. package/dist/clis/weibo/post.js +75 -0
  112. package/dist/clis/weibo/user.d.ts +1 -0
  113. package/dist/clis/weibo/user.js +63 -0
  114. package/dist/clis/weibo/utils.d.ts +6 -0
  115. package/dist/clis/weibo/utils.js +30 -0
  116. package/dist/clis/weread/search.js +3 -2
  117. package/dist/clis/xueqiu/search.yaml +2 -1
  118. package/dist/clis/yahoo-finance/quote.js +0 -1
  119. package/dist/clis/youtube/channel.d.ts +1 -0
  120. package/dist/clis/youtube/channel.js +150 -0
  121. package/dist/clis/youtube/comments.d.ts +1 -0
  122. package/dist/clis/youtube/comments.js +95 -0
  123. package/dist/clis/youtube/search.js +0 -1
  124. package/dist/clis/zhihu/search.yaml +2 -1
  125. package/dist/external-clis.yaml +0 -17
  126. package/dist/weread-search-regression.test.d.ts +1 -0
  127. package/dist/weread-search-regression.test.js +39 -0
  128. package/docs/.vitepress/config.mts +13 -0
  129. package/docs/adapters/browser/douyin.md +75 -0
  130. package/docs/adapters/browser/twitter.md +6 -0
  131. package/docs/adapters/index.md +6 -1
  132. package/extension/dist/background.js +508 -518
  133. package/extension/manifest.json +6 -2
  134. package/extension/package.json +1 -1
  135. package/extension/popup.html +84 -0
  136. package/extension/popup.js +25 -0
  137. package/extension/src/background.ts +20 -1
  138. package/package.json +1 -1
  139. package/src/clis/apple-podcasts/search.ts +2 -1
  140. package/src/clis/arxiv/search.ts +2 -2
  141. package/src/clis/bbc/news.ts +0 -1
  142. package/src/clis/ctrip/search.ts +0 -1
  143. package/src/clis/douyin/_shared/browser-fetch.test.ts +38 -0
  144. package/src/clis/douyin/_shared/browser-fetch.ts +45 -0
  145. package/src/clis/douyin/_shared/creation-id.test.ts +26 -0
  146. package/src/clis/douyin/_shared/creation-id.ts +8 -0
  147. package/src/clis/douyin/_shared/imagex-upload.test.ts +113 -0
  148. package/src/clis/douyin/_shared/imagex-upload.ts +76 -0
  149. package/src/clis/douyin/_shared/sts2.ts +20 -0
  150. package/src/clis/douyin/_shared/text-extra.test.ts +42 -0
  151. package/src/clis/douyin/_shared/text-extra.ts +33 -0
  152. package/src/clis/douyin/_shared/timing.test.ts +38 -0
  153. package/src/clis/douyin/_shared/timing.ts +22 -0
  154. package/src/clis/douyin/_shared/tos-upload-short-read.test.ts +102 -0
  155. package/src/clis/douyin/_shared/tos-upload.test.ts +281 -0
  156. package/src/clis/douyin/_shared/tos-upload.ts +444 -0
  157. package/src/clis/douyin/_shared/transcode.test.ts +117 -0
  158. package/src/clis/douyin/_shared/transcode.ts +78 -0
  159. package/src/clis/douyin/_shared/types.ts +29 -0
  160. package/src/clis/douyin/activities.test.ts +25 -0
  161. package/src/clis/douyin/activities.ts +23 -0
  162. package/src/clis/douyin/collections.test.ts +26 -0
  163. package/src/clis/douyin/collections.ts +25 -0
  164. package/src/clis/douyin/delete.test.ts +12 -0
  165. package/src/clis/douyin/delete.ts +20 -0
  166. package/src/clis/douyin/draft.test.ts +12 -0
  167. package/src/clis/douyin/draft.ts +282 -0
  168. package/src/clis/douyin/drafts.test.ts +12 -0
  169. package/src/clis/douyin/drafts.ts +27 -0
  170. package/src/clis/douyin/hashtag.test.ts +28 -0
  171. package/src/clis/douyin/hashtag.ts +56 -0
  172. package/src/clis/douyin/location.test.ts +26 -0
  173. package/src/clis/douyin/location.ts +27 -0
  174. package/src/clis/douyin/profile.test.ts +12 -0
  175. package/src/clis/douyin/profile.ts +37 -0
  176. package/src/clis/douyin/publish.test.ts +45 -0
  177. package/src/clis/douyin/publish.ts +340 -0
  178. package/src/clis/douyin/stats.test.ts +25 -0
  179. package/src/clis/douyin/stats.ts +30 -0
  180. package/src/clis/douyin/update.test.ts +12 -0
  181. package/src/clis/douyin/update.ts +43 -0
  182. package/src/clis/douyin/videos.test.ts +12 -0
  183. package/src/clis/douyin/videos.ts +49 -0
  184. package/src/clis/hackernews/search.yaml +1 -1
  185. package/src/clis/instagram/search.yaml +2 -1
  186. package/src/clis/linux-do/search.yaml +3 -1
  187. package/src/clis/medium/search.ts +1 -1
  188. package/src/clis/reuters/search.ts +0 -1
  189. package/src/clis/twitter/search.test.ts +69 -2
  190. package/src/clis/twitter/search.ts +5 -3
  191. package/src/clis/weibo/comments.ts +54 -0
  192. package/src/clis/weibo/feed.ts +57 -0
  193. package/src/clis/weibo/hot.ts +0 -1
  194. package/src/clis/weibo/me.ts +77 -0
  195. package/src/clis/weibo/post.ts +77 -0
  196. package/src/clis/weibo/user.ts +64 -0
  197. package/src/clis/weibo/utils.ts +32 -0
  198. package/src/clis/weread/search.ts +3 -2
  199. package/src/clis/xueqiu/search.yaml +2 -1
  200. package/src/clis/yahoo-finance/quote.ts +0 -1
  201. package/src/clis/youtube/channel.ts +155 -0
  202. package/src/clis/youtube/comments.ts +97 -0
  203. package/src/clis/youtube/search.ts +0 -1
  204. package/src/clis/zhihu/search.yaml +2 -1
  205. package/src/external-clis.yaml +0 -17
  206. package/src/weread-search-regression.test.ts +44 -0
  207. package/tests/e2e/browser-public-extended.test.ts +162 -0
  208. package/tests/e2e/browser-public.test.ts +7 -146
  209. package/vitest.config.ts +24 -17
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { validateTiming, toUnixSeconds } from './timing.js';
3
+
4
+ describe('validateTiming', () => {
5
+ const now = () => Math.floor(Date.now() / 1000);
6
+
7
+ it('accepts a time 3 hours from now', () => {
8
+ expect(() => validateTiming(now() + 3 * 3600)).not.toThrow();
9
+ });
10
+
11
+ it('rejects a time less than 2 hours from now', () => {
12
+ expect(() => validateTiming(now() + 3600)).toThrow('至少 2 小时后');
13
+ });
14
+
15
+ it('rejects a time more than 14 days from now', () => {
16
+ expect(() => validateTiming(now() + 15 * 86400)).toThrow('不能超过 14 天');
17
+ });
18
+ });
19
+
20
+ describe('toUnixSeconds', () => {
21
+ it('passes through a numeric unix timestamp', () => {
22
+ expect(toUnixSeconds(1744070400)).toBe(1744070400);
23
+ });
24
+
25
+ it('parses a numeric unix timestamp string', () => {
26
+ expect(toUnixSeconds('1744070400')).toBe(1744070400);
27
+ });
28
+
29
+ it('parses ISO8601 string', () => {
30
+ expect(toUnixSeconds('2026-04-08T12:00:00Z')).toBe(
31
+ Math.floor(new Date('2026-04-08T12:00:00Z').getTime() / 1000)
32
+ );
33
+ });
34
+
35
+ it('throws on invalid input', () => {
36
+ expect(() => toUnixSeconds('not-a-date')).toThrow();
37
+ });
38
+ });
@@ -0,0 +1,22 @@
1
+ const MIN_OFFSET = 7200; // 2 hours
2
+ const MAX_OFFSET = 14 * 86400; // 14 days
3
+
4
+ export function validateTiming(unixSeconds: number): void {
5
+ if (!Number.isFinite(unixSeconds))
6
+ throw new Error(`无效的时间戳: ${unixSeconds}`);
7
+ const now = Math.floor(Date.now() / 1000);
8
+ if (unixSeconds < now + MIN_OFFSET)
9
+ throw new Error(`定时发布时间必须在至少 2 小时后`);
10
+ if (unixSeconds > now + MAX_OFFSET)
11
+ throw new Error(`定时发布时间不能超过 14 天`);
12
+ }
13
+
14
+ export function toUnixSeconds(input: string | number): number {
15
+ if (typeof input === 'number') return input;
16
+ if (/^\d+$/.test(input)) {
17
+ return Number(input);
18
+ }
19
+ const ms = new Date(input).getTime();
20
+ if (isNaN(ms)) throw new Error(`无效的时间格式: "${input}"`);
21
+ return Math.floor(ms / 1000);
22
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Tests for the fs.readSync short-read guard in tosUpload.
3
+ *
4
+ * This file is separate from tos-upload.test.ts because vi.mock is hoisted and
5
+ * would interfere with the real-fs tests there.
6
+ *
7
+ * Strategy:
8
+ * - Use setReadSyncOverride (exported testing seam) to force readSync to return 0
9
+ * - Mock global fetch to satisfy initMultipartUpload so the code path reaches readSync
10
+ */
11
+
12
+ import * as actualFs from 'node:fs';
13
+ import * as os from 'node:os';
14
+ import * as path from 'node:path';
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
16
+ import { CommandExecutionError } from '../../../errors.js';
17
+ import { setReadSyncOverride, tosUpload } from './tos-upload.js';
18
+
19
+ /** Build a minimal fetch mock that satisfies initMultipartUpload (POST ?uploads → 200 + UploadId XML). */
20
+ function makeFetchMock(): typeof fetch {
21
+ return vi.fn().mockResolvedValue({
22
+ status: 200,
23
+ text: async () => '<InitiateMultipartUploadResult><UploadId>mock-upload-id</UploadId></InitiateMultipartUploadResult>',
24
+ headers: { forEach: (_cb: (v: string, k: string) => void) => {} },
25
+ } as unknown as Response);
26
+ }
27
+
28
+ describe('tosUpload short-read guard', () => {
29
+ let tmpDir: string;
30
+ let tmpFile: string;
31
+ let originalFetch: typeof globalThis.fetch;
32
+
33
+ beforeEach(() => {
34
+ originalFetch = globalThis.fetch;
35
+
36
+ tmpDir = actualFs.mkdtempSync(path.join(os.tmpdir(), 'tos-upload-shortread-'));
37
+ tmpFile = path.join(tmpDir, 'video.mp4');
38
+ // 100-byte file — fits in a single part
39
+ actualFs.writeFileSync(tmpFile, Buffer.alloc(100, 0xff));
40
+ });
41
+
42
+ afterEach(() => {
43
+ setReadSyncOverride(null);
44
+ globalThis.fetch = originalFetch;
45
+ actualFs.rmSync(tmpDir, { recursive: true, force: true });
46
+ });
47
+
48
+ it('throws CommandExecutionError on short read', async () => {
49
+ // Mock fetch so initMultipartUpload succeeds and code reaches readSync
50
+ globalThis.fetch = makeFetchMock();
51
+
52
+ // Override readSync to return 0 (fewer bytes than requested)
53
+ setReadSyncOverride(() => 0);
54
+
55
+ const mockCredentials = {
56
+ access_key_id: 'AKIAIOSFODNN7EXAMPLE',
57
+ secret_access_key: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
58
+ session_token: 'test-session-token',
59
+ expired_time: Date.now() / 1000 + 3600,
60
+ };
61
+
62
+ const uploadInfo = {
63
+ tos_upload_url: 'https://tos-cn-i-alisg.volces.com/bucket/key',
64
+ auth: 'AWS4-HMAC-SHA256 Credential=test',
65
+ video_id: 'test-video-id',
66
+ };
67
+
68
+ await expect(
69
+ tosUpload({
70
+ filePath: tmpFile,
71
+ uploadInfo,
72
+ credentials: mockCredentials,
73
+ }),
74
+ ).rejects.toThrow(CommandExecutionError);
75
+ });
76
+
77
+ it('error message identifies the part number and byte counts', async () => {
78
+ globalThis.fetch = makeFetchMock();
79
+ setReadSyncOverride(() => 0);
80
+
81
+ const mockCredentials = {
82
+ access_key_id: 'AKIAIOSFODNN7EXAMPLE',
83
+ secret_access_key: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
84
+ session_token: 'test-session-token',
85
+ expired_time: Date.now() / 1000 + 3600,
86
+ };
87
+
88
+ const uploadInfo = {
89
+ tos_upload_url: 'https://tos-cn-i-alisg.volces.com/bucket/key',
90
+ auth: 'AWS4-HMAC-SHA256 Credential=test',
91
+ video_id: 'test-video-id',
92
+ };
93
+
94
+ await expect(
95
+ tosUpload({
96
+ filePath: tmpFile,
97
+ uploadInfo,
98
+ credentials: mockCredentials,
99
+ }),
100
+ ).rejects.toThrow(/Short read on part 1: expected 100 bytes, got 0/);
101
+ });
102
+ });
@@ -0,0 +1,281 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import {
6
+ PART_SIZE,
7
+ computeAws4Headers,
8
+ deleteResumeState,
9
+ extractRegionFromHost,
10
+ getResumeFilePath,
11
+ loadResumeState,
12
+ saveResumeState,
13
+ } from './tos-upload.js';
14
+ import type { ResumeState } from './tos-upload.js';
15
+
16
+ // ── extractRegionFromHost ────────────────────────────────────────────────────
17
+
18
+ describe('extractRegionFromHost', () => {
19
+ it('extracts region from standard TOS host', () => {
20
+ expect(extractRegionFromHost('tos-cn-i-alisg.volces.com')).toBe('cn-i-alisg');
21
+ });
22
+
23
+ it('extracts region from beijing TOS host', () => {
24
+ expect(extractRegionFromHost('tos-cn-beijing.ivolces.com')).toBe('cn-beijing');
25
+ });
26
+
27
+ it('falls back to cn-north-1 for unknown host', () => {
28
+ expect(extractRegionFromHost('unknown.example.com')).toBe('cn-north-1');
29
+ });
30
+ });
31
+
32
+ // ── Part chunking ────────────────────────────────────────────────────────────
33
+
34
+ describe('PART_SIZE and part chunking logic', () => {
35
+ it('PART_SIZE is exactly 5 MB', () => {
36
+ expect(PART_SIZE).toBe(5 * 1024 * 1024);
37
+ });
38
+
39
+ it('single file smaller than PART_SIZE fits in 1 part', () => {
40
+ const fileSize = 1 * 1024 * 1024; // 1 MB
41
+ const totalParts = Math.ceil(fileSize / PART_SIZE);
42
+ expect(totalParts).toBe(1);
43
+ const lastPartSize = fileSize - (totalParts - 1) * PART_SIZE;
44
+ expect(lastPartSize).toBe(fileSize);
45
+ });
46
+
47
+ it('exactly 5 MB file produces 1 part', () => {
48
+ const fileSize = PART_SIZE;
49
+ expect(Math.ceil(fileSize / PART_SIZE)).toBe(1);
50
+ });
51
+
52
+ it('5 MB + 1 byte produces 2 parts', () => {
53
+ const fileSize = PART_SIZE + 1;
54
+ expect(Math.ceil(fileSize / PART_SIZE)).toBe(2);
55
+ });
56
+
57
+ it('100 MB file produces 20 parts of 5 MB each', () => {
58
+ const fileSize = 100 * 1024 * 1024;
59
+ const totalParts = Math.ceil(fileSize / PART_SIZE);
60
+ expect(totalParts).toBe(20);
61
+ // Each part is exactly PART_SIZE
62
+ for (let i = 1; i <= totalParts; i++) {
63
+ const offset = (i - 1) * PART_SIZE;
64
+ const chunkSize = Math.min(PART_SIZE, fileSize - offset);
65
+ expect(chunkSize).toBe(PART_SIZE);
66
+ }
67
+ });
68
+
69
+ it('101 MB file produces 21 parts, last part is 1 MB', () => {
70
+ const fileSize = 101 * 1024 * 1024;
71
+ const totalParts = Math.ceil(fileSize / PART_SIZE);
72
+ expect(totalParts).toBe(21);
73
+ const lastOffset = (totalParts - 1) * PART_SIZE;
74
+ const lastPartSize = fileSize - lastOffset;
75
+ expect(lastPartSize).toBe(1 * 1024 * 1024);
76
+ });
77
+ });
78
+
79
+ // ── Resume file serialization/deserialization ─────────────────────────────────
80
+
81
+ describe('resume state read/write', () => {
82
+ let tmpDir: string;
83
+ let resumePath: string;
84
+
85
+ beforeEach(() => {
86
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tos-upload-test-'));
87
+ resumePath = path.join(tmpDir, 'resume.json');
88
+ });
89
+
90
+ afterEach(() => {
91
+ fs.rmSync(tmpDir, { recursive: true, force: true });
92
+ });
93
+
94
+ it('saves and loads resume state correctly', () => {
95
+ const state: ResumeState = {
96
+ uploadId: 'test-upload-id-123',
97
+ fileSize: 12345678,
98
+ parts: [
99
+ { partNumber: 1, etag: '"abc123"' },
100
+ { partNumber: 2, etag: '"def456"' },
101
+ ],
102
+ };
103
+
104
+ saveResumeState(resumePath, state);
105
+ const loaded = loadResumeState(resumePath, 12345678);
106
+
107
+ expect(loaded).not.toBeNull();
108
+ expect(loaded!.uploadId).toBe('test-upload-id-123');
109
+ expect(loaded!.fileSize).toBe(12345678);
110
+ expect(loaded!.parts).toHaveLength(2);
111
+ expect(loaded!.parts[0]).toEqual({ partNumber: 1, etag: '"abc123"' });
112
+ expect(loaded!.parts[1]).toEqual({ partNumber: 2, etag: '"def456"' });
113
+ });
114
+
115
+ it('returns null when file does not exist', () => {
116
+ const result = loadResumeState('/nonexistent/path/resume.json', 12345678);
117
+ expect(result).toBeNull();
118
+ });
119
+
120
+ it('returns null when fileSize does not match', () => {
121
+ const state: ResumeState = {
122
+ uploadId: 'upload-id',
123
+ fileSize: 100,
124
+ parts: [],
125
+ };
126
+ saveResumeState(resumePath, state);
127
+
128
+ // Different file size — should not resume
129
+ const result = loadResumeState(resumePath, 999);
130
+ expect(result).toBeNull();
131
+ });
132
+
133
+ it('returns null when JSON is malformed', () => {
134
+ fs.writeFileSync(resumePath, 'not-valid-json', 'utf8');
135
+ const result = loadResumeState(resumePath, 100);
136
+ expect(result).toBeNull();
137
+ });
138
+
139
+ it('returns null when uploadId is missing', () => {
140
+ const broken = { fileSize: 100, parts: [] };
141
+ fs.writeFileSync(resumePath, JSON.stringify(broken), 'utf8');
142
+ const result = loadResumeState(resumePath, 100);
143
+ expect(result).toBeNull();
144
+ });
145
+
146
+ it('deletes resume file without throwing when file exists', () => {
147
+ fs.writeFileSync(resumePath, '{}', 'utf8');
148
+ expect(() => deleteResumeState(resumePath)).not.toThrow();
149
+ expect(fs.existsSync(resumePath)).toBe(false);
150
+ });
151
+
152
+ it('deleteResumeState does not throw when file does not exist', () => {
153
+ expect(() => deleteResumeState('/nonexistent/path/resume.json')).not.toThrow();
154
+ });
155
+
156
+ it('saveResumeState creates parent directories if missing', () => {
157
+ const nestedPath = path.join(tmpDir, 'nested', 'deep', 'resume.json');
158
+ const state: ResumeState = { uploadId: 'x', fileSize: 0, parts: [] };
159
+ expect(() => saveResumeState(nestedPath, state)).not.toThrow();
160
+ expect(fs.existsSync(nestedPath)).toBe(true);
161
+ });
162
+ });
163
+
164
+ // ── getResumeFilePath ────────────────────────────────────────────────────────
165
+
166
+ describe('getResumeFilePath', () => {
167
+ it('returns a path inside ~/.opencli/douyin-resume/', () => {
168
+ const result = getResumeFilePath('/some/video/file.mp4');
169
+ expect(result).toContain('douyin-resume');
170
+ expect(result).toMatch(/\.json$/);
171
+ });
172
+
173
+ it('produces same path for same input', () => {
174
+ const a = getResumeFilePath('/video.mp4');
175
+ const b = getResumeFilePath('/video.mp4');
176
+ expect(a).toBe(b);
177
+ });
178
+
179
+ it('produces different paths for different inputs', () => {
180
+ const a = getResumeFilePath('/video1.mp4');
181
+ const b = getResumeFilePath('/video2.mp4');
182
+ expect(a).not.toBe(b);
183
+ });
184
+ });
185
+
186
+ // ── computeAws4Headers ───────────────────────────────────────────────────────
187
+
188
+ describe('computeAws4Headers', () => {
189
+ const mockCredentials = {
190
+ access_key_id: 'AKIAIOSFODNN7EXAMPLE',
191
+ secret_access_key: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
192
+ session_token: 'FQoGZXIvYXdzEJr//////////test-session-token',
193
+ expired_time: Date.now() / 1000 + 3600,
194
+ };
195
+
196
+ it('returns Authorization header', () => {
197
+ const headers = computeAws4Headers({
198
+ method: 'PUT',
199
+ url: 'https://tos-cn-i-alisg.volces.com/bucket/object?partNumber=1&uploadId=abc',
200
+ headers: { 'content-type': 'application/octet-stream' },
201
+ body: Buffer.from('hello'),
202
+ credentials: mockCredentials,
203
+ service: 'tos',
204
+ region: 'cn-i-alisg',
205
+ datetime: '20260325T120000Z',
206
+ });
207
+
208
+ expect(headers['Authorization']).toMatch(/^AWS4-HMAC-SHA256 Credential=/);
209
+ expect(headers['Authorization']).toContain('AKIAIOSFODNN7EXAMPLE/20260325/cn-i-alisg/tos/aws4_request');
210
+ expect(headers['Authorization']).toContain('SignedHeaders=');
211
+ expect(headers['Authorization']).toContain('Signature=');
212
+ });
213
+
214
+ it('includes x-amz-date header', () => {
215
+ const headers = computeAws4Headers({
216
+ method: 'PUT',
217
+ url: 'https://tos-cn-i-alisg.volces.com/bucket/object?partNumber=1&uploadId=abc',
218
+ headers: {},
219
+ body: Buffer.alloc(0),
220
+ credentials: mockCredentials,
221
+ service: 'tos',
222
+ region: 'cn-i-alisg',
223
+ datetime: '20260325T120000Z',
224
+ });
225
+
226
+ expect(headers['x-amz-date']).toBe('20260325T120000Z');
227
+ });
228
+
229
+ it('includes x-amz-security-token with session token', () => {
230
+ const headers = computeAws4Headers({
231
+ method: 'PUT',
232
+ url: 'https://tos-cn-i-alisg.volces.com/bucket/object',
233
+ headers: {},
234
+ body: '',
235
+ credentials: mockCredentials,
236
+ service: 'tos',
237
+ region: 'cn-i-alisg',
238
+ datetime: '20260325T120000Z',
239
+ });
240
+
241
+ expect(headers['x-amz-security-token']).toBe(mockCredentials.session_token);
242
+ });
243
+
244
+ it('signed headers list is sorted', () => {
245
+ const headers = computeAws4Headers({
246
+ method: 'POST',
247
+ url: 'https://tos-cn-i-alisg.volces.com/bucket/object?uploadId=abc',
248
+ headers: { 'content-type': 'application/xml' },
249
+ body: '<xml/>',
250
+ credentials: mockCredentials,
251
+ service: 'tos',
252
+ region: 'cn-i-alisg',
253
+ datetime: '20260325T120000Z',
254
+ });
255
+
256
+ const authHeader = headers['Authorization'];
257
+ const signedHeadersMatch = authHeader.match(/SignedHeaders=([^,]+)/);
258
+ expect(signedHeadersMatch).not.toBeNull();
259
+ const signedHeadersList = signedHeadersMatch![1].split(';');
260
+ const sorted = [...signedHeadersList].sort((a, b) => a.localeCompare(b));
261
+ expect(signedHeadersList).toEqual(sorted);
262
+ });
263
+
264
+ it('produces deterministic signature for same inputs', () => {
265
+ const opts = {
266
+ method: 'PUT',
267
+ url: 'https://tos-cn-i-alisg.volces.com/bucket/key?partNumber=1&uploadId=xyz',
268
+ headers: { 'content-type': 'application/octet-stream' },
269
+ body: Buffer.from('test-data'),
270
+ credentials: mockCredentials,
271
+ service: 'tos',
272
+ region: 'cn-i-alisg',
273
+ datetime: '20260325T120000Z',
274
+ };
275
+
276
+ const h1 = computeAws4Headers(opts);
277
+ const h2 = computeAws4Headers(opts);
278
+ expect(h1['Authorization']).toBe(h2['Authorization']);
279
+ });
280
+ });
281
+