Files
MangaCircus/scraper/test/checkpoint-location.test.js
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

74 lines
3.1 KiB
JavaScript

'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { ScraperService } = require('../service');
test('status and watchdog read the checkpoint for each source layout', async t => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mc-checkpoint-location-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
for (const [source, id, folder] of [
['weebcentral', 'W1', '.mcscraper-work-W1'],
['mangadex', 'D1', '.mcscraper-work-D1'],
['mangapill', 'P1', '.mcscraper-work-mangapill-P1'],
]) {
const dir = path.join(root, folder);
await fs.mkdir(dir);
await fs.writeFile(path.join(dir, 'job.json'), JSON.stringify({
status: 'downloading', publicationMode: 'incremental', jobCompleted: 3,
total: 10, updated: '2026-10-14T12:00:00Z', current: { downloadedPages: 4 },
}));
const service = Object.create(ScraperService.prototype);
service.libraryRoot = root;
const job = await service.jobState({ source, seriesId: id, kind: 'download' });
assert.equal(job.completed, 3, source);
assert.equal(job.current.downloadedPages, 4, source);
}
});
function stallService(job) {
const service = new ScraperService({ stallMs: 100 });
service.state = { paused: false, sourcePaused: {}, currentIds: { a: 'job' }, incidents: [],
queue: [{ id: 'job', source: 'a', title: 'Manga', status: 'downloading' }] };
service.jobState = async () => job.value;
service.event = async () => {};
return service;
}
test('stall check leaves progressing and deliberately paused jobs alone', async () => {
const job = { value: { completed: 0, current: { downloadedPages: 1 } } };
const service = stallService(job), item = service.state.queue[0];
await service.checkStalls(0);
job.value = { completed: 0, current: { downloadedPages: 2 } };
await service.checkStalls(500000);
service.state.paused = true;
await service.checkStalls(1000000);
assert.equal(item.stopReason, undefined);
// Paused time does not count: the clock restarts on resume.
service.state.paused = false;
await service.checkStalls(1000050);
assert.equal(item.stopReason, undefined);
await service.checkStalls(1190000);
assert.equal(item.stopReason, 'recovering');
assert.equal(item.stallRecoveries, 1);
assert.equal(service.state.incidents[0].failure.kind, 'stall');
});
test('a second stall quarantines the job', async () => {
const service = stallService({ value: { completed: 2, total: 5 } }), item = service.state.queue[0];
item.stallRecoveries = 1;
await service.checkStalls(0); await service.checkStalls(1000000);
assert.equal(item.stopReason, 'quarantined');
assert.equal(item.failure.kind, 'stall');
});
test('missing checkpoint does not trigger a false stall recovery', async () => {
const service = stallService({ value: null });
for (const now of [0, 1000000, 2000000]) await service.checkStalls(now);
assert.equal(service.state.queue[0].stopReason, undefined);
assert.equal(service.state.incidents.length, 0);
});