@agentrhq/webcmd 0.2.5 → 0.3.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 (200) hide show
  1. package/README.md +13 -2
  2. package/cli-manifest.json +217 -13
  3. package/clis/_shared/site-auth.js +3 -3
  4. package/clis/_shared/site-auth.test.js +4 -4
  5. package/clis/chatgpt/ask.js +1 -1
  6. package/clis/chatgpt/ask.test.js +11 -0
  7. package/clis/chatgpt/commands.test.js +102 -1
  8. package/clis/chatgpt/deep-research-result.js +45 -3
  9. package/clis/chatgpt/image.js +22 -2
  10. package/clis/chatgpt/project-file-add.js +24 -1
  11. package/clis/chatgpt/utils.js +258 -52
  12. package/clis/chatgpt/utils.test.js +259 -1
  13. package/clis/claude/ask.js +21 -1
  14. package/clis/facebook/feed.js +110 -24
  15. package/clis/facebook/feed.test.js +62 -0
  16. package/clis/facebook/search.js +185 -38
  17. package/clis/facebook/search.test.js +95 -50
  18. package/clis/instagram/explore.js +30 -12
  19. package/clis/instagram/explore.test.js +77 -0
  20. package/clis/instagram/post.js +14 -1
  21. package/clis/instagram/reel.js +13 -1
  22. package/clis/linkedin/company.js +153 -0
  23. package/clis/linkedin/company.test.js +111 -0
  24. package/clis/linkedin/connections.js +135 -0
  25. package/clis/linkedin/connections.test.js +141 -0
  26. package/clis/mercury/reimbursement-draft.js +12 -1
  27. package/clis/slock/whoami.test.js +2 -2
  28. package/clis/twitter/article-evaluate.test.js +27 -0
  29. package/clis/twitter/article.js +73 -16
  30. package/clis/twitter/article.test.js +209 -0
  31. package/clis/twitter/download.js +6 -1
  32. package/clis/twitter/post.js +14 -1
  33. package/clis/twitter/profile.js +1 -1
  34. package/clis/twitter/profile.test.js +8 -0
  35. package/clis/twitter/quote.js +11 -1
  36. package/clis/twitter/reply.js +11 -1
  37. package/dist/src/browser/base-page.d.ts +26 -0
  38. package/dist/src/browser/base-page.js +23 -8
  39. package/dist/src/browser/base-page.test.js +45 -12
  40. package/dist/src/browser/command-catalog.d.ts +6 -0
  41. package/dist/src/browser/command-catalog.js +259 -0
  42. package/dist/src/browser/command-catalog.test.d.ts +1 -0
  43. package/dist/src/browser/command-catalog.test.js +79 -0
  44. package/dist/src/browser/daemon-client.d.ts +2 -1
  45. package/dist/src/browser/runtime/local-cloak/actions.js +4 -1
  46. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.d.ts +17 -0
  47. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.js +106 -0
  48. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.d.ts +1 -0
  49. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.js +65 -0
  50. package/dist/src/browser/runtime/local-cloak/provider.test.js +36 -0
  51. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +10 -2
  52. package/dist/src/browser/runtime/local-cloak/session-manager.js +25 -9
  53. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +36 -0
  54. package/dist/src/browser/target-resolver.d.ts +1 -0
  55. package/dist/src/browser/target-resolver.js +99 -4
  56. package/dist/src/browser/target-resolver.test.js +141 -1
  57. package/dist/src/build-manifest.d.ts +6 -0
  58. package/dist/src/build-manifest.js +18 -1
  59. package/dist/src/build-manifest.test.js +57 -1
  60. package/dist/src/builtin-command-surface.d.ts +9 -0
  61. package/dist/src/builtin-command-surface.js +16 -0
  62. package/dist/src/check-hosted-contract.test.d.ts +1 -0
  63. package/dist/src/check-hosted-contract.test.js +56 -0
  64. package/dist/src/cli.js +63 -110
  65. package/dist/src/cli.test.js +82 -6
  66. package/dist/src/command-presentation.d.ts +95 -0
  67. package/dist/src/command-presentation.js +486 -0
  68. package/dist/src/command-presentation.test.d.ts +1 -0
  69. package/dist/src/command-presentation.test.js +97 -0
  70. package/dist/src/command-surface.d.ts +43 -0
  71. package/dist/src/command-surface.js +205 -0
  72. package/dist/src/command-surface.test.d.ts +1 -0
  73. package/dist/src/command-surface.test.js +406 -0
  74. package/dist/src/commanderAdapter.d.ts +5 -1
  75. package/dist/src/commanderAdapter.js +12 -55
  76. package/dist/src/commanderAdapter.test.js +15 -3
  77. package/dist/src/commands/auth.js +3 -2
  78. package/dist/src/commands/auth.test.js +6 -6
  79. package/dist/src/community-plugin-sync.d.ts +11 -0
  80. package/dist/src/community-plugin-sync.js +138 -0
  81. package/dist/src/community-plugin-sync.test.d.ts +1 -0
  82. package/dist/src/community-plugin-sync.test.js +123 -0
  83. package/dist/src/completion-fast.d.ts +4 -4
  84. package/dist/src/completion-fast.js +15 -34
  85. package/dist/src/completion-shared.d.ts +4 -0
  86. package/dist/src/completion-shared.js +38 -0
  87. package/dist/src/completion.js +3 -27
  88. package/dist/src/completion.test.js +31 -2
  89. package/dist/src/docs-sync-review-cli.test.d.ts +1 -0
  90. package/dist/src/docs-sync-review-cli.test.js +322 -0
  91. package/dist/src/docs-sync-review.d.ts +117 -0
  92. package/dist/src/docs-sync-review.js +475 -0
  93. package/dist/src/docs-sync-review.test.d.ts +1 -0
  94. package/dist/src/docs-sync-review.test.js +324 -0
  95. package/dist/src/errors.d.ts +12 -3
  96. package/dist/src/errors.js +11 -7
  97. package/dist/src/errors.test.js +14 -1
  98. package/dist/src/execution.d.ts +1 -2
  99. package/dist/src/execution.js +4 -45
  100. package/dist/src/generate-release-notes-cli.test.js +11 -1
  101. package/dist/src/help.d.ts +4 -0
  102. package/dist/src/help.js +50 -255
  103. package/dist/src/help.test.js +27 -1
  104. package/dist/src/hosted/args.d.ts +4 -0
  105. package/dist/src/hosted/args.js +4 -0
  106. package/dist/src/hosted/args.test.d.ts +1 -0
  107. package/dist/src/hosted/args.test.js +49 -0
  108. package/dist/src/hosted/availability.d.ts +13 -0
  109. package/dist/src/hosted/availability.js +16 -0
  110. package/dist/src/hosted/availability.test.d.ts +1 -0
  111. package/dist/src/hosted/availability.test.js +180 -0
  112. package/dist/src/hosted/browser-args.d.ts +18 -0
  113. package/dist/src/hosted/browser-args.js +152 -0
  114. package/dist/src/hosted/browser-args.test.d.ts +1 -0
  115. package/dist/src/hosted/browser-args.test.js +182 -0
  116. package/dist/src/hosted/client.d.ts +59 -0
  117. package/dist/src/hosted/client.js +510 -0
  118. package/dist/src/hosted/client.test.d.ts +1 -0
  119. package/dist/src/hosted/client.test.js +738 -0
  120. package/dist/src/hosted/config.d.ts +55 -0
  121. package/dist/src/hosted/config.js +112 -0
  122. package/dist/src/hosted/config.test.d.ts +1 -0
  123. package/dist/src/hosted/config.test.js +81 -0
  124. package/dist/src/hosted/contract.d.ts +94 -0
  125. package/dist/src/hosted/contract.js +208 -0
  126. package/dist/src/hosted/contract.test.d.ts +1 -0
  127. package/dist/src/hosted/contract.test.js +361 -0
  128. package/dist/src/hosted/credentials.d.ts +38 -0
  129. package/dist/src/hosted/credentials.js +248 -0
  130. package/dist/src/hosted/credentials.test.d.ts +1 -0
  131. package/dist/src/hosted/credentials.test.js +93 -0
  132. package/dist/src/hosted/file-contract.test.d.ts +1 -0
  133. package/dist/src/hosted/file-contract.test.js +174 -0
  134. package/dist/src/hosted/files.d.ts +29 -0
  135. package/dist/src/hosted/files.js +296 -0
  136. package/dist/src/hosted/files.test.d.ts +1 -0
  137. package/dist/src/hosted/files.test.js +231 -0
  138. package/dist/src/hosted/main-lifecycle.test.d.ts +1 -0
  139. package/dist/src/hosted/main-lifecycle.test.js +213 -0
  140. package/dist/src/hosted/manifest.d.ts +14 -0
  141. package/dist/src/hosted/manifest.js +47 -0
  142. package/dist/src/hosted/manifest.test.d.ts +1 -0
  143. package/dist/src/hosted/manifest.test.js +164 -0
  144. package/dist/src/hosted/output-parity.test.d.ts +1 -0
  145. package/dist/src/hosted/output-parity.test.js +108 -0
  146. package/dist/src/hosted/root-command-surface.test.d.ts +1 -0
  147. package/dist/src/hosted/root-command-surface.test.js +646 -0
  148. package/dist/src/hosted/runner.d.ts +19 -0
  149. package/dist/src/hosted/runner.js +705 -0
  150. package/dist/src/hosted/runner.test.d.ts +1 -0
  151. package/dist/src/hosted/runner.test.js +1607 -0
  152. package/dist/src/hosted/setup.d.ts +10 -0
  153. package/dist/src/hosted/setup.js +79 -0
  154. package/dist/src/hosted/setup.test.d.ts +1 -0
  155. package/dist/src/hosted/setup.test.js +195 -0
  156. package/dist/src/hosted/types.d.ts +175 -0
  157. package/dist/src/hosted/types.js +1 -0
  158. package/dist/src/main.js +123 -97
  159. package/dist/src/manifest-types.d.ts +2 -0
  160. package/dist/src/output.d.ts +17 -2
  161. package/dist/src/output.js +88 -81
  162. package/dist/src/output.test.js +141 -51
  163. package/dist/src/pipeline/executor.test.js +1 -0
  164. package/dist/src/pipeline/steps/download.test.js +1 -0
  165. package/dist/src/plugin-create-cli.test.d.ts +1 -0
  166. package/dist/src/plugin-create-cli.test.js +37 -0
  167. package/dist/src/plugin-manifest.d.ts +12 -0
  168. package/dist/src/plugin-manifest.js +16 -0
  169. package/dist/src/plugin-manifest.test.js +19 -1
  170. package/dist/src/plugin-scaffold.d.ts +4 -1
  171. package/dist/src/plugin-scaffold.js +4 -1
  172. package/dist/src/plugin-scaffold.test.js +23 -8
  173. package/dist/src/plugin.js +4 -1
  174. package/dist/src/plugin.test.js +13 -0
  175. package/dist/src/registry.d.ts +2 -0
  176. package/dist/src/release-notes.d.ts +6 -1
  177. package/dist/src/release-notes.js +192 -4
  178. package/dist/src/release-notes.test.js +143 -4
  179. package/dist/src/root-command-surface.d.ts +31 -0
  180. package/dist/src/root-command-surface.js +106 -0
  181. package/dist/src/serialization.d.ts +1 -16
  182. package/dist/src/serialization.js +5 -55
  183. package/dist/src/stream-write.d.ts +12 -0
  184. package/dist/src/stream-write.js +91 -0
  185. package/dist/src/stream-write.test.d.ts +1 -0
  186. package/dist/src/stream-write.test.js +186 -0
  187. package/dist/src/types.d.ts +5 -0
  188. package/dist/src/utils.d.ts +1 -1
  189. package/dist/src/utils.js +2 -8
  190. package/dist/src/utils.test.js +50 -0
  191. package/hosted-contract.json +36748 -0
  192. package/package.json +6 -1
  193. package/scripts/check-hosted-contract.mjs +108 -0
  194. package/scripts/docs-sync-review.ts +332 -0
  195. package/scripts/generate-release-notes.ts +1 -1
  196. package/scripts/sync-community-plugins.ts +14 -0
  197. package/skills/smart-search/SKILL.md +14 -3
  198. package/skills/webcmd-autofix/SKILL.md +26 -37
  199. package/skills/webcmd-usage/SKILL.md +21 -7
  200. package/clis/antigravity/SKILL.md +0 -38
