Files
MangaCircus/tools/test-compose.js
T

255 lines
19 KiB
JavaScript

#!/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');
const peerGatewayQa = process.argv.includes('--peer-gateway');
fs.mkdirSync(path.join(data, 'scraper'), { recursive: true }); fs.mkdirSync(path.join(data, 'sentinel'));
if (peerGatewayQa) {
const peerData = path.join(data, 'peering');
const peerUsage = path.join(data, 'peering-usage');
fs.mkdirSync(peerUsage, { recursive: true });
const { PeeringState } = require('../peering/state');
if (process.platform === 'win32' && !process.env.OPENSSL_BIN) process.env.OPENSSL_BIN = 'C:\\Program Files\\Git\\usr\\bin\\openssl.exe';
new PeeringState(peerData).generateIdentity('ComposeQA');
}
const pausedConfirmQa = process.argv.includes('--paused-confirm');
const readingGateQa = process.argv.includes('--reading-limit-ui');
if (readingGateQa) fs.writeFileSync(path.join(data, 'reading-quiz.json'), JSON.stringify({ version: 1, manga: {
'Moon Garden': [0, 1, 2].map(i => ({ id: `moon-${i}`, prompt: `Moon Garden question ${i}?`, answers: [`moonanswer${i}`], requires: ['1/1'], evidence: 'Disposable Compose fixture' })),
'Before the Rain': [0, 1, 2].map(i => ({ id: `rain-${i}`, prompt: `Before the Rain question ${i}?`, answers: [`rainanswer${i}`], requires: ['1/1'], evidence: 'Disposable Compose fixture' })),
} }));
fs.writeFileSync(path.join(data, 'scraper', 'queue.json'), JSON.stringify({ paused: true,
sourcePaused: pausedConfirmQa ? { weebcentral: true } : {},
queue: pausedConfirmQa ? [{ id: 'ready-fixture', source: 'weebcentral',
url: 'https://weebcentral.com/series/01J76XYAWYDTD62TB8GEBFTARZ/Fixture',
seriesId: '01J76XYAWYDTD62TB8GEBFTARZ', title: 'Fixture', sourceTitle: 'Fixture',
kind: 'inspection', status: 'ready' }] : [], 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',
READER_IMAGE: process.env.APP_IMAGE || 'mangacircus:local',
...(readingGateQa ? { READING_WINDOWS_JSON: '{"0":[],"1":[],"2":[],"3":[],"4":[],"5":[],"6":[]}',
READING_TIMEZONE: 'Europe/Berlin', READING_QUIZ_UNLOCK_MINUTES: '60', READING_QUIZ_CATALOG_PATH: '/data/reading-quiz.json' }
: { READING_WINDOWS_JSON: '', READING_QUIZ_CATALOG_PATH: '' }),
...(peerGatewayQa ? { PEER_DATA_PATH: path.join(data, 'peering'), PEER_USAGE_PATH: path.join(data, 'peering-usage'), PEER_PORT: process.env.QA_PEER_PORT || '8193' } : {}) };
const args = ['compose', '-p', project, '-f', path.join(root, 'docker-compose.yml'), ...(peerGatewayQa ? ['--profile', 'peering'] : [])];
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');
if (peerGatewayQa) {
const https = require('node:https');
try {
await until(() => new Promise(resolve => https.get({ hostname: '127.0.0.1', port: Number(env.PEER_PORT), path: '/health', rejectUnauthorized: false },
response => { response.resume(); resolve(response.statusCode === 200); }).on('error', () => resolve(false))), 'Peer gateway did not start');
} catch (error) { console.error(await docker('logs', '--tail', '30', 'peer-gateway').catch(() => 'Gateway logs unavailable')); throw error; }
const gateway = await docker('ps', '--format', 'json');
assert.ok(gateway.includes('peer-gateway'), 'Peer gateway missing from isolated Compose');
}
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) } : {}) });
assert.equal((await fetch(url + '/api/reading-schedule')).status, 401);
const schedule = await (await api('/api/reading-schedule')).json();
assert.equal(schedule.enabled, readingGateQa);
if (readingGateQa) {
assert.equal(schedule.timezone, 'Europe/Berlin');
assert.deepEqual(schedule.days, Array.from({ length: 7 }, () => []));
}
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;
if (readingGateQa) {
const manga = byTitle('Moon Garden'), other = byTitle('Before the Rain');
const chapter = (await (await api('/api/manga?m=' + encodeURIComponent(manga))).json()).chapters[0].id;
const chapterUrl = '/api/chapter?m=' + encodeURIComponent(manga) + '&c=' + encodeURIComponent(chapter);
const pageUrl = '/lib/' + encodeURIComponent(manga) + '/v01/c0001%20The%20Beginning/0001.png';
assert.equal((await api(chapterUrl)).status, 423, 'chapter JSON must be gated');
assert.equal((await api(pageUrl)).status, 423, 'chapter image must be gated');
assert.equal((await api('/lib/' + encodeURIComponent(manga) + '/cover.png')).status, 200);
assert.equal((await (await api('/api/reading-access?m=' + encodeURIComponent(manga))).json()).quizAvailable, false);
for (const title of [manga, other]) {
const id = (await (await api('/api/manga?m=' + encodeURIComponent(title))).json()).chapters[0].id;
assert.equal((await api('/api/progress', { m: title, c: id, p: 0, n: 1, done: true })).status, 200);
}
assert.equal((await (await api('/api/reading-access?m=' + encodeURIComponent(manga))).json()).quizAvailable, true);
const { chromium } = require('playwright');
const browser = await chromium.launch({ channel: 'msedge', headless: true });
try {
const context = await browser.newContext();
const [name, ...values] = cookie.split('=');
await context.addCookies([{ name, value: values.join('='), url }]);
const page = await context.newPage();
await page.goto(url + '/read.html?m=' + encodeURIComponent(manga) + '&c=' + encodeURIComponent(chapter));
await page.getByRole('heading', { name: 'Reading is closed right now' }).waitFor();
await page.getByRole('button', { name: 'Start five-question challenge' }).click();
await page.getByText('Question 1 of 5').waitFor();
const firstPrompt = await page.locator('.reading-gate-question h2').textContent();
const firstTitle = await page.locator('.reading-gate-question .muted').textContent();
const firstNumber = /question (\d)/.exec(firstPrompt)[1];
const answerBox = page.getByRole('textbox', { name: 'Your answer' });
await answerBox.fill('wrong');
await page.getByRole('button', { name: 'Check answer' }).click();
const retryNotice = page.locator('.reading-gate-message[data-tone="retry"]');
await retryNotice.waitFor();
const retryStyle = await retryNotice.evaluate(el => ({ color: getComputedStyle(el).color,
border: getComputedStyle(el).borderTopWidth, background: getComputedStyle(el).backgroundColor }));
assert.notEqual(retryStyle.border, '0px');
await answerBox.fill((firstTitle === 'Moon Garden' ? 'moonanswer' : 'rainanswer') + firstNumber);
await page.getByRole('button', { name: 'Check answer' }).click();
const successNotice = page.locator('.reading-gate-message[data-tone="success"]');
await successNotice.waitFor();
const successStyle = await successNotice.evaluate(el => ({ color: getComputedStyle(el).color,
background: getComputedStyle(el).backgroundColor }));
assert.notEqual(successStyle.color, retryStyle.color);
assert.notEqual(successStyle.background, retryStyle.background);
await page.getByText('Question 2 of 5').waitFor();
await page.goto(url + '/manga.html?m=' + encodeURIComponent(manga));
await page.goto(url + '/read.html?m=' + encodeURIComponent(manga) + '&c=' + encodeURIComponent(chapter));
await page.getByRole('button', { name: 'Start five-question challenge' }).waitFor();
} finally { await browser.close(); }
if (!process.argv.includes('--keep')) {
let question = await (await api('/api/reading-quiz/start', {})).json();
const wrong = await api('/api/reading-quiz/answer', { challenge: question.challenge, answer: 'incorrect' });
assert.equal((await wrong.json()).correct, false);
for (let i = 0; i < 5; i++) {
const match = /(?:Moon Garden|Before the Rain) question (\d)/.exec(question.prompt);
const answer = (question.mangaTitle === 'Moon Garden' ? 'moonanswer' : 'rainanswer') + match[1];
question = await (await api('/api/reading-quiz/answer', { challenge: question.challenge, answer })).json();
}
assert.equal(question.complete, true);
assert.equal((await api(chapterUrl)).status, 200);
const image = await api(pageUrl);
assert.equal(image.status, 200);
assert.equal(image.headers.get('cache-control'), 'private, no-store');
console.log('PASS: schedule blocks chapter and page bytes, progress-gated mixed-manga quiz unlocks reading');
} else {
console.log('PASS: isolated playable gate stays locked with progress-qualified sample questions');
}
}
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');
if (pausedConfirmQa) {
const { chromium } = require('playwright');
const browser = await chromium.launch({ channel: 'msedge', headless: true });
try {
const context = await browser.newContext();
const [name, ...values] = cookie.split('=');
await context.addCookies([{ name, value: values.join('='), url }]);
const page = await context.newPage();
await page.goto(url + '/settings.html#admin/scraper');
const row = page.locator('[data-scraper-tab=activity] .scraper-item').filter({ hasText: 'Fixture' });
await row.getByRole('button', { name: 'Confirm download' }).click();
await row.getByText('Confirmed · waiting for source access review').waitFor();
state = await (await api('/api/admin/scraper')).json();
assert.equal(state.sourcePaused.weebcentral, true);
assert.equal(state.queue.find(item => item.id === 'ready-fixture').status, 'queued');
assert.equal(state.queue.find(item => item.id === 'ready-fixture').kind, 'download');
console.log('PASS: paused-source confirmation returns success and queues without source traffic');
} finally { await browser.close(); }
}
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('--discovery-ui')) {
const login = await api('/api/login', { name: 'qa', password: 'disposable-local-test' });
assert.equal(login.status, 200);
await require('./test-discovery-ui')(url, login.headers.get('set-cookie').split(';')[0]);
}
if (process.argv.includes('--peer-ui')) {
const login = await api('/api/login', { name: 'qa', password: 'disposable-local-test' });
assert.equal(login.status, 200);
await require('./test-peer-ui')(url, login.headers.get('set-cookie').split(';')[0]);
}
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',
...(readingGateQa ? ['READING_WINDOWS_JSON', 'READING_TIMEZONE', 'READING_QUIZ_UNLOCK_MINUTES', 'READING_QUIZ_CATALOG_PATH'] : [])]
.map(k => `${k}=${k === 'READING_WINDOWS_JSON' ? `'${env[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; });