Files
ggjuku/scripts/check-cover-retry.cjs
T
2026-09-30 10:16:50 +08:00

145 lines
8.7 KiB
JavaScript

const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const {chromium, webkit} = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const project = path.resolve(process.argv[2] || path.join(__dirname, '..'));
const id = process.argv[3] || 'hongguo:7000000000000000001';
const source = id.split(':')[0];
const group = process.argv[4] || 'hongguo';
const controlID = source + ':7000000000000000002';
const origin = 'http://127.0.0.1:27831';
const remote = 'https://covers.example.org/text-fixture?auth=a%2Fb%2B%3D&expires=1900000000';
const cover = '/api/ui/image?url=' + encodeURIComponent(remote) + '&dramaId=' + encodeURIComponent(id);
const controlCover = '/api/ui/image?url=' + encodeURIComponent('https://covers.example.org/control-text') + '&dramaId=' + encodeURIComponent(controlID);
const ui = path.join(project, 'internal/webui');
let dramas = [
{id, source, title: '海报重试文字测试', cover, coverUrl: cover, totalEpisode: 3},
{id: controlID, source, title: '无关条目文字测试', cover: controlCover, coverUrl: controlCover, totalEpisode: 3},
];
async function until(predicate, message) {
const end = Date.now() + 5000;
while (!predicate()) {
if (Date.now() >= end) throw new Error(message);
await new Promise(resolve => setTimeout(resolve, 20));
}
}
(async () => {
const engine = process.env.JUKU_BROWSER_ENGINE === 'webkit' ? webkit : chromium;
const browser = await engine.launch({headless: true, ...(process.env.JUKU_BROWSER_EXECUTABLE ? {executablePath: process.env.JUKU_BROWSER_EXECUTABLE} : {})});
const context = await browser.newContext({serviceWorkers: 'block', viewport: process.env.JUKU_MOBILE ? {width: 390, height: 844} : {width: 1280, height: 900}});
context.setDefaultTimeout(7000);
const imageAttempts = [], unexpected = [], pageErrors = [];
let refreshes = 0, repairs = 0, releaseRefresh;
const count = target => imageAttempts.filter(address => new URL(address).searchParams.get('url') === target).length;
try {
await context.route('**/*', async route => {
const request = route.request(), address = new URL(request.url());
if (request.resourceType() === 'image' || address.pathname === '/api/ui/image') {
if (address.origin === origin && address.pathname === '/api/ui/image') imageAttempts.push(address.href);
else unexpected.push('image:' + address.origin + address.pathname);
return route.abort();
}
if (address.origin !== origin) {
unexpected.push('external:' + address.origin + address.pathname);
return route.abort();
}
if (address.pathname === '/') return route.fulfill({contentType: 'text/html', body: fs.readFileSync(path.join(ui, 'index.html'), 'utf8')});
if (/^\/assets\/[a-zA-Z0-9_.-]+\.(?:js|css|txt)$/.test(address.pathname)) {
const name = path.basename(address.pathname);
let body = fs.readFileSync(path.join(ui, name), 'utf8');
if (name === 'main.js') body = body.replace('const app = {api, post};', 'const app = {api, post}; window.__coverTestApp = app;');
return route.fulfill({contentType: name.endsWith('.js') ? 'text/javascript' : name.endsWith('.css') ? 'text/css' : 'text/plain', body});
}
let result;
switch (address.pathname) {
case '/api/ui/viewer': result = {id: 'a'.repeat(64), ready: true, sources: [group], account: {username: 'fixture', admin: false}}; break;
case '/api/ui/dramas': result = {data: dramas, revision: 1, loadedAt: '2026-09-20T00:00:00Z', sources: {}}; break;
case '/api/ui/tasks': result = {data: [], merges: {}}; break;
case '/api/ui/playback/history': result = {data: []}; break;
case '/api/ui/following': result = {data: dramas.map(drama => ({dramaId: drama.id, source, title: drama.title, saved: true, knownEpisodes: 3, updatedAt: '2026-09-20T00:00:00Z'}))}; break;
case '/api/ui/dramas/refresh': {
refreshes++;
assert.equal(request.postDataJSON().dramaId, id);
await new Promise(resolve => {releaseRefresh = resolve;});
dramas[0] = {...dramas[0], title: '资料已更新文字测试', sortMetadata: {version: 2, checkedAt: '2026-09-20T01:00:00Z'}};
result = {dramaId: id, drama: dramas[0], retryAfter: 300};
break;
}
case '/api/ui/cover/repair': repairs++; result = {dramaId: id, cover, retryAfter: 300}; break;
default: unexpected.push('local:' + address.pathname); return route.abort();
}
return route.fulfill({contentType: 'application/json', body: JSON.stringify(result)});
});
const page = await context.newPage();
page.on('pageerror', error => pageErrors.push(error.message));
await page.goto(origin);
await page.waitForFunction(() => document.documentElement.dataset.ready === 'true');
await page.evaluate(() => {
window.__plays = [];
window.dramaPlayer.open = (id, title) => window.__plays.push({id, title});
window.dramaPlayer.openHistory = window.dramaPlayer.open;
});
const card = page.locator(`.card[data-drama-id="${id}"]`);
await card.locator('.cover.placeholder').waitFor();
assert.equal(refreshes, 0, 'ordinary library reads must not refresh metadata');
const unrelated = count('https://covers.example.org/control-text');
let before = count(remote);
await card.locator('.poster').click();
await until(() => refreshes === 1 && count(remote) > before, 'first click did not retry the failed cover before metadata returned');
assert.equal(await page.evaluate(() => window.__plays.length), 1, 'playback waited for metadata');
await card.locator('.cover.placeholder').waitFor();
before = count(remote);
await card.locator('.poster').click();
await until(() => count(remote) > before, 'pending metadata suppressed the second cover retry');
assert.equal(refreshes, 1);
releaseRefresh();
releaseRefresh = null;
await page.waitForFunction(id => window.__coverTestApp.library.get(id)?.title === '资料已更新文字测试', id);
await card.locator('.cover.placeholder').waitFor();
before = count(remote);
await card.locator('.poster').click();
await until(() => count(remote) > before, 'metadata cooldown suppressed the cover retry');
assert.equal(refreshes, 1);
assert.equal(count('https://covers.example.org/control-text'), unrelated, 'clicking one drama retried an unrelated card');
await card.locator('.card-title').click();
await page.waitForFunction(() => document.querySelector('.detail-cover-status')?.textContent === '海报加载失败');
await page.locator('#detailPanel [data-close]').click();
before = count(remote);
await card.locator('.card-title').click();
await until(() => count(remote) > before, 'reopening details did not retry its poster');
await page.waitForFunction(() => document.querySelector('.detail-cover-status')?.textContent === '海报加载失败');
before = count(remote);
await page.locator('#detailPanel .primary-action').click();
await until(() => count(remote) > before, 'playing from details did not retry its poster');
await page.locator('#detailPanel [data-close]').click();
await page.locator('[data-page="following"]:visible').click();
await page.locator('[data-follow-tab="saved"]').click();
const following = page.locator(`.following-row[data-drama-id="${id}"]`);
await following.waitFor();
await page.waitForFunction(id => !document.querySelector(`.following-row[data-drama-id="${id}"] .following-cover img`), id);
before = count(remote);
await following.locator(`[data-focus-key="following-play-${id}"]`).click();
await until(() => count(remote) > before, 'playing from the following list did not retry its poster');
assert.equal(refreshes, 1);
assert.equal(repairs, 0, 'cover retries created redundant metadata repairs');
assert.equal(await page.evaluate(id => window.__coverTestApp.library.get(id).cover, id), cover, 'a transient retry URL replaced the canonical cover');
for (const address of imageAttempts.filter(address => new URL(address).searchParams.has('_retry'))) {
const target = new URL(address);
assert.equal(target.searchParams.get('url'), remote, 'source signature changed');
assert.equal(target.searchParams.get('dramaId'), id, 'drama identity changed');
}
assert.deepEqual(unexpected, []);
assert.deepEqual(pageErrors, []);
console.log(JSON.stringify({project, source, engine: process.env.JUKU_BROWSER_ENGINE || 'chromium', mobile: Boolean(process.env.JUKU_MOBILE), refreshes, repairs, blockedImageAttempts: imageAttempts.length, upstreamRequests: 0, screenshots: 0, result: 'passed'}));
} finally {
releaseRefresh?.();
await context.close();
await browser.close();
}
})().catch(error => {console.error(error); process.exitCode = 1;});