@@ -1,8 +1,13 @@
1
1
  import { AuthRequiredError, CommandExecutionError } from '@agentrhq/webcmd/errors';
2
2
  import { cli, Strategy } from '@agentrhq/webcmd/registry';
3
- import { resolveTwitterQueryId, describeTwitterApiError } from './shared.js';
3
+ import { resolveTwitterQueryId, describeTwitterApiError, unwrapBrowserResult } from './shared.js';
4
4
  import { TWITTER_BEARER_TOKEN } from './utils.js';
5
5
  const TWEET_RESULT_BY_REST_ID_QUERY_ID = '7xflPyRiUxGVbJd4uWmbfg';
6
+
7
+ function isPlainObject(value) {
8
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
9
+ }
10
+
6
11
  cli({
7
12
  site: 'twitter',
8
13
  name: 'article',
@@ -43,10 +48,11 @@ cli({
43
48
  return null;
44
49
  })()
45
50
  `);
46
- if (!resolvedId || typeof resolvedId !== 'string') {
51
+ const resolvedTweetId = unwrapBrowserResult(resolvedId);
52
+ if (!resolvedTweetId || typeof resolvedTweetId !== 'string') {
47
53
  throw new CommandExecutionError(`Could not resolve article ${tweetId} to a tweet ID. The article page may not contain a linked tweet.`);
48
54
  }
49
- tweetId = resolvedId;
55
+ tweetId = resolvedTweetId;
50
56
  }
51
57
  // Navigate to the tweet page for cookie context
52
58
  await page.goto(`https://x.com/i/status/${tweetId}`);
@@ -57,9 +63,9 @@ cli({
57
63
  if (!ct0)
58
64
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
59
65
  const queryId = await resolveTwitterQueryId(page, 'TweetResultByRestId', TWEET_RESULT_BY_REST_ID_QUERY_ID);
60
- const result = await page.evaluate(`
66
+ const rawResult = unwrapBrowserResult(await page.evaluate(`
61
67
  async () => {
62
- const tweetId = "${tweetId}";
68
+ const tweetId = ${JSON.stringify(tweetId)};
63
69
  const ct0 = ${JSON.stringify(ct0)};
64
70
 
65
71
  const bearer = ${JSON.stringify(TWITTER_BEARER_TOKEN)};
@@ -95,18 +101,49 @@ cli({
95
101
  + '&features=' + encodeURIComponent(features)
96
102
  + '&fieldToggles=' + encodeURIComponent(fieldToggles);
97
103
 
98
- const resp = await fetch(url, {headers, credentials: 'include'});
104
+ let resp;
105
+ try {
106
+ resp = await fetch(url, {headers, credentials: 'include'});
107
+ } catch (error) {
108
+ return {error: 'Twitter article request failed: ' + String(error && error.message || error)};
109
+ }
99
110
  if (!resp.ok) return {httpStatus: resp.status};
100
- const d = await resp.json();
111
+ let d;
112
+ try {
113
+ d = await resp.json();
114
+ } catch {
115
+ return {error: 'Twitter API response was not valid JSON', hint: 'You may be logged out or the request was blocked'};
116
+ }
117
+ if (!d || typeof d !== 'object' || Array.isArray(d)) {
118
+ return {error: 'Twitter API response payload was malformed'};
119
+ }
101
120
 
102
- const result = d.data?.tweetResult?.result;
103
- if (!result) return {error: 'Article not found'};
121
+ const result = d?.data?.tweetResult?.result;
122
+ if (!result) {
123
+ if (Array.isArray(d.errors) && d.errors.length > 0) {
124
+ return {error: 'Twitter TweetResultByRestId returned GraphQL errors: ' + JSON.stringify(d.errors).slice(0, 200)};
125
+ }
126
+ return {error: 'Article not found'};
127
+ }
104
128
 
105
129
  // Unwrap TweetWithVisibilityResults
130
+ if (!result || typeof result !== 'object' || Array.isArray(result)) {
131
+ return {error: 'Twitter API response tweet result was malformed'};
132
+ }
106
133
  const tw = result.tweet || result;
134
+ if (!tw || typeof tw !== 'object' || Array.isArray(tw)) {
135
+ return {error: 'Twitter API response tweet result was malformed'};
136
+ }
107
137
  const legacy = tw.legacy || {};
108
138
  const user = tw.core?.user_results?.result;
109
- const screenName = user?.legacy?.screen_name || user?.core?.screen_name || 'unknown';
139
+ const returnedTweetId = tw.rest_id || legacy.id_str;
140
+ if (typeof returnedTweetId !== 'string' || returnedTweetId !== tweetId) {
141
+ return {error: 'Twitter API response did not match requested tweet ' + tweetId};
142
+ }
143
+ const screenName = user?.legacy?.screen_name || user?.core?.screen_name || '';
144
+ if (typeof screenName !== 'string' || !/^[A-Za-z0-9_]{1,15}$/.test(screenName)) {
145
+ return {error: 'Twitter API response did not include a valid author screen name for tweet ' + tweetId};
146
+ }
110
147
 
111
148
  // Extract article content
112
149
  const articleResults = tw.article?.article_results?.result;
@@ -123,15 +160,25 @@ cli({
123
160
  }
124
161
  return {error: 'Tweet ' + tweetId + ' has no article content'};
125
162
  }
163
+ if (!articleResults || typeof articleResults !== 'object' || Array.isArray(articleResults)) {
164
+ return {error: 'Twitter API response article result was malformed'};
165
+ }
126
166
 
127
167
  const title = articleResults.title || '(Untitled)';
128
168
  const contentState = articleResults.content_state || {};
169
+ if (!contentState || typeof contentState !== 'object' || Array.isArray(contentState)) {
170
+ return {error: 'Twitter API response article content was malformed'};
171
+ }
129
172
  const blocks = contentState.blocks || [];
173
+ if (!Array.isArray(blocks)) {
174
+ return {error: 'Twitter API response article blocks were malformed'};
175
+ }
130
176
 
131
177
  // Convert draft.js blocks to Markdown
132
178
  const parts = [];
133
179
  let orderedCounter = 0;
134
180
  for (const block of blocks) {
181
+ if (!block || typeof block !== 'object' || Array.isArray(block)) continue;
135
182
  const blockType = block.type || 'unstyled';
136
183
  if (blockType === 'atomic') continue;
137
184
  const text = block.text || '';
@@ -158,13 +205,23 @@ cli({
158
205
  url: 'https://x.com/' + screenName + '/status/' + tweetId,
159
206
  }];
160
207
  }
161
- `);
162
- if (result?.httpStatus) {
163
- throw new CommandExecutionError(describeTwitterApiError('TweetResultByRestId', result.httpStatus));
208
+ `));
209
+ if (!Array.isArray(rawResult) && !isPlainObject(rawResult)) {
210
+ throw new CommandExecutionError('Twitter article response payload is malformed');
211
+ }
212
+ if (rawResult?.httpStatus) {
213
+ const message = describeTwitterApiError('TweetResultByRestId', rawResult.httpStatus);
214
+ if (rawResult.httpStatus === 401 || rawResult.httpStatus === 403) {
215
+ throw new AuthRequiredError('x.com', message);
216
+ }
217
+ throw new CommandExecutionError(message);
218
+ }
219
+ if (rawResult?.error) {
220
+ throw new CommandExecutionError(rawResult.error + (rawResult.hint ? ` (${rawResult.hint})` : ''));
164
221
  }
165
- if (result?.error) {
166
- throw new CommandExecutionError(result.error + (result.hint ? ` (${result.hint})` : ''));
222
+ if (!Array.isArray(rawResult)) {
223
+ throw new CommandExecutionError('Twitter article response payload is malformed');
167
224
  }
168
- return result || [];
225
+ return rawResult;
169
226
  }
170
227
  });
@@ -0,0 +1,209 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@agentrhq/webcmd/registry';
3
+ import { AuthRequiredError, CommandExecutionError } from '@agentrhq/webcmd/errors';
4
+ import './article.js';
5
+
6
+ const TWEET_ID = '1234567890';
7
+
8
+ function createPage(articleResult) {
9
+ return {
10
+ goto: vi.fn().mockResolvedValue(undefined),
11
+ wait: vi.fn().mockResolvedValue(undefined),
12
+ getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'csrf-token' }]),
13
+ evaluate: vi.fn()
14
+ .mockResolvedValueOnce(null)
15
+ .mockResolvedValueOnce(articleResult),
16
+ };
17
+ }
18
+
19
+ function createPageWithEvaluateResults(evaluateResults) {
20
+ return {
21
+ goto: vi.fn().mockResolvedValue(undefined),
22
+ wait: vi.fn().mockResolvedValue(undefined),
23
+ getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'csrf-token' }]),
24
+ evaluate: vi.fn().mockImplementation(() => Promise.resolve(evaluateResults.shift())),
25
+ };
26
+ }
27
+
28
+ function validArticlePayload(tweetOverrides = {}) {
29
+ return {
30
+ data: {
31
+ tweetResult: {
32
+ result: {
33
+ tweet: {
34
+ rest_id: TWEET_ID,
35
+ legacy: { full_text: 'fallback text' },
36
+ core: {
37
+ user_results: {
38
+ result: { legacy: { screen_name: 'alice' } },
39
+ },
40
+ },
41
+ article: {
42
+ article_results: {
43
+ result: {
44
+ title: 'Long article',
45
+ content_state: {
46
+ blocks: [{ type: 'unstyled', text: 'body' }],
47
+ },
48
+ },
49
+ },
50
+ },
51
+ ...tweetOverrides,
52
+ },
53
+ },
54
+ },
55
+ },
56
+ };
57
+ }
58
+
59
+ async function evaluateArticleFetchScript(script, fetchImplementation) {
60
+ const previousFetch = globalThis.fetch;
61
+ globalThis.fetch = vi.fn(fetchImplementation);
62
+ try {
63
+ return await eval(`(${script})`)();
64
+ } finally {
65
+ globalThis.fetch = previousFetch;
66
+ }
67
+ }
68
+
69
+ function createFetchPage(fetchImplementation) {
70
+ const page = createPageWithEvaluateResults([]);
71
+ page.evaluate
72
+ .mockImplementationOnce(() => Promise.resolve(null))
73
+ .mockImplementationOnce((script) => evaluateArticleFetchScript(script, fetchImplementation));
74
+ return page;
75
+ }
76
+
77
+ function jsonResponse(payload, overrides = {}) {
78
+ return {
79
+ ok: true,
80
+ status: 200,
81
+ json: vi.fn().mockResolvedValue(payload),
82
+ ...overrides,
83
+ };
84
+ }
85
+
86
+ describe('twitter article command', () => {
87
+ const command = getRegistry().get('twitter/article');
88
+ const rows = [{
89
+ title: 'Long article',
90
+ author: 'alice',
91
+ content: 'body',
92
+ url: `https://x.com/alice/status/${TWEET_ID}`,
93
+ }];
94
+
95
+ it('accepts raw Cloak rows and legacy Browser Bridge envelopes', async () => {
96
+ await expect(command.func(createPage(rows), { 'tweet-id': TWEET_ID })).resolves.toEqual(rows);
97
+ await expect(command.func(createPage({ session: 'browser:default', data: rows }), { 'tweet-id': TWEET_ID }))
98
+ .resolves.toEqual(rows);
99
+ });
100
+
101
+ it('unwraps legacy envelopes while resolving article URLs', async () => {
102
+ const page = createPageWithEvaluateResults([
103
+ { session: 'browser:default', data: TWEET_ID },
104
+ null,
105
+ rows,
106
+ ]);
107
+
108
+ await expect(command.func(page, { 'tweet-id': 'https://x.com/i/article/987654321' }))
109
+ .resolves.toEqual(rows);
110
+ expect(page.goto).toHaveBeenNthCalledWith(1, 'https://x.com/i/article/987654321');
111
+ expect(page.goto).toHaveBeenNthCalledWith(2, `https://x.com/i/status/${TWEET_ID}`);
112
+ });
113
+
114
+ it('maps 401 and 403 API responses to AuthRequiredError', async () => {
115
+ for (const httpStatus of [401, 403]) {
116
+ await expect(command.func(createPage({ httpStatus }), { 'tweet-id': TWEET_ID }))
117
+ .rejects.toBeInstanceOf(AuthRequiredError);
118
+ }
119
+ });
120
+
121
+ it('fails closed for malformed evaluated response payloads', async () => {
122
+ for (const value of [null, 42, {}, { session: 'browser:default', data: {} }]) {
123
+ await expect(command.func(createPage(value), { 'tweet-id': TWEET_ID }))
124
+ .rejects.toBeInstanceOf(CommandExecutionError);
125
+ }
126
+ });
127
+
128
+ it('reports invalid JSON and network failures as typed command errors', async () => {
129
+ const invalidJsonPage = createFetchPage(async () => ({
130
+ ok: true,
131
+ status: 200,
132
+ json: vi.fn().mockRejectedValue(new SyntaxError('invalid JSON')),
133
+ }));
134
+ await expect(command.func(invalidJsonPage, { 'tweet-id': TWEET_ID }))
135
+ .rejects.toBeInstanceOf(CommandExecutionError);
136
+
137
+ const networkPage = createFetchPage(async () => {
138
+ throw new Error('socket closed');
139
+ });
140
+ await expect(command.func(networkPage, { 'tweet-id': TWEET_ID }))
141
+ .rejects.toBeInstanceOf(CommandExecutionError);
142
+ });
143
+
144
+ it('rejects null and array GraphQL roots', async () => {
145
+ for (const payload of [null, []]) {
146
+ const page = createFetchPage(async () => jsonResponse(payload));
147
+ await expect(command.func(page, { 'tweet-id': TWEET_ID }))
148
+ .rejects.toBeInstanceOf(CommandExecutionError);
149
+ }
150
+ });
151
+
152
+ it('surfaces GraphQL errors instead of returning a generic not-found error', async () => {
153
+ const page = createFetchPage(async () => jsonResponse({
154
+ errors: [{ message: 'rate limited' }],
155
+ }));
156
+
157
+ await expect(command.func(page, { 'tweet-id': TWEET_ID }))
158
+ .rejects.toThrow(/GraphQL errors/);
159
+ });
160
+
161
+ it('requires the returned tweet identity to match the request', async () => {
162
+ const page = createFetchPage(async () => jsonResponse(validArticlePayload({ rest_id: '999' })));
163
+
164
+ await expect(command.func(page, { 'tweet-id': TWEET_ID }))
165
+ .rejects.toThrow(/did not match requested tweet/);
166
+ });
167
+
168
+ it('requires a valid author screen name', async () => {
169
+ for (const screenName of ['', 'bad/name']) {
170
+ const payload = validArticlePayload({
171
+ core: { user_results: { result: { legacy: { screen_name: screenName } } } },
172
+ });
173
+ const page = createFetchPage(async () => jsonResponse(payload));
174
+ await expect(command.func(page, { 'tweet-id': TWEET_ID }))
175
+ .rejects.toThrow(/valid author screen name/);
176
+ }
177
+ });
178
+
179
+ it('fails closed for malformed tweet, article, content, and block shapes', async () => {
180
+ const payloads = [
181
+ { data: { tweetResult: { result: 'not-an-object' } } },
182
+ { data: { tweetResult: { result: { tweet: 'not-an-object' } } } },
183
+ validArticlePayload({ article: { article_results: { result: 'not-an-object' } } }),
184
+ validArticlePayload({ article: { article_results: { result: { content_state: [] } } } }),
185
+ validArticlePayload({ article: { article_results: { result: { content_state: { blocks: {} } } } } }),
186
+ ];
187
+
188
+ for (const payload of payloads) {
189
+ const page = createFetchPage(async () => jsonResponse(payload));
190
+ await expect(command.func(page, { 'tweet-id': TWEET_ID }))
191
+ .rejects.toBeInstanceOf(CommandExecutionError);
192
+ }
193
+ });
194
+
195
+ it('keeps the valid note-tweet fallback', async () => {
196
+ const payload = validArticlePayload({
197
+ article: undefined,
198
+ note_tweet: { note_tweet_results: { result: { text: 'A long note' } } },
199
+ });
200
+ const page = createFetchPage(async () => jsonResponse(payload));
201
+
202
+ await expect(command.func(page, { 'tweet-id': TWEET_ID })).resolves.toEqual([{
203
+ title: '(Note Tweet)',
204
+ author: 'alice',
205
+ content: 'A long note',
206
+ url: `https://x.com/alice/status/${TWEET_ID}`,
207
+ }]);
208
+ });
209
+ });
@@ -311,7 +311,12 @@ cli({
311
311
  { name: 'username', positional: true, help: 'Twitter username (with or without @) to scan their profile media. Either <username> or --tweet-url is required.' },
312
312
  { name: 'tweet-url', help: 'Single tweet URL to download. Use this OR <username>, not both required at once.' },
313
313
  { name: 'limit', type: 'int', default: 10, help: 'Maximum number of media items to download when scanning a profile (default 10). Ignored when --tweet-url is used.' },
314
- { name: 'output', default: './twitter-downloads', help: 'Output directory (default ./twitter-downloads). A per-source subdir is created inside.' },
314
+ {
315
+ name: 'output',
316
+ default: './twitter-downloads',
317
+ help: 'Output directory (default ./twitter-downloads). A per-source subdir is created inside.',
318
+ file: { direction: 'output', pathKind: 'directory', multiple: false },
319
+ },
315
320
  ],
316
321
  columns: ['index', 'tweet_id', 'url', 'type', 'status', 'size'],
317
322
  func: async (page, kwargs) => {
@@ -258,7 +258,20 @@ cli({
258
258
  browser: true,
259
259
  args: [
260
260
  { name: 'text', type: 'string', required: true, positional: true, help: 'The text content of the tweet' },
261
- { name: 'images', type: 'string', required: false, help: 'Image paths, comma-separated, max 4 (jpg/png/gif/webp)' },
261
+ {
262
+ name: 'images',
263
+ type: 'string',
264
+ required: false,
265
+ help: 'Image paths, comma-separated, max 4 (jpg/png/gif/webp)',
266
+ file: {
267
+ direction: 'input',
268
+ pathKind: 'file',
269
+ multiple: true,
270
+ separator: ',',
271
+ contentTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
272
+ maxBytes: 26_214_400,
273
+ },
274
+ },
262
275
  ],
263
276
  columns: ['status', 'message', 'text', 'id', 'url'],
264
277
  func: async (page, kwargs) => {
@@ -88,7 +88,7 @@ cli({
88
88
  const queryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
89
89
  const rawResult = unwrapBrowserResult(await page.evaluate(`
90
90
  async () => {
91
- const screenName = "${username}";
91
+ const screenName = ${JSON.stringify(username)};
92
92
  const ct0 = ${JSON.stringify(ct0)};
93
93
 
94
94
  const bearer = ${JSON.stringify(TWITTER_BEARER_TOKEN)};
@@ -4,6 +4,14 @@ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultErr
4
4
  import { __test__ } from './profile.js';
5
5
 
6
6
  describe('twitter profile command', () => {
7
+ it('serializes the validated screen name before embedding it in page.evaluate', () => {
8
+ const command = getRegistry().get('twitter/profile');
9
+ const source = command.func.toString();
10
+
11
+ expect(source).toContain('const screenName = ${JSON.stringify(username)};');
12
+ expect(source).not.toContain('const screenName = "${username}";');
13
+ });
14
+
7
15
  it('maps current result.core profile fields while preserving legacy fallback fields', () => {
8
16
  const rows = __test__.mapTwitterProfileResult({
9
17
  core: {
@@ -112,7 +112,17 @@ cli({
112
112
  args: [
113
113
  { name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to quote' },
114
114
  { name: 'text', type: 'string', required: true, positional: true, help: 'The text content of your quote' },
115
- { name: 'image', help: 'Optional local image path to attach to the quote tweet' },
115
+ {
116
+ name: 'image',
117
+ help: 'Optional local image path to attach to the quote tweet',
118
+ file: {
119
+ direction: 'input',
120
+ pathKind: 'file',
121
+ multiple: false,
122
+ contentTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
123
+ maxBytes: 26_214_400,
124
+ },
125
+ },
116
126
  { name: 'image-url', help: 'Optional remote image URL to download and attach to the quote tweet' },
117
127
  ],
118
128
  columns: ['status', 'message', 'text'],
@@ -194,7 +194,17 @@ cli({
194
194
  args: [
195
195
  { name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to reply to' },
196
196
  { name: 'text', type: 'string', required: true, positional: true, help: 'The text content of your reply' },
197
- { name: 'image', help: 'Optional local image path to attach to the reply' },
197
+ {
198
+ name: 'image',
199
+ help: 'Optional local image path to attach to the reply',
200
+ file: {
201
+ direction: 'input',
202
+ pathKind: 'file',
203
+ multiple: false,
204
+ contentTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
205
+ maxBytes: 26_214_400,
206
+ },
207
+ },
198
208
  { name: 'image-url', help: 'Optional remote image URL to download and attach to the reply' },
199
209
  ],
200
210
  columns: ['status', 'message', 'text', 'url'],
@@ -18,6 +18,25 @@ export interface ResolveSuccess {
18
18
  * clean `exact` match — the page changed, the action still succeeded.
19
19
  */
20
20
  match_level: TargetMatchLevel;
21
+ /**
22
+ * Which physical click path executed: `cdp` (trusted Input.dispatchMouseEvent),
23
+ * `js` (el.click() fallback), or `ax` (accessibility-node click). Surfaced so
24
+ * agents can tell a trusted synthetic click from the untrusted JS fallback.
25
+ */
26
+ click_method?: 'cdp' | 'js' | 'ax';
27
+ /**
28
+ * Result of hit-testing the click point against the resolved element:
29
+ * `target` (the point lands on the element or a descendant — trustworthy),
30
+ * `other` (an overlay/sibling covers it — the CDP click would miss), or
31
+ * `none` (nothing at the point). Only set on click().
32
+ */
33
+ hit?: 'target' | 'ancestor' | 'other' | 'none';
34
+ /**
35
+ * True when the click was retargeted from the resolved element to a nearby
36
+ * clickable ancestor (e.g. an <svg> icon whose click handler lives on the
37
+ * wrapping <div>), so the handler actually fires. See issue #2071.
38
+ */
39
+ retargeted?: boolean;
21
40
  }
22
41
  export interface FillTextResult extends ResolveSuccess {
23
42
  filled: boolean;
@@ -139,6 +158,13 @@ export declare abstract class BasePage implements IPage {
139
158
  }): Promise<void>;
140
159
  networkRequests(includeStatic?: boolean): Promise<unknown[]>;
141
160
  consoleMessages(_level?: string): Promise<unknown[]>;
161
+ /**
162
+ * Pure client-side sleep with no page evaluation.
163
+ *
164
+ * Poll loops that already re-check state should use this instead of
165
+ * `wait(number)`, whose DOM-stability probe observes the whole document.
166
+ */
167
+ sleep(seconds: number): Promise<void>;
142
168
  wait(options: number | WaitOptions): Promise<void>;
143
169
  snapshot(opts?: SnapshotOptions): Promise<unknown>;
144
170
  private collectAxSnapshotTrees;
@@ -182,23 +182,29 @@ export class BasePage {
182
182
  // Phase 2: measure first so native click can run before DOM el.click().
183
183
  // Custom dropdowns often listen to pointer/mouse down/up; DOM el.click()
184
184
  // only fires click and can silently report success without opening/selecting.
185
- const rect = await this.evaluate(boundingRectResolvedJs({ skipScroll: nativeScrolled }));
186
- if (rect?.visible === true) {
185
+ const rect = await this.evaluate(boundingRectResolvedJs({ skipScroll: nativeScrolled, forClick: true }));
186
+ const meta = { hit: rect?.hit, retargeted: rect?.retargeted };
187
+ // Trust a CDP click when the point hit-tests onto the target, a descendant,
188
+ // or an ancestor (open shadow-DOM host / own wrapper — CDP still reaches the
189
+ // target there). Only an unrelated overlay ('other') forces the el.click()
190
+ // fallback, which dispatches straight on the node. See issues #2076/#2071.
191
+ if (rect?.visible === true && (rect.hit === 'target' || rect.hit === 'ancestor')) {
187
192
  const success = await this.tryNativeClick(rect.x, rect.y);
188
193
  if (success)
189
- return resolved;
194
+ return { ...resolved, click_method: 'cdp', ...meta };
190
195
  }
191
- // JS fallback for older backends or zero-rect targets.
196
+ // JS fallback: el.click() dispatches straight on __resolved, bypassing the
197
+ // occluding overlay (also covers older backends / zero-rect targets).
192
198
  const result = await this.evaluate(clickResolvedJs({ skipScroll: nativeScrolled }));
193
199
  if (typeof result === 'string' || result == null)
194
- return resolved;
200
+ return { ...resolved, click_method: 'js', ...meta };
195
201
  if (result.status === 'clicked')
196
- return resolved;
202
+ return { ...resolved, click_method: 'js', ...meta };
197
203
  // JS click failed — try CDP native click if coordinates available
198
204
  if (result.x != null && result.y != null) {
199
205
  const success = await this.tryNativeClick(result.x, result.y);
200
206
  if (success)
201
- return resolved;
207
+ return { ...resolved, click_method: 'cdp', ...meta };
202
208
  }
203
209
  throw new Error(`Click failed: ${result.error ?? 'JS click and CDP fallback both failed'}`);
204
210
  }
@@ -275,7 +281,7 @@ export class BasePage {
275
281
  return null;
276
282
  try {
277
283
  await nativeClick.call(this, resolved.x, resolved.y);
278
- return { matches_n: 1, match_level: resolved.matchLevel };
284
+ return { matches_n: 1, match_level: resolved.matchLevel, click_method: 'ax' };
279
285
  }
280
286
  catch {
281
287
  return null;
@@ -851,6 +857,15 @@ export class BasePage {
851
857
  async consoleMessages(_level = 'info') {
852
858
  return [];
853
859
  }
860
+ /**
861
+ * Pure client-side sleep with no page evaluation.
862
+ *
863
+ * Poll loops that already re-check state should use this instead of
864
+ * `wait(number)`, whose DOM-stability probe observes the whole document.
865
+ */
866
+ async sleep(seconds) {
867
+ await new Promise(resolve => setTimeout(resolve, seconds * 1000));
868
+ }
854
869
  async wait(options) {
855
870
  if (typeof options === 'number') {
856
871
  if (options >= 1) {