#!/usr/bin/env node /** * Document Library UI Tests * * Tests for the library page including filters, views, PDF/text viewers, * and document management. * * Run: node test_library_documents_ci.js */ const { setupTest, teardownTest, TestResults, log, delay, navigateTo, withTimeout } = require('./test_lib'); // ============================================================================ // Library Page Structure Tests // ============================================================================ const LibraryPageTests = { async libraryPageLoads(page, baseUrl) { await navigateTo(page, `${baseUrl}/library`); const result = await page.evaluate(() => { const hasContent = document.body.textContent.length > 100; const title = document.title.toLowerCase(); const hasLibraryContent = title.includes('library') || title.includes('document') || !!document.querySelector('.library, #library, [class*="library"]'); return { hasContent, hasLibraryContent, title, url: window.location.href }; }); return { passed: result.hasContent, message: `Library page loads (title: "${result.title}", has content: ${result.hasContent})` }; }, async libraryHeaderStats(page, baseUrl) { await navigateTo(page, `${baseUrl}/library`); const result = await page.evaluate(() => { // Look for statistics in header const statsArea = document.querySelector('.stats, .ldr-library-stats, .header-stats, [class*="statistics"]'); const pdfCount = document.querySelector('#total-pdfs'); const storageSize = document.querySelector('#total-size'); // Look for any numbers that might be stats const pageText = document.body.textContent; const hasNumbers = /\d+\s*(?:PDFs?|documents?|MB|GB|files?)/i.test(pageText); return { hasStatsArea: !!statsArea, hasPdfCount: !!pdfCount, hasStorageSize: !!storageSize, hasNumbers, pdfCountText: pdfCount?.textContent?.trim(), storageSizeText: storageSize?.textContent?.trim() }; }); const hasStats = result.hasStatsArea || result.hasPdfCount || result.hasNumbers; return { passed: hasStats, message: `Library stats: area=${result.hasStatsArea}, count="${result.pdfCountText}", size="${result.storageSizeText}"` }; }, async storageModeDisplay(page, baseUrl) { await navigateTo(page, `${baseUrl}/library`); const result = await page.evaluate(() => { const pageText = document.body.textContent.toLowerCase(); // Look for storage mode indicators const hasEncrypted = pageText.includes('encrypted') || pageText.includes('database'); const hasFilesystem = pageText.includes('filesystem') || pageText.includes('unencrypted'); const hasTextOnly = pageText.includes('text only'); // Look for storage mode badge/indicator const modeBadge = document.querySelector( '.storage-mode, ' + '[class*="storage-badge"], ' + '.mode-indicator, ' + '.badge' ); return { hasEncrypted, hasFilesystem, hasTextOnly, hasModeBadge: !!modeBadge, badgeText: modeBadge?.textContent?.trim() }; }); const hasModeInfo = result.hasEncrypted || result.hasFilesystem || result.hasTextOnly || result.hasModeBadge; if (!hasModeInfo) { return { passed: null, skipped: true, message: 'No storage mode display found' }; } return { passed: true, message: `Storage mode: encrypted=${result.hasEncrypted}, filesystem=${result.hasFilesystem}, badge="${result.badgeText}"` }; } }; // ============================================================================ // Filter Controls Tests // ============================================================================ const FilterControlsTests = { async filterControlsExist(page, baseUrl) { await navigateTo(page, `${baseUrl}/library`); await delay(500); const result = await page.evaluate(() => { // Broader search for search inputs const searchInput = document.querySelector( 'input[type="search"], ' + 'input[type="text"][placeholder*="search" i], ' + 'input[type="text"][placeholder*="filter" i], ' + '#search, ' + '.search-input, ' + '[class*="search-input"], ' + '[class*="filter-input"]' ); // Look for any filter controls const collectionFilter = document.querySelector( 'select[name*="collection"], ' + '#collection-filter, ' + '[class*="collection-filter"], ' + '[class*="collection-select"]' ); const domainFilter = document.querySelector( 'select[name*="domain"], ' + '#domain-filter, ' + '[class*="domain-filter"]' ); const dateFilter = document.querySelector( 'select[name*="date"], ' + '#date-filter, ' + '.date-filter, ' + '[class*="date-range"], ' + '[class*="date-filter"]' ); // Also check if library page has any content at all const hasLibraryContent = document.querySelector('.library, #library, [class*="library"], .documents, [class*="document"]'); const pageTitle = document.title?.toLowerCase() || ''; const isLibraryPage = pageTitle.includes('library') || !!hasLibraryContent; return { hasSearch: !!searchInput, hasCollectionFilter: !!collectionFilter, hasDomainFilter: !!domainFilter, hasDateFilter: !!dateFilter, searchPlaceholder: searchInput?.placeholder, isLibraryPage, hasAnyContent: !!hasLibraryContent }; }); const hasFilters = result.hasSearch || result.hasCollectionFilter || result.hasDomainFilter || result.hasDateFilter; // If no filters but we're on library page, skip instead of fail if (!hasFilters) { if (result.isLibraryPage || result.hasAnyContent) { return { passed: null, skipped: true, message: 'Library page found but filter controls not visible (may be dynamic or require documents)' }; } return { passed: null, skipped: true, message: 'Library page or filter controls not found' }; } return { passed: true, message: `Filters: search=${result.hasSearch} ("${result.searchPlaceholder}"), collection=${result.hasCollectionFilter}, domain=${result.hasDomainFilter}, date=${result.hasDateFilter}` }; }, async searchFilterFunctionality(page, baseUrl) { // #search-documents (library.html) is the real document search input, // wired to filter .ldr-document-card client-side on the 'input' event // (library_search_ui.js:142). The old test typed a query but returned // passed:true regardless. Assert the real input exists and reflects // typed input. await navigateTo(page, `${baseUrl}/library`); await page.waitForSelector('#search-documents', { timeout: 10000 }); const info = await page.evaluate(() => { const el = document.getElementById('search-documents'); return { exists: !!el, type: el?.type, placeholder: el?.placeholder || '' }; }); if (!info.exists) { return { passed: false, message: '#search-documents input not found on /library' }; } // Clear any pre-filled value first so the assertion is deterministic // (Puppeteer's page.type appends — there is no page.fill). await page.$eval('#search-documents', el => { el.value = ''; }); await page.type('#search-documents', 'fixture'); const value = await page.$eval('#search-documents', el => el.value); const passed = value === 'fixture'; return { passed, message: passed ? `Document search input accepts input (placeholder="${info.placeholder}")` : `Search input did not reflect typed value (got "${value}")` }; }, async dateFilterButtons(page, baseUrl) { await navigateTo(page, `${baseUrl}/library`); const result = await page.evaluate(() => { const buttons = Array.from(document.querySelectorAll('button, .btn, [class*="filter-btn"]')); const dateButtons = { today: buttons.find(b => b.textContent?.toLowerCase().includes('today')), week: buttons.find(b => b.textContent?.toLowerCase().includes('week')), month: buttons.find(b => b.textContent?.toLowerCase().includes('month')), all: buttons.find(b => b.textContent?.toLowerCase().includes('all')) }; return { hasToday: !!dateButtons.today, hasWeek: !!dateButtons.week, hasMonth: !!dateButtons.month, hasAll: !!dateButtons.all, foundButtons: Object.entries(dateButtons).filter(([_k, v]) => v).map(([k]) => k) }; }); const hasDateButtons = result.foundButtons.length > 0; if (!hasDateButtons) { return { passed: null, skipped: true, message: 'No date filter buttons found' }; } return { passed: true, message: `Date filter buttons: ${result.foundButtons.join(', ')}` }; } }; // ============================================================================ // View Toggle Tests // ============================================================================ const ViewToggleTests = { async viewToggleExists(page, baseUrl) { await navigateTo(page, `${baseUrl}/library`); const result = await page.evaluate(() => { const gridBtn = document.querySelector( '[data-view="grid"], ' + '.grid-view, ' + '#grid-view, ' + 'button[title*="grid"], ' + '[class*="grid-toggle"]' ); const listBtn = document.querySelector( '[data-view="list"], ' + '.list-view, ' + '#list-view, ' + 'button[title*="list"], ' + '[class*="list-toggle"]' ); const viewToggle = document.querySelector( '.ldr-view-toggle, ' + '[class*="view-switch"], ' + '.view-options' ); return { hasGridBtn: !!gridBtn, hasListBtn: !!listBtn, hasViewToggle: !!viewToggle, gridIcon: gridBtn?.innerHTML?.includes('grid') || gridBtn?.className?.includes('grid'), listIcon: listBtn?.innerHTML?.includes('list') || listBtn?.className?.includes('list') }; }); const hasToggle = result.hasGridBtn || result.hasListBtn || result.hasViewToggle; if (!hasToggle) { return { passed: null, skipped: true, message: 'No view toggle found' }; } return { passed: true, message: `View toggle: grid=${result.hasGridBtn}, list=${result.hasListBtn}` }; }, async viewToggleFunctionality(page, baseUrl) { await navigateTo(page, `${baseUrl}/library`); const result = await page.evaluate(() => { const gridBtn = document.querySelector('[data-view="grid"], .grid-view, #grid-view'); const listBtn = document.querySelector('[data-view="list"], .list-view, #list-view'); // Get initial view state const container = document.querySelector('.library-content, .documents, .doc-list, main'); const initialClass = container?.className; // Click list view if grid exists (or vice versa) if (listBtn) { listBtn.click(); } else if (gridBtn) { gridBtn.click(); } const afterClass = container?.className; return { hasButtons: !!(gridBtn || listBtn), classChanged: initialClass !== afterClass, initialClass, afterClass }; }); if (!result.hasButtons) { return { passed: null, skipped: true, message: 'No view toggle buttons to test' }; } return { passed: result.hasButtons, message: `View toggle: class changed=${result.classChanged}` }; } }; // ============================================================================ // Document Card Tests // ============================================================================ const DocumentCardTests = { async documentCardsDisplay(page, baseUrl) { await navigateTo(page, `${baseUrl}/library`); const result = await page.evaluate(() => { const cards = document.querySelectorAll( '.ldr-document-card, ' + '.doc-card, ' + '.library-item, ' + '[class*="document-item"], ' + '.card' ); if (cards.length === 0) { // Check for empty state const emptyState = document.querySelector('.ldr-empty-state, .no-documents, [class*="empty"]'); return { hasCards: false, cardCount: 0, hasEmptyState: !!emptyState, emptyText: emptyState?.textContent?.trim()?.substring(0, 50) }; } const firstCard = cards[0]; const hasTitle = !!firstCard.querySelector('.title, .ldr-doc-title, h3, h4, [class*="title"]'); const hasAuthor = !!firstCard.querySelector('.author, [class*="author"]'); const hasDate = !!firstCard.querySelector('.date, time, [class*="date"]'); const hasDomain = !!firstCard.querySelector('.domain, .badge, [class*="domain"]'); return { hasCards: true, cardCount: cards.length, hasTitle, hasAuthor, hasDate, hasDomain }; }); if (!result.hasCards && result.hasEmptyState) { return { passed: true, message: `Empty state shown: "${result.emptyText}"` }; } return { passed: result.hasCards, message: result.hasCards ? `${result.cardCount} document cards (title=${result.hasTitle}, author=${result.hasAuthor}, date=${result.hasDate})` : 'No document cards found' }; }, async documentCardActions(page, baseUrl) { // NOTE: deferred from the dead-field audit's PR 3. The real assertion // (seed collection + upload a doc, then assert .ldr-action-btn-txt + // .ldr-btn-delete-doc on the card) works, but the uploaded doc leaks // into this file's later PDF/Text viewer tests (collection deletion // doesn't cascade-delete the doc), turning their skips into failures. // Needs document-level cleanup or test reordering — tracked as a // follow-up so it doesn't destabilise the library shard here. await navigateTo(page, `${baseUrl}/library`); const result = await page.evaluate(() => { const card = document.querySelector('.ldr-document-card, .doc-card, .library-item, .card'); if (!card) return { hasCard: false }; const viewPdfBtn = card.querySelector('[class*="view-pdf"], button[title*="PDF"], .pdf-btn'); const viewTextBtn = card.querySelector('[class*="view-text"], button[title*="text"], .text-btn'); const deleteBtn = card.querySelector('[class*="delete"], .delete-btn, button[title*="delete"]'); const downloadBtn = card.querySelector('[class*="download"], .download-btn, a[download]'); return { hasCard: true, hasViewPdf: !!viewPdfBtn, hasViewText: !!viewTextBtn, hasDelete: !!deleteBtn, hasDownload: !!downloadBtn }; }); if (!result.hasCard) { return { passed: null, skipped: true, message: 'No document cards to check actions' }; } const hasActions = result.hasViewPdf || result.hasViewText || result.hasDelete || result.hasDownload; return { passed: hasActions, message: `Card actions: viewPdf=${result.hasViewPdf}, viewText=${result.hasViewText}, delete=${result.hasDelete}, download=${result.hasDownload}` }; } }; // ============================================================================ // PDF Viewer Tests // ============================================================================ const PdfViewerTests = { async pdfViewerModalExists(page, baseUrl) { await navigateTo(page, `${baseUrl}/library`); // Only test PDF viewer if there are document cards const hasCards = await page.evaluate(() => { return !!document.querySelector('.ldr-document-card, .doc-card, .library-item'); }); if (!hasCards) { return { passed: null, skipped: true, message: 'No documents to test PDF viewer' }; } // Try to click view PDF button on a card const clicked = await page.evaluate(() => { const card = document.querySelector('.ldr-document-card, .doc-card, .library-item'); if (!card) return false; const viewBtn = card.querySelector( '[class*="view-pdf"], ' + 'button[title*="PDF"], ' + '.pdf-btn, ' + '[data-action="view-pdf"]' ); if (viewBtn) { viewBtn.click(); return true; } return false; }); if (!clicked) { return { passed: null, skipped: true, message: 'No PDF view button on document card' }; } await delay(500); const result = await page.evaluate(() => { const modal = document.querySelector( '.modal, ' + '.pdf-viewer, ' + '[role="dialog"], ' + '.viewer-modal, ' + '#pdf-modal' ); const pdfEmbed = document.querySelector('embed[type*="pdf"], iframe[src*="pdf"], object[type*="pdf"]'); const pdfCanvas = document.querySelector('canvas.pdf-page, .pdf-canvas'); return { hasModal: !!modal && window.getComputedStyle(modal).display !== 'none', hasPdfEmbed: !!pdfEmbed, hasPdfCanvas: !!pdfCanvas, modalClass: modal?.className }; }); const hasPdfViewer = result.hasModal || result.hasPdfEmbed || result.hasPdfCanvas; return { passed: hasPdfViewer, message: hasPdfViewer ? `PDF viewer: modal=${result.hasModal}, embed=${result.hasPdfEmbed}, canvas=${result.hasPdfCanvas}` : 'PDF viewer modal did not appear' }; }, async pdfViewerCloseButton(page, baseUrl) { await navigateTo(page, `${baseUrl}/library`); // Only test if there are document cards const hasCards = await page.evaluate(() => { return !!document.querySelector('.ldr-document-card, .doc-card, .library-item'); }); if (!hasCards) { return { passed: null, skipped: true, message: 'No documents to test PDF close button' }; } // Open PDF viewer from a card await page.evaluate(() => { const card = document.querySelector('.ldr-document-card, .doc-card, .library-item'); if (!card) return; const viewBtn = card.querySelector('[class*="view-pdf"], .pdf-btn, [data-action="view-pdf"]'); if (viewBtn) viewBtn.click(); }); await delay(500); const result = await page.evaluate(() => { const modal = document.querySelector('.modal, .pdf-viewer, [role="dialog"]'); if (!modal) return { hasModal: false }; const closeBtn = modal.querySelector( '.close, ' + '.close-btn, ' + '[class*="close"], ' + 'button[aria-label="Close"], ' + '.modal-close' ); return { hasModal: true, hasCloseBtn: !!closeBtn, closeBtnText: closeBtn?.textContent?.trim() || closeBtn?.getAttribute('aria-label') }; }); if (!result.hasModal) { return { passed: null, skipped: true, message: 'PDF modal not open for close button test' }; } return { passed: result.hasCloseBtn, message: result.hasCloseBtn ? `Close button found: "${result.closeBtnText}"` : 'No close button in PDF viewer' }; } }; // ============================================================================ // Text Viewer Tests // ============================================================================ const TextViewerTests = { async textViewerModalExists(page, baseUrl) { await navigateTo(page, `${baseUrl}/library`); // Only test if there are document cards const hasCards = await page.evaluate(() => { return !!document.querySelector('.ldr-document-card, .doc-card, .library-item'); }); if (!hasCards) { return { passed: null, skipped: true, message: 'No documents to test text viewer' }; } // Try to click view text button on a card const clicked = await page.evaluate(() => { const card = document.querySelector('.ldr-document-card, .doc-card, .library-item'); if (!card) return false; const viewBtn = card.querySelector( '[class*="view-text"], ' + 'button[title*="text"], ' + '.text-btn, ' + '[data-action="view-text"]' ); if (viewBtn) { viewBtn.click(); return true; } return false; }); if (!clicked) { return { passed: null, skipped: true, message: 'No text view button on document card' }; } await delay(500); const result = await page.evaluate(() => { const modal = document.querySelector( '.modal, ' + '.text-viewer, ' + '[role="dialog"], ' + '.viewer-modal, ' + '#text-modal' ); const textContent = modal?.querySelector('pre, .text-content, .document-text, textarea[readonly]'); return { hasModal: !!modal && window.getComputedStyle(modal).display !== 'none', hasTextContent: !!textContent, textLength: textContent?.textContent?.length || 0 }; }); const hasTextViewer = result.hasModal || result.hasTextContent; return { passed: hasTextViewer, message: hasTextViewer ? `Text viewer: modal=${result.hasModal}, content length=${result.textLength}` : 'Text viewer modal did not appear' }; }, async textViewerCopyButton(page, baseUrl) { await navigateTo(page, `${baseUrl}/library`); // Open text viewer await page.evaluate(() => { const viewBtn = document.querySelector('[class*="view-text"], .text-btn, [data-action="view-text"]'); if (viewBtn) viewBtn.click(); }); await delay(500); const result = await page.evaluate(() => { const modal = document.querySelector('.modal, .text-viewer, [role="dialog"]'); if (!modal) return { hasModal: false }; const copyBtn = modal.querySelector( '[class*="copy"], ' + 'button[title*="copy"], ' + '.copy-btn, ' + '[data-action="copy"]' ); return { hasModal: true, hasCopyBtn: !!copyBtn, copyBtnText: copyBtn?.textContent?.trim() || copyBtn?.getAttribute('title') }; }); if (!result.hasModal) { return { passed: null, skipped: true, message: 'Text modal not open for copy button test' }; } if (!result.hasCopyBtn) { return { passed: null, skipped: true, message: 'No copy button in text viewer' }; } return { passed: true, message: `Copy button found: "${result.copyBtnText}"` }; } }; // ============================================================================ // Bulk Actions Tests // ============================================================================ const BulkActionsTests = { async getAllPdfsButton(page, baseUrl) { await navigateTo(page, `${baseUrl}/library`); const result = await page.evaluate(() => { const buttons = Array.from(document.querySelectorAll('button, .btn, a.btn')); const getAllBtn = buttons.find(btn => { const text = btn.textContent?.toLowerCase() || ''; return text.includes('get all') || text.includes('download all') || text.includes('fetch all') || text.includes('bulk download'); }); return { hasButton: !!getAllBtn, buttonText: getAllBtn?.textContent?.trim() }; }); if (!result.hasButton) { return { passed: null, skipped: true, message: 'No get all PDFs button found' }; } return { passed: true, message: `Get all PDFs button: "${result.buttonText}"` }; }, async syncLibraryButton(page, baseUrl) { await navigateTo(page, `${baseUrl}/library`); const result = await page.evaluate(() => { const buttons = Array.from(document.querySelectorAll('button, .btn, a.btn')); const syncBtn = buttons.find(btn => { const text = btn.textContent?.toLowerCase() || ''; return text.includes('sync') || text.includes('refresh') || text.includes('update library'); }); return { hasButton: !!syncBtn, buttonText: syncBtn?.textContent?.trim() }; }); if (!result.hasButton) { return { passed: null, skipped: true, message: 'No sync library button found' }; } return { passed: true, message: `Sync button: "${result.buttonText}"` }; } }; // ============================================================================ // Main Test Runner // ============================================================================ async function main() { log.section('Document Library UI Tests'); const ctx = await setupTest({ authenticate: true }); const results = new TestResults('Library Documents Tests'); const { page } = ctx; const { baseUrl } = ctx.config; // Helper: run a sub-test with a 30s timeout so a single hang // doesn't block the entire 300s process-level timeout. 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 { // Library Page Structure log.section('Library Page Structure'); await run('Page', 'Loads', (p, u) => LibraryPageTests.libraryPageLoads(p, u)); await run('Page', 'Header Stats', (p, u) => LibraryPageTests.libraryHeaderStats(p, u)); await run('Page', 'Storage Mode', (p, u) => LibraryPageTests.storageModeDisplay(p, u)); // Filter Controls log.section('Filter Controls'); await run('Filters', 'Controls Exist', (p, u) => FilterControlsTests.filterControlsExist(p, u)); await run('Filters', 'Search', (p, u) => FilterControlsTests.searchFilterFunctionality(p, u)); await run('Filters', 'Date Buttons', (p, u) => FilterControlsTests.dateFilterButtons(p, u)); // View Toggle log.section('View Toggle'); await run('View', 'Toggle Exists', (p, u) => ViewToggleTests.viewToggleExists(p, u)); await run('View', 'Toggle Works', (p, u) => ViewToggleTests.viewToggleFunctionality(p, u)); // Document Cards log.section('Document Cards'); await run('Cards', 'Display', (p, u) => DocumentCardTests.documentCardsDisplay(p, u)); await run('Cards', 'Actions', (p, u) => DocumentCardTests.documentCardActions(p, u)); // PDF Viewer log.section('PDF Viewer'); await run('PDF', 'Viewer Modal', (p, u) => PdfViewerTests.pdfViewerModalExists(p, u)); await run('PDF', 'Close Button', (p, u) => PdfViewerTests.pdfViewerCloseButton(p, u)); // Text Viewer log.section('Text Viewer'); await run('Text', 'Viewer Modal', (p, u) => TextViewerTests.textViewerModalExists(p, u)); await run('Text', 'Copy Button', (p, u) => TextViewerTests.textViewerCopyButton(p, u)); // Bulk Actions log.section('Bulk Actions'); await run('Bulk', 'Get All PDFs', (p, u) => BulkActionsTests.getAllPdfsButton(p, u)); await run('Bulk', 'Sync Library', (p, u) => BulkActionsTests.syncLibraryButton(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 = { LibraryPageTests, FilterControlsTests, ViewToggleTests, DocumentCardTests, PdfViewerTests, TextViewerTests, BulkActionsTests };