frontend/e2e/configuration-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 type { Browser, Page } from 'playwright-core';
import { configurationBody, expectBody, launch, layout, settle, visit, VIEWPORTS } from './harness.ts';
const measure = (page: Page, names = ['媒体库与目录']) => page.locator('.ui-configpage').evaluate((root, names) => {
const rect = (el: Element) => {
const r = el.getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height };
};
const sections = names.map(name => root.querySelector(`[aria-label="${name}"]`)!);
return {
nav: rect(root.querySelector('.ui-board-local-nav')!),
selected: root.querySelector('.ui-board-local-nav [aria-selected="true"]')?.textContent,
sections: sections.map(section => ({
label: rect(section.firstElementChild!),
card: rect(section.lastElementChild!),
// 保存键在卡片页脚,取分区里最后一个按钮:Select 的触发器也是 button。
save: rect([...section.querySelectorAll('button')].at(-1)!),
})),
};
}, names);
async function openEditableConfiguration(browser: Browser, viewport: (typeof VIEWPORTS)[number]) {
const opened = await visit(browser, '/', viewport);
try {
// 每例在导航前读取真实配置快照,页面等待与接管由响应门闩控制。
const response = await opened.page.request.get(new URL('/api/configuration', opened.page.url()).href);
assert.equal(response.status(), 200);
const configuration = await response.json();
await opened.page.route('**/api/configuration', async route => {
if (route.request().method() !== 'GET') return route.continue();
await route.fulfill({ json: { ...configuration, editable: true } });
});
await opened.page.goto(new URL('/settings/library', opened.page.url()).href, { waitUntil: 'load' });
return { ...opened, configuration };
} catch (error) {
await opened.close();
throw error;
}
}
describe('配置页等待态', () => {
let browser: Browser;
before(async () => { browser = await launch() });
after(async () => { await browser?.close() });
for (const viewport of VIEWPORTS) {
it(`${viewport.name} 的分类导航适配方向并保留未保存输入`, { timeout: 60_000 }, async () => {
const opened = await openEditableConfiguration(browser, viewport);
try {
const { page } = opened;
await expectBody(page, '/settings/library', configurationBody(page));
await settle(page);
const nav = page.getByRole('tablist', { name: '配置分区' });
assert.equal(await nav.getAttribute('aria-orientation'), viewport.mobile ? 'horizontal' : 'vertical');
const geometry = await measure(page);
assert.ok(viewport.mobile
? geometry.nav.y + geometry.nav.height <= geometry.sections[0].label.y
: geometry.nav.x + geometry.nav.width <= geometry.sections[0].label.x);
await nav.getByRole('tab', { name: '来源与订阅', exact: true }).click();
// 切到别的页签后这一栏所在的面板隐藏,值仍要留在原处。
const input = page.getByRole('textbox', { name: 'JavDB 地址', exact: true, includeHidden: true });
await input.fill('example.invalid');
const general = nav.getByRole('tab', { name: '媒体库与入库', exact: true });
await general.click();
await page.keyboard.press('ArrowRight');
assert.equal(await nav.getByRole('tab', { name: '来源与订阅', exact: true }).getAttribute('aria-selected'), 'true');
await page.keyboard.press('End');
assert.equal(await nav.getByRole('tab', { name: '系统诊断' }).getAttribute('aria-selected'), 'true');
await page.keyboard.press('Home');
assert.equal(await input.inputValue(), 'example.invalid');
const bounds = await layout(page);
assert.ok(bounds.scrollWidth <= bounds.viewportWidth);
assert.deepEqual(bounds.offenders, []);
await page.setViewportSize({ width: viewport.mobile ? 1280 : 390, height: 844 });
await page.waitForFunction(expected => document.querySelector('[role="tablist"][aria-label="配置分区"]')?.getAttribute('aria-orientation') === expected,
viewport.mobile ? 'vertical' : 'horizontal');
assert.equal(await general.getAttribute('aria-selected'), 'true');
assert.equal(await input.inputValue(), 'example.invalid');
assert.deepEqual(opened.problems, []);
} finally { await opened.close() }
});
it(`${viewport.name} 的分类导航与媒体标题在接管时保持位置`, { timeout: 60_000 }, async () => {
const opened = await openEditableConfiguration(browser, viewport);
let release = () => {};
try {
await expectBody(opened.page, '/settings/library', configurationBody(opened.page));
await settle(opened.page);
const pending = new Promise<void>(resolve => { release = resolve });
await opened.page.route('**/api/configuration', async route => {
if (route.request().method() !== 'GET') return route.continue();
await pending;
await route.fulfill({ json: { ...opened.configuration, editable: true, startup: {
available: true, enabled: true, silent: true, desktop: true, desktop_message: '', message: '',
}, automatic_updates: { mode: 'check', interval_hours: 24, available: true, download_available: false } } });
});
await opened.page.reload({ waitUntil: 'load' });
await opened.page.locator('[data-skeleton="board/settings/library"]').waitFor();
const skeleton = await measure(opened.page);
const waitingLayout = await layout(opened.page);
release();
await expectBody(opened.page, '/settings/library', configurationBody(opened.page));
await settle(opened.page);
const live = await measure(opened.page);
assert.equal(skeleton.selected, live.selected);
const pairs = [[skeleton.nav, live.nav], ...skeleton.sections.map((section, i) =>
[section.label, live.sections[i].label])];
for (const [a, b] of pairs) for (const key of ['x', 'y', 'width', 'height'] as const) {
assert.ok(Math.abs(a[key] - b[key]) <= 1, `${key}: skeleton=${a[key]}, content=${b[key]}`);
}
assert.ok(waitingLayout.scrollWidth <= waitingLayout.viewportWidth);
assert.deepEqual(waitingLayout.offenders, []);
assert.deepEqual(opened.problems, []);
} finally {
release();
await opened.close();
}
});
}
/* 来源与订阅有四块另取数据的分区:整页骨架、分区骨架与读回后的终态画的是同一套结构,来源凭据的
站点行取自静态登记,行数与终态一致。各块的位置与高度允许细小出入,不允许超过一行(40px)的位移。
本机没有记录(冷启动)与正常再开两种都测:随配置变化的部分在冷启动时按最小结构画。 */
for (const cold of [true, false]) it(`来源与订阅的整页骨架、分区骨架与读回的分区结构一致、位移不超过一行(${cold ? '本机没有记录' : '再次打开'})`, { timeout: 90_000 }, async () => {
const opened = await visit(browser, '/settings/subscriptions', VIEWPORTS[0]);
let release = () => {};
let mount = () => {};
try {
const { page } = opened;
const names = ['来源凭据', '关注采集', '资源搜索', '作品资料站点'];
/* 骨架淡出时新旧两份 `.ui-configpage` 可能同时在:取最后挂上的那一份。 */
const sections = () => page.locator('.ui-configpage').last().evaluate((root, names) => names.map((name) => {
const section = root.querySelector(`[aria-label="${name}"]`);
if (!section) return null;
const r = section.getBoundingClientRect();
return { name, y: r.y, height: r.height, sites: section.querySelectorAll('b').length };
}), names);
const done = () => page.waitForFunction(() => document.querySelector('.ui-configpage section[aria-label="来源凭据"]')
&& !document.querySelector('.ui-configpage [data-skeleton]'), null, { timeout: 30_000 });
await done();
await settle(page);
if (cold) await page.evaluate(() => localStorage.removeItem('peach.configuration.shape.v1'));
/* 两道门:配置读回之前是整页骨架;配置放行、分区自己的数据还压着时是分区骨架。 */
const configuration = new Promise<void>((resolve) => { mount = resolve });
const held = new Promise<void>((resolve) => { release = resolve });
await page.route('**/api/configuration', async (route) => { await configuration; await route.continue() });
for (const url of ['**/api/follow/credentials', '**/api/configuration/indexers', '**/api/follow/schedule', '**/api/feeds/schedule']) {
await page.route(url, async (route) => { await held; await route.continue() });
}
await page.reload({ waitUntil: 'commit' });
await page.locator('[data-skeleton="board/settings/subscriptions"]').waitFor();
const pageSkeleton = await sections();
mount();
await page.getByLabel('正在读取来源凭据', { exact: true }).waitFor();
const sectionSkeleton = await sections();
release();
await done();
await settle(page);
const live = await sections();
for (const [phase, shape] of [['整页骨架', pageSkeleton], ['分区骨架', sectionSkeleton]] as const) {
shape.forEach((section, i) => {
const end = live[i]!;
assert.ok(section && Math.abs(section.y - end.y) <= 40 && Math.abs(section.height - end.height) <= 40,
`${phase}「${names[i]}」${JSON.stringify(section)},读回后 ${JSON.stringify(end)}`);
});
assert.equal(shape[0]!.sites, live[0]!.sites, `${phase}的来源凭据站点行数`);
}
assert.deepEqual(opened.problems, []);
} finally {
mount();
release();
await opened.close();
}
});
for (const path of ['/settings/library', '/review']) {
it(`${path} 的明暗主题使用透明容器与灰色 Pills 选中底色`, { timeout: 60_000 }, async () => {
const opened = await visit(browser, path, VIEWPORTS[0]);
try {
const { page } = opened;
const nav = page.locator('#stats [data-section-nav]:not([data-skeleton] *)');
await nav.locator('[aria-selected=true]').waitFor();
await settle(page);
if (path === '/review') {
assert.equal(await nav.getByRole('heading', { name: '复核分类' }).count(), 1);
assert.equal(await nav.getByRole('tablist').getAttribute('aria-orientation'), 'vertical');
}
for (const dark of [false, true]) {
await page.evaluate(dark => {
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
document.documentElement.classList.toggle('dark', dark);
}, dark);
const colors = await nav.evaluate(node => {
const selected = getComputedStyle(node.querySelector('[aria-selected=true]')!);
const selection = getComputedStyle(node.querySelector('[data-pill-thumb]')!);
const inactive = getComputedStyle(node.querySelector('[aria-selected=false]')!);
return {
surface: getComputedStyle(node).backgroundColor,
selected: selection.backgroundColor,
border: selected.borderWidth,
radius: selection.borderRadius,
text: selected.color,
inactive: inactive.color,
inactiveBackground: inactive.backgroundColor,
};
});
assert.equal(colors.surface, 'rgba(0, 0, 0, 0)');
assert.notEqual(colors.selected, 'rgba(0, 0, 0, 0)');
assert.equal(colors.border, '0px');
assert.notEqual(colors.radius, '0px');
assert.notEqual(colors.text, colors.inactive);
assert.equal(colors.inactiveBackground, 'rgba(0, 0, 0, 0)');
}
assert.deepEqual(opened.problems, []);
} finally { await opened.close() }
});
}
});