@jackwener/opencli 1.7.18 → 1.7.19

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 (95) hide show
  1. package/README.md +7 -8
  2. package/README.zh-CN.md +7 -8
  3. package/cli-manifest.json +305 -9
  4. package/clis/ctrip/ctrip.test.js +486 -1
  5. package/clis/ctrip/flight.js +136 -0
  6. package/clis/ctrip/hotel-search.js +132 -0
  7. package/clis/ctrip/utils.js +298 -0
  8. package/clis/google/search.js +16 -6
  9. package/clis/google-scholar/search.js +20 -5
  10. package/clis/google-scholar/search.test.js +35 -2
  11. package/clis/reddit/home.js +117 -0
  12. package/clis/reddit/home.test.js +127 -0
  13. package/clis/reddit/read.js +400 -54
  14. package/clis/reddit/read.test.js +315 -12
  15. package/clis/reddit/subreddit-info.js +117 -0
  16. package/clis/reddit/subreddit-info.test.js +163 -0
  17. package/clis/reddit/whoami.js +84 -0
  18. package/clis/reddit/whoami.test.js +105 -0
  19. package/clis/rednote/search.js +6 -2
  20. package/clis/twitter/bookmark-folder.js +3 -1
  21. package/clis/twitter/bookmarks.js +3 -1
  22. package/clis/twitter/followers.js +20 -5
  23. package/clis/twitter/followers.test.js +44 -0
  24. package/clis/twitter/following.js +36 -20
  25. package/clis/twitter/following.test.js +60 -8
  26. package/clis/twitter/likes.js +28 -13
  27. package/clis/twitter/likes.test.js +111 -1
  28. package/clis/twitter/list-add.js +128 -204
  29. package/clis/twitter/list-add.test.js +97 -1
  30. package/clis/twitter/list-tweets.js +13 -4
  31. package/clis/twitter/list-tweets.test.js +48 -0
  32. package/clis/twitter/lists.js +5 -2
  33. package/clis/twitter/post.js +23 -4
  34. package/clis/twitter/post.test.js +30 -0
  35. package/clis/twitter/profile.js +16 -8
  36. package/clis/twitter/profile.test.js +39 -0
  37. package/clis/twitter/reply.js +133 -10
  38. package/clis/twitter/reply.test.js +55 -0
  39. package/clis/twitter/search.js +188 -170
  40. package/clis/twitter/search.test.js +96 -258
  41. package/clis/twitter/shared.js +167 -16
  42. package/clis/twitter/shared.test.js +102 -1
  43. package/clis/twitter/timeline.js +3 -1
  44. package/clis/twitter/tweets.js +147 -51
  45. package/clis/twitter/tweets.test.js +238 -1
  46. package/clis/xiaohongshu/comments.js +23 -2
  47. package/clis/xiaohongshu/comments.test.js +63 -1
  48. package/clis/xiaohongshu/search.js +168 -13
  49. package/clis/xiaohongshu/search.test.js +82 -8
  50. package/clis/xueqiu/earnings-date.js +2 -2
  51. package/clis/xueqiu/kline.js +2 -2
  52. package/clis/xueqiu/utils.js +19 -0
  53. package/clis/xueqiu/utils.test.js +26 -0
  54. package/clis/zhihu/answer-detail.js +233 -0
  55. package/clis/zhihu/answer-detail.test.js +330 -0
  56. package/clis/zhihu/question.js +44 -10
  57. package/clis/zhihu/question.test.js +78 -1
  58. package/clis/zhihu/recommend.js +103 -0
  59. package/clis/zhihu/recommend.test.js +143 -0
  60. package/dist/src/browser/base-page.d.ts +3 -2
  61. package/dist/src/browser/base-page.test.js +2 -2
  62. package/dist/src/browser/cdp.js +3 -3
  63. package/dist/src/browser/page.d.ts +3 -2
  64. package/dist/src/browser/page.js +4 -4
  65. package/dist/src/browser/page.test.js +31 -0
  66. package/dist/src/browser/utils.d.ts +10 -0
  67. package/dist/src/browser/utils.js +37 -0
  68. package/dist/src/browser/utils.test.d.ts +1 -0
  69. package/dist/src/browser/utils.test.js +29 -0
  70. package/dist/src/cli-argv-preprocess.d.ts +37 -0
  71. package/dist/src/cli-argv-preprocess.js +131 -0
  72. package/dist/src/cli-argv-preprocess.test.d.ts +1 -0
  73. package/dist/src/cli-argv-preprocess.test.js +130 -0
  74. package/dist/src/cli.js +123 -86
  75. package/dist/src/cli.test.js +33 -28
  76. package/dist/src/commands/daemon.js +6 -7
  77. package/dist/src/doctor.js +15 -16
  78. package/dist/src/download/progress.js +15 -11
  79. package/dist/src/download/progress.test.d.ts +1 -0
  80. package/dist/src/download/progress.test.js +25 -0
  81. package/dist/src/execution.js +1 -3
  82. package/dist/src/execution.test.js +4 -16
  83. package/dist/src/help.d.ts +11 -0
  84. package/dist/src/help.js +46 -5
  85. package/dist/src/logger.js +8 -9
  86. package/dist/src/main.js +16 -0
  87. package/dist/src/output.js +4 -5
  88. package/dist/src/runtime-detect.d.ts +1 -1
  89. package/dist/src/runtime-detect.js +1 -1
  90. package/dist/src/runtime-detect.test.js +3 -2
  91. package/dist/src/tui.d.ts +0 -1
  92. package/dist/src/tui.js +9 -22
  93. package/dist/src/types.d.ts +3 -1
  94. package/dist/src/update-check.js +4 -5
  95. package/package.json +5 -4
