/* 侧栏岛(`frontend/src/react/sidebar/`,ADR-0031 第 11f 步)在真浏览器里的行为:拖动排序写回、五处语境的
* 筛选分组与计数、切页时那块玻璃一路滑过去、手机宽度下抽屉的开合,以及覆盖式滚动条仍挂在滚动层上。
*
* 写接口一律落在桩里(`settings-fixture`、`follow-fixture`、`item-fixture`)。聚合接口 `/api/facets` 照演示库
* 真取一份,只把内容标签换成按语境可区分的几枚:首页与实体页数字不同,才看得出侧栏是按语境取的。演示库没有
* 人物实体,实体页的资料与作品照接口的形状在这里给(同 `entity-filter.test.ts`)。 */
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import type { Browser, Page } from 'playwright-core';
import { expectBody, launch, settle, visit, VIEWPORTS } from './harness.ts';
import { openFollowFeed } from './follow-fixture.ts';
import { ITEM, openItemPage } from './item-fixture.ts';
import { openHome, ORDER } from './settings-fixture.ts';
const DESKTOP = VIEWPORTS.find((viewport) => !viewport.mobile)!;
const PHONE = VIEWPORTS.find((viewport) => viewport.mobile)!;
const HOME_TAGS = [['演示侧栏甲', 41], ['演示侧栏乙', 7]] as const;
const ENTITY_TAGS = [['演示侧栏甲', 23], ['演示侧栏乙', 5]] as const;
const withoutPlayer = (problems: string[]) => problems.filter((line) => !line.includes('VIDEOJS'));
const PERFORMER = '篠田ゆう';
const THUMB = '';
/** 女优资料页要的资料、作品、照片与图。 */
async function serveEntity(page: Page): Promise {
const entity = {
id: 90_101, kind: 'performer', canonical_name: PERFORMER, aliases: [], display_aliases: [], user_aliases: [],
asset_count: 6, tags: [], related_performers: [], links: [], metadata: {}, has_image: false, has_avatar: false,
avatar_focus: null, representative_asset_id: null, entry_links: [], feed: null,
};
const items = Array.from({ length: 6 }, (_, at) => ({
id: 91_000 + at, name: `SSIS-${100 + at} 演示作品 ${at + 1}`, code: `SSIS-${100 + at}`, is_jav: true, has_thumb: true,
duration: 7200, studio: 'S1 NO.1 STYLE', tags: [],
}));
await page.route(/\/api\/entity\?/, (route) => route.fulfill({ json: entity }));
await page.route((url) => url.pathname === '/api/items', (route) => route.fulfill({ json: {
items: Number(new URL(route.request().url()).searchParams.get('offset') || 0) ? [] : items, total: items.length, has_more: false,
} }));
await page.route((url) => url.pathname === '/api/photos', (route) =>
route.fulfill({ json: { total: 0, sample_total: 0, sets: [], items: [], has_more: false, seed: '' } }));
await page.route((url) => url.pathname === '/api/feeds/discoveries', (route) =>
route.fulfill({ json: { ok: true, more: false, items: [] } }));
await page.route(/\/(?:thumb|poster|photo-thumb|cover)\?/, (route) =>
route.fulfill({ contentType: 'image/svg+xml', body: THUMB }));
}
/** 把 `/api/facets` 的内容标签换成按语境给的那一份;交回收到的查询串,好看实体页是不是按实体口径取的。 */
async function scopeFacets(page: Page): Promise {
const seen: string[] = [];
await page.route((url) => url.pathname === '/api/facets', async (route) => {
const url = new URL(route.request().url());
seen.push(url.search);
const real = await (await route.fetch()).json() as Record;
const rows = url.searchParams.get('scope_kind') ? ENTITY_TAGS : HOME_TAGS;
await route.fulfill({ json: { ...real, tags: rows.map(([k, n]) => ({ k, n, cat: 'general' })) } });
});
return seen;
}
/** 抽屉里某一组的 chip:值与计数徽标的数字,按画出来的先后。 */
const chips = (page: Page, group: string) =>
page.locator(`#drawer [data-sidebar-group="${group}"] [data-sidebar-chip]`).evaluateAll((nodes) =>
nodes.map((node) => [node.getAttribute('data-val') ?? node.getAttribute('data-follow-drawer-tag'),
node.querySelector('[data-sidebar-count]')?.textContent ?? null]));
const navKeys = (page: Page) =>
page.locator('#drawer [data-sidebar-nav] [data-nav]').evaluateAll((nodes) => nodes.map((node) => node.getAttribute('data-nav')));
/** 点开一组(初始只有正在生效的组展开,收着的组 `inert`,点不到里面的 chip)。 */
async function openGroup(page: Page, group: string): Promise {
const toggle = page.locator(`#drawer [data-sidebar-group="${group}"] > [data-sidebar-toggle]`);
if (await toggle.getAttribute('aria-expanded') !== 'true') await toggle.click();
await page.locator(`#drawer [data-sidebar-group="${group}"] [data-sidebar-collapse]:not([inert])`).waitFor({ timeout: 5_000 });
}
describe('侧栏岛', () => {
let browser: Browser;
before(async () => {
browser = await launch();
});
after(async () => {
await browser.close();
});
it('拖动一项改导航顺序:当场重排、写进设置,刷新之后照新顺序', { timeout: 60_000 }, async () => {
const opened = await openHome(browser);
const { page, server } = opened;
try {
assert.deepEqual(await navKeys(page), ORDER, '侧栏没有按设置里的顺序画');
const from = (await page.locator('#drawer [data-nav="tags"]').boundingBox())!;
const to = (await page.locator('#drawer [data-nav="follow"]').boundingBox())!;
await page.mouse.move(from.x + 40, from.y + from.height / 2);
await page.mouse.down();
await page.mouse.move(from.x + 44, from.y + from.height / 2 - 6, { steps: 4 });
await page.mouse.move(to.x + 40, to.y + 6, { steps: 8 });
await page.mouse.move(to.x + 42, to.y + 7, { steps: 2 });
assert.equal(await page.locator('#drawer [data-nav="tags"]').getAttribute('data-dragging'), '', '被拖的那一项没有标出来');
const line = await page.locator('#drawer [data-sidebar-drop-line]').boundingBox();
assert.ok(line && line.width > 0, '插入线没有显示');
assert.equal(await page.locator('#drawer [data-nav="follow"]').evaluate(node => node.style.getPropertyValue('--sidebar-nudge')), '10px');
assert.equal(await page.locator('#drawer [data-nav="jav"]').evaluate(node => node.style.getPropertyValue('--sidebar-delay')), '24ms');
await page.mouse.up();
const moved = ['', 'tags', 'follow', 'jav', 'performers', 'studios', 'flagged', 'manage'];
await page.waitForFunction((want) => JSON.stringify([...document.querySelectorAll('#drawer [data-sidebar-nav] [data-nav]')]
.map((node) => node.getAttribute('data-nav'))) === want, JSON.stringify(moved), { timeout: 5_000 });
const posts = server.posts.filter((post) => post.path === '/api/settings' && 'sidebarOrder' in post.body);
assert.deepEqual(posts.at(-1)?.body.sidebarOrder, moved, '新顺序没有写进设置');
assert.equal(await page.locator('#drawer [data-dragging], #drawer [data-sidebar-drop-line]').count(), 0, '松手后拖动标记没有清掉');
await page.reload({ waitUntil: 'load' });
await page.locator('#drawer [data-sidebar-nav] [data-nav]').first().waitFor({ timeout: 15_000 });
await settle(page);
assert.deepEqual(await navKeys(page), moved, '刷新之后侧栏回到了旧顺序');
assert.deepEqual(opened.problems, []);
} finally {
await opened.close();
}
});
it('Esc 和拖出导航区域取消排序,不保存也不触发导航', { timeout: 60_000 }, async () => {
const opened = await openHome(browser);
const { page, server } = opened;
try {
for (const escape of [true, false]) {
const from = (await page.locator('#drawer [data-nav="tags"]').boundingBox())!;
const to = (await page.locator('#drawer [data-nav="follow"]').boundingBox())!;
await page.mouse.move(from.x + 40, from.y + from.height / 2);
await page.mouse.down();
await page.mouse.move(to.x + 40, to.y + 4, { steps: 8 });
if (escape) await page.keyboard.press('Escape');
else await page.mouse.move(600, to.y + 4, { steps: 4 });
await page.mouse.up();
assert.deepEqual(await navKeys(page), ORDER);
assert.equal(new URL(page.url()).pathname, '/');
assert.equal(await page.locator('#drawer [data-dragging],#drawer [data-sidebar-drop-line]').count(), 0);
}
assert.equal(server.posts.filter(post => post.path === '/api/settings' && 'sidebarOrder' in post.body).length, 0);
assert.deepEqual(opened.problems, []);
} finally { await opened.close() }
});
it('关注页往返复用已加载内容,不重新取列表或铺首屏骨架', { timeout: 60_000 }, async () => {
const opened = await openFollowFeed(browser, '/follow', DESKTOP);
const { page } = opened;
let requests = 0;
page.on('request', request => { if (new URL(request.url()).pathname === '/api/follow') requests++ });
try {
const items = () => page.locator('[data-follow-list] > [data-follow-item]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-follow-item')));
const before = await items();
await page.evaluate(() => {
const tracking = { skeletons: 0 };
Object.assign(window, { followReturnTracking: tracking });
new MutationObserver(records => {
for (const record of records) for (const node of record.addedNodes) {
if (node instanceof Element && (node.matches('[data-skeleton-tier]') || node.querySelector('[data-skeleton-tier]'))) tracking.skeletons++;
}
}).observe(document.getElementById('stats')!, { childList: true, subtree: true });
});
for (let round = 0; round < 2; round++) {
await page.locator('#drawer [data-nav=""]').click();
await page.locator('#grid [data-media-body]').waitFor({ state: 'visible' });
await page.locator('html[data-page-navigation-pending]').waitFor({ state: 'detached' });
await page.locator('#drawer [data-nav="follow"]').click();
await page.locator('[data-follow-list] > [data-follow-item]').first().waitFor({ state: 'visible' });
await page.locator('html[data-page-navigation-pending]').waitFor({ state: 'detached' });
assert.deepEqual(await items(), before);
}
assert.equal(requests, 0);
assert.equal(await page.evaluate(() => (window as unknown as { followReturnTracking: { skeletons: number } }).followReturnTracking.skeletons), 0);
assert.deepEqual(withoutPlayer(opened.problems), []);
} finally { await opened.close() }
});
it('首页与实体页各按自己的口径列内容标签与计数;点一枚 chip 就按下并进地址', { timeout: 60_000 }, async () => {
const opened = await visit(browser, '/', DESKTOP);
const { page } = opened;
try {
const seen = await scopeFacets(page);
await page.reload({ waitUntil: 'load' });
await page.locator('#drawer [data-sidebar-group="内容标签"]').waitFor({ state: 'attached', timeout: 15_000 });
await settle(page);
assert.deepEqual(await chips(page, '内容标签'), HOME_TAGS.map(([k, n]) => [k, String(n)]), '首页的内容标签不是全库那一份');
assert.ok((await chips(page, '来源')).some(([value, count]) => value === 'local' && Number(count) > 0), '来源那一组没有本机的计数');
await openGroup(page, '内容标签');
const chip = page.locator(`#drawer [data-sidebar-chip][data-key="tag"][data-val="${HOME_TAGS[0][0]}"]`);
await chip.click();
await page.waitForFunction((tag) => new URL(location.href).searchParams.get('tag') === tag, HOME_TAGS[0][0], { timeout: 5_000 });
await page.locator(`#drawer [data-sidebar-chip][data-val="${HOME_TAGS[0][0]}"][aria-pressed="true"]`).waitFor({ timeout: 5_000 });
assert.ok(seen.some((query) => new URLSearchParams(query).get('tag') === HOME_TAGS[0][0]), '按下 chip 之后聚合没有按这条筛选重取');
await serveEntity(page);
await page.goto(new URL(`/performers/${encodeURIComponent(PERFORMER)}`, page.url()).toString(), { waitUntil: 'load' });
await page.locator('#drawer [data-sidebar-group="内容标签"]').waitFor({ state: 'attached', timeout: 15_000 });
await settle(page);
assert.ok(seen.some((query) => {
const params = new URLSearchParams(query);
return params.get('scope_kind') === 'performer' && params.get('scope_name') === PERFORMER;
}), `实体页没有按实体口径取聚合:${seen.join(' | ')}`);
assert.deepEqual(await chips(page, '内容标签'), ENTITY_TAGS.map(([k, n]) => [k, String(n)]), '实体页列的不是这个实体的计数');
assert.deepEqual(opened.problems, []);
} finally {
await opened.close();
}
});
it('关注页按这一版条目数标签,关注详情只列这一条的标签、每枚计 1', { timeout: 60_000 }, async () => {
const opened = await openFollowFeed(browser, '/follow', DESKTOP);
const { page } = opened;
try {
await page.locator('#drawer [data-sidebar-group="内容标签"] [data-follow-drawer-tag]').first().waitFor({ state: 'attached', timeout: 10_000 });
const listed = await chips(page, '内容标签');
const shown = await page.locator('[data-follow-list] > [data-follow-item]').count();
assert.ok(listed.length > 1, '关注页的标签组是空的');
assert.ok(listed.every(([, count]) => Number(count) >= 1), `关注页有标签没带计数:${JSON.stringify(listed)}`);
assert.equal(listed.reduce((sum, [, count]) => sum + Number(count), 0), shown * 2,
'每条更新两枚标签,计数之和应是条目数的两倍');
await page.locator('[data-follow-item="1002"] [data-follow-open]').click();
await page.locator('#stage[open] [data-follow-detail-side]').waitFor({ timeout: 10_000 });
await page.waitForFunction(() => document.querySelectorAll('#drawer [data-follow-drawer-tag]').length === 2, undefined, { timeout: 5_000 });
assert.deepEqual((await chips(page, '内容标签')).sort(), [['3d', '1'], ['outdoor', '1']], '关注详情列的不是这一条的标签');
await page.locator('#closeStage').click();
await page.waitForFunction((count) => document.querySelectorAll('#drawer [data-follow-drawer-tag]').length === count,
listed.length, { timeout: 5_000 });
assert.deepEqual(await chips(page, '内容标签'), listed, '关掉详情之后关注页的标签计数变了');
/* 抽屉里的关注标签是单选:点一枚就只按这一枚筛,抽屉随之收起,让出来的是筛过的列表。 */
await openGroup(page, '内容标签');
const [tag] = listed[0]!;
await page.locator(`#drawer [data-follow-drawer-tag="${tag}"]`).click();
await page.waitForFunction((want) => new URL(location.href).searchParams.get('tag') === want
&& !document.querySelector('#drawer')?.classList.contains('open'), tag, { timeout: 5_000 });
await page.locator('#filterBtn').click();
await page.locator(`#drawer.open [data-follow-drawer-tag="${tag}"][aria-pressed="true"]`).waitFor({ timeout: 5_000 });
const pressed = await page.locator('#drawer [data-follow-drawer-tag][aria-pressed="true"]').count();
assert.equal(pressed, 1, '关注标签按下了不止一枚');
assert.deepEqual(withoutPlayer(opened.problems), []);
} finally {
await opened.close();
}
});
it('关注页侧栏有来源、时长、内容标签三组;来源与时长进地址、按它向服务端取数,抽屉不收;刷新照地址按下', { timeout: 60_000 }, async () => {
const opened = await openFollowFeed(browser, '/follow', DESKTOP);
const { page } = opened;
const lists: URLSearchParams[] = [];
page.on('request', (request) => {
const url = new URL(request.url());
if (url.pathname === '/api/follow' && !url.searchParams.has('item')) lists.push(url.searchParams);
});
const groups = () => page.locator('#drawer [data-sidebar-group]').evaluateAll((nodes) =>
nodes.map((node) => node.getAttribute('data-sidebar-group')));
try {
await page.locator('#drawer [data-sidebar-group="来源"] [data-sidebar-chip]').first().waitFor({ state: 'attached', timeout: 10_000 });
assert.deepEqual(await groups(), ['来源', '时长', '内容标签'], '关注页侧栏的分组不对');
assert.deepEqual((await chips(page, '来源')).map(([value]) => value), ['kemono', 'rule34xxx', 'fanbox'],
'来源组不是全库那几个站');
await openGroup(page, '来源');
await page.locator('#drawer [data-sidebar-chip][data-key="provider"][data-val="rule34xxx"]').click();
await page.waitForFunction(() => new URL(location.href).searchParams.get('provider') === 'rule34xxx', undefined, { timeout: 5_000 });
await page.locator('#drawer.open [data-sidebar-chip][data-val="rule34xxx"][aria-pressed="true"]').waitFor({ timeout: 5_000 });
await page.waitForFunction(() => document.querySelectorAll('#drawer [data-sidebar-group="来源"] [data-sidebar-chip]').length === 3,
undefined, { timeout: 5_000 });
assert.ok(lists.some((params) => params.get('provider') === 'rule34xxx'), '按下来源之后没有按它向服务端取数');
await openGroup(page, '时长');
await page.locator('#durMin').focus();
await page.keyboard.press('ArrowRight');
await page.waitForFunction(() => new URL(location.href).searchParams.get('dur_min') === '300', undefined, { timeout: 5_000 });
await page.waitForFunction(() => document.querySelector('#drawer')?.classList.contains('open'), undefined, { timeout: 5_000 });
assert.ok(lists.some((params) => params.get('dur_min') === '300' && params.get('provider') === 'rule34xxx'),
'拉时长之后没有带着两条筛选向服务端取数');
assert.ok(!new URL(page.url()).searchParams.has('dur_max'), '右端没动却写进了上限');
await page.reload({ waitUntil: 'load' });
await page.locator('#drawer [data-sidebar-chip][data-val="rule34xxx"][aria-pressed="true"]').waitFor({ state: 'attached', timeout: 10_000 });
assert.equal(await page.locator('#durMin').inputValue(), '5', '刷新之后时长左端没有照地址回到 5 分钟');
assert.deepEqual(withoutPlayer(opened.problems), []);
} finally {
await opened.close();
}
});
it('作品详情开着时侧栏只剩导航,关掉之后列表那几组原样回来', { timeout: 60_000 }, async () => {
const opened = await openItemPage(browser, '/', DESKTOP, { ready: `#grid [data-media-card][data-id="${ITEM.plain}"]` });
const { page } = opened;
try {
await page.locator('#drawer [data-sidebar-group]').first().waitFor({ state: 'attached', timeout: 10_000 });
const groups = () => page.locator('#drawer [data-sidebar-group]').evaluateAll((nodes) => nodes.map((node) => node.getAttribute('data-sidebar-group')));
const before = await groups();
const sources = await chips(page, '来源');
assert.ok(before.length > 1, `首页的分组不全:${before.join('、')}`);
await page.locator(`#grid [data-media-card][data-id="${ITEM.plain}"] [data-media-title]`).first().click();
await page.locator('#stage[open] [data-item-side]').waitFor({ timeout: 10_000 });
assert.deepEqual(await groups(), [], '详情开着时侧栏还挂着列表的筛选分组');
assert.ok((await navKeys(page)).includes(''), '详情开着时导航那一列不在了');
await page.locator('#closeStage').click();
await page.waitForFunction((want) => JSON.stringify([...document.querySelectorAll('#drawer [data-sidebar-group]')]
.map((node) => node.getAttribute('data-sidebar-group'))) === want, JSON.stringify(before), { timeout: 10_000 });
assert.deepEqual(await chips(page, '来源'), sources, '关掉详情之后来源那一组的计数变了');
assert.deepEqual(withoutPlayer(opened.problems), []);
} finally {
await opened.close();
}
});
it('已被浮窗或弹层用掉的 Esc 不再收起侧栏,没人处理的 Esc 照常收起', { timeout: 60_000 }, async () => {
const opened = await visit(browser, '/', DESKTOP);
const { page } = opened;
try {
await settle(page);
const drawer = () => page.evaluate(() => document.body.classList.contains('drawer-open'));
assert.equal(await drawer(), true, '桌面宽度默认展开侧栏');
// 详情先于全局入口关完时,冒泡上来的就是这样一下已被 preventDefault 的 Esc。
await page.evaluate(() => document.body.addEventListener('keydown', event => {
if (event.key === 'Escape') event.preventDefault();
}, { capture: true, once: true }));
await page.keyboard.press('Escape');
assert.equal(await drawer(), true, '已被用掉的 Esc 收起了侧栏');
await page.keyboard.press('Escape');
assert.equal(await drawer(), false, '没人处理的 Esc 没有收起侧栏');
assert.deepEqual(withoutPlayer(opened.problems), []);
} finally {
await opened.close();
}
});
it('切页时当前项玻璃保留节点,完整播放动画并停在选中项', { timeout: 60_000 }, async () => {
const opened = await visit(browser, '/', DESKTOP);
const { page } = opened;
try {
await expectBody(page, '首页', [page.locator('#drawer.open [data-sidebar-nav] [aria-pressed="true"]')]);
await page.locator('#drawer [data-sidebar-group]').first().waitFor({ state: 'attached', timeout: 15_000 });
await settle(page);
await page.emulateMedia({ reducedMotion: 'no-preference' });
for (const key of ['performers', 'tags', '']) {
const run = await page.evaluate(async (target) => {
const glide = document.querySelector('#drawer > [data-sidebar-glide]')!;
(glide as unknown as { __mark: boolean }).__mark = true;
const seen = new Set();
let cancelled = 0;
const watch = () => glide.getAnimations().forEach((animation) => {
if (seen.has(animation)) return;
seen.add(animation);
animation.addEventListener('cancel', () => { cancelled += 1 });
});
document.querySelector(`#drawer [data-sidebar-nav] [data-nav="${target}"]`)!.click();
watch();
const started = seen.size;
await Promise.all([...seen].map((animation) => animation.finished));
const now = document.querySelector('#drawer > [data-sidebar-glide]') as (HTMLElement & { __mark?: boolean }) | null;
const pressed = document.querySelector('#drawer [data-sidebar-nav] [aria-pressed="true"]')!;
return {
same: !!now?.__mark, started, cancelled, to: now!.getBoundingClientRect().top,
pressedKey: pressed.dataset.nav, pressedTop: pressed.getBoundingClientRect().top,
};
}, key);
assert.equal(run.pressedKey, key, `点了 ${key || '首页'} 之后按下的不是它`);
assert.ok(run.same, '切页之后那块玻璃换了节点,位移会从头起跑');
assert.ok(run.started > 0, `切到 ${key || '首页'} 时玻璃没有起动画`);
assert.equal(run.cancelled, 0, `切到 ${key || '首页'} 的途中玻璃的动画被掐断了`);
assert.ok(Math.abs(run.to - run.pressedTop) <= 1, `玻璃停在 ${run.to},当前项在 ${run.pressedTop}`);
}
assert.deepEqual(opened.problems, []);
} finally {
await opened.close();
}
});
it('手机宽度下抽屉收着、点开盖在页面上,点遮罩或点导航都收回去;滚动层仍挂着覆盖式滚动条', { timeout: 60_000 }, async () => {
const opened = await visit(browser, '/', PHONE);
const { page } = opened;
try {
await page.locator('#drawer [data-sidebar-nav] [data-nav]').first().waitFor({ state: 'attached', timeout: 15_000 });
await settle(page);
const state = () => page.evaluate(() => {
const drawer = document.querySelector('#drawer')!;
return {
open: drawer.classList.contains('open'), inert: drawer.inert,
expanded: document.querySelector('#filterBtn')!.getAttribute('aria-expanded'),
scrim: getComputedStyle(document.querySelector('#scrim')!).display,
left: Math.round(drawer.getBoundingClientRect().left),
};
});
const closed = await state();
assert.equal(closed.open, false, '手机上一进来抽屉就开着');
assert.equal(closed.inert, true, '收着的抽屉没有 inert,Tab 会走进屏幕外面');
assert.equal(closed.scrim, 'none', '抽屉收着时遮罩还在渲染树里');
assert.ok(closed.left < 0, '收着的抽屉没有移出屏幕');
await page.locator('#filterBtn').tap();
await page.waitForFunction(() => document.querySelector('#drawer')?.classList.contains('open'));
const shown = await state();
assert.deepEqual([shown.inert, shown.expanded, shown.scrim, shown.left], [false, 'true', 'block', 12], '点开之后抽屉没有盖进来');
assert.equal(await page.evaluate(() => document.elementFromPoint(370, 420)?.id), 'scrim', '抽屉右边那一截点到的不是遮罩');
await page.mouse.click(370, 420);
await page.waitForFunction(() => !document.querySelector('#drawer')?.classList.contains('open'));
assert.deepEqual((await state()).scrim, 'none', '点遮罩收起之后遮罩没退出渲染树');
await page.locator('#filterBtn').tap();
await page.waitForFunction(() => document.querySelector('#drawer')?.classList.contains('open'));
await page.locator('#drawer [data-sidebar-nav] [data-nav="tags"]').tap();
await page.waitForFunction(() => location.pathname === '/tags' && !document.querySelector('#drawer')?.classList.contains('open'),
undefined, { timeout: 5_000 });
const scroller = await page.evaluate(() => {
const scroll = document.querySelector('#drawerScroll')!;
const drawer = document.querySelector('#drawer')!;
return {
parent: scroll.parentElement?.id, overflow: getComputedStyle(scroll).overflowY,
drawerOverflow: getComputedStyle(drawer).overflowY,
nav: !!scroll.querySelector(':scope > [data-sidebar-nav]'),
tracks: drawer.querySelectorAll(':scope > .ovtrack').length,
};
});
assert.deepEqual(scroller, { parent: 'drawer', overflow: 'auto', drawerOverflow: 'hidden', nav: true, tracks: 2 },
'滚的不是抽屉里那一层,或覆盖式滚动条没有接在它上面');
assert.deepEqual(opened.problems, []);
} finally {
await opened.close();
}
});
});