@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.
- package/README.md +13 -2
- package/cli-manifest.json +217 -13
- package/clis/_shared/site-auth.js +3 -3
- package/clis/_shared/site-auth.test.js +4 -4
- package/clis/chatgpt/ask.js +1 -1
- package/clis/chatgpt/ask.test.js +11 -0
- package/clis/chatgpt/commands.test.js +102 -1
- package/clis/chatgpt/deep-research-result.js +45 -3
- package/clis/chatgpt/image.js +22 -2
- package/clis/chatgpt/project-file-add.js +24 -1
- package/clis/chatgpt/utils.js +258 -52
- package/clis/chatgpt/utils.test.js +259 -1
- package/clis/claude/ask.js +21 -1
- package/clis/facebook/feed.js +110 -24
- package/clis/facebook/feed.test.js +62 -0
- package/clis/facebook/search.js +185 -38
- package/clis/facebook/search.test.js +95 -50
- package/clis/instagram/explore.js +30 -12
- package/clis/instagram/explore.test.js +77 -0
- package/clis/instagram/post.js +14 -1
- package/clis/instagram/reel.js +13 -1
- package/clis/linkedin/company.js +153 -0
- package/clis/linkedin/company.test.js +111 -0
- package/clis/linkedin/connections.js +135 -0
- package/clis/linkedin/connections.test.js +141 -0
- package/clis/mercury/reimbursement-draft.js +12 -1
- package/clis/slock/whoami.test.js +2 -2
- package/clis/twitter/article-evaluate.test.js +27 -0
- package/clis/twitter/article.js +73 -16
- package/clis/twitter/article.test.js +209 -0
- package/clis/twitter/download.js +6 -1
- package/clis/twitter/post.js +14 -1
- package/clis/twitter/profile.js +1 -1
- package/clis/twitter/profile.test.js +8 -0
- package/clis/twitter/quote.js +11 -1
- package/clis/twitter/reply.js +11 -1
- package/dist/src/browser/base-page.d.ts +26 -0
- package/dist/src/browser/base-page.js +23 -8
- package/dist/src/browser/base-page.test.js +45 -12
- package/dist/src/browser/command-catalog.d.ts +6 -0
- package/dist/src/browser/command-catalog.js +259 -0
- package/dist/src/browser/command-catalog.test.d.ts +1 -0
- package/dist/src/browser/command-catalog.test.js +79 -0
- package/dist/src/browser/daemon-client.d.ts +2 -1
- package/dist/src/browser/runtime/local-cloak/actions.js +4 -1
- package/dist/src/browser/runtime/local-cloak/darwin-background-launch.d.ts +17 -0
- package/dist/src/browser/runtime/local-cloak/darwin-background-launch.js +106 -0
- package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.js +65 -0
- package/dist/src/browser/runtime/local-cloak/provider.test.js +36 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +10 -2
- package/dist/src/browser/runtime/local-cloak/session-manager.js +25 -9
- package/dist/src/browser/runtime/local-cloak/session-manager.test.js +36 -0
- package/dist/src/browser/target-resolver.d.ts +1 -0
- package/dist/src/browser/target-resolver.js +99 -4
- package/dist/src/browser/target-resolver.test.js +141 -1
- package/dist/src/build-manifest.d.ts +6 -0
- package/dist/src/build-manifest.js +18 -1
- package/dist/src/build-manifest.test.js +57 -1
- package/dist/src/builtin-command-surface.d.ts +9 -0
- package/dist/src/builtin-command-surface.js +16 -0
- package/dist/src/check-hosted-contract.test.d.ts +1 -0
- package/dist/src/check-hosted-contract.test.js +56 -0
- package/dist/src/cli.js +63 -110
- package/dist/src/cli.test.js +82 -6
- package/dist/src/command-presentation.d.ts +95 -0
- package/dist/src/command-presentation.js +486 -0
- package/dist/src/command-presentation.test.d.ts +1 -0
- package/dist/src/command-presentation.test.js +97 -0
- package/dist/src/command-surface.d.ts +43 -0
- package/dist/src/command-surface.js +205 -0
- package/dist/src/command-surface.test.d.ts +1 -0
- package/dist/src/command-surface.test.js +406 -0
- package/dist/src/commanderAdapter.d.ts +5 -1
- package/dist/src/commanderAdapter.js +12 -55
- package/dist/src/commanderAdapter.test.js +15 -3
- package/dist/src/commands/auth.js +3 -2
- package/dist/src/commands/auth.test.js +6 -6
- package/dist/src/community-plugin-sync.d.ts +11 -0
- package/dist/src/community-plugin-sync.js +138 -0
- package/dist/src/community-plugin-sync.test.d.ts +1 -0
- package/dist/src/community-plugin-sync.test.js +123 -0
- package/dist/src/completion-fast.d.ts +4 -4
- package/dist/src/completion-fast.js +15 -34
- package/dist/src/completion-shared.d.ts +4 -0
- package/dist/src/completion-shared.js +38 -0
- package/dist/src/completion.js +3 -27
- package/dist/src/completion.test.js +31 -2
- package/dist/src/docs-sync-review-cli.test.d.ts +1 -0
- package/dist/src/docs-sync-review-cli.test.js +322 -0
- package/dist/src/docs-sync-review.d.ts +117 -0
- package/dist/src/docs-sync-review.js +475 -0
- package/dist/src/docs-sync-review.test.d.ts +1 -0
- package/dist/src/docs-sync-review.test.js +324 -0
- package/dist/src/errors.d.ts +12 -3
- package/dist/src/errors.js +11 -7
- package/dist/src/errors.test.js +14 -1
- package/dist/src/execution.d.ts +1 -2
- package/dist/src/execution.js +4 -45
- package/dist/src/generate-release-notes-cli.test.js +11 -1
- package/dist/src/help.d.ts +4 -0
- package/dist/src/help.js +50 -255
- package/dist/src/help.test.js +27 -1
- package/dist/src/hosted/args.d.ts +4 -0
- package/dist/src/hosted/args.js +4 -0
- package/dist/src/hosted/args.test.d.ts +1 -0
- package/dist/src/hosted/args.test.js +49 -0
- package/dist/src/hosted/availability.d.ts +13 -0
- package/dist/src/hosted/availability.js +16 -0
- package/dist/src/hosted/availability.test.d.ts +1 -0
- package/dist/src/hosted/availability.test.js +180 -0
- package/dist/src/hosted/browser-args.d.ts +18 -0
- package/dist/src/hosted/browser-args.js +152 -0
- package/dist/src/hosted/browser-args.test.d.ts +1 -0
- package/dist/src/hosted/browser-args.test.js +182 -0
- package/dist/src/hosted/client.d.ts +59 -0
- package/dist/src/hosted/client.js +510 -0
- package/dist/src/hosted/client.test.d.ts +1 -0
- package/dist/src/hosted/client.test.js +738 -0
- package/dist/src/hosted/config.d.ts +55 -0
- package/dist/src/hosted/config.js +112 -0
- package/dist/src/hosted/config.test.d.ts +1 -0
- package/dist/src/hosted/config.test.js +81 -0
- package/dist/src/hosted/contract.d.ts +94 -0
- package/dist/src/hosted/contract.js +208 -0
- package/dist/src/hosted/contract.test.d.ts +1 -0
- package/dist/src/hosted/contract.test.js +361 -0
- package/dist/src/hosted/credentials.d.ts +38 -0
- package/dist/src/hosted/credentials.js +248 -0
- package/dist/src/hosted/credentials.test.d.ts +1 -0
- package/dist/src/hosted/credentials.test.js +93 -0
- package/dist/src/hosted/file-contract.test.d.ts +1 -0
- package/dist/src/hosted/file-contract.test.js +174 -0
- package/dist/src/hosted/files.d.ts +29 -0
- package/dist/src/hosted/files.js +296 -0
- package/dist/src/hosted/files.test.d.ts +1 -0
- package/dist/src/hosted/files.test.js +231 -0
- package/dist/src/hosted/main-lifecycle.test.d.ts +1 -0
- package/dist/src/hosted/main-lifecycle.test.js +213 -0
- package/dist/src/hosted/manifest.d.ts +14 -0
- package/dist/src/hosted/manifest.js +47 -0
- package/dist/src/hosted/manifest.test.d.ts +1 -0
- package/dist/src/hosted/manifest.test.js +164 -0
- package/dist/src/hosted/output-parity.test.d.ts +1 -0
- package/dist/src/hosted/output-parity.test.js +108 -0
- package/dist/src/hosted/root-command-surface.test.d.ts +1 -0
- package/dist/src/hosted/root-command-surface.test.js +646 -0
- package/dist/src/hosted/runner.d.ts +19 -0
- package/dist/src/hosted/runner.js +705 -0
- package/dist/src/hosted/runner.test.d.ts +1 -0
- package/dist/src/hosted/runner.test.js +1607 -0
- package/dist/src/hosted/setup.d.ts +10 -0
- package/dist/src/hosted/setup.js +79 -0
- package/dist/src/hosted/setup.test.d.ts +1 -0
- package/dist/src/hosted/setup.test.js +195 -0
- package/dist/src/hosted/types.d.ts +175 -0
- package/dist/src/hosted/types.js +1 -0
- package/dist/src/main.js +123 -97
- package/dist/src/manifest-types.d.ts +2 -0
- package/dist/src/output.d.ts +17 -2
- package/dist/src/output.js +88 -81
- package/dist/src/output.test.js +141 -51
- package/dist/src/pipeline/executor.test.js +1 -0
- package/dist/src/pipeline/steps/download.test.js +1 -0
- package/dist/src/plugin-create-cli.test.d.ts +1 -0
- package/dist/src/plugin-create-cli.test.js +37 -0
- package/dist/src/plugin-manifest.d.ts +12 -0
- package/dist/src/plugin-manifest.js +16 -0
- package/dist/src/plugin-manifest.test.js +19 -1
- package/dist/src/plugin-scaffold.d.ts +4 -1
- package/dist/src/plugin-scaffold.js +4 -1
- package/dist/src/plugin-scaffold.test.js +23 -8
- package/dist/src/plugin.js +4 -1
- package/dist/src/plugin.test.js +13 -0
- package/dist/src/registry.d.ts +2 -0
- package/dist/src/release-notes.d.ts +6 -1
- package/dist/src/release-notes.js +192 -4
- package/dist/src/release-notes.test.js +143 -4
- package/dist/src/root-command-surface.d.ts +31 -0
- package/dist/src/root-command-surface.js +106 -0
- package/dist/src/serialization.d.ts +1 -16
- package/dist/src/serialization.js +5 -55
- package/dist/src/stream-write.d.ts +12 -0
- package/dist/src/stream-write.js +91 -0
- package/dist/src/stream-write.test.d.ts +1 -0
- package/dist/src/stream-write.test.js +186 -0
- package/dist/src/types.d.ts +5 -0
- package/dist/src/utils.d.ts +1 -1
- package/dist/src/utils.js +2 -8
- package/dist/src/utils.test.js +50 -0
- package/hosted-contract.json +36748 -0
- package/package.json +6 -1
- package/scripts/check-hosted-contract.mjs +108 -0
- package/scripts/docs-sync-review.ts +332 -0
- package/scripts/generate-release-notes.ts +1 -1
- package/scripts/sync-community-plugins.ts +14 -0
- package/skills/smart-search/SKILL.md +14 -3
- package/skills/webcmd-autofix/SKILL.md +26 -37
- package/skills/webcmd-usage/SKILL.md +21 -7
- package/clis/antigravity/SKILL.md +0 -38
package/clis/twitter/article.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
66
|
+
const rawResult = unwrapBrowserResult(await page.evaluate(`
|
|
61
67
|
async () => {
|
|
62
|
-
const 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
|
-
|
|
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
|
-
|
|
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
|
|
103
|
-
if (!result)
|
|
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
|
|
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 (
|
|
163
|
-
throw new CommandExecutionError(
|
|
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 (
|
|
166
|
-
throw new CommandExecutionError(
|
|
222
|
+
if (!Array.isArray(rawResult)) {
|
|
223
|
+
throw new CommandExecutionError('Twitter article response payload is malformed');
|
|
167
224
|
}
|
|
168
|
-
return
|
|
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
|
+
});
|
package/clis/twitter/download.js
CHANGED
|
@@ -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
|
-
{
|
|
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) => {
|
package/clis/twitter/post.js
CHANGED
|
@@ -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
|
-
{
|
|
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) => {
|
package/clis/twitter/profile.js
CHANGED
|
@@ -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 =
|
|
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: {
|
package/clis/twitter/quote.js
CHANGED
|
@@ -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
|
-
{
|
|
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'],
|
package/clis/twitter/reply.js
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
|
|
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
|
|
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) {
|