#!/usr/bin/env node /** * News & Subscriptions UI Tests * * Tests for the news feed page and subscription management. * * Run: node test_news_subscriptions_ci.js */ const { setupTest, teardownTest, TestResults, log, navigateTo, withTimeout, findActionButton } = require('./test_lib'); const { seedSubscription, deleteSubscription } = require('./test_lib/fixtures'); // ============================================================================ // News Feed Page Tests // ============================================================================ const NewsFeedTests = { async newsPageLoads(page, baseUrl) { await navigateTo(page, `${baseUrl}/news`); const result = await page.evaluate(() => { return { hasNewsContent: !!document.querySelector('.news-container, .ldr-news, #news-feed, .news-feed'), hasHeader: !!document.querySelector('h1, .news-header, .page-title'), headerText: document.querySelector('h1, .news-header, .page-title')?.textContent?.trim(), hasCards: document.querySelectorAll('.news-card, .news-item, .article-card, [data-news-id]').length, hasEmptyState: !!document.querySelector('.ldr-empty-state, .no-news, .alert-info') }; }); const passed = result.hasNewsContent || result.hasHeader || result.hasCards > 0 || result.hasEmptyState; return { passed, message: passed ? `News page loaded (header: "${result.headerText}", cards: ${result.hasCards})` : 'News page failed to load' }; }, async newsCardStructure(page, baseUrl) { await navigateTo(page, `${baseUrl}/news`); const result = await page.evaluate(() => { const cards = document.querySelectorAll('.news-card, .news-item, .article-card, [data-news-id]'); if (cards.length === 0) return { hasCards: false }; const firstCard = cards[0]; return { hasCards: true, cardCount: cards.length, hasTitle: !!firstCard.querySelector('.card-title, h3, h4, .title, .news-title'), hasSummary: !!firstCard.querySelector('.card-text, .summary, .description, p'), hasSource: !!firstCard.querySelector('.source, .publication, .meta'), hasDate: !!firstCard.querySelector('.date, time, .timestamp'), hasActions: !!firstCard.querySelector('.card-actions, .actions, button, .btn') }; }); if (!result.hasCards) { return { passed: null, skipped: true, message: 'No news cards to test structure' }; } const hasRequiredParts = result.hasTitle; return { passed: hasRequiredParts, message: hasRequiredParts ? `News cards: ${result.cardCount} found (title=${result.hasTitle}, summary=${result.hasSummary}, actions=${result.hasActions})` : 'News cards missing required title element' }; }, async newsCardVoteButtons(page, baseUrl) { await navigateTo(page, `${baseUrl}/news`); const result = await page.evaluate(() => { const cards = document.querySelectorAll('.news-card, .news-item, .article-card'); if (cards.length === 0) return { hasCards: false }; const firstCard = cards[0]; const upvoteBtn = firstCard.querySelector( '.upvote, ' + '.vote-up, ' + '[data-vote="up"], ' + 'button[onclick*="upvote"], ' + '.fa-thumbs-up, ' + '.bi-hand-thumbs-up' ); const downvoteBtn = firstCard.querySelector( '.downvote, ' + '.vote-down, ' + '[data-vote="down"], ' + 'button[onclick*="downvote"], ' + '.fa-thumbs-down, ' + '.bi-hand-thumbs-down' ); return { hasCards: true, hasUpvote: !!upvoteBtn, hasDownvote: !!downvoteBtn }; }); if (!result.hasCards) { return { passed: null, skipped: true, message: 'No news cards to test vote buttons' }; } const hasVoting = result.hasUpvote || result.hasDownvote; if (!hasVoting) { return { passed: null, skipped: true, message: 'No voting buttons found on news cards' }; } return { passed: true, message: `Vote buttons found (upvote=${result.hasUpvote}, downvote=${result.hasDownvote})` }; }, async deeperResearchButton(page, baseUrl) { await navigateTo(page, `${baseUrl}/news`); const result = await page.evaluate(() => { const cards = document.querySelectorAll('.news-card, .news-item, .article-card'); if (cards.length === 0) return { hasCards: false }; const buttons = Array.from(document.querySelectorAll('button, a.btn')); const researchBtn = buttons.find(b => b.textContent?.toLowerCase().includes('research') || b.textContent?.toLowerCase().includes('deeper') || b.textContent?.toLowerCase().includes('investigate') ); return { hasCards: true, hasResearchButton: !!researchBtn, buttonText: researchBtn?.textContent?.trim() }; }); if (!result.hasCards) { return { passed: null, skipped: true, message: 'No news cards to test deeper research button' }; } if (!result.hasResearchButton) { return { passed: null, skipped: true, message: 'No deeper research button found' }; } return { passed: true, message: `Deeper research button found ("${result.buttonText}")` }; }, async newsCategoryFilter(page, baseUrl) { await navigateTo(page, `${baseUrl}/news`); const result = await page.evaluate(() => { const categoryFilter = document.querySelector( 'select[name*="category"], ' + '.category-filter, ' + '.category-tabs, ' + '[data-category]' ); const categoryBadges = document.querySelectorAll('.category-badge, .category-tag, .badge[data-category]'); return { hasFilter: !!categoryFilter, hasCategoryBadges: categoryBadges.length > 0, badgeCount: categoryBadges.length }; }); if (!result.hasFilter && !result.hasCategoryBadges) { return { passed: null, skipped: true, message: 'No category filter found' }; } return { passed: true, message: result.hasFilter ? 'Category filter dropdown found' : `${result.badgeCount} category badges found` }; } }; // ============================================================================ // Subscriptions Page Tests // ============================================================================ const SubscriptionsPageTests = { async subscriptionsPageLoads(page, baseUrl) { await navigateTo(page, `${baseUrl}/news/subscriptions`); const result = await page.evaluate(() => { return { hasContent: !!document.querySelector('.subscriptions-container, .ldr-subscriptions, #subscriptions'), hasHeader: !!document.querySelector('h1, .subscriptions-header, .page-title'), headerText: document.querySelector('h1, .subscriptions-header, .page-title')?.textContent?.trim(), subscriptionCount: document.querySelectorAll('.subscription-card, .subscription-item, [data-subscription-id]').length, hasEmptyState: !!document.querySelector('.ldr-empty-state, .no-subscriptions') }; }); const passed = result.hasContent || result.hasHeader || result.subscriptionCount > 0 || result.hasEmptyState; return { passed, message: passed ? `Subscriptions page loaded (header: "${result.headerText}", subscriptions: ${result.subscriptionCount})` : 'Subscriptions page failed to load' }; }, async subscriptionStatsDisplay(page, baseUrl) { await navigateTo(page, `${baseUrl}/news/subscriptions`); const result = await page.evaluate(() => { const statsSection = document.querySelector('.stats-overview, .subscription-stats, .stats'); const statBadges = document.querySelectorAll('.stat-badge, .badge, .ldr-stat-item'); // Look for common stat indicators const pageText = document.body.textContent?.toLowerCase() || ''; const hasTotal = pageText.includes('total'); const hasActive = pageText.includes('active'); const hasPaused = pageText.includes('paused') || pageText.includes('inactive'); return { hasStatsSection: !!statsSection, statCount: statBadges.length, hasTotal, hasActive, hasPaused }; }); const hasAnyStats = result.hasStatsSection || (result.hasTotal && result.hasActive); if (!hasAnyStats) { return { passed: null, skipped: true, message: 'No subscription stats display found' }; } return { passed: true, message: `Subscription stats found (total=${result.hasTotal}, active=${result.hasActive}, paused=${result.hasPaused})` }; }, async createSubscriptionButton(page, baseUrl) { await navigateTo(page, `${baseUrl}/news/subscriptions`); const { found, text } = await findActionButton(page); return { passed: found, message: found ? `Create subscription button found ("${text}")` : 'No create subscription button found' }; }, async subscriptionCardStructure(page, baseUrl) { // Subscription cards render client-side (subscriptions.js:: // createSubscriptionCard) as .ldr-subscription-card[data-subscription-id] // with .ldr-card-header h4 (title) + .ldr-query-text + .ldr-status-badge, // an edit link (a[href*="/edit"]) and a .btn-danger delete button. The old // test scanned for `.subscription-card` (no such class) and SKIPped on an // empty DB. Seed a subscription and assert the real card markup. await navigateTo(page, `${baseUrl}/news/subscriptions`); const seeded = await seedSubscription(page); if (!seeded) { return { passed: false, message: 'Could not seed subscription for card-structure test' }; } try { // navigateTo no-ops (already on this path); reload to render the card. await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForSelector(`.ldr-subscription-card[data-subscription-id="${seeded.id}"]`, { timeout: 10000 }); const result = await page.evaluate((id) => { const card = document.querySelector(`.ldr-subscription-card[data-subscription-id="${id}"]`); if (!card) return { found: false }; return { found: true, hasTitle: !!card.querySelector('.ldr-card-header h4'), hasQuery: !!card.querySelector('.ldr-query-text'), hasStatus: !!card.querySelector('.ldr-status-badge'), hasEdit: !!card.querySelector('a[href*="/edit"]'), hasDelete: !!card.querySelector('.btn-danger'), }; }, seeded.id); const passed = result.found && result.hasTitle && result.hasQuery && result.hasStatus && result.hasEdit && result.hasDelete; return { passed, message: passed ? 'Subscription card renders real structure (title + query + status badge + edit + delete)' : `Subscription card structure incomplete (${JSON.stringify(result)})` }; } finally { await deleteSubscription(page, seeded.id); } }, async subscriptionFormPage(page, baseUrl) { // The Create Subscription flow is page-navigation, not a modal: // #create-subscription-btn on /news/subscriptions has a JS handler // that runs `window.location.href = '/news/subscriptions/new'`. // Earlier this method ("subscriptionFormModal") asserted a modal // and accidentally passed when the navigation landed on the new // page (which happens to contain a form). Rename + rewrite to // test the real flow: navigate directly to /new and assert the // create form is present and reachable. await navigateTo(page, `${baseUrl}/news/subscriptions/new`); const result = await page.evaluate(() => { const form = document.querySelector('form:not(#logout-form)'); const query = document.querySelector('#subscription-query'); return { hasForm: !!form, hasQueryField: !!query, queryRequired: !!query?.required, }; }); const passed = result.hasForm && result.hasQueryField; return { passed, message: passed ? `Subscription create form reachable (queryRequired=${result.queryRequired})` : `Subscription create form missing (form=${result.hasForm}, query=${result.hasQueryField})` }; }, async subscriptionFormFields(page, baseUrl) { // The form lives at /news/subscriptions/new and uses // `subscription-*` ids (#subscription-query, #subscription-model, // #subscription-search-engine, ...). The previous version // looked for `name*="query"` / `#query` / etc., which match // nothing on the actual page, so this test always reported a // misleading SKIP ("Could not find subscription form fields"). await navigateTo(page, `${baseUrl}/news/subscriptions/new`); const result = await page.evaluate(() => { const form = document.querySelector('form:not(#logout-form)'); if (!form) return { hasForm: false }; return { hasForm: true, hasQueryField: !!form.querySelector('#subscription-query'), hasModelField: !!form.querySelector('#subscription-model'), hasSearchEngineField: !!form.querySelector('#subscription-search-engine'), hasStrategyField: !!form.querySelector('#subscription-strategy'), hasActiveToggle: !!form.querySelector('#subscription-active'), }; }); if (!result.hasForm) { return { passed: false, message: 'Subscription form not present at /news/subscriptions/new' }; } // The query textarea is the core required field. Other fields // (model, search engine, strategy, active toggle) round out the // expected schema; we treat all four as required signals so the // test fails loudly if the form skeleton ever drops a section. const passed = result.hasQueryField && result.hasModelField && result.hasSearchEngineField && result.hasStrategyField && result.hasActiveToggle; return { passed, message: passed ? 'Subscription form has query, model, search-engine, strategy, active toggle' : `Subscription form missing fields: query=${result.hasQueryField}, model=${result.hasModelField}, search=${result.hasSearchEngineField}, strategy=${result.hasStrategyField}, active=${result.hasActiveToggle}` }; } }; // ============================================================================ // News API Tests // ============================================================================ const NewsApiTests = { async newsFeedApiResponds(page, baseUrl) { await navigateTo(page, `${baseUrl}/news`); const result = await page.evaluate(async (url) => { try { const response = await fetch(`${url}/api/news/feed`); if (!response.ok) return { ok: false, status: response.status }; const data = await response.json(); return { ok: true, status: response.status, itemCount: Array.isArray(data) ? data.length : (data.items?.length || 0) }; } catch (e) { return { ok: false, error: e.message }; } }, baseUrl); if (!result.ok && result.status === 404) { return { passed: null, skipped: true, message: 'News feed API endpoint not found' }; } return { passed: result.ok, message: result.ok ? `News feed API responds (${result.itemCount} items)` : `News feed API failed: ${result.error || 'status ' + result.status}` }; }, async subscriptionsApiResponds(page, baseUrl) { await navigateTo(page, `${baseUrl}/news/subscriptions`); const result = await page.evaluate(async (url) => { try { const response = await fetch(`${url}/api/news/subscriptions`); if (!response.ok) return { ok: false, status: response.status }; const data = await response.json(); return { ok: true, status: response.status, subscriptionCount: Array.isArray(data) ? data.length : (data.subscriptions?.length || 0) }; } catch (e) { return { ok: false, error: e.message }; } }, baseUrl); if (!result.ok && result.status === 404) { return { passed: null, skipped: true, message: 'Subscriptions API endpoint not found' }; } return { passed: result.ok, message: result.ok ? `Subscriptions API responds (${result.subscriptionCount} subscriptions)` : `Subscriptions API failed: ${result.error || 'status ' + result.status}` }; } }; // ============================================================================ // Main Test Runner // ============================================================================ async function main() { log.section('News & Subscriptions Tests'); const ctx = await setupTest({ authenticate: true }); const results = new TestResults('News & Subscriptions Tests'); const { page } = ctx; const { baseUrl } = ctx.config; const subTestTimeout = ctx.config.isCI ? 60000 : 30000; async function run(category, name, testFn) { try { const result = await withTimeout( testFn(page, baseUrl), subTestTimeout, `${category}/${name}` ); if (result.skipped) { results.skip(category, name, result.message); } else { results.add(category, name, result.passed, result.message); } } catch (error) { results.add(category, name, false, `Error: ${error.message}`); } } try { // News Feed Tests log.section('News Feed'); await run('News', 'News Page Loads', (p, u) => NewsFeedTests.newsPageLoads(p, u)); await run('News', 'News Card Structure', (p, u) => NewsFeedTests.newsCardStructure(p, u)); await run('News', 'News Card Vote Buttons', (p, u) => NewsFeedTests.newsCardVoteButtons(p, u)); await run('News', 'Deeper Research Button', (p, u) => NewsFeedTests.deeperResearchButton(p, u)); await run('News', 'Category Filter', (p, u) => NewsFeedTests.newsCategoryFilter(p, u)); // Subscriptions Page Tests log.section('Subscriptions Page'); await run('Subscriptions', 'Subscriptions Page Loads', (p, u) => SubscriptionsPageTests.subscriptionsPageLoads(p, u)); await run('Subscriptions', 'Subscription Stats Display', (p, u) => SubscriptionsPageTests.subscriptionStatsDisplay(p, u)); await run('Subscriptions', 'Create Subscription Button', (p, u) => SubscriptionsPageTests.createSubscriptionButton(p, u)); await run('Subscriptions', 'Subscription Card Structure', (p, u) => SubscriptionsPageTests.subscriptionCardStructure(p, u)); await run('Subscriptions', 'Subscription Form Page', (p, u) => SubscriptionsPageTests.subscriptionFormPage(p, u)); await run('Subscriptions', 'Subscription Form Fields', (p, u) => SubscriptionsPageTests.subscriptionFormFields(p, u)); // API Tests log.section('News APIs'); await run('API', 'News Feed API Responds', (p, u) => NewsApiTests.newsFeedApiResponds(p, u)); await run('API', 'Subscriptions API Responds', (p, u) => NewsApiTests.subscriptionsApiResponds(p, u)); } catch (error) { log.error(`Fatal error: ${error.message}`); console.error(error.stack); } finally { results.print(); results.save(); await teardownTest(ctx); process.exit(results.exitCode()); } } // Run if executed directly if (require.main === module) { main().catch(error => { console.error('Test runner failed:', error); process.exit(1); }); } module.exports = { NewsFeedTests, SubscriptionsPageTests, NewsApiTests };