'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(); } };