Verify reading flows on desktop and mobile and fix login contrast
This commit is contained in:
@@ -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。
|
||||
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
## 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.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
|
||||
@@ -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 登录成功。
|
||||
|
||||
Vendored
+5
@@ -1,2 +1,7 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
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.
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
@@ -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 } },
|
||||
],
|
||||
});
|
||||
Generated
+31
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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; }
|
||||
|
||||
@@ -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("好研究");
|
||||
});
|
||||
Reference in New Issue
Block a user