diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b1da228..7851729 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -83,6 +83,84 @@ jobs: echo "No Go project detected, skipping go test" fi + e2e: + runs-on: ubuntu-latest + needs: [docker-build] + steps: + - name: Clone repo + run: | + rm -rf $GITHUB_WORKSPACE/* + git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE + git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true + + - name: Create test network + run: docker network create newsarchiver-network 2>/dev/null || true + + - name: Start app container + run: | + docker run -d --name newsarchiver \ + --network newsarchiver-network \ + -p 5000:5000 \ + -e ADMIN_PASSWORD="" \ + -e ARCHIVE_DIR=/data/archives \ + -e DISABLE_RSS_FETCH=1 \ + jarianc/newsarchiverv2:test \ + python run_archiver.py --serve --host 0.0.0.0 --port 5000 + + - name: Seed test data + run: | + sleep 3 + docker exec newsarchiver python3 -c " + import sqlite3, os + db = os.environ.get('ARCHIVE_DIR', '/app/archival_data') + '/cache.db' + conn = sqlite3.connect(db) + c = conn.cursor() + c.execute('''CREATE TABLE IF NOT EXISTS articles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, source_name TEXT NOT NULL, + article_url TEXT NOT NULL UNIQUE, article_guid TEXT, title TEXT, + author TEXT, publish_date TEXT, content_text TEXT, content_html TEXT, + archive_file_path TEXT, metadata_file_path TEXT, + status TEXT DEFAULT 'pending', error_message TEXT, + extraction_method TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + c.execute('CREATE INDEX IF NOT EXISTS idx_articles_source ON articles(source_name)') + c.execute('CREATE INDEX IF NOT EXISTS idx_articles_url ON articles(article_url)') + c.execute('CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(status)') + now = '2026-07-07 12:00:00' + sources = ['Test News', 'Daily Wire', 'Tech Today'] + for i in range(1, 81): + src = sources[(i-1) % len(sources)] + c.execute('INSERT OR IGNORE INTO articles (source_name, article_url, title, publish_date, content_text, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + (src, f'https://test.com/a/{i}', f'Test Article {i}', f'2026-07-{(i % 28) + 1:02d} 10:00:00', f'Content for article {i}.', 'archived', now)) + conn.commit() + conn.close() + print('Seeded 80 test articles across 3 sources') + " + + - name: Wait for app + run: | + sleep 5 + for i in $(seq 1 30); do + curl -sf http://localhost:5000/ && echo "App ready" && break + sleep 2 + done + + - name: Run Playwright tests + run: | + docker run --rm \ + --network newsarchiver-network \ + -v $GITHUB_WORKSPACE/tests/playwright:/tests \ + -w /tests \ + -e APP_URL=http://newsarchiver:5000 \ + mcr.microsoft.com/playwright:v1.51.0-jammy \ + npx playwright test + + - name: Cleanup + if: always() + run: | + docker rm -f newsarchiver || true + docker network rm newsarchiver-network 2>/dev/null || true + docker-build: runs-on: ubuntu-latest steps: @@ -133,7 +211,7 @@ jobs: fi build-result: - needs: [lint, test, docker-build, security] + needs: [lint, test, docker-build, security, e2e] runs-on: ubuntu-latest container: image: gitea-job-image diff --git a/.gitignore b/.gitignore index cb04307..4a2d2dd 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,8 @@ singlefile-*.html Thumbs.db *.pid + +# Node +node_modules/ +package-lock.json +test-results/ diff --git a/tests/playwright/package.json b/tests/playwright/package.json new file mode 100644 index 0000000..d30f0be --- /dev/null +++ b/tests/playwright/package.json @@ -0,0 +1,13 @@ +{ + "name": "newsarchiver-e2e", + "version": "1.0.0", + "private": true, + "devDependencies": { + "@playwright/test": "^1.49.1", + "typescript": "^5.6.0" + }, + "scripts": { + "test": "playwright test", + "test:ui": "playwright test --ui" + } +} diff --git a/tests/playwright/playwright.config.ts b/tests/playwright/playwright.config.ts new file mode 100644 index 0000000..576d5c5 --- /dev/null +++ b/tests/playwright/playwright.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? [['list'], ['html']] : 'list', + use: { + baseURL: process.env.APP_URL || 'http://localhost:5000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, +}); diff --git a/tests/playwright/tests/ui.spec.ts b/tests/playwright/tests/ui.spec.ts new file mode 100644 index 0000000..8c98543 --- /dev/null +++ b/tests/playwright/tests/ui.spec.ts @@ -0,0 +1,128 @@ +import { test, expect } from '@playwright/test'; + +const TEST_SOURCE = 'Test News'; + +test.describe('Home page', () => { + test('loads and shows sources', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle('NewsArchiver'); + await expect(page.locator('h1').filter({ hasText: 'News Archives' })).toBeVisible(); + await expect(page.locator('.newspaper-list')).toBeVisible(); + }); + + test('shows source count', async ({ page }) => { + await page.goto('/'); + const count = await page.locator('p:has-text("Total sources:")').textContent(); + expect(count || '').toMatch(/Total sources: \d+/); + }); + + test('navigates to a source', async ({ page }) => { + await page.goto('/'); + const firstLink = page.locator('.newspaper-item h2 a').first(); + const sourceName = (await firstLink.textContent())?.trim() || ''; + await firstLink.click(); + await expect(page).toHaveURL(/\/source\//); + await expect(page.locator('h1')).toContainText(sourceName); + }); +}); + +test.describe('Article listing', () => { + test('shows articles for a source', async ({ page }) => { + await page.goto(`/source/${TEST_SOURCE.toLowerCase()}`); + await expect(page.locator('.article-list')).toBeVisible(); + const articles = page.locator('.article-item'); + await expect.poll(() => articles.count()).toBeGreaterThan(0); + }); + + test('article links navigate to detail', async ({ page }) => { + await page.goto(`/source/${TEST_SOURCE.toLowerCase()}`); + const firstArticle = page.locator('.article-item h3 a').first(); + const title = (await firstArticle.textContent())?.trim() || ''; + await firstArticle.click(); + await expect(page).toHaveURL(/\/article\/\d+/); + await expect(page.locator('.article-view h1')).toContainText(title); + }); + + test('shows article metadata', async ({ page }) => { + await page.goto(`/source/${TEST_SOURCE.toLowerCase()}/article/1`); + await expect(page.locator('.article-source')).toBeVisible(); + await expect(page.locator('.article-meta')).toBeVisible(); + }); + + test('back link returns to listing', async ({ page }) => { + await page.goto(`/source/${TEST_SOURCE.toLowerCase()}/article/1`); + await page.locator('.back-link').click(); + await expect(page).toHaveURL(new RegExp(`/source/${TEST_SOURCE.toLowerCase()}`)); + await expect(page.locator('.article-list')).toBeVisible(); + }); +}); + +test.describe('Pagination', () => { + test('shows page info', async ({ page }) => { + await page.goto(`/source/${TEST_SOURCE.toLowerCase()}`); + await expect(page.locator('.pagination span')).toContainText('Page 1'); + }); +}); + +test.describe('Navigation', () => { + test('header nav links work', async ({ page }) => { + await page.goto('/'); + await page.locator('nav a:has-text("Archives")').click(); + await expect(page).toHaveURL(/\/$/); + + await page.locator('nav a:has-text("Status")').click(); + await expect(page).toHaveURL(/\/status/); + }); +}); + +test.describe('Status page', () => { + test('shows system status', async ({ page }) => { + await page.goto('/status'); + await expect(page.locator('h1')).toBeVisible(); + }); + + test('shows total articles count', async ({ page }) => { + await page.goto('/status'); + await expect(page.locator('text=/Total Articles: \d+/')).toBeVisible(); + }); +}); + +test.describe('RSS feed', () => { + test('serves RSS XML', async ({ request }) => { + const resp = await request.get('/rss'); + expect(resp.ok()).toBeTruthy(); + expect(resp.headers()['content-type']).toContain('application/rss+xml'); + const body = await resp.text(); + expect(body).toContain(''); + }); +}); + +test.describe('Atom feed', () => { + test('serves Atom XML', async ({ request }) => { + const resp = await request.get('/atom'); + expect(resp.ok()).toBeTruthy(); + expect(resp.headers()['content-type']).toContain('application/atom+xml'); + const body = await resp.text(); + expect(body).toContain(' { + test('toggles dark mode', async ({ page }) => { + await page.goto('/'); + const html = page.locator('html'); + await page.locator('#theme-toggle').click(); + await expect(html).toHaveAttribute('data-theme', 'dark'); + + await page.locator('#theme-toggle').click(); + await expect(html).toHaveAttribute('data-theme', 'light'); + }); + + test('persists theme in localStorage', async ({ page }) => { + await page.goto('/'); + await page.locator('#theme-toggle').click(); + const theme = await page.evaluate(() => localStorage.getItem('theme')); + expect(theme).toBe('dark'); + }); +});