'use strict'; // Optional headless browser QA against the disposable Compose fixture only. // Playwright is a development tool, not an application dependency. const assert = require('node:assert/strict'); const fs = require('node:fs'), path = require('node:path'); module.exports = async function testSettingsUI(url, cookie) { const { chromium } = require('playwright'); const browser = await chromium.launch({ channel: process.env.QA_BROWSER_CHANNEL || 'msedge', headless: true }); const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); const [name, ...value] = cookie.split('='); await context.addCookies([{ name, value: value.join('='), url }]); const page = await context.newPage(), errors = [], requests = []; page.on('pageerror', e => errors.push(e.message)); page.on('request', r => requests.push(new URL(r.url()).pathname)); const shots = path.resolve(__dirname, '..', 'data', 'qa', 'settings'); fs.mkdirSync(shots, { recursive: true }); const clickSection = async (id, title) => { await page.locator(`.settings-menu a[href="settings.html#${id}"]`).click(); await page.getByRole('heading', { name: title, exact: true, level: 1 }).waitFor(); }; const noOverflow = async () => assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'Horizontal page overflow'); try { await page.goto(url + '/settings.html'); await page.getByRole('heading', { name: 'Reading', exact: true, level: 1 }).waitFor(); assert.equal(await page.locator('.settings-menu-group').count(), 2); assert.equal(requests.filter(p => p.startsWith('/api/admin') && p !== '/api/admin/peering').length, 0, 'Personal settings fetched admin data beyond the top navigation peer indicator'); await clickSection('user/reading-hours', 'Reading hours'); await page.getByRole('heading', { name: 'Server reading hours' }).waitFor(); const schedule = await page.evaluate(() => fetch('/api/reading-schedule').then(r => r.json())); if (schedule.enabled) { await page.getByText(`Server timezone: ${schedule.timezone}`).waitFor(); assert.equal(await page.locator('.reading-hours-day').count(), 7); } else await page.getByText('Reading is available at any time. No schedule is configured.').waitFor(); assert.equal(await page.locator('[data-settings-section="user/reading-hours"] input').count(), 0); await page.screenshot({ path: path.join(shots, 'desktop-reading-hours.png'), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); await noOverflow(); await page.screenshot({ path: path.join(shots, 'mobile-reading-hours.png'), fullPage: true }); await page.setViewportSize({ width: 1280, height: 900 }); await clickSection('user/reading', 'Reading'); await page.getByLabel('Skip chapter cover pages', { exact: true }).check(); await page.waitForFunction(async () => (await (await fetch('/api/me')).json()).settings.skipCovers === true); await page.screenshot({ path: path.join(shots, 'desktop-reading.png'), fullPage: true }); await clickSection('user/notifications', 'Notifications'); const webhook = page.locator('input[type=url]'); await webhook.fill('https://example.test/unsaved-draft'); await clickSection('admin/scraper', 'Scraper'); await page.getByRole('heading', { name: 'Activity', exact: false, level: 2 }).waitFor(); assert.equal(await page.locator('[data-scraper-tab]:visible').getAttribute('data-scraper-tab'), 'activity'); await page.getByRole('link', { name: 'Sources', exact: true }).click(); const min = page.locator('[data-scraper-tab=sources] input[type=number]').first(); await min.fill('1.5'); await page.getByRole('link', { name: 'Updates', exact: true }).click(); await page.getByRole('link', { name: 'Sources', exact: true }).click(); assert.equal(await min.inputValue(), '1.5', 'Tab switch discarded a draft'); await page.waitForTimeout(3300); assert.equal(await min.inputValue(), '1.5', 'Polling discarded a draft'); await page.getByRole('button', { name: 'Save WeebCentral', exact: true }).click(); await page.waitForFunction(() => document.querySelector('[data-scraper-tab=sources] input[type=number]')?.value === '1.5'); const state = await page.evaluate(() => fetch('/api/admin/scraper').then(r => r.json())); assert.equal(state.settings.sourceRates.weebcentral.minMs, 1500); await noOverflow(); await page.screenshot({ path: path.join(shots, 'desktop-hosters.png'), fullPage: true }); await clickSection('user/notifications', 'Notifications'); assert.equal(await page.locator('input[type=url]').inputValue(), 'https://example.test/unsaved-draft'); await page.waitForTimeout(300); const before = requests.filter(p => p === '/api/admin/scraper').length; await page.waitForTimeout(3300); assert.equal(requests.filter(p => p === '/api/admin/scraper').length, before, 'Hidden scraper keeps polling'); await page.goBack(); await page.locator('[data-scraper-tab=sources]:visible').waitFor(); await page.goForward(); await page.getByRole('heading', { name: 'Notifications', exact: true, level: 1 }).waitFor(); await clickSection('user/reading-time', 'Reading time & privacy'); await page.getByText('Track and show the Active Reading Clock').waitFor(); await clickSection('admin/library', 'Server & library'); await page.getByRole('button', { name: 'Full rescan', exact: true }).waitFor(); assert.equal(await page.getByRole('button', { name: 'Save user', exact: true }).count(), 0); await clickSection('admin/users', 'Users & access'); await page.getByRole('button', { name: 'Save user', exact: true }).waitFor(); assert.equal(await page.getByRole('button', { name: 'Full rescan', exact: true }).count(), 0); await page.locator('.settings-menu a[href="collections.html"]').click(); await page.getByRole('heading', { name: 'Collections & metadata', exact: true, level: 1 }).waitFor(); assert.equal(await page.locator('.settings-menu a[aria-current=page]').getAttribute('href'), 'collections.html'); await page.locator('.settings-menu a[href="health.html"]').click(); await page.getByRole('heading', { name: 'Service health', exact: true, level: 1 }).waitFor(); await page.getByText('No failed jobs.', { exact: true }).waitFor(); await page.setViewportSize({ width: 390, height: 844 }); await noOverflow(); await clickSection('admin/scraper', 'Scraper'); await page.getByRole('heading', { name: 'Activity', exact: false, level: 2 }).waitFor(); await noOverflow(); await page.screenshot({ path: path.join(shots, 'mobile-queue.png'), fullPage: true }); await page.getByRole('link', { name: 'Sources', exact: true }).click(); await noOverflow(); await page.screenshot({ path: path.join(shots, 'mobile-hosters.png'), fullPage: true }); // Create/login a fixture-only ordinary reader. No production accounts are touched. const result = await page.evaluate(async () => { const post = (p, b) => fetch(p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) }); const created = await post('/api/admin/user', { name: 'ordinary-qa', password: 'disposable-reader-test', admin: false }); if (!created.ok) return created.status; await post('/api/logout', {}); return (await post('/api/login', { name: 'ordinary-qa', password: 'disposable-reader-test' })).status; }); assert.equal(result, 200); const readerRequestStart = requests.length; // Change the document URL, not just its hash, after switching fixture accounts. await page.goto(url + '/settings.html?qa=ordinary#admin/users'); await page.getByRole('heading', { name: 'Reading', exact: true, level: 1 }).waitFor(); assert.equal(await page.locator('.settings-menu-group').count(), 1); assert.equal(await page.locator('.settings-menu a[href*="admin"]').count(), 0); await clickSection('user/reading-hours', 'Reading hours'); await page.getByRole('heading', { name: 'Server reading hours' }).waitFor(); assert.equal(await page.evaluate(() => fetch('/api/reading-schedule').then(r => r.status)), 200); await noOverflow(); assert.equal(requests.slice(readerRequestStart).filter(p => p.startsWith('/api/admin')).length, 0, 'Ordinary reader fetched admin data'); assert.equal(await page.evaluate(() => fetch('/api/admin').then(r => r.status)), 403); await noOverflow(); assert.deepEqual(errors, []); console.log('PASS: settings desktop/mobile navigation, deep links, role boundaries, draft retention, saves and polling lifecycle'); console.log('Screenshots: ' + shots); } catch (e) { await page.screenshot({ path: path.join(shots, 'failure.png'), fullPage: true }).catch(() => {}); throw e; } finally { await browser.close(); } };