@jackwener/opencli 1.7.12 → 1.7.14
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 +8 -7
- package/README.zh-CN.md +9 -8
- package/cli-manifest.json +12128 -6665
- package/clis/1point3acres/digest.js +35 -0
- package/clis/1point3acres/forum.js +51 -0
- package/clis/1point3acres/forums.js +44 -0
- package/clis/1point3acres/hot.js +35 -0
- package/clis/1point3acres/latest.js +35 -0
- package/clis/1point3acres/notifications.js +64 -0
- package/clis/1point3acres/search.js +71 -0
- package/clis/1point3acres/thread.js +117 -0
- package/clis/1point3acres/user.js +77 -0
- package/clis/1point3acres/utils.js +247 -0
- package/clis/_shared/desktop-commands.js +4 -0
- package/clis/aibase/news.js +110 -0
- package/clis/aibase/news.test.js +59 -0
- package/clis/amazon/discussion.test.js +1 -28
- package/clis/antigravity/watch.js +3 -2
- package/clis/arxiv/author.js +44 -0
- package/clis/baidu-scholar/search.js +0 -1
- package/clis/bbc/topic.js +57 -0
- package/clis/bbc/utils.js +79 -0
- package/clis/chaoxing/assignments.js +1 -1
- package/clis/chaoxing/exams.js +1 -1
- package/clis/chatgpt/ask.js +57 -0
- package/clis/chatgpt/commands.test.js +45 -0
- package/clis/chatgpt/detail.js +46 -0
- package/clis/chatgpt/history.js +39 -0
- package/clis/chatgpt/image.js +12 -11
- package/clis/chatgpt/image.test.js +23 -0
- package/clis/chatgpt/new.js +25 -0
- package/clis/chatgpt/read.js +43 -0
- package/clis/chatgpt/send.js +46 -0
- package/clis/chatgpt/status.js +29 -0
- package/clis/chatgpt/utils.js +294 -4
- package/clis/chatgpt/utils.test.js +13 -0
- package/clis/chatgpt-app/ask.js +6 -3
- package/clis/chatwise/ask.js +16 -43
- package/clis/chatwise/composer.test.js +186 -0
- package/clis/chatwise/send.js +2 -24
- package/clis/chatwise/utils.js +143 -0
- package/clis/claude/ask.js +1 -1
- package/clis/claude/detail.js +1 -0
- package/clis/claude/history.js +1 -0
- package/clis/claude/new.js +1 -0
- package/clis/claude/read.js +1 -0
- package/clis/claude/send.js +1 -0
- package/clis/claude/status.js +1 -0
- package/clis/codex/ask.js +15 -9
- package/clis/codex/history.js +16 -33
- package/clis/codex/projects.js +28 -0
- package/clis/codex/read.js +10 -4
- package/clis/codex/send.js +10 -3
- package/clis/codex/sidebar.js +356 -0
- package/clis/codex/sidebar.test.js +329 -0
- package/clis/coingecko/categories.js +75 -0
- package/clis/coingecko/coin.js +107 -0
- package/clis/coingecko/coingecko.test.js +109 -0
- package/clis/coingecko/derivatives.js +84 -0
- package/clis/coingecko/exchanges.js +74 -0
- package/clis/coingecko/global.js +71 -0
- package/clis/coingecko/top.js +64 -0
- package/clis/coingecko/trending.js +55 -0
- package/clis/coupang/add-to-cart.js +21 -13
- package/clis/coupang/coupang.test.js +159 -0
- package/clis/coupang/product.js +257 -0
- package/clis/coupang/search.js +38 -16
- package/clis/coupang/utils.js +55 -1
- package/clis/crates/crate.js +62 -0
- package/clis/crates/search.js +44 -0
- package/clis/crates/utils.js +72 -0
- package/clis/ctrip/ctrip.test.js +234 -0
- package/clis/ctrip/hotel-suggest.js +45 -0
- package/clis/ctrip/search.js +22 -68
- package/clis/ctrip/utils.js +175 -0
- package/clis/cursor/ask.js +6 -3
- package/clis/dblp/author.js +133 -0
- package/clis/dblp/venue.js +64 -0
- package/clis/deepseek/ask.js +12 -7
- package/clis/deepseek/ask.test.js +13 -13
- package/clis/deepseek/detail.js +38 -0
- package/clis/deepseek/detail.test.js +81 -0
- package/clis/deepseek/history.js +1 -0
- package/clis/deepseek/new.js +1 -0
- package/clis/deepseek/read.js +1 -0
- package/clis/deepseek/send.js +140 -0
- package/clis/deepseek/send.test.js +107 -0
- package/clis/deepseek/status.js +1 -0
- package/clis/deepseek/utils.js +66 -0
- package/clis/deepseek/utils.test.js +107 -1
- package/clis/defillama/defillama.test.js +99 -0
- package/clis/defillama/protocol.js +84 -0
- package/clis/defillama/protocols.js +55 -0
- package/clis/defillama/utils.js +99 -0
- package/clis/devto/latest.js +74 -0
- package/clis/dockerhub/image.js +52 -0
- package/clis/dockerhub/search.js +47 -0
- package/clis/dockerhub/utils.js +100 -0
- package/clis/doubao/ask.js +7 -3
- package/clis/doubao/detail.js +1 -0
- package/clis/doubao/history.js +1 -0
- package/clis/doubao/meeting-summary.js +1 -0
- package/clis/doubao/meeting-transcript.js +1 -0
- package/clis/doubao/new.js +1 -0
- package/clis/doubao/read.js +1 -0
- package/clis/doubao/send.js +1 -0
- package/clis/doubao/status.js +1 -0
- package/clis/douyin/draft.test.js +1 -30
- package/clis/endoflife/endoflife.test.js +51 -0
- package/clis/endoflife/product.js +55 -0
- package/clis/endoflife/utils.js +89 -0
- package/clis/facebook/__fixtures__/notifications-page.html +13 -0
- package/clis/facebook/notifications.js +326 -30
- package/clis/facebook/notifications.test.js +458 -0
- package/clis/flathub/app.js +71 -0
- package/clis/flathub/flathub.test.js +90 -0
- package/clis/flathub/search.js +80 -0
- package/clis/flathub/utils.js +114 -0
- package/clis/gemini/ask.js +7 -3
- package/clis/gemini/ask.test.js +2 -2
- package/clis/gemini/deep-research-result.js +6 -2
- package/clis/gemini/deep-research-result.test.js +15 -14
- package/clis/gemini/deep-research.js +8 -4
- package/clis/gemini/deep-research.test.js +15 -18
- package/clis/gemini/image.js +7 -2
- package/clis/gemini/new.js +1 -0
- package/clis/gemini/utils.js +0 -4
- package/clis/google-scholar/cite.js +0 -1
- package/clis/google-scholar/profile.js +0 -1
- package/clis/google-scholar/search.js +0 -1
- package/clis/goproxy/goproxy.test.js +103 -0
- package/clis/goproxy/module.js +47 -0
- package/clis/goproxy/utils.js +165 -0
- package/clis/goproxy/versions.js +59 -0
- package/clis/gov-law/recent.js +0 -1
- package/clis/gov-law/search.js +0 -1
- package/clis/gov-policy/__fixtures__/recent.html +16 -0
- package/clis/gov-policy/__fixtures__/search.html +41 -0
- package/clis/gov-policy/gov-policy.test.js +224 -0
- package/clis/gov-policy/recent.js +66 -24
- package/clis/gov-policy/search.js +65 -23
- package/clis/gov-policy/utils.js +54 -0
- package/clis/grok/ask.js +49 -265
- package/clis/grok/ask.test.js +21 -46
- package/clis/grok/detail.js +60 -0
- package/clis/grok/history.js +48 -0
- package/clis/grok/{image.ts → image.js} +56 -70
- package/clis/grok/image.test.ts +20 -0
- package/clis/grok/new.js +20 -0
- package/clis/grok/read.js +39 -0
- package/clis/grok/send.js +50 -0
- package/clis/grok/status.js +41 -0
- package/clis/grok/utils.js +326 -0
- package/clis/grok/utils.test.js +103 -0
- package/clis/hf/datasets.js +88 -0
- package/clis/hf/hf.test.js +16 -0
- package/clis/hf/models.js +91 -0
- package/clis/hf/paper.js +79 -0
- package/clis/hf/spaces.js +101 -0
- package/clis/hf/top.js +1 -0
- package/clis/homebrew/cask.js +39 -0
- package/clis/homebrew/formula.js +41 -0
- package/clis/homebrew/popular.js +54 -0
- package/clis/homebrew/utils.js +100 -0
- package/clis/hupu/__fixtures__/hot-home.html +64 -0
- package/clis/hupu/detail.js +0 -1
- package/clis/hupu/hot.js +156 -35
- package/clis/hupu/hot.test.js +224 -0
- package/clis/hupu/search.js +0 -1
- package/clis/instagram/note.js +1 -1
- package/clis/instagram/note.test.js +1 -29
- package/clis/instagram/post.js +1 -1
- package/clis/instagram/post.test.js +1 -1
- package/clis/instagram/reel.js +1 -1
- package/clis/instagram/story.js +1 -1
- package/clis/instagram/story.test.js +1 -34
- package/clis/jd/commands.test.js +1 -24
- package/clis/lichess/lichess.test.js +85 -0
- package/clis/lichess/top.js +46 -0
- package/clis/lichess/user.js +91 -0
- package/clis/lichess/utils.js +97 -0
- package/clis/linkedin/search.js +107 -10
- package/clis/linkedin/search.test.js +222 -0
- package/clis/linux-do/feed.js +2 -5
- package/clis/linux-do/feed.test.js +35 -0
- package/clis/lobsters/domain.js +92 -0
- package/clis/maven/artifact.js +49 -0
- package/clis/maven/search.js +51 -0
- package/clis/maven/utils.js +110 -0
- package/clis/mdn/search.js +97 -0
- package/clis/medium/tag.js +135 -0
- package/clis/npm/downloads.js +59 -0
- package/clis/npm/package.js +70 -0
- package/clis/npm/search.js +49 -0
- package/clis/npm/utils.js +76 -0
- package/clis/nuget/nuget.test.js +111 -0
- package/clis/nuget/package.js +101 -0
- package/clis/nuget/search.js +69 -0
- package/clis/nuget/utils.js +87 -0
- package/clis/nvd/cve.js +121 -0
- package/clis/oeis/oeis.test.js +88 -0
- package/clis/oeis/search.js +63 -0
- package/clis/oeis/sequence.js +71 -0
- package/clis/oeis/utils.js +88 -0
- package/clis/openalex/search.js +69 -0
- package/clis/openalex/utils.js +160 -0
- package/clis/openalex/work.js +65 -0
- package/clis/openfda/drug-label.js +74 -0
- package/clis/openfda/food-recall.js +65 -0
- package/clis/openfda/openfda.test.js +114 -0
- package/clis/openfda/utils.js +67 -0
- package/clis/osv/osv.test.js +97 -0
- package/clis/osv/query.js +72 -0
- package/clis/osv/utils.js +169 -0
- package/clis/osv/vulnerability.js +54 -0
- package/clis/packagist/package.js +49 -0
- package/clis/packagist/search.js +43 -0
- package/clis/packagist/utils.js +113 -0
- package/clis/paperreview/feedback.js +1 -1
- package/clis/paperreview/review.js +1 -1
- package/clis/paperreview/submit.js +1 -1
- package/clis/pixiv/download.test.js +1 -1
- package/clis/pixiv/illusts.test.js +1 -1
- package/clis/pixiv/search.test.js +1 -1
- package/clis/pubmed/article.js +50 -0
- package/clis/pubmed/author.js +64 -0
- package/clis/pubmed/citations.js +36 -0
- package/clis/pubmed/pubmed.test.js +276 -0
- package/clis/pubmed/related.js +45 -0
- package/clis/pubmed/search.js +75 -0
- package/clis/pubmed/utils.js +309 -0
- package/clis/pypi/downloads.js +66 -0
- package/clis/pypi/package.js +79 -0
- package/clis/pypi/utils.js +55 -0
- package/clis/quark/mv.js +1 -1
- package/clis/quark/save.js +1 -1
- package/clis/qwen/ask.js +85 -0
- package/clis/qwen/detail.js +62 -0
- package/clis/qwen/history.js +61 -0
- package/clis/qwen/image.js +179 -0
- package/clis/qwen/new.js +23 -0
- package/clis/qwen/read.js +41 -0
- package/clis/qwen/send.js +55 -0
- package/clis/qwen/status.js +37 -0
- package/clis/qwen/utils.js +409 -0
- package/clis/qwen/utils.test.js +45 -0
- package/clis/rest-countries/country.js +65 -0
- package/clis/rest-countries/region.js +64 -0
- package/clis/rest-countries/rest-countries.test.js +83 -0
- package/clis/rest-countries/utils.js +126 -0
- package/clis/reuters/article-detail.js +53 -0
- package/clis/reuters/reuters.test.js +299 -0
- package/clis/reuters/search.js +45 -34
- package/clis/reuters/utils.js +159 -0
- package/clis/rfc/rfc.js +52 -0
- package/clis/rfc/rfc.test.js +74 -0
- package/clis/rfc/utils.js +72 -0
- package/clis/rubygems/gem.js +42 -0
- package/clis/rubygems/search.js +47 -0
- package/clis/rubygems/utils.js +86 -0
- package/clis/stackoverflow/related.js +66 -0
- package/clis/stackoverflow/stackoverflow.test.js +58 -0
- package/clis/stackoverflow/tag.js +60 -0
- package/clis/stackoverflow/user.js +50 -0
- package/clis/stackoverflow/utils.js +118 -0
- package/clis/steam/app.js +67 -0
- package/clis/steam/search.js +58 -0
- package/clis/steam/steam.test.js +46 -0
- package/clis/steam/utils.js +107 -0
- package/clis/taobao/commands.test.js +1 -24
- package/clis/test-utils.js +61 -0
- package/clis/tieba/hot.js +0 -1
- package/clis/tiktok/comment.js +128 -41
- package/clis/tiktok/creator-videos.js +270 -0
- package/clis/tiktok/creator-videos.test.js +113 -0
- package/clis/tiktok/explore.js +137 -29
- package/clis/tiktok/follow.js +115 -33
- package/clis/tiktok/following.js +157 -36
- package/clis/tiktok/friends.js +139 -37
- package/clis/tiktok/live.js +137 -41
- package/clis/tiktok/notifications.js +141 -38
- package/clis/tiktok/refactor.test.js +389 -0
- package/clis/tiktok/unfollow.js +124 -38
- package/clis/tiktok/user.js +203 -29
- package/clis/tiktok/utils.js +505 -0
- package/clis/tiktok/write-refactor.test.js +370 -0
- package/clis/toutiao/articles.js +36 -62
- package/clis/toutiao/hot.js +63 -0
- package/clis/toutiao/toutiao.test.js +378 -0
- package/clis/toutiao/utils.js +161 -0
- package/clis/tvmaze/search.js +61 -0
- package/clis/tvmaze/show.js +60 -0
- package/clis/tvmaze/tvmaze.test.js +93 -0
- package/clis/tvmaze/utils.js +110 -0
- package/clis/twitter/accept.js +1 -1
- package/clis/twitter/followers.js +134 -69
- package/clis/twitter/quote.js +139 -0
- package/clis/twitter/quote.test.js +106 -0
- package/clis/twitter/reply-dm.js +1 -1
- package/clis/twitter/reply.test.js +1 -29
- package/clis/twitter/retweet.js +99 -0
- package/clis/twitter/retweet.test.js +69 -0
- package/clis/twitter/shared.js +38 -0
- package/clis/twitter/shared.test.js +28 -1
- package/clis/twitter/unlike.js +87 -0
- package/clis/twitter/unlike.test.js +72 -0
- package/clis/twitter/unretweet.js +99 -0
- package/clis/twitter/unretweet.test.js +69 -0
- package/clis/uisdc/news.js +105 -0
- package/clis/uisdc/news.test.js +66 -0
- package/clis/wanfang/search.js +0 -1
- package/clis/web/read.js +47 -17
- package/clis/web/read.test.js +101 -1
- package/clis/weixin/create-draft.js +1 -1
- package/clis/weixin/drafts.js +1 -1
- package/clis/weixin/drafts.test.js +5 -1
- package/clis/weixin/search.js +157 -0
- package/clis/weixin/search.test.js +227 -0
- package/clis/wikidata/entity.js +60 -0
- package/clis/wikidata/search.js +50 -0
- package/clis/wikidata/utils.js +117 -0
- package/clis/wikidata/wikidata.test.js +83 -0
- package/clis/wikipedia/page.js +95 -0
- package/clis/wttr/current.js +63 -0
- package/clis/wttr/forecast.js +71 -0
- package/clis/wttr/utils.js +50 -0
- package/clis/wttr/wttr.test.js +84 -0
- package/clis/xianyu/chat.js +16 -4
- package/clis/xianyu/chat.test.js +64 -0
- package/clis/xianyu/publish.js +485 -0
- package/clis/xianyu/publish.test.js +220 -0
- package/clis/xiaoe/catalog.js +105 -40
- package/clis/xiaoe/content.js +164 -29
- package/clis/xiaoe/courses.js +86 -29
- package/clis/xiaoe/xiaoe.test.js +486 -0
- package/clis/xiaohongshu/creator-notes-summary.js +1 -1
- package/clis/xiaohongshu/publish.js +16 -3
- package/clis/xiaohongshu/publish.test.js +46 -1
- package/clis/youtube/transcript.js +13 -19
- package/clis/youtube/transcript.test.js +17 -0
- package/clis/yuanbao/ask.js +17 -66
- package/clis/yuanbao/ask.test.js +5 -5
- package/clis/yuanbao/detail.js +65 -0
- package/clis/yuanbao/history.js +51 -0
- package/clis/yuanbao/new.js +1 -0
- package/clis/yuanbao/read.js +38 -0
- package/clis/yuanbao/send.js +57 -0
- package/clis/yuanbao/shared.js +297 -5
- package/clis/yuanbao/shared.test.js +80 -0
- package/clis/yuanbao/status.js +44 -0
- package/clis/zlibrary/commands.test.js +1 -11
- package/dist/src/browser/base-page.d.ts +9 -0
- package/dist/src/browser/base-page.js +44 -1
- package/dist/src/browser/base-page.test.js +66 -0
- package/dist/src/browser/bridge.js +47 -45
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/cdp.js +51 -9
- package/dist/src/browser/daemon-client.d.ts +4 -0
- package/dist/src/browser/errors.js +1 -1
- package/dist/src/browser/page.d.ts +1 -1
- package/dist/src/browser/page.js +3 -1
- package/dist/src/browser/page.test.js +29 -0
- package/dist/src/browser/target-errors.d.ts +2 -1
- package/dist/src/browser/target-errors.js +1 -0
- package/dist/src/browser/target-resolver.d.ts +25 -0
- package/dist/src/browser/target-resolver.js +43 -0
- package/dist/src/browser.test.js +18 -0
- package/dist/src/build-manifest.js +9 -4
- package/dist/src/build-manifest.test.js +2 -8
- package/dist/src/capabilityRouting.d.ts +16 -1
- package/dist/src/capabilityRouting.js +24 -1
- package/dist/src/capabilityRouting.test.js +19 -1
- package/dist/src/cli.js +76 -11
- package/dist/src/cli.test.js +241 -1
- package/dist/src/commanderAdapter.js +23 -9
- package/dist/src/commanderAdapter.test.js +0 -1
- package/dist/src/discovery.js +2 -5
- package/dist/src/errors.js +1 -1
- package/dist/src/execution.d.ts +1 -1
- package/dist/src/execution.js +111 -27
- package/dist/src/execution.test.js +326 -17
- package/dist/src/help.d.ts +27 -2
- package/dist/src/help.js +196 -23
- package/dist/src/help.test.d.ts +1 -0
- package/dist/src/help.test.js +54 -0
- package/dist/src/main.js +14 -1
- package/dist/src/manifest-types.d.ts +5 -3
- package/dist/src/pipeline/executor.js +1 -1
- package/dist/src/pipeline/executor.test.js +8 -0
- package/dist/src/pipeline/registry.d.ts +9 -0
- package/dist/src/pipeline/registry.js +13 -1
- package/dist/src/pipeline/steps/browser.d.ts +1 -0
- package/dist/src/pipeline/steps/browser.js +10 -0
- package/dist/src/pipeline/steps/download.test.js +1 -0
- package/dist/src/registry-api.d.ts +1 -1
- package/dist/src/registry.d.ts +12 -11
- package/dist/src/registry.js +16 -6
- package/dist/src/registry.test.js +2 -2
- package/dist/src/runtime.d.ts +2 -1
- package/dist/src/runtime.js +1 -1
- package/dist/src/serialization.d.ts +2 -2
- package/dist/src/serialization.js +4 -6
- package/dist/src/serialization.test.js +17 -0
- package/dist/src/types.d.ts +17 -0
- package/dist/src/validate.js +15 -11
- package/dist/src/validate.test.d.ts +9 -0
- package/dist/src/validate.test.js +90 -0
- package/package.json +1 -1
- package/scripts/fetch-adapters.js +1 -1
- package/scripts/typed-error-lint-baseline.json +5 -77
- package/clis/ctrip/search.test.js +0 -64
- package/clis/gov-policy/commands.test.js +0 -27
- package/clis/linux-do/category.js +0 -37
- package/clis/linux-do/hot.js +0 -26
- package/clis/linux-do/latest.js +0 -19
- package/clis/pixiv/test-utils.js +0 -23
- package/clis/toutiao/articles.test.js +0 -30
- package/dist/src/analysis.d.ts +0 -40
- package/dist/src/analysis.js +0 -172
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
4
|
+
import { __test__ } from './quote.js';
|
|
5
|
+
import './quote.js';
|
|
6
|
+
import { createPageMock } from '../test-utils.js';
|
|
7
|
+
|
|
8
|
+
describe('twitter quote helpers', () => {
|
|
9
|
+
it('extracts tweet ids from both user and i/status URLs', () => {
|
|
10
|
+
expect(__test__.extractTweetId('https://x.com/alice/status/2040254679301718161?s=20')).toBe('2040254679301718161');
|
|
11
|
+
expect(__test__.extractTweetId('https://x.com/i/status/2040318731105313143')).toBe('2040318731105313143');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('builds the quote composer URL with the source tweet attached as ?url=...', () => {
|
|
15
|
+
const composeUrl = __test__.buildQuoteComposerUrl('https://x.com/alice/status/2040254679301718161?s=20');
|
|
16
|
+
// The full source URL is round-tripped via encodeURIComponent — decoding it
|
|
17
|
+
// back must yield the original URL. This guards against accidental drops of
|
|
18
|
+
// query parameters or fragment characters in future refactors.
|
|
19
|
+
const parsed = new URL(composeUrl);
|
|
20
|
+
expect(parsed.origin + parsed.pathname).toBe('https://x.com/compose/post');
|
|
21
|
+
expect(parsed.searchParams.get('url')).toBe('https://x.com/alice/status/2040254679301718161?s=20');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('rejects malformed URLs before any browser interaction', () => {
|
|
25
|
+
expect(() => __test__.buildQuoteComposerUrl('https://x.com/alice/home')).toThrow(/Could not extract tweet ID/);
|
|
26
|
+
expect(() => __test__.buildQuoteComposerUrl('not a url')).toThrow(/Invalid tweet URL/);
|
|
27
|
+
expect(() => __test__.buildQuoteComposerUrl('https://evil.com/?next=https://x.com/alice/status/2040254679301718161')).toThrow(ArgumentError);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('twitter quote command', () => {
|
|
32
|
+
it('navigates to the quote composer and reports success when the script confirms', async () => {
|
|
33
|
+
const cmd = getRegistry().get('twitter/quote');
|
|
34
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
35
|
+
const page = createPageMock([
|
|
36
|
+
{ ok: true, message: 'Quote tweet posted successfully.' },
|
|
37
|
+
]);
|
|
38
|
+
const result = await cmd.func(page, {
|
|
39
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
40
|
+
text: 'great take',
|
|
41
|
+
});
|
|
42
|
+
expect(page.goto).toHaveBeenCalledWith(
|
|
43
|
+
'https://x.com/compose/post?url=https%3A%2F%2Fx.com%2Falice%2Fstatus%2F2040254679301718161',
|
|
44
|
+
{ waitUntil: 'load', settleMs: 2500 },
|
|
45
|
+
);
|
|
46
|
+
expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
|
|
47
|
+
expect(page.wait).toHaveBeenNthCalledWith(2, 3);
|
|
48
|
+
const script = page.evaluate.mock.calls[0][0];
|
|
49
|
+
// Quote-attachment guard: the script must verify the quoted card rendered
|
|
50
|
+
// before submitting; otherwise we'd silently post a plain tweet without
|
|
51
|
+
// the quote attachment.
|
|
52
|
+
expect(script).toContain('Quote target did not render');
|
|
53
|
+
expect(script).toContain('document.execCommand');
|
|
54
|
+
expect(script).toContain('tweetButton');
|
|
55
|
+
expect(script).toContain('getStatusId(link.href) === tweetId');
|
|
56
|
+
expect(script).toContain('Quote tweet submission did not complete before timeout');
|
|
57
|
+
expect(script).toContain('[role="alert"], [data-testid="toast"]');
|
|
58
|
+
expect(result).toEqual([
|
|
59
|
+
{
|
|
60
|
+
status: 'success',
|
|
61
|
+
message: 'Quote tweet posted successfully.',
|
|
62
|
+
text: 'great take',
|
|
63
|
+
},
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns a failed row when the quote target fails to render', async () => {
|
|
68
|
+
const cmd = getRegistry().get('twitter/quote');
|
|
69
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
70
|
+
const page = createPageMock([
|
|
71
|
+
{ ok: false, message: 'Quote target did not render in the composer. The source tweet may be deleted or restricted.' },
|
|
72
|
+
]);
|
|
73
|
+
const result = await cmd.func(page, {
|
|
74
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
75
|
+
text: 'orphaned quote',
|
|
76
|
+
});
|
|
77
|
+
expect(result).toEqual([
|
|
78
|
+
{
|
|
79
|
+
status: 'failed',
|
|
80
|
+
message: 'Quote target did not render in the composer. The source tweet may be deleted or restricted.',
|
|
81
|
+
text: 'orphaned quote',
|
|
82
|
+
},
|
|
83
|
+
]);
|
|
84
|
+
// Only the textarea wait should run when ok is false (no extra 3s post-submit wait).
|
|
85
|
+
expect(page.wait).toHaveBeenCalledTimes(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('throws CommandExecutionError when no page is provided', async () => {
|
|
89
|
+
const cmd = getRegistry().get('twitter/quote');
|
|
90
|
+
await expect(cmd.func(undefined, {
|
|
91
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
92
|
+
text: 'hi',
|
|
93
|
+
})).rejects.toThrow(CommandExecutionError);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('rejects invalid tweet URLs before navigation', async () => {
|
|
97
|
+
const cmd = getRegistry().get('twitter/quote');
|
|
98
|
+
const page = createPageMock([]);
|
|
99
|
+
await expect(cmd.func(page, {
|
|
100
|
+
url: 'https://x.com.evil.com/alice/status/2040254679301718161',
|
|
101
|
+
text: 'hi',
|
|
102
|
+
})).rejects.toThrow(ArgumentError);
|
|
103
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
104
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
});
|
package/clis/twitter/reply-dm.js
CHANGED
|
@@ -8,11 +8,11 @@ cli({
|
|
|
8
8
|
domain: 'x.com',
|
|
9
9
|
strategy: Strategy.UI,
|
|
10
10
|
browser: true,
|
|
11
|
-
timeoutSeconds: 600, // 10 min — batch operation
|
|
12
11
|
args: [
|
|
13
12
|
{ name: 'text', type: 'string', required: true, positional: true, help: 'Message text to send (e.g. "我的微信 wxkabi")' },
|
|
14
13
|
{ name: 'max', type: 'int', required: false, default: 20, help: 'Maximum number of conversations to reply to (default: 20)' },
|
|
15
14
|
{ name: 'skip-replied', type: 'boolean', required: false, default: true, help: 'Skip conversations where you already sent the same text (default: true)' },
|
|
15
|
+
{ name: 'timeout', type: 'int', required: false, default: 600, help: 'Max seconds for the overall command (default: 600 — batch op)' },
|
|
16
16
|
],
|
|
17
17
|
columns: ['index', 'status', 'user', 'message'],
|
|
18
18
|
func: async (page, kwargs) => {
|
|
@@ -4,35 +4,7 @@ import * as path from 'node:path';
|
|
|
4
4
|
import { describe, expect, it, vi } from 'vitest';
|
|
5
5
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
6
6
|
import { __test__ } from './reply.js';
|
|
7
|
-
|
|
8
|
-
const evaluate = vi.fn();
|
|
9
|
-
for (const result of evaluateResults) {
|
|
10
|
-
evaluate.mockResolvedValueOnce(result);
|
|
11
|
-
}
|
|
12
|
-
return {
|
|
13
|
-
goto: vi.fn().mockResolvedValue(undefined),
|
|
14
|
-
evaluate,
|
|
15
|
-
snapshot: vi.fn().mockResolvedValue(undefined),
|
|
16
|
-
click: vi.fn().mockResolvedValue(undefined),
|
|
17
|
-
typeText: vi.fn().mockResolvedValue(undefined),
|
|
18
|
-
pressKey: vi.fn().mockResolvedValue(undefined),
|
|
19
|
-
scrollTo: vi.fn().mockResolvedValue(undefined),
|
|
20
|
-
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
|
|
21
|
-
wait: vi.fn().mockResolvedValue(undefined),
|
|
22
|
-
tabs: vi.fn().mockResolvedValue([]),
|
|
23
|
-
selectTab: vi.fn().mockResolvedValue(undefined),
|
|
24
|
-
networkRequests: vi.fn().mockResolvedValue([]),
|
|
25
|
-
consoleMessages: vi.fn().mockResolvedValue([]),
|
|
26
|
-
scroll: vi.fn().mockResolvedValue(undefined),
|
|
27
|
-
autoScroll: vi.fn().mockResolvedValue(undefined),
|
|
28
|
-
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
|
29
|
-
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
|
30
|
-
getCookies: vi.fn().mockResolvedValue([]),
|
|
31
|
-
screenshot: vi.fn().mockResolvedValue(''),
|
|
32
|
-
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
|
33
|
-
...overrides,
|
|
34
|
-
};
|
|
35
|
-
}
|
|
7
|
+
import { createPageMock } from '../test-utils.js';
|
|
36
8
|
describe('twitter reply command', () => {
|
|
37
9
|
it('uses the dedicated reply composer for text-only replies too', async () => {
|
|
38
10
|
const cmd = getRegistry().get('twitter/reply');
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
import { parseTweetUrl } from './shared.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'twitter',
|
|
7
|
+
name: 'retweet',
|
|
8
|
+
access: 'write',
|
|
9
|
+
description: 'Retweet a specific tweet',
|
|
10
|
+
domain: 'x.com',
|
|
11
|
+
strategy: Strategy.UI,
|
|
12
|
+
browser: true,
|
|
13
|
+
args: [
|
|
14
|
+
{ name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to retweet' },
|
|
15
|
+
],
|
|
16
|
+
columns: ['status', 'message'],
|
|
17
|
+
func: async (page, kwargs) => {
|
|
18
|
+
if (!page)
|
|
19
|
+
throw new CommandExecutionError('Browser session required for twitter retweet');
|
|
20
|
+
const target = parseTweetUrl(kwargs.url);
|
|
21
|
+
await page.goto(target.url);
|
|
22
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
23
|
+
const result = await page.evaluate(`(async () => {
|
|
24
|
+
try {
|
|
25
|
+
const tweetId = ${JSON.stringify(target.id)};
|
|
26
|
+
const findTargetArticle = () => Array.from(document.querySelectorAll('article')).find((article) =>
|
|
27
|
+
Array.from(article.querySelectorAll('a[href*="/status/"]')).some((link) => {
|
|
28
|
+
try {
|
|
29
|
+
const match = new URL(link.href, window.location.origin).pathname.match(/^\/(?:[^/]+|i)\/status\/(\d+)\/?$/);
|
|
30
|
+
return match?.[1] === tweetId;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
);
|
|
36
|
+
// Poll for the tweet to render
|
|
37
|
+
let attempts = 0;
|
|
38
|
+
let retweetBtn = null;
|
|
39
|
+
let unretweetBtn = null;
|
|
40
|
+
let targetArticle = null;
|
|
41
|
+
|
|
42
|
+
while (attempts < 20) {
|
|
43
|
+
targetArticle = findTargetArticle();
|
|
44
|
+
unretweetBtn = targetArticle?.querySelector('[data-testid="unretweet"]') || null;
|
|
45
|
+
retweetBtn = targetArticle?.querySelector('[data-testid="retweet"]') || null;
|
|
46
|
+
|
|
47
|
+
if (unretweetBtn || retweetBtn) break;
|
|
48
|
+
|
|
49
|
+
await new Promise(r => setTimeout(r, 500));
|
|
50
|
+
attempts++;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Already retweeted: idempotent success
|
|
54
|
+
if (unretweetBtn) {
|
|
55
|
+
return { ok: true, message: 'Tweet is already retweeted.' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!retweetBtn) {
|
|
59
|
+
return { ok: false, message: 'Could not find the Retweet button on this tweet after waiting 10 seconds. Are you logged in?' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Step 1: click Retweet button → opens menu
|
|
63
|
+
retweetBtn.click();
|
|
64
|
+
|
|
65
|
+
// Step 2: wait for the confirm menu item to appear, then click it
|
|
66
|
+
let confirmBtn = null;
|
|
67
|
+
for (let i = 0; i < 20; i++) {
|
|
68
|
+
await new Promise(r => setTimeout(r, 250));
|
|
69
|
+
confirmBtn = document.querySelector('[data-testid="retweetConfirm"]');
|
|
70
|
+
if (confirmBtn) break;
|
|
71
|
+
}
|
|
72
|
+
if (!confirmBtn) {
|
|
73
|
+
return { ok: false, message: 'Retweet menu opened but the confirm option did not appear.' };
|
|
74
|
+
}
|
|
75
|
+
confirmBtn.click();
|
|
76
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
77
|
+
|
|
78
|
+
// Verify success by checking if the 'unretweet' button appeared
|
|
79
|
+
const verifyArticle = findTargetArticle() || targetArticle;
|
|
80
|
+
const verifyBtn = verifyArticle?.querySelector('[data-testid="unretweet"]');
|
|
81
|
+
if (verifyBtn) {
|
|
82
|
+
return { ok: true, message: 'Tweet successfully retweeted.' };
|
|
83
|
+
} else {
|
|
84
|
+
return { ok: false, message: 'Retweet action was initiated but UI did not update as expected.' };
|
|
85
|
+
}
|
|
86
|
+
} catch (e) {
|
|
87
|
+
return { ok: false, message: e.toString() };
|
|
88
|
+
}
|
|
89
|
+
})()`);
|
|
90
|
+
if (result.ok) {
|
|
91
|
+
// Wait for the retweet network request to be processed
|
|
92
|
+
await page.wait(2);
|
|
93
|
+
}
|
|
94
|
+
return [{
|
|
95
|
+
status: result.ok ? 'success' : 'failed',
|
|
96
|
+
message: result.message
|
|
97
|
+
}];
|
|
98
|
+
}
|
|
99
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
expect(script).toContain("document.querySelectorAll('article')");
|
|
28
|
+
expect(script).toContain('match?.[1] === tweetId');
|
|
29
|
+
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"retweet\"]')");
|
|
30
|
+
// Idempotency probe: when already retweeted ([data-testid="unretweet"] present),
|
|
31
|
+
// the script returns ok:true with an "already retweeted" message.
|
|
32
|
+
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"unretweet\"]')");
|
|
33
|
+
expect(result).toEqual([
|
|
34
|
+
{ status: 'success', message: 'Tweet successfully retweeted.' },
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns a failed row when the confirm menu item never appears', async () => {
|
|
39
|
+
const cmd = getRegistry().get('twitter/retweet');
|
|
40
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
41
|
+
const page = createPageMock([
|
|
42
|
+
{ ok: false, message: 'Retweet menu opened but the confirm option did not appear.' },
|
|
43
|
+
]);
|
|
44
|
+
const result = await cmd.func(page, {
|
|
45
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
46
|
+
});
|
|
47
|
+
expect(result).toEqual([
|
|
48
|
+
{ status: 'failed', message: 'Retweet menu opened but the confirm option did not appear.' },
|
|
49
|
+
]);
|
|
50
|
+
expect(page.wait).toHaveBeenCalledTimes(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('throws CommandExecutionError when no page is provided', async () => {
|
|
54
|
+
const cmd = getRegistry().get('twitter/retweet');
|
|
55
|
+
await expect(cmd.func(undefined, {
|
|
56
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
57
|
+
})).rejects.toThrow(CommandExecutionError);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('rejects invalid tweet URLs before navigation', async () => {
|
|
61
|
+
const cmd = getRegistry().get('twitter/retweet');
|
|
62
|
+
const page = createPageMock([]);
|
|
63
|
+
await expect(cmd.func(page, {
|
|
64
|
+
url: 'https://evil.com/?next=https://x.com/alice/status/2040254679301718161',
|
|
65
|
+
})).rejects.toThrow(ArgumentError);
|
|
66
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
67
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
});
|
package/clis/twitter/shared.js
CHANGED
|
@@ -1,4 +1,41 @@
|
|
|
1
|
+
import { ArgumentError } from '@jackwener/opencli/errors';
|
|
2
|
+
|
|
1
3
|
const QUERY_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
4
|
+
const TWEET_PATH_PATTERN = /^\/(?:[^/]+|i)\/status\/(\d+)\/?$/;
|
|
5
|
+
const TWEET_HOSTS = new Set(['x.com', 'twitter.com']);
|
|
6
|
+
|
|
7
|
+
function isTwitterHost(hostname) {
|
|
8
|
+
return TWEET_HOSTS.has(hostname)
|
|
9
|
+
|| hostname.endsWith('.x.com')
|
|
10
|
+
|| hostname.endsWith('.twitter.com');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parseTweetUrl(rawUrl) {
|
|
14
|
+
const value = String(rawUrl ?? '').trim();
|
|
15
|
+
if (!value) {
|
|
16
|
+
throw new ArgumentError('twitter tweet URL cannot be empty', 'Example: opencli twitter retweet https://x.com/jack/status/20');
|
|
17
|
+
}
|
|
18
|
+
let parsed;
|
|
19
|
+
try {
|
|
20
|
+
parsed = new URL(value);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new ArgumentError(`Invalid tweet URL: ${value}`, 'Use a full https://x.com/<user>/status/<id> URL');
|
|
24
|
+
}
|
|
25
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
26
|
+
if (parsed.protocol !== 'https:' || !isTwitterHost(hostname)) {
|
|
27
|
+
throw new ArgumentError(`Invalid tweet URL host: ${value}`, 'Use a full https://x.com/<user>/status/<id> URL');
|
|
28
|
+
}
|
|
29
|
+
const match = parsed.pathname.match(TWEET_PATH_PATTERN);
|
|
30
|
+
if (!match?.[1]) {
|
|
31
|
+
throw new ArgumentError(`Could not extract tweet ID from URL: ${value}`, 'Use a full https://x.com/<user>/status/<id> URL');
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
id: match[1],
|
|
35
|
+
url: parsed.toString(),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
2
39
|
export function sanitizeQueryId(resolved, fallbackId) {
|
|
3
40
|
return typeof resolved === 'string' && QUERY_ID_PATTERN.test(resolved) ? resolved : fallbackId;
|
|
4
41
|
}
|
|
@@ -65,4 +102,5 @@ export function extractMedia(legacy) {
|
|
|
65
102
|
export const __test__ = {
|
|
66
103
|
sanitizeQueryId,
|
|
67
104
|
extractMedia,
|
|
105
|
+
parseTweetUrl,
|
|
68
106
|
};
|
|
@@ -1,7 +1,34 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import { __test__ } from './shared.js';
|
|
3
|
+
import { ArgumentError } from '@jackwener/opencli/errors';
|
|
3
4
|
|
|
4
|
-
const { extractMedia } = __test__;
|
|
5
|
+
const { extractMedia, parseTweetUrl } = __test__;
|
|
6
|
+
|
|
7
|
+
describe('twitter parseTweetUrl', () => {
|
|
8
|
+
it('accepts exact Twitter/X tweet URLs and preserves query parameters', () => {
|
|
9
|
+
expect(parseTweetUrl('https://x.com/alice/status/2040254679301718161?s=20')).toEqual({
|
|
10
|
+
id: '2040254679301718161',
|
|
11
|
+
url: 'https://x.com/alice/status/2040254679301718161?s=20',
|
|
12
|
+
});
|
|
13
|
+
expect(parseTweetUrl('https://mobile.twitter.com/i/status/2040318731105313143')).toEqual({
|
|
14
|
+
id: '2040318731105313143',
|
|
15
|
+
url: 'https://mobile.twitter.com/i/status/2040318731105313143',
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('rejects non-https, off-domain, host-suffix, embedded, and path-suffix URLs', () => {
|
|
20
|
+
const invalid = [
|
|
21
|
+
'http://x.com/alice/status/2040254679301718161',
|
|
22
|
+
'https://evil.com/alice/status/2040254679301718161',
|
|
23
|
+
'https://x.com.evil.com/alice/status/2040254679301718161',
|
|
24
|
+
'https://evil.com/?next=https://x.com/alice/status/2040254679301718161',
|
|
25
|
+
'https://x.com/alice/status/2040254679301718161/photo/1',
|
|
26
|
+
];
|
|
27
|
+
for (const url of invalid) {
|
|
28
|
+
expect(() => parseTweetUrl(url)).toThrow(ArgumentError);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
});
|
|
5
32
|
|
|
6
33
|
describe('twitter extractMedia', () => {
|
|
7
34
|
it('returns false + empty list when legacy has no media', () => {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
import { parseTweetUrl } from './shared.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'twitter',
|
|
7
|
+
name: 'unlike',
|
|
8
|
+
access: 'write',
|
|
9
|
+
description: 'Remove a like from a specific tweet',
|
|
10
|
+
domain: 'x.com',
|
|
11
|
+
strategy: Strategy.UI,
|
|
12
|
+
browser: true,
|
|
13
|
+
args: [
|
|
14
|
+
{ name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to unlike' },
|
|
15
|
+
],
|
|
16
|
+
columns: ['status', 'message'],
|
|
17
|
+
func: async (page, kwargs) => {
|
|
18
|
+
if (!page)
|
|
19
|
+
throw new CommandExecutionError('Browser session required for twitter unlike');
|
|
20
|
+
const target = parseTweetUrl(kwargs.url);
|
|
21
|
+
await page.goto(target.url);
|
|
22
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
23
|
+
const result = await page.evaluate(`(async () => {
|
|
24
|
+
try {
|
|
25
|
+
const tweetId = ${JSON.stringify(target.id)};
|
|
26
|
+
const findTargetArticle = () => Array.from(document.querySelectorAll('article')).find((article) =>
|
|
27
|
+
Array.from(article.querySelectorAll('a[href*="/status/"]')).some((link) => {
|
|
28
|
+
try {
|
|
29
|
+
const match = new URL(link.href, window.location.origin).pathname.match(/^\/(?:[^/]+|i)\/status\/(\d+)\/?$/);
|
|
30
|
+
return match?.[1] === tweetId;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
);
|
|
36
|
+
// Poll for the tweet to render
|
|
37
|
+
let attempts = 0;
|
|
38
|
+
let likeBtn = null;
|
|
39
|
+
let unlikeBtn = null;
|
|
40
|
+
let targetArticle = null;
|
|
41
|
+
|
|
42
|
+
while (attempts < 20) {
|
|
43
|
+
targetArticle = findTargetArticle();
|
|
44
|
+
likeBtn = targetArticle?.querySelector('[data-testid="like"]') || null;
|
|
45
|
+
unlikeBtn = targetArticle?.querySelector('[data-testid="unlike"]') || null;
|
|
46
|
+
|
|
47
|
+
if (likeBtn || unlikeBtn) break;
|
|
48
|
+
|
|
49
|
+
await new Promise(r => setTimeout(r, 500));
|
|
50
|
+
attempts++;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check if it's already not liked
|
|
54
|
+
if (likeBtn) {
|
|
55
|
+
return { ok: true, message: 'Tweet is not liked (already unliked).' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!unlikeBtn) {
|
|
59
|
+
return { ok: false, message: 'Could not find the Unlike button on this tweet after waiting 10 seconds. Are you logged in?' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Click Unlike
|
|
63
|
+
unlikeBtn.click();
|
|
64
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
65
|
+
|
|
66
|
+
// Verify success by checking if the 'like' button reappeared
|
|
67
|
+
const verifyArticle = findTargetArticle() || targetArticle;
|
|
68
|
+
const verifyBtn = verifyArticle?.querySelector('[data-testid="like"]');
|
|
69
|
+
if (verifyBtn) {
|
|
70
|
+
return { ok: true, message: 'Tweet successfully unliked.' };
|
|
71
|
+
} else {
|
|
72
|
+
return { ok: false, message: 'Unlike action was initiated but UI did not update as expected.' };
|
|
73
|
+
}
|
|
74
|
+
} catch (e) {
|
|
75
|
+
return { ok: false, message: e.toString() };
|
|
76
|
+
}
|
|
77
|
+
})()`);
|
|
78
|
+
if (result.ok) {
|
|
79
|
+
// Wait for the unlike network request to be processed
|
|
80
|
+
await page.wait(2);
|
|
81
|
+
}
|
|
82
|
+
return [{
|
|
83
|
+
status: result.ok ? 'success' : 'failed',
|
|
84
|
+
message: result.message
|
|
85
|
+
}];
|
|
86
|
+
}
|
|
87
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
4
|
+
import './unlike.js';
|
|
5
|
+
import { createPageMock } from '../test-utils.js';
|
|
6
|
+
|
|
7
|
+
describe('twitter unlike command', () => {
|
|
8
|
+
it('navigates to the tweet URL and reports success when the unlike script confirms', async () => {
|
|
9
|
+
const cmd = getRegistry().get('twitter/unlike');
|
|
10
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
11
|
+
const page = createPageMock([
|
|
12
|
+
{ ok: true, message: 'Tweet successfully unliked.' },
|
|
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
|
+
// After ok:true the adapter waits an extra 2s for the network round-trip.
|
|
20
|
+
expect(page.wait).toHaveBeenNthCalledWith(2, 2);
|
|
21
|
+
const script = page.evaluate.mock.calls[0][0];
|
|
22
|
+
// Idempotency check: looks for the like button (already-not-liked path) before clicking unlike.
|
|
23
|
+
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"like\"]')");
|
|
24
|
+
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"unlike\"]')");
|
|
25
|
+
expect(script).toContain('unlikeBtn.click()');
|
|
26
|
+
expect(script).toContain("document.querySelectorAll('article')");
|
|
27
|
+
expect(script).toContain('match?.[1] === tweetId');
|
|
28
|
+
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"unlike\"]')");
|
|
29
|
+
expect(result).toEqual([
|
|
30
|
+
{ status: 'success', message: 'Tweet successfully unliked.' },
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns a failed row without re-waiting when the unlike script reports a UI mismatch', async () => {
|
|
35
|
+
const cmd = getRegistry().get('twitter/unlike');
|
|
36
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
37
|
+
const page = createPageMock([
|
|
38
|
+
{
|
|
39
|
+
ok: false,
|
|
40
|
+
message: 'Could not find the Unlike button on this tweet after waiting 10 seconds. Are you logged in?',
|
|
41
|
+
},
|
|
42
|
+
]);
|
|
43
|
+
const result = await cmd.func(page, {
|
|
44
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
45
|
+
});
|
|
46
|
+
expect(result).toEqual([
|
|
47
|
+
{
|
|
48
|
+
status: 'failed',
|
|
49
|
+
message: 'Could not find the Unlike button on this tweet after waiting 10 seconds. Are you logged in?',
|
|
50
|
+
},
|
|
51
|
+
]);
|
|
52
|
+
// Only the primaryColumn wait should run when ok is false.
|
|
53
|
+
expect(page.wait).toHaveBeenCalledTimes(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('throws CommandExecutionError when no page is provided', async () => {
|
|
57
|
+
const cmd = getRegistry().get('twitter/unlike');
|
|
58
|
+
await expect(cmd.func(undefined, {
|
|
59
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
60
|
+
})).rejects.toThrow(CommandExecutionError);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('rejects invalid tweet URLs before navigation', async () => {
|
|
64
|
+
const cmd = getRegistry().get('twitter/unlike');
|
|
65
|
+
const page = createPageMock([]);
|
|
66
|
+
await expect(cmd.func(page, {
|
|
67
|
+
url: 'https://x.com/alice/status/2040254679301718161/photo/1',
|
|
68
|
+
})).rejects.toThrow(ArgumentError);
|
|
69
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
70
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
});
|