Files
MaashkinoozheandClaude Opus 5.5 b2bb34174c Fold the sentinel into the scraper and drop the sentinel service (BL-0046)
The stall watchdog now runs inside the scraper (ScraperService.checkStalls,
every 30 s from the work loop): same rule as before, one checkpoint-safe
restart, quarantine on the second stall, missing checkpoint is no evidence,
and paused time no longer counts. Stalls are recorded as scraper incidents
(failure.kind 'stall'). The private /api/recovery endpoint is gone.

Dropped with the service: reader/scraper-unreachable incidents (supervisor
and Docker handle crashes; the health page shows "Scraper: unreachable"),
duplicate job-failure incidents and the unused /metrics endpoint.
The stack is now reader + scraper (+ optional peer-gateway).
The supervisor test moved to runtime/test (npm run test:runtime stays).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:58:03 +02:00

261 lines
19 KiB
JavaScript

#!/usr/bin/env node
'use strict';
// Disposable, two-project Compose acceptance. No live library, db or queue is mounted.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const fsp = fs.promises;
const os = require('node:os');
const path = require('node:path');
const crypto = require('node:crypto');
const net = require('node:net');
const { spawn } = require('node:child_process');
const { PeeringState } = require('../peering/state');
const { Canvas } = require('./png');
const ROOT = path.resolve(__dirname, '..');
const APP_IMAGE = process.env.PEER_AB_IMAGE || `mangacircus:peer-ab-${crypto.randomBytes(3).toString('hex')}`;
const TEST_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'mangacircus-peer-ab-'));
const projects = ['mc-peer-a-' + crypto.randomBytes(3).toString('hex'), 'mc-peer-b-' + crypto.randomBytes(3).toString('hex')];
const OPENSSL_BIN = process.env.OPENSSL_BIN || (process.platform === 'win32' ? 'C:\\Program Files\\Git\\usr\\bin\\openssl.exe' : 'openssl');
process.env.OPENSSL_BIN = OPENSSL_BIN;
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
async function freePort() {
const server = net.createServer();
await new Promise((resolve, reject) => server.once('error', reject).listen(0, '127.0.0.1', resolve));
const port = server.address().port;
await new Promise(resolve => server.close(resolve));
return port;
}
function command(bin, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(bin, args, { cwd: ROOT, windowsHide: true, env: options.env || process.env, stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '', stderr = '';
child.stdout.on('data', data => stdout += data);
child.stderr.on('data', data => stderr += data);
child.on('error', reject);
child.once('exit', code => code === 0 ? resolve(stdout.trim()) : reject(new Error(`${bin} ${args[0]} failed (${code}): ${stderr.slice(-4000)}`)));
});
}
function composeArgs(project) { return ['compose', '-p', project, '-f', path.join(ROOT, 'docker-compose.yml'), '--profile', 'peering']; }
async function docker(project, env, ...args) { return command('docker', [...composeArgs(project), ...args], { env }); }
async function until(check, message, attempts = 120) {
let last;
for (let i = 0; i < attempts; i++) {
try { if (await check()) return; } catch (error) { last = error; }
await sleep(500);
}
throw new Error(`${message}${last ? `: ${last.message}` : ''}`);
}
async function setup(label, readerPort, peerPort) {
const root = path.join(TEST_ROOT, label), library = path.join(root, 'library'), data = path.join(root, 'data');
const scraper = path.join(data, 'scraper'), peerData = path.join(data, 'peering');
const usage = path.join(data, 'peer-usage'), token = path.join(root, 'scraper-token');
for (const dir of [library, scraper, peerData, usage]) await fsp.mkdir(dir, { recursive: true });
await fsp.writeFile(path.join(scraper, 'queue.json'), JSON.stringify({ paused: true, queue: [], settings: { autoUpdateEnabled: false } }));
await fsp.writeFile(token, crypto.randomBytes(32).toString('hex'), { mode: 0o600 });
const png = new Canvas(180, 270, [55, 105, 150]).png();
const manga = `${label === 'a' ? 'Peer A' : 'Peer B'} Test Manga`;
await fsp.writeFile(path.join(library, '.fixture'), '');
const mangaDir = path.join(library, manga);
await fsp.mkdir(mangaDir);
await fsp.writeFile(path.join(mangaDir, 'cover.png'), png);
await fsp.writeFile(path.join(mangaDir, 'manga.json'), JSON.stringify({ title: manga, year: 2024,
status: 'ongoing', description: 'A disposable peer test title.', tags: ['Fixture', 'Peering'], genres: ['Test'], direction: 'ttb' }));
for (let n = 1; n <= 2; n++) {
const chapter = path.join(mangaDir, 'v01', `c${String(n).padStart(4, '0')} Chapter ${n}`);
await fsp.mkdir(chapter, { recursive: true });
await fsp.writeFile(path.join(chapter, 'chapter.json'), JSON.stringify({ title: `Chapter ${n}`, source: { name: 'LocalFixture' } }));
await fsp.writeFile(path.join(chapter, '0001.png'), png);
}
new PeeringState(peerData).generateIdentity(label === 'a' ? 'PeerA' : 'PeerB');
const env = { ...process.env, APP_IMAGE, VCS_REF: 'peer-redesign-test', LIBRARY_PATH: library, DATA_PATH: data,
SCRAPER_DATA_PATH: scraper, PEER_DATA_PATH: peerData, PEER_USAGE_PATH: usage,
SCRAPER_TOKEN_PATH: token, READER_PORT: String(readerPort), READER_BIND: '127.0.0.1',
PEER_PORT: String(peerPort), PEER_BIND: '127.0.0.1', COOKIE_SECURE: '0', TZ: 'UTC' };
return { label, root, library, data, peerData, manga, readerPort, peerPort, env, url: `http://127.0.0.1:${readerPort}` };
}
async function json(url, cookie, body) {
const response = await fetch(url, { headers: { ...(cookie ? { cookie } : {}), 'content-type': 'application/json' },
...(body === undefined ? {} : { method: 'POST', body: JSON.stringify(body) }) });
const data = response.headers.get('content-type')?.includes('application/json') ? await response.json() : await response.arrayBuffer();
return { response, data };
}
async function login(instance) {
const { response } = await json(`${instance.url}/api/signup`, null, { name: 'qa', password: 'DisposablePeerTest42' });
assert.equal(response.status, 200, `${instance.label}: first admin signup`);
return response.headers.get('set-cookie').split(';')[0];
}
async function browserCheck(instance, cookie, peerId, shareId) {
const { chromium } = require('playwright');
const browser = await chromium.launch({ channel: process.env.QA_BROWSER_CHANNEL || 'msedge', headless: true });
try {
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const [name, ...value] = cookie.split('='); await context.addCookies([{ name, value: value.join('='), url: instance.url }]);
const page = await context.newPage(), errors = []; page.on('pageerror', error => errors.push(error.message));
await page.goto(`${instance.url}/peer-catalog.html?peer=${encodeURIComponent(peerId)}#browse`);
await page.getByRole('heading', { name: 'Peer catalogs', exact: true }).waitFor();
await page.locator('.peer-catalog-card').filter({ hasText: 'Peer A Test Manga' }).waitFor();
assert.equal(await page.locator('.peer-cover').count(), 1, 'Remote cover must render in the catalog');
await page.getByRole('button', { name: 'Details', exact: true }).click();
const dialog = page.getByRole('dialog');
await dialog.getByText('A disposable peer test title.').waitFor();
assert.equal(await dialog.locator('.peer-chapter-option').count(), 2);
await dialog.locator('.peer-chapter-picker summary').click();
await dialog.getByLabel('Queue scope').selectOption('after');
await dialog.getByLabel('Chapter threshold').waitFor({ state: 'visible' });
const exactQueue = dialog.getByRole('button', { name: 'Queue checked chapters' });
assert.equal(await exactQueue.isDisabled(), true, 'Exact chapter queue starts disabled');
await dialog.getByLabel('Select chapter 1').check();
assert.equal(await exactQueue.isDisabled(), false);
await dialog.getByLabel('Select chapter 1').uncheck();
await dialog.getByRole('button', { name: 'Close details' }).click();
await page.getByRole('checkbox', { name: 'Select Peer A Test Manga' }).check();
await page.getByRole('button', { name: 'Queue 1 missing' }).click();
await page.getByText(/1 manga queued; 0 unavailable or failed/).waitFor();
assert.equal(errors.length, 0, `Peer catalog browser errors: ${errors.join('; ')}`);
} finally { await browser.close(); }
}
async function peerSettingsUiCheck(instance, cookie, options = {}) {
const { chromium } = require('playwright');
const browser = await chromium.launch({ channel: process.env.QA_BROWSER_CHANNEL || 'msedge', headless: true });
try {
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const [name, ...value] = cookie.split('='); await context.addCookies([{ name, value: value.join('='), url: instance.url }]);
const page = await context.newPage(), errors = []; page.on('pageerror', error => errors.push(error.message));
await page.goto(instance.url);
await page.locator('.nav[data-peer-catalog-resolved="true"]').waitFor();
assert.equal(await page.locator('.nav a[href="peer-catalog.html"]').count(), options.hasPeer ? 1 : 0,
'Peer catalogs nav appears only after a peer is configured');
if (options.peerId) {
await page.goto(`${instance.url}/peers.html#admin/peering/${encodeURIComponent(options.peerId)}`);
await page.getByRole('heading', { name: 'Access between servers' }).waitFor();
const issue = page.getByRole('button', { name: options.outboundGrant ? 'Save grant permissions' : 'Issue grant', exact: true });
if (options.paired) {
await page.getByText('Peering confirmed on both servers', { exact: true }).waitFor();
if (!options.outboundGrant) await page.getByLabel('Browse approved catalog').check();
assert.equal(await issue.isDisabled(), false, 'A selected grant permission enables the grant action after pairing');
} else {
await page.getByText('Identity not verified yet', { exact: true }).waitFor();
await page.getByLabel('Browse approved catalog').check();
await page.getByLabel('Download chapters (includes catalog access)').check();
assert.equal(await issue.isDisabled(), true, 'Grant issue remains locked before identity verification');
await page.getByText(/Granting is locked until identity pairing is complete/).waitFor();
}
if (options.outboundGrant) await page.getByText('Grant active', { exact: true }).waitFor();
if (options.inboundGrant) await page.getByText('Active grant from peer', { exact: true }).waitFor();
if (options.paired) {
const stateCard = page.locator('.peer-state-ok').first();
const visual = await stateCard.evaluate(element => {
const style = getComputedStyle(element);
return { weight: getComputedStyle(element.querySelector('strong')).fontWeight,
border: style.borderLeftWidth, background: style.backgroundColor };
});
assert.ok(Number(visual.weight) >= 700 && visual.border === '4px' && visual.background !== 'rgba(0, 0, 0, 0)',
`Confirmed state must be visually prominent: ${JSON.stringify(visual)}`);
}
}
assert.equal(errors.length, 0, `Peer settings browser errors: ${errors.join('; ')}`);
} finally { await browser.close(); }
}
async function main() {
let started = [], succeeded = false;
try {
const ports = [];
while (ports.length < 4) { const value = await freePort(); if (!ports.includes(value)) ports.push(value); }
const [portA, portB, gateA, gateB] = ports;
const A = await setup('a', portA, gateA), B = await setup('b', portB, gateB);
const args = ['build', '-t', APP_IMAGE, '--build-arg', 'VCS_REF=peer-redesign-test', '.'];
await command('docker', args);
for (const [index, instance] of [A, B].entries()) {
const project = projects[index];
await docker(project, instance.env, 'config', '-q');
await docker(project, instance.env, 'up', '-d'); started.push({ project, env: instance.env });
await until(async () => (await fetch(`${instance.url}/health`)).ok, `${instance.label}: reader health`);
await until(async () => new Promise(resolve => {
const https = require('node:https');
https.get({ hostname: '127.0.0.1', port: instance.peerPort, path: '/health', rejectUnauthorized: false }, res => { res.resume(); resolve(res.statusCode === 200); }).on('error', () => resolve(false));
}), `${instance.label}: peer gateway health`);
}
const cookieA = await login(A), cookieB = await login(B);
await peerSettingsUiCheck(A, cookieA, { hasPeer: false });
await peerSettingsUiCheck(B, cookieB, { hasPeer: false });
const stateA = (await json(`${A.url}/api/admin/peering`, cookieA)).data;
const stateB = (await json(`${B.url}/api/admin/peering`, cookieB)).data;
const hostUrlA = `https://host.docker.internal:${gateA}/`, hostUrlB = `https://host.docker.internal:${gateB}/`;
const inviteA = (await json(`${A.url}/api/admin/peering`, cookieA, { command: 'invite', url: hostUrlA })).data.invitation;
const inviteB = (await json(`${B.url}/api/admin/peering`, cookieB, { command: 'invite', url: hostUrlB })).data.invitation;
let importedA = await json(`${A.url}/api/admin/peering`, cookieA, { command: 'import', invitation: inviteB });
let importedB = await json(`${B.url}/api/admin/peering`, cookieB, { command: 'import', invitation: inviteA });
assert.equal(importedA.response.status, 200); assert.equal(importedB.response.status, 200);
const peerA = importedA.data.peers.find(peer => peer.fingerprint === inviteB.fingerprint);
const peerB = importedB.data.peers.find(peer => peer.fingerprint === inviteA.fingerprint);
assert.ok(peerA && peerB);
await peerSettingsUiCheck(B, cookieB, { hasPeer: true, peerId: peerB.id, paired: false });
const probeA = await json(`${A.url}/api/admin/peering`, cookieA, { command: 'probe', id: peerA.id });
const probeB = await json(`${B.url}/api/admin/peering`, cookieB, { command: 'probe', id: peerB.id });
if (probeA.response.status !== 200 || probeB.response.status !== 200) {
console.error('Peer probe responses:', JSON.stringify({ a: probeA.data, b: probeB.data }));
for (const [index, instance] of [A, B].entries()) {
console.error(`${instance.label} gateway logs:\n${await docker(projects[index], instance.env, 'logs', '--tail', '60', 'peer-gateway').catch(() => '(unavailable)')}`);
console.error(`${instance.label} reader logs:\n${await docker(projects[index], instance.env, 'logs', '--tail', '60', 'reader').catch(() => '(unavailable)')}`);
}
}
assert.equal(probeA.response.status, 200); assert.equal(probeB.response.status, 200);
assert.equal(probeA.data.peers.find(peer => peer.id === peerA.id).paired, true);
assert.equal(probeB.data.peers.find(peer => peer.id === peerB.id).paired, true);
await peerSettingsUiCheck(A, cookieA, { hasPeer: true, peerId: peerA.id, paired: true });
assert.ok(stateA.identity?.fingerprint && stateB.identity?.fingerprint);
let libraryA = [];
await until(async () => { libraryA = (await json(`${A.url}/api/admin/peering`, cookieA)).data.library || []; return libraryA.some(item => item.title === A.manga); }, 'A fixture manga was not scanned');
const latestLibraryA = (await json(`${A.url}/api/admin/peering`, cookieA)).data.library || libraryA;
const mangaA = latestLibraryA.find(item => item.title === A.manga);
assert.ok(mangaA, 'A fixture manga is indexed');
let saved = await json(`${A.url}/api/admin/peering`, cookieA, { command: 'share-set', id: peerA.id, mangaIds: [mangaA.id] });
assert.equal(saved.response.status, 200); assert.equal(saved.data.peers.find(peer => peer.id === peerA.id).shares.length, 1);
const grant = await json(`${A.url}/api/admin/peering`, cookieA, { command: 'grant', id: peerA.id, scopes: ['catalog'] });
assert.equal(grant.response.status, 200);
assert.equal((await json(`${B.url}/api/admin/peering`, cookieB, { command: 'accept-grant', id: peerB.id, token: grant.data.grant.token })).response.status, 200);
await peerSettingsUiCheck(A, cookieA, { hasPeer: true, peerId: peerA.id, paired: true, outboundGrant: true });
await peerSettingsUiCheck(B, cookieB, { hasPeer: true, peerId: peerB.id, paired: true, inboundGrant: true });
const catalog = await json(`${B.url}/api/peering/catalog?peer=${encodeURIComponent(peerB.id)}`, cookieB);
assert.equal(catalog.response.status, 200); assert.equal(catalog.data.items.length, 1);
assert.equal(catalog.data.items[0].workIdentity?.scheme, 'mangacircus-work-v1', 'Peer catalogs expose a stable, opaque work identity');
const shareId = catalog.data.items[0].id;
const cover = await fetch(`${B.url}/api/peering/cover?peer=${encodeURIComponent(peerB.id)}&manga=${shareId}`, { headers: { cookie: cookieB } });
assert.equal(cover.status, 200); assert.match(cover.headers.get('content-type'), /^image\/png/);
const denied = await json(`${B.url}/api/peering/queue`, cookieB, { peer: peerB.id, manga: shareId, scope: { mode: 'missing' } });
assert.equal(denied.response.status, 403, 'Catalog grant alone must not permit chapter queueing');
const updateGrant = await json(`${A.url}/api/admin/peering`, cookieA, { command: 'set', id: peerA.id, patch: { grantScopes: ['download'] } });
assert.equal(updateGrant.response.status, 200);
const accessB = await json(`${B.url}/api/peering/access?peer=${encodeURIComponent(peerB.id)}`, cookieB);
assert.deepEqual(accessB.data.grant.scopes, ['catalog', 'download'], 'Grant scope updates must not need token reimport');
const receiving = await json(`${B.url}/api/admin/peering`, cookieB, { command: 'set', id: peerB.id, patch: { receive: true } });
assert.equal(receiving.response.status, 200);
await browserCheck(B, cookieB, peerB.id, shareId);
const queue = (await json(`${B.url}/api/admin/scraper`, cookieB)).data;
assert.equal(queue.paused, true, 'Peer testing must leave the scraper queue paused');
const peerQueueItems = queue.queue.filter(item => item.url === `peer://${peerB.id}/${shareId}`);
assert.equal(peerQueueItems.length, 1, 'Exactly one peer queue item is added');
assert.deepEqual(peerQueueItems[0].workIdentity, catalog.data.items[0].workIdentity,
'The resolved work identity is persisted on the queued peer transfer');
const volumes = await Promise.all(started.map(({ project, env }) => docker(project, env, 'ps', '--format', '{{.Service}}')));
for (const services of volumes) for (const name of ['reader', 'scraper', 'peer-gateway']) assert.ok(services.includes(name), `${name} is running in A and B`);
console.log('PASS: isolated Compose A↔B pairing, catalog-only access, cover proxy, grant update, browser details/bulk queue, one paused queue writer, and admin-only API/UI boundary.');
console.log(`Test Compose projects: ${projects.join(', ')}; disposable roots: ${TEST_ROOT}`);
succeeded = true;
} finally {
if (!succeeded && process.argv.includes('--keep-on-failure')) console.error(`Failure fixtures retained for cleanup: ${TEST_ROOT}; projects: ${projects.join(', ')}`);
else {
for (const { project, env } of [...started].reverse()) await docker(project, env, 'down').catch(error => console.error(`Cleanup warning for ${project}: ${error.message}`));
await fsp.rm(TEST_ROOT, { recursive: true, force: true });
if (!process.env.PEER_AB_IMAGE) await command('docker', ['image', 'rm', APP_IMAGE]).catch(() => {});
}
}
}
main().catch(error => { console.error(error.stack || error); process.exitCode = 1; });