From 6c4d6100b71e5789520b0d8588bb182cd80cdf3d Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 23:20:34 +0800 Subject: [PATCH] refactor: migrate pages and API routes to App Router --- README.md | 16 ++- VALIDATION.md | 12 ++ next-env.d.ts | 2 +- src/app/api/foundry/[...path]/route.ts | 122 ++++++++++++++++++++ src/app/api/oauth/[provider]/route.ts | 22 ++++ src/app/auth/callback/page.tsx | 1 + src/{pages => app/dashboard}/dashboard.tsx | 11 +- src/app/dashboard/page.tsx | 16 +++ src/app/layout.tsx | 16 +++ src/{pages/login.tsx => app/login/page.tsx} | 6 +- src/{pages/index.tsx => app/page.tsx} | 2 + src/pages/_app.tsx | 18 --- src/pages/_document.tsx | 12 -- src/pages/api/foundry/[...path].ts | 99 ---------------- src/pages/api/oauth/[provider].ts | 26 ----- src/pages/auth/callback.tsx | 1 - tests/api-smoke.mjs | 50 +++++++- tsconfig.json | 29 ++++- 18 files changed, 287 insertions(+), 174 deletions(-) create mode 100644 src/app/api/foundry/[...path]/route.ts create mode 100644 src/app/api/oauth/[provider]/route.ts create mode 100644 src/app/auth/callback/page.tsx rename src/{pages => app/dashboard}/dashboard.tsx (99%) create mode 100644 src/app/dashboard/page.tsx create mode 100644 src/app/layout.tsx rename src/{pages/login.tsx => app/login/page.tsx} (98%) rename src/{pages/index.tsx => app/page.tsx} (99%) delete mode 100644 src/pages/_app.tsx delete mode 100644 src/pages/_document.tsx delete mode 100644 src/pages/api/foundry/[...path].ts delete mode 100644 src/pages/api/oauth/[provider].ts delete mode 100644 src/pages/auth/callback.tsx diff --git a/README.md b/README.md index 7f6049c..27b4baf 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 啡跃 Endoleap -本地 Next.js Pages Router + TypeScript + Tailwind CSS + shadcn/ui 项目。首页和账户页参考 qyof 的 PROMPT 文件,工作台结合双侧栏画布、Notion 的留白与 Miro 的空间组织方式。Other Designs 用于首页叙事与版式参考。 +本地 Next.js App Router + TypeScript + Tailwind CSS + shadcn/ui 项目。首页和账户页参考 qyof 的 PROMPT 文件,工作台结合双侧栏画布、Notion 的留白与 Miro 的空间组织方式。Other Designs 用于首页叙事与版式参考。 项目目录:`/Users/zzyang/endoleap`。 @@ -19,6 +19,14 @@ npm run dev - 真实工作台:/dashboard - 本地演示:/dashboard?demo=1 +## 路由结构 + +- `src/app/layout.tsx`:根布局、中文语言标记、全局样式与页面元信息。 +- `src/app/page.tsx`:首页。 +- `src/app/login/page.tsx`、`src/app/auth/callback/page.tsx`:账户页与登录回调。 +- `src/app/dashboard/page.tsx`:工作台入口,以 Suspense 包裹读取查询参数的客户端工作台。 +- `src/app/api/**/route.ts`:Foundry 代理与 OAuth 跳转,使用 App Router Route Handlers。 + ## 配置 复制 .env.example 为 .env.local,按实际部署填写: @@ -48,9 +56,11 @@ npm run build node tests/api-smoke.mjs ``` -API 测试使用本机临时后端和独立前端进程,检查会话 Cookie、令牌不泄漏、同源校验和路径白名单,不访问线上账号。构建需先完成。浏览器验收覆盖登录/注册切换、演示画布创建、搜索空状态、笔记保存、桌面与 390px 手机布局。 +API 测试使用本机临时后端和独立前端进程,检查会话 Cookie、令牌不泄漏、同源校验和路径白名单,不访问线上账号。构建需先完成。冒烟测试还覆盖所有页面地址、元信息、OAuth 跳转、请求体限制及退出会话。此前的浏览器验收覆盖登录/注册切换、演示画布创建、搜索空状态、笔记保存、桌面与 390px 手机布局;本次迁移的验证结果见 VALIDATION.md。 -项目尚未创建远程仓库或部署。 +若执行环境限制 Turbopack 的 CSS 工作进程绑定本地端口,可使用官方 Webpack 模式:`npm run build -- --webpack`。 + +项目已同步至 `ssh://git@gitea.entroia.com:22222/Zayden-Jung/endoleap.git`;尚未部署。 性能分析、实测耗时和修复说明见 [PERFORMANCE.md](PERFORMANCE.md)。 diff --git a/VALIDATION.md b/VALIDATION.md index 93c9dff..f74d88f 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -16,3 +16,15 @@ ## 真实账号创建验收 已在用户已登录的 Chrome 中打开 http://localhost:17321/dashboard。初始空间列表为空;使用新建画布流程提交用户原先填写的「周末的灵感」后,创建了「个人空间」和该画布。完整刷新页面后,侧栏和主列表仍显示该画布,确认读取到后端持久化记录。此项为真实账号验收,补充此前只完成隔离模拟测试的限制。 + +## App Router 迁移验收(2026-11-05) + +- 全部页面迁入 `src/app`;根布局提供全局样式、`zh-CN`、标题及描述。工作台通过 `next/navigation` 读取演示参数,入口提供 Suspense 边界。 +- 两个 API 改为 Route Handlers,保留会话 Cookie、Origin 校验、路径和查询参数白名单、15 秒超时、请求取消及 Server-Timing;显式保留 1 MiB 请求体限制。 +- `npm run build -- --webpack`:通过,构建结果全部为 App Router 路由。 +- 默认 `npm run build`:当前环境禁止 Turbopack CSS 工作进程绑定本地端口而失败;没有更改项目默认构建器。清理迁移前 `.next` 缓存后,Webpack 编译、类型检查和静态生成均通过。 +- `npm run typecheck`:通过。 +- `node --experimental-strip-types --test tests/workspace.test.mjs`:4 项通过。 +- `node tests/api-smoke.mjs`:通过。覆盖首页、登录、登录回调、真实及演示工作台的 HTTP 响应、语言/标题/描述/viewport、404、Cookie 与令牌隔离、HTTPS Secure Cookie、登录态代理、跨站及非法 Origin、路径及查询白名单、请求方法、非法 JSON、超限请求体、Google/Slack 302、无效 challenge 和 204 退出清除 Cookie。 +- 本次未重跑浏览器交互与响应式验收:浏览器连接不可用;未进行真人账户登录或部署验证。 +- 迁移前 Pages Router 快照已先推送到现有仓库 main;本次迁移按用户明确授权直接更新 main,不创建 PR。 diff --git a/next-env.d.ts b/next-env.d.ts index 15849f6..ce4e94a 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -4,4 +4,4 @@ import "./.next/types/routes.d.ts"; import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited -// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/app/api/foundry/[...path]/route.ts b/src/app/api/foundry/[...path]/route.ts new file mode 100644 index 0000000..8318218 --- /dev/null +++ b/src/app/api/foundry/[...path]/route.ts @@ -0,0 +1,122 @@ +import { NextRequest, NextResponse } from "next/server"; + +const allowed = + /^(profile|auth\/(login|register|verify-email|exchange|me|logout)|workspaces(?:\/[a-zA-Z0-9_-]+(?:\/(?:materials|search)(?:\/[a-zA-Z0-9_-]+)?)?)?)$/; +const bodyLimit = 1024 * 1024; + +// Route Handlers do not have the Pages Router bodyParser size limit. +async function readBody(req: NextRequest) { + const reader = req.body?.getReader(); + if (!reader) return {}; + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > bodyLimit) { + await reader.cancel(); + throw new Error("body_too_large"); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + if (!size) return {}; + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +async function handler( + req: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +) { + const responseHeaders = new Headers({ "Cache-Control": "no-store" }); + const json = (body: unknown, status: number) => + NextResponse.json(body, { status, headers: responseHeaders }); + const requestOrigin = req.headers.get("origin"); + if (req.method !== "GET" && requestOrigin) { + try { + if (new URL(requestOrigin).host !== req.headers.get("host")) + return json({ error: "invalid_origin" }, 403); + } catch { + return json({ error: "invalid_origin" }, 403); + } + } + const path = (await params).path.join("/"); + if (!allowed.test(path)) return json({ error: "not_found" }, 404); + let requestBody: unknown; + if (req.method !== "GET") { + try { + requestBody = await readBody(req); + } catch (error) { + const tooLarge = error instanceof Error && error.message === "body_too_large"; + return json({ error: tooLarge ? "body_too_large" : "invalid_json" }, tooLarge ? 413 : 400); + } + } + const origin = process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com"; + const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap"; + const headers: Record = { "Content-Type": "application/json" }; + const session = req.cookies.get("foundry_session")?.value; + if (session) headers.Authorization = "Bearer " + session; + for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match"]) { + const value = req.headers.get(h); + if (value !== null) headers[h] = value; + } + const query = new URLSearchParams(); + for (const key of ["kind", "sort", "q", "limit", "offset"]) { + const values = req.nextUrl.searchParams.getAll(key); + if (values.length === 1 && values[0].length < 300) query.set(key, values[0]); + } + const started = performance.now(); + try { + const upstream = await fetch( + origin + "/projects/" + encodeURIComponent(project) + "/" + path + + (query.size ? "?" + query.toString() : ""), + { + method: req.method, + headers, + body: req.method === "GET" ? undefined : JSON.stringify(requestBody || {}), + signal: AbortSignal.any([AbortSignal.timeout(15000), req.signal]), + redirect: "manual", + cache: "no-store", + }, + ); + const headersReceived = performance.now(); + const body = upstream.status === 204 ? null : await upstream.json(); + responseHeaders.set( + "Server-Timing", + `upstream;dur=${(headersReceived - started).toFixed(1)}, total;dur=${(performance.now() - started).toFixed(1)}`, + ); + if (body?.access_token) { + responseHeaders.set( + "Set-Cookie", + `foundry_session=${encodeURIComponent(body.access_token)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.min(body.expires_in || 28800, 28800)}${process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https" ? "; Secure" : ""}`, + ); + delete body.access_token; + } + if (path === "auth/logout" && upstream.ok) + responseHeaders.set( + "Set-Cookie", + "foundry_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0", + ); + if (upstream.status === 204) + return new NextResponse(null, { status: 204, headers: responseHeaders }); + return json(body || { error: "upstream_unavailable" }, upstream.status); + } catch (error) { + responseHeaders.set("Server-Timing", `total;dur=${(performance.now() - started).toFixed(1)}`); + const timeout = error instanceof Error && error.name === "TimeoutError"; + return json({ error: timeout ? "upstream_timeout" : "upstream_unavailable" }, timeout ? 504 : 502); + } +} + +function methodNotAllowed() { + return NextResponse.json({ error: "method_not_allowed" }, { + status: 405, + headers: { "Cache-Control": "no-store", Allow: "GET, POST, PATCH, DELETE" }, + }); +} + +export { handler as GET, handler as POST, handler as PATCH, handler as DELETE }; +export { methodNotAllowed as PUT, methodNotAllowed as HEAD, methodNotAllowed as OPTIONS }; diff --git a/src/app/api/oauth/[provider]/route.ts b/src/app/api/oauth/[provider]/route.ts new file mode 100644 index 0000000..5ddf5c8 --- /dev/null +++ b/src/app/api/oauth/[provider]/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ provider: string }> }, +) { + const { provider } = await params; + const challenges = req.nextUrl.searchParams.getAll("challenge"); + const challenge = challenges[0]; + if ( + !["google", "slack"].includes(provider) || + challenges.length !== 1 || + !/^[-_a-zA-Z0-9]{43}$/.test(challenge) + ) + return NextResponse.json({ error: "invalid_request" }, { status: 400 }); + const origin = process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com"; + const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap"; + return NextResponse.redirect( + origin + "/projects/" + encodeURIComponent(project) + "/auth/" + provider + "?challenge=" + challenge, + { status: 302, headers: { "Cache-Control": "no-store" } }, + ); +} diff --git a/src/app/auth/callback/page.tsx b/src/app/auth/callback/page.tsx new file mode 100644 index 0000000..a32805d --- /dev/null +++ b/src/app/auth/callback/page.tsx @@ -0,0 +1 @@ +export { default } from "../../login/page"; diff --git a/src/pages/dashboard.tsx b/src/app/dashboard/dashboard.tsx similarity index 99% rename from src/pages/dashboard.tsx rename to src/app/dashboard/dashboard.tsx index f8ebeae..5e53f1c 100644 --- a/src/pages/dashboard.tsx +++ b/src/app/dashboard/dashboard.tsx @@ -1,5 +1,7 @@ +"use client"; + import { useEffect, useRef, useState, type FormEvent } from "react"; -import { useRouter } from "next/router"; +import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; import { ArrowUpRight, @@ -68,7 +70,7 @@ const initialBoards: Board[] = [ ]; export default function Dashboard() { const router = useRouter(); - const demo = router.query.demo === "1"; + const demo = useSearchParams().get("demo") === "1"; const mutationVersion = useRef(0); const [boardsLoading, setBoardsLoading] = useState(false); const [spacesLoaded, setSpacesLoaded] = useState(false); @@ -93,7 +95,6 @@ export default function Dashboard() { [nodeText, setNodeText] = useState(""), [note, setNote] = useState(""); useEffect(() => { - if (!router.isReady) return; let active = true; if (demo) { setProfile({ @@ -137,7 +138,7 @@ export default function Dashboard() { active = false; controller.abort(); }; - }, [router.isReady, demo, router]); + }, [demo, router]); useEffect(() => { if (demo && ready) localStorage.setItem( @@ -245,7 +246,7 @@ export default function Dashboard() { async function logout() { try { if (!demo) await api("auth/logout", { method: "POST" }); - await router.push("/login"); + router.push("/login"); } catch (e) { setError(errorText(e)); } diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..9dc854f --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,16 @@ +import { Suspense } from "react"; +import { Brand } from "@/components/brand"; +import Dashboard from "./dashboard"; + +export default function DashboardPage() { + return ( + + +

