Discover page: refresh, harvest admin and seed reasons; docs (BL-0051)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MaashkinoozheandClaude Opus 5.5 committed 2026-10-25 09:28:38 +01:00
1 parent 0f87fbe948
commit 613db96355
10 files changed
+265 -58

No files matched your search

+23 -2
View File
@@ -18,6 +18,23 @@ if (peerGatewayQa) {
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');
}
const discoveryQa = process.argv.includes('--discovery-ui');
// Review focus: an untrusted AniList title in the QA admin's first feed must render as text, never markup.
const XSS_TITLE = '<img src=x onerror=alert(1)>';
if (discoveryQa) {
const fetchedAt = '2026-10-20T10:00:00.000Z';
const evidence = [{ id: 'recommendation:990001', kind: 'recommendation', label: 'AniList recommendation',
note: 'Fixture recommendation', url: 'https://anilist.co/manga/990001', fetchedAt }];
const reasons = ['AniList readers who liked Moon Garden recommend this (42).', 'New: started 2025, ongoing and trending.'];
fs.writeFileSync(path.join(data, 'discovery.json'), JSON.stringify({ schema: 1, revision: 1,
candidates: { 'anilist:990001': { id: 'anilist:990001', title: XSS_TITLE, year: 2025, status: 'RELEASING', cover: null,
seed: 'recs', fetchedAt, evidence, metadataUrl: 'https://anilist.co/manga/990001' } },
feeds: { qa: { generatedAt: fetchedAt, items: [{ candidateId: 'anilist:990001', reason: reasons[0], reasons, evidence,
evidenceIds: evidence.map(entry => entry.id), scoreParts: { communityRec: 3, fresh: 2 }, score: 5, period: 'Related works', group: 'recs' }] } },
lastRun: { status: 'success', startedAt: fetchedAt, finishedAt: fetchedAt, candidateCount: 1, examined: 1, clipped: 0 },
// Nightly off, so only the test's own clicks start harvests.
harvest: null, settings: { harvestEnabled: false, harvestAt: '03:30' }, nightly: { lastDate: null } }));
}
const pausedConfirmQa = process.argv.includes('--paused-confirm');
const readingGateQa = process.argv.includes('--reading-limit-ui');
if (readingGateQa) fs.writeFileSync(path.join(data, 'reading-quiz.json'), JSON.stringify({ version: 1, manga: {
@@ -45,6 +62,8 @@ for (const [index, name] of ['Moon Garden', 'Before the Rain', 'Iron Harbor'].en
fs.writeFileSync(path.join(library, name, 'manga.json'), JSON.stringify({ title: name,
direction: directionQa && index === 1 ? 'ltr' : process.argv.includes('--reading-ui') && index === 0 ? 'ttb' : 'rtl',
...(directionQa && index === 0 ? { 'x-reading-direction': { version: 1, value: 'rtl', source: 'fallback' } } : {}),
// AniList IDs make these owned titles harvest anchors; the unreachable endpoint turns their requests into upstream errors.
...(discoveryQa ? { 'x-anilist': { id: 990101 + index } } : {}),
status: index ? 'completed' : 'ongoing', tags: ['Fantasy', 'Demo'] }));
}
const env = { ...process.env, LIBRARY_PATH: library, DATA_PATH: data, SCRAPER_DATA_PATH: path.join(data, 'scraper'),
@@ -53,6 +72,8 @@ const env = { ...process.env, LIBRARY_PATH: library, DATA_PATH: data, SCRAPER_DA
...(readingGateQa ? { READING_WINDOWS_JSON: '{"0":[],"1":[],"2":[],"3":[],"4":[],"5":[],"6":[]}',
READING_TIMEZONE: 'Europe/Berlin', READING_QUIZ_UNLOCK_MINUTES: '60', READING_QUIZ_CATALOG_PATH: '/data/reading-quiz.json' }
: { READING_WINDOWS_JSON: '', READING_QUIZ_CATALOG_PATH: '' }),
// A closed local port: every harvest request fails fast and AniList is never contacted.
...(discoveryQa ? { DISCOVERY_ANILIST_URL: 'http://127.0.0.1:9/' } : {}),
...(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) => {
@@ -215,10 +236,10 @@ 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')) {
if (discoveryQa) {
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]);
await require('./test-discovery-ui')(url, login.headers.get('set-cookie').split(';')[0], { xssTitle: XSS_TITLE });
}
if (process.argv.includes('--peer-ui')) {
const login = await api('/api/login', { name: 'qa', password: 'disposable-local-test' });
+53 -3
View File
@@ -1,7 +1,7 @@
'use strict';
const assert = require('node:assert/strict');
module.exports = async function testDiscoveryUI(url, cookie) {
module.exports = async function testDiscoveryUI(url, cookie, { xssTitle } = {}) {
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 } });
@@ -9,24 +9,71 @@ module.exports = async function testDiscoveryUI(url, cookie) {
await context.addCookies([{ name, value: value.join('='), url }]);
const page = await context.newPage(), errors = [];
page.on('pageerror', error => errors.push(error.message));
// An injected title must never run script; any dialog counts as a failure.
page.on('dialog', dialog => { errors.push(`Unexpected dialog: ${dialog.message()}`); dialog.dismiss().catch(() => {}); });
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.getByText('the server sends AniList the IDs of titles you liked, without login').waitFor();
await page.getByLabel('Personalized discovery').check();
// Review focus 5: the seeded feed is the first one shown, before any run replaces it.
if (xssTitle) {
const seeded = page.locator('.discovery-card').filter({ hasText: 'AniList readers who liked Moon Garden' });
await seeded.waitFor();
assert.equal(await seeded.locator('h2').textContent(), xssTitle);
assert.equal(await page.locator('.discovery-card h2 img').count(), 0);
await seeded.getByText('Why this is here · evidence').click();
const details = await seeded.locator('details li').allTextContents();
assert.ok(details.some(text => text.includes('New: started 2025')), `All reasons listed: ${details}`);
assert.ok(details.some(text => /AniList recommendation.* · fetched \S+/.test(text)), `Evidence fetch date: ${details}`);
}
await page.getByRole('button', { name: 'Run discovery now' }).click();
// The seeded store already says "success"; wait until the run's own feed replaced the seeded card.
await page.waitForFunction(title => /Status: success/.test(document.querySelector('.discovery-admin')?.textContent || '')
&& ![...document.querySelectorAll('.discovery-card h2')].some(heading => heading.textContent === title), xssTitle, { timeout: 60000 });
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();
// Per-user refresh: the unreachable AniList endpoint ends it with upstream errors, then the cooldown shows.
const refresh = page.locator('.discovery-refresh');
await page.getByRole('button', { name: 'Refresh my suggestions' }).click();
await page.waitForFunction(() => /Next refresh possible at \d{1,2}:\d{2}/.test(document.querySelector('.discovery-refresh')?.textContent || ''),
null, { timeout: 90000 });
assert.equal(await refresh.isDisabled(), true);
await page.locator('.discovery-admin').getByText('Last harvest: stopped').waitFor({ timeout: 30000 });
await page.locator('.discovery-admin').getByText('upstream-errors').waitFor();
// Nightly settings save through the settings route and survive a reload.
await page.getByLabel('Automatic harvest').check();
await page.getByText('Harvest settings saved').waitFor();
await page.getByLabel('Harvest time').fill('04:15');
await page.getByText(/Harvest settings saved \(on, 04:15\)/).waitFor();
await page.reload();
await page.getByRole('heading', { name: 'Discovery job' }).waitFor();
assert.equal(await page.getByLabel('Harvest time').inputValue(), '04:15');
assert.equal(await page.getByLabel('Automatic harvest').isChecked(), true);
// Harvest now is single-flight: a second click while one runs shows the server's 409 message.
await page.getByRole('button', { name: 'Harvest now' }).click();
await page.getByText('Harvest started').waitFor();
await page.getByRole('button', { name: 'Harvest now' }).click();
await page.getByText('A harvest is already queued or running').waitFor();
assert.equal(errors.length, 0, `Browser errors: ${errors.join('; ')}`);
await page.setViewportSize({ width: 390, height: 844 });
await page.setViewportSize({ width: 375, height: 812 });
await page.getByRole('button', { name: /Refresh my suggestions|Next refresh possible|Queued|Fetching|Paused by AniList/ }).waitFor();
assert.ok(await refresh.isVisible());
const box = await refresh.boundingBox();
assert.ok(box && box.x >= 0 && box.x + box.width <= 375, `Refresh button off screen: ${JSON.stringify(box)}`);
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)}`);
assert.ok(overflow.scroll <= 375, `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);
@@ -36,7 +83,10 @@ module.exports = async function testDiscoveryUI(url, cookie) {
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.getByRole('button', { name: 'Harvest now' }).count(), 0);
assert.equal(await page.evaluate(() => fetch('/api/admin/discovery').then(response => response.status)), 403);
assert.equal(await page.evaluate(() => fetch('/api/admin/discovery/harvest', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
.then(response => response.status)), 403);
assert.equal(errors.length, 0, `Browser errors: ${errors.join('; ')}`);
} finally { await browser.close(); }
};