88 lines
5.8 KiB
JavaScript
88 lines
5.8 KiB
JavaScript
'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'));
|
|
assert.equal(errors.length, 0, errors.join('\n'));
|
|
console.log('PASS: mobile flow reader fast coverage, 60% exit save with pulse in flight, lookup exclusion, bounded pulse frequency, 30-second idle pause and input resume');
|
|
} finally { await context.close(); await browser.close(); }
|
|
};
|