正在打开你的创作空间…

+ + }> + +
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..1933fe9 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; +import "@/styles/globals.css"; + +export const metadata: Metadata = { + title: "啡跃 Endoleap · 让灵感有形", + description: "收藏灵感、连接知识,在自己的创作空间让想法慢慢成形。", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/pages/login.tsx b/src/app/login/page.tsx similarity index 98% rename from src/pages/login.tsx rename to src/app/login/page.tsx index dd98654..b40625b 100644 --- a/src/pages/login.tsx +++ b/src/app/login/page.tsx @@ -1,6 +1,8 @@ +"use client"; + import { useEffect, useState, type FormEvent } from "react"; import Link from "next/link"; -import { useRouter } from "next/router"; +import { useRouter } from "next/navigation"; import { ArrowRight, Eye, @@ -65,7 +67,7 @@ export default function Login() { }); if (register) setMessage("验证邮件已请求发送,请检查邮箱并点击验证链接。"); - else await router.push("/dashboard"); + else router.push("/dashboard"); } catch (e) { setError(errorText(e)); } finally { diff --git a/src/pages/index.tsx b/src/app/page.tsx similarity index 99% rename from src/pages/index.tsx rename to src/app/page.tsx index bb4f8e0..4b951c9 100644 --- a/src/pages/index.tsx +++ b/src/app/page.tsx @@ -1,3 +1,5 @@ +"use client"; + import Link from "next/link"; import { useState } from "react"; import { ArrowUpRight, ArrowRight, Plus, Minus } from "lucide-react"; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx deleted file mode 100644 index b45f7ef..0000000 --- a/src/pages/_app.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { AppProps } from "next/app"; -import Head from "next/head"; -import "@/styles/globals.css"; -export default function App({ Component, pageProps }: AppProps) { - return ( - <> - - 啡跃 Endoleap · 让灵感有形 - - - - - - ); -} diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx deleted file mode 100644 index 531fba1..0000000 --- a/src/pages/_document.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Html, Head, Main, NextScript } from "next/document"; -export default function Document() { - return ( - - - -
- - - - ); -} diff --git a/src/pages/api/foundry/[...path].ts b/src/pages/api/foundry/[...path].ts deleted file mode 100644 index a6630ab..0000000 --- a/src/pages/api/foundry/[...path].ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -const allowed = - /^(profile|auth\/(login|register|verify-email|exchange|me|logout)|workspaces(?:\/[a-zA-Z0-9_-]+(?:\/(?:materials|search)(?:\/[a-zA-Z0-9_-]+)?)?)?)$/; -export default async function handler( - req: NextApiRequest, - res: NextApiResponse, -) { - res.setHeader("Cache-Control", "no-store"); - if (!["GET", "POST", "PATCH", "DELETE"].includes(req.method || "")) - return res.status(405).json({ error: "method_not_allowed" }); - if (req.method !== "GET" && req.headers.origin) { - try { - if (new URL(req.headers.origin).host !== req.headers.host) - return res.status(403).json({ error: "invalid_origin" }); - } catch { - return res.status(403).json({ error: "invalid_origin" }); - } - } - const path = (Array.isArray(req.query.path) ? req.query.path : []).join("/"); - if (!allowed.test(path)) return res.status(404).json({ error: "not_found" }); - const origin = - process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com"; - const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap"; - const headers: Record = { - "Content-Type": "application/json", - }; - if (req.cookies.foundry_session) - headers.Authorization = "Bearer " + req.cookies.foundry_session; - for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match"]) - if (typeof req.headers[h] === "string") - headers[h] = req.headers[h] as string; - const query = new URLSearchParams(); - for (const key of ["kind", "sort", "q", "limit", "offset"]) { - const v = req.query[key]; - if (typeof v === "string" && v.length < 300) query.set(key, v); - } - const started = performance.now(); - const disconnected = new AbortController(); - const onClose = () => { - if (!res.writableEnded) disconnected.abort(); - }; - res.once("close", onClose); - try { - const upstream = await fetch( - origin + - "/projects/" + - encodeURIComponent(project) + - "/" + - path + - (query.size ? "?" + query.toString() : ""), - { - method: req.method, - headers, - body: req.method === "GET" ? undefined : JSON.stringify(req.body || {}), - signal: AbortSignal.any([ - AbortSignal.timeout(15000), - disconnected.signal, - ]), - redirect: "manual", - }, - ); - const headersReceived = performance.now(); - const body = upstream.status === 204 ? null : await upstream.json(); - res.setHeader( - "Server-Timing", - `upstream;dur=${(headersReceived - started).toFixed(1)}, total;dur=${(performance.now() - started).toFixed(1)}`, - ); - const session = body?.access_token; - if (session) { - res.setHeader( - "Set-Cookie", - `foundry_session=${encodeURIComponent(session)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.min(body.expires_in || 28800, 28800)}${process.env.NODE_ENV === "production" && req.headers["x-forwarded-proto"] === "https" ? "; Secure" : ""}`, - ); - delete body.access_token; - } - if (path === "auth/logout" && upstream.ok) - res.setHeader( - "Set-Cookie", - "foundry_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0", - ); - if (upstream.status === 204) return res.status(204).end(); - return res - .status(upstream.status) - .json(body || { error: "upstream_unavailable" }); - } catch (error) { - if (res.destroyed) return; - res.setHeader( - "Server-Timing", - `total;dur=${(performance.now() - started).toFixed(1)}`, - ); - const timeout = error instanceof Error && error.name === "TimeoutError"; - return res - .status(timeout ? 504 : 502) - .json({ error: timeout ? "upstream_timeout" : "upstream_unavailable" }); - } finally { - res.off("close", onClose); - } -} -export const config = { api: { bodyParser: { sizeLimit: "1mb" } } }; diff --git a/src/pages/api/oauth/[provider].ts b/src/pages/api/oauth/[provider].ts deleted file mode 100644 index 49a503a..0000000 --- a/src/pages/api/oauth/[provider].ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -export default function handler(req: NextApiRequest, res: NextApiResponse) { - const p = req.query.provider, - c = req.query.challenge; - if ( - req.method !== "GET" || - !["google", "slack"].includes(String(p)) || - typeof c !== "string" || - !/^[-_a-zA-Z0-9]{43}$/.test(c) - ) - return res.status(400).json({ error: "invalid_request" }); - const origin = - process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com"; - const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap"; - res.setHeader("Cache-Control", "no-store"); - res.redirect( - 302, - origin + - "/projects/" + - encodeURIComponent(project) + - "/auth/" + - p + - "?challenge=" + - c, - ); -} diff --git a/src/pages/auth/callback.tsx b/src/pages/auth/callback.tsx deleted file mode 100644 index b88e0c4..0000000 --- a/src/pages/auth/callback.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../login"; diff --git a/tests/api-smoke.mjs b/tests/api-smoke.mjs index 0ed0b85..b9d3bd5 100644 --- a/tests/api-smoke.mjs +++ b/tests/api-smoke.mjs @@ -6,7 +6,12 @@ import { fileURLToPath } from "node:url"; const host = "http://127.0.0.1:3005"; const upstream = createServer((req, res) => { res.setHeader("Content-Type", "application/json"); - if (req.url.endsWith("/auth/login")) { + if (req.url.endsWith("/auth/logout")) { + res.statusCode = 204; + res.end(); + } else if (req.url.includes("/workspaces/space/materials")) { + res.end(JSON.stringify({ url: req.url, method: req.method })); + } else if (req.url.endsWith("/auth/login")) { res.end( JSON.stringify({ access_token: "test-only-session", @@ -64,6 +69,16 @@ try { await new Promise((r) => setTimeout(r, 100)); } assert(ready, "Frontend did not start"); + for (const path of ["/", "/login", "/auth/callback", "/dashboard", "/dashboard?demo=1"]) { + const page = await fetch(host + path); + assert.equal(page.status, 200, path); + const html = await page.text(); + assert.match(html, /