Keep research search recoverable and isolate browser checks [skip ci]
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
# Thesisly 研迹 working agreements
|
||||
|
||||
- 本项目使用 Next.js App Router、TypeScript、Tailwind CSS、shadcn/ui(Radix primitives)、Lucide 和 Sonner。用户明确授权本地 dev,随后仅撤回本地 build:允许按需启动 pnpm dev,禁止 pnpm build。不配置前端 CI/CD,不部署。
|
||||
- 本项目使用 Next.js App Router、TypeScript、Tailwind CSS、shadcn/ui(Radix primitives)、Lucide 和 Sonner。用户明确授权本地 dev,随后仅撤回本地 build:允许按需启动 pnpm dev,禁止 pnpm build。不配置前端 CI/CD,不部署。用户最新要求本阶段暂不需要 CI:不新增或触发 CI。交付使用已核对的 Gitea [skip ci] 提交及 PR 标题标记跳过推送和 PR 同步事件,并核对本次 commit 未新增 Actions 运行;不删除或禁用共享工作流。
|
||||
- 仅通过 Foundry Core 共享后端管理身份。复用 Endoleap 的 PKCE、HttpOnly Cookie、闲置续期、退出撤销与同源校验;禁止把 token 发到客户端或 localStorage。
|
||||
- 论文读取模式必须显式区分真实数据库样本与远程 API。不得把本地20条样本称为全库、最新或热门数据,不虚构摘要、代码仓库、会议决定或指标。
|
||||
- 源数据只读;不复制整库,不从浏览器直连 SQLite 或持有数据库凭据。
|
||||
- 阅读与文章列表优先,无 KPI 仪表盘。默认 Vignelli;Editorial、Tufte 可切换。UI Craft Editorial 的衬线默认按用户现代简洁偏好及选定主题调整。
|
||||
- 项目级技能在 .agents/skills;beautiful-article 内含 Vignelli/Tufte authoring profiles,两者不是独立上游 skill。Beautiful Article 只在另行请求单篇 HTML 文章时进入其完整 harness。
|
||||
- 英文品牌 Thesisly、中文名研迹;远程仓库为 `ssh://git@gitea.entroia.com:22222/Zayden-Jung/Thesisly.git`。用户已在 FOUNDRY_PROJECTS 新增 thesisly 回调,线上加载及登录仍待验收。main 仅允许本次已授权的空初始化提交,其余代码走工作分支与人工 PR 合并。
|
||||
- 论文库迁移至 PostgreSQL 由「调查云盘占用原因」任务负责;等待其迁移完成并核对实际 schema 后,在 Foundry Core 实现只读论文 API。不部署已撤回的 SQLite 方案,也不要求添加 FOUNDRY_RESEARCH_DATABASE。
|
||||
- 英文品牌 Thesisly、中文名研迹;远程仓库为 `ssh://git@gitea.entroia.com:22222/Zayden-Jung/Thesisly.git`。用户已在 FOUNDRY_PROJECTS 新增 thesisly 回调,并复用当前正式 Core 二进制完成仅配置重载;新旧项目路由、Google/Slack 跳转和取消回调通过,真实账户授权与登录状态仍待验收。main 仅允许本次已授权的空初始化提交,其余代码走工作分支与人工 PR 合并。
|
||||
- 论文库已由「调查云盘占用原因」任务完成 PostgreSQL 迁移、验收与切换;Foundry Core 的只读论文 API 正在按已发布 schema 联调。服务器已配置专用 SELECT 账号,连接串仅保存在服务器受保护文件中。不部署已撤回的 SQLite 方案,也不要求添加 FOUNDRY_RESEARCH_DATABASE。
|
||||
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ pnpm dev
|
||||
```sh
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
# 在另一个终端已运行 pnpm dev 后,使用系统 Chrome
|
||||
# 自动启动并清理隔离的样本dev,使用系统Chrome,不改预览配置
|
||||
pnpm test:browser
|
||||
# 自动启动并清理隔离 dev 副本及模拟 Core,使用系统 Chrome
|
||||
pnpm test:session
|
||||
@@ -46,13 +46,15 @@ pnpm test:session
|
||||
|
||||
## 当前验证边界
|
||||
|
||||
类型检查与13项测试已通过:除了Cookie/样本策略,测试直接调用生产认证代理,覆盖兑换、登录/邮件验证、续期、退出、错误、凭据隔离与请求限制。它们使用进程内模拟的Core响应,不代表真实OAuth成功,也不需要启动Next.js或编译。
|
||||
类型检查与16项测试已通过:除了Cookie/样本策略,测试直接调用生产认证代理,覆盖兑换、登录/邮件验证、续期、退出、错误、凭据隔离与请求限制。它们使用进程内模拟的Core响应,不代表真实OAuth成功,也不需要启动Next.js或编译。
|
||||
|
||||
12项桌面/手机浏览器测试覆盖搜索、分页、空结果、文章摘要与引用复制、三种主题持久化、登录/注册表单和404恢复;截图用于人工核对布局。另有7项浏览器会话测试,使用隔离的实际Next dev副本和生产代理连接模拟Core,验证刷新/新标签恢复、HttpOnly Cookie、跨标签退出、故障重试、失效清理、Google/Slack模拟PKCE和邮箱验证的一次性兑换。测试不复制.env,不访问真实账户,结束后清理副本;真实OAuth尚未验收,生产构建按用户要求未执行。前端目录为 `/Users/zzyang/thesisly`,远程已连接 Zayden-Jung/Thesisly,main 仅有用户授权的空初始化提交;论文 HTTP API 已纳入本目标,由代理在 Foundry Core 的 codex/research-api 分支实现。
|
||||
12项隔离样本模式桌面/手机浏览器测试已通过,覆盖搜索、分页、空结果、文章摘要与引用复制、三种主题持久化、登录/注册表单和404恢复;截图用于人工核对布局;样本dev运行在17323,不占用17322预览。另有7项浏览器会话测试,使用隔离的实际Next dev副本和生产代理连接模拟Core,验证刷新/新标签恢复、HttpOnly Cookie、跨标签退出、故障重试、失效清理、Google/Slack模拟PKCE和邮箱验证的一次性兑换。测试不复制.env,不访问真实账户,结束后清理副本;新增2项隔离浏览器回归验证搜索错误恢复及异常数据处理,合计9项隔离浏览器测试通过;这些测试的Core响应仍为合成数据。另已在真实库上检查桌面/手机首页、简介、领域和全文搜索、超时后缩小搜索恢复。真实OAuth尚未验收,生产构建按用户要求未执行。前端目录为 `/Users/zzyang/thesisly`,远程已连接 Zayden-Jung/Thesisly,main 仅有用户授权的空初始化提交;论文 HTTP API 已纳入本目标,由代理在 Foundry Core 的 codex/research-api 分支实现。
|
||||
|
||||
## 当前后端依赖
|
||||
|
||||
用户已配置 Thesisly 的 FOUNDRY_PROJECTS。论文库正在迁移至 PostgreSQL;本目标等待迁移完成后实现并接通 Core 的论文 API,不再采用 SQLite sidecar,也不需要 FOUNDRY_RESEARCH_DATABASE。当前本地20条导出仅用于视觉与交互开发,绝不代表全库已接通。
|
||||
论文库已完成 PostgreSQL 迁移及独立验收。当前本地忽略配置通过 SSH 回环隧道连接 ECS 上的临时 Core(真实库已验证3,526,999篇);浏览器不持有数据库连接串。临时 Core 仅启用真实论文只读连接,账户仍访问正式 Core。宽泛全文计数可能超时,页面会保留搜索表单并提示缩小范围,不显示伪造总数或退回样本。
|
||||
|
||||
用户已配置 Thesisly 的 FOUNDRY_PROJECTS,正式 Core 已通过仅配置重载生效,原有项目保留;Google/Slack 跳转与取消回调通过,真实账户登录仍待用户授权验收。研究 API 的新代码尚未合入或部署正式后端。本阶段不触发CI,交付使用Gitea [skip ci]提交与PR标记并核对运行记录;本地build未执行,不采用SQLite sidecar,也不需要FOUNDRY_RESEARCH_DATABASE。
|
||||
|
||||
## Endoleap 参考
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{"thesisly":"http://localhost:17322/auth/callback"}
|
||||
```
|
||||
|
||||
这只是新增项示例,**不要用此单项覆盖全部现有项目**。前端 `.env.local` 的 `FOUNDRY_PROJECT` 必须与键完全相同,`APP_ORIGIN=http://localhost:17322`。第三方平台 Google/Slack 回调依然指向共享 Foundry 后端,不改为前端地址。后端变量的应用方式由后端维护方处理;本前端不配置CI/CD。
|
||||
这只是新增项示例,**不要用此单项覆盖全部现有项目**。前端 `.env.local` 的 `FOUNDRY_PROJECT` 必须与键完全相同,`APP_ORIGIN=http://localhost:17322`。第三方平台 Google/Slack 回调依然指向共享 Foundry 后端,不改为前端地址。本前端不配置CI/CD。此次已对正式 Core 执行仅配置重载,保留原有 endoleap、notes、board,并追加 thesisly;复用原镜像和发布二进制,没有发布工作分支代码。
|
||||
|
||||
## 流程
|
||||
|
||||
@@ -31,8 +31,13 @@ App Router 的 route.ts 直接调用 src/lib/auth-gateway.mjs。tests/auth-gatew
|
||||
|
||||
## 2026-11-07 配置更新
|
||||
|
||||
用户已确认新增 Thesisly 的 FOUNDRY_PROJECTS 回调。FOUNDRY_RESEARCH_DATABASE 为已撤回的 SQLite 路径方案,无需添加。论文数据正在独立任务迁移到 PostgreSQL;本任务等待迁移后的数据库/schema与只读接入方式,再完成 Core 论文 API。
|
||||
用户已确认新增 Thesisly 的 FOUNDRY_PROJECTS 回调。FOUNDRY_RESEARCH_DATABASE 为已撤回的 SQLite 路径方案,无需添加。论文库已完成 PostgreSQL 迁移及验收,Core 的论文 API 已通过临时回环服务接入前端。正式 Core 项目注册现已生效,新旧四个项目匿名 auth/me 均返回401;Google/Slack 实际授权地址和取消回调已验证。实际账户授权、邮件投递与完整会话验收仍未完成。
|
||||
|
||||
配置重载第一次因公网 TLS 探测超时而回退,已确认旧服务健康;随后按现有部署脚本使用回环 TLS、真实 SNI 与证书验证完成切换。服务器受保护验收记录为 foundry/verification/thesisly-config-foundry-core-1595929e8395.json,包含未变更的发布二进制 SHA256 和旧路由备份位置,不含认证凭据。
|
||||
|
||||
## 浏览器会话回归
|
||||
|
||||
`pnpm test:session` 使用系统 Chrome 和独立端口17323/17324:复制应用源文件到可清理的临时目录,运行真实 Next dev、App Router 代理与浏览器,只有 Core 响应为合成服务。它不会读取项目 .env、占用17322预览、发送真实密码或连接真实 OAuth。7项测试验证HttpOnly/SameSite Cookie及客户端无token、刷新/新标签恢复、跨标签退出、暂时故障后重试、失效清理、退出失败保留会话,以及Google/Slack模拟PKCE和邮箱验证的一次性消费。它证明前端会话接线,不证明真实Core数据、邮件投递、提供方授权或生产配置。
|
||||
`pnpm test:session` 使用系统 Chrome 和独立端口17323/17324:复制应用源文件到可清理的临时目录,运行真实 Next dev、App Router 代理与浏览器,只有 Core 响应为合成服务。它不会读取项目 .env、占用17322预览、发送真实密码或连接真实 OAuth。其中7项测试验证HttpOnly/SameSite Cookie及客户端无token、刷新/新标签恢复、跨标签退出、暂时故障后重试、失效清理、退出失败保留会话,以及Google/Slack模拟PKCE和邮箱验证的一次性消费。它证明前端会话接线,不证明真实Core数据、邮件投递、提供方授权或生产配置。
|
||||
|
||||
|
||||
另外2项浏览器用例使用同一隔离dev和合成论文API,验证400/429/503/504时保留搜索条件、隐藏未知总数和分页、缩小查询后恢复,以及格式异常响应不会显示成空结果或退回样本。共9项浏览器回归已通过。真实库上也验证了宽泛搜索超时后修改为CartoonGAN可恢复3条结果;宽泛全文计数的性能仍待优化。
|
||||
|
||||
+14
-8
@@ -1,6 +1,6 @@
|
||||
# 论文只读 HTTP 接口
|
||||
|
||||
状态:用户要求等待既有论文库从 SQLite 迁移至 PostgreSQL。此前 SQLite 实现方案已撤回,未部署;Foundry Core 的 codex/research-api 分支在迁移完成后按实际 PostgreSQL schema实现。本任务包括后端实现与联调,不把接口开发留给用户。
|
||||
状态:既有论文库已完成 PostgreSQL 迁移与独立验收。此前 SQLite 实现方案已撤回,未部署;Foundry Core 的 codex/research-api 分支正在按实际 PostgreSQL schema 联调。本任务包括后端实现与联调,不把接口开发留给用户。
|
||||
|
||||
## 地址与配置
|
||||
|
||||
@@ -19,25 +19,31 @@ GET {PAPER_API_ORIGIN}/papers?q=&searchIn=text&topic=&sort=recent&page=1&pageSiz
|
||||
- q:最多200个Unicode字符、20个词。searchIn=text时检索标题/摘要,searchIn=author时检索显示作者名。不会把用户输入作为任意SQL或FTS操作符。
|
||||
- topic:精确arxiv:category值。
|
||||
- sort:recent是稳定论文ID降序,即收录顺序,**不是发表日期排序**;title使用现有title_key索引。
|
||||
- page:1–1000;pageSize:1–50,前端当前为8。
|
||||
- page:1–1000;pageSize:1–50,前端当前为8。前端与此上限一致;结果超过8,000条时明确提示缩小搜索或领域范围,不生成不可访问的下一页链接。超出实际末页的页面地址会跳转至当前条件下的末页。
|
||||
- 返回{items:Paper[],total,topics,page,pageSize},同一只读事务内计数及取页;无效、重复、未知参数返回400。
|
||||
|
||||
## 论文
|
||||
|
||||
Paper包含id、title、abstract(可空)、authors字符串数组、publishedAt(保留来源格式,可空)、topics、sources(label/url/externalId),以及publications(venue/year/track/status/evidenceUrl)。可选doi、journal、comments。
|
||||
|
||||
source_records按paper_id关联;作者兼容[family,given,suffix]数组与字符串。会议记录通过submissions→editions→venues,状态取最新决定观察;没有决定时保持unknown,不推断拒稿。不虚构代码库、数据集、引用量、热门或AI结论。详情不存在返回404。
|
||||
source_records按paper_id关联;作者兼容[family,given,suffix]数组与字符串;真实 arXiv 的第四段机构名不拼入姓名。会议记录通过submissions→editions→venues,状态取最新决定观察;没有决定时保持unknown,不推断拒稿。不虚构代码库、数据集、引用量、热门或AI结论。详情不存在返回404。
|
||||
|
||||
## 读取边界
|
||||
|
||||
待 PostgreSQL 迁移任务完成后,核对实际数据库/schema、字段类型、全文索引及最小只读权限,在 Foundry Core 中实现查询。不部署 SQLite sidecar,不复制整库,不从浏览器持有数据库连接串。
|
||||
已核对实际 PostgreSQL schema、编码标记和索引,并在服务器创建专用 SELECT 账号及受保护连接配置。已通过 SSH 回环隧道验证真实全库首页和详情(桌面/手机无页面异常和横向溢出),查询性能仍在调整;临时 Core 不是正式部署。不部署 SQLite sidecar,不复制整库,不从浏览器持有数据库连接串。
|
||||
|
||||
最多两个并行查询、8秒查询预算;繁忙返回429,超时/存储异常明确报错。作者筛选可能全库扫描,需要在真实库上验证性能。语料库是共享公开数据;账户与未来私有收藏仍须按项目隔离。
|
||||
最多两个并行查询、8秒查询预算;繁忙返回429,超时/存储异常明确报错。已补建作者和领域索引,并局部关闭 JIT、调整过滤取页顺序。实测作者搜索146条、cs.AR领域9457条、CartoonGAN搜索3条、多词全文检索8653条均返回200;非常宽泛的检索仍可能超出8秒预算,需继续优化。语料库是共享公开数据;账户与未来私有收藏仍须按项目隔离。
|
||||
|
||||
## 联调前置
|
||||
|
||||
- 用户手动合并后端PR;后端沿用已确认ECS runner验证与部署。
|
||||
- 当前阶段不触发 CI、不推送启动流水线,也不部署;手动验证在已确认 ECS 的独立临时目录执行。后续主线合并仍需用户手动操作。
|
||||
- 保留FOUNDRY_PROJECTS旧条目,追加thesisly→http://localhost:17322/auth/callback。
|
||||
- 用户已确认新增 FOUNDRY_PROJECTS;无需添加 FOUNDRY_RESEARCH_DATABASE。PostgreSQL 的后端接入配置待迁移完成后确定。
|
||||
- 用户已确认新增 FOUNDRY_PROJECTS;无需添加 FOUNDRY_RESEARCH_DATABASE。PostgreSQL 后端连接仅放服务器受保护的 research.env,不放 Gitea Variables 或前端配置。
|
||||
- 本地忽略文件 PAPER_API_ORIGIN 现指向 127.0.0.1:17325/projects/thesisly/research/,通过 SSH 连接 ECS 临时 Core。隧道和临时服务停止时应显式报错,不退回样本。
|
||||
- 前端仓库为Zayden-Jung/Thesisly,不配置CI/CD;本地build按用户最新要求暂不执行。
|
||||
- 真实Google/Slack、邮箱验证、刷新恢复与退出需要实际浏览器授权验收;302与fixture测试不代表登录成功。
|
||||
- 正式 Core 已仅重载项目配置,Thesisly 匿名 auth/me 当前返回401,原有项目保留;论文 API 新代码仍未发布正式后端。真实Google/Slack、邮箱验证、刷新恢复与退出需要实际浏览器授权验收;302与fixture测试不代表登录成功。
|
||||
|
||||
|
||||
## 查询失败后的恢复
|
||||
|
||||
列表将超时、繁忙、无效条件和服务不可用作为可恢复状态:保留关键词/搜索范围/领域,不显示未知总数和分页,不把错误显示成0条结果,也不退回样本。用户可直接修改关键词或重试。真实库已验证graph超时后修改为CartoonGAN恢复3条;隔离浏览器和匿名HTTP传输用例覆盖了错误分类、表单保留及异常响应处理。
|
||||
|
||||
+10
-3
@@ -1,7 +1,7 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
// Use an already running, explicitly authorized dev server. This configuration
|
||||
// never runs `next build`, starts CI, or accesses a personal Chrome profile.
|
||||
// Disposable sample-mode dev copy. Never reads the preview .env, stops its
|
||||
// server, runs next build/CI, or accesses a personal Chrome profile.
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
workers: 1,
|
||||
@@ -10,7 +10,7 @@ export default defineConfig({
|
||||
expect: { timeout: 10000 },
|
||||
reporter: "list",
|
||||
use: {
|
||||
baseURL: "http://localhost:17322",
|
||||
baseURL: "http://localhost:17323",
|
||||
channel: "chrome",
|
||||
headless: true,
|
||||
launchOptions: { args: ["--no-proxy-server"] },
|
||||
@@ -18,6 +18,13 @@ export default defineConfig({
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
webServer: {
|
||||
command: "THESISLY_PAPER_MODE=sample node tests/fixtures/session-app.mjs",
|
||||
url: "http://localhost:17323/login",
|
||||
reuseExistingServer: false,
|
||||
timeout: 120000,
|
||||
gracefulShutdown: { signal: "SIGTERM", timeout: 15000 },
|
||||
},
|
||||
projects: [
|
||||
{ name: "desktop", use: { viewport: { width: 1440, height: 1000 } } },
|
||||
{ name: "mobile", use: { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true } },
|
||||
|
||||
@@ -51,6 +51,7 @@ svg { flex-shrink:0; }
|
||||
.status-dot { width:5px; height:5px; background:var(--accent); border-radius:50%; flex-shrink:0; }
|
||||
.index-grid { display:grid; grid-template-columns:180px minmax(0,1fr); gap:64px; padding:46px 0 70px; }
|
||||
.index-aside h2 { font-size:12px; font-weight:500; margin-bottom:24px; }
|
||||
.topic-list { max-height:min(60vh,560px); overflow-y:auto; overscroll-behavior:contain; scrollbar-gutter:stable; padding:4px; margin:-4px; }
|
||||
.topic { display:flex; justify-content:space-between; padding:9px 0; color:var(--secondary); font-size:12px; }
|
||||
.topic.active { color:var(--ink); font-weight:600; }
|
||||
.topic:hover { color:var(--accent); }
|
||||
@@ -151,3 +152,5 @@ svg { flex-shrink:0; }
|
||||
@media(prefers-reduced-motion:reduce) { html { scroll-behavior:auto; } *,*:before,*:after { animation:none!important; transition:none!important; } }
|
||||
|
||||
.search-form select { border:0; background:transparent; color:var(--secondary); font-size:11px; max-width:100px; padding:3px 0; }
|
||||
|
||||
@media(max-width:640px) { .topic-list { display:flex; flex-wrap:wrap; gap:8px; width:100%; max-height:160px; padding:4px; margin:0; } }
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
import Link from "next/link";
|
||||
export default function NotFound() { return <main id="main" className="page-shell empty-state"><span className="section-kicker">404</span><h1>这篇论文不在当前索引里。</h1><p>如果正在使用样本模式,只有导出的 20 条论文可以打开。</p><Link className="text-link" href="/">返回论文索引 →</Link></main>; }
|
||||
export default function NotFound() { return <main id="main" className="page-shell empty-state"><span className="section-kicker">404</span><h1>这篇论文不在当前索引里。</h1><p>请检查链接,或回到论文索引继续浏览。</p><a className="text-link" href="/">返回论文索引 →</a></main>; }
|
||||
|
||||
+13
-5
@@ -1,16 +1,24 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ArrowRight, ArrowUpRight, Search, X } from "lucide-react";
|
||||
import { listPapers, normalizeFilters, paperDate, type Filters } from "@/lib/papers";
|
||||
import { listPapers, normalizeFilters, paperDate, MAX_PAPER_PAGE, type Filters } from "@/lib/papers";
|
||||
import { Button } from "@/components/ui/button";
|
||||
export default async function Home({ searchParams }: { searchParams: Promise<Record<string,string | string[] | undefined>> }) {
|
||||
const f = normalizeFilters(await searchParams), data = await listPapers(f);
|
||||
const pages = Math.ceil(data.total / data.pageSize);
|
||||
const pages = data.total === null ? 0 : Math.min(MAX_PAPER_PAGE, Math.ceil(data.total / data.pageSize));
|
||||
const issue = data.mode === "error" ? {
|
||||
timeout: ["这次检索耗时较长", "试试更具体的关键词,或清除筛选后重新浏览。"],
|
||||
busy: ["论文索引正在处理较多请求", "请稍候重试。你的搜索条件已保留。"],
|
||||
invalid_query: ["请调整搜索条件", "请缩短关键词或清除筛选后重试。"],
|
||||
unavailable: ["论文暂时无法读取", "请稍后重试。你的搜索条件已保留。"],
|
||||
}[data.reason] : null;
|
||||
function href(changes: Filters) { return "/?" + new URLSearchParams(Object.entries({ ...f, ...changes }).filter(([, v]) => typeof v === "string" && !!v) as [string, string][]).toString(); }
|
||||
if (!issue && data.page > Math.max(1, pages)) redirect(href({ page: String(Math.max(1, pages)) }));
|
||||
return <main id="main" className="page-shell"><section className="index-intro"><div className="section-kicker">THE OPEN RESEARCH READER <span>01 — INDEX</span></div><h1>好研究,<br /><span className="muted-heading">值得被读到。</span></h1><div className="intro-bottom"><p>从一个问题,到一篇论文。<br />发现研究、阅读摘要,回到每一项工作的原始出处。</p><a href="#papers" className="text-link">浏览论文 <ArrowRight size={16} /></a></div></section>
|
||||
{data.mode === "sample" && <div className="sample-note" role="note"><span className="status-dot" /><p>真实数据库样本 · 当前展示 2026-11-06 导出的前 20 条记录,搜索与筛选仅限此样本。</p><Link href="/about">数据说明 <ArrowUpRight size={13} /></Link></div>}
|
||||
<section id="papers" className="index-grid"><aside className="index-aside"><h2>研究领域</h2><Link className={!f.topic ? "topic active" : "topic"} href={href({ topic: "", page: "1" })}>全部领域 <span>↗</span></Link>{data.topics.map(t => <Link key={t} className={f.topic === t ? "topic active" : "topic"} href={href({ topic: t, page: "1" })}>{t}</Link>)}<p className="aside-note">保留原始标题与摘要。<br />不把自动生成的解读当成论文结论。</p></aside><div className="paper-index"><div className="index-heading"><h2>{f.topic || "论文索引"}<span>{data.total.toString().padStart(2, "0")}</span></h2><form className="sort-form"><input type="hidden" name="q" value={f.q || ""} /><input type="hidden" name="topic" value={f.topic || ""} /><input type="hidden" name="searchIn" value={f.searchIn || "text"} /><label htmlFor="sort" className="sr-only">排序</label><select id="sort" name="sort" defaultValue={f.sort || "recent"}><option value="recent">收录顺序</option><option value="title">标题 A–Z</option></select><button type="submit" className="quiet-button">排序</button></form></div>
|
||||
<section id="papers" className="index-grid"><aside className="index-aside"><h2>研究领域</h2><div className="topic-list" role="region" aria-label="研究领域分类" tabIndex={0}><Link className={!f.topic ? "topic active" : "topic"} href={href({ topic: "", page: "1" })}>全部领域 <span>↗</span></Link>{data.topics.map(t => <Link key={t} className={f.topic === t ? "topic active" : "topic"} href={href({ topic: t, page: "1" })}>{t}</Link>)}</div><p className="aside-note">保留原始标题与摘要。<br />不把自动生成的解读当成论文结论。</p></aside><div className="paper-index"><div className="index-heading"><h2>{f.topic || "论文索引"}{data.total !== null && <span>{data.total.toLocaleString("zh-CN")}</span>}</h2><form className="sort-form"><input type="hidden" name="q" value={f.q || ""} /><input type="hidden" name="topic" value={f.topic || ""} /><input type="hidden" name="searchIn" value={f.searchIn || "text"} /><label htmlFor="sort" className="sr-only">排序</label><select id="sort" name="sort" defaultValue={f.sort || "recent"}><option value="recent">收录顺序</option><option value="title">标题 A–Z</option></select><button type="submit" className="quiet-button">排序</button></form></div>
|
||||
<form className="search-form" role="search"><Search size={18} aria-hidden /><select name="searchIn" aria-label="搜索范围" defaultValue={f.searchIn || "text"}><option value="text">标题 / 摘要</option><option value="author">作者</option></select><label htmlFor="paper-search" className="sr-only">搜索论文</label><input id="paper-search" name="q" placeholder={f.searchIn === "author" ? "搜索作者姓名…" : "搜索标题或摘要…"} defaultValue={f.q || ""} maxLength={200} /><input type="hidden" name="topic" value={f.topic || ""} /><input type="hidden" name="sort" value={f.sort || "recent"} /><button type="submit" aria-label="搜索论文"><ArrowRight size={18} /></button></form>
|
||||
{(f.q || f.topic) && <div className="filter-note"><span>{f.q ? `搜索 “${f.q}”` : f.topic}</span><Link href="/">清除筛选 <X size={13} /></Link></div>}
|
||||
{data.items.length ? <ol className="paper-list">{data.items.map((p, i) => <li key={p.id}><article className="paper-row"><span className="row-number">{String((data.page - 1) * data.pageSize + i + 1).padStart(2, "0")}</span><div><div className="paper-meta"><span>{p.topics[0] || "Research"}</span><span>{paperDate(p.publishedAt)}</span></div><h3><Link href={`/papers/${p.id}`}>{p.title}</Link></h3><p className="paper-summary">{p.abstract || "来源未提供摘要。"}</p><div className="row-bottom"><span className="authors">{p.authors.slice(0, 3).join(", ")}{p.authors.length > 3 ? ` +${p.authors.length - 3}` : ""}</span><Link href={`/papers/${p.id}`} className="read-link">阅读简介 <ArrowUpRight size={15} /></Link></div></div></article></li>)}</ol> : <div className="empty-state"><h3>没有找到符合条件的论文</h3><p>{data.mode === "sample" ? "当前只有 20 条样本,试试更短的关键词,或清除领域筛选。" : "试试其他关键词,或清除筛选。"}</p><Button asChild variant="outline"><Link href="/">查看全部论文</Link></Button></div>}
|
||||
<nav className="pagination" aria-label="论文分页"><span>{data.total ? `${data.page} / ${Math.max(1,pages)}` : "0 条结果"}</span><div>{data.page > 1 && <Link href={href({ page: String(data.page - 1) })}>上一页</Link>}{data.page < pages && <Link href={href({ page: String(data.page + 1) })}>下一页 <ArrowRight size={14} /></Link>}</div></nav></div></section></main>;
|
||||
{issue ? <div className="empty-state" role="alert"><h3>{issue[0]}</h3><p>{issue[1]}</p><div className="flex justify-center gap-4"><Button asChild variant="outline"><a href={href({})}>重新尝试</a></Button><Button asChild variant="ghost"><Link href="/">清除筛选</Link></Button></div></div> : data.items.length ? <ol className="paper-list">{data.items.map((p, i) => <li key={p.id}><article className="paper-row"><span className="row-number">{String((data.page - 1) * data.pageSize + i + 1).padStart(2, "0")}</span><div><div className="paper-meta"><span>{f.topic || p.topics[0] || "Research"}</span><span>{paperDate(p.publishedAt)}</span></div><h3><Link href={`/papers/${p.id}`}>{p.title}</Link></h3><p className="paper-summary">{p.abstract || "来源未提供摘要。"}</p><div className="row-bottom"><span className="authors">{p.authors.slice(0, 3).join(", ")}{p.authors.length > 3 ? ` +${p.authors.length - 3}` : ""}</span><Link href={`/papers/${p.id}`} className="read-link">阅读简介 <ArrowUpRight size={15} /></Link></div></div></article></li>)}</ol> : <div className="empty-state"><h3>没有找到符合条件的论文</h3><p>{data.mode === "sample" ? "当前只有 20 条样本,试试更短的关键词,或清除领域筛选。" : "试试其他关键词,或清除筛选。"}</p><Button asChild variant="outline"><Link href="/">查看全部论文</Link></Button></div>}
|
||||
{!issue && <nav className="pagination" aria-label="论文分页"><span>{data.total ? `${data.page} / ${Math.max(1,pages)}` : "0 条结果"}</span><div>{data.page > 1 && <Link href={href({ page: String(data.page - 1) })}>上一页</Link>}{data.page < pages && <Link href={href({ page: String(data.page + 1) })}>下一页 <ArrowRight size={14} /></Link>}</div></nav>}{data.total !== null && data.total > MAX_PAPER_PAGE * data.pageSize && <p className="source-caption" role="note">当前最多浏览前 {(MAX_PAPER_PAGE * data.pageSize).toLocaleString("zh-CN")} 条结果。搜索或筛选研究领域,可进一步缩小范围。</p>}</div></section></main>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Anonymous server-side transport. Expected HTTP/network failures are values;
|
||||
// callers can keep the search form usable without falling back to sample data.
|
||||
export function createPaperTransport(origin, requestUpstream = fetch) {
|
||||
return async function request(path) {
|
||||
let response;
|
||||
try {
|
||||
response = await requestUpstream(new URL(path, origin.replace(/\/$/, "") + "/"), {
|
||||
cache: "no-store", signal: AbortSignal.timeout(12000), redirect: "error",
|
||||
});
|
||||
} catch (error) {
|
||||
return { ok: false, status: error?.name === "TimeoutError" || error?.name === "AbortError" ? 504 : 503 };
|
||||
}
|
||||
if (!response.ok) return { ok: false, status: response.status };
|
||||
try { return { ok: true, data: await response.json() }; }
|
||||
catch { return { ok: false, status: 502 }; }
|
||||
};
|
||||
}
|
||||
+14
-13
@@ -1,13 +1,16 @@
|
||||
import snapshot from "@/data/papers.json";
|
||||
import { createPaperTransport } from "./paper-api.mjs";
|
||||
|
||||
export type Paper = { id: string; title: string; abstract: string | null; authors: string[]; publishedAt: string | null; topics: string[]; sources: { label: string; url: string; externalId: string }[]; doi?: string | null; journal?: string | null; comments?: string | null; publications?: { venue: string; year: number; track: string; status: string; evidenceUrl?: string }[] };
|
||||
export type PaperPage = { items: Paper[]; total: number; page: number; pageSize: number; topics: string[]; mode: "sample" | "live"; capturedAt?: string };
|
||||
export type PaperFailure = { mode: "error"; reason: "timeout" | "busy" | "invalid_query" | "unavailable"; items: Paper[]; total: null; page: number; pageSize: number; topics: string[] };
|
||||
export type Filters = { q?: string; topic?: string; sort?: string; page?: string; searchIn?: string };
|
||||
export function normalizeFilters(input: Record<string, string | string[] | undefined>): Filters {
|
||||
const value = (key: string) => typeof input[key] === "string" ? (input[key] as string).slice(0, 200) : undefined;
|
||||
return { q: value("q"), topic: value("topic"), sort: value("sort"), page: value("page"), searchIn: value("searchIn") };
|
||||
}
|
||||
const size = 8;
|
||||
export const MAX_PAPER_PAGE = 1000;
|
||||
export function safeExternalUrl(input: string | null | undefined) {
|
||||
if (!input) return null;
|
||||
try { const u = new URL(input); return u.protocol === "https:" || u.protocol === "http:" ? u.href : null; } catch { return null; }
|
||||
@@ -15,23 +18,21 @@ export function safeExternalUrl(input: string | null | undefined) {
|
||||
function isPaper(p: unknown): p is Paper {
|
||||
if (!p || typeof p !== "object") return false;
|
||||
const v = p as Paper;
|
||||
return typeof v.id === "string" && typeof v.title === "string" && (typeof v.abstract === "string" || v.abstract === null) && Array.isArray(v.authors) && v.authors.every(x => typeof x === "string") && Array.isArray(v.topics) && v.topics.every(x => typeof x === "string") && Array.isArray(v.sources) && v.sources.every(x => typeof x.label === "string" && typeof x.externalId === "string" && typeof x.url === "string") && (v.publishedAt === null || typeof v.publishedAt === "string");
|
||||
return typeof v.id === "string" && typeof v.title === "string" && (typeof v.abstract === "string" || v.abstract === null) && Array.isArray(v.authors) && v.authors.every(x => typeof x === "string") && Array.isArray(v.topics) && v.topics.every(x => typeof x === "string") && Array.isArray(v.sources) && v.sources.every(x => !!x && typeof x === "object" && typeof x.label === "string" && typeof x.externalId === "string" && typeof x.url === "string") && (v.publishedAt === null || typeof v.publishedAt === "string");
|
||||
}
|
||||
async function remote(path: string) {
|
||||
const origin = process.env.PAPER_API_ORIGIN;
|
||||
if (!origin) throw new Error("paper_api_not_configured");
|
||||
const res = await fetch(new URL(path, origin.replace(/\/$/, "") + "/"), { cache: "no-store", signal: AbortSignal.timeout(12000), redirect: "error" });
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error("paper_api_unavailable");
|
||||
return res.json();
|
||||
return createPaperTransport(process.env.PAPER_API_ORIGIN || "")(path);
|
||||
}
|
||||
export async function listPapers(filters: Filters): Promise<PaperPage> {
|
||||
const page = Math.max(1, Math.min(10000, Number.parseInt(filters.page || "1", 10) || 1));
|
||||
export async function listPapers(filters: Filters): Promise<PaperPage | PaperFailure> {
|
||||
const page = Math.max(1, Math.min(MAX_PAPER_PAGE, Number.parseInt(filters.page || "1", 10) || 1));
|
||||
const q = (filters.q || "").trim().slice(0, 200), topic = filters.topic || "", sort = filters.sort === "title" ? "title" : "recent";
|
||||
if (process.env.PAPER_API_ORIGIN) {
|
||||
const params = new URLSearchParams({ q, topic, sort, searchIn: filters.searchIn === "author" ? "author" : "text", page: String(page), pageSize: String(size) });
|
||||
const data = await remote("papers?" + params);
|
||||
if (!data || !Array.isArray(data.items) || !data.items.every(isPaper) || !Number.isSafeInteger(data.total) || data.total < 0 || !Array.isArray(data.topics) || !data.topics.every((x: unknown) => typeof x === "string")) throw new Error("invalid_paper_response");
|
||||
const failure = (status: number): PaperFailure => ({ mode: "error", reason: status === 504 ? "timeout" : status === 429 ? "busy" : status === 400 ? "invalid_query" : "unavailable", items: [], total: null, page, pageSize: size, topics: topic ? [topic] : [] });
|
||||
const response = await remote("papers?" + params);
|
||||
if (!response.ok) return failure(response.status ?? 503);
|
||||
const data = response.data;
|
||||
if (!data || !Array.isArray(data.items) || !data.items.every(isPaper) || !Number.isSafeInteger(data.total) || data.total < 0 || !Array.isArray(data.topics) || !data.topics.every((x: unknown) => typeof x === "string")) return failure(502);
|
||||
return { ...data, page, pageSize: size, mode: "live" };
|
||||
}
|
||||
const all: Paper[] = snapshot.papers;
|
||||
@@ -41,8 +42,8 @@ export async function listPapers(filters: Filters): Promise<PaperPage> {
|
||||
return { items: items.slice((page - 1) * size, page * size), total: items.length, page, pageSize: size, topics, mode: "sample", capturedAt: snapshot.capturedAt };
|
||||
}
|
||||
export async function getPaper(id: string): Promise<Paper | null> {
|
||||
if (!/^[a-zA-Z0-9_-]{1,100}$/.test(id)) return null;
|
||||
if (process.env.PAPER_API_ORIGIN) { const data = await remote("papers/" + encodeURIComponent(id)); if (data === null) return null; if (!isPaper(data)) throw new Error("invalid_paper_response"); return data; }
|
||||
if (!/^[1-9][0-9]{0,18}$/.test(id) || BigInt(id) > 9223372036854775807n) return null;
|
||||
if (process.env.PAPER_API_ORIGIN) { const response = await remote("papers/" + encodeURIComponent(id)); if (!response.ok) { if (response.status === 404) return null; throw new Error("paper_api_unavailable"); } if (!isPaper(response.data)) throw new Error("invalid_paper_response"); return response.data; }
|
||||
return snapshot.papers.find(p => p.id === id) || null;
|
||||
}
|
||||
export function paperDate(date: string | null) {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Vendored
+18
-2
@@ -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); }
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user