Files
reader/web/tools/ui-review/server.mjs
Claude faf0ffd17a fix(ui-review): use reply in reading-overrides fixture route
The reading-overrides fixture handler referenced an undefined res, so
npm run ui:review:reader crashed on startup.
2026-10-19 22:33:16 +08:00

991 lines
45 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
/**
* A stand-in server for the UI review.
*
* The review has to render the *production bundle* against something that answers
* its API, because the thing being reviewed is the real screen: a component
* storybook would show the chrome with hand-fed state, and the two bugs this
* harness is meant to catch (a panel that covers the page, a footer whose page
* count disagrees with the presses left) are both about how the screen behaves
* against a real response.
*
* Only the endpoints the reader touches are implemented, and each returns the
* smallest body the screen will accept. Anything else answers 404 so a missing
* endpoint fails loudly here rather than looking like a rendering bug.
*/
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { extname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = fileURLToPath(new URL('.', import.meta.url));
const dist = join(here, '..', '..', 'dist');
const BOOK_ID = 'review-book';
const TITLE = '剑来';
const AUTHOR = '烽火戏诸侯';
/**
* A book whose chapters are *documents*, and whose first one carries an image.
*
* A second fixture rather than a variant of the TXT one, because the two exercise
* opposite sides of the same decision — "does this body get parsed" — and the
* report that produced this scene was exactly the case where they were conflated: a
* TXT chapter was rendered as one paragraph *and* every illustration in an
* illustrated EPUB came out as a broken-image placeholder. Both are visible only on
* a screen, and both need a chapter of a shape the TXT fixture cannot have.
*
* The image is served from the book's own asset endpoint and reached through the
* URL the server actually writes — an absolute one, carrying
* `__reader-book-resource__` — because that is the URL the client has to accept.
* A fixture that inlined a `data:` URI would leave the defect reproducible in
* production and invisible here.
*/
const ILLUSTRATED_ID = 'review-illustrated';
const ILLUSTRATED_CHAPTERS = [
{ title: '第一卷 插图', path: 'OEBPS/Text/ch1.xhtml' },
{ title: '第一卷 后记', path: 'OEBPS/Text/ch2.xhtml' },
];
/**
* A plate big enough to *look* like one in the screenshot.
*
* It used to be 4×4, on the reasoning that "the assertion is that the image loads,
* not how it looks". That reasoning is what this harness exists to reject: the report
* is a person looking at the screen and seeing a broken-image placeholder, and a
* 4×4 fixture renders as four grey pixels whether it loaded or not — so the screenshot
* could not show the difference between the defect and the fix, and the only thing
* standing between them was one `naturalWidth` assertion. A plate with a shape in it
* makes the picture itself the evidence.
*/
const PLATE_WIDTH = 240;
const PLATE_HEIGHT = 160;
/**
* A plate far wider than any phone column.
*
* The report behind this is a screenshot of an illustration running off *both* edges
* of the page, and the fixture it was reported against was 240px wide — which fits
* inside a 342px column, so the harness certified that illustrations render while the
* defect was fully reproducible on a real book. A publisher sizes a plate in the
* book's own pixels, and reflowable comics and screenshot-heavy novels are full of
* them, so the fixture has to be one of those: the question this scene answers is not
* "did the image load" but "does the page still fit the screen once it has".
*
* The aspect ratio is deliberately extreme (1400×160), so the reported failure and
* the *other* failure a naive clamp produces are distinguishable in one picture. A
* plate clamped in width alone comes out squashed at 342×160; one clamped with an
* automatic height comes out at 342×39, which is what the book's own proportions are.
*/
const WIDE_PLATE_WIDTH = 1400;
const WIDE_PLATE_HEIGHT = 160;
/** A PNG with a visible border and a diagonal, so "did it render" is answerable by eye. */
function platePng(width, height) {
// Built by hand rather than with a library: the point is that this file has no
// image dependency, and a PNG whose rows are one filter byte plus RGB triples is
// short enough to write out.
const raw = Buffer.alloc((width * 3 + 1) * height);
for (let y = 0; y < height; y += 1) {
const rowStart = y * (width * 3 + 1);
raw[rowStart] = 0; // filter: none
for (let x = 0; x < width; x += 1) {
const onBorder = x < 3 || y < 3 || x >= width - 3 || y >= height - 3;
const onDiagonal = Math.abs(x / width - y / height) < 0.03;
const ink = onBorder || onDiagonal ? 0x3a : 0xd8;
const at = rowStart + 1 + x * 3;
raw[at] = ink;
raw[at + 1] = onDiagonal ? 0x6a : ink;
raw[at + 2] = onDiagonal ? 0x3a : ink;
}
}
const crcTable = [];
for (let n = 0; n < 256; n += 1) {
let c = n;
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
crcTable[n] = c >>> 0;
}
const crc32 = (buf) => {
let c = 0xffffffff;
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
};
const chunk = (type, data) => {
const head = Buffer.alloc(8);
head.writeUInt32BE(data.length, 0);
head.write(type, 4, 'ascii');
const body = Buffer.concat([head.subarray(4), data]);
const tail = Buffer.alloc(4);
tail.writeUInt32BE(crc32(body), 0);
return Buffer.concat([head, data, tail]);
};
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 2; // colour type: truecolour
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr),
chunk('IDAT', zlibDeflate(raw)),
chunk('IEND', Buffer.alloc(0)),
]);
}
/** zlib-wrapped deflate, which is what a PNG `IDAT` holds. */
function zlibDeflate(buf) {
const header = Buffer.from([0x78, 0x01]);
const body = [];
// Stored (uncompressed) deflate blocks: 5 bytes of header per 65535-byte block.
for (let offset = 0; offset < buf.length; offset += 65535) {
const slice = buf.subarray(offset, Math.min(offset + 65535, buf.length));
const last = offset + 65535 >= buf.length ? 1 : 0;
const block = Buffer.alloc(5 + slice.length);
block[0] = last;
block.writeUInt16LE(slice.length, 1);
block.writeUInt16LE(~slice.length & 0xffff, 3);
slice.copy(block, 5);
body.push(block);
}
const adler = (() => {
let a = 1;
let b = 0;
for (const byte of buf) {
a = (a + byte) % 65521;
b = (b + a) % 65521;
}
return ((b << 16) | a) >>> 0;
})();
const tail = Buffer.alloc(4);
tail.writeUInt32BE(adler, 0);
return Buffer.concat([header, ...body, tail]);
}
const PNG = platePng(PLATE_WIDTH, PLATE_HEIGHT);
const WIDE_PNG = platePng(WIDE_PLATE_WIDTH, WIDE_PLATE_HEIGHT);
/** The two paragraphs of a document chapter, and the image between them. */
function illustratedChapter(index) {
if (index !== 0) {
return [
'<?xml version="1.0" encoding="utf-8"?>',
'<html xmlns="http://www.w3.org/1999/xhtml"><head><title>第一卷 后记</title></head>',
'<body><p>后记:这一章没有插图,用来确认上一章的图片不是碰巧出现的。</p></body></html>',
].join('\n');
}
const image = (name) =>
`/api/v1/books/${ILLUSTRATED_ID}/assets?__reader-book-resource__=1&ref=${encodeURIComponent(`OEBPS/Images/${name}`)}`;
return [
'<?xml version="1.0" encoding="utf-8"?>',
'<html xmlns="http://www.w3.org/1999/xhtml"><head><title>第一卷 插图</title></head>',
'<body>',
// The plate that fits the column, as a book normally presents one…
`<div class="pic"><img src="${image('pic.png')}" alt="插图"/></div>`,
'<p>台版 转自 天使动漫</p>',
// …and one the publisher sized in its own pixels, which is the case the reader
// reported. Both are on the page so one screenshot shows "a plate is drawn" and
// "a plate wider than the page is still inside it" at the same time.
`<div class="pic wide"><img src="${image('wide.png')}" alt="宽插图" width="${WIDE_PLATE_WIDTH}" height="${WIDE_PLATE_HEIGHT}"/></div>`,
'<p>插图下面还有正文,这样图片没显示出来的时候,空出来的位置也是看得见的。</p>',
'</body></html>',
].join('\n');
}
const CHAPTERS = [
{ title: '第一章 惊蛰', body: ['小镇上的人都知道,泥瓶巷住着一个少年。', '他叫陈平安。'] },
{ title: '第二章 山水', body: ['山上的风很大。', '他站在山顶,看了很久。'] },
{ title: '第三章 落雨', body: ['雨来了。', '他没有打伞,就这么走回去。'] },
];
/**
* Enough text that the chapter is several screens tall, with a scraper's indentation.
*
* The full-width spaces are the point: they are what "段前空格" is, they are what a
* converted TXT library is full of, and they have to be *gone* from the rendered page
* — left in, they are indent added on top of the reader's own, and the screenshot is
* where that is visible. A fixture indented with ordinary spaces would not show it,
* because the browser collapses those.
*/
const LONG = Array.from({ length: 40 }, (_v, i) =>
`  第 ${i + 1} 段。这一段特意写长一些,用来把这一章撑到好几屏,这样翻页和页数才看得出来。`,
).join('\n\n');
const CHAPTER_BODY = CHAPTERS.map((chapter, index) =>
index === 0 ? [chapter.title, LONG].join('\n') : [chapter.title, ...chapter.body].join('\n'),
);
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.ttf': 'font/ttf',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
};
const json = (reply, body, status = 200) => {
const text = JSON.stringify(body);
reply.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(text) });
reply.end(text);
};
const html = (reply, body, status = 200) => {
reply.writeHead(status, { 'content-type': MIME['.html'], 'content-length': Buffer.byteLength(body) });
reply.end(body);
};
function session() {
return {
/*
* An **admin**, and the harness's one scene that depends on it.
*
* The library's file manager is admin-only (#40: 「书库管理页面只有管理员才能看到
* 使用」), so the review has to sign in as the role that is allowed to see it — a
* `member` fixture would make every file-manager screenshot a picture of the
* browsing page, and the review would certify the split by photographing the half
* that is reachable. The *guard* itself is asserted in the client's own suite
* (a URL is user input; see `App.render`), because a fixture cannot be a
* permission check.
*/
user: { id: 'u1', username: 'review', displayName: '评测', role: 'admin', createdAt: 0 },
accessToken: 'review-token',
accessTokenExpiresAt: Date.now() + 86_400_000,
refreshToken: 'review-refresh',
refreshTokenExpiresAt: Date.now() + 86_400_000,
};
}
/**
* A book, with every field `docs/api.md` declares.
*
* Written out in full rather than with the three fields that look load-bearing: the
* client's own types are a contract, and a fixture that satisfies only the parts the
* shelf happens to read today is a fixture that starts failing the day a column is
* added — with no change to the code under review, which makes it look like a
* regression in the review.
*/
const book = {
id: BOOK_ID,
title: TITLE,
author: AUTHOR,
publisher: '',
language: 'zh',
isbn: '',
description: '',
series: '',
seriesIndex: null,
tags: [],
pubdate: '',
format: 'epub',
coverUrl: null,
fileSize: 1024,
pageCount: CHAPTERS.length,
source: '',
manualFields: [],
addedAt: Date.now(),
updatedAt: Date.now(),
};
/**
* A chapter, in the shape the server actually produces: the characters, as text.
*
* This used to answer with a `txt-body` div full of `<p>`s, which is what the server
* rendered before the typesetting moved to the client. Keeping the fixture on the
* *new* shape matters more than keeping it on the old one: the review runs against
* the production bundle, and a fixture that still pre-rendered paragraphs would let
* a regression in the client's own split render perfectly.
*
* The `\u3000` indents in `LONG` are kept deliberately — they are "段前空格", and
* left in they are indent stacked on the reader's own. The screenshot is where that
* is visible.
*/
function chapterText(index) {
return `${CHAPTER_BODY[index]}\n`;
}
/** The illustrated book, with every field `docs/api.md` declares. */
function illustratedBook() {
return {
...book,
id: ILLUSTRATED_ID,
title: '第一卷 插图',
format: 'epub',
pageCount: ILLUSTRATED_CHAPTERS.length,
};
}
function illustratedManifest() {
return {
id: ILLUSTRATED_ID,
format: 'epub',
total: ILLUSTRATED_CHAPTERS.length,
files: [{ rel_path: '插图.epub', size: 4096, missing: 0 }],
content: {
kind: 'reflowable',
total: ILLUSTRATED_CHAPTERS.length,
groups: [{ id: 'spine:0', seq: 0, title: '章节', count: ILLUSTRATED_CHAPTERS.length, offset: 0 }],
items: ILLUSTRATED_CHAPTERS.map((chapter, index) => ({
id: chapter.path,
seq: index,
title: chapter.title,
kind: 'chapter',
// A *document*, not characters: the media type is what tells the client to
// parse this body instead of typesetting it, and it is the field the report
// was about.
mediaType: 'application/xhtml+xml',
href: `xhtml:${chapter.path}`,
})),
},
};
}
function manifest() {
return {
id: BOOK_ID,
format: 'epub',
total: CHAPTERS.length,
files: [{ rel_path: '剑来.epub', size: 1024, missing: 0 }],
content: {
// Deliberately `reflowable` rather than `text`, and the manifest says `epub`.
//
// A TXT *is* reflowable, so a server windowing it that way is making a
// reasonable choice — and the review found that the client believed the label
// instead of looking at the chapter it had been sent, leaving the paragraphs
// unstyled with no indent control beside them. Keeping the fixture on the
// awkward path is what keeps that fixed.
kind: 'reflowable',
total: CHAPTERS.length,
groups: [{ id: 'chapters', seq: 0, title: '章节', count: CHAPTERS.length, offset: 0 }],
items: CHAPTERS.map((chapter, index) => ({
id: `c${index}`,
seq: index,
title: chapter.title,
kind: 'chapter',
// The TXT manifest as the server actually emits it: plain characters, and
// `format: 'html'` to say "this is a document to be typeset" rather than
// "this is markup". The media type and the format field are both asserted
// here because they are what `renditionRef` keys on — a fixture that omitted
// them would have the client fetch `chapter:` and render an untitled slab,
// which is a review of a path no reader is on.
mediaType: 'text/plain; charset=utf-8',
href: `chapter:${index}`,
format: 'html',
})),
},
};
}
function toc() {
return CHAPTERS.map((chapter, index) => ({
href: `chapter:${index}`,
title: chapter.title,
level: 0,
spine: index,
}));
}
/**
* Whether an asset request is authorised the way the real server would authorise it.
*
* Two ways in, and the fixture has to accept both or it is not a model of the
* endpoint: the client's own `fetch` carries `Authorization: Bearer …`, and a
* *sub-resource* request — an `<img src>`, a `<link href>` — cannot, so it carries
* `?access_token=` instead (see the server's `queryTokenAllowed`).
*
* The endpoint is guarded at all so the review cannot pass on a chapter whose
* pictures only load because the stub forgot to check. That is exactly how an
* illustrated EPUB shipped with broken images while every check was green: the
* fixture served the bytes to an anonymous request, and only the real server said
* no. Reproducing the *guard* here is what makes the signer load-bearing.
*/
function authorisedForAssets(request, url) {
const header = request.headers.authorization ?? '';
if (header.startsWith('Bearer ') && header.slice('Bearer '.length).trim().length > 0) return true;
return (url.searchParams.get('access_token') ?? '').length > 0;
}
function unauthorised(reply) {
reply.writeHead(401, { 'content-type': 'application/json; charset=utf-8' });
return reply.end(JSON.stringify({ error: { code: 'NO_TOKEN', message: 'missing bearer token' } }));
}
export function createReviewServer({ port = 5199 } = {}) {
const seen = [];
/*
* The paths the review has shelved, in memory.
*
* The one *write* this harness models, and it models it because without it the
* scene that presses 加入书架 screenshots the state *before* the repair: the shelf
* endpoint would report success and the browse listing would keep answering
* `shelfState: 'off'` for the same file, so the card would still carry its badge and
* the picture would be of the feature not working. A review harness that cannot be
* written to cannot certify a write.
*/
const shelved = new Set();
/*
* The *book* ids the review has shelved, which is what the preview page's list is
* drawn from now.
*
* The page asks for the **library** (`scope=library`) rather than the reader's shelf,
* so each entry carries `shelfState` — and the state a card reads comes from *this*
* list, not from the file listing's copy of it. Keeping both sets in step is what
* makes the scene after the press show a control that has gone: a fixture that only
* recorded paths would answer `'off'` again on the reload and certify the bug.
*/
const shelvedBooks = new Set();
/*
* The body of each request that had one, collected *before* the handler runs.
*
* The server's callback cannot await, and the handler is `async` — so reading a
* stream inside the handler is a race the review would lose intermittently, and one
* that `readBody(request)` at the top of the handler entry could not fix without
* making the whole request path sequential. Collecting here, where the stream is
* still untouched, is the same data with no ordering question.
*/
const bodies = new WeakMap();
const server = createServer((request, reply) => {
seen.push(`${request.method} ${request.url}`);
const chunks = [];
request.on('data', (chunk) => chunks.push(chunk));
request.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
try {
const parsed = raw === '' ? {} : JSON.parse(raw);
// The shelf write sends `{ paths: [...] }`; a `FormData` upload does not parse
// and is simply not recorded, which is the right answer for every handler here.
if (Array.isArray(parsed?.paths)) bodies.set(request, parsed.paths);
} catch {
/* not JSON: nothing to record */
}
void handle(request, reply);
});
});
server.seen = seen;
async function handle(request, reply) {
const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`);
const path = url.pathname;
if (path.startsWith('/api/v1/books/review-pdf')) {
if (path.endsWith('/manifest')) return json(reply, {
id: 'review-pdf', format: 'pdf', total: 1,
files: [{ rel_path: 'document.pdf', size: 1024, missing: 0 }],
});
if (path.endsWith('/progress')) return json(reply, null);
if (path.endsWith('/toc')) return json(reply, { toc: [] });
if (path.endsWith('/content')) {
const stream = 'BT /F1 24 Tf 45 700 Td (PDF reading preview) Tj ET';
const objects = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 780] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`,
];
let pdf = '%PDF-1.4\n';
const offsets = [0];
objects.forEach((object, index) => { offsets.push(pdf.length); pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; });
const xref = pdf.length;
pdf += 'xref\n0 6\n0000000000 65535 f \n';
pdf += offsets.slice(1).map(offset => `${String(offset).padStart(10, '0')} 00000 n \n`).join('');
pdf += `trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF`;
reply.writeHead(200, { 'content-type': 'application/pdf' });
return reply.end(pdf);
}
return json(reply, { book: { ...book, id: 'review-pdf', title: 'PDF 测试', format: 'pdf', pageCount: 1 }, progress: null });
}
if (path.startsWith('/api/v1/books/review-comic')) {
const items = Array.from({ length: 3 }, (_, seq) => ({
id: `page-${seq}`, seq, title: `第 ${seq + 1} 页`, kind: 'page',
mediaType: 'image/png', href: `page:${seq}`,
}));
const content = { kind: 'paged', total: 3, items,
groups: [{ id: 'volume-0', seq: 0, title: '第一卷', count: 3, offset: 0 }] };
if (path.endsWith('/manifest')) return json(reply, {
id: 'review-comic', format: 'cbz', total: 3,
files: [{ rel_path: '漫画.cbz', size: 4096, missing: 0 }], content,
});
if (path.endsWith('/toc')) return json(reply, { toc: items.map(i => ({ href: i.href, title: i.title, level: 0, spine: i.seq })) });
if (path.endsWith('/items')) return json(reply, content);
if (path.endsWith('/progress')) return json(reply, null);
if (path.endsWith('/assets')) {
reply.writeHead(200, { 'content-type': 'image/png' });
return reply.end(PNG);
}
return json(reply, { book: { ...book, id: 'review-comic', title: '漫画测试', format: 'cbz', pageCount: 3 }, progress: null });
}
if (path === '/__seen') {
return json(reply, seen);
}
// The request log, *counted*, in the names the traffic assertions use.
//
// Counting at the HTTP boundary is the only place that sees every request the
// bundle makes — including the ones a debounce was supposed to fold away — and
// it is deliberately blind to *why* a request was made. A review of the traffic
// has to be: the defects it is looking for are a request that was redundant, and
// no amount of reading the client's intent tells you whether it was sent.
if (path === '/__counts') {
const counts = { sync: 0, 'sync-post': 0, 'sync-get': 0, assets: 0, toc: 0 };
for (const entry of seen) {
const [method, target = ''] = entry.split(' ');
const bare = target.split('?')[0] ?? '';
if (bare === '/api/v1/sync') {
counts.sync += 1;
if (method === 'POST') counts['sync-post'] += 1;
else counts['sync-get'] += 1;
}
if (/\/assets$/.test(bare)) counts.assets += 1;
if (/\/toc$/.test(bare)) counts.toc += 1;
}
return json(reply, counts);
}
if (path.startsWith('/api/')) {
return api(request, reply, url);
}
// Static bundle. `dist` has to exist: a review that renders last week's build
// is worse than no review, because it looks like a pass.
let file = path === '/' ? '/index.html' : path;
let target = join(dist, file);
if (!existsSync(target) || path.startsWith('/assets/') === false && !existsSync(target)) {
target = join(dist, 'index.html');
}
if (!existsSync(target)) {
reply.writeHead(404);
reply.end('run `npm run build` first');
return;
}
const bytes = await readFile(target);
reply.writeHead(200, { 'content-type': MIME[extname(target)] ?? 'application/octet-stream' });
reply.end(bytes);
}
function api(request, reply, url) {
const path = url.pathname;
if (path === '/api/v1/instance') {
return json(reply, { name: 'reader', apiVersion: 1, registrationOpen: false, userCount: 1 });
}
if (path === '/api/v1/auth/session') {
// No session on the boot request, so the app shows its login screen — then
// the driver logs in, which is what a real reader does too.
return json(reply, null);
}
if (path === '/api/v1/auth/login') {
return json(reply, session());
}
if (path === '/api/v1/auth/refresh') {
return json(reply, session());
}
// Called on boot with a restored session, and the app refuses to enter with a
// token the server will not confirm. Missing it made every scene after the first
// land on the login screen again.
if (path === '/api/v1/auth/me') {
return json(reply, { user: session().user });
}
if (path === '/api/v1/books') {
/*
* Enough pages for the shelf's own pager to be worth looking at.
*
* The shelf is paginated now (60 a page), so a fixture with one book renders a
* screen with no pager on it — and a review of "does the pager look right"
* against a library that has no second page is a review that passes by
* showing nothing. The page is honoured so `#/shelf/2` is a *different* list,
* which is what makes the screenshot evidence that the page is real.
*/
const page = Number.parseInt(url.searchParams.get('page') ?? '1', 10) || 1;
const pageSize = Number.parseInt(url.searchParams.get('pageSize') ?? '60', 10) || 60;
/*
* The shelf's default sort is 最近阅读, which is a *client-side* order, so the
* client reads a window of the shelf in one go — several pages at once. The
* harness answers whatever page it is asked for, so that works without a
* special case.
*/
/*
* A `path` narrows the list, exactly as it does on the real server.
*
* Answering it here is what makes the library's preview page a screenshot of *a
* folder* rather than of the whole shelf under a folder's breadcrumb — and the
* difference is not cosmetic, because the two would look identical and mean
* different things. The file listing for any folder holds three entries, so the
* folder's book list is the first three of the synthetic shelf.
*/
const folder = url.searchParams.get('path') ?? '';
/*
* A `search` narrows it further, as on the real server.
*
* The browsing half of the library is a shop now and its search box is the
* control the scene exists to photograph, so the harness has to answer the
* parameter — a fixture that ignored `search` would render a screenshot of a
* filtered page that is not filtered, which is the state the *defect* looks
* like. The filter keeps the `第<N>卷` volumes whose number contains the query,
* so a scene can ask for a specific volume by typing its number.
*/
const search = url.searchParams.get('search') ?? '';
const total = folder === '' ? 130 : 3;
const from = (page - 1) * pageSize;
const items = Array.from({ length: Math.max(0, Math.min(pageSize, total - from)) }, (_v, i) => ({
...book,
// The book at index 0 keeps the *fixture* id, because the reader scenes deep
// link to it: a list of purely synthetic ids would make `#/book/<id>` a dead
// link and every reader screenshot one of the "这本书不在书架上了" fallback.
id: from + i === 0 ? BOOK_ID : `${BOOK_ID}-${from + i}`,
// Titled `… 第<N>卷` to line up with the file names in the browse fixture. The
// *join* between a book and its file is the server's now (a card writes by id),
// so this is no longer load-bearing for 加入书架 — but the two lists describing
// the same folder should still describe the same books, or the screenshots
// disagree with each other.
title: `${TITLE} 第${from + i + 1}卷`,
}));
// The illustrated book is appended rather than paged: it exists for one reader
// scene that deep links to it, and burying it under 130 synthetic volumes would
// make that link depend on the shelf's own pagination arithmetic. `total` counts
// it, so the pager still agrees with the list it is counting.
/*
* The library screen's *browsing* half asks for the books **in a folder**.
*
* `path` is a real filter on the real server (see `ShelfService.list`), and
* answering it here is what makes the preview page a screenshot of a folder
* rather than of the whole shelf under a folder's breadcrumb. The fixture keeps
* it simple — every book it synthesises is "in" every folder — because what the
* review is looking at is the *page*, not the query.
*/
/*
* The *total* follows the filter, which is the half that is easy to leave behind.
*
* A server that narrows `items` and answers the unfiltered `total` puts "找到 131
* 本" above a grid of two — a correct-looking page that lies about the search, and
* exactly the state a fixture that only paged would produce. The illustrated book
* is counted too, because it is appended unconditionally (one reader scene deep
* links to it) and a total that ignored it would be a pager disagreeing with its
* own list.
*/
const listed = search === '' ? items : items.filter((item) => item.title.includes(search));
const matched = listed.length + 1;
const from2 = (page - 1) * pageSize;
const answer = [...listed, illustratedBook()].slice(from2, from2 + pageSize);
/*
* `scope=library` is the *index*, not the shelf, and the difference is the whole
* browsing page: a shop has to show a book the reader has not shelved in order to
* offer to shelve it. Only the shelf's own list is drawn from `items` alone; the
* library's carries `shelfState` per book, exactly as the real endpoint does.
*
* The state is `shelvedBooks` rather than the file listing's `shelved` set,
* because a card reads *this* list — a fixture that answered the state from the
* other one would leave the control on screen after the write and the screenshot
* would be of the feature not working.
*/
if (url.searchParams.get('scope') === 'library') {
return json(reply, {
items: answer.map((entry, index) => ({
...entry,
shelfState: shelvedBooks.has(entry.id) || index !== 0 ? 'on' : 'off',
})),
total: search === '' ? total + 1 : matched,
page,
pageSize,
});
}
return json(reply, {
items: answer,
total: search === '' ? total + 1 : matched,
page,
pageSize,
});
}
// The shelf asks for its "continue reading" strip in the same breath as the
// list, and a 404 there makes the shelf render an empty state that looks like a
// missing library rather than a missing endpoint.
/*
* The library tree, with enough entries that its pager is on screen.
*
* Written as a fixture rather than left to 404 because the two list screens are
* now separate routes with separate pagination, and a screenshot of `#/library`
* against a missing endpoint is a screenshot of an error line — which is a real
* defect in a review harness (it certifies a screen nobody has seen).
*/
if (path === '/api/v1/library/facets') {
return json(res, { authors: [AUTHOR], series: [], tags: [], formats: ['txt', 'epub'] });
}
if (/^\/api\/v1\/books\/[^/]+\/reading-overrides$/.test(path)) {
return json(reply, { version: 0, corrections: [], headingPrefix: '' });
}
if (path === '/api/v1/library/browse') {
const dir = url.searchParams.get('path') ?? '';
const page = Number.parseInt(url.searchParams.get('page') ?? '1', 10) || 1;
const pageSize = 200;
// Two pages of files, so page 2 is a real page rather than an empty one.
// Two pages of files in the subfolder, so page 2 is a real page rather than an
// empty one — a pager whose second page is blank is a pager that looks broken
// and is not.
/*
* The file listing and the book list describe the *same* folder.
*
* The two halves are drawn from different endpoints — this one is the filesystem,
* the preview page is the index — and the file names line up with the book titles
* so a screenshot of either describes the same three volumes. The join that used
* to be load-bearing (a card matched its book's title to a filename here in order
* to name a path) is gone: the write carries a book id, so a fixture that
* disagreed about names could no longer break 加入书架 — it would only make the
* two screenshots disagree with each other.
*
* Root holds the first three volumes plus a folder to walk into; the folder holds
* enough files for a real second page.
*/
const fileCount = dir === 'folder' ? 340 : 3;
const total = dir === 'folder' ? fileCount : fileCount + 1;
const all = [
...(dir === '' ? [{ name: 'folder', type: 'dir', path: 'folder' }] : []),
...Array.from({ length: fileCount }, (_v, i) => ({
name: `${TITLE} 第${i + 1}卷.epub`,
type: 'file',
path: dir === '' ? `${TITLE} 第${i + 1}卷.epub` : `${dir}/${TITLE} 第${i + 1}卷.epub`,
})),
];
const entries = all.map((entry) => ({
...entry,
size: 1024 * 512,
mtime: Date.now(),
mode: 0o644,
hidden: false,
hiddenByRule: false,
scanned: true,
ext: 'epub',
indexed: true,
/*
* One book is deliberately *off* the shelf.
*
* The library screen's whole reason to exist is answering "why is this book
* not on my shelf", and the answer for a book the reader took off it is a
* badge and an action. A fixture where every row is `'on'` renders a screen
* with neither, so the review would certify the state where the feature is
* invisible — the same way the continue-reading fixture certified a card that
* could not be opened. `三体.epub` is the one row that carries the mark, and
* the scene's label names it.
*/
/*
* One file is deliberately *off* the shelf, and one is deliberately `null`.
*
* This listing answers the *file* page (§3.4), whose badge is what a reader
* looks at when a book is missing from their shelf — so the marks stay here even
* though the shelf directions no longer do (#40 removed them from this screen).
* `null` is a file the server does not index as a book at all — a folder, a
* stray file — and the page must draw no badge for it.
*
* The preview page reads `shelfState` from the *book* list instead, for the same
* reason: two screens, two sources, one question.
*/
shelfState:
entry.type === 'dir'
? null
: shelved.has(entry.path) || entry.name !== `${TITLE} 第1卷.epub`
? 'on'
: 'off',
}));
const from = (page - 1) * pageSize;
return json(reply, {
path: dir,
crumbs: dir === '' ? [{ name: '书库', path: '' }] : [{ name: '书库', path: '' }, { name: 'folder', path: 'folder' }],
parent: dir === '' ? null : '',
entries: entries.slice(from, from + pageSize),
total,
dirs: entries.filter((entry) => entry.type === 'dir').length,
files: entries.filter((entry) => entry.type === 'file').length,
size: entries.length * 1024 * 512,
writable: true,
name: dir,
});
}
if (path === '/api/v1/library/continue') {
/*
* A `ContinueReadingItem` is a whole `Book` with the progress flattened onto
* it — `id`, `addedAt`, `manualFields` and the rest, exactly as
* `GET /api/v1/books` returns them. See `ContinueReadingItem` in the client's
* types for why that is the contract.
*
* This fixture used to answer a *shorter*, differently-spelled object — the
* progress row's `bookId`/`updatedAt` — which is precisely the shape the real
* server was returning while the shelf's card drew from `book.id`. The
* harness agreed with the server and both were wrong, so the review passed on
* a screen whose every card deep-linked to `#/book/undefined`. Spreading the
* same `book` the shelf fixture uses is what keeps the two in step: a card
* that cannot be opened now fails the review instead of hiding in it.
*/
return json(reply, {
items: [
{ ...book, percentage: 0.18, chapterTitle: CHAPTERS[1].title, lastReadAt: Date.now() },
// A second book, older, so the shelf's default order has something to be an
// order *of*: with one entry there is no way to see from a screenshot that
// 最近阅读 is doing anything at all.
{
...book,
id: `${BOOK_ID}-1`,
title: `${TITLE} 第2卷`,
percentage: 0.42,
chapterTitle: CHAPTERS[2].title,
lastReadAt: Date.now() - 86_400_000,
},
],
});
}
if (path === `/api/v1/books/${BOOK_ID}/manifest`) {
return json(reply, manifest());
}
if (path === `/api/v1/books/${ILLUSTRATED_ID}/manifest`) {
return json(reply, illustratedManifest());
}
if (path === `/api/v1/books/${ILLUSTRATED_ID}/toc`) {
return json(reply, {
toc: ILLUSTRATED_CHAPTERS.map((chapter, index) => ({
href: `xhtml:${chapter.path}`,
title: chapter.title,
level: 0,
spine: index,
})),
});
}
if (path === `/api/v1/books/${ILLUSTRATED_ID}/items`) {
return json(reply, illustratedManifest().content);
}
if (path === `/api/v1/books/${ILLUSTRATED_ID}/assets`) {
const ref = url.searchParams.get('ref') ?? '';
// The asset endpoint is *authenticated* in production, and the browser fetches
// a chapter's images itself — with no `Authorization` header available. The
// only way such a request can be authorised is a token in the URL, and the
// fixture has to demand one or the review certifies a chapter whose images
// only load because this stub forgot to check. That is exactly how an
// illustrated EPUB shipped with broken pictures while the review was green.
if (!authorisedForAssets(request, url)) return unauthorised(reply);
if (ref.endsWith('pic.png')) {
reply.writeHead(200, { 'content-type': 'image/png' });
return reply.end(PNG);
}
if (ref.endsWith('wide.png')) {
reply.writeHead(200, { 'content-type': 'image/png' });
return reply.end(WIDE_PNG);
}
const index = ILLUSTRATED_CHAPTERS.findIndex((chapter) => ref === chapter.path);
reply.writeHead(200, { 'content-type': 'application/xhtml+xml; charset=utf-8' });
return reply.end(illustratedChapter(index < 0 ? 0 : index));
}
if (path === `/api/v1/books/${BOOK_ID}/toc`) {
// `{ toc: [...] }`, matching `docs/api.md`. A bare array here is the mistake
// the reader is not allowed to make: it is what made the harness discover that
// `openStaged` crashed on an unexpected response shape instead of falling back
// to the manifest's own items.
return json(reply, { toc: toc() });
}
if (path === `/api/v1/books/${BOOK_ID}/items`) {
return json(reply, manifest().content);
}
if (path === `/api/v1/books/${BOOK_ID}/assets`) {
if (!authorisedForAssets(request, url)) return unauthorised(reply);
const ref = url.searchParams.get('ref') ?? '';
const index = Number.parseInt(ref.replace(/^chapter(-html)?:/, ''), 10);
const body = chapterText(Number.isFinite(index) ? index : 0);
// `text/plain`, not `text/html`: the server stopped rendering markup, and the
// content type is part of what the client is being reviewed against.
reply.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
return reply.end(body);
}
/*
* A single book, which is what a deep link asks for.
*
* `#/book/<id>` resolves through this endpoint (or the local mirror) before the
* reader is built, and a 404 here is answered with a shelf redirect — so a
* missing handler makes every reader screenshot a picture of the shelf, with
* nothing on it to say the route was the problem. It has to be *after* the
* `/manifest`, `/toc`, `/items` and `/assets` handlers above, because those
* paths start with the same prefix.
*/
if (path === `/api/v1/books/${BOOK_ID}`) {
return json(reply, { book, progress: null });
}
if (path === `/api/v1/books/${BOOK_ID}/progress`) {
return json(reply, null);
}
// A pull is a GET on `/sync`; a push is a POST on the same path. Both have to
// answer, or the sync engine retries into the review's screenshots.
if (path === '/api/v1/sync') {
if (request.method === 'POST') {
return json(reply, { accepted: 0, rejected: 0, serverTime: Date.now(), progress: [], notes: [] });
}
return json(reply, { serverTime: Date.now(), progress: [], notes: [] });
}
if (path.startsWith('/api/v1/sync/progress/')) {
// `{ progress: null }`, which is what the endpoint answers for a book nobody
// has read. Returning a bare `null` makes the client dereference `undefined`
// and the reader reports a crash it cannot explain.
return json(reply, { progress: null });
}
if (path === '/api/v1/tts/capabilities') {
return json(reply, { http: false, system: true });
}
/*
* The one *write* the library's preview page performs.
*
* Answered rather than left to 404 because the scene that presses 加入书架 has to
* reach the state the feature is about — a card whose badge has gone, and a report
* under the grid — and a 404 would screenshot the error instead. It answers the
* `BatchResult` shape the screen reads: `applied` is what the status line prints,
* and `failed` is the list of paths that were not books.
*/
if (path === '/api/v1/library/browse/shelf') {
/*
* The one *write* this harness models, and it models it because the *state after
* it* is the thing being reviewed.
*
* Recording the paths is what makes the reload the write triggers answer `'on'`
* for the file that was just shelved. Without it the endpoint would report
* success and the browse listing would keep answering `'off'` for the same file,
* so the card would still carry its badge and the screenshot would be of the
* feature not working — the harness certifying its own fixture.
*
* The response's `applied` is what the status line prints, and the paths are the
* paths: this fixture does not model the server's validation, because a fixture
* that answered "not a book" for a book would be a *second* failure mode rather
* than a review of the first one.
*/
const payload = bodies.get(request) ?? {};
if (Array.isArray(payload.paths)) for (const entry of payload.paths) shelved.add(entry);
/*
* A body may name the write by `paths` (the file manager had them) or by
* `bookIds` (a card has an id — see `browseBatchShelf`). The preview page uses the
* id form now, so *this* is the set that has to be recorded for its reload to
* answer `'on'`.
*/
if (Array.isArray(payload.bookIds)) for (const id of payload.bookIds) shelvedBooks.add(id);
return json(reply, { applied: 1, books: payload.bookIds ?? [], failed: [] });
}
if (
path === '/api/v1/library/browse/metadata' ||
path === '/api/v1/library/browse/move' ||
path === '/api/v1/library/browse/rename' ||
path === '/api/v1/library/browse/mkdir' ||
path === '/api/v1/library/browse/delete'
) {
// The rest of the file page's writes, so a stray click in a scene reports a
// result rather than an error line.
return json(reply, { applied: 0, books: [], failed: [], moved: 0, removed: 0, path: '' });
}
return json(reply, { error: { code: 'NOT_FOUND', message: `no review handler for ${path}` } }, 404);
}
return {
server,
seen,
listen: () => new Promise((resolve) => server.listen(port, '127.0.0.1', () => {
const address = server.address();
const actualPort = address && typeof address === 'object' ? address.port : port;
resolve(`http://127.0.0.1:${actualPort}`);
})),
close: () => new Promise((resolve) => server.close(resolve)),
};
}
export { CHAPTERS, BOOK_ID, CHAPTER_BODY, chapterText, ILLUSTRATED_ID, ILLUSTRATED_CHAPTERS };