115 lines
9.4 KiB
JavaScript
115 lines
9.4 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');
|
|
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; });
|