Keep research search recoverable and isolate browser checks [skip ci]

This commit is contained in:
2026-11-07 15:51:12 +08:00
parent 4478750909
commit d527d059eb
14 changed files with 189 additions and 46 deletions
+8 -3
View File
@@ -25,6 +25,11 @@ test("sample index has working pagination and returns to the same list", async (
await expect(page.locator(".paper-row h3").first()).not.toHaveText(first);
await page.getByRole("link", { name: "上一页" }).click();
await expect(page.locator(".paper-row h3").first()).toHaveText(first);
await page.goto("/?page=999999");
await expect(page).toHaveURL(/page=3/);
await expect(page.locator(".paper-row")).toHaveCount(4);
await expect(page.getByRole("navigation", { name: "论文分页" })).toContainText("3 / 3");
await expect(page.getByRole("link", { name: "下一页" })).toHaveCount(0);
});
test("search, empty results, author scope and category filters work", async ({ page }) => {
@@ -34,7 +39,7 @@ test("search, empty results, author scope and category filters work", async ({ p
await expect(page.getByRole("heading", { name: "没有找到符合条件的论文" })).toBeVisible();
await page.getByRole("link", { name: "查看全部论文" }).click();
const author = snapshot.papers[0].authors[0];
await page.getByLabel("搜索范围").selectOption("author");
await page.getByRole("combobox", { name: "搜索范围", exact: true }).selectOption("author");
await page.getByRole("textbox", { name: "搜索论文", exact: true }).fill(author);
await page.getByRole("button", { name: "搜索论文", exact: true }).click();
await expect(page.locator(".paper-row").first()).toContainText(author);
@@ -45,7 +50,7 @@ test("search, empty results, author scope and category filters work", async ({ p
for (const row of await page.locator(".paper-meta").all()) await expect(row).toContainText(topic);
});
test("article preserves original abstract and offers citation copying", async ({ page }, info) => {
test("article preserves original abstract and offers citation copying", async ({ page, baseURL }, info) => {
const paper = snapshot.papers[0];
await page.goto(`/papers/${paper.id}`);
await expect(page.getByRole("heading", { level: 1 })).toHaveText(paper.title);
@@ -57,7 +62,7 @@ test("article preserves original abstract and offers citation copying", async ({
await expect(page.getByRole("link", { name: "登录", exact: true })).toBeVisible();
await page.screenshot({ path: info.outputPath("article.png"), fullPage: true });
await page.getByRole("link", { name: "返回论文索引" }).click();
await expect(page).toHaveURL("http://localhost:17322/");
await expect(page).toHaveURL(new URL("/", baseURL).href);
});
test("all reading themes apply and persist through a reload", async ({ page }) => {
+18 -2
View File
@@ -2,7 +2,7 @@
// in a disposable Next dev copy; no real accounts, .env files or OAuth services.
import { createServer } from "node:http";
import { createHash, randomBytes } from "node:crypto";
import { cp, mkdtemp, rm, symlink } from "node:fs/promises";
import { cp, mkdtemp, readFile, rm, symlink } from "node:fs/promises";
import { createRequire } from "node:module";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -10,11 +10,14 @@ import { spawn } from "node:child_process";
const root = fileURLToPath(new URL("../../", import.meta.url));
const origin = "http://localhost:17323";
const snapshot = JSON.parse(await readFile(join(root, "src/data/papers.json"), "utf8"));
const sessions = new Set(), grants = new Map();
let meFailure = false, logoutFailure = false, verified = false;
let counts = {};
let paperStatus = 0, paperMalformed = false;
function reset() {
sessions.clear(); grants.clear(); meFailure = false; logoutFailure = false; verified = false;
paperStatus = 0; paperMalformed = false;
counts = { exchange: 0, verified: 0, logout: 0, google: 0, slack: 0 };
}
reset();
@@ -42,8 +45,21 @@ const server = createServer(async (req, res) => {
if (data.expire) sessions.clear();
if (typeof data.meFailure === "boolean") meFailure = data.meFailure;
if (typeof data.logoutFailure === "boolean") logoutFailure = data.logoutFailure;
if ([0, 400, 429, 503, 504].includes(data.paperStatus)) paperStatus = data.paperStatus;
if (typeof data.paperMalformed === "boolean") paperMalformed = data.paperMalformed;
return reply(res, 200, {});
}
if (url.pathname.startsWith("/projects/thesisly/research/papers/")) {
const paper = snapshot.papers.find(p => p.id === url.pathname.split("/").at(-1));
return reply(res, paper ? 200 : 404, paper || { error: "not_found" });
}
if (url.pathname === "/projects/thesisly/research/papers") {
if (paperStatus) return reply(res, paperStatus, { error: "synthetic research failure" });
if (paperMalformed) return reply(res, 200, { unexpected: true });
const page = Number(url.searchParams.get("page") || 1), pageSize = Number(url.searchParams.get("pageSize") || 8);
// Fixture transport data, not an implementation of Core search semantics.
return reply(res, 200, { items: snapshot.papers.slice((page - 1) * pageSize, page * pageSize), total: snapshot.papers.length, page, pageSize, topics: [...new Set(snapshot.papers.flatMap(p => p.topics))] });
}
const match = /^\/projects\/thesisly\/auth\/([a-z-]+)$/.exec(url.pathname);
if (!match) return reply(res, 404, { error: "not_found" });
const action = match[1];
@@ -111,7 +127,7 @@ try {
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(17324, "localhost", resolve); });
const require = createRequire(import.meta.url);
child = spawn(process.execPath, [require.resolve("next/dist/bin/next"), "dev", "--hostname", "localhost", "--port", "17323"], {
cwd: directory, stdio: "inherit", env: { ...process.env, FOUNDRY_PROJECT: "thesisly", FOUNDRY_API_ORIGIN: "http://localhost:17324", APP_ORIGIN: origin, PAPER_API_ORIGIN: "", NEXT_TELEMETRY_DISABLED: "1" },
cwd: directory, stdio: "inherit", env: { ...process.env, FOUNDRY_PROJECT: "thesisly", FOUNDRY_API_ORIGIN: "http://localhost:17324", APP_ORIGIN: origin, PAPER_API_ORIGIN: process.env.THESISLY_PAPER_MODE === "sample" ? "" : "http://localhost:17324/projects/thesisly/research/", NEXT_TELEMETRY_DISABLED: "1" },
});
child.once("exit", code => { if (!closing) void close(code || 1); });
} catch (error) { console.error(error.message); await close(1); }
+30
View File
@@ -0,0 +1,30 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createPaperTransport } from "../src/lib/paper-api.mjs";
const origin = "https://core.example.test/projects/thesisly/research/";
test("paper transport uses the anonymous no-store API without forwarding site credentials", async () => {
const transport = createPaperTransport(origin, async (url, options) => {
assert.equal(url.href, origin + "papers?q=graph&page=2");
assert.equal(options.cache, "no-store");
assert.equal(options.redirect, "error");
assert.equal(options.headers, undefined);
assert.ok(options.signal instanceof AbortSignal);
return Response.json({ items: [], total: 0, topics: [], page: 2, pageSize: 8 });
});
assert.deepEqual(await transport("papers?q=graph&page=2"), { ok: true, data: { items: [], total: 0, topics: [], page: 2, pageSize: 8 } });
});
test("HTTP errors stay distinguishable and never become empty search results", async () => {
for (const status of [400, 404, 429, 503, 504]) {
const transport = createPaperTransport(origin, async () => Response.json({ error: "private upstream detail" }, { status }));
assert.deepEqual(await transport("papers?q=graph"), { ok: false, status });
}
});
test("network, timeout and malformed responses are recoverable without sample substitution", async () => {
for (const [error, status] of [[new TypeError("private connection detail"), 503], [new DOMException("deadline", "TimeoutError"), 504], [new DOMException("aborted", "AbortError"), 504]]) {
assert.deepEqual(await createPaperTransport(origin, async () => { throw error; })("papers"), { ok: false, status });
}
assert.deepEqual(await createPaperTransport(origin, async () => new Response("invalid JSON"))("papers"), { ok: false, status: 502 });
});
+44
View File
@@ -0,0 +1,44 @@
import { test, expect } from "@playwright/test";
const core = "http://localhost:17324";
const errors = new WeakMap();
async function control(request, data) { expect((await request.post(core + "/__test/control", { data })).ok()).toBe(true); }
test.beforeEach(async ({ page, request }) => {
expect((await request.post(core + "/__test/reset")).ok()).toBe(true);
errors.set(page, []);
page.on("pageerror", error => errors.get(page).push(error.message));
});
test.afterEach(async ({ page }) => {
expect(errors.get(page)).toEqual([]);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
});
test("search failures retain controls and recover after a revised query", async ({ page, request }) => {
for (const [status, heading] of [[504, "这次检索耗时较长"], [429, "论文索引正在处理较多请求"], [503, "论文暂时无法读取"], [400, "请调整搜索条件"]]) {
await control(request, { paperStatus: status });
await page.goto("/?q=original&topic=cs.LG&searchIn=author");
await expect(page.getByRole("heading", { name: heading, exact: true })).toBeVisible();
await expect(page.getByRole("textbox", { name: "搜索论文", exact: true })).toHaveValue("original");
await expect(page.getByLabel("搜索范围")).toHaveValue("author");
await expect(page.locator('.search-form input[name="topic"]')).toHaveValue("cs.LG");
await expect(page.locator(".index-heading h2 span, .pagination, .sample-note")).toHaveCount(0);
await expect(page.getByRole("heading", { name: "没有找到符合条件的论文" })).toHaveCount(0);
await expect(page.getByRole("link", { name: "重新尝试", exact: true })).toHaveAttribute("href", /q=original/);
}
await control(request, { paperStatus: 0 });
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole("textbox", { name: "搜索论文", exact: true }).fill("revised query");
await page.getByRole("button", { name: "搜索论文", exact: true }).click();
await expect(page).toHaveURL(/q=revised\+query/);
await expect(page.locator(".paper-row")).toHaveCount(8);
await expect(page.locator(".paper-index [role=alert]")).toHaveCount(0);
});
test("malformed research responses do not become empty results or sample mode", async ({ page, request }) => {
await control(request, { paperMalformed: true });
await page.goto("/?q=keep-this-query");
await expect(page.getByRole("heading", { name: "论文暂时无法读取", exact: true })).toBeVisible();
await expect(page.getByRole("textbox", { name: "搜索论文", exact: true })).toHaveValue("keep-this-query");
await expect(page.locator(".paper-row, .sample-note, .index-heading h2 span, .pagination")).toHaveCount(0);
});