#!/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'), backupDir = path.join(dir, 'backup'), 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')); fs.mkdirSync(backupDir); 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, BACKUP_PATH: backupDir, 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; // Built-in backup through the real control API: archive written, paused queue left paused. const queueBefore = JSON.parse(fs.readFileSync(path.join(data, 'scraper', 'queue.json'), 'utf8')); assert.equal((await api('/api/admin/backup', { command: 'run' })).status, 200); let backupState; await until(async () => { backupState = await (await api('/api/admin/backup')).json(); return backupState.last && !backupState.running; }, 'Backup did not finish'); assert.equal(backupState.last.error, undefined, backupState.last.error); assert.ok(fs.existsSync(path.join(backupDir, backupState.last.file)), 'Backup archive missing on the host'); assert.equal(JSON.parse(fs.readFileSync(path.join(data, 'scraper', 'queue.json'), 'utf8')).paused, queueBefore.paused); 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', 'BACKUP_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; });