@jackwener/opencli 1.7.13 → 1.7.15

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 (97) hide show
  1. package/cli-manifest.json +326 -44
  2. package/clis/bilibili/subtitle.js +1 -1
  3. package/clis/dianping/cityResolver.js +185 -0
  4. package/clis/dianping/dianping.test.js +154 -0
  5. package/clis/dianping/search.js +6 -3
  6. package/clis/douyin/_shared/browser-fetch.js +14 -2
  7. package/clis/douyin/_shared/browser-fetch.test.js +13 -0
  8. package/clis/douyin/stats.js +1 -1
  9. package/clis/douyin/update.js +1 -1
  10. package/clis/jike/search.js +1 -1
  11. package/clis/reddit/search.js +1 -1
  12. package/clis/reddit/subreddit.js +1 -1
  13. package/clis/reddit/user-comments.js +1 -1
  14. package/clis/reddit/user-posts.js +1 -1
  15. package/clis/reddit/user.js +1 -1
  16. package/clis/twitter/article.js +2 -1
  17. package/clis/twitter/bookmark-folder.js +189 -0
  18. package/clis/twitter/bookmark-folder.test.js +334 -0
  19. package/clis/twitter/bookmark-folders.js +117 -0
  20. package/clis/twitter/bookmark-folders.test.js +150 -0
  21. package/clis/twitter/bookmark.js +15 -6
  22. package/clis/twitter/bookmark.test.js +74 -0
  23. package/clis/twitter/bookmarks.js +7 -5
  24. package/clis/twitter/delete.js +11 -35
  25. package/clis/twitter/delete.test.js +21 -9
  26. package/clis/twitter/download.js +5 -5
  27. package/clis/twitter/followers.js +9 -3
  28. package/clis/twitter/following.js +11 -5
  29. package/clis/twitter/hide-reply.js +24 -5
  30. package/clis/twitter/hide-reply.test.js +76 -0
  31. package/clis/twitter/like.js +21 -11
  32. package/clis/twitter/like.test.js +73 -0
  33. package/clis/twitter/likes.js +8 -6
  34. package/clis/twitter/list-add.js +4 -4
  35. package/clis/twitter/list-remove.js +4 -4
  36. package/clis/twitter/list-tweets.js +6 -4
  37. package/clis/twitter/lists.js +3 -3
  38. package/clis/twitter/notifications.js +2 -2
  39. package/clis/twitter/profile.js +4 -3
  40. package/clis/twitter/quote.js +167 -0
  41. package/clis/twitter/quote.test.js +194 -0
  42. package/clis/twitter/reply.js +24 -178
  43. package/clis/twitter/reply.test.js +29 -11
  44. package/clis/twitter/retweet.js +94 -0
  45. package/clis/twitter/retweet.test.js +73 -0
  46. package/clis/twitter/search.js +175 -23
  47. package/clis/twitter/search.test.js +266 -1
  48. package/clis/twitter/shared.js +81 -0
  49. package/clis/twitter/shared.test.js +134 -1
  50. package/clis/twitter/thread.js +6 -4
  51. package/clis/twitter/timeline.js +8 -6
  52. package/clis/twitter/tweets.js +5 -3
  53. package/clis/twitter/unbookmark.js +13 -6
  54. package/clis/twitter/unbookmark.test.js +73 -0
  55. package/clis/twitter/unlike.js +80 -0
  56. package/clis/twitter/unlike.test.js +75 -0
  57. package/clis/twitter/unretweet.js +94 -0
  58. package/clis/twitter/unretweet.test.js +73 -0
  59. package/clis/twitter/utils.js +286 -0
  60. package/clis/twitter/utils.test.js +169 -0
  61. package/dist/src/browser/ax-snapshot.d.ts +37 -0
  62. package/dist/src/browser/ax-snapshot.js +217 -0
  63. package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
  64. package/dist/src/browser/ax-snapshot.test.js +91 -0
  65. package/dist/src/browser/base-page.d.ts +51 -0
  66. package/dist/src/browser/base-page.js +545 -2
  67. package/dist/src/browser/base-page.test.js +520 -4
  68. package/dist/src/browser/bridge.js +47 -45
  69. package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
  70. package/dist/src/browser/cdp-click-fixture.test.js +87 -0
  71. package/dist/src/browser/cdp.js +5 -0
  72. package/dist/src/browser/cdp.test.js +1 -0
  73. package/dist/src/browser/daemon-client.d.ts +3 -1
  74. package/dist/src/browser/find.d.ts +9 -1
  75. package/dist/src/browser/find.js +219 -0
  76. package/dist/src/browser/find.test.js +61 -1
  77. package/dist/src/browser/page.d.ts +2 -1
  78. package/dist/src/browser/page.js +13 -0
  79. package/dist/src/browser/page.test.js +28 -0
  80. package/dist/src/browser/target-errors.d.ts +3 -1
  81. package/dist/src/browser/target-errors.js +2 -0
  82. package/dist/src/browser/target-resolver.d.ts +14 -0
  83. package/dist/src/browser/target-resolver.js +28 -0
  84. package/dist/src/browser/visual-refs.d.ts +11 -0
  85. package/dist/src/browser/visual-refs.js +108 -0
  86. package/dist/src/browser.test.js +18 -0
  87. package/dist/src/build-manifest.d.ts +23 -0
  88. package/dist/src/build-manifest.js +34 -0
  89. package/dist/src/build-manifest.test.js +108 -1
  90. package/dist/src/cli.js +560 -58
  91. package/dist/src/cli.test.js +689 -1
  92. package/dist/src/commanderAdapter.js +23 -4
  93. package/dist/src/help.d.ts +36 -0
  94. package/dist/src/help.js +301 -5
  95. package/dist/src/types.d.ts +82 -0
  96. package/package.json +1 -1
  97. package/scripts/typed-error-lint-baseline.json +18 -18
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
4
+ import './retweet.js';
5
+ import { createPageMock } from '../test-utils.js';
6
+
7
+ describe('twitter retweet command', () => {
8
+ it('clicks the retweet button then the confirm menu item and reports success', async () => {
9
+ const cmd = getRegistry().get('twitter/retweet');
10
+ expect(cmd?.func).toBeTypeOf('function');
11
+ const page = createPageMock([
12
+ { ok: true, message: 'Tweet successfully retweeted.' },
13
+ ]);
14
+ const result = await cmd.func(page, {
15
+ url: 'https://x.com/alice/status/2040254679301718161',
16
+ });
17
+ expect(page.goto).toHaveBeenCalledWith('https://x.com/alice/status/2040254679301718161');
18
+ expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="primaryColumn"]' });
19
+ expect(page.wait).toHaveBeenNthCalledWith(2, 2);
20
+ const script = page.evaluate.mock.calls[0][0];
21
+ // Two-step UI flow must be present:
22
+ // 1) click the retweet button
23
+ // 2) wait for and click the confirm menu item (data-testid="retweetConfirm")
24
+ expect(script).toContain('retweetBtn.click()');
25
+ expect(script).toContain("document.querySelector('[data-testid=\"retweetConfirm\"]')");
26
+ expect(script).toContain('confirmBtn.click()');
27
+ // Article scoping comes from the shared helper (buildTwitterArticleScopeSource):
28
+ // emits __twHasLinkToTarget + __twGetStatusIdFromHref + the anchored
29
+ // tweet-path regex. JSDOM-level coverage lives in shared.test.js.
30
+ expect(script).toContain('__twHasLinkToTarget');
31
+ expect(script).toContain('__twGetStatusIdFromHref');
32
+ expect(script).toContain("document.querySelectorAll('article')");
33
+ expect(script).toContain("targetArticle?.querySelector('[data-testid=\"retweet\"]')");
34
+ // Idempotency probe: when already retweeted ([data-testid="unretweet"] present),
35
+ // the script returns ok:true with an "already retweeted" message.
36
+ expect(script).toContain("targetArticle?.querySelector('[data-testid=\"unretweet\"]')");
37
+ expect(result).toEqual([
38
+ { status: 'success', message: 'Tweet successfully retweeted.' },
39
+ ]);
40
+ });
41
+
42
+ it('returns a failed row when the confirm menu item never appears', async () => {
43
+ const cmd = getRegistry().get('twitter/retweet');
44
+ expect(cmd?.func).toBeTypeOf('function');
45
+ const page = createPageMock([
46
+ { ok: false, message: 'Retweet menu opened but the confirm option did not appear.' },
47
+ ]);
48
+ const result = await cmd.func(page, {
49
+ url: 'https://x.com/alice/status/2040254679301718161',
50
+ });
51
+ expect(result).toEqual([
52
+ { status: 'failed', message: 'Retweet menu opened but the confirm option did not appear.' },
53
+ ]);
54
+ expect(page.wait).toHaveBeenCalledTimes(1);
55
+ });
56
+
57
+ it('throws CommandExecutionError when no page is provided', async () => {
58
+ const cmd = getRegistry().get('twitter/retweet');
59
+ await expect(cmd.func(undefined, {
60
+ url: 'https://x.com/alice/status/2040254679301718161',
61
+ })).rejects.toThrow(CommandExecutionError);
62
+ });
63
+
64
+ it('rejects invalid tweet URLs before navigation', async () => {
65
+ const cmd = getRegistry().get('twitter/retweet');
66
+ const page = createPageMock([]);
67
+ await expect(cmd.func(page, {
68
+ url: 'https://evil.com/?next=https://x.com/alice/status/2040254679301718161',
69
+ })).rejects.toThrow(ArgumentError);
70
+ expect(page.goto).not.toHaveBeenCalled();
71
+ expect(page.evaluate).not.toHaveBeenCalled();
72
+ });
73
+ });
@@ -1,6 +1,104 @@
1
- import { CommandExecutionError } from '@jackwener/opencli/errors';
1
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
3
  import { extractMedia } from './shared.js';
