From af0cf1ba59787e3ea5519fea892aee9a419e9f90 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sat, 7 Nov 2026 01:51:57 +0800 Subject: [PATCH] Verify reading flows on desktop and mobile and fix login contrast --- AGENTS.md | 12 ++++- README.md | 14 ++++-- next-env.d.ts | 5 ++ package.json | 4 +- playwright.config.mjs | 25 ++++++++++ pnpm-lock.yaml | 33 ++++++++++++- src/app/globals.css | 2 +- tests/e2e/reader.spec.mjs | 100 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 185 insertions(+), 10 deletions(-) create mode 100644 playwright.config.mjs create mode 100644 tests/e2e/reader.spec.mjs diff --git a/AGENTS.md b/AGENTS.md index 671bd78..b49024a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Thesisly 研迹 working agreements -- 本项目使用 Next.js App Router、TypeScript、Tailwind CSS、shadcn/ui(Radix primitives)、Lucide 和 Sonner。用户要求本地开发,但最新指示为暂不执行本地 build;不启动会隐式编译的预览,直至确认适用授权。不配置前端 CI/CD,不部署。 +- 本项目使用 Next.js App Router、TypeScript、Tailwind CSS、shadcn/ui(Radix primitives)、Lucide 和 Sonner。用户明确授权本地 dev,随后仅撤回本地 build:允许按需启动 pnpm dev,禁止 pnpm build。不配置前端 CI/CD,不部署。 - 仅通过 Foundry Core 共享后端管理身份。复用 Endoleap 的 PKCE、HttpOnly Cookie、闲置续期、退出撤销与同源校验;禁止把 token 发到客户端或 localStorage。 - 论文读取模式必须显式区分真实数据库样本与远程 API。不得把本地20条样本称为全库、最新或热门数据,不虚构摘要、代码仓库、会议决定或指标。 - 源数据只读;不复制整库,不从浏览器直连 SQLite 或持有数据库凭据。 @@ -8,3 +8,13 @@ - 项目级技能在 .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。 + + + +## This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/README.md b/README.md index 8292fb3..af758c1 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## 本地开发 -用户最新要求暂不执行本地 build,当前尚未启动开发预览。以下为后续启动命令: +用户允许本地 dev,暂不执行本地 build。开发预览已启动并完成桌面/手机浏览器检查: ```sh pnpm install @@ -17,8 +17,8 @@ pnpm dev ```sh pnpm typecheck pnpm test -# 明确允许本地生产构建后 -pnpm build +# 在另一个终端已运行 pnpm dev 后,使用系统 Chrome +pnpm test:browser ``` ## 页面 @@ -30,7 +30,7 @@ pnpm build - `/about`:数据范围与主题说明。 - 空结果、404、加载中与数据服务错误均有对应状态。 -默认Vignelli,顶部可切换Editorial/Tufte;仅主题偏好存于localStorage,绝不保存认证token。英文品牌已确定为 Thesisly;中文名为研迹,Gitea 仓库链接待用户提供,尚未注册域名或商标。 +默认Vignelli,顶部可切换Editorial/Tufte;仅主题偏好存于localStorage,绝不保存认证token。英文品牌已确定为 Thesisly;中文名为研迹,Gitea 仓库为 Zayden-Jung/Thesisly,尚未注册域名或商标。 ## 数据与后端 @@ -46,8 +46,12 @@ pnpm build 类型检查与13项测试已通过:除了Cookie/样本策略,测试直接调用生产认证代理,覆盖兑换、登录/邮件验证、续期、退出、错误、凭据隔离与请求限制。它们使用进程内模拟的Core响应,不代表真实OAuth成功,也不需要启动Next.js或编译。 -生产构建、开发服务器、浏览器交互及真实OAuth尚未通过验收;此前构建/预览被自动审批阻止,随后用户要求暂不 build。前端目录为 `/Users/zzyang/thesisly`,远程已连接 Zayden-Jung/Thesisly,main 仅有用户授权的空初始化提交;论文 HTTP API 已纳入本目标,由代理在 Foundry Core 的 codex/research-api 分支实现。 +12项桌面/手机浏览器测试覆盖搜索、分页、空结果、文章摘要与引用复制、三种主题持久化、登录/注册表单和404恢复;截图用于人工核对布局。真实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条导出仅用于视觉与交互开发,绝不代表全库已接通。 + +## Endoleap 参考 + +已核对 [Endoleap 主线 README](https://gitea.entroia.com/Zayden-Jung/Endoleap/src/branch/main/README.md)(blob `358824763e09811a85f8986526e93dda406fdb93`):沿用 App Router、同源身份代理、PKCE、HttpOnly Cookie、Core 控制的30天闲置/90天绝对会话期限与共享第三方回调。Thesisly 使用独立端口17322、项目键thesisly及服务端环境变量;不复制 Endoleap 的品牌、Canvas 功能或部署流程。浏览器表单测试不代表真实 Google/Slack 登录成功。 diff --git a/next-env.d.ts b/next-env.d.ts index 6080add..a419cbe 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,2 +1,7 @@ /// /// +import "./.next/dev/types/routes.d.ts"; +import "./.next/dev/types/root-params.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package.json b/package.json index 17d90a1..f9f01c7 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "build": "next build", "start": "next start --hostname localhost --port 17322", "typecheck": "tsc --noEmit", - "test": "node --test tests/*.test.mjs" + "test": "node --test tests/*.test.mjs", + "test:browser": "playwright test" }, "dependencies": { "@radix-ui/react-dialog": "1.3.1", @@ -23,6 +24,7 @@ "tailwind-merge": "3.7.0" }, "devDependencies": { + "@playwright/test": "1.64.1", "@tailwindcss/postcss": "4.3.4", "@types/node": "26.6.6", "@types/react": "19.3.0", diff --git a/playwright.config.mjs b/playwright.config.mjs new file mode 100644 index 0000000..b75e9b0 --- /dev/null +++ b/playwright.config.mjs @@ -0,0 +1,25 @@ +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. +export default defineConfig({ + testDir: "./tests/e2e", + workers: 1, + retries: 0, + timeout: 30000, + expect: { timeout: 10000 }, + reporter: "list", + use: { + baseURL: "http://localhost:17322", + channel: "chrome", + headless: true, + launchOptions: { args: ["--no-proxy-server"] }, + permissions: ["clipboard-read", "clipboard-write"], + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + projects: [ + { name: "desktop", use: { viewport: { width: 1440, height: 1000 } } }, + { name: "mobile", use: { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true } }, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9936a7c..c4bc6f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: version: 1.69.0(@types/react@19.3.0)(react@19.3.1) next: specifier: 16.4.5 - version: 16.4.5(@types/node@26.6.6)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + version: 16.4.5(@playwright/test@1.64.1)(@types/node@26.6.6)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) react: specifier: 19.3.1 version: 19.3.1 @@ -42,6 +42,9 @@ importers: specifier: 3.7.0 version: 3.7.0 devDependencies: + '@playwright/test': + specifier: 1.64.1 + version: 1.64.1 '@tailwindcss/postcss': specifier: 4.3.4 version: 4.3.4 @@ -318,6 +321,11 @@ packages: cpu: [x64] os: [win32] + '@playwright/test@1.64.1': + resolution: {integrity: sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==} + engines: {node: '>=20'} + hasBin: true + '@radix-ui/primitive@1.2.0': resolution: {integrity: sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==} @@ -940,6 +948,16 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + playwright-core@1.64.1: + resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.1: + resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==} + engines: {node: '>=20'} + hasBin: true + postcss@8.5.23: resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} @@ -1250,6 +1268,10 @@ snapshots: '@next/swc-win32-x64-msvc@16.4.5': optional: true + '@playwright/test@1.64.1': + dependencies: + playwright: 1.64.1 + '@radix-ui/primitive@1.2.0': {} '@radix-ui/react-arrow@1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': @@ -1701,7 +1723,7 @@ snapshots: nanoid@3.3.20: {} - next@16.4.5(@types/node@26.6.6)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + next@16.4.5(@playwright/test@1.64.1)(@types/node@26.6.6)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): dependencies: '@next/env': 16.4.5 '@swc/helpers': 0.5.23 @@ -1720,6 +1742,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.4.5 '@next/swc-win32-arm64-msvc': 16.4.5 '@next/swc-win32-x64-msvc': 16.4.5 + '@playwright/test': 1.64.1 sharp: 0.35.5(@types/node@26.6.6) transitivePeerDependencies: - '@babel/core' @@ -1728,6 +1751,12 @@ snapshots: picocolors@1.1.1: {} + playwright-core@1.64.1: {} + + playwright@1.64.1: + dependencies: + playwright-core: 1.64.1 + postcss@8.5.23: dependencies: nanoid: 3.3.20 diff --git a/src/app/globals.css b/src/app/globals.css index d537612..edf43ee 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -13,7 +13,7 @@ html { scroll-behavior:smooth; scroll-padding-top:100px; } body { margin:0; background:var(--paper); color:var(--ink); font-family:"Helvetica Neue",Arial,"PingFang SC",sans-serif; font-size:14px; -webkit-font-smoothing:antialiased; } ::selection { background:#dededb; color:#171717; } -a { color:inherit; text-decoration:none; } +@layer base { a { color:inherit; text-decoration:none; } } button,select,input { font:inherit; } button,select { cursor:pointer; } button:disabled { cursor:wait; } diff --git a/tests/e2e/reader.spec.mjs b/tests/e2e/reader.spec.mjs new file mode 100644 index 0000000..79c5d79 --- /dev/null +++ b/tests/e2e/reader.spec.mjs @@ -0,0 +1,100 @@ +import { test, expect } from "@playwright/test"; +import { readFileSync } from "node:fs"; + +const snapshot = JSON.parse(readFileSync(new URL("../../src/data/papers.json", import.meta.url))); +const errors = new WeakMap(); +test.beforeEach(async ({ page }) => { + 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 <= window.innerWidth)).toBe(true); +}); + +test("sample index has working pagination and returns to the same list", async ({ page }, info) => { + await page.goto("/"); + await expect(page.getByRole("heading", { level: 1 })).toContainText("好研究"); + await expect(page.getByRole("note")).toContainText("前 20 条记录"); + await expect(page.locator(".paper-row")).toHaveCount(8); + await expect(page.getByRole("link", { name: "登录", exact: true })).toBeVisible(); + const first = await page.locator(".paper-row h3").first().innerText(); + await page.screenshot({ path: info.outputPath("home.png"), fullPage: true }); + await page.getByRole("link", { name: "下一页" }).click(); + await expect(page).toHaveURL(/page=2/); + 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); +}); + +test("search, empty results, author scope and category filters work", async ({ page }) => { + await page.goto("/"); + await page.getByRole("textbox", { name: "搜索论文", exact: true }).fill("no-paper-matches-this-query-92871"); + await page.getByRole("button", { name: "搜索论文", exact: true }).click(); + 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("textbox", { name: "搜索论文", exact: true }).fill(author); + await page.getByRole("button", { name: "搜索论文", exact: true }).click(); + await expect(page.locator(".paper-row").first()).toContainText(author); + await page.getByRole("link", { name: "清除筛选" }).click(); + const topic = snapshot.papers[0].topics[0]; + await page.getByRole("link", { name: topic, exact: true }).click(); + await expect(page).toHaveURL(new RegExp("topic=" + encodeURIComponent(topic).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + 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) => { + const paper = snapshot.papers[0]; + await page.goto(`/papers/${paper.id}`); + await expect(page.getByRole("heading", { level: 1 })).toHaveText(paper.title); + await expect(page.locator("#abstract")).toContainText(paper.abstract.replace(/\n/g, " ")); + await expect(page.getByRole("link", { name: new RegExp(paper.sources[0].externalId.replaceAll(".", "\\.")) })).toHaveAttribute("href", paper.sources[0].url); + await page.getByRole("button", { name: "复制引用" }).click(); + await expect(page.getByText("已复制引用信息", { exact: true })).toBeVisible(); + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain(paper.title); + 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/"); +}); + +test("all reading themes apply and persist through a reload", async ({ page }) => { + await page.goto("/"); + const theme = page.getByRole("combobox", { name: "阅读主题" }); + for (const value of ["editorial", "tufte", "vignelli"]) { + await theme.selectOption(value); + await expect(page.locator("html")).toHaveAttribute("data-theme", value); + await page.reload(); + await expect(theme).toHaveValue(value); + const login = page.getByRole("link", { name: "登录", exact: true }); + await expect(login).toBeVisible(); + expect(await login.evaluate(el => { + const expected = getComputedStyle(document.body).backgroundColor; + return getComputedStyle(el).color === expected; + })).toBe(true); + await expect(page.locator("html")).toHaveAttribute("data-theme", value); + } +}); + +test("login and registration forms remain usable without sending credentials", async ({ page }, info) => { + await page.goto("/login"); + await expect(page.getByRole("button", { name: "Google", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Slack", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "创建账户", exact: true }).click(); + await expect(page.getByRole("heading", { name: "创建账户" })).toBeVisible(); + await expect(page.getByLabel("密码", { exact: true })).toHaveAttribute("minlength", "12"); + await page.getByRole("button", { name: "显示密码" }).click(); + await expect(page.getByLabel("密码", { exact: true })).toHaveAttribute("type", "text"); + await page.screenshot({ path: info.outputPath("register.png"), fullPage: true }); + await page.getByRole("button", { name: "登录", exact: true }).last().click(); + await expect(page.getByRole("heading", { name: "继续你的阅读。" })).toBeVisible(); +}); + +test("missing paper shows recovery navigation instead of an empty article", async ({ page }) => { + await page.goto("/papers/999999999"); + await expect(page.getByRole("heading", { name: "这篇论文不在当前索引里。" })).toBeVisible(); + await page.getByRole("link", { name: "返回论文索引" }).click(); + await expect(page.getByRole("heading", { level: 1 })).toContainText("好研究"); +});