diff --git a/docs/greatest.md b/docs/greatest.md index c4245f1..bd383ac 100644 --- a/docs/greatest.md +++ b/docs/greatest.md @@ -12,7 +12,10 @@ Primary-source checks for selected concrete claims in the seed include [Tezuka's - `greatest/catalog.json` is a versioned, read-only editorial seed in the application image. Entries have stable IDs, debut year, cohort, curator rank, rationale, official source lead, and optional AniList cover/chapter/status metadata. It is **not** part of `library/`, scraper queue, or `data/catalog.json` collections. - `greatest/index.js` validates the seed and performs only exact title/alias matching against the existing library index. A match adds the local cover, chapter count, status and **In library** badge; it never moves or downloads a title. - `data/db.json` holds `greatestRatings[userName][entryId]`. Each account can select `flame`, `good`, `neutral`, or `skip`, or click its current choice to clear it. Existing chapter-version votes are separate. Deleting a user removes their ratings. Include `data/db.json` in backups as before. -- The only write route is authenticated `POST /api/greatest/rating` with `{id,rating}` (`null` clears). Authenticated `GET /api/greatest` returns the catalog, per-user choice, valid-user community counts, score, and local-library match. Unknown IDs/ratings are rejected. There is no admin-only gate: every account may vote. +- The rating write route is authenticated `POST /api/greatest/rating` with `{id,rating}` (`null` clears). Authenticated `GET /api/greatest` returns the catalog, per-user choice, valid-user community counts, score, local-library match, and supported series links from that manga's library metadata. Unknown IDs/ratings are rejected. There is no admin-only gate for voting. +- The details popup shows MangaDex and WeebCentral **series** links from the matched library manga's `manga.json`. Admin-only `GET /api/greatest/sources?id=…` checks live scraper queue state by source series ID and conservative title/alias match. Queue-only title matches are visibly marked as needing edition verification; no search result is silently treated as a confirmed match. If the scraper is unavailable, library links remain visible while queue actions are disabled. +- Admins can use `GET /api/greatest/mangadex-search?id=…` for a single on-demand, metadata-only title lookup (10 candidates maximum, two-second minimum between uncached lookups, one-hour result cache). The UI shows candidate title and year so an admin can choose the right edition. The WeebCentral search-page link and a pasted series URL cover sources without a safe automatic title lookup. Neither source is bulk-searched when the dashboard opens. +- **Add to scraper** uses the existing admin `enqueue` command with `inspect:true`, `scope:{mode:"missing"}`, `existingPolicy:"skip-any"`, and the owned library ID when available. It adds an inspection job; an admin must confirm any download later in Admin → Scraper. Existing jobs are marked with their queue status and cannot be added again from a known link. The daemon remains responsible for URL validation, duplicate prevention, and source protections. Ordinary users can view known links but cannot inspect queue state or add jobs. Within each cohort, **Rank by my vote** sorts flame → good → neutral → skull → unrated; **Rank by everyone** uses `3×flames + thumbs-up − 2×skulls`, with curator rank as the tie-breaker. These are preference scores, not independent critical ratings. The quick chips filter by *your* ratings (or in-library status), even when community sorting is active. The popup shows the full rationale, metadata, official source link, community counts, and a library link where available. @@ -20,7 +23,7 @@ Within each cohort, **Rank by my vote** sorts flame → good → neutral → sku The one-time seed is curated from the research document. All 106 catalog entries now have a cover URL. Most were matched to public AniList manga metadata; the five initial gaps were checked individually. Phoenix (1954) and Nausicaä use matching AniList records; Kitarō uses Drawn & Quarterly's Kitaro edition, Someone Hertz uses Shonen Jump's first-volume cover, and Hitoner uses Shueisha's 2026 first-volume cover. Hitoner's AniList 2024 entry represents an earlier work, so it was **not** used for the 2026 series. The five added URLs returned image content on 2026-10-09, but remote hosts can change them; the UI still shows a title-letter fallback if an image fails. -`node tools/enrich-greatest-catalog.js` is an **optional manual** public-AniList metadata refresh, paced at one request every 1.5 seconds. It only accepts matching manga title and **original serialization year**, leaving uncertain results untouched rather than using a sequel or remake cover. The server makes **no metadata calls at runtime**. Browser covers are lazy-loaded from their listed source; local library covers take precedence. +`node tools/enrich-greatest-catalog.js` is an **optional manual** public-AniList metadata refresh, paced at one request every 1.5 seconds. It only accepts matching manga title and **original serialization year**, leaving uncertain results untouched rather than using a sequel or remake cover. The dashboard makes **no metadata calls when loading**; only an admin pressing **Search MangaDex** triggers a bounded lookup. Browser covers are lazy-loaded from their listed source; local library covers take precedence. Public catalog chapter counts are often absent; the UI says “Chapters unknown” rather than inventing one. For owned titles, the local library count wins. Status comes from AniList's releasing/finished flag or local library metadata, and may be unknown. Official source links may be publisher editions, chapter services, or an award/info lead; they do not imply all chapters are available in every country. No unverified hoster coverage is claimed. diff --git a/greatest/hosters.js b/greatest/hosters.js new file mode 100644 index 0000000..21c67fa --- /dev/null +++ b/greatest/hosters.js @@ -0,0 +1,43 @@ +'use strict'; + +const normalize = value => String(value || '').normalize('NFKD').toLowerCase().replace(/[^a-z0-9]+/g, ''); + +function seriesLink(value) { + let url; + try { url = new URL(String(value || '')); } catch { return null; } + if (url.protocol !== 'https:') return null; + const weeb = /^\/series\/([A-Z0-9]{26})(?:\/[^/?#]+)?\/?$/i.exec(url.pathname); + if (url.hostname === 'weebcentral.com' && weeb) return { + source: 'weebcentral', label: 'WeebCentral', seriesId: weeb[1].toUpperCase(), + url: `https://weebcentral.com${url.pathname.replace(/\/$/, '')}`, + }; + const dex = /^\/title\/([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})(?:\/[^/?#]+)?\/?$/i.exec(url.pathname); + if (['mangadex.org', 'www.mangadex.org'].includes(url.hostname) && dex) return { + source: 'mangadex', label: 'MangaDex', seriesId: dex[1].toLowerCase(), + url: `https://mangadex.org/title/${dex[1].toLowerCase()}`, + }; + return null; +} + +const keyOf = link => `${link.source}:${link.seriesId}`; + +function hosterLinks(entry, owned, queue = []) { + const names = new Set([entry.title, ...(entry.aliases || [])].map(normalize)); + const known = new Map(); + for (const source of owned?.meta?.sources || []) { + const link = seriesLink(source.url); + if (link) known.set(keyOf(link), { ...link, origin: 'library' }); + } + const jobs = []; + for (const item of queue) { + const link = seriesLink(item.url); + if (!link) continue; + if (item.libraryMangaId && owned && item.libraryMangaId !== owned.id) continue; + if (!(owned && item.libraryMangaId === owned.id) && !names.has(normalize(item.title)) && !known.has(keyOf(link))) continue; + if (!known.has(keyOf(link))) known.set(keyOf(link), { ...link, origin: 'queue title match' }); + jobs.push({ id: item.id, source: link.source, seriesId: link.seriesId, kind: item.kind, status: item.status }); + } + return { links: [...known.values()], jobs }; +} + +module.exports = { seriesLink, hosterLinks }; diff --git a/public/greatest.css b/public/greatest.css index 2428a21..b9879c4 100644 --- a/public/greatest.css +++ b/public/greatest.css @@ -37,6 +37,12 @@ .greatest-reason-heading { margin: 18px 0 3px; font-size: 15px; color: #e9a47d; } .greatest-dialog-body p { margin: 9px 0; } .greatest-links { display: flex; flex-wrap: wrap; gap: 8px; margin: 17px 0; } +.greatest-hoster-panel { margin-top: 15px; border-top: 1px solid var(--line); padding-top: 4px; } +.greatest-hoster-row, .greatest-hoster-tools { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 8px 0; } +.greatest-hoster-row > a { overflow-wrap: anywhere; } +.greatest-hoster-row > .btn { margin-left: auto; } +.greatest-hoster-tools > input { flex: 1 1 220px; min-width: 0; } +.greatest-hoster-results { max-height: 240px; overflow: auto; padding: 0 8px; border: 1px solid var(--line); border-radius: 8px; } @media (max-width: 680px) { .greatest-toolbar { align-items: stretch; flex-direction: column; } .greatest-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 12px; } diff --git a/public/greatest.js b/public/greatest.js index d870aa7..ce06802 100644 --- a/public/greatest.js +++ b/public/greatest.js @@ -1,5 +1,5 @@ 'use strict'; -/* Curated catalog UI. Rating is the only write; the library and scraper are untouched. */ +/* Curated catalog UI. Ratings are personal; scraper inspection is admin-only. */ (async function () { let me, data; try { [me, data] = await Promise.all([api('api/me'), api('api/greatest')]); } catch (error) { return showError(error); } @@ -24,6 +24,8 @@ } const results = h('div', { class: 'greatest-periods' }); const dialog = h('dialog', { class: 'greatest-dialog', 'aria-label': 'Manga details' }); + const sourceCache = new Map(); + let detailId = null; document.body.append(dialog); mine.onclick = () => { sortMode = 'mine'; store.set('greatest-sort', sortMode); render(); }; everyone.onclick = () => { sortMode = 'all'; store.set('greatest-sort', sortMode); render(); }; @@ -90,6 +92,7 @@ function plainNote(note) { return String(note || '').replace(/\*/g, ''); } function openDetails(item) { + detailId = item.id; dialog.textContent = ''; const close = h('button', { type: 'button', class: 'btn small', onclick: () => dialog.close() }, 'Close'); const links = h('div', { class: 'greatest-links' }); @@ -97,6 +100,7 @@ if (safeHref(item.source?.url)) links.append(h('a', { class: 'btn', href: item.source.url, target: '_blank', rel: 'noopener noreferrer' }, `Official ${item.source.kind || 'source'} ↗`)); if (safeHref(item.metadata?.url)) links.append(h('a', { class: 'btn', href: item.metadata.url, target: '_blank', rel: 'noopener noreferrer' }, 'AniList metadata ↗')); + const hosterPanel = h('section', { class: 'greatest-hoster-panel', 'aria-label': 'Scraper sources' }); const votes = item.votes || {}; dialog.append( h('div', { class: 'greatest-dialog-head' }, h('span', { class: 'muted small' }, `${item.period} · curator #${item.rank}`), close), @@ -106,9 +110,95 @@ h('h3', { class: 'greatest-reason-heading' }, 'Why it matters'), h('p', {}, plainNote(item.note)), h('p', { class: 'muted small' }, `Community votes: 🔥 ${votes.flame || 0} · 👍 ${votes.good || 0} · ● ${votes.neutral || 0} · 💀 ${votes.skip || 0}`), - ratingButtons(item), links, + ratingButtons(item), links, hosterPanel, h('p', { class: 'muted small' }, 'Chapter counts and status come from public catalog metadata, or from your local library when present. Availability may vary by region.')))); if (!dialog.open) dialog.showModal(); + renderHosters(item, hosterPanel, sourceCache.get(item.id)); + if (me.admin && !sourceCache.has(item.id)) refreshHosters(item, hosterPanel); + } + + async function refreshHosters(item, panel) { + try { + const state = await api(`api/greatest/sources?id=${encodeURIComponent(item.id)}`); + sourceCache.set(item.id, state); + if (detailId === item.id && dialog.open) renderHosters(item, panel, state); + } catch (error) { + if (detailId === item.id && dialog.open) renderHosters(item, panel, { available: false, error: error.message, links: item.hosters || [] }); + } + } + + function renderHosters(item, panel, state) { + panel.textContent = ''; + panel.append(h('h3', { class: 'greatest-reason-heading' }, 'MangaDex / WeebCentral')); + if (me.admin && !state) { + panel.append(h('p', { class: 'muted small' }, 'Checking scraper queue…')); + return; + } + const links = state?.links || item.hosters || []; + const jobs = state?.jobs || []; + if (!links.length) panel.append(h('p', { class: 'muted small' }, 'No confirmed series link yet. A title search may find a different edition; check it before adding.')); + for (const link of links) { + const job = jobs.find(job => job.source === link.source && job.seriesId === link.seriesId); + panel.append(h('div', { class: 'greatest-hoster-row' }, + h('a', { href: link.url, target: '_blank', rel: 'noopener noreferrer' }, `${link.label} ↗`), + link.origin === 'queue title match' ? h('span', { class: 'muted small' }, 'Queue title match · verify edition') : null, + me.admin ? h('span', { class: 'muted small' }, job ? `In scraper · ${job.status.replaceAll('_', ' ')}` : state?.available === false ? 'Queue status unavailable' : 'Not in scraper queue') : null, + me.admin && state?.available !== false ? queueButton(item, link.url, job) : null)); + } + if (me.admin) { + if (state?.available === false) panel.append(h('p', { class: 'small error' }, `Scraper status unavailable: ${state.error || 'unknown error'}. Queue actions are disabled.`)); + const message = h('span', { class: 'small', role: 'status' }); + const search = h('button', { class: 'btn small', type: 'button', onclick: async () => { + search.disabled = true; message.textContent = 'Looking up MangaDex titles…'; + try { + const found = await api(`api/greatest/mangadex-search?id=${encodeURIComponent(item.id)}`); + message.textContent = ''; + const results = h('div', { class: 'greatest-hoster-results' }); + for (const candidate of found.results || []) { + const existing = jobs.find(job => job.source === 'mangadex' && job.seriesId === candidate.url.split('/').at(-1)); + results.append(h('div', { class: 'greatest-hoster-row' }, + h('a', { href: candidate.url, target: '_blank', rel: 'noopener noreferrer' }, `${candidate.title}${candidate.year ? ` (${candidate.year})` : ''} ↗`), + existing ? h('span', { class: 'muted small' }, `In scraper · ${existing.status}`) : null, + state?.available !== false ? queueButton(item, candidate.url, existing) : null)); + } + if (!found.results?.length) results.append(h('p', { class: 'muted small' }, 'No MangaDex candidates found.')); + panel.append(h('p', { class: 'muted small' }, 'Search candidates are not verified matches. Check title, year, and edition before adding.'), results); + } catch (error) { message.textContent = error.message || String(error); } + finally { search.disabled = false; } + } }, 'Search MangaDex'); + const refresh = h('button', { class: 'btn small', type: 'button', onclick: () => refreshHosters(item, panel) }, 'Refresh queue status'); + panel.append(h('div', { class: 'greatest-hoster-tools' }, search, + h('a', { class: 'btn small', href: 'https://weebcentral.com/search', target: '_blank', rel: 'noopener noreferrer' }, 'Search WeebCentral ↗'), + refresh, message)); + const manual = h('input', { type: 'url', placeholder: 'Paste a MangaDex or WeebCentral series URL', 'aria-label': 'Series URL' }); + const add = h('button', { class: 'btn small', type: 'button', disabled: state?.available === false, onclick: () => inspectUrl(item, manual.value, add, message, panel) }, 'Add URL to scraper'); + panel.append(h('div', { class: 'greatest-hoster-tools' }, manual, add)); + } + } + + function queueButton(item, url, job) { + return h('button', { class: 'btn small', type: 'button', disabled: !!job, + title: job ? `Already in scraper: ${job.status}` : 'Queue an inspection; confirm in Admin → Scraper before downloading', + onclick: async event => { + const panel = event.currentTarget.closest('.greatest-hoster-panel'); + await inspectUrl(item, url, event.currentTarget, null, panel); + } }, job ? 'Already added' : 'Add to scraper'); + } + + async function inspectUrl(item, url, button, message, panel) { + button.disabled = true; + try { + await api('api/admin/scraper', { command: 'enqueue', url: String(url || '').trim(), title: item.title, + metadataSearch: item.title, libraryMangaId: item.library?.id, scope: { mode: 'missing' }, + existingPolicy: 'skip-any', inspect: true }); + if (message) message.textContent = 'Added for inspection. Confirm any download in Admin → Scraper.'; + sourceCache.delete(item.id); + await refreshHosters(item, panel); + } catch (error) { + if (message) message.textContent = error.message || String(error); + else alert(error.message || String(error)); + button.disabled = false; + } } function matches(item, q) { diff --git a/server.js b/server.js index f14070b..fca1428 100644 --- a/server.js +++ b/server.js @@ -16,6 +16,7 @@ const { scanLibrary, scanChangedLibrary, libraryStamps, snapshotIndex, restoreIn const { Catalog, CatalogError, collectionViews, TYPES, ROLES } = require('./catalog'); const { RATINGS: GREATEST_RATINGS, loadCatalog: loadGreatestCatalog, findOwned: findGreatestOwned, countsFor: greatestCounts, scoreFor: greatestScore } = require('./greatest'); +const { hosterLinks } = require('./greatest/hosters'); const { secret } = require('./runtime/config'); const { applyPulse, applyVisit, chapterInsight, eligiblePages, emptyRecord, libraryInsight, mangaInsight, publicSummary, @@ -204,6 +205,28 @@ async function scraperRequest(endpoint, body) { return data; } +const mangaDexSearchCache = new Map(); +let lastMangaDexSearch = 0; +async function searchGreatestMangaDex(entry) { + const cached = mangaDexSearchCache.get(entry.id); + if (cached && Date.now() - cached.time < 60 * 60 * 1000) return cached.results; + if (Date.now() - lastMangaDexSearch < 2000) bad(429, 'Wait two seconds before another MangaDex lookup'); + lastMangaDexSearch = Date.now(); + let response; + try { + response = await fetch(`https://api.mangadex.org/manga?title=${encodeURIComponent(entry.title)}&limit=10`, + { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(7000) }); + } catch (error) { throw new HttpError(503, `MangaDex lookup unavailable: ${error.message}`); } + if (!response.ok) throw new HttpError(response.status === 429 ? 503 : 502, `MangaDex lookup returned HTTP ${response.status}`); + const body = await response.json(); + const results = (body.data || []).filter(row => /^[0-9a-f-]{36}$/i.test(row.id)).map(row => ({ + title: row.attributes?.title?.en || Object.values(row.attributes?.title || {})[0] || 'Untitled', + year: row.attributes?.year || null, url: `https://mangadex.org/title/${row.id}`, + })); + mangaDexSearchCache.set(entry.id, { time: Date.now(), results }); + return results; +} + // Versions sorted by the sum of all user votes; ties go to the higher "priority" in chapter.json, then folder order. function ranked(m, ch) { const votes = (db.votes[m.id] && db.votes[m.id][ch.id]) || {}; @@ -380,6 +403,7 @@ const routes = { chapters: owned ? owned.chapters.length : entry.chapters, status: owned?.meta?.status || entry.status, library: owned ? { id: owned.id, chapters: owned.chapters.length } : null, + hosters: hosterLinks(entry, owned).links, myRating: db.greatestRatings?.[u.name]?.[entry.id] || null, votes: counts, score: greatestScore(counts), }; @@ -397,6 +421,24 @@ const routes = { const votes = greatestCounts(id, all, db.users); return { id, myRating: rating, votes, score: greatestScore(votes) }; }, + 'GET /api/greatest/sources': async ({ u, q }) => { + admin(u); + const entry = greatestById.get(String(q.get('id') || '')); + if (!entry) bad(404, 'Catalog entry not found'); + const owned = findGreatestOwned(entry, lib.mangas); + try { + const state = await scraperRequest('/api/state'); + return { available: true, ...hosterLinks(entry, owned, state.queue || []) }; + } catch (error) { + return { available: false, error: error.message, ...hosterLinks(entry, owned) }; + } + }, + 'GET /api/greatest/mangadex-search': async ({ u, q }) => { + admin(u); + const entry = greatestById.get(String(q.get('id') || '')); + if (!entry) bad(404, 'Catalog entry not found'); + return { results: await searchGreatestMangaDex(entry) }; + }, 'GET /api/manga': ({ u, q }) => { const m = mangaOf(q.get('m')), prog = progressOf(u, m), showStory = settingsOf(u).readingStory; diff --git a/test/greatest.test.js b/test/greatest.test.js index 4915696..6988363 100644 --- a/test/greatest.test.js +++ b/test/greatest.test.js @@ -2,6 +2,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const { loadCatalog, findOwned, countsFor, scoreFor } = require('../greatest'); +const { seriesLink, hosterLinks } = require('../greatest/hosters'); test('curated manga catalog has unique identities and ordered five-year periods', () => { const catalog = loadCatalog(); @@ -30,3 +31,21 @@ test('library matching and vote aggregation stay independent of scraper data', ( assert.deepEqual(counts, { flame: 1, good: 1, neutral: 0, skip: 0 }); assert.equal(scoreFor(counts), 4); }); + +test('hoster links use confirmed series URLs and do not mistake unrelated queue entries for matches', () => { + const entry = { title: 'BLAME!', aliases: ['Blame'] }; + const weeb = 'https://weebcentral.com/series/01J76XY8CJ2RJGWMHM69ZS0J3H'; + const dex = 'https://mangadex.org/title/06a37094-b3ba-4f25-bf2b-54c9751e0e91/bleach'; + const owned = { id: 'BLAME!', meta: { sources: [{ name: 'WeebCentral', url: weeb }] } }; + const result = hosterLinks(entry, owned, [ + { id: 'match', title: 'blame', url: weeb, status: 'complete', kind: 'download' }, + { id: 'other', title: 'Bleach', url: dex, status: 'queued', kind: 'download' }, + ]); + assert.equal(result.links.length, 1); + assert.equal(result.links[0].source, 'weebcentral'); + assert.equal(result.jobs.length, 1); + assert.equal(result.jobs[0].status, 'complete'); + assert.equal(seriesLink('https://weebcentral.com/chapters/01J76XY8CJ2RJGWMHM69ZS0J3H'), null); + assert.equal(seriesLink('http://weebcentral.com/series/01J76XY8CJ2RJGWMHM69ZS0J3H'), null); + assert.equal(seriesLink(dex).seriesId, '06a37094-b3ba-4f25-bf2b-54c9751e0e91'); +}); diff --git a/tools/test-greatest-ui.js b/tools/test-greatest-ui.js index d80bd97..8de5bc2 100644 --- a/tools/test-greatest-ui.js +++ b/tools/test-greatest-ui.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'), os = require('node:os'), path = require('node:path'), net = require('node:net'); const { spawn } = require('node:child_process'); +const http = require('node:http'); const { chromium } = require('playwright'); const { loadCatalog } = require('../greatest'); const root = path.resolve(__dirname, '..'); @@ -20,9 +21,26 @@ async function freePort() { const library = path.join(dir, 'library'), data = path.join(dir, 'data'); fs.mkdirSync(library); fs.mkdirSync(data); const port = await freePort(), base = `http://127.0.0.1:${port}`; + const queue = [{ id: 'existing', title: 'Astro Boy', url: 'https://mangadex.org/title/ca4c84bb-7272-45aa-a22d-dc1282b52372', + source: 'mangadex', seriesId: 'ca4c84bb-7272-45aa-a22d-dc1282b52372', kind: 'download', status: 'queued' }]; + const fakeScraper = http.createServer(async (req, res) => { + let result; + if (req.method === 'GET' && req.url === '/api/state') result = { queue }; + else if (req.method === 'POST' && req.url === '/api/queue') { + let body = ''; for await (const chunk of req) body += chunk; + const input = JSON.parse(body); + queue.push({ id: 'added', title: input.title, url: input.url, source: 'weebcentral', + seriesId: String(input.url || '').split('/series/')[1]?.split('/')[0] || '', + kind: input.inspect ? 'inspection' : 'download', status: 'queued' }); + result = { item: queue.at(-1) }; + } else { res.writeHead(404); return res.end(); } + res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(result)); + }); + await new Promise(resolve => fakeScraper.listen(0, '127.0.0.1', resolve)); + const scraperPort = fakeScraper.address().port; const child = spawn(process.execPath, ['server.js'], { cwd: root, windowsHide: true, env: { ...process.env, PORT: String(port), LIBRARY: library, DATA: data, SCAN_INTERVAL: '0', - FULL_SCAN_INTERVAL: '0', SCRAPER_URL: 'http://127.0.0.1:1', COOKIE_SECURE: '0' }, stdio: 'ignore' }); + FULL_SCAN_INTERVAL: '0', SCRAPER_URL: `http://127.0.0.1:${scraperPort}`, COOKIE_SECURE: '0' }, stdio: 'ignore' }); let browser; try { for (let i = 0; i < 80; i++) { @@ -59,8 +77,18 @@ async function freePort() { await page.getByRole('button', { name: 'Details for Astro Boy' }).click(); await page.getByRole('dialog').getByRole('heading', { name: 'Why it matters' }).waitFor(); assert.ok((await page.getByRole('dialog').textContent()).includes(astroReason)); + await page.getByRole('dialog').getByText('In scraper · queued').waitFor(); + assert.equal(await page.getByRole('dialog').getByRole('button', { name: 'Already added' }).isDisabled(), true); await page.getByRole('dialog').getByText('Community votes: 🔥 1').waitFor(); await page.getByRole('dialog').getByRole('button', { name: 'Close' }).click(); + await page.getByRole('button', { name: 'Details for Princess Knight' }).click(); + await page.getByRole('dialog').getByRole('textbox', { name: 'Series URL' }) + .fill('https://weebcentral.com/series/01J76XY7E9FNDZ1DBBM6PBJPF0'); + await page.getByRole('dialog').getByRole('button', { name: 'Add URL to scraper' }).click(); + await page.getByRole('dialog').getByText('In scraper · queued').waitFor(); + assert.equal(queue.at(-1).kind, 'inspection'); + assert.equal(queue.at(-1).title, 'Princess Knight'); + await page.getByRole('dialog').getByRole('button', { name: 'Close' }).click(); await page.setViewportSize({ width: 390, height: 844 }); assert.equal(await astro.locator('.greatest-reason').isVisible(), true); assert.equal(await page.locator('.nav-greatest').evaluate(el => getComputedStyle(el, '::after').content), '"Saikō"'); @@ -69,10 +97,11 @@ async function freePort() { assert.equal(await page.locator('.greatest-tile').count(), 0); assert.deepEqual(errors, []); await context.close(); - console.log('Greatest of All Time browser QA passed: periods, ratings, filters, modal, mobile layout.'); + console.log('Greatest of All Time browser QA passed: periods, ratings, filters, source status, modal, mobile layout.'); } finally { if (browser) await browser.close(); await new Promise(resolve => { if (child.exitCode !== null) return resolve(); child.once('exit', resolve); child.kill(); }); + await new Promise(resolve => fakeScraper.close(resolve)); fs.rmSync(dir, { recursive: true, force: true }); } })().catch(error => { console.error(error); process.exitCode = 1; });