test_article.py 12 KB

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