test_article.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. from base import BaseTestCase
  2. from parameterized import parameterized
  3. class ArticleSelectors:
  4. page = '.article-page'
  5. cover = '.article-cover'
  6. body = '.article-body'
  7. title = '.article-title'
  8. author = '.article-author'
  9. fullname = '.article-author .fullname'
  10. username = '.article-author .username'
  11. date = '.article-author .article-date'
  12. avatar = '.article-author img.avatar'
  13. verified = '.article-author .verified-icon'
  14. media = '.article-media'
  15. caption = '.article-media-caption'
  16. divider = '.article-divider'
  17. articles = [
  18. ['2064166507438059759',
  19. '1s,秒杀一切,开源一个 X 文章发布 Skill【重磅升级】',
  20. 'punk2898', 'Punk'],
  21. ['2064689664213041529',
  22. 'SpaceX Thesis & Valuation Memorandum',
  23. 'Dialectic_Group', 'Dialectic'],
  24. ['2064691088636424322',
  25. 'Consciousness and AI: The Problem of Inner Experience',
  26. 'CosmicOrFun', 'Cosmic Orphan'],
  27. ['2064755789391110154',
  28. 'DeFi Markets Update 2026-06-10',
  29. 'SteakhouseFi', 'Steakhouse Financial'],
  30. ['2064755231901319527',
  31. 'The machine economy has a killswitch and somebody just pulled it.',
  32. '1914ad', 'Justin Bechler HMP-028'],
  33. ['2062858677149675788',
  34. 'Yakshinis',
  35. 'CosmicOrFun', 'Cosmic Orphan'],
  36. ]
  37. articles_with_media = [
  38. ['2064166507438059759', 6],
  39. ['2064689664213041529', 11],
  40. ['2064755789391110154', 5],
  41. ]
  42. articles_with_dividers = [
  43. ['2064166507438059759', 1],
  44. ['2064689664213041529', 6],
  45. ]
  46. class ArticleBasicTest(BaseTestCase):
  47. @parameterized.expand(articles)
  48. def test_article_loads(self, tweet_id, title, username, fullname):
  49. self.open_nitter(f'i/article/{tweet_id}')
  50. self.assert_element_visible(ArticleSelectors.page)
  51. self.assert_element_visible(ArticleSelectors.body)
  52. self.assert_text(title, ArticleSelectors.title)
  53. @parameterized.expand(articles)
  54. def test_article_author(self, tweet_id, title, username, fullname):
  55. self.open_nitter(f'i/article/{tweet_id}')
  56. self.assert_element_visible(ArticleSelectors.author)
  57. self.assert_text(f'@{username}', ArticleSelectors.username)
  58. @parameterized.expand(articles)
  59. def test_article_has_cover(self, tweet_id, title, username, fullname):
  60. self.open_nitter(f'i/article/{tweet_id}')
  61. self.assert_element_visible(ArticleSelectors.cover)
  62. src = self.get_attribute(ArticleSelectors.cover, 'src')
  63. self.assertIn('/pic/', src)
  64. @parameterized.expand(articles)
  65. def test_article_has_date(self, tweet_id, title, username, fullname):
  66. self.open_nitter(f'i/article/{tweet_id}')
  67. date_text = self.get_text(ArticleSelectors.date)
  68. self.assertTrue(len(date_text) > 3)
  69. @parameterized.expand(articles)
  70. def test_article_author_avatar(self, tweet_id, title, username, fullname):
  71. self.open_nitter(f'i/article/{tweet_id}')
  72. self.assert_element_visible(ArticleSelectors.avatar)
  73. src = self.get_attribute(ArticleSelectors.avatar, 'src')
  74. self.assertIn('/pic/', src)
  75. self.assertGreater(len(src), len('/pic/'))
  76. @parameterized.expand(articles)
  77. def test_article_author_verified(self, tweet_id, title, username, fullname):
  78. self.open_nitter(f'i/article/{tweet_id}')
  79. self.assert_element_visible(ArticleSelectors.verified)
  80. def test_article_author_verified_business(self):
  81. self.open_nitter('i/article/2064755789391110154')
  82. self.assert_element_visible('.article-author .verified-icon.business')
  83. class ArticleContentTest(BaseTestCase):
  84. def test_article_has_paragraphs(self):
  85. self.open_nitter('i/article/2064689664213041529')
  86. paragraphs = self.find_elements('.article-body p')
  87. self.assertGreater(len(paragraphs), 10)
  88. def test_article_has_headers(self):
  89. self.open_nitter('i/article/2064689664213041529')
  90. headers = self.find_elements('.article-body h1, .article-body h2')
  91. self.assertGreater(len(headers), 5)
  92. def test_article_has_bold_text(self):
  93. self.open_nitter('i/article/2064166507438059759')
  94. bold = self.find_elements('.article-body strong')
  95. self.assertGreater(len(bold), 0)
  96. def test_article_has_italic_text(self):
  97. self.open_nitter('i/article/2064166507438059759')
  98. italic = self.find_elements('.article-body em')
  99. self.assertGreater(len(italic), 0)
  100. def test_article_has_blockquotes(self):
  101. self.open_nitter('i/article/2064166507438059759')
  102. self.assert_element_visible('.article-body blockquote')
  103. def test_article_has_lists(self):
  104. self.open_nitter('i/article/2064166507438059759')
  105. self.assert_element_visible('.article-body ul')
  106. def test_article_has_emoji_text(self):
  107. self.open_nitter('i/article/2064166507438059759')
  108. body = self.get_text(ArticleSelectors.body)
  109. self.assertTrue(any(ord(c) > 0x1F000 for c in body))
  110. def test_article_has_links(self):
  111. self.open_nitter('i/article/2064691088636424322')
  112. links = self.find_elements('.article-body a[href]')
  113. self.assertGreater(len(links), 0)
  114. def test_article_twitter_links_localized(self):
  115. self.open_nitter('i/article/2064755789391110154')
  116. links = self.find_elements('.article-body a[href^="https://x.com"]')
  117. self.assertEqual(len(links), 0, 'x.com links should be converted to local paths')
  118. @parameterized.expand(articles_with_media)
  119. def test_article_media_count(self, tweet_id, expected_count):
  120. self.open_nitter(f'i/article/{tweet_id}')
  121. media = self.find_elements(ArticleSelectors.media)
  122. self.assertEqual(len(media), expected_count)
  123. @parameterized.expand(articles_with_dividers)
  124. def test_article_divider_count(self, tweet_id, expected_count):
  125. self.open_nitter(f'i/article/{tweet_id}')
  126. dividers = self.find_elements(ArticleSelectors.divider)
  127. self.assertEqual(len(dividers), expected_count)
  128. class ArticleMediaTest(BaseTestCase):
  129. def test_media_images_proxied(self):
  130. self.open_nitter('i/article/2064689664213041529')
  131. self.assert_element_visible(ArticleSelectors.media)
  132. img = self.find_element(f'{ArticleSelectors.media} img')
  133. src = img.get_attribute('src')
  134. self.assertIn('/pic/', src)
  135. self.assertFalse(src.startswith('https://pbs.twimg.com'))
  136. def test_cover_image_proxied(self):
  137. self.open_nitter('i/article/2064689664213041529')
  138. self.assert_element_visible(ArticleSelectors.cover)
  139. src = self.get_attribute(ArticleSelectors.cover, 'src')
  140. self.assertIn('/pic/', src)
  141. self.assertFalse(src.startswith('https://pbs.twimg.com'))
  142. def test_embedded_tweet(self):
  143. self.open_nitter('i/article/2064755789391110154')
  144. self.assert_element_visible('.article-body .timeline-item')
  145. def test_multiple_embedded_tweets(self):
  146. self.open_nitter('i/article/2064755231901319527')
  147. tweets = self.find_elements('.article-body .timeline-item')
  148. self.assertGreaterEqual(len(tweets), 3)
  149. def test_media_caption_displayed(self):
  150. self.open_nitter('i/article/2064689664213041529')
  151. self.assert_element_visible(ArticleSelectors.caption)
  152. captions = self.find_elements(ArticleSelectors.caption)
  153. self.assertGreaterEqual(len(captions), 5)
  154. def test_media_caption_text(self):
  155. self.open_nitter('i/article/2064689664213041529')
  156. self.assert_text_visible('FIGURE 1', ArticleSelectors.caption)
  157. def test_media_caption_alt_attribute(self):
  158. self.open_nitter('i/article/2064689664213041529')
  159. img = self.find_element(f'{ArticleSelectors.media} img')
  160. alt = img.get_attribute('alt')
  161. self.assertGreater(len(alt), 0)
  162. def test_no_caption_when_absent(self):
  163. self.open_nitter('i/article/2062858677149675788')
  164. captions = self.find_elements(ArticleSelectors.caption)
  165. self.assertEqual(len(captions), 0)
  166. class ArticleMentionTest(BaseTestCase):
  167. def test_mention_linkified(self):
  168. self.open_nitter('i/article/2064755231901319527')
  169. link = self.find_element('.article-body a[href="/ZachXBT"]')
  170. self.assertEqual(link.text, '@ZachXBT')
  171. def test_multiple_mentions_linkified(self):
  172. self.open_nitter('i/article/2064755231901319527')
  173. links = self.find_elements('.article-body a[href^="/"]')
  174. mention_hrefs = [l.get_attribute('href') for l in links
  175. if l.text.startswith('@')]
  176. usernames = [h.split('/')[-1] for h in mention_hrefs]
  177. self.assertIn('ZachXBT', usernames)
  178. self.assertIn('River', usernames)
  179. def test_mention_in_different_article(self):
  180. self.open_nitter('i/article/2064689664213041529')
  181. link = self.find_element('.article-body a[href="/FutureJurvetson"]')
  182. self.assertEqual(link.text, '@FutureJurvetson')
  183. def test_no_spurious_whitespace_in_styled_paragraph(self):
  184. """Styled paragraphs should not have extra whitespace from VNode serialization."""
  185. self.open_nitter('i/article/2064166507438059759')
  186. source = self.get_page_source()
  187. self.assertNotIn('white-space: pre-wrap', source)
  188. self.assertNotIn('white-space:pre-wrap', source)
  189. class ArticleCardTest(BaseTestCase):
  190. @parameterized.expand(articles)
  191. def test_status_page_shows_article_card(self, tweet_id, title, username, fullname):
  192. self.open_nitter(f'{username}/status/{tweet_id}')
  193. self.assert_element_visible('.article-card')
  194. self.assert_text(title, '.article-card .card-title')
  195. def test_article_card_has_cover_image(self):
  196. self.open_nitter('Dialectic_Group/status/2064689664213041529')
  197. self.assert_element_visible('.article-card .card-image img')
  198. src = self.get_attribute('.article-card .card-image img', 'src')
  199. self.assertIn('/pic/', src)
  200. def test_article_card_has_badge(self):
  201. self.open_nitter('Dialectic_Group/status/2064689664213041529')
  202. self.assert_element_visible('.article-card-badge')
  203. self.assert_text('Article', '.article-card-badge')
  204. def test_article_card_has_preview_text(self):
  205. self.open_nitter('CosmicOrFun/status/2064691088636424322')
  206. self.assert_element_visible('.article-card .card-description')
  207. def test_article_card_links_to_article(self):
  208. self.open_nitter('punk2898/status/2064166507438059759')
  209. href = self.get_attribute('.article-card .card-container', 'href')
  210. self.assertIn('/article/', href)
  211. def test_article_url_stripped_from_tweet_text(self):
  212. self.open_nitter('punk2898/status/2064166507438059759')
  213. self.assert_element_visible('.article-card')
  214. source = self.get_page_source()
  215. # Main tweet text should not contain article URL
  216. import re
  217. main = re.search(r'id="m".*?tweet-content[^>]*>(.*?)</div>', source, re.DOTALL)
  218. self.assertIsNotNone(main)
  219. self.assertNotIn('/article/', main.group(1))
  220. class ArticleQuotedCardTest(BaseTestCase):
  221. """Article cards inside quoted tweets (1914ad quoting own article)."""
  222. quoted_tweet = '1914ad/status/2064789532071891085'
  223. quoted_article_id = '2063677483548102688'
  224. def test_quoted_card_visible(self):
  225. self.open_nitter(self.quoted_tweet)
  226. self.assert_element_visible('.quote .article-card')
  227. def test_quoted_card_has_title(self):
  228. self.open_nitter(self.quoted_tweet)
  229. self.assert_text('David Bailey Already Won', '.quote .article-card .card-title')
  230. def test_quoted_card_has_badge(self):
  231. self.open_nitter(self.quoted_tweet)
  232. self.assert_element_visible('.quote .article-card-badge')
  233. self.assert_text('Article', '.quote .article-card-badge')
  234. def test_quoted_card_has_cover_image(self):
  235. self.open_nitter(self.quoted_tweet)
  236. # Scroll to element to trigger lazy loading
  237. self.scroll_to('.quote .article-card .card-image img')
  238. self.assert_element_visible('.quote .article-card .card-image img')
  239. src = self.get_attribute('.quote .article-card .card-image img', 'src')
  240. self.assertIn('/pic/', src)
  241. def test_quoted_card_has_description(self):
  242. self.open_nitter(self.quoted_tweet)
  243. self.assert_element_visible('.quote .article-card .card-description')
  244. def test_quoted_card_links_to_article(self):
  245. self.open_nitter(self.quoted_tweet)
  246. href = self.get_attribute('.quote .article-card .card-container', 'href')
  247. self.assertIn(f'/article/{self.quoted_article_id}', href)
  248. class ArticleRoutingTest(BaseTestCase):
  249. def test_username_article_route_redirects(self):
  250. self.open_nitter('punk2898/article/2064166507438059759')
  251. self.assert_element_visible(ArticleSelectors.page)
  252. self.assert_text('1s', ArticleSelectors.title)
  253. def test_status_article_route_redirects(self):
  254. self.open_nitter('punk2898/status/2064166507438059759/article')
  255. self.assert_element_visible(ArticleSelectors.page)
  256. self.assert_text('1s', ArticleSelectors.title)
  257. def test_invalid_id_returns_404(self):
  258. self.open_nitter('i/article/notanumber')
  259. self.assert_element_not_visible(ArticleSelectors.page)
  260. def test_nonexistent_article(self):
  261. self.open_nitter('i/article/1')
  262. self.assert_element_visible('.error-panel')