@jackwener/opencli 1.8.0 → 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.
Files changed (153) hide show
  1. package/README.md +8 -49
  2. package/README.zh-CN.md +8 -52
  3. package/cli-manifest.json +1796 -191
  4. package/clis/_atlassian/shared.js +577 -0
  5. package/clis/_atlassian/shared.test.js +170 -0
  6. package/clis/bilibili/comment.js +125 -0
  7. package/clis/bilibili/comment.test.js +153 -0
  8. package/clis/bilibili/comments.js +116 -21
  9. package/clis/bilibili/comments.test.js +77 -18
  10. package/clis/bilibili/subtitle.js +76 -31
  11. package/clis/bilibili/subtitle.test.js +156 -9
  12. package/clis/bilibili/utils.js +63 -5
  13. package/clis/bilibili/utils.test.js +45 -1
  14. package/clis/chess/analyze.js +35 -0
  15. package/clis/chess/analyze.test.js +79 -0
  16. package/clis/chess/game.js +114 -0
  17. package/clis/chess/game.test.js +178 -0
  18. package/clis/chess/games.js +67 -0
  19. package/clis/chess/games.test.js +164 -0
  20. package/clis/chess/stats.js +32 -0
  21. package/clis/chess/stats.test.js +79 -0
  22. package/clis/chess/utils.js +170 -0
  23. package/clis/chess/utils.test.js +230 -0
  24. package/clis/confluence/commands.test.js +195 -0
  25. package/clis/confluence/create.js +39 -0
  26. package/clis/confluence/page.js +23 -0
  27. package/clis/confluence/search.js +34 -0
  28. package/clis/confluence/shared.js +173 -0
  29. package/clis/confluence/update.js +38 -0
  30. package/clis/douyin/hashtag.js +84 -23
  31. package/clis/douyin/hashtag.test.js +113 -0
  32. package/clis/geogebra/add-circle.js +46 -0
  33. package/clis/geogebra/add-line.js +35 -0
  34. package/clis/geogebra/add-point.js +27 -0
  35. package/clis/geogebra/add-polygon.js +25 -0
  36. package/clis/geogebra/eval.js +35 -0
  37. package/clis/geogebra/geogebra.test.js +175 -0
  38. package/clis/geogebra/hexagon.js +62 -0
  39. package/clis/geogebra/info.js +72 -0
  40. package/clis/geogebra/list.js +35 -0
  41. package/clis/geogebra/triangle.js +60 -0
  42. package/clis/geogebra/utils.js +271 -0
  43. package/clis/jira/attachments.js +28 -0
  44. package/clis/jira/commands.test.js +287 -0
  45. package/clis/jira/comments.js +28 -0
  46. package/clis/jira/issue.js +28 -0
  47. package/clis/jira/links.js +28 -0
  48. package/clis/jira/search.js +47 -0
  49. package/clis/jira/shared.js +256 -0
  50. package/clis/linkedin/job-detail.js +167 -0
  51. package/clis/linkedin/job-detail.test.js +38 -0
  52. package/clis/linkedin/jobs-preferences.js +113 -0
  53. package/clis/linkedin/jobs-preferences.test.js +43 -0
  54. package/clis/linkedin/post-analytics.js +74 -0
  55. package/clis/linkedin/post-analytics.test.js +40 -0
  56. package/clis/linkedin/posts-core.js +241 -0
  57. package/clis/linkedin/posts.js +22 -0
  58. package/clis/linkedin/posts.test.js +40 -0
  59. package/clis/linkedin/profile-analytics.js +104 -0
  60. package/clis/linkedin/profile-analytics.test.js +67 -0
  61. package/clis/linkedin/profile-experience.js +671 -0
  62. package/clis/linkedin/profile-experience.test.js +152 -0
  63. package/clis/linkedin/profile-projects.js +311 -0
  64. package/clis/linkedin/profile-projects.test.js +111 -0
  65. package/clis/linkedin/profile-read.js +148 -0
  66. package/clis/linkedin/profile-read.test.js +77 -0
  67. package/clis/linkedin/services-read.js +213 -0
  68. package/clis/linkedin/services-read.test.js +105 -0
  69. package/clis/linkedin/shared.js +124 -0
  70. package/clis/linkedin/timeline.js +14 -7
  71. package/clis/notebooklm/add-source.js +269 -0
  72. package/clis/notebooklm/add-source.test.js +97 -0
  73. package/clis/notebooklm/create.js +76 -0
  74. package/clis/notebooklm/create.test.js +58 -0
  75. package/clis/notebooklm/generate-audio.js +91 -0
  76. package/clis/notebooklm/generate-audio.test.js +63 -0
  77. package/clis/notebooklm/generate-slides.js +106 -0
  78. package/clis/notebooklm/generate-slides.test.js +75 -0
  79. package/clis/notebooklm/open.test.js +10 -10
  80. package/clis/notebooklm/rpc.js +20 -6
  81. package/clis/notebooklm/rpc.test.js +27 -1
  82. package/clis/notebooklm/utils.js +100 -24
  83. package/clis/notebooklm/utils.test.js +60 -1
  84. package/clis/notebooklm/write-note.js +103 -0
  85. package/clis/notebooklm/write-note.test.js +70 -0
  86. package/clis/pixiv/detail.js +41 -34
  87. package/clis/pixiv/detail.test.js +93 -0
  88. package/clis/pixiv/user.js +36 -31
  89. package/clis/pixiv/user.test.js +100 -0
  90. package/clis/pixiv/utils.js +56 -7
  91. package/clis/suno/generate.js +5 -0
  92. package/clis/suno/generate.test.js +9 -0
  93. package/clis/suno/status.js +3 -2
  94. package/clis/suno/utils.js +33 -24
  95. package/clis/suno/utils.test.js +106 -0
  96. package/clis/twitter/followers.js +6 -2
  97. package/clis/twitter/followers.test.js +19 -1
  98. package/clis/twitter/following.js +14 -5
  99. package/clis/twitter/following.test.js +29 -0
  100. package/clis/twitter/likes.js +12 -4
  101. package/clis/twitter/likes.test.js +26 -1
  102. package/clis/twitter/list-add.js +1 -1
  103. package/clis/twitter/list-remove.js +1 -1
  104. package/clis/twitter/notifications.js +4 -4
  105. package/clis/twitter/post.js +62 -4
  106. package/clis/twitter/post.test.js +35 -3
  107. package/clis/twitter/profile.js +81 -28
  108. package/clis/twitter/profile.test.js +113 -2
  109. package/clis/twitter/quote.js +9 -4
  110. package/clis/twitter/reply.js +13 -10
  111. package/clis/twitter/reply.test.js +41 -0
  112. package/clis/twitter/search.js +1 -1
  113. package/clis/twitter/search.test.js +35 -0
  114. package/clis/twitter/shared.js +11 -0
  115. package/clis/twitter/shared.test.js +37 -1
  116. package/clis/twitter/utils.js +53 -16
  117. package/clis/upwork/detail.js +132 -0
  118. package/clis/upwork/feed.js +109 -0
  119. package/clis/upwork/search.js +115 -0
  120. package/clis/upwork/upwork.test.js +566 -0
  121. package/clis/upwork/utils.js +323 -0
  122. package/clis/weread/book-search.js +438 -0
  123. package/clis/weread/book-search.test.js +242 -0
  124. package/clis/weread/search-regression.test.js +80 -0
  125. package/clis/weread/search.js +17 -2
  126. package/clis/xiaohongshu/creator-note-detail.js +165 -28
  127. package/clis/xiaohongshu/creator-note-detail.test.js +186 -37
  128. package/clis/xiaohongshu/creator-notes.js +251 -2
  129. package/clis/xiaohongshu/creator-notes.test.js +79 -2
  130. package/clis/xiaohongshu/download.js +97 -39
  131. package/clis/xiaohongshu/download.test.js +201 -0
  132. package/clis/zhihu/answer-comments.js +2 -21
  133. package/clis/zhihu/answer-detail.js +2 -31
  134. package/clis/zhihu/collection.js +2 -14
  135. package/clis/zhihu/collection.test.js +4 -3
  136. package/clis/zhihu/question.js +1 -9
  137. package/clis/zhihu/question.test.js +2 -2
  138. package/clis/zhihu/search.js +1 -12
  139. package/clis/zhihu/search.test.js +2 -2
  140. package/clis/zhihu/text.js +29 -0
  141. package/clis/zhihu/text.test.js +24 -0
  142. package/dist/src/browser/network-cache.js +13 -1
  143. package/dist/src/browser/network-cache.test.js +17 -0
  144. package/dist/src/download/index.js +13 -1
  145. package/dist/src/download/index.test.js +23 -1
  146. package/dist/src/download/media-download.test.js +3 -1
  147. package/dist/src/download/progress.js +2 -2
  148. package/dist/src/download/progress.test.js +12 -1
  149. package/dist/src/output.js +11 -1
  150. package/dist/src/output.test.js +6 -0
  151. package/dist/src/registry.js +1 -0
  152. package/dist/src/registry.test.js +11 -0
  153. package/package.json +1 -1