4
+ import { applyTopByEngagement } from './utils.js';
5
+
6
+ // ── Public-search operator surface ─────────────────────────────────────
7
+ //
8
+ // X's web search supports a small set of inline operators (from:, filter:,
9
+ // -filter:, etc.) plus a tab-selector URL param `f=`. We expose the most
10
+ // useful subset as flags so callers don't have to memorise the operator
11
+ // strings, while still letting power users append raw operators in <query>.
12
+
13
+ /** Operands accepted by `--has`. Map 1:1 to Twitter's `filter:<x>` operator. */
14
+ const HAS_CHOICES = Object.freeze(['media', 'images', 'videos', 'links', 'replies']);
15
+
16
+ /**
17
+ * Operands accepted by `--exclude`. Note that `retweets` is exposed as the
18
+ * friendlier name but X's actual operator stays as `-filter:nativeretweets`
19
+ * (the historical "native" prefix is preserved by their backend).
20
+ */
21
+ const EXCLUDE_CHOICES = Object.freeze(['replies', 'retweets', 'media', 'links']);
22
+
23
+ /**
24
+ * Operands accepted by `--product`. `photos`/`videos` are the human-friendly
25
+ * forms used by the X UI tabs; the URL param uses the singular forms (image,
26
+ * video). `people` is intentionally NOT supported here because that tab
27
+ * returns User objects, not tweets, and would need a different output schema.
28
+ */
29
+ const PRODUCT_CHOICES = Object.freeze(['top', 'live', 'photos', 'videos']);
30
+
31
+ const PRODUCT_TO_F_PARAM = Object.freeze({
32
+ top: 'top',
33
+ live: 'live',
34
+ photos: 'image',
35
+ videos: 'video',
36
+ });
37
+
38
+ const FROM_USER_PATTERN = /^[A-Za-z0-9_]{1,15}$/;
39
+
40
+ const EXCLUDE_TO_OPERATOR = Object.freeze({
41
+ replies: '-filter:replies',
42
+ // `retweets` is a CLI-friendly alias for X's actual `-filter:nativeretweets`.
43
+ retweets: '-filter:nativeretweets',
44
+ media: '-filter:media',
45
+ links: '-filter:links',
46
+ });
47
+
48
+ /**
49
+ * Compose the final search query string by appending operator clauses for
50
+ * --from / --has / --exclude. Pure synchronous — exported via __test__ for
51
+ * unit coverage.
52
+ *
53
+ * Behaviour notes:
54
+ * - Trims leading `@` from --from so callers can pass `@alice` or `alice`.
55
+ * - Order is `<query> from:X filter:Y -filter:Z` (matches what X's own search
56
+ * bar emits when you click the suggestions UI).
57
+ * - Empty <query> with non-empty filters is allowed — the resulting string
58
+ * is just the operator clauses joined; X handles that fine.
59
+ *
60
+ * @param {string} rawQuery
61
+ * @param {{ from?: string, has?: string, exclude?: string }} kwargs
62
+ * @returns {string}
63
+ */
64
+ function buildSearchQuery(rawQuery, kwargs) {
65
+ const parts = [String(rawQuery ?? '').trim()];
66
+ if (kwargs.from) {
67
+ const fromUser = String(kwargs.from).trim().replace(/^@+/, '');
68
+ if (fromUser && !FROM_USER_PATTERN.test(fromUser)) {
69
+ throw new ArgumentError(
70
+ `Invalid --from username: ${JSON.stringify(kwargs.from)}`,
71
+ 'Use a Twitter/X handle with 1-15 letters, numbers, or underscores; omit @ or pass @handle.',
72
+ );
73
+ }
74
+ if (fromUser) parts.push(`from:${fromUser}`);
75
+ }
76
+ if (kwargs.has) {
77
+ parts.push(`filter:${kwargs.has}`);
78
+ }
79
+ if (kwargs.exclude) {
80
+ const op = EXCLUDE_TO_OPERATOR[kwargs.exclude];
81
+ if (op) parts.push(op);
82
+ }
83
+ return parts.filter(Boolean).join(' ');
84
+ }
85
+
86
+ /**
87
+ * Resolve which X search tab (`f=` URL param) to land on. `--product` wins
88
+ * over the legacy `--filter` so adding `--product` doesn't break callers that
89
+ * were already setting `--filter top|live`.
90
+ *
91
+ * @param {{ product?: string, filter?: string }} kwargs
92
+ * @returns {string} URL `f=` value: top|live|image|video
93
+ */
94
+ function resolveSearchFParam(kwargs) {
95
+ if (kwargs.product) {
96
+ const mapped = PRODUCT_TO_F_PARAM[kwargs.product];
97
+ if (mapped) return mapped;
98
+ }
99
+ return kwargs.filter === 'live' ? 'live' : 'top';
100
+ }
101
+
4
102
  /**
5
103
  * Trigger Twitter search SPA navigation with fallback strategies.
6
104
  *
@@ -9,9 +107,13 @@ import { extractMedia } from './shared.js';
9
107
  * intermittently (e.g. due to Twitter A/B tests or timing races — see #690).
10
108
  *
11
109
  * Both strategies preserve the JS context so the fetch interceptor stays alive.
110
+ *
111
+ * @param {object} page
112
+ * @param {string} query — final composed query (already merged with operators)
113
+ * @param {string} fParam — Twitter URL `f=` value (top|live|image|video)
12
114
  */
