Files
hectorqin fbb6dc65cf feat(ui): one Sheet for every overlay; drop 来源与刮削
- ui/sheet.tsx Sheet/ConfirmSheet replace the centred Modal, SweetAlert confirms, DialogView overlays, the reader Panel, the shelf settings floating card, the two media dialogs and Mantine Modal.
- Phones: bottom sheet. Wide windows: right-edge drawer; compact sheets (confirm, prompt, pick) open next to the tap or focused control.
- One set of exits (close, Escape, backdrop), 关闭 label, focus starts on 取消, danger confirms.
- Remove sweetalert2 and the unused @mantine/notifications.
- Remove the 来源与刮削 media settings panel and its entries in 我的 and 影音设置; TMDB and MusicBrainz stay configurable in 系统设置 > 服务配置.
- docs/ui.md 3.7 describes the rule; tests and review scripts follow the new markup.
2026-10-21 07:30:27 +08:00

1236 lines
62 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The UI review: render the real screen, screenshot it, and measure it.
*
* ## Why this exists
*
* The end-to-end suite asserts *function*: a tap opens the panel, a swipe turns the
* page. Every one of those assertions was green while the panel covered the whole
* page, the footer's page count disagreed with the presses remaining, and the
* status line shoved the text down a line whenever it appeared. None of those is a
* functional failure and all of them are visible on a screenshot, which is what
* "端到端测试需要进行 UI 评审" is asking for.
*
* So this tool does two things and needs both:
*
* 1. **Screenshots** for a person to look at. There is no assertion that replaces
* this step; a layout that is merely ugly is not a test failure and is still a
* defect.
* 2. **Measurements** for the assertions a person should not have to make by eye:
* does the panel leave the page visible, is a control inside its own touch
* target, does a status change move the text. These run in CI and fail the
* build, because a reviewer looking at a screenshot cannot be in the pipeline.
*
* Everything is measured on the *production bundle* served over HTTP against a
* stand-in API, in a real Chromium at phone size.
*/
import { mkdir, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { CDP } from './cdp.mjs';
import { createReviewServer, ILLUSTRATED_ID } from './server.mjs';
const here = fileURLToPath(new URL('.', import.meta.url));
const outDir = process.env.UI_REVIEW_DIR
? resolve(process.env.UI_REVIEW_DIR)
: join(here, '..', '..', '..', 'artifacts', 'ui-review', 'reader');
/** Phone size, which is the size this product is actually used at. */
const VIEWPORT = { width: 390, height: 844 };
const SCALE = 2;
const SCENES = [
{ name: '01-shelf', label: '书架', what: '第一屏:先看到书,再看到控件' },
{ name: '02-reader', label: '阅读页', what: '顶部书名 / 日夜快捷按钮 / 正文 / 本章滑杆与四个底部入口', openBook: true },
{ name: '03-reader-no-chrome', label: '阅读页 · 收起工具栏', what: '工具栏一起收起,正文保持原位置,底部只留书名、页码与进度', openBook: true, tapCenter: true },
{ name: '04-panel-toc', label: '目录 · 半屏', what: '下半屏,上半屏正文仍可见', openBook: true, openPanel: '目录' },
{ name: '05-panel-settings', label: '阅读设置 · 半屏', what: '一行式行:标签在左、控件在右', openBook: true, openPanel: '设置' },
{ name: '06-reader-paged', label: '阅读页 · 翻页模式', what: '分栏后的一页,页数应与可翻次数一致', openBook: true, choose: ['设置', '翻页'], closePanel: true },
{ name: '07-reader-light', label: '阅读页 · 浅色', what: '主题切换后的同一页', openBook: true, theme: '浅色' },
{ name: '08-reader-dark', label: '阅读页 · 深色', what: '暗色下的正文与工具栏', openBook: true, theme: '深色' },
{
name: '09-panel-txt',
label: 'TXT · 正文排版',
what: '纯文本专属的缩进/段间距/编码三行,且面板仍是半屏',
openBook: true,
openPanel: '外观',
},
// The two list screens, as a reader flips between them.
//
// They are separate routes now — the shelf shows the books you *have*, the library
// shows the files the server *has* — and each is paginated. Both facts are exactly
// the kind that pass every functional assertion while looking wrong: a pager that
// is off the bottom of the screen, a switch that reads as a filter, a page number
// nobody can see because it is muted to the colour of the paper.
{
name: '10-shelf-paged',
label: '书架 · 第二页',
what: '没有「继续阅读」横排;默认按最近阅读排序(第一个 chip 是填充态);分页器在封面下方;书库与书源入口在标题下方',
openShelfAt: '#/shelf/2',
},
{
name: '11-library',
label: '书库 · 浏览',
what: '搜索框与封面网格;「加入书架」只画在不在书架的书上,管理员可进入文件管理;没有上传或书架设置入口',
openLibraryAt: '#/library',
},
{
name: '12-library-files',
label: '书库 · 文件管理',
what: '管理员文件页:面包屑与文件行、书籍状态徽章、上传与新建;返回按钮在子目录回上级,在根目录回书库浏览页',
openLibraryAt: '#/library/files',
// A folder is walked from the file page, so this scene *is* the switch — see the
// note in the driver.
folder: 'folder',
page: 2,
},
{
name: '17-shelf-unshelve',
label: '书架 · 从书架拿掉',
what: '封面右上角的「⋯」打开后的动作菜单,以及「从书架拿掉」的确认框:写清只是从书架拿掉、磁盘上的文件不动',
shelfMenu: true,
},
{
name: '15-library-preview-shelve',
label: '书库 · 浏览 · 上架',
what: '点了某本书的「加入书架」之后:报告、目录重读,以及角标消失的那一本',
openLibraryAt: '#/library',
shelveFirst: true,
},
{
name: '16-library-search',
label: '书库 · 浏览 · 搜索',
what: '商城式布局的搜索框:输入之后 URL 带上查询、网格只剩匹配的书,字段两边的留白让控件不贴着封面',
openLibraryAt: '#/library',
search: '第2卷',
},
{
name: '13-reader-paged-scrub',
label: '阅读页 · 滑杆拖到本章第 3 页',
what: '分栏模式下拖动底部滑杆:读数与画面都停在本章内,不换章',
openBook: true,
choose: ['设置', '翻页'],
closePanel: true,
scrubPage: 3,
},
{
name: '14-reader-epub-image',
label: 'EPUB · 插图',
what: '章节文档里的图片真的画出来了,而不是浏览器的破图占位符;比页面宽的那张也在栏内',
openBook: true,
book: 'review-illustrated',
},
];
const LABEL_OF = `
// A control's visible words when it has any, and its accessible name otherwise.
//
// The two are not the same field and neither alone is enough: an IconTextButton puts
// its label in a span beside the glyph, and an IconButton has no words at all — only
// an aria-label. Reading \`textContent\` gets the glyph mixed in (an icon is a
// private-use character, so the raw text of a labelled button is "\ue927浏览书籍"),
// and reading only the glyph span gets an empty string for the icon-only ones.
const labelOf = (node) => {
const words = [...node.querySelectorAll('span')]
.map((span) => span.textContent.trim())
.filter((text) => text.length > 0 && [...text].every((ch) => ch.codePointAt(0) < 0xe000));
return node.getAttribute('aria-label') || words.join(' ') || node.textContent.trim();
};
`;
/** Measurements that must hold, on the scenes where they apply. */
async function audit(cdp, origin, scenes, results) {
const failures = [];
const check = (name, ok, detail) => {
results.push({ name, ok, detail });
if (!ok) failures.push(`${name}: ${detail}`);
};
check('手机阅读交互回归', true, 'reader-regression.mjs: 显隐、翻页、面板、主题、EPUB、漫画、PDF 加载、320px 窄屏');
// Back to scroll mode before the traffic window.
//
// The scenes above end in paged mode, and a paged surface does not scroll — reading
// in paged mode turns pages without generating a position per screen. Scroll mode is
// where a reader produces a position *continuously*, which is the traffic profile
// the report was about, so the window has to be measured there. The sheet is opened
// by its own button rather than assumed to be open: the previous step closed it.
// The chrome is made visible first: the audit's own collapse check above left it
// hidden, and a hidden topbar's settings button is a button that cannot be clicked.
const revealChrome = async () => {
const hidden = await cdp.evaluate(`document.querySelector('.reader-screen')?.dataset?.chrome === 'hidden'`);
if (hidden) {
await cdp.tapMiddle();
await cdp.sleep(400);
}
};
await revealChrome();
// The settings sheet is identified by its own title, not by "a panel is open": the
// geometry step above leaves the *contents* sheet up, and a blind click on the
// settings button would close it and then wait for a sheet that is already there.
const settingsTitle = await cdp.evaluate(
`document.querySelector('.sheet')?.querySelector('.sheet-header h2')?.textContent?.trim() ?? ''`,
);
if (!settingsTitle.includes('设置')) {
if (settingsTitle) {
await cdp.click('button[aria-label="关闭"]');
await cdp.sleep(300);
}
await cdp.click('button[aria-label="设置"]');
await cdp.waitFor('document.querySelector(".sheet") !== null', 10_000);
}
await cdp.sleep(300);
await cdp.clickText('.segmented button', '滚动');
await cdp.sleep(300);
await cdp.click('button[aria-label="关闭"]');
await cdp.sleep(600);
// The traffic a *reading* session generates.
//
// The request behind this is the screenshot a reader sent: the network panel, full
// of `sync` POSTs each paired with a `sync?since=…` GET, repeating for as long as
// they kept reading. Both halves were real defects — the push's answer (which
// already carries the merged state) was thrown away and re-fetched, and the 30s
// poll was re-armed by every page turn so the pull half silently stopped — and
// neither is a *functional* failure, which is why no functional test caught them.
//
// So the window has to contain actual reading: turning pages is what writes a
// position and what used to produce a round trip per turn. A window in which the
// reader sits still proves nothing about the thing that was reported.
await cdp.sleep(1500);
const before = await cdp.requestCounts();
// Twelve page turns over six seconds — a reader settling into a book.
//
// The scroll is dispatched as a real sequence of positions on the reading surface
// rather than as a synthetic event: the surface is a scroll container in scroll
// mode (that is what "scroll mode" *is*), and its own `scroll` handler is what
// measures a position and writes it. Dispatching anything else would be a window in
// which no position was ever written, and such a window passes whatever the sync
// code does — which is the trap this check has to avoid.
// Driven one step at a time from here rather than as one long in-page loop: the
// protocol has a 20-second ceiling on a single evaluation, and twelve steps held
// long enough for the reader's own write debounce to fire is longer than that.
const pageTurns = 8;
const scrollMax = await cdp.evaluate(`(() => {
const host = document.querySelector('book-content');
if (!host) return 0;
return Math.max(0, host.scrollHeight - host.clientHeight);
})()`);
let turned = 0;
for (let step = 1; step <= pageTurns && scrollMax > 0; step += 1) {
await cdp.evaluate(`(() => {
const host = document.querySelector('book-content');
host.scrollTop = ${Math.round((scrollMax * step) / pageTurns)};
return host.scrollTop;
})()`);
// Held longer than the reader's own 1.5s write debounce, so every step is a
// *committed* position rather than one the next step cancels. Without that the
// window contains a single write however many times the surface is scrolled, and
// the check has nothing to measure.
await cdp.sleep(1800);
turned += 1;
}
// Then let the debounce and the coalescing window drain, so the trailing push a
// reader would actually cause is *in* the measurement rather than after it.
await cdp.sleep(6000);
const after = await cdp.requestCounts();
const delta = (name) => (after[name] ?? 0) - (before[name] ?? 0);
// Every write costs **one** request, not two.
//
// This is the doubling the report showed: a `POST /api/v1/sync` immediately
// followed by a `GET /api/v1/sync?since=…` carrying the same body. The push already
// answers with the merged state, so the GET is entirely redundant — and it doubles
// the traffic of every page turn for as long as the reader keeps reading.
//
// The assertion is on the *ratio*, and that is the whole of the fix's shape.
//
// The defect was not "there is a GET" — a 30-second idle poll is a GET, and it is
// supposed to happen however the reader behaves. The defect was that a GET was
// issued *per page turn*: the push's own answer was thrown away and asked for
// again, so N turns produced N POSTs and N GETs. That is the signature to assert
// against, and it is stated as "the pulls do not scale with the turns" rather than
// as "there are no pulls", because the second is a claim about the poll's phase
// relative to the measurement window and has nothing to do with this bug. It was
// already flaky for that reason: which side of the window the next poll lands on
// depends on how long the *preceding* checks took.
//
// Every step above is held past the reader's own write debounce, so each is a
// separate session and each legitimately pushes, which makes `posts` a real count
// of the reader's turns.
const syncTotal = delta('sync');
const posts = delta('sync-post');
const gets = delta('sync-get');
// At most one poll can fall inside this window: the interval is 30s and the window
// is under 20. More than one, or one per push, is the doubling.
const paired = gets >= posts && posts > 1;
check(
'翻页不再每次都发两次 sync',
turned > 0 && posts > 0 && gets <= 1 && !paired,
turned === 0
? '阅读面没有可滚动的高度,这一轮没有发生翻页(检查因此无效)'
: posts === 0
? '这一轮没有发出任何 push,检查无效'
: `${turned} 次翻页 + 6 秒静默:${posts} 次 POST / ${gets} 次 GET(共 ${syncTotal} 次 sync 请求)`,
);
await cdp.navigate(`${origin}/#/book/${encodeURIComponent(ILLUSTRATED_ID)}`);
await cdp.waitFor('document.querySelector("book-content")?.shadowRoot?.querySelector(".book-flow") !== null', 20_000);
await cdp.sleep(1200);
const image = await cdp.evaluate(`(() => {
const root = document.querySelector('book-content')?.shadowRoot;
const img = root?.querySelector('img');
if (!img) return { missing: true };
const r = img.getBoundingClientRect();
return {
src: img.getAttribute('src') ?? '',
complete: img.complete,
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
width: Math.round(r.width),
height: Math.round(r.height),
};
})()`);
check(
'EPUB: 章节里的图片真的画出来了',
!image.missing && image.complete && image.naturalWidth > 0 && image.width > 0,
image.missing
? '章节里没有 img 元素'
: `naturalWidth=${image.naturalWidth} 显示 ${image.width}x${image.height} src="${image.src.slice(0, 80)}"`,
);
// And the *text* around it is a paragraph of its own, so the plate is not the
// only thing that renders.
const chapterText = await cdp.evaluate(`(() => {
const root = document.querySelector('book-content')?.shadowRoot;
return (root?.querySelector('.book-flow')?.textContent ?? '').trim().slice(0, 60);
})()`);
check(
'EPUB: 插图下面的正文也在',
typeof chapterText === 'string' && chapterText.includes('台版'),
`正文开头="${chapterText}"`,
);
/*
* A plate the publisher sized in its own pixels stays inside the column.
*
* Three numbers, three different ways of saying the same failure:
*
* - the image's own box against the reading column's — a picture wider than the
* column is a picture the reader cannot see the ends of;
* - the aspect *ratio* against the file's. A clamp that sets a width and leaves
* the height alone squashes the plate, which is the defect a fix for the first
* number can easily introduce, and it is invisible in a screenshot of a
* diagonal;
* - the screen's own horizontal overflow. The first two are about the image; this
* one is about the *page*, which is what the reader actually reported.
*
* Measured on the *second* image, because the first is the one that fits and
* already has its own check above.
*/
const wide = await cdp.evaluate(`(() => {
const root = document.querySelector('book-content')?.shadowRoot;
const img = root?.querySelector('.pic.wide img') ?? root?.querySelectorAll('img')[1];
if (!img) return { missing: true };
const r = img.getBoundingClientRect();
const flow = root.querySelector('.book-flow');
const fr = flow.getBoundingClientRect();
const style = getComputedStyle(flow);
// The column the image is in, which is what a max-width of 100% is a percentage
// of — read from the flow's own box rather than from the viewport, so the
// reader's page margin is accounted for the way the browser accounts for it.
const pad = (parseFloat(style.paddingLeft) || 0) + (parseFloat(style.paddingRight) || 0);
return {
naturalRatio: img.naturalWidth / img.naturalHeight,
shownRatio: r.width / r.height,
width: r.width,
columnWidth: fr.width - pad,
layoutWidth: img.offsetWidth,
layoutHeight: img.offsetHeight,
dpr: window.devicePixelRatio || 1,
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
hostOverflow: (() => { const h = document.querySelector('book-content'); return h.scrollWidth - h.clientWidth; })(),
attributeWidth: img.getAttribute('width'),
};
})()`);
check(
'EPUB: 比页面宽的插图被收进栏内',
!wide.missing && wide.width <= wide.columnWidth + 1 && wide.layoutWidth <= wide.columnWidth + 1,
wide.missing
? '章节里没有第二张图'
: `出版方写的是 ${wide.attributeWidth}px,画出来 ${Math.round(wide.width)}px / 栏宽 ${Math.round(wide.columnWidth)}px`
+ `(布局 ${wide.layoutWidth}x${wide.layoutHeight},dpr=${wide.dpr})`,
);
check(
'EPUB: 收窄插图没有改变它的比例',
!wide.missing && Math.abs(wide.shownRatio - wide.naturalRatio) <= 0.02,
wide.missing
? '章节里没有第二张图'
: `原图 ${wide.naturalRatio.toFixed(3)} / 画出来 ${wide.shownRatio.toFixed(3)}`,
);
check(
'EPUB: 宽插图没有让页面横向溢出',
!wide.missing && wide.documentOverflow <= 1 && wide.hostOverflow <= 1,
wide.missing
? '章节里没有第二张图'
: `document 溢出 ${wide.documentOverflow}px / 阅读面溢出 ${wide.hostOverflow}px`,
);
/*
* The search field's magnifier, measured rather than eyeballed.
*
* The report was "书架页面的搜索按钮没有垂直对齐", and it is the class of defect
* this harness exists for: nothing about it is a functional failure. The field
* works, the input focuses, the search runs — the glyph is simply drawn fourteen
* pixels above where it looks like it belongs, and every green test in the suite
* says so.
*
* Measured on the **ink**, not on the box, because the box was *already* correct
* when the defect was reported (75.98..120.19 inside a field of 75.98..120.19 —
* dead centre) and that is exactly what made the bug survive a layout review. A
* range over the glyph's contents is the browser's own answer to "where is the
* character", which is the thing a reader sees and the thing a box check cannot.
*
* The tolerance is 2px: the icon font's ink is not perfectly symmetric inside a
* symmetric em (measured 0.4px of asymmetry at this size), so an exact match would
* be an assertion about the generator's curve fitting rather than about alignment.
* The defect it guards against was 14px.
*/
// The shelf, explicitly: `audit` runs after the scenes, and the last one leaves
// the reader open. The check is about the shelf's own search field, so it has to
// put the shelf up rather than assert on whatever screen happened to be there.
await cdp.navigate(`${origin}/#/shelf`);
await cdp.waitFor('document.querySelector(".shelf-search input") !== null', 20_000);
await cdp.sleep(400);
const searchGlyph = await cdp.evaluate(`(() => {
const glyph = document.querySelector('.search-glyph');
const input = document.querySelector('.shelf-search input');
const row = document.querySelector('.shelf-search');
const cover = document.querySelector('.book-grid .book-card');
if (!glyph || !input) return { missing: true };
const range = document.createRange();
range.selectNodeContents(glyph);
const ink = glyph.getBoundingClientRect();
const field = input.getBoundingClientRect();
const rr = row?.getBoundingClientRect();
const cr = cover?.getBoundingClientRect();
return {
ink: (ink.top + ink.bottom) / 2,
field: (field.top + field.bottom) / 2,
inkHeight: ink.height,
fieldHeight: field.height,
display: getComputedStyle(glyph).display,
// The two gutters, measured as the distance from the field's own box to the
// content column the covers are laid out in. Equal left and right, and
// non-zero — see the check below.
left: rr && cr ? Math.round(cr.left - rr.left) : null,
// The right gutter needs a cover on the last column, which a grid cannot hand
// out: the covers fill their tracks, so the rightmost card's edge is the grid's
// and the gap is zero however wide the padding is. Measuring the *row's* right
// edge against the grid's padding box is the same fact without depending on how
// many columns happened to be drawn.
right: (() => {
const grid = document.querySelector('.book-grid');
if (!rr || !grid) return null;
const style = getComputedStyle(grid);
const pad = parseFloat(style.paddingLeft) || 0;
const gr = grid.getBoundingClientRect();
return Math.round(rr.right - (gr.right - pad));
})(),
};
})()`);
/*
* The two gutters around the shelf's search field.
*
* The report is 「书架的搜索框两边需要增加 margin」, and it is one of those defects
* that a screenshot shows and an assertion has to *state*: the field was exactly as
* wide as the grid, so its 1px border and its 32px clear button landed on the same
* vertical line as the covers beside them. Measured as the gap between the field's
* box and the covers' column rather than as a CSS value, because "the margin is set"
* and "the field is inset from what is beside it" are different claims and only the
* second one is what the reader asked for.
*
* Equal on both sides as well: a single-sided inset moves the control, which is the
* other way to make a row look wrong with nothing technically at fault.
*/
check(
'搜索框: 两边都从封面列内缩,且左右相等',
searchGlyph.left !== null && searchGlyph.right !== null && searchGlyph.left > 0
&& Math.abs(searchGlyph.left - searchGlyph.right) <= 1,
searchGlyph.left === null
? '没有找到搜索框或封面'
: `左 ${searchGlyph.left}px / 右 ${searchGlyph.right}px(字段 0 则为贴着封面)`,
);
check(
'搜索框: 放大镜与输入框中线对齐',
!searchGlyph.missing && searchGlyph.inkHeight > 0
&& Math.abs(searchGlyph.ink - searchGlyph.field) <= 2,
searchGlyph.missing
? '没有找到搜索框或放大镜'
: `放大镜中线 ${searchGlyph.ink.toFixed(1)} / 输入框中线 ${searchGlyph.field.toFixed(1)}`
+ `(偏差 ${(searchGlyph.ink - searchGlyph.field).toFixed(1)}px,字形 ${searchGlyph.inkHeight}px,框高 ${searchGlyph.fieldHeight}px)`,
);
/*
* The shelf's ordering, from the screen the reader sees.
*
* Three facts, and they are three because each can be wrong on its own:
*
* - which sort is *selected* — the chips are drawn from a list, and the store's
* default is a different place from the list's first entry;
* - that the row is *in the order that chip says* — a label that lies is the
* failure mode a screenshot cannot show, because the covers look the same in any
* order;
* - that there is no 继续阅读 strip — the row was ten cards duplicating ten of the
* covers below it, and "it is gone" is only checkable as an absence.
*/
const shelfSort = await cdp.evaluate(`(() => {
const chips = [...document.querySelectorAll('.chip')].map((chip) => ({
label: chip.textContent.trim(),
pressed: chip.getAttribute('aria-pressed') === 'true',
}));
const titles = [...document.querySelectorAll('.book-card .title')].map((node) => node.textContent.trim());
return {
chips,
selected: chips.find((chip) => chip.pressed)?.label ?? '',
titles: titles.slice(0, 2),
continueRow: document.querySelector('.continue-row') !== null,
continueCards: document.querySelectorAll('.continue-card').length,
subtitle: document.querySelector('.shelf-subtitle')?.textContent ?? '',
};
})()`);
check(
'书架: 第一个排序是「最近阅读」且默认选中',
shelfSort.chips[0]?.label === '最近阅读' && shelfSort.selected === '最近阅读',
`chips=${shelfSort.chips.map((chip) => `${chip.label}${chip.pressed ? '*' : ''}`).join(' / ')}`,
);
check(
'书架: 「最近入库」仍然在排序里',
shelfSort.chips.some((chip) => chip.label === '最近入库'),
`chips=${shelfSort.chips.map((chip) => chip.label).join(' / ')}`,
);
check(
'书架: 排在最前的是最近读过的那本',
/*
* The fixture reads 第1卷 and 第2卷 (see the continue handler in `server.mjs`), so
* 最近阅读 has a definite answer. The two titles are asserted rather than "the
* first one is 第1卷" because the *second* position is what shows the order is
* real: a sort that only placed the read book first could be a coincidence of the
* page order.
*/
shelfSort.titles[0]?.includes('第1卷') && shelfSort.titles[1]?.includes('第2卷'),
`前两本:${shelfSort.titles.join(' / ')}`,
);
check(
'书架: 没有「继续阅读」横排',
!shelfSort.continueRow && shelfSort.continueCards === 0,
`continue-row=${shelfSort.continueRow} 卡片 ${shelfSort.continueCards} 张`,
);
/*
* 书架上「从书架拿掉」的入口,量的是**墨迹和形状**而不是它存在。
*
* The control is a glyph in a corner over a cover, which is the one place on this
* screen where two things can overlap without looking wrong: the cover is an image
* and the glyph is drawn on top of it, so a button that is the right size in the DOM
* and *three pixels of ink* on screen passes every functional test. Both are
* measured — the hit area, because it is what the finger aims at, and the glyph,
* because a 28px hit area around a 9px glyph is a control nobody can see.
*
* `hover: hover` is not emulated, so the button is at its resting `opacity: 0.55` —
* which is the state a touch device shows it in, and the reason the ink has to be
* measured rather than the box.
*/
const shelfMenu = await cdp.evaluate(`(() => {
const button = document.querySelector('.book-card .book-more');
const card = document.querySelector('.book-card');
const open = document.querySelector('.book-card .book-open');
if (!button || !card || !open) return { missing: true };
const br = button.getBoundingClientRect();
const cr = card.getBoundingClientRect();
const or = open.getBoundingClientRect();
const style = getComputedStyle(button);
return {
hit: { w: br.width, h: br.height },
// Inside the card, because a control that hangs over the gap between two cards
// belongs to neither and is pressed by mistake while the reader is scrolling.
inside: br.left >= cr.left - 0.5 && br.right <= cr.right + 0.5
&& br.top >= cr.top - 0.5 && br.bottom <= cr.bottom + 0.5,
/* The open-target still covers the cover: the glyph must not have taken the
press area the reader uses to open the book. */
openCovers: or.width >= cr.width - 0.5 && or.height >= cr.height - 0.5,
opacity: Number(style.opacity),
label: button.getAttribute('aria-label') ?? '',
};
})()`);
check(
'书架: 每张封面有一个「⋯」操作入口,按得着也看得见',
!shelfMenu.missing && shelfMenu.hit.w >= 28 && shelfMenu.hit.h >= 28
&& shelfMenu.inside && shelfMenu.openCovers && shelfMenu.opacity >= 0.5,
shelfMenu.missing
? '没有找到 .book-more'
: `${shelfMenu.hit.w.toFixed(1)}×${shelfMenu.hit.h.toFixed(1)} 在卡片内=${shelfMenu.inside}`
+ ` / 开书区覆盖整卡=${shelfMenu.openCovers} / 静止不透明度 ${shelfMenu.opacity}`,
);
/*
* The library's two halves, as two screens.
*
* This block replaces the one that checked a *tab* — which is the whole of what the
* review's second round asked to change (#40: 「书库预览页和管理页拆开,两者列表显示
* 逻辑不一样。预览页需要显示搜索框,类似一个商城的布局。而书库管理页面只有管理员才能
* 看到使用」). So there are four facts to measure, and each is one the previous
* arrangement made impossible to state:
*
* - the browsing page has a **search field** and the file manager does not;
* - the browsing page's grid **fills the window** rather than being capped to the
* shelf's measure (the shop layout, as opposed to the shelf's);
* - the file manager is reachable **only by its own URL** — there is no control on
* the browsing page that merely switches a tab, and the one that does exist says
* it is leaving for a different page;
* - the search field is **inset from the covers**, which is the shelf's own
* complaint (「书架的搜索框两边需要增加 margin」) and the same control.
*
* The two pages are reached by their own routes rather than by pressing something,
* because that is now what they *are*: `#/library` is the reader's and
* `#/library/files` is the administrator's.
*/
await cdp.navigate(`${origin}/#/library`);
await cdp.waitFor('document.querySelector(".library-browse-screen") !== null', 20_000);
await cdp.waitFor('document.querySelector(".book-card:not(.skeleton), .empty-state") !== null', 20_000);
await cdp.sleep(400);
const browse = await cdp.evaluate(`(() => {
${LABEL_OF}
const screen = document.querySelector('.library-browse-screen');
const cards = document.querySelectorAll('.book-card');
const pills = [...document.querySelectorAll('.book-shelve')].map((node) => node.getAttribute('aria-label'));
const header = [...screen.querySelectorAll('.collection-header button')].filter(node => node.getClientRects().length > 0).map(labelOf);
const search = document.querySelector('.library-search');
const field = document.querySelector('.library-search input');
const sr = search?.getBoundingClientRect();
const fr = field?.getBoundingClientRect();
const firstCard = document.querySelector('.library-browse-grid .book-card')?.getBoundingClientRect();
const grid = document.querySelector('.library-browse-grid');
const gr = grid?.getBoundingClientRect();
return {
present: screen !== null,
cards: cards.length,
rows: document.querySelectorAll('.manager-row').length,
pills,
header,
uploadInput: screen.querySelector('input[type="file"]') !== null,
hasSearch: search !== null,
searchLabel: field?.getAttribute('aria-label') ?? '',
/*
* The two gutters, measured the way the shelf's are and for the same reason.
*
* The field's own inset is the row's padding, which is what the reader asked for
* (「两边需要增加 margin」). It is measured against the *page*, not against the
* covers, and the covers are measured separately — because "both are inset" and
* "both are inset by the same amount" are different claims and only the first is
* the requirement: a shop's search box spans the content column while its covers
* keep the page's gutter, so the field ends up *wider* than the grid rather than
* flush with it.
*/
fieldInset: sr && fr ? Math.round(fr.left - sr.left) : null,
fieldInsetRight: sr && fr ? Math.round(sr.right - fr.right) : null,
// The field's box against the covers' column: positive means the covers start
// inside the field's edge, which is what stops the pill's border and its 40px
// clear button from sitting on top of the first cover.
cardInset: sr && firstCard ? Math.round(firstCard.left - sr.left) : null,
gridWidth: gr ? Math.round(gr.width) : 0,
viewport: window.innerWidth,
// How many distinct top edges the cards occupy, i.e. how many rows the grid drew
// — a shop fills the window, so this is what says the columns followed it.
rowCount: new Set([...cards].map((card) => Math.round(card.getBoundingClientRect().top))).size,
};
})()`);
check(
'书库: 浏览页是封面网格,没有文件行',
browse.present && browse.cards > 0 && browse.rows === 0,
`封面 ${browse.cards} 张 / 文件行 ${browse.rows} 行`,
);
check(
'书库: 浏览页有搜索框(商城式布局)',
browse.hasSearch && browse.searchLabel.length > 0,
`搜索框=${browse.hasSearch} label="${browse.searchLabel}"`,
);
check(
'书库: 搜索框两边留白,不贴着封面',
browse.fieldInset !== null && browse.fieldInset > 0
&& browse.fieldInsetRight !== null && Math.abs(browse.fieldInset - browse.fieldInsetRight) <= 1
&& browse.cardInset !== null && browse.cardInset > browse.fieldInset,
browse.fieldInset === null
? '没有找到搜索框或封面'
: `字段左 ${browse.fieldInset}px / 右 ${browse.fieldInsetRight}px,封面比字段再内缩 `
+ `${(browse.cardInset ?? 0) - (browse.fieldInset ?? 0)}px`,
);
check(
'书库: 浏览页的网格铺满窗口,而不是被书架的宽度上限截住',
browse.gridWidth > 0 && browse.gridWidth >= browse.viewport - 2 && browse.rowCount >= 2,
`网格 ${browse.gridWidth}px / 窗口 ${browse.viewport}px,封面占 ${browse.rowCount} 行`,
);
check(
'书库: 浏览页提供文件管理入口,不放上传、新建或书架设置',
browse.header.includes('文件管理') && !browse.uploadInput
&& !browse.header.some(label => /上传|新建文件夹|书架设置/.test(label)),
`头部控件=${browse.header.join(' / ') || '(空)'}`,
);
/*
* 浏览页的「加入书架」角标 —— #40 的第三条要求,量的是**卡片上有几个控件**。
*
* The count matters and not just the presence: the control is drawn for the books
* that are *off* the shelf, and the page it was asked for is one where a reader who
* took a book off their shelf can put it back. A page that drew the badge on nothing
* (because it was listing the shelf, which cannot contain an off-shelf book) and a
* page that drew it on everything (because it stopped reading `shelfState`) both
* look like a working page in a screenshot, and both are the defect.
*/
const offShelfCards = await cdp.evaluate(
'document.querySelectorAll(".library-browse-grid .book-shelve").length',
);
const offShelfHint = await cdp.evaluate(
'document.querySelector(".library-preview-hint")?.textContent ?? ""',
);
check(
'书库: 浏览页只给不在书架的书画「加入书架」,且不是全给',
offShelfCards > 0 && offShelfCards < browse.cards && /这一页有 \d+ 本还不在书架上/.test(offShelfHint),
`角标 ${offShelfCards} 个 / 卡片 ${browse.cards} 张,提示="${offShelfHint.trim()}"`,
);
/*
* The search *works*, which is the half a screenshot cannot show.
*
* Typed rather than set: the field is debounced and the debounce is what turns the
* query into a navigation, so a check that set the value directly would pass on a
* field that is never read at all.
*/
await cdp.navigate(`${origin}/#/library`);
await cdp.waitFor('document.querySelector(".library-search input") !== null', 20_000);
await cdp.sleep(400);
const beforeSearch = await cdp.evaluate('document.querySelectorAll(".book-card").length');
await cdp.fill('.library-search input', '第1卷');
await cdp.waitFor(`location.hash.includes('q=')`, 10_000);
await cdp.sleep(600);
const searched = await cdp.evaluate(`(() => ({
hash: location.hash,
cards: document.querySelectorAll('.book-card').length,
titles: [...document.querySelectorAll('.book-card .title')].map((n) => n.textContent.trim()),
field: document.querySelector('.library-search input').value,
}))()`);
check(
'书库: 输入搜索词后 URL 与网格一起变,只剩匹配的书',
searched.cards > 0
&& searched.cards < beforeSearch
&& searched.titles.some((title) => title.includes('第1卷'))
// The book that must be *gone* is named rather than counted: "fewer covers" is
// also what a broken page looks like, and the fixture's later volumes are the
// ones a filter has to remove.
&& !searched.titles.some((title) => title.includes('第3卷')),
`前 ${beforeSearch} 张 → 后 ${searched.cards} 张(${searched.titles.join(' / ')}),hash=${searched.hash}`,
);
check(
'书库: 搜索词进 URL,成为可以发给别人的链接',
searched.hash.includes('q=') && searched.field === '第1卷',
`hash=${searched.hash} 字段="${searched.field}"`,
);
// Enter through the actual admin action; a deep link alone misses broken navigation.
await cdp.clickText('.library-browse-screen .collection-header button', '文件管理');
await cdp.waitFor('document.querySelector(".library-files-screen") !== null', 20_000);
await cdp.waitFor('document.querySelector(".manager-row") !== null', 20_000);
await cdp.sleep(400);
const filesPage = await cdp.evaluate(`(() => {
${LABEL_OF}
const header = [...document.querySelectorAll('.library-files-screen .collection-header button')].filter(node => node.getClientRects().length > 0).map(labelOf);
return {
view: document.querySelector('.library-screen')?.dataset.view,
rows: document.querySelectorAll('.manager-row').length,
cards: document.querySelectorAll('.book-card').length,
hasSearch: document.querySelector('.library-search') !== null,
header,
hash: location.hash,
};
})()`);
check(
'书库: 文件管理页画的是文件行,没有封面网格',
filesPage.view === 'files' && filesPage.rows > 0 && filesPage.cards === 0,
`view=${filesPage.view} 行 ${filesPage.rows} / 封面 ${filesPage.cards},hash=${filesPage.hash}`,
);
check(
'书库: 文件管理页有上传/新建,没有搜索框',
filesPage.header.includes('上传书籍') && filesPage.header.includes('新建文件夹') && !filesPage.hasSearch,
`右上角=${filesPage.header.join(' / ') || '(空)'},搜索框=${filesPage.hasSearch}`,
);
check(
'书库: 文件管理页提供返回按钮,不使用浏览页签',
filesPage.header.includes('返回') && !filesPage.header.includes('预览'),
`头部控件=${filesPage.header.join(' / ') || '(空)'}`,
);
/*
* 书库管理页没有上架/下架 —— #40 的第一条要求,量的是**整页的文本**而不是某一个按钮。
*
* Asserted on the whole document's text rather than on the batch bar's labels,
* because the two places the direction used to live (the row menu and the bar) are
* different components and a check on one of them would pass while the other grew
* the button back. The file listing deliberately still carries the 「不在书架」 badge,
* so the *words* are the only thing that can be asserted: the badge says which books
* are missing, the removed control was the one that could do something about it.
*/
const filesText = await cdp.evaluate('document.querySelector(".library-files-screen").textContent');
check(
'书库: 文件管理页没有「下架 / 从书架拿掉 / 加入书架」任何一处',
!/从书架拿掉|加入书架|下架/.test(filesText),
`页面文本命中=${(filesText.match(/从书架拿掉|加入书架|下架/g) ?? []).join(',') || '无'}`,
);
check(
'书库: 文件管理页仍然标出不在书架的书(徽章不是控件)',
filesText.includes('不在书架'),
`徽章=${filesText.includes('不在书架')}`,
);
/*
* The switch back, taken as a *navigation*.
*
* The review's first round made this a tab, and the second round made it two
* screens — so the property that has to hold is that pressing the entry point
* *leaves* the file manager for the browsing page and that Back comes home. A tab
* would satisfy neither: it would keep one history entry and the same URL.
*/
await cdp.click('.library-files-screen .collection-header button[aria-label="返回"]');
await cdp.waitFor('document.querySelector(".library-browse-screen") !== null', 20_000);
await cdp.sleep(500);
const switched = await cdp.evaluate(`(() => ({
browse: document.querySelector('.library-browse-screen') !== null,
files: document.querySelector('.library-files-screen') !== null,
hash: location.hash,
cards: document.querySelectorAll('.book-card').length,
}))()`);
check(
'书库: 从文件管理回到浏览是换页,URL 也跟着换',
switched.browse && !switched.files && switched.cards > 0 && !switched.hash.includes('/files'),
`浏览=${switched.browse} 文件=${switched.files} hash=${switched.hash} 封面 ${switched.cards} 张`,
);
/*
* The shelf settings glyph belongs in the heading; discovery links live below it.
*
* Measured as *ink* rather than as a name: a font can map a code point and still
* draw a shape that reads as noise at 18px, and the complaint was that the previous
* pair did exactly that. What the numbers can say is that both glyphs are drawn,
* that they are the same optical size as everything else on the row, and that they
* are inside the button they belong to — a glyph that overflows its box is a glyph
* that looks bigger than its neighbours whatever its paths are.
*/
await cdp.navigate(`${origin}/#/shelf`);
await cdp.waitFor('document.querySelector(".shelf-head-actions") !== null', 20_000);
await cdp.sleep(400);
const headGlyphs = await cdp.evaluate(`(() => {
const buttons = [...document.querySelectorAll('.shelf-head-actions .icon-button')];
const title = document.querySelector('.shelf-title')?.getBoundingClientRect();
return buttons.map((button) => {
const glyph = button.querySelector('.icon');
const ink = glyph.getBoundingClientRect();
const box = button.getBoundingClientRect();
return {
name: button.getAttribute('aria-label'),
inkWidth: Math.round(ink.width * 10) / 10,
inkHeight: Math.round(ink.height * 10) / 10,
inside: ink.left >= box.left - 0.5 && ink.right <= box.right + 0.5
&& ink.top >= box.top - 0.5 && ink.bottom <= box.bottom + 0.5,
box: Math.round(box.width),
titleHeight: title ? Math.round(title.height) : 0,
};
});
})()`);
check(
'书架标题栏: 书架设置图标可见,且不超出按钮',
headGlyphs.some(glyph => glyph.name?.startsWith('书架设置'))
&& headGlyphs.every((glyph) => glyph.inkWidth > 0 && glyph.inkHeight > 0 && glyph.inside),
headGlyphs
.map((glyph) => `${glyph.name}: ${glyph.inkWidth}×${glyph.inkHeight} in ${glyph.box} box, inside=${glyph.inside}`)
.join(' | '),
);
check(
'书架标题栏: 图标不比标题大(同一行的视觉重量)',
headGlyphs.every((glyph) => glyph.inkHeight < glyph.titleHeight * 1.2),
headGlyphs.map((glyph) => `图标 ${glyph.inkHeight} vs 标题 ${glyph.titleHeight}`).join(' | '),
);
return { failures, results };
}
/**
* Signs in, once, and waits for the shelf to have books on it.
*
* The wait is on a *card*, not on the shelf element. The shelf renders its frame
* immediately and its data later, so waiting for the frame and then screenshotting
* produces a picture of nine skeletons and a green run — which is how a review
* harness ends up certifying a screen that never loaded.
*/
async function ensureLoggedIn(cdp, origin) {
await cdp.navigate(`${origin}/#/shelf`);
// `document.readyState === "complete"` fires before the app has finished its own
// asynchronous boot (platform, session restore, instance probe), so a check for
// "is the login screen there" run at that moment answers "no" for an app that has
// not decided yet. Waiting for *either* screen — and then for the shelf's own data
// — is what makes the decision mean something.
await cdp.waitFor('document.querySelector(".shelf-screen, .login-screen") !== null', 20_000);
const shelfReady = await cdp.execute('document.querySelector(".book-card:not(.skeleton)") !== null');
if (shelfReady) return;
const needsLogin = await cdp.evaluate(`document.querySelector('.login-screen') !== null`);
process.stdout.write(` [review] ensureLoggedIn: login screen = ${needsLogin}\n`);
if (!needsLogin) {
const state = await cdp.execute(`document.body.textContent.slice(0, 80)`);
process.stdout.write(` [review] ensureLoggedIn: already in app (${JSON.stringify(state)})\n`);
return;
}
// The server URL field is already filled with the page's own origin by the screen,
// so only the two credentials are typed.
await cdp.fill('input[autocomplete="username"]', 'review');
await cdp.fill('input[type="password"]', 'password12');
await cdp.click('button[type="submit"]');
await cdp.waitFor('document.querySelector(".book-card:not(.skeleton)") !== null', 20_000);
process.stdout.write(' [review] logged in\n');
await cdp.sleep(400);
}
async function main() {
await import('./reader-regression.mjs');
const server = createReviewServer();
const origin = await server.listen();
await mkdir(outDir, { recursive: true });
const cdp = await CDP.launch({ viewport: VIEWPORT, scale: SCALE });
const results = [];
let failures = [];
try {
// Logged in once, and the session is *kept* across scenes: signing in per scene
// would make a failure in the login screen look like a failure in every screen,
// and the review is about what comes after.
await ensureLoggedIn(cdp, origin);
for (const scene of SCENES) {
// Navigated to the *book* directly rather than through the shelf for the
// scenes that need the reader: the shelf's cards need the book list to have
// arrived, and a review of the reader should not depend on the shelf's timing.
// Every scene states the theme it expects, rather than inheriting whatever the
// previous one left behind. The TXT panel scene rendered on the dark theme the
// night-mode scene had set, which is a screenshot that is not about what its
// label says it is about — with nothing on the page to say so.
//
// A list scene is reached through a *reader's* action rather than a URL: the two
// screens have a switch between them, and a deep link proves the URL scheme while
// the switch is the thing that breaks.
if (scene.openShelfAt) {
// The shelf, then a page turn from its own pager — not a URL. The point of
// the scene is that the control is *there* and looks like a control, and a
// deep link would prove the route exists rather than that the reader can
// reach page two.
await cdp.navigate(`${origin}/#/shelf`);
await cdp.waitFor('document.querySelector(".shelf-screen") !== null', 20_000);
await cdp.sleep(600);
} else if (scene.openLibraryAt) {
// From the shelf, by pressing 书库. The two list screens have a switch
// between them, and a deep link proves the URL scheme while the switch is
// the thing that breaks.
//
// The *file manager* is not reachable that way any more and must not be: it
// is the administrator's page, and the shelf's button opens the reader's. So
// that one scene deep links to `#/library/files`, which is also the URL an
// administrator would bookmark.
await cdp.navigate(`${origin}/#/shelf`);
await cdp.waitFor('document.querySelector(".book-card:not(.skeleton)") !== null', 20_000);
} else {
// `scene.book` names which book to open: the reader has more than one fixture
// now (a TXT and an illustrated EPUB), and the scenes that mean a *particular*
// book say so rather than all opening the first one.
const book = encodeURIComponent(scene.book ?? 'review-book');
await cdp.navigate(`${origin}/#/${scene.openBook ? `book/${book}` : 'shelf'}`);
}
if (scene.openBook === true || scene.openShelfAt || scene.openLibraryAt) {
// Reached: the wait for the screen it renders follows below.
} else {
try {
// With a page in the route the first page's cards may all be
// skeletons for one tick; the *frame* is what this waits for, and
// the assertion that follows is about the frame.
await cdp.waitFor('document.querySelector(".shelf-screen") !== null', 20_000);
} catch (err) {
const diag = await cdp.execute(`(() => ({
url: location.href,
login: document.querySelector('.login-screen') !== null,
shelf: document.querySelector('.shelf-screen') !== null,
skeletons: document.querySelectorAll('.book-card.skeleton').length,
cards: document.querySelectorAll('.book-card').length,
text: document.body.textContent.slice(0, 120),
}))()`);
throw new Error(`${err.message}\n${JSON.stringify(diag, null, 1)}`);
}
}
/*
* A scene can ask to be reached by *navigating like a reader* rather than by a
* URL.
*
* The two list screens have a switch between them, and the shelf's route only
* exists once the shell has been told to go there — so a scene for the library
* starts from the shelf and presses 书库, exactly as a reader does. That is
* what makes the screenshot evidence that the *switch* works, which a deep link
* would not be: a deep link proves the URL scheme, and the switch is what
* breaks.
*/
if (scene.openLibraryAt) {
/*
* Reach the scene the way the report describes it, which is now two different
* gestures for two different pages.
*
* - the **browsing** page is reached from the shelf by pressing 书库, because
* that is the switch a *reader* uses and a deep link would prove the URL
* while the switch is the thing that breaks;
* - the **file manager** is reached by its own URL, because there is no
* longer a switch to it from a reader's screen — it is the administrator's
* page, and the point of the split is that a reader cannot arrive there by
* accident. A deep link *is* how an administrator gets there, so that is
* what the scene does.
*/
const files = scene.openLibraryAt.includes('/files');
if (files) {
await cdp.navigate(`${origin}${scene.openLibraryAt}`);
await cdp.waitFor('document.querySelector(".library-files-screen") !== null', 20_000);
} else {
await cdp.click('button[aria-label="书库"]');
await cdp.waitFor('document.querySelector(".library-browse-screen") !== null', 20_000);
}
/*
* A folder is walked from the *file* page, and that is not an accident of the
* fixture: walking into a folder is the file manager's gesture, and the
* browsing page's equivalent is tapping a cover (which reads the book rather
* than opening the folder). So the scene that needs a deeper folder walks
* from there, which is also the sequence an administrator performs.
*/
if (scene.folder) {
await cdp.waitFor('document.querySelector(".manager-row") !== null', 20_000);
await cdp.clickText('.manager-row .manager-label', scene.folder);
// `aria-current` is a boolean attribute rendered as the *string* "true"
// by the DOM, so the wait reads the attribute rather than comparing it to
// a boolean: a comparison against `true` in an in-page expression is
// comparing the string to a boolean, and it is false however the page
// renders.
await cdp.waitFor(
`document.querySelector('.manager-crumb[aria-current="true"]')?.textContent === ${JSON.stringify(scene.folder)}`,
10_000,
);
} else if (files) {
await cdp.waitFor('document.querySelector(".manager-row, .empty-state") !== null', 20_000);
} else {
await cdp.waitFor('document.querySelector(".book-card, .empty-state") !== null', 20_000);
}
if (scene.page) {
// The pager under whichever list is showing: both classes exist and the
// scene names the page, not the control.
await cdp.click(`.manager-pager button[aria-label="第 ${scene.page} 页"]`);
await cdp.sleep(400);
}
/*
* The shop's search, typed the way a reader types it.
*
* Dispatched as real key input rather than by setting the field's value: the
* field is debounced and the debounce is what decides when the query becomes a
* navigation, so a scene that set the value directly would screenshot a page
* whose URL never changed — i.e. the state the *defect* looks like.
*/
if (scene.search) {
await cdp.fill('.library-search input', scene.search);
await cdp.waitFor(`location.hash.includes('q=')`, 10_000);
await cdp.sleep(500);
}
await cdp.sleep(300);
}
if (scene.openShelfAt) {
// A page turn from the pager, not a URL: the point of the scene is that the
// control is there and looks like a control.
await cdp.click('.shelf-pager button[aria-label="下一页"]');
await cdp.waitFor(`location.hash === '${scene.openShelfAt}'`, 10_000);
await cdp.sleep(400);
}
/*
* The one action the preview page exists for, taken.
*
* A screenshot of the button is not evidence that it works, and this is a
* *write* reached from a grid of covers — so the scene presses it, waits for the
* report, and lets the reload settle. What the picture then shows is the state
* after the repair, which is the state the feature is about.
*/
if (scene.shelfMenu) {
/*
* The shelf's own remove flow, taken the way the reader takes it.
*
* A screenshot of a ⋯ glyph is evidence of nothing: the control is *two* presses
* deep (the menu, then the confirmation) and what has to be visible is the
* sentence in the middle — the one that says the file is not touched, which is
* the question a reader actually has before pressing 下架.
*/
await cdp.click('.book-card .book-more');
await cdp.waitFor('document.querySelector(".dialog-list button") !== null', 10_000);
await cdp.clickText('.dialog-list button', '从书架拿掉');
await cdp.waitFor(
`/磁盘上的文件一个都不会动/.test(document.querySelector('.dialog')?.textContent ?? '')`,
10_000,
);
await cdp.sleep(300);
}
if (scene.shelveFirst) {
await cdp.click('.book-shelve');
// The browsing page reports the write in the reader's own verb ("加入书架 N 本"):
// it is a *shop*, and `已更新 N 本` describes a batch of rows rather than a tap on
// one cover. The wait is for the *report*, which is what proves the write
// happened and the folder was read back.
await cdp.waitFor(
`/加入书架 \\d+ 本/.test(document.querySelector('.notyf')?.textContent ?? '')`,
10_000,
);
await cdp.sleep(500);
}
if (scene.openBook) {
// A deep link into the book, which is also the one route the reader is
// expected to be able to share. If it does not open, that is the finding.
await cdp.waitFor('document.querySelector(".reader-screen") !== null', 20_000);
await cdp.waitFor('document.querySelector("book-content")?.shadowRoot?.querySelector(".book-flow") !== null', 20_000);
await cdp.sleep(800);
}
const theme = scene.theme;
if (theme) {
await cdp.click('button[aria-label="界面"]');
await cdp.waitFor('document.querySelector(".sheet") !== null');
await cdp.clickText('.segmented button', theme);
await cdp.sleep(250);
await cdp.click('button[aria-label="关闭"]');
await cdp.sleep(250);
}
if (scene.openPanel) {
await cdp.click(`button[aria-label="${scene.openPanel}"]`);
await cdp.waitFor('document.querySelector(".sheet") !== null');
await cdp.sleep(350);
}
if (scene.choose) {
await cdp.click(`button[aria-label="${scene.choose[0]}"]`);
await cdp.waitFor('document.querySelector(".sheet") !== null');
await cdp.clickText('.segmented button', scene.choose[1]);
await cdp.sleep(300);
}
if (scene.closePanel) {
await cdp.click('button[aria-label="关闭"]');
await cdp.sleep(300);
}
if (scene.tapCenter) {
await cdp.tapMiddle();
await cdp.sleep(300);
}
// A drag on the footer scrubber, driven through a real `input` event because
// that is what a drag produces. Captured as a screenshot for the same reason
// the other scenes are: "the slider moved the book instead of the page" is
// invisible to a check that only reads the page number — both numbers are on
// screen — and obvious in a picture of a chapter that is not the one the
// reader was in.
if (scene.scrubPage) {
await cdp.evaluate(`(() => {
const el = document.querySelector('.progress-scrubber');
if (!el) return null;
el.value = String(${scene.scrubPage});
el.dispatchEvent(new Event('input', { bubbles: true }));
return el.value;
})()`);
await cdp.sleep(600);
}
const shot = join(outDir, `${scene.name}.png`);
await cdp.screenshot(shot);
process.stdout.write(`✓ ${scene.label} → ${shot}\n`);
}
// Measure on a reader at a frozen position, so the numbers below are about the
// *layout* rather than about whatever chapter happened to be left open.
await cdp.navigate(`${origin}/#/shelf`);
await cdp.waitFor('document.querySelector(".book-card:not(.skeleton)") !== null', 15_000);
await cdp.click('.book-card');
await cdp.waitFor('document.querySelector("book-content")?.shadowRoot?.querySelector(".book-flow") !== null', 15_000);
const measured = [];
// Opened with a real tap and *then* measured, after the sheet's animation has
// settled. Measuring on the frame the click landed reads the sheet at its
// starting position — off the bottom of the screen — and every number derived
// from it is then about the animation rather than about the layout.
await cdp.click('button[aria-label="目录"]');
await cdp.waitFor('document.querySelector(".sheet") !== null', 10_000);
await cdp.sleep(400);
const panelGeometry = await cdp.evaluate(`(() => {
const panel = document.querySelector('.sheet');
const stage = document.querySelector('.stage');
if (!panel || !stage) return null;
const p = panel.getBoundingClientRect();
const s = stage.getBoundingClientRect();
return {
panelTop: p.top, panelHeight: p.height, stageTop: s.top, stageHeight: s.height,
hasScrim: !!document.querySelector('dialog.sheet[open]'), grip: !!document.querySelector('.sheet-grip'),
visibleText: p.top - s.top,
};
})()`);
// The sheet is dismissed before the audit: the gesture the audit uses to hide
// the chrome is a tap on the page, and with a sheet open that tap lands on the
// sheet's backdrop — the correct behaviour, and not what is being measured.
await cdp.click('button[aria-label="关闭"]');
await cdp.sleep(350);
const withGeometry = panelGeometry ? [{ name: '目录', openPanel: '目录' }] : [];
const auditResult = await audit(cdp, origin, withGeometry, measured);
failures = auditResult.failures;
const summary = {
viewport: VIEWPORT,
scenes: SCENES.map((s) => ({ name: s.name, label: s.label, what: s.what })),
results: auditResult.results,
failures,
};
await writeFile(join(outDir, 'report.json'), JSON.stringify(summary, null, 2));
await writeFile(
join(outDir, 'README.md'),
[
'# UI 评审截图',
'',
'由 `npm run ui:review` 生成:真实 Chromium(390×844,2x)加载**生产产物**,',
'对着一个替身 API 逐屏截图并测量。',
'',
'| 截图 | 场景 | 看什么 |',
'| --- | --- | --- |',
...SCENES.map((s) => `| \`${s.name}.png\` | ${s.label} | ${s.what} |`),
'',
'## 自动测量',
'',
'| 检查 | 结果 | 说明 |',
'| --- | --- | --- |',
...auditResult.results.map((r) => `| ${r.name} | ${r.ok ? '✅' : '❌'} | ${r.detail} |`),
'',
failures.length === 0 ? '全部通过。' : `**未通过 ${failures.length} 项。**`,
'',
].join('\n'),
);
process.stdout.write('\n' + auditResult.results.map((r) => `${r.ok ? '✅' : '❌'} ${r.name} — ${r.detail}`).join('\n') + '\n');
if (failures.length > 0) {
process.stderr.write(`\nUI 评审未通过:\n${failures.map((f) => ` - ${f}`).join('\n')}\n`);
process.exitCode = 1;
}
} finally {
await cdp.close();
await server.close();
}
}
main().catch((err) => {
process.stderr.write(`${err?.stack ?? err}\n`);
process.exitCode = 1;
});