Checkpoint peering identity and opt-in discovery
This commit is contained in:
1 parent
eba9b50e30
commit
47de081721
44 files changed
+1557
-55
No files matched your search
@@ -0,0 +1,120 @@
|
||||
// Local-only research benchmark. Reads completed library files; writes no library state.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const zlib = require('node:zlib');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const library = path.resolve(__dirname, '..', 'library');
|
||||
const series = [
|
||||
'Solo Leveling',
|
||||
'Gosick',
|
||||
'The Martial God Who Regressed Back to Level 2',
|
||||
'The Exiled Heavy Knight Knows How to Game the System',
|
||||
];
|
||||
const extensions = new Set(['.jpg', '.jpeg', '.png', '.webp', '.avif']);
|
||||
const codecs = [
|
||||
{ name: 'gzip-6', encode: (b) => zlib.gzipSync(b, { level: 6 }), decode: zlib.gunzipSync },
|
||||
{ name: 'brotli-4', encode: (b) => zlib.brotliCompressSync(b, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } }), decode: zlib.brotliDecompressSync },
|
||||
{ name: 'zstd-3', encode: (b) => zlib.zstdCompressSync(b, { params: { [zlib.constants.ZSTD_c_compressionLevel]: 3 } }), decode: zlib.zstdDecompressSync },
|
||||
{ name: 'zstd-10', encode: (b) => zlib.zstdCompressSync(b, { params: { [zlib.constants.ZSTD_c_compressionLevel]: 10 } }), decode: zlib.zstdDecompressSync },
|
||||
];
|
||||
|
||||
function firstCompleteChapter(manga) {
|
||||
const root = path.join(library, manga);
|
||||
for (const volume of fs.readdirSync(root, { withFileTypes: true }).filter((x) => x.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const volumePath = path.join(root, volume.name);
|
||||
for (const chapter of fs.readdirSync(volumePath, { withFileTypes: true }).filter((x) => x.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const chapterPath = path.join(volumePath, chapter.name);
|
||||
if (!fs.existsSync(path.join(chapterPath, 'chapter.json'))) continue;
|
||||
const images = fs.readdirSync(chapterPath).filter((f) => extensions.has(path.extname(f).toLowerCase())).sort();
|
||||
if (images.length) return { chapterPath, images };
|
||||
}
|
||||
}
|
||||
throw new Error(`No complete chapter for ${manga}`);
|
||||
}
|
||||
|
||||
function measure(fn) {
|
||||
const begin = process.hrtime.bigint();
|
||||
const result = fn();
|
||||
return { result, ms: Number(process.hrtime.bigint() - begin) / 1e6 };
|
||||
}
|
||||
|
||||
const chapters = series.map((manga) => ({ manga, ...firstCompleteChapter(manga) }));
|
||||
const totals = Object.fromEntries(codecs.map((codec) => [codec.name, { bytes: 0, encodeMs: 0, decodeMs: 0 }]));
|
||||
const byChapter = [];
|
||||
const seenHashes = new Set();
|
||||
let rawBytes = 0;
|
||||
let imageCount = 0;
|
||||
let duplicateBytes = 0;
|
||||
let duplicateCount = 0;
|
||||
const formats = {};
|
||||
let metadataRaw = 0;
|
||||
let metadataZstd = 0;
|
||||
const adaptive = { sampleBytes: 32768, threshold: 0.03, transmittedBytes: 0, sampleMs: 0, selectedEncodeMs: 0, selectedDecodeMs: 0, compressedImages: 0, missedUsefulImages: 0, wastedImages: 0 };
|
||||
|
||||
for (const chapter of chapters) {
|
||||
const row = { manga: chapter.manga, chapter: path.relative(library, chapter.chapterPath), images: chapter.images.length, rawBytes: 0, formats: {}, codecs: {} };
|
||||
const buffers = [];
|
||||
for (const file of chapter.images) {
|
||||
const buffer = fs.readFileSync(path.join(chapter.chapterPath, file));
|
||||
buffers.push(buffer);
|
||||
imageCount++;
|
||||
rawBytes += buffer.length;
|
||||
row.rawBytes += buffer.length;
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
formats[ext] = (formats[ext] || 0) + buffer.length;
|
||||
row.formats[ext] = (row.formats[ext] || 0) + 1;
|
||||
const hash = crypto.createHash('sha256').update(buffer).digest('hex');
|
||||
if (seenHashes.has(hash)) { duplicateCount++; duplicateBytes += buffer.length; }
|
||||
else seenHashes.add(hash);
|
||||
const sample = buffer.subarray(0, Math.min(buffer.length, adaptive.sampleBytes));
|
||||
const sampleEncoded = measure(() => codecs[2].encode(sample));
|
||||
adaptive.sampleMs += sampleEncoded.ms;
|
||||
const shouldCompress = sampleEncoded.result.length <= sample.length * (1 - adaptive.threshold);
|
||||
for (const codec of codecs) {
|
||||
const encoded = measure(() => codec.encode(buffer));
|
||||
const decoded = measure(() => codec.decode(encoded.result));
|
||||
if (!decoded.result.equals(buffer)) throw new Error(`Round trip failed: ${codec.name} ${file}`);
|
||||
const total = totals[codec.name];
|
||||
total.bytes += encoded.result.length;
|
||||
total.encodeMs += encoded.ms;
|
||||
total.decodeMs += decoded.ms;
|
||||
const cell = row.codecs[codec.name] ||= { bytes: 0, encodeMs: 0, decodeMs: 0 };
|
||||
cell.bytes += encoded.result.length;
|
||||
cell.encodeMs += encoded.ms;
|
||||
cell.decodeMs += decoded.ms;
|
||||
if (codec.name === 'zstd-3') {
|
||||
if (shouldCompress) {
|
||||
adaptive.compressedImages++;
|
||||
adaptive.transmittedBytes += encoded.result.length;
|
||||
adaptive.selectedEncodeMs += encoded.ms;
|
||||
adaptive.selectedDecodeMs += decoded.ms;
|
||||
if (encoded.result.length >= buffer.length) adaptive.wastedImages++;
|
||||
} else {
|
||||
adaptive.transmittedBytes += buffer.length;
|
||||
if (encoded.result.length <= buffer.length * 0.97) adaptive.missedUsefulImages++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Packing a chapter lets zstd exploit any repetition across page boundaries.
|
||||
const packed = Buffer.concat(buffers);
|
||||
const chapterEncoded = measure(() => codecs[2].encode(packed));
|
||||
const chapterDecoded = measure(() => codecs[2].decode(chapterEncoded.result));
|
||||
if (!chapterDecoded.result.equals(packed)) throw new Error(`Packed round trip failed: ${chapter.manga}`);
|
||||
row.packedZstd3 = { bytes: chapterEncoded.result.length, encodeMs: chapterEncoded.ms, decodeMs: chapterDecoded.ms };
|
||||
const metadata = fs.readFileSync(path.join(chapter.chapterPath, 'chapter.json'));
|
||||
metadataRaw += metadata.length;
|
||||
metadataZstd += codecs[2].encode(metadata).length;
|
||||
byChapter.push(row);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
runtime: process.version,
|
||||
platform: `${process.platform}/${process.arch}`,
|
||||
sample: { imageCount, rawBytes, formats, duplicateCount, duplicateBytes, metadataRaw, metadataZstd },
|
||||
codecs: totals,
|
||||
adaptive,
|
||||
chapters: byChapter,
|
||||
method: 'One cold file read per image; encode/decode timed separately in-process using synchronous Node zlib. No network, TLS, disk-write, or browser decode time. Sizes omit protocol headers. Exact-byte round-trip verified.',
|
||||
}, null, 2));
|
||||
@@ -0,0 +1,8 @@
|
||||
'use strict';
|
||||
const https = require('node:https');
|
||||
const req = https.get({ host: '127.0.0.1', port: Number(process.env.PEER_PORT || 8093), path: '/health', rejectUnauthorized: false,
|
||||
timeout: 4000 }, res => {
|
||||
let text = ''; res.on('data', chunk => { text += chunk; if (text.length > 1000) req.destroy(); });
|
||||
res.on('end', () => { try { if (res.statusCode !== 200 || !JSON.parse(text).ok) process.exitCode = 1; } catch { process.exitCode = 1; } });
|
||||
});
|
||||
req.on('timeout', () => req.destroy()); req.on('error', () => { process.exitCode = 1; });
|
||||
+31
-2
@@ -8,7 +8,16 @@ const root = path.resolve(__dirname, '..'), dir = fs.mkdtempSync(path.join(os.tm
|
||||
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');
|
||||
}
|
||||
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();
|
||||
@@ -28,8 +37,9 @@ for (const [index, name] of ['Moon Garden', 'Before the Rain', 'Iron Harbor'].en
|
||||
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')];
|
||||
SCRAPER_TOKEN_PATH: tokenFile, READER_PORT: port, READER_BIND: '127.0.0.1', COOKIE_SECURE: '0', APP_IMAGE: process.env.APP_IMAGE || 'mangacircus:local',
|
||||
...(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));
|
||||
@@ -46,6 +56,15 @@ let keep = false;
|
||||
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) } : {}) });
|
||||
@@ -79,6 +98,16 @@ let keep = false;
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
module.exports = async function testDiscoveryUI(url, cookie) {
|
||||
const { chromium } = require('playwright');
|
||||
const browser = await chromium.launch({ channel: process.env.QA_BROWSER_CHANNEL || 'msedge', headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const [name, ...value] = cookie.split('=');
|
||||
await context.addCookies([{ name, value: value.join('='), url }]);
|
||||
const page = await context.newPage(), errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
try {
|
||||
await page.goto(url + '/discover.html');
|
||||
await page.getByRole('heading', { name: 'Discover', exact: true }).waitFor();
|
||||
await page.getByText('Turn on personalized discovery').waitFor();
|
||||
await page.getByLabel('Personalized discovery').check();
|
||||
await page.getByRole('button', { name: 'Run discovery now' }).click();
|
||||
await page.getByText(/suggestions/, { exact: false }).first().waitFor();
|
||||
await page.waitForFunction(() => document.querySelectorAll('.discovery-card').length > 0);
|
||||
await page.locator('.discovery-card').first().getByText('Why this is here · evidence').click();
|
||||
assert.ok(await page.locator('.discovery-card').first().locator('details li').count() > 0);
|
||||
await page.locator('.discovery-card').first().getByRole('button', { name: '👍 Maybe' }).click();
|
||||
await page.getByText('Preference saved').waitFor();
|
||||
assert.equal(errors.length, 0, `Browser errors: ${errors.join('; ')}`);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const overflow = await page.evaluate(() => ({ width: innerWidth, scroll: document.documentElement.scrollWidth,
|
||||
elements: [...document.querySelectorAll('body *')].filter(el => el.getBoundingClientRect().right > innerWidth + 1)
|
||||
.slice(0, 8).map(el => `${el.tagName}.${el.className}: ${Math.round(el.getBoundingClientRect().right)}`) }));
|
||||
assert.ok(overflow.scroll <= overflow.width, `Discover page overflows on mobile: ${JSON.stringify(overflow)}`);
|
||||
const create = await page.evaluate(() => fetch('/api/admin/user', { method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'discovery-reader-qa', password: 'disposable-reader-test', admin: false }) }).then(response => response.status));
|
||||
assert.equal(create, 200);
|
||||
await page.evaluate(() => fetch('/api/logout', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }));
|
||||
await page.evaluate(() => fetch('/api/login', { method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'discovery-reader-qa', password: 'disposable-reader-test' }) }));
|
||||
await page.reload();
|
||||
await page.getByRole('heading', { name: 'Discover', exact: true }).waitFor();
|
||||
assert.equal(await page.getByRole('button', { name: 'Run discovery now' }).count(), 0);
|
||||
assert.equal(await page.evaluate(() => fetch('/api/admin/discovery').then(response => response.status)), 403);
|
||||
assert.equal(errors.length, 0, `Browser errors: ${errors.join('; ')}`);
|
||||
} finally { await browser.close(); }
|
||||
};
|
||||
+54
-1
@@ -118,6 +118,49 @@ async function browserCheck(instance, cookie, peerId, shareId) {
|
||||
} 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 {
|
||||
@@ -138,6 +181,8 @@ async function main() {
|
||||
}), `${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}/`;
|
||||
@@ -149,6 +194,7 @@ async function main() {
|
||||
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) {
|
||||
@@ -161,6 +207,7 @@ async function main() {
|
||||
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 = [];
|
||||
@@ -173,8 +220,11 @@ async function main() {
|
||||
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/);
|
||||
@@ -189,7 +239,10 @@ async function main() {
|
||||
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');
|
||||
assert.equal(queue.queue.filter(item => item.url === `peer://${peerB.id}/${shareId}`).length, 1, 'Exactly one peer queue item is added');
|
||||
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', 'sentinel', '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.');
|
||||
|
||||
+20
-3
@@ -19,7 +19,7 @@ module.exports = async function testQueueUI(url, cookie) {
|
||||
];
|
||||
state.queue = [
|
||||
{ id: 'pilot-fixture', title: 'bleach', source: 'mangadex', kind: 'inspection', status: 'ready', sourceTitle: 'Bleach (Pre-Serialization)', availability: { selectedChapters: 2, specialReleases: 2, coverage: 'unknown' } },
|
||||
{ id: 'main-fixture', title: 'Bleach', source: 'mangadex', kind: 'download', status: 'complete', publishedCount: 1, sourceTitle: 'Bleach', availability: { selectedChapters: 1, expectedLastChapter: '686', coverage: 'partial' } },
|
||||
{ id: 'main-fixture', title: 'Bleach', source: 'mangadex', kind: 'download', status: 'complete', publishedCount: 1, sourceTitle: 'Bleach', availability: { selectedChapters: 1, numberedChapters: 1, expectedLastChapter: '686', highestHostedChapter: 686.1, coverage: 'unknown' } },
|
||||
{ id: 'external-fixture', title: 'Claymore', source: 'mangadex', kind: 'download', status: 'unavailable', error: 'MangaDex lists 8 English external-link releases, but no hosted English pages. External links are not downloaded.' },
|
||||
{ id: 'other-source-fixture', title: 'Another manga', source: 'weebcentral', kind: 'download', status: 'queued' },
|
||||
];
|
||||
@@ -63,7 +63,8 @@ module.exports = async function testQueueUI(url, cookie) {
|
||||
await page.waitForTimeout(3500);
|
||||
assert.equal(await queuePanel.locator('.scraper-completed').evaluate(el => el.open), true, 'Completed section lost a click during redraw');
|
||||
await queuePanel.getByText(/1 chapter published · finished/).waitFor();
|
||||
await queuePanel.getByText(/Partial source coverage/).waitFor();
|
||||
await queuePanel.getByText('Hosted selection complete', { exact: true }).waitFor();
|
||||
await queuePanel.getByText('Series coverage partial', { exact: true }).waitFor();
|
||||
page.once('dialog', dialog => dialog.accept());
|
||||
await page.getByRole('button', { name: 'Clear completed downloads (1)' }).click();
|
||||
assert.deepEqual(commands.at(-1), { command: 'clear-completed', source: '' });
|
||||
@@ -95,7 +96,23 @@ module.exports = async function testQueueUI(url, cookie) {
|
||||
await page.screenshot({ path: path.join(shots, 'mobile.png'), fullPage: true });
|
||||
const detailWidth = await page.locator('[data-scraper-tab=queue] .scraper-queue .scraper-item > .grow').first().evaluate(el => el.getBoundingClientRect().width);
|
||||
assert.ok(detailWidth > 280, `Queue details squeezed by action buttons (${detailWidth}px)`);
|
||||
await page.route('**/api/manga?*', async route => {
|
||||
const title = new URL(route.request().url()).searchParams.get('m');
|
||||
const first = title === 'Bleach' ? 336 : 1;
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({
|
||||
id: title, title, meta: { title, sources: [{ name: 'MangaDex', url: 'https://mangadex.org/' }] },
|
||||
cover: null, direction: 'rtl', books: [{ key: 'v01', label: 'Vol. 1' }],
|
||||
chapters: [{ id: `v01/c${first}`, book: 'v01', label: `Ch. ${first}`, title: '',
|
||||
versions: [{ id: 'fixture', pages: 1, added: '2026-10-01', source: 'MangaDex', score: 0, my: 0 }] }],
|
||||
collections: [], relations: [], following: false, reading: null, continue: null,
|
||||
}) });
|
||||
});
|
||||
await page.goto(url + '/manga.html?m=Bleach');
|
||||
await page.getByText('Series coverage partial', { exact: true }).waitFor();
|
||||
await page.getByText(/starts at chapter 336/).waitFor();
|
||||
await page.goto(url + '/manga.html?m=Complete%20Fixture');
|
||||
assert.equal(await page.getByText('Series coverage partial', { exact: true }).count(), 0);
|
||||
assert.deepEqual(errors, []); assert.deepEqual(upstream, []);
|
||||
console.log('PASS: scraper activity, queue cleanup, source filters, edition controls, add form and mobile layout; no source traffic');
|
||||
console.log('PASS: scraper activity, queue coverage, manga gap warning, cleanup, source filters, edition controls and mobile layout; no source traffic');
|
||||
} finally { await browser.close(); }
|
||||
};
|
||||
Reference in new issue
Block a user