|
@@ -0,0 +1,307 @@
|
|
|
|
|
+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[^>]*>(.*?)</div>', 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')
|