Consolidate MangaCircus app, scraper, and Compose baseline

This commit is contained in:
Maashkinoozhe committed 2026-10-07 23:28:20 +02:00
1 parent cfb2d79103
commit eef4ffa07c
103 files changed
+9316 -172

No files matched your search

+243
View File
@@ -0,0 +1,243 @@
'use strict';
// Real browser, disposable Compose storage; never uses a production account/catalog.
const assert = require('node:assert/strict');
const fs = require('node:fs'), path = require('node:path');
module.exports = async function testCollectionsUI(url, cookie) {
const { chromium } = require('playwright');
const browser = await chromium.launch({ channel: process.env.QA_BROWSER_CHANNEL || 'msedge', headless: true });
const origin = 'http://manga-qa.test:' + new URL(url).port;
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const [name, ...value] = cookie.split('=');
await context.addCookies([{ name, value: value.join('='), url: origin }]);
let delaySave = false, failSave = false, saveCount = 0;
// Proxy only this synthetic origin to the local disposable fixture. The browser
// still sees ordinary non-loopback HTTP, as it does on a Tailscale IP address.
await context.route(origin + '/**', async route => {
if (new URL(route.request().url()).pathname === '/api/admin/catalog' && route.request().method() === 'POST') {
saveCount++;
if (failSave) { failSave = false; return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'Fixture temporary save failure' }) }); }
if (delaySave) await new Promise(resolve => setTimeout(resolve, 600));
}
const response = await route.fetch({ url: url + route.request().url().slice(origin.length) });
await route.fulfill({ response });
});
const page = await context.newPage(), errors = [];
page.on('pageerror', e => errors.push(e.message));
const shots = path.resolve(__dirname, '..', 'data', 'qa', 'collections');
fs.mkdirSync(shots, { recursive: true });
const catalog = () => page.evaluate(() => fetch('/api/admin/catalog').then(r => r.json()));
const status = page.getByRole('status');
const save = async (button = 'Save collection') => {
await page.getByRole('button', { name: button, exact: true }).click();
await page.waitForFunction(() => document.querySelector('[role=status]')?.textContent === 'Saved');
};
const noOverflow = async () => assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'Collections page has horizontal overflow');
try {
await page.goto(origin + '/collections.html');
await page.getByRole('button', { name: 'New collection', exact: true }).waitFor();
assert.equal(await page.evaluate(() => isSecureContext), false, 'QA must reproduce an insecure browser origin');
assert.equal(await page.evaluate(() => typeof crypto.randomUUID), 'undefined');
await page.getByRole('button', { name: 'New collection', exact: true }).click();
if (process.argv.includes('--expect-collections-bug')) {
await page.waitForTimeout(200);
assert.ok(errors.some(e => e.includes('randomUUID')), 'Expected to reproduce the creation failure');
assert.equal(await page.getByLabel('Collection title', { exact: true }).count(), 0);
console.log('REPRODUCED: New collection throws crypto.randomUUID is not a function on non-loopback HTTP');
return;
}
await page.getByLabel('Collection title', { exact: true }).waitFor();
await page.getByLabel('Collection title', { exact: true }).fill('Harbor Stories');
await page.getByLabel('Collection description', { exact: true }).fill('A QA reading collection');
assert.match(await status.textContent(), /Unsaved/);
// An unrelated metadata action must not silently wipe a collection draft.
await page.getByRole('button', { name: 'Import cached metadata suggestions', exact: true }).click();
assert.match(await status.textContent(), /Save your changes/);
assert.equal(await page.getByLabel('Collection title', { exact: true }).inputValue(), 'Harbor Stories');
delaySave = true;
const beforeSave = saveCount;
await page.getByRole('button', { name: 'Save collection', exact: true }).click();
assert.equal(await page.getByRole('button', { name: 'Save collection', exact: true }).isDisabled(), true);
await page.evaluate(() => document.querySelector('fieldset button.btn.primary').click());
await page.waitForFunction(() => document.querySelector('[role=status]')?.textContent === 'Saved');
assert.equal(saveCount, beforeSave + 1, 'Duplicate save issued while one was in flight');
delaySave = false;
let state = await catalog();
const created = state.collections.find(c => c.title === 'Harbor Stories');
assert.ok(created); assert.match(created.id, /^[a-f0-9-]{36}$/);
const harborId = state.mangas.find(m => m.title === 'Iron Harbor').id;
const rainId = state.mangas.find(m => m.title === 'Before the Rain').id;
const moonId = state.mangas.find(m => m.title === 'Moon Garden').id;
assert.equal(created.members[0].mangaId, harborId);
await page.reload();
await page.getByLabel('Choose collection', { exact: true }).selectOption(created.id);
assert.equal(await page.getByLabel('Collection title', { exact: true }).inputValue(), 'Harbor Stories');
assert.equal(await page.getByLabel('Collection description', { exact: true }).inputValue(), 'A QA reading collection');
await page.getByRole('button', { name: 'New collection', exact: true }).click();
assert.match(await status.textContent(), /All manga are already grouped/);
assert.equal(await page.locator('[data-member] button').isDisabled(), true, 'Last member must not be removable');
// Server validation must be visible, recoverable, and retain the draft.
await page.getByLabel('Collection title', { exact: true }).fill('');
await page.getByRole('button', { name: 'Save collection', exact: true }).click();
await page.getByText('Collection title is required', { exact: false }).waitFor();
assert.equal(await page.getByRole('button', { name: 'Save collection', exact: true }).isEnabled(), true);
await page.getByLabel('Collection title', { exact: true }).fill('Harbor Stories');
await save();
// Edit/reorder and remove a member from the original fixture collection.
await page.getByLabel('Choose collection', { exact: true }).selectOption('moon-garden');
const member = page.locator('[data-member]').filter({ hasText: 'Before the Rain' });
await member.getByLabel('Member role').selectOption('side-story');
await member.getByLabel('Reading order').fill('0');
await page.getByLabel('Cover mode', { exact: true }).selectOption('selected');
await page.getByLabel('Cover manga', { exact: true }).selectOption(rainId);
await save();
state = await catalog();
assert.equal(state.collections.find(c => c.id === 'moon-garden').members[0].mangaId, rainId);
assert.equal(state.collections.find(c => c.id === 'moon-garden').coverMangaId, rainId);
await member.getByRole('button', { name: 'Remove', exact: true }).click();
await save();
state = await catalog();
assert.equal(state.collections.find(c => c.id === 'moon-garden').coverMangaId, moonId, 'Removing the cover member must repair the cover selection');
await page.getByLabel('Choose collection', { exact: true }).selectOption(created.id);
await page.getByLabel('Add manga to collection', { exact: true }).selectOption(rainId);
await page.getByLabel('Cover mode', { exact: true }).selectOption('mosaic');
await save();
state = await catalog();
assert.equal(state.collections.find(c => c.id === created.id).members.length, 2);
assert.equal(new Set(state.collections.flatMap(c => c.members.map(m => m.mangaId))).size, 3);
// Simulate a second admin's revision. A stale write must not overwrite it.
const other = await fetch(url + '/api/admin/catalog', { method: 'POST', headers: { cookie, 'content-type': 'application/json' },
body: JSON.stringify({ ...state, collections: state.collections.map(c => c.id === created.id ? { ...c, description: 'Changed by another editor' } : c) }) });
assert.equal(other.status, 200);
await page.getByLabel('Collection title', { exact: true }).fill('My unsaved title');
await page.getByRole('button', { name: 'Save collection', exact: true }).click();
await page.getByText('Catalog changed; reload before saving', { exact: false }).waitFor();
assert.equal(await page.getByLabel('Collection title', { exact: true }).inputValue(), 'My unsaved title');
page.once('dialog', d => d.dismiss());
await page.getByRole('button', { name: 'Reload catalog', exact: true }).click();
assert.equal(await page.getByLabel('Collection title', { exact: true }).inputValue(), 'My unsaved title');
page.once('dialog', d => d.accept());
await page.getByRole('button', { name: 'Reload catalog', exact: true }).click();
await page.getByText('Catalog reloaded', { exact: true }).waitFor();
assert.equal(await page.getByLabel('Collection description', { exact: true }).inputValue(), 'Changed by another editor');
// Network/server failure is visible and retryable without losing edits.
await page.getByLabel('Collection description', { exact: true }).fill('Retry kept my draft');
failSave = true;
await page.getByRole('button', { name: 'Save collection', exact: true }).click();
await page.getByText('Fixture temporary save failure', { exact: false }).waitFor();
assert.equal(await page.getByLabel('Collection description', { exact: true }).inputValue(), 'Retry kept my draft');
await save('Save all changes');
// Custom cover: real image encoding, backend validation, persisted selection.
const png = new (require('./png').Canvas)(80, 120, [140, 60, 90]).png();
await page.getByLabel('Custom cover', { exact: true }).setInputFiles({ name: 'qa.png', mimeType: 'image/png', buffer: png });
await page.getByText('Cover saved', { exact: true }).waitFor();
state = await catalog();
const cover = state.collections.find(c => c.id === created.id).customCover;
assert.ok(cover); assert.equal(state.collections.find(c => c.id === created.id).coverMode, 'custom');
assert.equal(await page.getByLabel('Cover mode', { exact: true }).inputValue(), 'custom');
assert.equal(await page.evaluate(id => fetch('/api/collection-cover?id=' + id).then(r => r.status), created.id), 200);
await page.getByLabel('Custom cover', { exact: true }).setInputFiles({ name: 'large.png', mimeType: 'image/png', buffer: Buffer.alloc(600001) });
await page.getByText('Choose an image under 600 KB.', { exact: true }).waitFor();
assert.equal((await catalog()).collections.find(c => c.id === created.id).customCover, cover);
// Relationships: create, reject, duplicate prevention, remove, cached import.
await page.getByLabel('Relationship source', { exact: true }).selectOption(harborId);
await page.getByLabel('Relationship target', { exact: true }).selectOption(moonId);
await page.getByLabel('Relationship type', { exact: true }).selectOption('spin-off');
await save('Add relationship');
const relation = page.locator('[data-relation]').filter({ hasText: 'spin-off' });
await relation.getByLabel('Relationship state', { exact: true }).selectOption('rejected');
await page.waitForFunction(() => document.querySelector('[role=status]')?.textContent === 'Saved');
assert.equal((await catalog()).relations.find(r => r.type === 'spin-off').state, 'rejected');
await page.getByLabel('Relationship source', { exact: true }).selectOption(harborId);
await page.getByLabel('Relationship target', { exact: true }).selectOption(moonId);
await page.getByLabel('Relationship type', { exact: true }).selectOption('spin-off');
const beforeDuplicate = saveCount;
await page.getByRole('button', { name: 'Add relationship', exact: true }).click();
assert.match(await status.textContent(), /already exists/); assert.equal(saveCount, beforeDuplicate);
await relation.getByRole('button', { name: 'Remove', exact: true }).click();
await page.waitForFunction(() => document.querySelector('[role=status]')?.textContent === 'Saved');
assert.ok(!(await catalog()).relations.some(r => r.type === 'spin-off'));
await save('Import cached metadata suggestions');
await page.getByRole('button', { name: 'Research relationships', exact: true }).click();
await page.getByText('Assign a verified AniList identity first', { exact: true }).waitFor();
await page.waitForFunction(() => !document.querySelector('fieldset').disabled);
assert.equal(await page.getByRole('button', { name: 'Research relationships', exact: true }).isEnabled(), true);
// Advanced JSON validation + round trip.
await page.getByText('Advanced · JSON import/export & agent research packet', { exact: true }).click();
await page.getByLabel('Catalog JSON', { exact: true }).fill('{');
await page.getByRole('button', { name: 'Validate & save JSON', exact: true }).click();
assert.equal(await page.getByLabel('Catalog JSON', { exact: true }).inputValue(), '{');
await page.getByLabel('Choose collection', { exact: true }).selectOption('moon-garden');
await page.getByLabel('Choose collection', { exact: true }).selectOption(created.id);
await page.getByText('Advanced · JSON import/export & agent research packet', { exact: true }).click();
assert.equal(await page.getByLabel('Catalog JSON', { exact: true }).inputValue(), '{', 'Changing collection discarded the JSON draft');
const beforeJSON = saveCount;
await page.getByRole('button', { name: 'Save all changes', exact: true }).click();
assert.match(await status.textContent(), /Validate & save JSON first/);
assert.equal(saveCount, beforeJSON, 'Ordinary save silently overwrote the JSON draft');
await page.getByLabel('Catalog JSON', { exact: true }).fill(JSON.stringify(await catalog()));
await save('Validate & save JSON');
await noOverflow();
await page.evaluate(() => scrollTo(0, 0));
await page.screenshot({ path: path.join(shots, 'desktop-editor.png'), fullPage: true });
// Mobile can edit + save too, not merely display the editor.
await page.setViewportSize({ width: 390, height: 844 });
await page.getByLabel('Collection description', { exact: true }).fill('Saved from mobile');
await save(); await noOverflow();
for (const row of await page.locator('[data-member]').all()) {
const text = await row.locator('span').first().boundingBox();
assert.ok(text.width > 150, 'Mobile member title is squeezed between controls');
}
await page.evaluate(() => scrollTo(0, 0));
await page.screenshot({ path: path.join(shots, 'mobile-editor.png'), fullPage: true });
// Reader presentation: grouped tiles, custom cover, order, expand/individual.
await page.goto(origin + '/');
await page.getByRole('link', { name: 'Harbor Stories', exact: false }).waitFor();
assert.equal(await page.locator('.tile').count(), 2);
await page.getByRole('link', { name: 'Harbor Stories', exact: false }).click();
await page.getByRole('heading', { name: 'Harbor Stories', exact: true }).waitFor();
assert.equal(await page.locator('.collection-member').count(), 2);
assert.match(await page.locator('.collection-member').first().textContent(), /Iron Harbor/);
assert.equal(await page.locator('.hero img').evaluate(img => img.complete && img.naturalWidth > 0), true);
await page.goto(origin + '/');
await page.getByLabel('Collection display', { exact: true }).selectOption('individual');
assert.equal(await page.locator('.tile').count(), 3);
await page.getByLabel('Collection display', { exact: true }).selectOption('expanded');
assert.equal(await page.locator('.tile').count(), 3);
assert.equal(await page.locator('.collection-heading').count(), 2);
// Group flag and deletion never delete manga or reading content.
await page.goto(origin + '/collections.html?id=' + created.id);
await page.getByLabel('Group on the dashboard', { exact: true }).uncheck(); await save();
assert.equal((await catalog()).collections.find(c => c.id === created.id).grouped, false);
page.once('dialog', d => d.dismiss());
await page.getByRole('button', { name: 'Delete collection', exact: true }).click();
assert.ok((await catalog()).collections.some(c => c.id === created.id));
page.once('dialog', d => d.accept());
await save('Delete collection');
state = await catalog(); assert.ok(!state.collections.some(c => c.id === created.id));
assert.equal(state.mangas.length, 3); assert.equal(state.relations.length, 1);
await page.reload();
await page.getByLabel('Choose collection', { exact: true }).waitFor();
assert.equal(await page.getByLabel('Choose collection', { exact: true }).locator('option').count(), 2);
// A reader can see groups, but cannot retrieve/change the admin catalog.
const result = await page.evaluate(async () => {
const post = (p, b) => fetch(p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) });
const created = await post('/api/admin/user', { name: 'collections-reader-qa', password: 'disposable-reader-test', admin: false });
if (!created.ok) return created.status;
await post('/api/logout', {});
return (await post('/api/login', { name: 'collections-reader-qa', password: 'disposable-reader-test' })).status;
});
assert.equal(result, 200);
assert.equal(await page.evaluate(() => fetch('/api/admin/catalog').then(r => r.status)), 403);
assert.equal(await page.evaluate(() => fetch('/api/admin/catalog', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }).then(r => r.status)), 403);
await page.goto(origin + '/collections.html?qa=reader');
await page.getByRole('link', { name: 'Back to the library', exact: true }).waitFor();
assert.equal(await page.getByRole('button', { name: 'New collection', exact: true }).count(), 0);
assert.equal(await page.evaluate(() => fetch('/api/collections').then(r => r.status)), 200);
assert.deepEqual(errors, []);
console.log('PASS: collections create/edit/save/reload, members/order/covers, relationships, JSON, failure/conflict recovery, mobile, dashboard and deletion on non-loopback HTTP');
console.log('Screenshots: ' + shots);
} catch (e) {
await page.screenshot({ path: path.join(shots, 'failure.png'), fullPage: true }).catch(() => {});
throw e;
} finally { await browser.close(); }
};