13
- async function navigateToSearch(page, query, filter) {
14
- const searchUrl = JSON.stringify(`/search?q=${encodeURIComponent(query)}&f=${filter}`);
115
+ async function navigateToSearch(page, query, fParam) {
116
+ const searchUrl = JSON.stringify(`/search?q=${encodeURIComponent(query)}&f=${fParam}`);
15
117
  let lastPath = '';
16
118
  // Strategy 1 (primary): pushState + popstate with retry
17
119
  for (let attempt = 1; attempt <= 2; attempt++) {
@@ -74,40 +176,78 @@ async function navigateToSearch(page, query, filter) {
74
176
  }
75
177
  lastPath = String(await page.evaluate('() => window.location.pathname') || '');
76
178
  if (lastPath.startsWith('/search')) {
77
- if (filter === 'live') {
78
- await page.evaluate(`(() => {
79
- const tabs = document.querySelectorAll('[role="tab"]');
80
- for (const tab of tabs) {
81
- if (tab.textContent.includes('Latest') || tab.textContent.includes('最新')) {
82
- tab.click();
83
- return;
84
- }
85
- }
86
- })()`);
87
- await page.wait(2);
179
+ // The fallback path doesn't carry the f= URL param, so click the
180
+ // matching tab to align with the requested product. Only `live`
181
+ // currently surfaces a distinct tab label — `image`/`video` tabs
182
+ // also need an explicit click, so try them all.
183
+ const tabClicked = await clickProductTabIfNeeded(page, fParam);
184
+ if (!tabClicked) {
185
+ throw new CommandExecutionError(`SPA fallback reached /search but could not select the requested product tab: ${fParam}`);
88
186
  }
89
187
  return;
90
188
  }
91
189
  }
92
190
  throw new CommandExecutionError(`SPA navigation to /search failed. Final path: ${lastPath || '(empty)'}. Twitter may have changed its routing.`);
93
191
  }
192
+
193
+ /**
194
+ * After the search-input fallback lands on /search, the f= param is missing
195
+ * from the URL. Click the matching tab in the result page header so the
196
+ * SearchTimeline call uses the right filter. No-op for fParam=top (default).
197
+ */
198
+ async function clickProductTabIfNeeded(page, fParam) {
199
+ if (fParam === 'top') return true;
200
+ const tabLabels = JSON.stringify({
201
+ live: ['Latest', '最新'],
202
+ image: ['Photos', 'Images', '照片', '图片'],
203
+ video: ['Videos', '视频'],
204
+ }[fParam] || []);
205
+ if (tabLabels === '[]') return true;
206
+ const clicked = await page.evaluate(`(() => {
207
+ const labels = ${tabLabels};
208
+ const tabs = document.querySelectorAll('[role="tab"]');
209
+ for (const tab of tabs) {
210
+ const txt = (tab.textContent || '').trim();
211
+ if (labels.some(l => txt.includes(l))) {
212
+ tab.click();
213
+ return true;
214
+ }
215
+ }
216
+ return false;
217
+ })()`);
218
+ if (!clicked) return false;
219
+ await page.wait(2);
220
+ return true;
221
+ }
222
+
94
223
  cli({
95
224
  site: 'twitter',
96
225
  name: 'search',
97
226
  access: 'read',
98
- description: 'Search Twitter/X for tweets',
227
+ description: 'Search Twitter/X for tweets, with optional --from / --has / --exclude / --product filters mapped to X\'s search operators',
99
228
  domain: 'x.com',
100
229
  strategy: Strategy.INTERCEPT, // Use intercept strategy
101
230
  browser: true,
102
231
  args: [
103
- { name: 'query', type: 'string', required: true, positional: true },
104
- { name: 'filter', type: 'string', default: 'top', choices: ['top', 'live'] },
105
- { name: 'limit', type: 'int', default: 15 },
232
+ { name: 'query', type: 'string', required: true, positional: true, help: 'Search query. Raw X operators (e.g. "exact phrase", #tag, OR, lang:en, since:YYYY-MM-DD, from:, since:) are passed through unchanged.' },
233
+ { name: 'filter', type: 'string', default: 'top', choices: ['top', 'live'], help: 'Legacy alias for --product. Kept for backwards compatibility; if --product is set it wins.' },
234
+ { name: 'product', type: 'string', choices: PRODUCT_CHOICES, help: 'Which X search tab to read: top (default), live (Latest), photos, videos. Maps to the f= URL param.' },
235
+ { name: 'from', type: 'string', help: 'Restrict to tweets authored by <user>. Leading @ is stripped. Equivalent to appending `from:<user>` to the query.' },
236
+ { name: 'has', type: 'string', choices: HAS_CHOICES, help: 'Restrict to tweets that have media|images|videos|links|replies. Maps to X\'s `filter:<has>` operator.' },
237
+ { name: 'exclude', type: 'string', choices: EXCLUDE_CHOICES, help: 'Exclude tweets matching <type>: replies|retweets|media|links. Maps to X\'s `-filter:<x>` operator (retweets → -filter:nativeretweets).' },
238
+ { name: 'limit', type: 'int', default: 15, help: 'Maximum number of tweets to return (default 15). Result count after server-side filtering.' },
239
+ { name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the results by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps X\'s native ordering.' },
106
240
  ],
107
241
  columns: ['id', 'author', 'text', 'created_at', 'likes', 'views', 'url', 'has_media', 'media_urls'],
108
242
  func: async (page, kwargs) => {
109
- const query = kwargs.query;
110
- const filter = kwargs.filter === 'live' ? 'live' : 'top';
243
+ const finalQuery = buildSearchQuery(kwargs.query, kwargs);
244
+ if (!finalQuery) {
245
+ throw new ArgumentError('twitter search query is empty', 'Provide a non-empty <query>, or use at least one of --from / --has / --exclude.');
246
+ }
247
+ if (!Number.isInteger(Number(kwargs.limit)) || Number(kwargs.limit) <= 0) {
248
+ throw new ArgumentError('twitter search --limit must be a positive integer', 'Example: opencli twitter search opencli --limit 15');
249
+ }
250
+ const fParam = resolveSearchFParam(kwargs);
111
251
  // 1. Navigate to x.com/explore (has a search input at the top)
112
252
  await page.goto('https://x.com/explore');
113
253
  await page.wait(3);
@@ -120,10 +260,10 @@ cli({
120
260
  // a full page reload, so the interceptor stays alive.
121
261
  // Note: the previous approach (nativeSetter + Enter keydown on the
122
262
  // search input) does not reliably trigger Twitter's form submission.
123
- await navigateToSearch(page, query, filter);
263
+ await navigateToSearch(page, finalQuery, fParam);
124
264
  // 4. Scroll to trigger additional pagination
125
265
  await page.autoScroll({ times: 3, delayMs: 2000 });
126
- // 6. Retrieve captured data
266
+ // 5. Retrieve captured data
127
267
  const requests = await page.getInterceptedRequests();
128
268
  if (!requests || requests.length === 0)
129
269
  return [];
@@ -167,6 +307,18 @@ cli({
167
307
  // ignore parsing errors for individual payloads
168
308
  }
169
309
  }
170
- return results.slice(0, kwargs.limit);
310
+ const trimmed = results.slice(0, kwargs.limit);
311
+ return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
171
312
  }
172
313
  });
314
+
315
+ export const __test__ = {
316
+ buildSearchQuery,
317
+ resolveSearchFParam,
318
+ HAS_CHOICES,
319
+ EXCLUDE_CHOICES,
320
+ PRODUCT_CHOICES,
321
+ EXCLUDE_TO_OPERATOR,
322
+ PRODUCT_TO_F_PARAM,
323
+ FROM_USER_PATTERN,
324
+ };
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@jackwener/opencli/registry';
3
- import './search.js';
3
+ import { __test__ } from './search.js';
4
+
5
+ const { buildSearchQuery, resolveSearchFParam, HAS_CHOICES, EXCLUDE_CHOICES, PRODUCT_CHOICES, EXCLUDE_TO_OPERATOR, PRODUCT_TO_F_PARAM, FROM_USER_PATTERN } = __test__;
4
6
  describe('twitter search command', () => {
5
7
  it('retries transient SPA navigation failures before giving up', async () => {
6
8
  const command = getRegistry().get('twitter/search');
@@ -213,6 +215,56 @@ describe('twitter search command', () => {
213
215
  expect(evaluate).toHaveBeenCalledTimes(6);
214
216
  expect(page.autoScroll).toHaveBeenCalled();
215
217
  });
218
+ it('clicks the requested product tab after fallback navigation when f= param is absent', async () => {
219
+ const command = getRegistry().get('twitter/search');
220
+ expect(command?.func).toBeTypeOf('function');
221
+ const evaluate = vi.fn()
222
+ .mockResolvedValueOnce(undefined) // pushState attempt 1
223
+ .mockResolvedValueOnce('/explore')
224
+ .mockResolvedValueOnce(undefined) // pushState attempt 2
225
+ .mockResolvedValueOnce('/explore')
226
+ .mockResolvedValueOnce({ ok: true }) // search input fallback
227
+ .mockResolvedValueOnce('/search')
228
+ .mockResolvedValueOnce(true); // product tab click
229
+ const page = {
230
+ goto: vi.fn().mockResolvedValue(undefined),
231
+ wait: vi.fn().mockResolvedValue(undefined),
232
+ installInterceptor: vi.fn().mockResolvedValue(undefined),
233
+ evaluate,
234
+ autoScroll: vi.fn().mockResolvedValue(undefined),
235
+ getInterceptedRequests: vi.fn().mockResolvedValue([]),
236
+ };
237
+ const result = await command.func(page, { query: 'cats', product: 'photos', limit: 5 });
238
+ expect(result).toEqual([]);
239
+ expect(evaluate).toHaveBeenCalledTimes(7);
240
+ expect(evaluate.mock.calls[6][0]).toContain('Photos');
241
+ expect(page.autoScroll).toHaveBeenCalled();
242
+ });
243
+ it('throws when fallback navigation cannot select the requested product tab', async () => {
244
+ const command = getRegistry().get('twitter/search');
245
+ expect(command?.func).toBeTypeOf('function');
246
+ const evaluate = vi.fn()
247
+ .mockResolvedValueOnce(undefined) // pushState attempt 1
248
+ .mockResolvedValueOnce('/explore')
249
+ .mockResolvedValueOnce(undefined) // pushState attempt 2
250
+ .mockResolvedValueOnce('/explore')
251
+ .mockResolvedValueOnce({ ok: true }) // search input fallback
252
+ .mockResolvedValueOnce('/search')
253
+ .mockResolvedValueOnce(false); // requested tab missing
254
+ const page = {
255
+ goto: vi.fn().mockResolvedValue(undefined),
256
+ wait: vi.fn().mockResolvedValue(undefined),
257
+ installInterceptor: vi.fn().mockResolvedValue(undefined),
258
+ evaluate,
259
+ autoScroll: vi.fn().mockResolvedValue(undefined),
260
+ getInterceptedRequests: vi.fn(),
261
+ };
262
+ await expect(command.func(page, { query: 'cats', product: 'videos', limit: 5 }))
263
+ .rejects
264
+ .toThrow(/could not select the requested product tab: video/);
265
+ expect(page.autoScroll).not.toHaveBeenCalled();
266
+ expect(page.getInterceptedRequests).not.toHaveBeenCalled();
267
+ });
216
268
  it('throws with the final path after both attempts fail', async () => {
217
269
  const command = getRegistry().get('twitter/search');
218
270
  expect(command?.func).toBeTypeOf('function');
@@ -238,3 +290,216 @@ describe('twitter search command', () => {
238
290
  expect(evaluate).toHaveBeenCalledTimes(5);
239
291
  });
240
292
  });
293
+
294
+ describe('twitter search filter helpers', () => {
295
+ describe('buildSearchQuery', () => {
296
+ it('returns the trimmed raw query when no filters are set', () => {
297
+ expect(buildSearchQuery(' hello world ', {})).toBe('hello world');
298
+ });
299
+ it('appends from: with leading @ stripped', () => {
300
+ expect(buildSearchQuery('hello', { from: '@alice' })).toBe('hello from:alice');
301
+ });
302
+ it('preserves from: when caller passes a bare username', () => {
303
+ expect(buildSearchQuery('hello', { from: 'alice' })).toBe('hello from:alice');
304
+ });
305
+ it('strips multiple leading @ characters from --from', () => {
306
+ expect(buildSearchQuery('hi', { from: '@@bob' })).toBe('hi from:bob');
307
+ });
308
+ it('drops --from when it is whitespace-only', () => {
309
+ expect(buildSearchQuery('hi', { from: ' ' })).toBe('hi');
310
+ });
311
+ it('rejects invalid --from usernames instead of injecting raw operators', () => {
312
+ expect(() => buildSearchQuery('hi', { from: 'alice filter:links' })).toThrow(/Invalid --from/);
313
+ expect(() => buildSearchQuery('hi', { from: 'alice/bob' })).toThrow(/Invalid --from/);
314
+ expect(() => buildSearchQuery('hi', { from: '@' + 'a'.repeat(16) })).toThrow(/Invalid --from/);
315
+ });
316
+ it('appends filter:<has> for --has', () => {
317
+ expect(buildSearchQuery('q', { has: 'images' })).toBe('q filter:images');
318
+ });
319
+ it('maps --exclude retweets to -filter:nativeretweets', () => {
320
+ expect(buildSearchQuery('q', { exclude: 'retweets' })).toBe('q -filter:nativeretweets');
321
+ });
322
+ it('maps --exclude replies/media/links to their -filter operators', () => {
323
+ expect(buildSearchQuery('q', { exclude: 'replies' })).toBe('q -filter:replies');
324
+ expect(buildSearchQuery('q', { exclude: 'media' })).toBe('q -filter:media');
325
+ expect(buildSearchQuery('q', { exclude: 'links' })).toBe('q -filter:links');
326
+ });
327
+ it('silently ignores unknown --exclude values', () => {
328
+ // choices: ['replies','retweets','media','links'] — unknowns shouldn't appear
329
+ // in real CLI use because the validator rejects them, but the helper still
330
+ // guards via the EXCLUDE_TO_OPERATOR map lookup.
331
+ expect(buildSearchQuery('q', { exclude: 'bogus' })).toBe('q');
332
+ });
333
+ it('composes multiple filter clauses in stable order: query → from → has → exclude', () => {
334
+ expect(buildSearchQuery('hot take', {
335
+ from: '@alice',
336
+ has: 'media',
337
+ exclude: 'retweets',
338
+ })).toBe('hot take from:alice filter:media -filter:nativeretweets');
339
+ });
340
+ it('allows an empty raw query when filters are present', () => {
341
+ expect(buildSearchQuery('', { from: 'alice' })).toBe('from:alice');
342
+ });
343
+ it('returns empty string when nothing useful is supplied', () => {
344
+ expect(buildSearchQuery('', {})).toBe('');
345
+ expect(buildSearchQuery(' ', {})).toBe('');
346
+ });
347
+ it('coerces nullish raw query into empty string', () => {
348
+ expect(buildSearchQuery(null, { from: 'alice' })).toBe('from:alice');
349
+ expect(buildSearchQuery(undefined, { from: 'alice' })).toBe('from:alice');
350
+ });
351
+ });
352
+
353
+ describe('resolveSearchFParam', () => {
354
+ it('defaults to top when neither product nor filter is set', () => {
355
+ expect(resolveSearchFParam({})).toBe('top');
356
+ });
357
+ it('returns top when filter=top', () => {
358
+ expect(resolveSearchFParam({ filter: 'top' })).toBe('top');
359
+ });
360
+ it('returns live when filter=live', () => {
361
+ expect(resolveSearchFParam({ filter: 'live' })).toBe('live');
362
+ });
363
+ it('maps --product photos to image (Twitter URL singular form)', () => {
364
+ expect(resolveSearchFParam({ product: 'photos' })).toBe('image');
365
+ });
366
+ it('maps --product videos to video (Twitter URL singular form)', () => {
367
+ expect(resolveSearchFParam({ product: 'videos' })).toBe('video');
368
+ });
369
+ it('maps --product top|live straight through', () => {
370
+ expect(resolveSearchFParam({ product: 'top' })).toBe('top');
371
+ expect(resolveSearchFParam({ product: 'live' })).toBe('live');
372
+ });
373
+ it('lets --product win when both --product and --filter are set', () => {
374
+ expect(resolveSearchFParam({ product: 'photos', filter: 'live' })).toBe('image');
375
+ expect(resolveSearchFParam({ product: 'top', filter: 'live' })).toBe('top');
376
+ });
377
+ it('falls back to filter when --product is unknown', () => {
378
+ // unknowns are blocked at the CLI validator layer; this is just defence
379
+ expect(resolveSearchFParam({ product: 'bogus', filter: 'live' })).toBe('live');
380
+ expect(resolveSearchFParam({ product: 'bogus' })).toBe('top');
381
+ });
382
+ });
383
+
384
+ describe('choice surface', () => {
385
+ it('exposes the documented HAS_CHOICES set', () => {
386
+ expect(HAS_CHOICES).toEqual(['media', 'images', 'videos', 'links', 'replies']);
387
+ });
388
+ it('exposes the documented EXCLUDE_CHOICES set', () => {
389
+ expect(EXCLUDE_CHOICES).toEqual(['replies', 'retweets', 'media', 'links']);
390
+ });
391
+ it('exposes the documented PRODUCT_CHOICES set', () => {
392
+ expect(PRODUCT_CHOICES).toEqual(['top', 'live', 'photos', 'videos']);
393
+ });
394
+ it('keeps PRODUCT_TO_F_PARAM domain a strict subset of PRODUCT_CHOICES', () => {
395
+ for (const choice of PRODUCT_CHOICES) {
396
+ expect(PRODUCT_TO_F_PARAM[choice]).toBeTypeOf('string');
397
+ }
398
+ });
399
+ it('keeps EXCLUDE_TO_OPERATOR domain a strict subset of EXCLUDE_CHOICES', () => {
400
+ for (const choice of EXCLUDE_CHOICES) {
401
+ expect(EXCLUDE_TO_OPERATOR[choice]).toMatch(/^-filter:/);
402
+ }
403
+ });
404
+ it('keeps FROM_USER_PATTERN aligned with X handle syntax', () => {
405
+ expect(FROM_USER_PATTERN.test('alice_123')).toBe(true);
406
+ expect(FROM_USER_PATTERN.test('a'.repeat(15))).toBe(true);
407
+ expect(FROM_USER_PATTERN.test('a'.repeat(16))).toBe(false);
408
+ expect(FROM_USER_PATTERN.test('alice/bob')).toBe(false);
409
+ });
410
+ });
411
+ });
412
+
413
+ describe('twitter search end-to-end with new filters', () => {
414
+ it('encodes the composed query and product=live into the f= URL param', async () => {
415
+ const command = getRegistry().get('twitter/search');
416
+ const evaluate = vi.fn()
417
+ .mockResolvedValueOnce(undefined)
418
+ .mockResolvedValueOnce('/search');
419
+ const page = {
420
+ goto: vi.fn().mockResolvedValue(undefined),
421
+ wait: vi.fn().mockResolvedValue(undefined),
422
+ installInterceptor: vi.fn().mockResolvedValue(undefined),
423
+ evaluate,
424
+ autoScroll: vi.fn().mockResolvedValue(undefined),
425
+ getInterceptedRequests: vi.fn().mockResolvedValue([]),
426
+ };
427
+ await command.func(page, {
428
+ query: 'breaking news',
429
+ from: '@alice',
430
+ has: 'images',
431
+ exclude: 'retweets',
432
+ product: 'live',
433
+ limit: 5,
434
+ });
435
+ const pushStateCall = evaluate.mock.calls[0][0];
436
+ // f=live wins because --product=live trumps the default --filter
437
+ expect(pushStateCall).toContain('f=live');
438
+ // composed query should be percent-encoded inside the URL
439
+ const encoded = encodeURIComponent('breaking news from:alice filter:images -filter:nativeretweets');
440
+ expect(pushStateCall).toContain(encoded);
441
+ });
442
+ it('throws ArgumentError when query and all filters are empty', async () => {
443
+ const command = getRegistry().get('twitter/search');
444
+ const page = {
445
+ goto: vi.fn().mockResolvedValue(undefined),
446
+ wait: vi.fn().mockResolvedValue(undefined),
447
+ installInterceptor: vi.fn().mockResolvedValue(undefined),
448
+ evaluate: vi.fn(),
449
+ autoScroll: vi.fn().mockResolvedValue(undefined),
450
+ getInterceptedRequests: vi.fn(),
451
+ };
452
+ await expect(command.func(page, { query: ' ', limit: 5 }))
453
+ .rejects
454
+ .toThrow(/empty/i);
455
+ expect(page.installInterceptor).not.toHaveBeenCalled();
456
+ });
457
+ it('throws ArgumentError for invalid --from before navigation', async () => {
458
+ const command = getRegistry().get('twitter/search');
459
+ const page = {
460
+ goto: vi.fn(),
461
+ wait: vi.fn(),
462
+ installInterceptor: vi.fn(),
463
+ evaluate: vi.fn(),
464
+ autoScroll: vi.fn(),
465
+ getInterceptedRequests: vi.fn(),
466
+ };
467
+ await expect(command.func(page, { query: 'hi', from: 'alice filter:links', limit: 5 }))
468
+ .rejects
469
+ .toThrow(/Invalid --from/);
470
+ expect(page.goto).not.toHaveBeenCalled();
471
+ });
472
+ it('throws ArgumentError for invalid --limit before navigation', async () => {
473
+ const command = getRegistry().get('twitter/search');
474
+ const page = {
475
+ goto: vi.fn(),
476
+ wait: vi.fn(),
477
+ installInterceptor: vi.fn(),
478
+ evaluate: vi.fn(),
479
+ autoScroll: vi.fn(),
480
+ getInterceptedRequests: vi.fn(),
481
+ };
482
+ await expect(command.func(page, { query: 'hi', limit: 0 }))
483
+ .rejects
484
+ .toThrow(/--limit/);
485
+ expect(page.goto).not.toHaveBeenCalled();
486
+ });
487
+ it('runs with only filters set (empty <query>)', async () => {
488
+ const command = getRegistry().get('twitter/search');
489
+ const evaluate = vi.fn()
490
+ .mockResolvedValueOnce(undefined)
491
+ .mockResolvedValueOnce('/search');
492
+ const page = {
493
+ goto: vi.fn().mockResolvedValue(undefined),
494
+ wait: vi.fn().mockResolvedValue(undefined),
495
+ installInterceptor: vi.fn().mockResolvedValue(undefined),
496
+ evaluate,
497
+ autoScroll: vi.fn().mockResolvedValue(undefined),
498
+ getInterceptedRequests: vi.fn().mockResolvedValue([]),
499
+ };
500
+ const result = await command.func(page, { query: '', from: 'alice', limit: 5 });
501
+ expect(result).toEqual([]);
502
+ const pushStateCall = evaluate.mock.calls[0][0];
503
+ expect(pushStateCall).toContain(encodeURIComponent('from:alice'));
504
+ });
505
+ });