244 lines
18 KiB
JavaScript
244 lines
18 KiB
JavaScript
'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(); }
|
|
};
|