frontend/e2e/entity-cold-skeleton.test.ts
Rama master · réplica de lectura del repositorio original.
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { join } from 'node:path';
import type { Browser } from 'playwright-core';
import { launch, requiredEnv, VIEWPORTS } from './harness.ts';
describe('实体页冷启动等待态', () => {
let browser: Browser;
before(async () => { browser = await launch() });
after(async () => { await browser.close() });
for (const viewport of VIEWPORTS) for (const jav of [false, true]) for (const theme of ['light', 'dark']) for (const view of ['cards', 'list']) {
it(`${viewport.name} ${theme} ${view} ${jav ? 'JAV' : '普通'}艺人直达和刷新保持形状,身份与作品独立显示`, { timeout: 60_000 }, async () => {
const context = await browser.newContext({ viewport });
const releases: (() => void)[] = [];
const gate = () => new Promise<void>(resolve => { releases.push(resolve) });
let shapes = gate(), entity = gate(), items = gate(), photos = gate();
const name = '演示艺人';
const shape = { id: 90001, kind: 'performer', names: [name], parts: jav ? ['facts', 'costars', 'jav'] : [] };
try {
await context.addInitScript(({ theme, view }) => {
localStorage.setItem('peach.settings.v1', JSON.stringify({ theme, detailAutoplay: false }));
localStorage.setItem('peach.catalog-view.v1', view);
const seen: { facts: boolean; costars: boolean; jav: boolean }[] = [];
Object.assign(window, { entityWaitingFrames: seen });
let previous: Element | null = null;
new MutationObserver(() => {
const root = document.querySelector('[data-skeleton="entity/performer"]');
if (!root || root === previous) return;
previous = root;
seen.push({ facts: !!root.querySelector('[data-entity-facts]'),
costars: !!root.querySelector('[data-entity-foot]'), jav: !!root.querySelector('[aria-label="JAV 卡片版式"]') });
}).observe(document, { childList: true, subtree: true });
}, { theme, view });
const page = await context.newPage();
await page.route('**/performers/90001', async route => {
const response = await route.fetch({ url: requiredEnv('PEACH_E2E_ORIGIN') + '/' });
await route.fulfill({ response, body: (await response.text()).replace('</head>',
`<script type="application/json" id="peach-entity-shape">${JSON.stringify(shape)}</script></head>`) });
});
await page.route('**/api/entity/shapes', async route => { await shapes; await route.fulfill({ json: { ok: true, entities: [shape] } }) });
await page.route(/\/api\/entity\?/, async route => { await entity; await route.fulfill({ json: {
id: shape.id, kind: shape.kind, canonical_name: name, asset_count: 1, tags: [], links: [],
related_performers: jav ? [{ id: 90002, k: '同台艺人', rep: null }] : [],
profile: jav ? { height: 160, birth_date: '1990-01-01', debut_date: '2010-01-01',
active: { from: '2010', ongoing: true }, tags: ['演示标签'] } : null,
aliases: [], metadata: {}, entry_links: [],
} }) });
await page.route(/\/api\/items\?/, async route => { await items; await route.fulfill({ json: {
items: [{ id: 91001, name: '演示作品', is_jav: jav, has_thumb: false }], total: 1, has_more: false,
} }) });
await page.route(/\/api\/photos\?/, async route => {
await photos;
await route.fulfill({ json: { items: [], total: 0, sets: [], has_more: false } });
});
for (const mode of ['direct', 'refresh']) {
if (mode === 'refresh') {
shapes = gate(); entity = gate(); items = gate(); photos = gate();
await page.reload({ waitUntil: 'load' });
} else await page.goto(requiredEnv('PEACH_E2E_ORIGIN') + '/performers/90001', { waitUntil: 'load' });
const root = page.locator('[data-skeleton="entity/performer"]');
await root.waitFor();
const first = await root.elementHandle();
assert.equal(await root.locator('[data-entity-portrait="round"]').evaluate(node =>
getComputedStyle(node).borderTopLeftRadius), '50%', '艺人头像等待态形状不准确');
assert.equal(await root.locator('[data-entity-facts]').count() > 0, jav, '首次等待态资料栏不准确');
assert.equal(await root.locator('[data-entity-foot]').count() > 0, jav, '首次等待态相关艺人不准确');
assert.equal(await root.locator('[aria-label="JAV 卡片版式"]').count() > 0, jav && view === 'cards', '首次等待态卡片版式不准确');
assert.equal(await root.locator('[aria-label="馆藏显示方式"]').count(), 1);
assert.equal(await root.locator('[data-folder-mode-toggle]').count(), 1);
await page.waitForFunction(() => !document.querySelector('[data-skeleton="entity/performer"] .skeleton-awaiting'));
const visibility = await root.evaluate(node => {
node.classList.add('skeleton-awaiting');
const leaks = [node, ...node.querySelectorAll('*')].flatMap(child => ['', '::before', '::after'].filter(pseudo => {
const css = getComputedStyle(child, pseudo || null);
return (!pseudo || !['none', 'normal'].includes(css.content)) && css.visibility !== 'hidden';
}).map(pseudo => `${child.tagName}${pseudo}`));
node.classList.remove('skeleton-awaiting');
return leaks;
});
assert.deepEqual(visibility, [], '等待门槛内出现了单独可见的骨架或微光');
const controls = async () => page.locator('[data-entity-filter-glass]').evaluate(node => {
const selectors = ['[data-filter-row="top"]','[data-filter-row="bottom"]','[data-entity-state=""]',
'[data-entity-batch]','[data-entity-sort="rating"]','[data-folder-mode-toggle]'];
return selectors.map(selector => {
const el = node.querySelector(selector)!;
const css = getComputedStyle(el), box = el.getBoundingClientRect();
const icon = selector === '[data-entity-batch]' ? el.querySelector('svg')?.getBoundingClientRect() : null;
return { selector, height: box.height, width: selector.includes('data-filter-row') ? 0 : box.width,
padding: css.padding, gap: css.gap, font: css.fontSize,
icon: icon ? { width: icon.width, height: icon.height,
x: icon.x + icon.width / 2 - box.x - box.width / 2,
y: icon.y + icon.height / 2 - box.y - box.height / 2 } : null };
});
});
const waitingControls = await controls();
const batch = waitingControls.find(control => control.selector === '[data-entity-batch]')!;
assert.deepEqual(batch.icon, { width: 16, height: 16, x: 0, y: 0 }, '换一批图标应居中且保持 16px');
// 筛选条里的标签取紧凑档 `--control-h-compact`,骨架与真实标签同高。
assert.equal(await root.locator('[data-entity-tags] .skeleton').first().evaluate(node => node.getBoundingClientRect().height), 32);
assert.ok(await root.locator('[data-entity-state]').count(), '静态观看筛选尚未显示');
const ratio = (scope = root) => scope.locator('[style*="--skeleton-card-ratio"]').first().evaluate(node =>
Number((node as HTMLElement).style.getPropertyValue('--skeleton-card-ratio')));
if (view === 'cards') assert.equal(await ratio(), jav ? .75 : 16 / 9);
if (process.env.PEACH_COLD_EVIDENCE) await page.screenshot({
path: join(process.env.PEACH_COLD_EVIDENCE, `${viewport.name}-${jav ? 'jav' : 'ordinary'}-${mode}.png`),
fullPage: false,
});
const identityResponse = page.waitForResponse(response => response.url().includes('/api/entity?'));
for (const release of releases.splice(0, 2)) release();
await identityResponse;
const identity = page.locator('[data-entity-hero] [data-entity-identity] h2');
await identity.filter({ hasText: name }).waitFor();
await root.waitFor({ state: 'detached' });
assert.equal(await first!.evaluate(node => node.isConnected), false, '身份已取得仍保留整页等待态');
const hero = await page.locator('[data-entity-hero]').elementHandle();
const body = page.locator('[data-entity-body]');
const waitingBody = body.locator('[data-content-transition][data-pending][aria-busy="true"]');
await waitingBody.waitFor();
assert.equal(await body.locator('[data-media-card], [data-catalog-row]').count(), 0,
'作品响应未放行时不应出现伪造或旧人物作品');
if (view === 'cards') assert.equal(await ratio(waitingBody), jav ? .75 : 16 / 9,
'身份先显示时,作品等待区仍应保持已知的卡片比例');
else await waitingBody.locator('[data-list-skeleton]').waitFor();
releases.shift()!();
await body.locator('[data-media-card][data-id="91001"], [data-catalog-row][data-id="91001"]').first().waitFor();
await waitingBody.waitFor({ state: 'detached' });
assert.equal(await hero!.evaluate(node => node === document.querySelector('[data-entity-hero]')), true,
'作品到达时重建了已显示的人物身份');
assert.equal(await identity.textContent(), name, '照片仍在等待时人物身份应继续显示');
const photoResponse = page.waitForResponse(response => response.url().includes('/api/photos?'));
releases.shift()!();
await photoResponse;
assert.equal(await hero!.evaluate(node => node === document.querySelector('[data-entity-hero]')), true,
'照片到达时重建了已显示的人物身份');
assert.deepEqual(await controls(), waitingControls, '筛选按钮的尺寸或间距与就绪状态不一致');
const bottom = page.locator('[data-entity-filter-glass] > [data-filter-row="bottom"]');
const overflow = await bottom.evaluate(node => {
const readout = node.querySelector('[data-entity-readout]')!;
const css = getComputedStyle(readout);
return { max: node.scrollWidth - node.clientWidth, shrink: css.flexShrink,
width: readout.clientWidth, content: readout.scrollWidth, overflow: getComputedStyle(node).overflowX };
});
assert.equal(overflow.shrink, '0', '操作行读数保持完整宽度');
assert.equal(overflow.width, overflow.content, '操作行读数完整显示');
assert.equal(overflow.overflow, 'auto');
if (overflow.max > 1) {
await bottom.scrollIntoViewIfNeeded();
const box = await bottom.boundingBox();
await page.mouse.move(box!.x + 15, box!.y + box!.height / 2);
await page.mouse.wheel(0, overflow.max + 100);
await page.waitForFunction(() => {
const row = document.querySelector('[data-entity-filter-glass] > [data-filter-row="bottom"]')!;
return row.scrollLeft > 0 && row.scrollLeft >= row.scrollWidth - row.clientWidth - 1;
});
const reachable = await bottom.evaluate(node => {
const edge = node.getBoundingClientRect(), keys = node.querySelectorAll('[data-entity-sort]');
return keys[keys.length - 1]!.getBoundingClientRect().right <= edge.right;
});
assert.ok(reachable, '最后一枚排序键可以滚动到完整可见');
}
const frames = await page.evaluate(() =>
(window as Window & { entityWaitingFrames: { facts: boolean; costars: boolean; jav: boolean }[] }).entityWaitingFrames);
assert.deepEqual(frames, [{ facts: jav, costars: jav, jav: jav && view === 'cards' }], '一次打开画出了多段或不准确的等待态');
assert.equal(await page.locator('[data-entity-facts]').count() > 0, jav);
assert.equal(await page.locator('[data-entity-foot]').count() > 0, jav);
await first!.dispose();
await hero!.dispose();
}
} finally { releases.forEach(release => release()); await context.close() }
});
}
});