Consolidate MangaCircus app, scraper, and Compose baseline

This commit is contained in:
Maashkinoozhe committed 2026-10-07 23:28:20 +02:00
1 parent cfb2d79103
commit eef4ffa07c
103 files changed
+9316 -172

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env node
'use strict';
const { secret } = require('../runtime/config');
const token = secret('SCRAPER_TOKEN');
const root = process.env.READER_URL || 'http://127.0.0.1:8080';
const scraper = process.env.SCRAPER_URL || 'http://127.0.0.1:8091';
const sentinel = process.env.SENTINEL_URL || 'http://127.0.0.1:8092';
(async () => {
for (const [name, base, endpoint] of [['reader', root, '/health'], ['scraper', scraper, '/api/state'], ['sentinel', sentinel, '/api/state']]) {
try {
const response = await fetch(base + endpoint, { headers: token ? { authorization: `Bearer ${token}` } : {}, signal: AbortSignal.timeout(10000) });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const state = await response.json();
console.log(JSON.stringify({ service: name, reachable: true, status: state.status || (state.ok ? 'healthy' : 'unhealthy'),
active: state.active?.map(e => ({ source: e.source, title: e.current?.title || e.title, completed: e.job?.completed ?? e.completed, total: e.job?.total ?? e.total })),
openIncidents: state.incidents?.filter(i => i.state === 'open').length, sourcePaused: state.sourcePaused }));
} catch (error) { console.log(JSON.stringify({ service: name, reachable: false, error: error.message })); process.exitCode = 1; }
}
})();
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env node
'use strict';
// Run on the host. Authentication stays inside the reader container; no token
// appears in command arguments, terminal output or a published daemon port.
const { spawnSync } = require('node:child_process');
const path = require('node:path');
const action = process.argv[2] || 'check';
const id = process.argv[3];
if (!['check', 'state', 'pause', 'resume', 'retry'].includes(action) ||
(action === 'retry' && !id) || (id && !/^[a-zA-Z0-9-]{1,80}$/.test(id))) {
console.error('Usage: node tools/compose-control.js check|state|pause|resume|retry <job-id>');
process.exit(2);
}
const script = action === 'check' ? "require('./tools/check.js')" : `
const {secret}=require('./runtime/config');
(async()=>{
const action=${JSON.stringify(action)},id=${JSON.stringify(id || null)};
const response=await fetch(process.env.SCRAPER_URL+(action==='state'?'/api/state':'/api/control'),{
method:action==='state'?'GET':'POST',signal:AbortSignal.timeout(20000),
headers:{authorization:'Bearer '+secret('SCRAPER_TOKEN'),'content-type':'application/json'},
...(action==='state'?{}:{body:JSON.stringify({action,id})})
});
if(!response.ok)throw new Error('Scraper returned HTTP '+response.status);
const s=await response.json();
const job=j=>j?{updated:j.updated,completed:j.completed,total:j.total,current:j.current,lastChapter:j.lastChapter}:null;
console.log(JSON.stringify({version:s.version,status:s.status,paused:s.paused,settings:s.settings,
sourcePaused:s.sourcePaused,publishedRevision:s.publishedRevision,
active:s.active?.map(a=>({source:a.source,id:a.current?.id,title:a.current?.title,job:job(a.job)})),
queue:s.queue?.map(q=>({id:q.id,title:q.title,source:q.source,status:q.status,failure:q.failure, nextAttemptAt:q.nextAttemptAt})),
openIncidents:s.incidents?.filter(i=>i.state==='open').map(i=>({kind:i.kind,title:i.title,message:i.message})),
runtimeHeartbeat:s.runtimeHeartbeat,nextDiscoveryAt:s.nextDiscoveryAt,libraryStatus:s.libraryStatus},null,2));
})().catch(e=>{console.error(e.message);process.exitCode=1;});`;
const result = spawnSync('docker', ['compose', 'exec', '-T', 'reader', 'node', '-e', script], {
cwd: path.resolve(__dirname, '..'), encoding: 'utf8', stdio: 'inherit', windowsHide: true,
});
if (result.error) console.error(result.error.message);
process.exit(result.status ?? 1);
+5
View File
@@ -0,0 +1,5 @@
'use strict';
const ports = { reader: process.env.PORT || 8080, scraper: process.env.SCRAPER_PORT || 8091, sentinel: process.env.SENTINEL_PORT || 8092 };
const service = process.argv[2] || 'reader';
fetch(`http://127.0.0.1:${ports[service]}/health`, { signal: AbortSignal.timeout(4000) })
.then(async r => { const body = await r.json(); if (!r.ok || !body.ok) process.exitCode = 1; }).catch(() => { process.exitCode = 1; });
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const root = path.resolve(__dirname, '..');
for (const relative of ['data', 'data/scraper', 'data/sentinel', 'library', '.secrets']) fs.mkdirSync(path.join(root, relative), { recursive: true });
const file = path.join(root, '.secrets', 'scraper_token');
if (!fs.existsSync(file)) fs.writeFileSync(file, crypto.randomBytes(32).toString('hex') + '\n', { flag: 'wx', mode: 0o600 });
if (!fs.existsSync(path.join(root, '.env'))) fs.copyFileSync(path.join(root, '.env.example'), path.join(root, '.env'));
console.log('Compose directories and private token are ready. Stop existing writers before using these paths in containers.');
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env node
'use strict';
// Reviewed, one-series migration for ambiguous pre-provenance metadata. Dry run
// by default. Apply only during a stopped-writer cutover with a state snapshot.
const fs = require('node:fs').promises;
const path = require('node:path');
const { imageSize } = require('../library');
const { detectVerticalStrip, KEY } = require('../reading-direction');
const { writeJsonAtomic } = require('../scraper/lib/publisher');
const [folder, expectedId, ...flags] = process.argv.slice(2);
const apply = flags.includes('--apply');
const mustInclude = flags.find(flag => flag.startsWith('--chapter='))?.slice('--chapter='.length);
if (!folder || !/^[A-Z0-9]{20,40}$/.test(expectedId || '') ||
(mustInclude && !/^c\d{1,6}$/i.test(mustInclude)) ||
flags.some(flag => !['--apply', '--writers-stopped'].includes(flag) && !/^--chapter=c\d{1,6}$/i.test(flag)) ||
(apply && !flags.includes('--writers-stopped'))) {
console.error('Usage: node tools/review-reading-direction.js <library-manga-folder> <weebcentral-id> [--chapter=c0056] [--apply --writers-stopped]');
process.exit(2);
}
async function main() {
const libraryRoot = path.resolve(process.env.LIBRARY || path.join(__dirname, '..', 'library'));
const mangaDir = path.resolve(folder);
if (path.dirname(mangaDir) !== libraryRoot) throw new Error('Manga folder must be directly inside the configured library');
const file = path.join(mangaDir, 'manga.json');
const original = await fs.readFile(file, 'utf8');
const meta = JSON.parse(original);
if (meta['x-scraper']?.ids?.weebcentral !== expectedId) throw new Error('Source identity did not match');
if (meta.direction !== 'rtl' || meta.directionOverride || meta[KEY]) throw new Error('Only an unmarked legacy RTL value can be reviewed');
const chapters = [];
for (const book of await fs.readdir(mangaDir, { withFileTypes: true })) {
if (!book.isDirectory() || !/^v\d/i.test(book.name)) continue;
for (const chapter of await fs.readdir(path.join(mangaDir, book.name), { withFileTypes: true })) {
if (chapter.isDirectory() && /^c\d/i.test(chapter.name)) chapters.push(path.join(mangaDir, book.name, chapter.name));
}
}
chapters.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
if (chapters.length < 3) throw new Error('Too few chapters for reviewed migration');
const requiredChapter = mustInclude && chapters.find(item => path.basename(item).toLowerCase().startsWith(mustInclude.toLowerCase() + ' '));
if (mustInclude && !requiredChapter) throw new Error('Required chapter was not found');
const selected = [...new Set([chapters[0], chapters[Math.floor(chapters.length / 2)], chapters.at(-1),
requiredChapter].filter(Boolean))];
const results = [];
for (const dir of selected) {
const pages = [];
for (const entry of (await fs.readdir(dir)).sort()) {
if (!/\.(?:jpe?g|png|webp|gif|avif|bmp)$/i.test(entry)) continue;
const size = await imageSize(path.join(dir, entry)).catch(() => null);
const rotation = /_rot(90|180|270)(?:_|\.)/i.exec(entry);
pages.push({ w: size?.w || 0, h: size?.h || 0, r: rotation ? Number(rotation[1]) : 0 });
}
const detected = detectVerticalStrip(pages);
results.push({ chapter: path.basename(dir), detected: detected?.value || null,
evidence: detected?.evidence || { pages: pages.length } });
}
const supporting = results.filter(item => item.detected === 'ttb').length;
const reviewed = supporting >= Math.max(3, Math.ceil(results.length * .75)) &&
(!requiredChapter || results.some(item => item.chapter === path.basename(requiredChapter) && item.detected === 'ttb'));
if (apply) {
if (!reviewed) throw new Error('Insufficient consistent strip evidence; metadata unchanged');
if (await fs.readFile(file, 'utf8') !== original) throw new Error('Metadata changed during review');
await writeJsonAtomic(file, { ...meta, direction: 'ttb',
[KEY]: { version: 1, value: 'ttb', source: 'detected',
evidence: { reviewedChapters: results.map(item => item.chapter), supportingChapters: supporting } } });
}
console.log(JSON.stringify({ manga: path.basename(mangaDir), reviewed, applied: apply, samples: results }, null, 2));
}
main().catch(error => { console.error(error.message); process.exitCode = 1; });
+10
View File
@@ -0,0 +1,10 @@
$ErrorActionPreference = 'Stop'
$projectRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path
$nodePath = (Get-Command node -ErrorAction Stop).Source
$outputLog = Join-Path $projectRoot 'data\scraper\supervisor.out.log'
$errorLog = Join-Path $projectRoot 'data\scraper\supervisor.err.log'
Set-Location -LiteralPath $projectRoot
& $nodePath 'tools/start-all.js' 1>> $outputLog 2>> $errorLog
exit $LASTEXITCODE
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env node
'use strict';
// Targeted recovery snapshot, not a library backup. Caller must prove all writers
// are stopped first. Never run this concurrently with the reader or scraper.
const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto');
if (process.argv[2] !== '--writers-stopped') throw new Error('First verify that every writer is stopped, then pass --writers-stopped');
const root = path.resolve(__dirname, '..'), library = path.join(root, 'library'), data = path.join(root, 'data');
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
const backup = path.join(data, 'scraper', 'backups', stamp + '-compose-cutover');
fs.mkdirSync(backup, { recursive: true });
const files = [];
function copy(source, relative) {
if (!fs.existsSync(source)) return;
if (fs.statSync(source).isDirectory()) {
for (const name of fs.readdirSync(source)) copy(path.join(source, name), path.join(relative, name));
return;
}
const target = path.join(backup, relative); fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(source, target);
files.push({ path: relative, bytes: fs.statSync(source).size, sha256: crypto.createHash('sha256').update(fs.readFileSync(source)).digest('hex') });
}
for (const name of ['db.json', 'scan-cache.json', 'catalog.json', 'collection-covers', 'runtime-reader.json']) copy(path.join(data, name), path.join('data', name));
for (const name of ['queue.json', 'runtime.json', 'daemon.lock', 'supervisor.lock']) copy(path.join(data, 'scraper', name), path.join('data', 'scraper', name));
for (const name of ['state.json', 'runtime.json']) copy(path.join(data, 'sentinel', name), path.join('data', 'sentinel', name));
for (const name of fs.readdirSync(library)) {
copy(path.join(library, name, name.startsWith('.mcscraper-work-') ? 'job.json' : 'manga.json'), path.join('library', name, name.startsWith('.mcscraper-work-') ? 'job.json' : 'manga.json'));
}
fs.writeFileSync(path.join(backup, 'snapshot.json'), JSON.stringify({ created: new Date().toISOString(), kind: 'targeted-state-not-image-backup', files }, null, 2));
console.log(JSON.stringify({ backup, files: files.length, imageFilesCopied: false }));
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs'), path = require('path');
const { Supervisor } = require('../runtime/supervisor');
const root = path.resolve(__dirname, '..');
const lock = path.resolve(process.env.SCRAPER_DATA || path.join(process.env.DATA || path.join(root, 'data'), 'scraper'), 'supervisor.lock');
fs.mkdirSync(path.dirname(lock), { recursive: true });
if (fs.existsSync(lock)) {
const pid = Number(fs.readFileSync(lock, 'utf8'));
try { process.kill(pid, 0); throw new Error(`Supervisor already alive (PID ${pid})`); }
catch (e) { if (e.code !== 'ESRCH') throw e; fs.unlinkSync(lock); }
}
fs.writeFileSync(lock, String(process.pid), { flag: 'wx' });
const supervisors = ['reader', 'scraper', 'sentinel'].map(name => new Supervisor(name, { root }));
supervisors.forEach(service => service.start());
const keepalive = setInterval(() => {}, 60000);
let closing = false;
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, async () => {
if (closing) return; closing = true; clearInterval(keepalive);
await Promise.all(supervisors.map(service => service.close()));
fs.unlinkSync(lock); process.exit(0);
});
+243
View File
@@ -0,0 +1,243 @@
'use strict';
// Real browser, disposable Compose storage; never uses a production account/catalog.
const assert = require('node:assert/strict');
const fs = require('node:fs'), path = require('node:path');
module.exports = async function testCollectionsUI(url, cookie) {
const { chromium } = require('playwright');
const browser = await chromium.launch({ channel: process.env.QA_BROWSER_CHANNEL || 'msedge', headless: true });
const origin = 'http://manga-qa.test:' + new URL(url).port;
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const [name, ...value] = cookie.split('=');
await context.addCookies([{ name, value: value.join('='), url: origin }]);
let delaySave = false, failSave = false, saveCount = 0;
// Proxy only this synthetic origin to the local disposable fixture. The browser
// still sees ordinary non-loopback HTTP, as it does on a Tailscale IP address.
await context.route(origin + '/**', async route => {
if (new URL(route.request().url()).pathname === '/api/admin/catalog' && route.request().method() === 'POST') {
saveCount++;
if (failSave) { failSave = false; return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'Fixture temporary save failure' }) }); }
if (delaySave) await new Promise(resolve => setTimeout(resolve, 600));
}
const response = await route.fetch({ url: url + route.request().url().slice(origin.length) });
await route.fulfill({ response });
});
const page = await context.newPage(), errors = [];
page.on('pageerror', e => errors.push(e.message));
const shots = path.resolve(__dirname, '..', 'data', 'qa', 'collections');
fs.mkdirSync(shots, { recursive: true });
const catalog = () => page.evaluate(() => fetch('/api/admin/catalog').then(r => r.json()));
const status = page.getByRole('status');
const save = async (button = 'Save collection') => {
await page.getByRole('button', { name: button, exact: true }).click();
await page.waitForFunction(() => document.querySelector('[role=status]')?.textContent === 'Saved');
};
const noOverflow = async () => assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'Collections page has horizontal overflow');
try {
await page.goto(origin + '/collections.html');
await page.getByRole('button', { name: 'New collection', exact: true }).waitFor();
assert.equal(await page.evaluate(() => isSecureContext), false, 'QA must reproduce an insecure browser origin');
assert.equal(await page.evaluate(() => typeof crypto.randomUUID), 'undefined');
await page.getByRole('button', { name: 'New collection', exact: true }).click();
if (process.argv.includes('--expect-collections-bug')) {
await page.waitForTimeout(200);
assert.ok(errors.some(e => e.includes('randomUUID')), 'Expected to reproduce the creation failure');
assert.equal(await page.getByLabel('Collection title', { exact: true }).count(), 0);
console.log('REPRODUCED: New collection throws crypto.randomUUID is not a function on non-loopback HTTP');
return;
}
await page.getByLabel('Collection title', { exact: true }).waitFor();
await page.getByLabel('Collection title', { exact: true }).fill('Harbor Stories');
await page.getByLabel('Collection description', { exact: true }).fill('A QA reading collection');
assert.match(await status.textContent(), /Unsaved/);
// An unrelated metadata action must not silently wipe a collection draft.
await page.getByRole('button', { name: 'Import cached metadata suggestions', exact: true }).click();
assert.match(await status.textContent(), /Save your changes/);
assert.equal(await page.getByLabel('Collection title', { exact: true }).inputValue(), 'Harbor Stories');
delaySave = true;
const beforeSave = saveCount;
await page.getByRole('button', { name: 'Save collection', exact: true }).click();
assert.equal(await page.getByRole('button', { name: 'Save collection', exact: true }).isDisabled(), true);
await page.evaluate(() => document.querySelector('fieldset button.btn.primary').click());
await page.waitForFunction(() => document.querySelector('[role=status]')?.textContent === 'Saved');
assert.equal(saveCount, beforeSave + 1, 'Duplicate save issued while one was in flight');
delaySave = false;
let state = await catalog();
const created = state.collections.find(c => c.title === 'Harbor Stories');
assert.ok(created); assert.match(created.id, /^[a-f0-9-]{36}$/);
const harborId = state.mangas.find(m => m.title === 'Iron Harbor').id;
const rainId = state.mangas.find(m => m.title === 'Before the Rain').id;
const moonId = state.mangas.find(m => m.title === 'Moon Garden').id;
assert.equal(created.members[0].mangaId, harborId);
await page.reload();
await page.getByLabel('Choose collection', { exact: true }).selectOption(created.id);
assert.equal(await page.getByLabel('Collection title', { exact: true }).inputValue(), 'Harbor Stories');
assert.equal(await page.getByLabel('Collection description', { exact: true }).inputValue(), 'A QA reading collection');
await page.getByRole('button', { name: 'New collection', exact: true }).click();
assert.match(await status.textContent(), /All manga are already grouped/);
assert.equal(await page.locator('[data-member] button').isDisabled(), true, 'Last member must not be removable');
// Server validation must be visible, recoverable, and retain the draft.
await page.getByLabel('Collection title', { exact: true }).fill('');
await page.getByRole('button', { name: 'Save collection', exact: true }).click();
await page.getByText('Collection title is required', { exact: false }).waitFor();
assert.equal(await page.getByRole('button', { name: 'Save collection', exact: true }).isEnabled(), true);
await page.getByLabel('Collection title', { exact: true }).fill('Harbor Stories');
await save();
// Edit/reorder and remove a member from the original fixture collection.
await page.getByLabel('Choose collection', { exact: true }).selectOption('moon-garden');
const member = page.locator('[data-member]').filter({ hasText: 'Before the Rain' });
await member.getByLabel('Member role').selectOption('side-story');
await member.getByLabel('Reading order').fill('0');
await page.getByLabel('Cover mode', { exact: true }).selectOption('selected');
await page.getByLabel('Cover manga', { exact: true }).selectOption(rainId);
await save();
state = await catalog();
assert.equal(state.collections.find(c => c.id === 'moon-garden').members[0].mangaId, rainId);
assert.equal(state.collections.find(c => c.id === 'moon-garden').coverMangaId, rainId);
await member.getByRole('button', { name: 'Remove', exact: true }).click();
await save();
state = await catalog();
assert.equal(state.collections.find(c => c.id === 'moon-garden').coverMangaId, moonId, 'Removing the cover member must repair the cover selection');
await page.getByLabel('Choose collection', { exact: true }).selectOption(created.id);
await page.getByLabel('Add manga to collection', { exact: true }).selectOption(rainId);
await page.getByLabel('Cover mode', { exact: true }).selectOption('mosaic');
await save();
state = await catalog();
assert.equal(state.collections.find(c => c.id === created.id).members.length, 2);
assert.equal(new Set(state.collections.flatMap(c => c.members.map(m => m.mangaId))).size, 3);
// Simulate a second admin's revision. A stale write must not overwrite it.
const other = await fetch(url + '/api/admin/catalog', { method: 'POST', headers: { cookie, 'content-type': 'application/json' },
body: JSON.stringify({ ...state, collections: state.collections.map(c => c.id === created.id ? { ...c, description: 'Changed by another editor' } : c) }) });
assert.equal(other.status, 200);
await page.getByLabel('Collection title', { exact: true }).fill('My unsaved title');
await page.getByRole('button', { name: 'Save collection', exact: true }).click();
await page.getByText('Catalog changed; reload before saving', { exact: false }).waitFor();
assert.equal(await page.getByLabel('Collection title', { exact: true }).inputValue(), 'My unsaved title');
page.once('dialog', d => d.dismiss());
await page.getByRole('button', { name: 'Reload catalog', exact: true }).click();
assert.equal(await page.getByLabel('Collection title', { exact: true }).inputValue(), 'My unsaved title');
page.once('dialog', d => d.accept());
await page.getByRole('button', { name: 'Reload catalog', exact: true }).click();
await page.getByText('Catalog reloaded', { exact: true }).waitFor();
assert.equal(await page.getByLabel('Collection description', { exact: true }).inputValue(), 'Changed by another editor');
// Network/server failure is visible and retryable without losing edits.
await page.getByLabel('Collection description', { exact: true }).fill('Retry kept my draft');
failSave = true;
await page.getByRole('button', { name: 'Save collection', exact: true }).click();
await page.getByText('Fixture temporary save failure', { exact: false }).waitFor();
assert.equal(await page.getByLabel('Collection description', { exact: true }).inputValue(), 'Retry kept my draft');
await save('Save all changes');
// Custom cover: real image encoding, backend validation, persisted selection.
const png = new (require('./png').Canvas)(80, 120, [140, 60, 90]).png();
await page.getByLabel('Custom cover', { exact: true }).setInputFiles({ name: 'qa.png', mimeType: 'image/png', buffer: png });
await page.getByText('Cover saved', { exact: true }).waitFor();
state = await catalog();
const cover = state.collections.find(c => c.id === created.id).customCover;
assert.ok(cover); assert.equal(state.collections.find(c => c.id === created.id).coverMode, 'custom');
assert.equal(await page.getByLabel('Cover mode', { exact: true }).inputValue(), 'custom');
assert.equal(await page.evaluate(id => fetch('/api/collection-cover?id=' + id).then(r => r.status), created.id), 200);
await page.getByLabel('Custom cover', { exact: true }).setInputFiles({ name: 'large.png', mimeType: 'image/png', buffer: Buffer.alloc(600001) });
await page.getByText('Choose an image under 600 KB.', { exact: true }).waitFor();
assert.equal((await catalog()).collections.find(c => c.id === created.id).customCover, cover);
// Relationships: create, reject, duplicate prevention, remove, cached import.
await page.getByLabel('Relationship source', { exact: true }).selectOption(harborId);
await page.getByLabel('Relationship target', { exact: true }).selectOption(moonId);
await page.getByLabel('Relationship type', { exact: true }).selectOption('spin-off');
await save('Add relationship');
const relation = page.locator('[data-relation]').filter({ hasText: 'spin-off' });
await relation.getByLabel('Relationship state', { exact: true }).selectOption('rejected');
await page.waitForFunction(() => document.querySelector('[role=status]')?.textContent === 'Saved');
assert.equal((await catalog()).relations.find(r => r.type === 'spin-off').state, 'rejected');
await page.getByLabel('Relationship source', { exact: true }).selectOption(harborId);
await page.getByLabel('Relationship target', { exact: true }).selectOption(moonId);
await page.getByLabel('Relationship type', { exact: true }).selectOption('spin-off');
const beforeDuplicate = saveCount;
await page.getByRole('button', { name: 'Add relationship', exact: true }).click();
assert.match(await status.textContent(), /already exists/); assert.equal(saveCount, beforeDuplicate);
await relation.getByRole('button', { name: 'Remove', exact: true }).click();
await page.waitForFunction(() => document.querySelector('[role=status]')?.textContent === 'Saved');
assert.ok(!(await catalog()).relations.some(r => r.type === 'spin-off'));
await save('Import cached metadata suggestions');
await page.getByRole('button', { name: 'Research relationships', exact: true }).click();
await page.getByText('Assign a verified AniList identity first', { exact: true }).waitFor();
await page.waitForFunction(() => !document.querySelector('fieldset').disabled);
assert.equal(await page.getByRole('button', { name: 'Research relationships', exact: true }).isEnabled(), true);
// Advanced JSON validation + round trip.
await page.getByText('Advanced · JSON import/export & agent research packet', { exact: true }).click();
await page.getByLabel('Catalog JSON', { exact: true }).fill('{');
await page.getByRole('button', { name: 'Validate & save JSON', exact: true }).click();
assert.equal(await page.getByLabel('Catalog JSON', { exact: true }).inputValue(), '{');
await page.getByLabel('Choose collection', { exact: true }).selectOption('moon-garden');
await page.getByLabel('Choose collection', { exact: true }).selectOption(created.id);
await page.getByText('Advanced · JSON import/export & agent research packet', { exact: true }).click();
assert.equal(await page.getByLabel('Catalog JSON', { exact: true }).inputValue(), '{', 'Changing collection discarded the JSON draft');
const beforeJSON = saveCount;
await page.getByRole('button', { name: 'Save all changes', exact: true }).click();
assert.match(await status.textContent(), /Validate & save JSON first/);
assert.equal(saveCount, beforeJSON, 'Ordinary save silently overwrote the JSON draft');
await page.getByLabel('Catalog JSON', { exact: true }).fill(JSON.stringify(await catalog()));
await save('Validate & save JSON');
await noOverflow();
await page.evaluate(() => scrollTo(0, 0));
await page.screenshot({ path: path.join(shots, 'desktop-editor.png'), fullPage: true });
// Mobile can edit + save too, not merely display the editor.
await page.setViewportSize({ width: 390, height: 844 });
await page.getByLabel('Collection description', { exact: true }).fill('Saved from mobile');
await save(); await noOverflow();
for (const row of await page.locator('[data-member]').all()) {
const text = await row.locator('span').first().boundingBox();
assert.ok(text.width > 150, 'Mobile member title is squeezed between controls');
}
await page.evaluate(() => scrollTo(0, 0));
await page.screenshot({ path: path.join(shots, 'mobile-editor.png'), fullPage: true });
// Reader presentation: grouped tiles, custom cover, order, expand/individual.
await page.goto(origin + '/');
await page.getByRole('link', { name: 'Harbor Stories', exact: false }).waitFor();
assert.equal(await page.locator('.tile').count(), 2);
await page.getByRole('link', { name: 'Harbor Stories', exact: false }).click();
await page.getByRole('heading', { name: 'Harbor Stories', exact: true }).waitFor();
assert.equal(await page.locator('.collection-member').count(), 2);
assert.match(await page.locator('.collection-member').first().textContent(), /Iron Harbor/);
assert.equal(await page.locator('.hero img').evaluate(img => img.complete && img.naturalWidth > 0), true);
await page.goto(origin + '/');
await page.getByLabel('Collection display', { exact: true }).selectOption('individual');
assert.equal(await page.locator('.tile').count(), 3);
await page.getByLabel('Collection display', { exact: true }).selectOption('expanded');
assert.equal(await page.locator('.tile').count(), 3);
assert.equal(await page.locator('.collection-heading').count(), 2);
// Group flag and deletion never delete manga or reading content.
await page.goto(origin + '/collections.html?id=' + created.id);
await page.getByLabel('Group on the dashboard', { exact: true }).uncheck(); await save();
assert.equal((await catalog()).collections.find(c => c.id === created.id).grouped, false);
page.once('dialog', d => d.dismiss());
await page.getByRole('button', { name: 'Delete collection', exact: true }).click();
assert.ok((await catalog()).collections.some(c => c.id === created.id));
page.once('dialog', d => d.accept());
await save('Delete collection');
state = await catalog(); assert.ok(!state.collections.some(c => c.id === created.id));
assert.equal(state.mangas.length, 3); assert.equal(state.relations.length, 1);
await page.reload();
await page.getByLabel('Choose collection', { exact: true }).waitFor();
assert.equal(await page.getByLabel('Choose collection', { exact: true }).locator('option').count(), 2);
// A reader can see groups, but cannot retrieve/change the admin catalog.
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: 'collections-reader-qa', password: 'disposable-reader-test', admin: false });
if (!created.ok) return created.status;
await post('/api/logout', {});
return (await post('/api/login', { name: 'collections-reader-qa', password: 'disposable-reader-test' })).status;
});
assert.equal(result, 200);
assert.equal(await page.evaluate(() => fetch('/api/admin/catalog').then(r => r.status)), 403);
assert.equal(await page.evaluate(() => fetch('/api/admin/catalog', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }).then(r => r.status)), 403);
await page.goto(origin + '/collections.html?qa=reader');
await page.getByRole('link', { name: 'Back to the library', exact: true }).waitFor();
assert.equal(await page.getByRole('button', { name: 'New collection', exact: true }).count(), 0);
assert.equal(await page.evaluate(() => fetch('/api/collections').then(r => r.status)), 200);
assert.deepEqual(errors, []);
console.log('PASS: collections create/edit/save/reload, members/order/covers, relationships, JSON, failure/conflict recovery, mobile, dashboard and deletion on non-loopback HTTP');
console.log('Screenshots: ' + shots);
} catch (e) {
await page.screenshot({ path: path.join(shots, 'failure.png'), fullPage: true }).catch(() => {});
throw e;
} finally { await browser.close(); }
};
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env node
'use strict';
// Isolated, repeatable container acceptance. Never mounts the real library/db/queue.
const fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto');
const assert = require('assert/strict'), { spawn } = require('child_process');
const { Canvas } = require('./png');
const root = path.resolve(__dirname, '..'), dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-compose-'));
const project = 'mc-qa-' + crypto.randomBytes(3).toString('hex'), port = process.env.QA_PORT || '8180';
const library = path.join(dir, 'library'), data = path.join(dir, 'data'), tokenFile = path.join(dir, 'token');
const directionQa = process.argv.includes('--direction-ui');
fs.mkdirSync(path.join(data, 'scraper'), { recursive: true }); fs.mkdirSync(path.join(data, 'sentinel'));
fs.writeFileSync(path.join(data, 'scraper', 'queue.json'), JSON.stringify({ paused: true, queue: [], settings: { autoUpdateEnabled: false } }));
fs.writeFileSync(tokenFile, crypto.randomBytes(32).toString('hex'), { mode: 0o600 });
const png = new Canvas(180, 270, [60, 90, 140]).png();
for (const [index, name] of ['Moon Garden', 'Before the Rain', 'Iron Harbor'].entries()) {
const folder = path.join(library, name, 'v01', 'c0001 The Beginning'); fs.mkdirSync(folder, { recursive: true });
fs.writeFileSync(path.join(folder, '0001.png'), png); fs.writeFileSync(path.join(library, name, 'cover.png'), png);
if (process.argv.includes('--reading-ui') && index === 0) {
for (let page = 2; page <= 20; page++) fs.writeFileSync(path.join(folder, String(page).padStart(4, '0') + '.png'), png);
}
if (directionQa && index === 0) {
fs.writeFileSync(path.join(folder, '0001.png'), new Canvas(270, 180, [60, 90, 140]).png());
for (let page = 2; page <= 4; page++) fs.writeFileSync(path.join(folder, String(page).padStart(4, '0') + '.png'), new Canvas(180, 1000, [60, 90, 140]).png());
}
fs.writeFileSync(path.join(library, name, 'manga.json'), JSON.stringify({ title: name,
direction: directionQa && index === 1 ? 'ltr' : process.argv.includes('--reading-ui') && index === 0 ? 'ttb' : 'rtl',
...(directionQa && index === 0 ? { 'x-reading-direction': { version: 1, value: 'rtl', source: 'fallback' } } : {}),
status: index ? 'completed' : 'ongoing', tags: ['Fantasy', 'Demo'] }));
}
const env = { ...process.env, LIBRARY_PATH: library, DATA_PATH: data, SCRAPER_DATA_PATH: path.join(data, 'scraper'), SENTINEL_DATA_PATH: path.join(data, 'sentinel'),
SCRAPER_TOKEN_PATH: tokenFile, READER_PORT: port, READER_BIND: '127.0.0.1', COOKIE_SECURE: '0', APP_IMAGE: process.env.APP_IMAGE || 'mangacircus:local' };
const args = ['compose', '-p', project, '-f', path.join(root, 'docker-compose.yml')];
const docker = (...command) => new Promise((resolve, reject) => {
const child = spawn('docker', [...args, ...command], { cwd: root, env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
let result = ''; child.stdout.on('data', chunk => result += chunk); child.stderr.on('data', chunk => process.stderr.write(chunk));
child.on('error', reject); child.once('exit', code => code ? reject(new Error(`Docker ${command[0]} failed (${code})`)) : resolve(result));
});
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
async function until(check, message, attempts = 100) {
for (let n = 0; n < attempts; n++) { try { if (await check()) return; } catch {} await pause(300); }
throw new Error(message);
}
let keep = false;
(async () => {
try {
await docker('config', '-q'); await docker('up', '-d', '--no-build');
const url = `http://127.0.0.1:${port}`;
await until(() => fetch(url + '/health').then(r => r.ok), 'Reader did not start');
const signup = await fetch(url + '/api/signup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'qa', password: 'disposable-local-test' }) });
assert.equal(signup.status, 200); const cookie = signup.headers.get('set-cookie').split(';')[0];
const api = (endpoint, body) => fetch(url + endpoint, { headers: { cookie, 'content-type': 'application/json' }, ...(body ? { method: 'POST', body: JSON.stringify(body) } : {}) });
let inventory;
await until(async () => { inventory = await (await api('/api/library')).json(); return inventory.mangas?.length === 3; }, 'Fixture inventory missing');
const byTitle = title => inventory.mangas.find(m => m.title === title).id;
const catalog = { revision: 0, collections: [{ id: 'moon-garden', title: 'Moon Garden Universe', coverMode: 'mosaic', members: [
{ mangaId: byTitle('Moon Garden'), role: 'main', readingOrder: 1 }, { mangaId: byTitle('Before the Rain'), role: 'prequel', readingOrder: 2 }] }],
relations: [{ from: byTitle('Moon Garden'), to: byTitle('Before the Rain'), type: 'prequel', state: 'confirmed' }] };
assert.equal((await api('/api/admin/catalog', catalog)).status, 200);
assert.equal((await api('/api/collection?id=moon-garden')).status, 200);
let state = await (await api('/api/admin/scraper')).json(); assert.equal(state.available, true); assert.equal(state.paused, true); assert.equal(state.version, 4);
const cleanup = await api('/api/admin/scraper', { command: 'clear-completed' });
assert.equal(cleanup.status, 200);
assert.equal((await cleanup.json()).cleared, 0);
assert.ok(state.libraryStatus);
await until(async () => { state = await (await api('/api/admin/scraper')).json(); return state.libraryStatus?.ready && state.library.length === 3; }, 'Background scraper inventory missing');
const settingsJs = await (await fetch(url + '/settings.js')).text();
assert.ok(!settingsJs.includes('Start it with: npm run start:scraper'));
await until(async () => { const h = await (await api('/api/admin/health')).json(); return h.sentinel.services?.reader && h.sentinel.services?.scraper; }, 'Internal Sentinel networking failed');
const mounts = JSON.parse(await docker('config', '--format', 'json'));
assert.ok(mounts.services.reader.volumes.find(v => v.target === '/library').read_only);
assert.ok(!mounts.services.scraper.ports?.length && !mounts.services.sentinel.ports?.length);
const denied = JSON.parse(await docker('exec', '-T', 'reader', 'node', '-e', "Promise.all(['http://scraper:8091/api/state','http://sentinel:8092/api/state'].map(u=>fetch(u).then(r=>r.status))).then(v=>console.log(JSON.stringify(v)))"));
assert.deepEqual(denied, [401, 401]);
// Deliberate fixture-only child crash: its supervisor must recover just the reader.
const before = JSON.parse(fs.readFileSync(path.join(data, 'runtime-reader.json'))).pid;
await docker('exec', '-T', 'reader', 'node', '-e', "const fs=require('fs');process.kill(JSON.parse(fs.readFileSync('/data/runtime-reader.json')).pid,'SIGKILL')");
await until(async () => { const runtime = JSON.parse(fs.readFileSync(path.join(data, 'runtime-reader.json'))); return runtime.pid !== before && runtime.status === 'running' && (await fetch(url + '/health')).ok; }, 'Reader child did not recover');
assert.equal(JSON.parse(fs.readFileSync(path.join(data, 'scraper', 'runtime.json'))).failures.length, 0);
const image = await api('/lib/' + encodeURIComponent('Moon Garden') + '/cover.png'); assert.equal(image.status, 200);
state = await (await api('/api/admin/scraper')).json(); assert.equal(state.paused, true);
if (process.argv.includes('--settings-ui')) await require('./test-settings-ui')(url, cookie);
if (process.argv.includes('--collections-ui')) {
// Each UI suite tests logout/ordinary-reader boundaries. Give the next suite
// a fresh disposable admin session rather than reusing a logged-out cookie.
const login = await api('/api/login', { name: 'qa', password: 'disposable-local-test' });
assert.equal(login.status, 200);
await require('./test-collections-ui')(url, login.headers.get('set-cookie').split(';')[0]);
}
if (process.argv.includes('--queue-ui')) {
const login = await api('/api/login', { name: 'qa', password: 'disposable-local-test' });
assert.equal(login.status, 200);
await require('./test-queue-ui')(url, login.headers.get('set-cookie').split(';')[0]);
}
if (process.argv.includes('--reading-ui')) {
const login = await api('/api/login', { name: 'qa', password: 'disposable-local-test' });
assert.equal(login.status, 200);
await require('./test-reading-ui')(url, login.headers.get('set-cookie').split(';')[0]);
}
if (directionQa) {
const login = await api('/api/login', { name: 'qa', password: 'disposable-local-test' });
assert.equal(login.status, 200);
await require('./test-direction-ui')(url, login.headers.get('set-cookie').split(';')[0]);
}
console.log('PASS: isolated Compose auth, collections, read-only reader, private APIs, Sentinel networking and child crash recovery');
keep = process.argv.includes('--keep');
if (keep) {
const envFile = path.join(dir, 'qa.env');
fs.writeFileSync(envFile, ['LIBRARY_PATH', 'DATA_PATH', 'SCRAPER_DATA_PATH', 'SENTINEL_DATA_PATH', 'SCRAPER_TOKEN_PATH', 'READER_PORT', 'READER_BIND', 'COOKIE_SECURE', 'APP_IMAGE'].map(k => `${k}=${env[k].replaceAll('\\', '/')}`).join('\n') + '\n');
console.log(JSON.stringify({ project, url, envFile, fixture: dir }));
}
} finally {
if (!keep) { await docker('down').catch(() => {}); fs.rmSync(dir, { recursive: true, force: true }); }
}
})().catch(error => { console.error(error.message); process.exitCode = 1; });
+34
View File
@@ -0,0 +1,34 @@
'use strict';
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 }]);
try {
const api = async path => {
const response = await context.request.get(url + path);
assert.equal(response.ok(), true, await response.text()); return response.json();
};
const inventory = await api('/api/library');
const pick = async title => {
const manga = inventory.mangas.find(item => item.title === title);
const detail = await api('/api/manga?m=' + encodeURIComponent(manga.id));
const chapter = detail.chapters[0];
return { manga, chapter, data: await api('/api/chapter?m=' + encodeURIComponent(manga.id) + '&c=' + encodeURIComponent(chapter.id)) };
};
const strip = await pick('Moon Garden');
const printed = await pick('Before the Rain');
assert.equal(strip.data.direction, 'ttb');
assert.equal(printed.data.direction, 'ltr');
const page = await context.newPage();
await page.goto(url + '/read.html?m=' + encodeURIComponent(strip.manga.id) + '&c=' + encodeURIComponent(strip.chapter.id));
await page.waitForFunction(() => document.querySelectorAll('#canvas img').length === 4);
const widths = await page.locator('#canvas img').evaluateAll(images => images.map(img => Math.round(img.getBoundingClientRect().width)));
assert.equal(widths.length, 4);
assert.ok(widths.every(width => Math.abs(width - widths[1]) <= 1), `Flow widths differ: ${widths.join(', ')}`);
console.log('PASS: mobile flow gives the landscape credits card the same width as detected webtoon strips; printed manual LTR remains LTR');
} finally { await context.close(); await browser.close(); }
};
+101
View File
@@ -0,0 +1,101 @@
'use strict';
// Fixture-only state interception: no source traffic or production accounts.
const assert = require('node:assert/strict'), fs = require('node:fs'), path = require('node:path');
module.exports = async function testQueueUI(url, cookie) {
const { chromium } = require('playwright');
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const [name, ...values] = cookie.split('=');
await context.addCookies([{ name, value: values.join('='), url }]);
const page = await context.newPage(), errors = [], commands = [], upstream = [];
page.on('pageerror', error => errors.push(error.message));
page.on('request', request => { if (new URL(request.url()).origin !== new URL(url).origin) upstream.push(request.url()); });
const state = await (await fetch(url + '/api/admin/scraper', { headers: { cookie } })).json();
state.library[0].sources = [{ name: 'MangaDex', adapter: 'mangadex', url: 'https://mangadex.org/title/fixture' }];
state.updateChecks = [{ mangaId: state.library[0].id, source: 'mangadex', lastCheckedAt: '2026-10-07T08:00:00Z', lastDownloadedAt: '2026-10-07T07:00:00Z', lastResult: 'no-updates' }];
state.history = [
{ title: 'Moon Garden', source: 'mangadex', status: 'checked', time: '2026-10-07T08:00:00Z' },
{ title: 'Another manga', source: 'weebcentral', status: 'checked', time: '2026-10-07T07:00:00Z' },
];
state.queue = [
{ id: 'pilot-fixture', title: 'bleach', source: 'mangadex', kind: 'inspection', status: 'ready', sourceTitle: 'Bleach (Pre-Serialization)', availability: { selectedChapters: 2, specialReleases: 2, coverage: 'unknown' } },
{ id: 'main-fixture', title: 'Bleach', source: 'mangadex', kind: 'download', status: 'complete', publishedCount: 1, sourceTitle: 'Bleach', availability: { selectedChapters: 1, expectedLastChapter: '686', coverage: 'partial' } },
{ id: 'external-fixture', title: 'Claymore', source: 'mangadex', kind: 'download', status: 'unavailable', error: 'MangaDex lists 8 English external-link releases, but no hosted English pages. External links are not downloaded.' },
{ id: 'other-source-fixture', title: 'Another manga', source: 'weebcentral', kind: 'download', status: 'queued' },
];
await page.route('**/api/admin/scraper', async route => {
if (route.request().method() === 'POST') {
const command = route.request().postDataJSON(); commands.push(command);
if (command.action === 'confirm') state.queue.find(item => item.id === command.id).status = 'queued';
if (command.action === 'inspect') state.queue.find(item => item.id === command.id).status = 'queued';
if (command.command === 'clear-completed') state.queue = state.queue.filter(item => item.status !== 'complete' || item.kind !== 'download');
}
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(state) });
});
const shots = path.resolve(__dirname, '..', 'data', 'qa', 'queue'); fs.mkdirSync(shots, { recursive: true });
try {
await page.goto(url + '/settings.html#admin/scraper');
const activity = page.locator('[data-scraper-tab=activity]'), queuePanel = page.locator('[data-scraper-tab=queue]');
await activity.getByText('Source edition: Bleach (Pre-Serialization)', { exact: true }).waitFor();
await activity.getByText(/no hosted English pages/).waitFor();
assert.equal(commands.length, 0, 'Inspection status must not start a download');
await page.screenshot({ path: path.join(shots, 'desktop.png'), fullPage: true });
await activity.getByRole('button', { name: 'Confirm download', exact: true }).click();
await page.waitForFunction(() => ![...document.querySelectorAll('button')].some(b => b.textContent === 'Confirm download'));
assert.deepEqual(commands.at(-1), { command: 'control', action: 'confirm', id: 'pilot-fixture' });
await activity.locator('.scraper-item').filter({ hasText: 'Claymore' }).getByRole('button', { name: 'Inspect again', exact: true }).click();
assert.deepEqual(commands.at(-1), { command: 'control', action: 'inspect', id: 'external-fixture' });
await page.getByRole('link', { name: 'Queue', exact: true }).click();
await queuePanel.getByText('Completed downloads · 1').click();
await page.waitForTimeout(3500);
assert.equal(await queuePanel.locator('.scraper-completed').evaluate(el => el.open), true, 'Completed section collapsed on polling');
// Rebuild the panel during the summary click, before the native toggle.
// A state snapshot taken only at redraw time misses this click and recloses it.
await queuePanel.getByText('Completed downloads · 1').click();
await page.evaluate(() => {
const summary = document.querySelector('.scraper-completed > summary');
summary.addEventListener('click', () => {
const buttons = document.querySelectorAll('.scraper-filters button');
buttons[1].click(); buttons[0].click();
}, { capture: true, once: true });
});
await queuePanel.getByText('Completed downloads · 1').click();
await page.waitForTimeout(3500);
assert.equal(await queuePanel.locator('.scraper-completed').evaluate(el => el.open), true, 'Completed section lost a click during redraw');
await queuePanel.getByText(/1 chapter published · finished/).waitFor();
await queuePanel.getByText(/Partial source coverage/).waitFor();
page.once('dialog', dialog => dialog.accept());
await page.getByRole('button', { name: 'Clear completed downloads (1)' }).click();
assert.deepEqual(commands.at(-1), { command: 'clear-completed', source: '' });
assert.equal(await queuePanel.locator('.scraper-completed').evaluate(el => el.open), true, 'Completed section collapsed after cleanup');
assert.equal(state.queue.some(item => item.id === 'main-fixture'), false);
assert.ok(state.queue.some(item => item.id === 'pilot-fixture'));
await page.getByRole('button', { name: 'MangaDex', exact: true }).click();
assert.equal(await page.getByText('Another manga').count(), 0);
await page.getByRole('button', { name: 'All sources', exact: true }).click();
await queuePanel.getByText('Another manga').waitFor();
await page.getByRole('link', { name: 'Updates', exact: true }).click();
await page.getByText(/Last checked:.*2026/).waitFor();
await page.getByText(/Last downloaded:.*2026/).waitFor();
await page.getByRole('button', { name: 'MangaDex', exact: true }).click();
await page.getByText('Recent check activity').click();
await page.waitForTimeout(3500);
assert.equal(await page.locator('.scraper-history').evaluate(el => el.open), true, 'Check history collapsed on polling');
assert.equal(await page.locator('[data-scraper-tab=updates] .scraper-history').getByText('Another manga', { exact: false }).count(), 0);
await page.getByRole('button', { name: 'All sources', exact: true }).click();
await page.getByRole('link', { name: 'Queue', exact: true }).click();
await page.getByText('Add manga to queue').click();
await page.waitForTimeout(3500);
assert.equal(await queuePanel.locator('.scraper-add').evaluate(el => el.open), true, 'Add form collapsed on polling');
await page.locator('input[type=url]').fill('https://mangadex.org/title/06a37094-b3ba-4f25-bf2b-54c9751e0e91');
await page.getByRole('button', { name: 'Inspect & queue', exact: true }).click();
assert.equal(commands.at(-1).inspect, true); assert.equal(commands.at(-1).command, 'enqueue');
await page.setViewportSize({ width: 390, height: 844 });
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
await page.screenshot({ path: path.join(shots, 'mobile.png'), fullPage: true });
const detailWidth = await page.locator('[data-scraper-tab=queue] .scraper-queue .scraper-item > .grow').first().evaluate(el => el.getBoundingClientRect().width);
assert.ok(detailWidth > 280, `Queue details squeezed by action buttons (${detailWidth}px)`);
assert.deepEqual(errors, []); assert.deepEqual(upstream, []);
console.log('PASS: scraper activity, queue cleanup, source filters, edition controls, add form and mobile layout; no source traffic');
} finally { await browser.close(); }
};
+87
View File
@@ -0,0 +1,87 @@
'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(); }
};
+109
View File
@@ -0,0 +1,109 @@
'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')).length, 0, 'Personal settings fetched admin data');
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);
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(); }
};