@@ -1,20 +1,163 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
4
+ import { normalizeRedditPostId, parseExpandRounds } from './read.js';
3
5
  import './read.js';
6
+
7
+ function makePage(result) {
8
+ return {
9
+ goto: vi.fn().mockResolvedValue(undefined),
10
+ evaluate: vi.fn().mockResolvedValue(result),
11
+ };
12
+ }
13
+
14
+ function redditPostEnvelope(children) {
15
+ return [
16
+ {
17
+ data: {
18
+ children: [{
19
+ data: {
20
+ title: 'Post title',
21
+ selftext: '',
22
+ author: 'op',
23
+ score: 10,
24
+ is_self: true,
25
+ },
26
+ }],
27
+ },
28
+ },
29
+ { data: { children } },
30
+ ];
31
+ }
32
+
33
+ function commentThing(id, body, parent = 't3_abc123', score = 1) {
34
+ return {
35
+ kind: 't1',
36
+ data: {
37
+ id,
38
+ name: `t1_${id}`,
39
+ parent_id: parent,
40
+ author: id,
41
+ score,
42
+ body,
43
+ replies: '',
44
+ },
45
+ };
46
+ }
47
+
48
+ function moreThing(id, children, parent = 't3_abc123', count = children.length) {
49
+ return {
50
+ kind: 'more',
51
+ data: { id, parent_id: parent, children, count },
52
+ };
53
+ }
54
+
55
+ function jsonResponse(payload, status = 200) {
56
+ return {
57
+ ok: status >= 200 && status < 300,
58
+ status,
59
+ json: vi.fn().mockResolvedValue(payload),
60
+ };
61
+ }
62
+
63
+ function makeRuntimePage(fetchImpl) {
64
+ return {
65
+ goto: vi.fn().mockResolvedValue(undefined),
66
+ evaluate: vi.fn(async (script) => {
67
+ const previousFetch = globalThis.fetch;
68
+ globalThis.fetch = fetchImpl;
69
+ try {
70
+ return await eval(script);
71
+ } finally {
72
+ globalThis.fetch = previousFetch;
73
+ }
74
+ }),
75
+ };
76
+ }
77
+
4
78
  describe('reddit read adapter', () => {
5
79
  const command = getRegistry().get('reddit/read');
80
+
6
81
  it('opts into the Reddit persistent site session', () => {
7
82
  expect(command?.browser).toBe(true);
8
83
  expect(command?.siteSession).toBe('persistent');
84
+ expect(command?.columns).toEqual(['type', 'author', 'score', 'text']);
9
85
  });
10
- it('returns threaded rows from the browser-evaluated payload', async () => {
11
- const page = {
12
- goto: vi.fn().mockResolvedValue(undefined),
13
- evaluate: vi.fn().mockResolvedValue([
86
+
87
+ it('exposes the new --expand-more / --expand-rounds args', () => {
88
+ const argNames = command.args.map((a) => a.name);
89
+ expect(argNames).toContain('expand-more');
90
+ expect(argNames).toContain('expand-rounds');
91
+ const expandMore = command.args.find((a) => a.name === 'expand-more');
92
+ expect(expandMore.type).toBe('bool');
93
+ expect(expandMore.default).toBe(false);
94
+ const rounds = command.args.find((a) => a.name === 'expand-rounds');
95
+ expect(rounds.type).toBe('int');
96
+ expect(rounds.default).toBe(2);
97
+ });
98
+
99
+ describe('normalizeRedditPostId', () => {
100
+ it('accepts bare ids, t3 fullnames, and exact reddit post URLs', () => {
101
+ expect(normalizeRedditPostId('1AbC23')).toBe('1abc23');
102
+ expect(normalizeRedditPostId('t3_1AbC23')).toBe('1abc23');
103
+ expect(normalizeRedditPostId('https://www.reddit.com/r/opencli/comments/1abc23/title_slug/?sort=top')).toBe('1abc23');
104
+ expect(normalizeRedditPostId('https://www.reddit.com/r/opencli/comments/1abc23/title_slug/okf3s7u/?context=3')).toBe('1abc23');
105
+ expect(normalizeRedditPostId('https://old.reddit.com/comments/1abc23/title_slug/')).toBe('1abc23');
106
+ });
107
+
108
+ it('rejects invalid or structurally loose post identities before navigation', () => {
109
+ for (const bad of [
110
+ '',
111
+ 't1_okf3s7u',
112
+ 'https://reddit.com.evil.com/r/opencli/comments/1abc23/title_slug/',
113
+ 'http://www.reddit.com/r/opencli/comments/1abc23/title_slug/',
114
+ 'https://www.reddit.com/r/opencli/comments/',
115
+ 'https://www.reddit.com/r/opencli/comments/1abc23/title_slug/okf3s7u/evil',
116
+ 'not/a/post',
117
+ ]) {
118
+ expect(() => normalizeRedditPostId(bad)).toThrow(ArgumentError);
119
+ }
120
+ });
121
+ });
122
+
123
+ describe('parseExpandRounds', () => {
124
+ it('returns the default for absent input but throws on out-of-range / non-integer', () => {
125
+ expect(parseExpandRounds(undefined)).toBe(2);
126
+ expect(parseExpandRounds(null)).toBe(2);
127
+ expect(parseExpandRounds('')).toBe(2);
128
+ expect(parseExpandRounds(1)).toBe(1);
129
+ expect(parseExpandRounds(5)).toBe(5);
130
+ for (const bad of [0, -1, 6, 1.5, NaN, 'abc']) {
131
+ expect(() => parseExpandRounds(bad)).toThrow(ArgumentError);
132
+ }
133
+ });
134
+ });
135
+
136
+ it('rejects a bad --expand-rounds BEFORE navigating', async () => {
137
+ const page = makePage({ kind: 'ok', rows: [] });
138
+ await expect(command.func(page, { 'post-id': 'abc123', 'expand-rounds': 99 }))
139
+ .rejects.toBeInstanceOf(ArgumentError);
140
+ expect(page.goto).not.toHaveBeenCalled();
141
+ expect(page.evaluate).not.toHaveBeenCalled();
142
+ });
143
+
144
+ it('rejects a bad post identity BEFORE navigating', async () => {
145
+ const page = makePage({ kind: 'ok', rows: [] });
146
+ await expect(command.func(page, { 'post-id': 'https://evil.test/r/x/comments/abc/title/' }))
147
+ .rejects.toBeInstanceOf(ArgumentError);
148
+ expect(page.goto).not.toHaveBeenCalled();
149
+ expect(page.evaluate).not.toHaveBeenCalled();
150
+ });
151
+
152
+ it('returns rows when the evaluate script reports kind=ok', async () => {
153
+ const page = makePage({
154
+ kind: 'ok',
155
+ rows: [
14
156
  { type: 'POST', author: 'alice', score: 10, text: 'Title' },
15
157
  { type: 'L0', author: 'bob', score: 5, text: 'Comment' },
16
- ]),
17
- };
158
+ ],
159
+ expandMeta: { rounds: 0, fetched: 0, capped: false, errors: [] },
160
+ });
18
161
  const result = await command.func(page, { 'post-id': 'abc123', limit: 5 });
19
162
  expect(page.goto).toHaveBeenCalledWith('https://www.reddit.com');
20
163
  expect(result).toEqual([
@@ -22,11 +165,171 @@ describe('reddit read adapter', () => {
22
165
  { type: 'L0', author: 'bob', score: 5, text: 'Comment' },
23
166
  ]);
24
167
  });
25
- it('surfaces adapter-level API errors clearly', async () => {
26
- const page = {
27
- goto: vi.fn().mockResolvedValue(undefined),
28
- evaluate: vi.fn().mockResolvedValue({ error: 'Reddit API returned HTTP 403' }),
29
- };
30
- await expect(command.func(page, { 'post-id': 'abc123' })).rejects.toThrow('Reddit API returned HTTP 403');
168
+
169
+ it('maps the five failure kinds to the right typed errors', async () => {
170
+ await expect(command.func(makePage({ kind: 'inaccessible', detail: 'post 403' }), { 'post-id': 'abc123' }))
171
+ .rejects.toBeInstanceOf(EmptyResultError);
172
+
173
+ await expect(command.func(makePage({ kind: 'auth', detail: 'morechildren 401' }), { 'post-id': 'abc123' }))
174
+ .rejects.toBeInstanceOf(AuthRequiredError);
175
+
176
+ await expect(command.func(makePage({ kind: 'http', httpStatus: 503, where: '/comments/abc.json' }), { 'post-id': 'abc123' }))
177
+ .rejects.toBeInstanceOf(CommandExecutionError);
178
+
179
+ await expect(command.func(makePage({ kind: 'malformed', detail: 'no comment listing' }), { 'post-id': 'abc123' }))
180
+ .rejects.toBeInstanceOf(CommandExecutionError);
181
+
182
+ await expect(command.func(makePage({ kind: 'parser-drift', detail: 'walker drift' }), { 'post-id': 'abc123' }))
183
+ .rejects.toBeInstanceOf(CommandExecutionError);
184
+
185
+ await expect(command.func(makePage({ kind: 'expand-failed', detail: 'morechildren errors' }), { 'post-id': 'abc123' }))
186
+ .rejects.toBeInstanceOf(CommandExecutionError);
187
+ });
188
+
189
+ it('throws CommandExecutionError on an unknown envelope shape (no kind)', async () => {
190
+ await expect(command.func(makePage({ random: 'stuff' }), { 'post-id': 'abc123' }))
191
+ .rejects.toBeInstanceOf(CommandExecutionError);
192
+ await expect(command.func(makePage(null), { 'post-id': 'abc123' }))
193
+ .rejects.toBeInstanceOf(CommandExecutionError);
194
+ });
195
+
196
+ it('embeds expandMore=false by default and inlines flags into the evaluate script', async () => {
197
+ const page = makePage({ kind: 'ok', rows: [], expandMeta: { rounds: 0, fetched: 0, capped: false, errors: [] } });
198
+ await command.func(page, { 'post-id': 'xyz', sort: 'top', limit: 3 });
199
+ const script = page.evaluate.mock.calls[0][0];
200
+ expect(script).toContain('var expandMore = false');
201
+ expect(script).toContain('var expandRounds = 2');
202
+ expect(script).toContain('var sort = "top"');
203
+ expect(script).toContain('var limit = 3');
204
+ expect(script).toContain('var postId = "xyz"');
205
+ });
206
+
207
+ it('embeds expandMore=true and the requested expandRounds when --expand-more is on', async () => {
208
+ const page = makePage({ kind: 'ok', rows: [], expandMeta: { rounds: 3, fetched: 12, capped: true, errors: [] } });
209
+ await command.func(page, { 'post-id': 'xyz', 'expand-more': true, 'expand-rounds': 3 });
210
+ const script = page.evaluate.mock.calls[0][0];
211
+ expect(script).toContain('var expandMore = true');
212
+ expect(script).toContain('var expandRounds = 3');
213
+ // The /api/morechildren request body construction must be present in
214
+ // the evaluate script (round-trips the link_id + children CSV).
215
+ expect(script).toContain("'/api/morechildren'");
216
+ expect(script).toContain("'api_type=json'");
217
+ expect(script).toContain("encodeURIComponent(linkFullname)");
218
+ expect(script).toContain("encodeURIComponent(batch.join(','))");
219
+ });
220
+
221
+ it('normalizes a full reddit URL before building the browser script', async () => {
222
+ const page = makePage({ kind: 'ok', rows: [], expandMeta: { rounds: 0, fetched: 0, capped: false, errors: [] } });
223
+ await command.func(page, { 'post-id': 'https://www.reddit.com/r/python/comments/1abc23/title_slug/' });
224
+ const script = page.evaluate.mock.calls[0][0];
225
+ expect(script).toContain('var postId = "1abc23"');
226
+ expect(script).not.toContain('postIdRaw.match');
227
+ });
228
+
229
+ it('expands morechildren in the original tree position instead of appending to the parent', async () => {
230
+ const fetchMock = vi.fn(async (url) => {
231
+ if (String(url).startsWith('/comments/')) {
232
+ return jsonResponse(redditPostEnvelope([
233
+ commentThing('a', 'A'),
234
+ moreThing('more_top', ['b', 'c']),
235
+ commentThing('d', 'D'),
236
+ ]));
237
+ }
238
+ if (String(url) === '/api/morechildren') {
239
+ return jsonResponse({
240
+ json: {
241
+ errors: [],
242
+ data: { things: [commentThing('b', 'B'), commentThing('c', 'C')] },
243
+ },
244
+ });
245
+ }
246
+ throw new Error(`unexpected URL ${url}`);
247
+ });
248
+ const page = makeRuntimePage(fetchMock);
249
+
250
+ const result = await command.func(page, {
251
+ 'post-id': 'abc123',
252
+ 'expand-more': true,
253
+ limit: 10,
254
+ replies: 10,
255
+ });
256
+
257
+ expect(result.map((row) => row.author)).toEqual(['op', 'a', 'b', 'c', 'd']);
258
+ expect(fetchMock).toHaveBeenCalledWith(
259
+ '/api/morechildren',
260
+ expect.objectContaining({
261
+ method: 'POST',
262
+ body: expect.stringContaining('link_id=t3_abc123'),
263
+ }),
264
+ );
265
+ });
266
+
267
+ it('fails expand-more when Reddit returns a child that cannot be placed in the requested tree', async () => {
268
+ const fetchMock = vi.fn(async (url) => {
269
+ if (String(url).startsWith('/comments/')) {
270
+ return jsonResponse(redditPostEnvelope([moreThing('more_top', ['b'])]));
271
+ }
272
+ if (String(url) === '/api/morechildren') {
273
+ return jsonResponse({
274
+ json: {
275
+ errors: [],
276
+ data: { things: [commentThing('b', 'B', 't3_other')] },
277
+ },
278
+ });
279
+ }
280
+ throw new Error(`unexpected URL ${url}`);
281
+ });
282
+ const page = makeRuntimePage(fetchMock);
283
+
284
+ await expect(command.func(page, {
285
+ 'post-id': 'abc123',
286
+ 'expand-more': true,
287
+ })).rejects.toBeInstanceOf(CommandExecutionError);
288
+ });
289
+
290
+ it('fails expand-more when Reddit omits a requested child instead of silently dropping the stub', async () => {
291
+ const fetchMock = vi.fn(async (url) => {
292
+ if (String(url).startsWith('/comments/')) {
293
+ return jsonResponse(redditPostEnvelope([moreThing('more_top', ['b', 'c'])]));
294
+ }
295
+ if (String(url) === '/api/morechildren') {
296
+ return jsonResponse({
297
+ json: {
298
+ errors: [],
299
+ data: { things: [commentThing('b', 'B')] },
300
+ },
301
+ });
302
+ }
303
+ throw new Error(`unexpected URL ${url}`);
304
+ });
305
+ const page = makeRuntimePage(fetchMock);
306
+
307
+ await expect(command.func(page, {
308
+ 'post-id': 'abc123',
309
+ 'expand-more': true,
310
+ })).rejects.toBeInstanceOf(CommandExecutionError);
311
+ });
312
+
313
+ it('uses 5-kind discriminated union keys that DO NOT collide with declared columns', () => {
314
+ // Read the evaluate template once to assert the intermediate keys we
315
+ // return on the browser side never name any of `type` / `author` /
316
+ // `score` / `text` (the declared columns) — that pattern would
317
+ // trigger the silent-column-drop audit.
318
+ const page = makePage({ kind: 'ok', rows: [], expandMeta: { rounds: 0, fetched: 0, capped: false, errors: [] } });
319
+ return command.func(page, { 'post-id': 'xyz' }).then(() => {
320
+ const script = page.evaluate.mock.calls[0][0];
321
+ // Each return shape uses kind / detail / httpStatus / where /
322
+ // rows / expandMeta. None overlap with the four declared
323
+ // columns. The walker IS allowed to push column-shaped row
324
+ // objects into `rows` — that's the final shape, not an
325
+ // intermediate one.
326
+ expect(script).toContain("kind: 'inaccessible'");
327
+ expect(script).toContain("kind: 'auth'");
328
+ expect(script).toContain("kind: 'http'");
329
+ expect(script).toContain("kind: 'malformed'");
330
+ expect(script).toContain("kind: 'parser-drift'");
331
+ expect(script).toContain("kind: 'expand-failed'");
332
+ expect(script).toContain("kind: 'ok'");
333
+ });
31
334
  });
32
335
  });
@@ -0,0 +1,117 @@
1
+ import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
2
+ import { cli, Strategy } from '@jackwener/opencli/registry';
3
+
4
+ // Reddit subreddit names: 3–21 chars, letters/digits/underscore, must start
5
+ // with a letter. Accept an optional `r/` prefix and normalise it off.
6
+ const SUBREDDIT_NAME_RE = /^[A-Za-z][A-Za-z0-9_]{2,20}$/;
7
+
8
+ export function parseSubredditName(raw) {
9
+ let name = String(raw || '').trim();
10
+ if (!name) {
11
+ throw new ArgumentError(
12
+ 'Subreddit name is required.',
13
+ 'Pass a subreddit name like `python` (or `r/python`).',
14
+ );
15
+ }
16
+ if (name.startsWith('/r/')) name = name.slice(3);
17
+ else if (name.startsWith('r/')) name = name.slice(2);
18
+ if (!SUBREDDIT_NAME_RE.test(name)) {
19
+ throw new ArgumentError(
20
+ 'Invalid subreddit name.',
21
+ 'Subreddit names are 3–21 characters, start with a letter, and contain only letters, digits, and underscores.',
22
+ );
23
+ }
24
+ return name;
25
+ }
26
+
27
+ cli({
28
+ site: 'reddit',
29
+ name: 'subreddit-info',
30
+ access: 'read',
31
+ description: 'Show metadata for a Reddit subreddit (subscribers, description, created date, NSFW)',
32
+ domain: 'reddit.com',
33
+ strategy: Strategy.COOKIE,
34
+ browser: true,
35
+ siteSession: 'persistent',
36
+ args: [
37
+ { name: 'name', type: 'string', required: true, positional: true, help: 'Subreddit name (no `r/` prefix needed)' },
38
+ ],
39
+ columns: ['field', 'value'],
40
+ func: async (page, kwargs) => {
41
+ const sub = parseSubredditName(kwargs.name);
42
+ await page.goto('https://www.reddit.com');
43
+ // Banned / private / non-existent subreddits return a 404 envelope
44
+ // ({"error":404,"reason":"banned"|"private"|null,"message":"Not Found"}).
45
+ // We surface those as EmptyResultError so the table never contains a
46
+ // silent sentinel row. Intermediate keys avoid `field`/`value`.
47
+ const result = await page.evaluate(`(async () => {
48
+ try {
49
+ const sub = ${JSON.stringify(sub)};
50
+ const res = await fetch('/r/' + encodeURIComponent(sub) + '/about.json?raw_json=1', { credentials: 'include' });
51
+ if (res.status === 401 || res.status === 403 || res.status === 404) {
52
+ return { kind: 'missing', detail: 'Subreddit r/' + sub + ' was not found or is not accessible (HTTP ' + res.status + ').' };
53
+ }
54
+ if (!res.ok) {
55
+ return { kind: 'http', httpStatus: res.status, where: '/r/' + sub + '/about.json' };
56
+ }
57
+ const j = await res.json();
58
+ // Reddit may return an envelope-style 200 with {"kind":"Listing"} or
59
+ // an error body for quarantined / private subs. Identify "subreddit
60
+ // not found / not accessible" by the absence of data.display_name.
61
+ if (j?.error) {
62
+ if (j.error === 404 || j.reason === 'banned' || j.reason === 'private' || j.reason === 'quarantined') {
63
+ return { kind: 'missing', detail: 'Subreddit r/' + sub + ' is ' + (j.reason || 'unavailable') + '.' };
64
+ }
65
+ return { kind: 'http', httpStatus: j.error, where: '/r/' + sub + '/about.json (' + (j.reason || 'error') + ')' };
66
+ }
67
+ const info = j?.data;
68
+ if (!info || !info.display_name) {
69
+ return { kind: 'malformed', detail: 'Reddit returned malformed subreddit info for r/' + sub + ' (missing data.display_name).' };
70
+ }
71
+ return { kind: 'ok', info };
72
+ } catch (e) {
73
+ return { kind: 'exception', detail: String(e && e.message || e) };
74
+ }
75
+ })()`);
76
+
77
+ if (result?.kind === 'missing') {
78
+ throw new EmptyResultError(result.detail);
79
+ }
80
+ if (result?.kind === 'http') {
81
+ throw new CommandExecutionError(`HTTP ${result.httpStatus} from ${result.where}`);
82
+ }
83
+ if (result?.kind === 'malformed') {
84
+ throw new CommandExecutionError(result.detail);
85
+ }
86
+ if (result?.kind === 'exception') {
87
+ throw new CommandExecutionError(`subreddit-info failed: ${result.detail}`);
88
+ }
89
+ if (result?.kind !== 'ok') {
90
+ throw new CommandExecutionError(`Unexpected result from reddit subreddit-info: ${JSON.stringify(result)}`);
91
+ }
92
+
93
+ const s = result.info;
94
+ const created = s.created_utc
95
+ ? new Date(s.created_utc * 1000).toISOString().split('T')[0]
96
+ : null;
97
+ const subscribers = typeof s.subscribers === 'number' ? s.subscribers : null;
98
+ const activeNow = typeof s.active_user_count === 'number'
99
+ ? s.active_user_count
100
+ : (typeof s.accounts_active === 'number' ? s.accounts_active : null);
101
+ const description = typeof s.public_description === 'string'
102
+ ? s.public_description.trim()
103
+ : '';
104
+
105
+ return [
106
+ { field: 'Name', value: s.display_name_prefixed || ('r/' + s.display_name) },
107
+ { field: 'Title', value: typeof s.title === 'string' ? s.title : null },
108
+ { field: 'Subscribers', value: subscribers != null ? String(subscribers) : null },
109
+ { field: 'Active Now', value: activeNow != null ? String(activeNow) : null },
110
+ { field: 'NSFW', value: s.over18 ? 'Yes' : 'No' },
111
+ { field: 'Type', value: typeof s.subreddit_type === 'string' ? s.subreddit_type : null },
112
+ { field: 'Description', value: description || null },
113
+ { field: 'Created', value: created },
114
+ { field: 'URL', value: s.url ? 'https://www.reddit.com' + s.url : null },
115
+ ];
116
+ },
117
+ });
@@ -0,0 +1,163 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
4
+ import { parseSubredditName } from './subreddit-info.js';
5
+ import './subreddit-info.js';
6
+
7
+ function makePage(result) {
8
+ return {
9
+ goto: vi.fn().mockResolvedValue(undefined),
10
+ evaluate: vi.fn().mockResolvedValue(result),
11
+ };
12
+ }
13
+
14
+ describe('reddit subreddit-info command', () => {
15
+ const command = getRegistry().get('reddit/subreddit-info');
16
+
17
+ it('registers with the expected shape', () => {
18
+ expect(command).toBeDefined();
19
+ expect(command.access).toBe('read');
20
+ expect(command.browser).toBe(true);
21
+ expect(command.columns).toEqual(['field', 'value']);
22
+ });
23
+
24
+ it('parseSubredditName strips prefixes and validates the name shape', () => {
25
+ expect(parseSubredditName('python')).toBe('python');
26
+ expect(parseSubredditName('r/python')).toBe('python');
27
+ expect(parseSubredditName('/r/python')).toBe('python');
28
+ expect(parseSubredditName(' AskReddit ')).toBe('AskReddit');
29
+ expect(parseSubredditName('aw3some_sub')).toBe('aw3some_sub');
30
+ });
31
+
32
+ it('parseSubredditName rejects invalid names without silent fallback', () => {
33
+ for (const bad of [
34
+ '',
35
+ ' ',
36
+ 'py', // too short
37
+ '1abc', // must start with letter
38
+ '_sub', // must start with letter
39
+ 'has space', // no spaces
40
+ 'has-dash', // no dashes
41
+ 'way_too_long_subreddit_name_here', // too long (>21)
42
+ ]) {
43
+ expect(() => parseSubredditName(bad)).toThrow(ArgumentError);
44
+ }
45
+ });
46
+
47
+ it('rejects bad subreddit names BEFORE navigating', async () => {
48
+ const page = makePage({ kind: 'ok', info: {} });
49
+ await expect(command.func(page, { name: 'has space' })).rejects.toBeInstanceOf(ArgumentError);
50
+ expect(page.goto).not.toHaveBeenCalled();
51
+ expect(page.evaluate).not.toHaveBeenCalled();
52
+ });
53
+
54
+ it('throws EmptyResultError for missing / banned / private / quarantined subreddits', async () => {
55
+ for (const detail of ['not found', 'banned', 'private', 'quarantined']) {
56
+ await expect(command.func(makePage({ kind: 'missing', detail }), { name: 'python' }))
57
+ .rejects.toBeInstanceOf(EmptyResultError);
58
+ }
59
+ });
60
+
61
+ it('treats HTTP 401/403/404 about.json responses as inaccessible subreddit, not auth-required', async () => {
62
+ for (const status of [401, 403, 404]) {
63
+ const scriptResults = [];
64
+ const page = {
65
+ goto: vi.fn().mockResolvedValue(undefined),
66
+ evaluate: vi.fn(async (script) => {
67
+ const result = await (new Function('fetch', `return (${script})`))(vi.fn(async () => ({
68
+ status,
69
+ ok: false,
70
+ })));
71
+ scriptResults.push(result);
72
+ return result;
73
+ }),
74
+ };
75
+ await expect(command.func(page, { name: 'python' })).rejects.toBeInstanceOf(EmptyResultError);
76
+ expect(scriptResults[0]).toMatchObject({ kind: 'missing' });
77
+ }
78
+ });
79
+
80
+ it('throws CommandExecutionError on HTTP and exception failure modes', async () => {
81
+ await expect(command.func(makePage({ kind: 'http', httpStatus: 500, where: 'about.json' }), { name: 'python' }))
82
+ .rejects.toBeInstanceOf(CommandExecutionError);
83
+ await expect(command.func(makePage({ kind: 'exception', detail: 'bad' }), { name: 'python' }))
84
+ .rejects.toBeInstanceOf(CommandExecutionError);
85
+ });
86
+
87
+ it('throws CommandExecutionError for malformed 200 subreddit payloads', async () => {
88
+ await expect(command.func(makePage({
89
+ kind: 'malformed',
90
+ detail: 'Reddit returned malformed subreddit info for r/python (missing data.display_name).',
91
+ }), { name: 'python' }))
92
+ .rejects.toMatchObject({
93
+ code: 'COMMAND_EXEC',
94
+ message: expect.stringContaining('malformed subreddit info'),
95
+ });
96
+ });
97
+
98
+ it('maps a normal subreddit payload into typed field/value rows', async () => {
99
+ const info = {
100
+ display_name: 'python',
101
+ display_name_prefixed: 'r/python',
102
+ title: 'Python',
103
+ public_description: ' News about the programming language Python. ',
104
+ subscribers: 1234567,
105
+ active_user_count: 4321,
106
+ over18: false,
107
+ subreddit_type: 'public',
108
+ created_utc: 1201881600, // 2008-02-01
109
+ url: '/r/python/',
110
+ };
111
+ const rows = await command.func(makePage({ kind: 'ok', info }), { name: 'python' });
112
+ const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
113
+
114
+ expect(byField.Name).toBe('r/python');
115
+ expect(byField.Title).toBe('Python');
116
+ expect(byField.Subscribers).toBe('1234567');
117
+ expect(byField['Active Now']).toBe('4321');
118
+ expect(byField.NSFW).toBe('No');
119
+ expect(byField.Type).toBe('public');
120
+ expect(byField.Description).toBe('News about the programming language Python.');
121
+ expect(byField.Created).toBe('2008-02-01');
122
+ expect(byField.URL).toBe('https://www.reddit.com/r/python/');
123
+
124
+ for (const row of rows) {
125
+ expect(Object.keys(row).sort()).toEqual(['field', 'value']);
126
+ }
127
+ });
128
+
129
+ it('falls back to accounts_active when active_user_count is missing', async () => {
130
+ const info = {
131
+ display_name: 'sub',
132
+ display_name_prefixed: 'r/sub',
133
+ accounts_active: 99,
134
+ url: '/r/sub/',
135
+ };
136
+ const rows = await command.func(makePage({ kind: 'ok', info }), { name: 'sub' });
137
+ const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
138
+ expect(byField['Active Now']).toBe('99');
139
+ });
140
+
141
+ it('emits typed null (not "-" sentinel) when subscribers / activity / created are missing', async () => {
142
+ const info = {
143
+ display_name: 'sparse',
144
+ display_name_prefixed: 'r/sparse',
145
+ url: '/r/sparse/',
146
+ };
147
+ const rows = await command.func(makePage({ kind: 'ok', info }), { name: 'sparse' });
148
+ const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
149
+ expect(byField.Subscribers).toBeNull();
150
+ expect(byField['Active Now']).toBeNull();
151
+ expect(byField.Created).toBeNull();
152
+ expect(byField.Description).toBeNull();
153
+ expect(byField.Title).toBeNull();
154
+ expect(byField.Type).toBeNull();
155
+ });
156
+
157
+ it('encodes the subreddit name into the fetch URL embedded in evaluate', async () => {
158
+ const page = makePage({ kind: 'ok', info: { display_name: 'python', display_name_prefixed: 'r/python', url: '/r/python/' } });
159
+ await command.func(page, { name: 'r/python' });
160
+ const script = page.evaluate.mock.calls[0][0];
161
+ expect(script).toContain('const sub = "python"');
162
+ });
163
+ });