'use strict'; // Browser QA uses ONLY test-compose's disposable user/library, never live history. const assert = require('node:assert/strict'); module.exports = async function (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: 390, height: 844 }, isMobile: true, hasTouch: true }); const [name, ...value] = cookie.split('='); await context.addCookies([{ name, value: value.join('='), url }]); const page = await context.newPage(), errors = [], packets = []; page.on('pageerror', e => errors.push(e.message)); let delayed = false; context.on('request', request => { if (!request.url().endsWith('/api/reading/visit') || request.method() !== 'POST') return; try { packets.push(request.postDataJSON()); } catch {} }); await context.route(url + '/api/reading/visit', async route => { const body = route.request().postDataJSON(); if (delayed && !body.final && body.activeMs > 0) { delayed = false; await new Promise(resolve => setTimeout(resolve, 1000)); } const response = await route.fetch(); await route.fulfill({ response }); }); const api = async (path, body) => { const response = await context.request.fetch(url + path, { method: body ? 'POST' : 'GET', data: body }); assert.equal(response.ok(), true, await response.text()); return response.json(); }; try { await api('/api/settings', { readingClock: true, startMode: 'flow', flowPageGap: false }); const inventory = await api('/api/library'), manga = inventory.mangas.find(m => m.title === 'Moon Garden'); const detail = await api('/api/manga?m=' + encodeURIComponent(manga.id)); const chapter = detail.chapters[0]; const reader = url + '/read.html?m=' + encodeURIComponent(manga.id) + '&c=' + encodeURIComponent(chapter.id); const summary = async () => (await api('/api/chapter?m=' + encodeURIComponent(manga.id) + '&c=' + encodeURIComponent(chapter.id))).reading; const load = async () => { await page.goto(reader); await page.locator('#readclock:not([hidden])').waitFor(); await page.waitForFunction(() => [...document.querySelectorAll('#canvas img')].some(img => img.complete && img.naturalWidth)); await page.locator('#stage').focus(); // Loaded pages, fast actual observed positions; no fictional intermediate views. await page.waitForTimeout(130); }; await load(); const images = page.locator('#canvas img'); for (let i = 0; i < 12; i++) { await images.nth(i).evaluate(img => { const stage = document.querySelector('#stage'); stage.scrollTop += img.getBoundingClientRect().top - stage.getBoundingClientRect().top; }); await page.waitForFunction(index => { const img = document.querySelectorAll('#canvas img')[index]; return img?.complete && img.naturalWidth; }, i); await page.waitForTimeout(125); } // Flow auto-hides the bars while scrolling; inspect the clock panel without // introducing another page-navigation gesture into the coverage test. await page.locator('#readclock').dispatchEvent('click'); await page.getByText('Yes ยท time continues counting', { exact: true }).waitFor(); // Force a normal in-flight save, then exercise pagehide explicitly before // Playwright's programmatic navigation tears down the old document. // The final beacon must not wait for the delayed ordinary save. delayed = true; await page.evaluate(() => window.dispatchEvent(new Event('blur'))); await page.evaluate(() => window.dispatchEvent(new Event('pagehide'))); await page.waitForTimeout(100); await page.goto(url + '/manga.html?m=' + encodeURIComponent(manga.id)); await page.waitForTimeout(1400); const saved = await summary(); assert.equal(saved.completedPasses, 1); assert.ok(saved.totalActiveMs >= 1000); assert.ok(packets.some(p => p.final && p.seen.length >= 12), `Final coverage packet missing (closed=${saved.currentPass?.closed}): ${JSON.stringify(packets.map(p => ({ seq: p.seq, final: p.final, seen: p.seen?.length, activeMs: p.activeMs })))}`); const before = saved.totalActiveMs; await load(); // Jump to one picture and dwell. Prior read must not lend this visit coverage. await images.nth(18).evaluate(img => { const stage = document.querySelector('#stage'); stage.scrollTop += img.getBoundingClientRect().top - stage.getBoundingClientRect().top; }); await page.waitForTimeout(1500); await page.goto(url + '/manga.html?m=' + encodeURIComponent(manga.id)); await page.waitForTimeout(400); const lookup = await summary(); assert.equal(lookup.totalActiveMs, before); assert.equal(lookup.completedPasses, 1); assert.ok(lookup.currentPass.seenPages < 12); // A stationary reader must pulse periodically, not continuously after replies. await load(); const count = packets.length; await page.waitForTimeout(11500); assert.ok(packets.length - count <= 2, 'Response-driven request storm'); await page.waitForFunction(() => document.querySelector('#readclock .clock-label')?.textContent.includes('Idle'), null, { timeout: 23000 }); const pausedTime = await page.locator('#readclock .clock-time').textContent(); await page.waitForTimeout(1100); assert.equal(await page.locator('#readclock .clock-time').textContent(), pausedTime, 'Idle time still accrued'); await page.mouse.move(20, 200); await page.waitForFunction(() => document.querySelector('#readclock .clock-label')?.textContent.includes('Reading')); await load(); const background = await page.locator('body').evaluate(body => getComputedStyle(body).backgroundColor); const toneButton = page.locator('#page-tone'); await toneButton.click(); for (const viewMode of ['flow', 'page', 'book']) { await page.locator(`#modes button[data-mode="${viewMode}"]`).click(); const appearance = await page.evaluate(() => ({ tone: document.querySelector('#canvas').dataset.pageTone, image: getComputedStyle(document.querySelector('#canvas .pg img')).filter, controls: getComputedStyle(document.querySelector('#bot')).filter, background: getComputedStyle(document.body).backgroundColor, })); assert.equal(appearance.tone, 'inverted'); assert.match(appearance.image, /invert\(1\)/); assert.equal(appearance.controls, 'none'); assert.equal(appearance.background, background); } for (const [tone, filter] of [['sepia', /sepia/], ['dim', /brightness\(0\.7\)/], ['extra-dim', /brightness\(0\.45\)/]]) { await toneButton.click(); const appearance = await page.evaluate(() => ({ tone: document.querySelector('#canvas').dataset.pageTone, filter: getComputedStyle(document.querySelector('#canvas .pg img')).filter })); assert.equal(appearance.tone, tone); assert.match(appearance.filter, filter); } await page.waitForFunction(async () => (await (await fetch('api/me')).json()).settings.pageTone === 'extra-dim'); await load(); assert.equal(await page.locator('#canvas').getAttribute('data-page-tone'), 'extra-dim'); await toneButton.click(); await page.waitForFunction(async () => (await (await fetch('api/me')).json()).settings.pageTone === 'original'); assert.equal(errors.length, 0, errors.join('\n')); console.log('PASS: mobile reading clock and five page tones across Flow, Page and Book without changing the theme'); } finally { await context.close(); await browser.close(); } };