@@ -0,0 +1,79 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
4
+ import './stats.js';
5
+
6
+ afterEach(() => {
7
+ vi.unstubAllGlobals();
8
+ });
9
+
10
+ function mockFetch(body) {
11
+ return vi.fn().mockResolvedValue({
12
+ ok: true,
13
+ status: 200,
14
+ json: () => Promise.resolve(body),
15
+ });
16
+ }
17
+
18
+ describe('chess stats command', () => {
19
+ it('rejects empty username via validateUsername', async () => {
20
+ const cmd = getRegistry().get('chess/stats');
21
+ await expect(cmd.func({ username: '' })).rejects.toBeInstanceOf(ArgumentError);
22
+ });
23
+
24
+ it('rejects invalid username characters', async () => {
25
+ const cmd = getRegistry().get('chess/stats');
26
+ await expect(cmd.func({ username: 'user name' })).rejects.toBeInstanceOf(ArgumentError);
27
+ });
28
+
29
+ it('returns one row per known game kind populated in the stats response', async () => {
30
+ const fetchMock = mockFetch({
31
+ chess_rapid: { last: { rating: 1700 }, best: { rating: 1800 }, record: { win: 50, loss: 20, draw: 5 } },
32
+ chess_blitz: { last: { rating: 1500 }, best: { rating: 1600 }, record: { win: 100, loss: 80, draw: 10 } },
33
+ });
34
+ vi.stubGlobal('fetch', fetchMock);
35
+ const cmd = getRegistry().get('chess/stats');
36
+ const rows = await cmd.func({ username: 'someuser' });
37
+ expect(rows).toHaveLength(2);
38
+ expect(rows[0].kind).toBe('rapid');
39
+ expect(rows[1].kind).toBe('blitz');
40
+ expect(fetchMock).toHaveBeenCalledWith(
41
+ 'https://api.chess.com/pub/player/someuser/stats',
42
+ expect.objectContaining({ headers: expect.any(Object) }),
43
+ );
44
+ });
45
+
46
+ it('lowercases username in the URL', async () => {
47
+ const fetchMock = mockFetch({ chess_rapid: { last: { rating: 1 }, best: {}, record: {} } });
48
+ vi.stubGlobal('fetch', fetchMock);
49
+ const cmd = getRegistry().get('chess/stats');
50
+ await cmd.func({ username: 'MixedCase' });
51
+ expect(fetchMock).toHaveBeenCalledWith(
52
+ 'https://api.chess.com/pub/player/mixedcase/stats',
53
+ expect.any(Object),
54
+ );
55
+ });
56
+
57
+ it('throws EmptyResultError when the stats response has no known kinds', async () => {
58
+ vi.stubGlobal('fetch', mockFetch({}));
59
+ const cmd = getRegistry().get('chess/stats');
60
+ await expect(cmd.func({ username: 'someuser' })).rejects.toBeInstanceOf(EmptyResultError);
61
+ });
62
+
63
+ it('throws CommandExecutionError when a populated stats kind is malformed', async () => {
64
+ vi.stubGlobal('fetch', mockFetch({ chess_rapid: 'bad' }));
65
+ const cmd = getRegistry().get('chess/stats');
66
+ await expect(cmd.func({ username: 'someuser' })).rejects.toBeInstanceOf(CommandExecutionError);
67
+ });
68
+
69
+ it('throws EmptyResultError on HTTP 404', async () => {
70
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 }));
71
+ const cmd = getRegistry().get('chess/stats');
72
+ await expect(cmd.func({ username: 'someuser' })).rejects.toBeInstanceOf(EmptyResultError);
73
+ });
74
+
75
+ it('registers with the expected columns', () => {
76
+ const cmd = getRegistry().get('chess/stats');
77
+ expect(cmd?.columns).toEqual(['kind', 'rating_current', 'rating_best', 'wins', 'losses', 'draws']);
78
+ });
79
+ });
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Shared helpers for the public Chess.com REST API
3
+ * (https://api.chess.com/pub/). No auth, no rate-limit headers.
4
+ */
5
+ import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
6
+
7
+ export const API_BASE = 'https://api.chess.com/pub';
8
+ export const UA = 'Mozilla/5.0 (compatible; opencli/1.0)';
9
+
10
+ const USERNAME_RE = /^[a-zA-Z0-9_-]{3,25}$/;
11
+ const GAME_URL_RE = /^https:\/\/www\.chess\.com\/game\/(live|daily)\/(\d+)/i;
12
+
13
+ export function isPlainObject(value) {
14
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
15
+ }
16
+
17
+ function isOptionalPlainObject(value) {
18
+ return value === undefined || value === null || isPlainObject(value);
19
+ }
20
+
21
+ export function validateUsername(value) {
22
+ const s = String(value ?? '').trim().toLowerCase();
23
+ if (!s) throw new ArgumentError('<username> is required');
24
+ if (!USERNAME_RE.test(s)) {
25
+ throw new ArgumentError(`Invalid Chess.com username "${value}"`, 'Usernames are 3-25 chars: a-z, 0-9, hyphen, underscore.');
26
+ }
27
+ return s;
28
+ }
29
+
30
+ export function parseGameUrl(value) {
31
+ const s = String(value ?? '').trim();
32
+ if (!s) throw new ArgumentError('<game-url> is required');
33
+ const m = s.match(GAME_URL_RE);
34
+ if (!m) {
35
+ throw new ArgumentError(
36
+ `Invalid Chess.com game URL: "${value}"`,
37
+ 'Expected https://www.chess.com/game/live/<id> or https://www.chess.com/game/daily/<id>.',
38
+ );
39
+ }
40
+ return { kind: m[1].toLowerCase(), id: m[2] };
41
+ }
42
+
43
+ export async function chessApi(path, fetchImpl = fetch) {
44
+ const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
45
+ let resp;
46
+ try {
47
+ resp = await fetchImpl(url, { headers: { 'User-Agent': UA, accept: 'application/json' } });
48
+ } catch (error) {
49
+ throw new CommandExecutionError(`Failed to fetch Chess.com API ${url}: ${error?.message || error}`);
50
+ }
51
+ if (!resp || typeof resp !== 'object') {
52
+ throw new CommandExecutionError(`Chess.com API returned an invalid response object for ${url}`);
53
+ }
54
+ if (resp.status === 404) throw new EmptyResultError(`Chess.com returned 404 for ${url}`);
55
+ if (!resp.ok) throw new CommandExecutionError(`Chess.com API returned HTTP ${resp.status} for ${url}`);
56
+ let payload;
57
+ try {
58
+ payload = await resp.json();
59
+ } catch (error) {
60
+ throw new CommandExecutionError(`Chess.com API returned malformed JSON for ${url}: ${error?.message || error}`);
61
+ }
62
+ if (!isPlainObject(payload)) {
63
+ throw new CommandExecutionError(`Chess.com API returned an unexpected payload shape for ${url}`);
64
+ }
65
+ return payload;
66
+ }
67
+
68
+ /** Pull rating + record fields out of a stats sub-object (`chess_rapid` etc). */
69
+ export function summarizeStats(stats, kind) {
70
+ const k = stats?.[kind];
71
+ if (!k) return null;
72
+ if (!isPlainObject(k)) {
73
+ throw new CommandExecutionError(`Chess.com stats payload for ${kind} is not an object`);
74
+ }
75
+ if (!isOptionalPlainObject(k.last)) {
76
+ throw new CommandExecutionError(`Chess.com stats payload for ${kind}.last is not an object`);
77
+ }
78
+ if (!isOptionalPlainObject(k.best)) {
79
+ throw new CommandExecutionError(`Chess.com stats payload for ${kind}.best is not an object`);
80
+ }
81
+ if (!isOptionalPlainObject(k.record)) {
82
+ throw new CommandExecutionError(`Chess.com stats payload for ${kind}.record is not an object`);
83
+ }
84
+ const record = isPlainObject(k.record) ? k.record : {};
85
+ return {
86
+ kind: kind.replace(/^chess_/, ''),
87
+ rating_current: k.last?.rating ?? '',
88
+ rating_best: k.best?.rating ?? '',
89
+ wins: record.win ?? '',
90
+ losses: record.loss ?? '',
91
+ draws: record.draw ?? '',
92
+ };
93
+ }
94
+
95
+ /** Parse an end_time epoch (seconds) into YYYY-MM-DD. */
96
+ export function formatDate(epochSeconds) {
97
+ if (!epochSeconds || typeof epochSeconds !== 'number') return '';
98
+ return new Date(epochSeconds * 1000).toISOString().slice(0, 10);
99
+ }
100
+
101
+ /**
102
+ * Pull "Reti Opening: Nimzo-Larsen Variation" out of the Chess.com eco URL
103
+ * (`https://www.chess.com/openings/Reti-Opening-Nimzo-Larsen-Variation-2...g6-...`).
104
+ * Returns '' for short-code eco values (`A01`) where no name is encoded.
105
+ */
106
+ export function openingName(eco) {
107
+ if (typeof eco !== 'string' || !eco.startsWith('http')) return '';
108
+ const tail = eco.replace(/\/+$/, '').split('/').pop() || '';
109
+ if (!tail) return '';
110
+ const namePart = tail.match(/^([^.]+?)(?:-\d|\.\.\.|$)/);
111
+ const cleaned = (namePart ? namePart[1] : tail).replace(/-/g, ' ').trim();
112
+ return cleaned;
113
+ }
114
+
115
+ /**
116
+ * Map a Chess.com game record (from the monthly archive) to a flat row.
117
+ * The viewer perspective controls win/loss orientation.
118
+ */
119
+ export function mapGameRow(game, viewerUsername) {
120
+ if (!isPlainObject(game)) {
121
+ throw new CommandExecutionError('Chess.com game archive entry is not an object');
122
+ }
123
+ if (typeof game.url !== 'string' || !/^https:\/\/www\.chess\.com\/game\/(?:live|daily)\/\d+(?:$|[/?#])/i.test(game.url)) {
124
+ throw new CommandExecutionError('Chess.com game archive entry is missing a stable game URL');
125
+ }
126
+ const white = game?.white || {};
127
+ const black = game?.black || {};
128
+ if (!isPlainObject(white) || !isPlainObject(black)) {
129
+ throw new CommandExecutionError('Chess.com game archive entry has malformed player objects');
130
+ }
131
+ if (typeof white.username !== 'string' || !white.username.trim() || typeof black.username !== 'string' || !black.username.trim()) {
132
+ throw new CommandExecutionError('Chess.com game archive entry is missing stable player identities');
133
+ }
134
+ const viewerLower = String(viewerUsername || '').toLowerCase();
135
+ const viewerIsWhite = String(white.username || '').toLowerCase() === viewerLower;
136
+ const viewerIsBlack = String(black.username || '').toLowerCase() === viewerLower;
137
+ if (!viewerIsWhite && !viewerIsBlack) {
138
+ throw new CommandExecutionError('Chess.com game archive entry does not include the requested player');
139
+ }
140
+ const me = viewerIsWhite ? white : black;
141
+ const opp = viewerIsWhite ? black : white;
142
+ const eco = game?.eco || '';
143
+ return {
144
+ date: formatDate(game?.end_time),
145
+ time_class: game?.time_class || '',
146
+ rated: game?.rated === true,
147
+ my_color: viewerIsWhite ? 'white' : 'black',
148
+ my_rating: me?.rating ?? '',
149
+ my_result: me?.result || '',
150
+ opponent: opp?.username || '',
151
+ opponent_rating: opp?.rating ?? '',
152
+ accuracy_white: typeof game?.accuracies?.white === 'number' ? game.accuracies.white : '',
153
+ accuracy_black: typeof game?.accuracies?.black === 'number' ? game.accuracies.black : '',
154
+ eco,
155
+ opening_name: openingName(eco),
156
+ url: game?.url || '',
157
+ };
158
+ }
159
+
160
+ export const __test__ = {
161
+ validateUsername,
162
+ parseGameUrl,
163
+ isPlainObject,
164
+ isOptionalPlainObject,
165
+ chessApi,
166
+ summarizeStats,
167
+ formatDate,
168
+ mapGameRow,
169
+ openingName,
170
+ };
@@ -0,0 +1,230 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
+ import { __test__ } from './utils.js';
4
+
5
+ const { validateUsername, parseGameUrl, chessApi, summarizeStats, formatDate, mapGameRow, openingName } = __test__;
6
+
7
+ describe('chess utils', () => {
8
+ it('validateUsername lowercases and accepts 3-25 char usernames', () => {
9
+ expect(validateUsername('Hikaru')).toBe('hikaru');
10
+ expect(validateUsername('MagnusCarlsen')).toBe('magnuscarlsen');
11
+ expect(validateUsername('a-b_c')).toBe('a-b_c');
12
+ });
13
+
14
+ it('validateUsername rejects empty / too-short / invalid chars', () => {
15
+ expect(() => validateUsername('')).toThrow(ArgumentError);
16
+ expect(() => validateUsername('ab')).toThrow(ArgumentError);
17
+ expect(() => validateUsername('user name')).toThrow(ArgumentError);
18
+ expect(() => validateUsername('a'.repeat(30))).toThrow(ArgumentError);
19
+ });
20
+
21
+ it('parseGameUrl parses both live and daily game URL forms', () => {
22
+ expect(parseGameUrl('https://www.chess.com/game/live/168842570216'))
23
+ .toEqual({ kind: 'live', id: '168842570216' });
24
+ expect(parseGameUrl('https://www.chess.com/game/daily/947761777'))
25
+ .toEqual({ kind: 'daily', id: '947761777' });
26
+ expect(parseGameUrl('https://www.chess.com/game/LIVE/1'))
27
+ .toEqual({ kind: 'live', id: '1' });
28
+ });
29
+
30
+ it('parseGameUrl strips trailing path / query off the URL', () => {
31
+ expect(parseGameUrl('https://www.chess.com/game/live/123/something?ref=share'))
32
+ .toEqual({ kind: 'live', id: '123' });
33
+ });
34
+
35
+ it('parseGameUrl rejects empty / non-URL / unsupported-kind inputs', () => {
36
+ expect(() => parseGameUrl('')).toThrow(ArgumentError);
37
+ expect(() => parseGameUrl(' ')).toThrow(ArgumentError);
38
+ expect(() => parseGameUrl('123')).toThrow(ArgumentError);
39
+ expect(() => parseGameUrl('https://www.chess.com/club/123')).toThrow(ArgumentError);
40
+ expect(() => parseGameUrl('https://lichess.org/abc')).toThrow(ArgumentError);
41
+ });
42
+
43
+ it('summarizeStats projects rating + record fields', () => {
44
+ const stats = {
45
+ chess_rapid: {
46
+ last: { rating: 1600 },
47
+ best: { rating: 1800 },
48
+ record: { win: 100, loss: 50, draw: 10 },
49
+ },
50
+ };
51
+ expect(summarizeStats(stats, 'chess_rapid')).toEqual({
52
+ kind: 'rapid',
53
+ rating_current: 1600,
54
+ rating_best: 1800,
55
+ wins: 100,
56
+ losses: 50,
57
+ draws: 10,
58
+ });
59
+ });
60
+
61
+ it('summarizeStats returns null for missing kind', () => {
62
+ expect(summarizeStats({}, 'chess_rapid')).toBeNull();
63
+ expect(summarizeStats({ chess_blitz: {} }, 'chess_rapid')).toBeNull();
64
+ });
65
+
66
+ it('summarizeStats typed-fails malformed populated kind objects', () => {
67
+ expect(() => summarizeStats({ chess_rapid: 'bad' }, 'chess_rapid')).toThrow(CommandExecutionError);
68
+ expect(() => summarizeStats({ chess_rapid: { record: [] } }, 'chess_rapid')).toThrow(CommandExecutionError);
69
+ });
70
+
71
+ it('summarizeStats coerces missing numeric fields to empty string', () => {
72
+ const row = summarizeStats({ chess_daily: { last: {}, record: {} } }, 'chess_daily');
73
+ expect(row).toEqual({
74
+ kind: 'daily',
75
+ rating_current: '',
76
+ rating_best: '',
77
+ wins: '',
78
+ losses: '',
79
+ draws: '',
80
+ });
81
+ });
82
+
83
+ it('formatDate converts epoch seconds to YYYY-MM-DD', () => {
84
+ expect(formatDate(1777737679)).toBe('2026-05-02');
85
+ expect(formatDate(0)).toBe('');
86
+ expect(formatDate(null)).toBe('');
87
+ expect(formatDate('not-a-number')).toBe('');
88
+ });
89
+
90
+ it('mapGameRow returns rows from the viewer perspective when viewer is white', () => {
91
+ const game = {
92
+ url: 'https://www.chess.com/game/live/123',
93
+ end_time: 1777737679,
94
+ time_class: 'blitz',
95
+ rated: true,
96
+ eco: 'C50',
97
+ accuracies: { white: 87.73, black: 80.23 },
98
+ white: { username: 'Hikaru', rating: 3286, result: 'win' },
99
+ black: { username: 'Magnus', rating: 2900, result: 'resigned' },
100
+ };
101
+ expect(mapGameRow(game, 'Hikaru')).toEqual({
102
+ date: '2026-05-02',
103
+ time_class: 'blitz',
104
+ rated: true,
105
+ my_color: 'white',
106
+ my_rating: 3286,
107
+ my_result: 'win',
108
+ opponent: 'Magnus',
109
+ opponent_rating: 2900,
110
+ accuracy_white: 87.73,
111
+ accuracy_black: 80.23,
112
+ eco: 'C50',
113
+ opening_name: '',
114
+ url: 'https://www.chess.com/game/live/123',
115
+ });
116
+ });
117
+
118
+ it('mapGameRow leaves accuracy fields empty when chess.com did not compute them', () => {
119
+ const game = {
120
+ url: 'https://www.chess.com/game/live/123',
121
+ white: { username: 'A', rating: 1, result: 'win' },
122
+ black: { username: 'B', rating: 1, result: 'resigned' },
123
+ };
124
+ const row = mapGameRow(game, 'A');
125
+ expect(row.accuracy_white).toBe('');
126
+ expect(row.accuracy_black).toBe('');
127
+ });
128
+
129
+ it('mapGameRow parses opening_name from the chess.com eco URL', () => {
130
+ const game = {
131
+ url: 'https://www.chess.com/game/live/123',
132
+ eco: 'https://www.chess.com/openings/Reti-Opening-Nimzo-Larsen-Variation-2...g6-3.Bb2-Bg7-4.d4',
133
+ white: { username: 'A', rating: 1, result: 'win' },
134
+ black: { username: 'B', rating: 1, result: 'resigned' },
135
+ };
136
+ const row = mapGameRow(game, 'A');
137
+ expect(row.opening_name).toBe('Reti Opening Nimzo Larsen Variation');
138
+ });
139
+
140
+ it('openingName helper returns clean human-readable name from URL form', () => {
141
+ expect(openingName('https://www.chess.com/openings/Sicilian-Defense')).toBe('Sicilian Defense');
142
+ expect(openingName('https://www.chess.com/openings/Kings-Indian-Defense-Semi-Classical-Variation...7.O-O'))
143
+ .toBe('Kings Indian Defense Semi Classical Variation');
144
+ expect(openingName('https://www.chess.com/openings/French-Defense-Advance-Variation-3...c5-4.c3'))
145
+ .toBe('French Defense Advance Variation');
146
+ });
147
+
148
+ it('openingName returns empty for short-code eco or missing input', () => {
149
+ expect(openingName('A01')).toBe('');
150
+ expect(openingName('')).toBe('');
151
+ expect(openingName(undefined)).toBe('');
152
+ expect(openingName(null)).toBe('');
153
+ });
154
+
155
+ it('mapGameRow flips perspective when viewer is black', () => {
156
+ const game = {
157
+ url: 'https://www.chess.com/game/live/123',
158
+ white: { username: 'Hikaru', rating: 3286, result: 'win' },
159
+ black: { username: 'Magnus', rating: 2900, result: 'resigned' },
160
+ };
161
+ const row = mapGameRow(game, 'Magnus');
162
+ expect(row.my_color).toBe('black');
163
+ expect(row.my_result).toBe('resigned');
164
+ expect(row.opponent).toBe('Hikaru');
165
+ });
166
+
167
+ it('mapGameRow matches viewer case-insensitively', () => {
168
+ const game = {
169
+ url: 'https://www.chess.com/game/live/123',
170
+ white: { username: 'Hikaru', rating: 3286, result: 'win' },
171
+ black: { username: 'Magnus', rating: 2900, result: 'resigned' },
172
+ };
173
+ expect(mapGameRow(game, 'hikaru').my_color).toBe('white');
174
+ expect(mapGameRow(game, 'MAGNUS').my_color).toBe('black');
175
+ });
176
+
177
+ it('mapGameRow typed-fails when viewer is neither player', () => {
178
+ const game = {
179
+ url: 'https://www.chess.com/game/live/123',
180
+ white: { username: 'A', rating: 1000, result: 'win' },
181
+ black: { username: 'B', rating: 1100, result: 'resigned' },
182
+ };
183
+ expect(() => mapGameRow(game, 'C')).toThrow(CommandExecutionError);
184
+ });
185
+
186
+ it('mapGameRow handles missing optional fields without throwing', () => {
187
+ const row = mapGameRow({
188
+ url: 'https://www.chess.com/game/live/123',
189
+ white: { username: 'x' },
190
+ black: { username: 'y' },
191
+ }, 'x');
192
+ expect(row.date).toBe('');
193
+ expect(row.url).toBe('https://www.chess.com/game/live/123');
194
+ expect(row.eco).toBe('');
195
+ });
196
+
197
+ it('mapGameRow typed-fails missing stable URL or player identity', () => {
198
+ expect(() => mapGameRow({
199
+ white: { username: 'x' },
200
+ black: { username: 'y' },
201
+ }, 'x')).toThrow(CommandExecutionError);
202
+ expect(() => mapGameRow({
203
+ url: 'https://www.chess.com/game/live/123',
204
+ white: { username: 'x' },
205
+ black: {},
206
+ }, 'x')).toThrow(CommandExecutionError);
207
+ });
208
+
209
+ it('chessApi maps network, malformed JSON, and wrong-shape payloads to typed errors', async () => {
210
+ await expect(chessApi('/x', async () => { throw new TypeError('network down'); }))
211
+ .rejects.toBeInstanceOf(CommandExecutionError);
212
+ await expect(chessApi('/x', async () => ({
213
+ ok: true,
214
+ status: 200,
215
+ json: async () => { throw new SyntaxError('bad json'); },
216
+ }))).rejects.toBeInstanceOf(CommandExecutionError);
217
+ await expect(chessApi('/x', async () => ({
218
+ ok: true,
219
+ status: 200,
220
+ json: async () => [],
221
+ }))).rejects.toBeInstanceOf(CommandExecutionError);
222
+ });
223
+
224
+ it('chessApi preserves 404 as empty and non-2xx as command execution errors', async () => {
225
+ await expect(chessApi('/x', async () => ({ ok: false, status: 404 })))
226
+ .rejects.toBeInstanceOf(EmptyResultError);
227
+ await expect(chessApi('/x', async () => ({ ok: false, status: 500 })))
228
+ .rejects.toBeInstanceOf(CommandExecutionError);
229
+ });
230
+ });
@@ -0,0 +1,195 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import { describe, expect, it, afterEach, vi } from 'vitest';
5
+ import { getRegistry } from '@jackwener/opencli/registry';
6
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
7
+ import './page.js';
8
+ import './search.js';
9
+ import './create.js';
10
+ import './update.js';
11
+
12
+ const ENV_KEYS = [
13
+ 'ATLASSIAN_CONFLUENCE_BASE_URL',
14
+ 'ATLASSIAN_DEPLOYMENT',
15
+ 'ATLASSIAN_EMAIL',
16
+ 'ATLASSIAN_API_TOKEN',
17
+ 'ATLASSIAN_USERNAME',
18
+ 'ATLASSIAN_PASSWORD',
19
+ 'ATLASSIAN_PAT',
20
+ ];
21
+
22
+ function clearEnv() {
23
+ for (const key of ENV_KEYS) delete process.env[key];
24
+ }
25
+
26
+ function setCloudEnv() {
27
+ clearEnv();
28
+ process.env.ATLASSIAN_CONFLUENCE_BASE_URL = 'https://team.atlassian.net/wiki';
29
+ process.env.ATLASSIAN_DEPLOYMENT = 'cloud';
30
+ process.env.ATLASSIAN_EMAIL = 'bot@example.com';
31
+ process.env.ATLASSIAN_API_TOKEN = 'secret';
32
+ }
33
+
34
+ function jsonResponse(body) {
35
+ return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
36
+ }
37
+
38
+ async function withTempMarkdown(markdown, fn) {
39
+ const dir = await mkdtemp(join(tmpdir(), 'opencli-confluence-'));
40
+ const file = join(dir, 'doc.md');
41
+ await writeFile(file, markdown);
42
+ try {
43
+ return await fn(file);
44
+ } finally {
45
+ await rm(dir, { recursive: true, force: true });
46
+ }
47
+ }
48
+
49
+ afterEach(() => {
50
+ clearEnv();
51
+ vi.unstubAllGlobals();
52
+ });
53
+
54
+ describe('confluence commands', () => {
55
+ it('registers expected REST commands', () => {
56
+ for (const name of ['page', 'search', 'create', 'update']) {
57
+ const cmd = getRegistry().get(`confluence/${name}`);
58
+ expect(cmd).toBeDefined();
59
+ expect(cmd.browser).toBe(false);
60
+ expect(cmd.strategy).toBe('public');
61
+ }
62
+ });
63
+
64
+ it('requires --execute before create performs remote writes', async () => {
65
+ const cmd = getRegistry().get('confluence/create');
66
+ await expect(cmd.func({ space: '123', title: 'Doc', file: 'doc.md' })).rejects.toThrow(/--execute/);
67
+ });
68
+
69
+ it('creates a Cloud page from Markdown storage', async () => {
70
+ setCloudEnv();
71
+ await withTempMarkdown('# RCA\n\n- Payment failed', async (file) => {
72
+ const fetchMock = vi.fn(async (url, init) => {
73
+ expect(String(url)).toBe('https://team.atlassian.net/wiki/api/v2/pages');
74
+ const payload = JSON.parse(init.body);
75
+ expect(payload).toMatchObject({ spaceId: '987', title: 'PROJ-1 RCA' });
76
+ expect(payload.body.value).toContain('<h1>RCA</h1>');
77
+ expect(payload.body.value.replace(/\s*\n\s*/g, '')).toContain('<li>Payment failed</li>');
78
+ return jsonResponse({
79
+ id: '555',
80
+ title: 'PROJ-1 RCA',
81
+ status: 'current',
82
+ spaceId: '987',
83
+ version: { number: 1, createdAt: '2026-05-01T00:00:00Z' },
84
+ body: { storage: { value: '<h1>RCA</h1>' } },
85
+ _links: { webui: '/spaces/ENG/pages/555' },
86
+ });
87
+ });
88
+ vi.stubGlobal('fetch', fetchMock);
89
+ const cmd = getRegistry().get('confluence/create');
90
+ const rows = await cmd.func({ space: '987', title: 'PROJ-1 RCA', file, representation: 'markdown', execute: true });
91
+ expect(rows[0]).toMatchObject({ status: 'created', id: '555', title: 'PROJ-1 RCA', version: 1 });
92
+ expect(rows[0].url).toBe('https://team.atlassian.net/wiki/spaces/ENG/pages/555');
93
+ });
94
+ });
95
+
96
+ it('updates a page by incrementing the current version', async () => {
97
+ setCloudEnv();
98
+ await withTempMarkdown('Updated body', async (file) => {
99
+ const fetchMock = vi.fn(async (url, init) => {
100
+ if (init.method === 'GET') {
101
+ expect(String(url)).toBe('https://team.atlassian.net/wiki/api/v2/pages/555?body-format=storage');
102
+ return jsonResponse({
103
+ id: '555',
104
+ title: 'Existing',
105
+ status: 'current',
106
+ version: { number: 7 },
107
+ body: { storage: { value: '<p>Old</p>' } },
108
+ });
109
+ }
110
+ expect(init.method).toBe('PUT');
111
+ const payload = JSON.parse(init.body);
112
+ expect(payload.version).toMatchObject({ number: 8, message: 'Sync from Jira' });
113
+ expect(payload.title).toBe('Existing');
114
+ return jsonResponse({
115
+ id: '555',
116
+ title: 'Existing',
117
+ status: 'current',
118
+ version: { number: 8 },
119
+ body: { storage: { value: '<p>Updated body</p>' } },
120
+ });
121
+ });
122
+ vi.stubGlobal('fetch', fetchMock);
123
+ const cmd = getRegistry().get('confluence/update');
124
+ const rows = await cmd.func({ id: '555', file, representation: 'markdown', 'version-message': 'Sync from Jira', execute: true });
125
+ expect(rows[0]).toMatchObject({ status: 'updated', id: '555', version: 8 });
126
+ expect(fetchMock).toHaveBeenCalledTimes(2);
127
+ });
128
+ });
129
+
130
+ it('does not update a Confluence page when the current version is malformed', async () => {
131
+ setCloudEnv();
132
+ await withTempMarkdown('Updated body', async (file) => {
133
+ const fetchMock = vi.fn(async (url, init) => {
134
+ expect(init.method).toBe('GET');
135
+ return jsonResponse({
136
+ id: '555',
137
+ title: 'Existing',
138
+ status: 'current',
139
+ body: { storage: { value: '<p>Old</p>' } },
140
+ });
141
+ });
142
+ vi.stubGlobal('fetch', fetchMock);
143
+ const cmd = getRegistry().get('confluence/update');
144
+ await expect(cmd.func({ id: '555', file, representation: 'markdown', execute: true }))
145
+ .rejects.toBeInstanceOf(CommandExecutionError);
146
+ expect(fetchMock).toHaveBeenCalledTimes(1);
147
+ });
148
+ });
149
+
150
+ it('searches with CQL scoped to a Data Center space', async () => {
151
+ clearEnv();
152
+ process.env.ATLASSIAN_CONFLUENCE_BASE_URL = 'https://conf.example.com/confluence';
153
+ process.env.ATLASSIAN_DEPLOYMENT = 'datacenter';
154
+ process.env.ATLASSIAN_PAT = 'pat';
155
+ const fetchMock = vi.fn(async (url) => {
156
+ const parsed = new URL(String(url));
157
+ expect(parsed.searchParams.get('cql')).toBe('space = "ENG" and (type = page)');
158
+ return jsonResponse({
159
+ results: [{
160
+ content: {
161
+ id: '123',
162
+ type: 'page',
163
+ status: 'current',
164
+ title: 'Runbook',
165
+ space: { key: 'ENG' },
166
+ _links: { webui: '/display/ENG/Runbook' },
167
+ },
168
+ lastModified: '2026-05-02T00:00:00Z',
169
+ }],
170
+ });
171
+ });
172
+ vi.stubGlobal('fetch', fetchMock);
173
+ const cmd = getRegistry().get('confluence/search');
174
+ const rows = await cmd.func({ cql: 'type = page', space: 'ENG', limit: 10 });
175
+ expect(rows[0]).toMatchObject({ id: '123', title: 'Runbook', spaceKey: 'ENG' });
176
+ expect(rows[0].url).toBe('https://conf.example.com/confluence/display/ENG/Runbook');
177
+ });
178
+
179
+ it('separates Confluence search empty results from malformed search payloads', async () => {
180
+ setCloudEnv();
181
+ const cmd = getRegistry().get('confluence/search');
182
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ results: [] })));
183
+ await expect(cmd.func({ cql: 'type = page', limit: 10 })).rejects.toBeInstanceOf(EmptyResultError);
184
+
185
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ items: [] })));
186
+ await expect(cmd.func({ cql: 'type = page', limit: 10 })).rejects.toBeInstanceOf(CommandExecutionError);
187
+ });
188
+
189
+ it('fails typed when Confluence page payload lacks stable page identity', async () => {
190
+ setCloudEnv();
191
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ title: 'Missing id', version: { number: 1 } })));
192
+ const cmd = getRegistry().get('confluence/page');
193
+ await expect(cmd.func({ id: '555' })).rejects.toBeInstanceOf(CommandExecutionError);
194
+ });
195
+ });