from base import BaseTestCase from parameterized import parameterized class ArticleSelectors: page = '.article-page' cover = '.article-cover' body = '.article-body' title = '.article-title' author = '.article-author' fullname = '.article-author .fullname' username = '.article-author .username' date = '.article-author .article-date' avatar = '.article-author img.avatar' verified = '.article-author .verified-icon' media = '.article-media' divider = '.article-divider' articles = [ ['2064166507438059759', '1s,秒杀一切,开源一个 X 文章发布 Skill【重磅升级】', 'punk2898', 'Punk'], ['2064689664213041529', 'SpaceX Thesis & Valuation Memorandum', 'Dialectic_Group', 'Dialectic'], ['2064691088636424322', 'Consciousness and AI: The Problem of Inner Experience', 'CosmicOrFun', 'Cosmic Orphan'], ['2064696491948777658', 'NC Push for Data Centers + Stablecoin Crypto= Data Centers are defacto BAILOUT OF Fed Reserve System', 'June_12_1776', 'June_12_1776'], ['2064755789391110154', 'DeFi Markets Update 2026-06-10', 'SteakhouseFi', 'Steakhouse Financial'], ['2064755231901319527', 'The machine economy has a killswitch and somebody just pulled it.', '1914ad', 'Justin Bechler HMP-028'], ['2062858677149675788', 'Yakshinis', 'CosmicOrFun', 'Cosmic Orphan'], ] articles_with_media = [ ['2064166507438059759', 6], ['2064689664213041529', 11], ['2064755789391110154', 5], ] articles_with_dividers = [ ['2064166507438059759', 1], ['2064689664213041529', 6], ] class ArticleBasicTest(BaseTestCase): @parameterized.expand(articles) def test_article_loads(self, tweet_id, title, username, fullname): self.open_nitter(f'i/article/{tweet_id}') self.assert_element_visible(ArticleSelectors.page) self.assert_element_visible(ArticleSelectors.body) self.assert_text(title, ArticleSelectors.title) @parameterized.expand(articles) def test_article_author(self, tweet_id, title, username, fullname): self.open_nitter(f'i/article/{tweet_id}') self.assert_element_visible(ArticleSelectors.author) self.assert_text(f'@{username}', ArticleSelectors.username) @parameterized.expand(articles) def test_article_has_cover(self, tweet_id, title, username, fullname): self.open_nitter(f'i/article/{tweet_id}') self.assert_element_visible(ArticleSelectors.cover) src = self.get_attribute(ArticleSelectors.cover, 'src') self.assertIn('/pic/', src) @parameterized.expand(articles) def test_article_has_date(self, tweet_id, title, username, fullname): self.open_nitter(f'i/article/{tweet_id}') date_text = self.get_text(ArticleSelectors.date) self.assertTrue(len(date_text) > 3) @parameterized.expand(articles) def test_article_author_avatar(self, tweet_id, title, username, fullname): self.open_nitter(f'i/article/{tweet_id}') self.assert_element_visible(ArticleSelectors.avatar) src = self.get_attribute(ArticleSelectors.avatar, 'src') self.assertIn('/pic/', src) self.assertGreater(len(src), len('/pic/')) @parameterized.expand(articles) def test_article_author_verified(self, tweet_id, title, username, fullname): self.open_nitter(f'i/article/{tweet_id}') self.assert_element_visible(ArticleSelectors.verified) def test_article_author_verified_business(self): self.open_nitter('i/article/2064755789391110154') self.assert_element_visible('.article-author .verified-icon.business') class ArticleContentTest(BaseTestCase): def test_article_has_paragraphs(self): self.open_nitter('i/article/2064689664213041529') paragraphs = self.find_elements('.article-body p') self.assertGreater(len(paragraphs), 10) def test_article_has_headers(self): self.open_nitter('i/article/2064689664213041529') headers = self.find_elements('.article-body h1, .article-body h2') self.assertGreater(len(headers), 5) def test_article_has_bold_text(self): self.open_nitter('i/article/2064166507438059759') bold = self.find_elements('.article-body strong') self.assertGreater(len(bold), 0) def test_article_has_italic_text(self): self.open_nitter('i/article/2064166507438059759') italic = self.find_elements('.article-body em') self.assertGreater(len(italic), 0) def test_article_has_blockquotes(self): self.open_nitter('i/article/2064696491948777658') self.assert_element_visible('.article-body blockquote') def test_article_has_lists(self): self.open_nitter('i/article/2064696491948777658') self.assert_element_visible('.article-body ul') def test_article_has_emoji_text(self): self.open_nitter('i/article/2064166507438059759') body = self.get_text(ArticleSelectors.body) self.assertTrue(any(ord(c) > 0x1F000 for c in body)) def test_article_has_links(self): self.open_nitter('i/article/2064691088636424322') links = self.find_elements('.article-body a[href]') self.assertGreater(len(links), 0) def test_article_twitter_links_localized(self): self.open_nitter('i/article/2064755789391110154') links = self.find_elements('.article-body a[href^="https://x.com"]') self.assertEqual(len(links), 0, 'x.com links should be converted to local paths') @parameterized.expand(articles_with_media) def test_article_media_count(self, tweet_id, expected_count): self.open_nitter(f'i/article/{tweet_id}') media = self.find_elements(ArticleSelectors.media) self.assertEqual(len(media), expected_count) @parameterized.expand(articles_with_dividers) def test_article_divider_count(self, tweet_id, expected_count): self.open_nitter(f'i/article/{tweet_id}') dividers = self.find_elements(ArticleSelectors.divider) self.assertEqual(len(dividers), expected_count) class ArticleMediaTest(BaseTestCase): def test_media_images_proxied(self): self.open_nitter('i/article/2064689664213041529') self.assert_element_visible(ArticleSelectors.media) img = self.find_element(f'{ArticleSelectors.media} img') src = img.get_attribute('src') self.assertIn('/pic/', src) self.assertFalse(src.startswith('https://pbs.twimg.com')) def test_cover_image_proxied(self): self.open_nitter('i/article/2064689664213041529') self.assert_element_visible(ArticleSelectors.cover) src = self.get_attribute(ArticleSelectors.cover, 'src') self.assertIn('/pic/', src) self.assertFalse(src.startswith('https://pbs.twimg.com')) def test_embedded_tweet(self): self.open_nitter('i/article/2064755789391110154') self.assert_element_visible('.article-body .timeline-item') def test_multiple_embedded_tweets(self): self.open_nitter('i/article/2064755231901319527') tweets = self.find_elements('.article-body .timeline-item') self.assertGreaterEqual(len(tweets), 3) class ArticleMentionTest(BaseTestCase): def test_mention_linkified(self): self.open_nitter('i/article/2064755231901319527') link = self.find_element('.article-body a[href="/ZachXBT"]') self.assertEqual(link.text, '@ZachXBT') def test_multiple_mentions_linkified(self): self.open_nitter('i/article/2064755231901319527') links = self.find_elements('.article-body a[href^="/"]') mention_hrefs = [l.get_attribute('href') for l in links if l.text.startswith('@')] usernames = [h.split('/')[-1] for h in mention_hrefs] self.assertIn('ZachXBT', usernames) self.assertIn('River', usernames) def test_mention_in_different_article(self): self.open_nitter('i/article/2064689664213041529') link = self.find_element('.article-body a[href="/FutureJurvetson"]') self.assertEqual(link.text, '@FutureJurvetson') def test_no_spurious_whitespace_in_styled_paragraph(self): """Styled paragraphs should not have extra whitespace from VNode serialization.""" self.open_nitter('i/article/2064696491948777658') source = self.get_page_source() self.assertNotIn('white-space: pre-wrap', source) self.assertNotIn('white-space:pre-wrap', source) class ArticleCardTest(BaseTestCase): @parameterized.expand(articles) def test_status_page_shows_article_card(self, tweet_id, title, username, fullname): self.open_nitter(f'{username}/status/{tweet_id}') self.assert_element_visible('.article-card') self.assert_text(title, '.article-card .card-title') def test_article_card_has_cover_image(self): self.open_nitter('Dialectic_Group/status/2064689664213041529') self.assert_element_visible('.article-card .card-image img') src = self.get_attribute('.article-card .card-image img', 'src') self.assertIn('/pic/', src) def test_article_card_has_badge(self): self.open_nitter('Dialectic_Group/status/2064689664213041529') self.assert_element_visible('.article-card-badge') self.assert_text('Article', '.article-card-badge') def test_article_card_has_preview_text(self): self.open_nitter('CosmicOrFun/status/2064691088636424322') self.assert_element_visible('.article-card .card-description') def test_article_card_links_to_article(self): self.open_nitter('punk2898/status/2064166507438059759') href = self.get_attribute('.article-card .card-container', 'href') self.assertIn('/article/', href) def test_article_url_stripped_from_tweet_text(self): self.open_nitter('punk2898/status/2064166507438059759') self.assert_element_visible('.article-card') source = self.get_page_source() # Main tweet text should not contain article URL import re main = re.search(r'id="m".*?tweet-content[^>]*>(.*?)', source, re.DOTALL) self.assertIsNotNone(main) self.assertNotIn('/article/', main.group(1)) class ArticleQuotedCardTest(BaseTestCase): """Article cards inside quoted tweets (1914ad quoting own article).""" quoted_tweet = '1914ad/status/2064789532071891085' quoted_article_id = '2063677483548102688' def test_quoted_card_visible(self): self.open_nitter(self.quoted_tweet) self.assert_element_visible('.quote .article-card') def test_quoted_card_has_title(self): self.open_nitter(self.quoted_tweet) self.assert_text('David Bailey Already Won', '.quote .article-card .card-title') def test_quoted_card_has_badge(self): self.open_nitter(self.quoted_tweet) self.assert_element_visible('.quote .article-card-badge') self.assert_text('Article', '.quote .article-card-badge') def test_quoted_card_has_cover_image(self): self.open_nitter(self.quoted_tweet) self.assert_element_visible('.quote .article-card .card-image img') src = self.get_attribute('.quote .article-card .card-image img', 'src') self.assertIn('/pic/', src) def test_quoted_card_has_description(self): self.open_nitter(self.quoted_tweet) self.assert_element_visible('.quote .article-card .card-description') def test_quoted_card_links_to_article(self): self.open_nitter(self.quoted_tweet) href = self.get_attribute('.quote .article-card .card-container', 'href') self.assertIn(f'/article/{self.quoted_article_id}', href) class ArticleRoutingTest(BaseTestCase): def test_username_article_route_redirects(self): self.open_nitter('punk2898/article/2064166507438059759') self.assert_element_visible(ArticleSelectors.page) self.assert_text('1s', ArticleSelectors.title) def test_status_article_route_redirects(self): self.open_nitter('punk2898/status/2064166507438059759/article') self.assert_element_visible(ArticleSelectors.page) self.assert_text('1s', ArticleSelectors.title) def test_invalid_id_returns_404(self): self.open_nitter('i/article/notanumber') self.assert_element_not_visible(ArticleSelectors.page) def test_nonexistent_article(self): self.open_nitter('i/article/1') self.assert_element_visible('.error-panel')