尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

SurfSense 中的 Playwright 错误与边界场景测试:网络故障、错误边界、离线与表单校验实战指南

SurfSense 中的 Playwright 错误与边界场景测试:网络故障、错误边界、离线与表单校验实战指南 SurfSense 中的 Playwright 错误与边界场景测试网络故障、错误边界、离线与表单校验实战指南【免费下载链接】SurfSenseOpen-source NotebookLM alternative. Research the open web with live data(Reddit, YT, IG, TikTok, Indeed, Google Search, Maps etc) through one platform, API or MCP server. Join our Discord: https://discord.gg/ejRNvftDp9项目地址: https://gitcode.com/GitHub_Trending/su/SurfSense本文围绕 SurfSense 仓库中.cursor/skills/playwright-testing/debugging/error-testing.md这份错误与边界场景测试参考文档展开系统讲解如何用 Playwright 的page.route网络拦截、context.setOffline离线模拟和pageerror事件监听来测试错误边界、网络故障、加载态与表单校验。读完本文你既能掌握文档中全部五类测试场景的可复制代码模式也能结合 SurfSense 前端真实的错误边界组件surfsense_web/app/error.tsx、错误类层次surfsense_web/lib/error.ts与 E2E 测试基建在自己的项目中落地一套完整的异常路径测试方案。文档定位它在 SurfSense 测试知识库中的位置error-testing.md是 SurfSense 仓库内置 Playwright 技能知识库.cursor/skills/playwright-testing/Debugging Troubleshooting 分类下的错误与边界场景参考文档。在同目录的 SKILL.md 中Error Edge Case Testing 一节的映射表明确列出了该文档的职责范围活动参考文件Error boundary testingerror-testing.mdNetwork failure simulationerror-testing.md、network-advanced.mdOffline mode testingerror-testing.md、service-workers.mdLoading state testingerror-testing.mdForm validation testingerror-testing.md也就是说这份文档覆盖的是非离线优先应用非 PWA在意外网络故障下的错误处理与恢复如果你测试的是带 Service Worker 缓存与后台同步的离线优先应用文档指引应转向 service-workers.md 的 offline-testing 章节。SurfSense 自身的 E2E 套件位于surfsense_web/tests/其配置见 playwright.config.ts。与本文错误测试直接相关的配置要点testDir: ./tests, timeout: 30_000, expect: { timeout: 15_000 }, retries: process.env.CI ? 1 : 0, use: { trace: on-first-retry, screenshot: only-on-failure, video: process.env.CI ? off : retain-on-failure, },两点值得注意其一expect的默认断言超时15 秒短于整体测试超时30 秒文档中Test Timeout示例里显式传入{ timeout: 15000 }正是对这类慢响应断言的常见做法其二trace: on-first-retry加 CI 重试一次的组合意味着失败用例在重试时会留下 trace 供事后分析——这与 tests/README.md 描述的确定性测试基建一脉相承SurfSense 的 E2E 采用 API 驱动的确定性断言、后端 fake 与 CI 出口封锁三层防御错误路径的后端分支放在后端单测/集成测试中而浏览器侧错误 UI 的验证正是本文这类测试的职责。错误边界测试用 mock 触发组件级错误核心思路用page.route拦截一个本应返回数据的接口让它返回一个会引发前端组件抛错的响应例如json: null然后断言错误边界渲染了兜底 UI 而非白屏。test(error boundary catches component error, async ({ page }) { // Trigger error via mock await page.route(**/api/user, (route) { route.fulfill({ json: null, // Will cause component to throw }); }); await page.goto(/profile); // Error boundary should render fallback await expect(page.getByText(Something went wrong)).toBeVisible(); await expect(page.getByRole(button, { name: Try Again })).toBeVisible(); });在 SurfSense 中这个断言目标是真实存在的Next.js 路由级错误边界 app/error.tsx 正是渲染 Something went wrong 标题并附带一个 Try again 按钮调用 Next.js 提供的reset()回调重新挂载页面和一个预填诊断信息的 Report Issue 链接// surfsense_web/app/error.tsx 核心逻辑 export default function ErrorPage({ error, reset, }: { error: globalThis.Error { digest?: string; code?: string; requestId?: string }; reset: () void; }) { useEffect(() { import(posthog-js) .then(({ default: posthog }) { posthog.captureException(error); }) .catch(() {}); }, [error]); const issueUrl useMemo(() buildIssueUrl(error), [error]); return ( div className... h2 classNametext-2xl font-semiboldSomething went wrong/h2 ... {(error.digest || error.code || error.requestId) ( div className... font-mono ... {error.code spanCode: {error.code}/span} {error.requestId span classNameml-3ID: {error.requestId}/span} {error.digest span classNameml-3Digest: {error.digest}/span} /div )} div classNameflex gap-2 Button typebutton onClick{reset}Try again/Button a href{issueUrl} ...Report Issue/a /div /div ); }这里有两个可测试的错误恢复细节错误边界通过reset()实现重试且页面上会展示code/requestId/digest三类诊断标识digest是 Next.js 对服务端错误的哈希标识requestId来自 SurfSense 的AppError见下文。因此针对 SurfSense 的错误边界测试断言 Something went wrong 与 Try again 可见是成立的点击 Try again 后应断言错误 UI 消失、页面恢复——即文档下一节的恢复模式。此外SurfSense 还有一层更外层的 app/global-error.tsx捕获布局layout层面的错误普通error.tsx覆盖不到的情况同样展示 Something went wrong、Try again 与 Report Issue。从源码结构看测试全局错误时需要构造比路由级错误更深层的异常例如在 layout 级组件中注入错误。测试错误恢复失败后重试成功错误处理不能只测显示错误还要测恢复路径。经典模式是第一次请求返回 500用户点击 Retry 后第二次请求成功。用请求计数器在同一个routehandler 中区分两次响应test(recover from error state, async ({ page }) { let requestCount 0; await page.route(**/api/data, (route) { requestCount; if (requestCount 1) { return route.fulfill({ status: 500 }); } return route.fulfill({ json: { data: success }, }); }); await page.goto(/dashboard); // Error state await expect(page.getByText(Failed to load)).toBeVisible(); // Retry await page.getByRole(button, { name: Retry }).click(); // Success state await expect(page.getByText(success)).toBeVisible(); });这个先失败后成功的时序控制技巧在 SurfSense 的 OAuth mock 中也能看到类似用法的变体——tests/helpers/mocks/composio-oauth.ts 用page.route拦截第三方域名的重定向并route.fulfill一个 302export async function mockComposioOAuthRedirect( page: Page, options: { rewriteTo: string } ): Promisevoid { await page.route(/composio\.dev/, async (route) { await route.fulfill({ status: 302, headers: { Location: options.rewriteTo }, body: , }); }); }该文件注释明确说明它为未来的负向测试保留——比如故意构造被篡改/跨源的auth_url验证前端不会盲目跟随跨域跳转。这正是错误与边界场景测试在 SurfSense 中的真实落地形态用route.fulfill构造异常响应验证前端的防御性行为。捕获 JavaScript 运行时错误与错误边界互补的是验证未捕获异常不拖垮整个应用。Playwright 的pageerror事件监听浏览器中的未捕获 JS 异常区别于console.error可以断言错误被记录与应用仍可用两件事同时成立test(handles runtime error gracefully, async ({ page }) { const errors: string[] []; page.on(pageerror, (error) { errors.push(error.message); }); await page.goto(/buggy-page); // App should still be functional despite error await expect(page.getByRole(navigation)).toBeVisible(); // Error was logged expect(errors.length).toBeGreaterThan(0); });在 SurfSense 的实践中pageerror之外还有对应的应用内上报链路lib/error-toast.ts 的showErrorToast会对AppError弹出带 Report Issue 操作的 toastduration 8000ms并在描述中附加code与requestId同时它刻意静默两类错误——AbortedError用户主动取消请求与AuthenticationError走重定向逻辑处理避免把正常的取消当作错误噪音上报。这个静默策略本身就是可测试的边界行为模拟用户取消例如导航离开导致的AbortError后应断言没有错误 toast 出现。错误类型的完整定义在 lib/error.ts这是一个值得在测试中逐一覆盖的错误类层次export class AppError extends Error { status?: number; statusText?: string; code?: string; requestId?: string; reportUrl?: string; /** Per-field failures from a 422 response, keyed by their location path. */ fields?: ValidationFieldError[]; ... } export class NetworkError extends AppError { ... } // code: NETWORK_ERROR export class AbortedError extends AppError { ... } // code: REQUEST_ABORTED export class ValidationError extends AppError { ... } // code: VALIDATION_ERROR export class AuthenticationError extends AppError { ... } // code: UNAUTHORIZED export class AuthorizationError extends AppError { ... } // code: FORBIDDEN export class NotFoundError extends AppError { ... } // code: NOT_FOUND注意ValidationError.fields字段——它保存 422 响应中的逐字段失败{ loc, msg }结构这是后面服务器端校验测试在 SurfSense 中应当断言的数据结构。网络故障测试批量覆盖 API 错误码与其手写七个几乎一样的用例不如用test.describe 循环展开test.describe(API error handling, () { const errorCodes [400, 401, 403, 404, 500, 502, 503]; for (const status of errorCodes) { test(handles ${status} error, async ({ page }) { await page.route(**/api/data, (route) route.fulfill({ status, json: { error: Error ${status} }, }), ); await page.goto(/dashboard); // Appropriate error message shown await expect(page.getByRole(alert)).toBeVisible(); }); } });这个模式与 SurfSense 的错误类映射天然契合401 对应AuthenticationError静默 重定向、403 对应AuthorizationError、404 对应NotFoundError、5xx 对应NetworkError/toast。因此移植该模式时errorCodes数组可以直接扩展且每个状态码可以断言不同的UI 表现例如 401 应触发登录跳转而非 alert而不是一刀切地只断言 alert 可见。模拟请求超时超时模拟的关键技巧是让routehandler永远不响应——用永不 resolve 的 Promisetest(handles request timeout, async ({ page }) { await page.route(**/api/slow, async (route) { // Never respond - simulates timeout await new Promise(() {}); }); await page.goto(/slow-page); // Should show timeout message (app should have its own timeout) await expect(page.getByText(Request timed out)).toBeVisible({ timeout: 15000, }); });注意文档特意在断言上写{ timeout: 15000 }被测应用应自己实现请求超时fetch/AbortController 之类测试端只需给一个足够宽松的等待窗口。这要求被测代码确实有超时机制否则该测试会一直挂到超时失败——这本身也是错误处理缺失的有效探针。模拟连接重置route.abort比fulfill更强的地方在于它能模拟 TCP 层的中断浏览器会直接收到网络错误而非 HTTP 状态码test(handles connection failure, async ({ page }) { await page.route(**/api/data, (route) { route.abort(connectionfailed); }); await page.goto(/dashboard); await expect(page.getByText(Connection failed)).toBeVisible(); await expect(page.getByRole(button, { name: Retry })).toBeVisible(); });route.abort()可接受一组 Playwright 网络错误码如failed、timeout、connectionrefused、connectionclosed等具体取值以 Playwright 文档为准不同错误码在浏览器侧可能表现为不同的fetchreject 原因测试中可按需选用。模拟请求进行中的失败上传中断比请求没发出更真实的是请求发到一半断了——尤其对文件上传这类长请求。技巧是在 handler 中先等待再 abort模拟传输中途失败test(handles failure during request, async ({ page }) { let requestStarted false; await page.route(**/api/upload, async (route) { requestStarted true; // Abort after small delay (mid-request) await new Promise((resolve) setTimeout(resolve, 500)); route.abort(failed); }); await page.goto(/upload); await page.getByLabel(File).setInputFiles(./fixtures/large-file.pdf); await page.getByRole(button, { name: Upload }).click(); // Should show failure, not hang await expect(page.getByText(Upload failed)).toBeVisible(); expect(requestStarted).toBe(true); });两个断言各有意义Upload failed验证失败被用户感知而不是 UI 永远转圈requestStarted true验证确实是请求已发出后失败这条路径而非前端在发请求前就因文件校验失败短路。SurfSense 的文档上传流程是核心用户路径见 tests/documents/file-upload/journey.spec.ts 与 fixture 目录tests/documents/file-upload/fixtures/把上传中途断网这类边界纳入覆盖是符合该套件设计意图的。离线测试文档在这一节开头给出了边界划分本节覆盖意外断网与错误恢复离线优先应用Service Worker、缓存、后台同步请参见 service-workers.md 的 offline-testing 章节。会话中途掉线context.setOffline(true/false)可以精确控制何时掉线因此能测出页面已加载、数据已渲染然后网络消失这一类纯运行时故障test(handles going offline, async ({ page, context }) { await page.goto(/dashboard); await expect(page.getByTestId(data)).toBeVisible(); // Go offline unexpectedly await context.setOffline(true); // Try to refresh data await page.getByRole(button, { name: Refresh }).click(); // Should show offline indicator await expect(page.getByText(Youre offline)).toBeVisible(); // Go back online await context.setOffline(false); // Should recover await page.getByRole(button, { name: Refresh }).click(); await expect(page.getByText(Youre offline)).toBeHidden(); });测试骨架是完整的状态机正常态 → 断网态刷新触发失败→ 提示离线 → 恢复态再次刷新后提示消失。这里断言的离线指示器应来自被测应用的navigator.onLine监听或请求失败分支若应用两者都没有该测试失败恰好暴露了缺失。断网恢复后的自动/手动重试test(recovers gracefully when connection returns, async ({ page, context, }) { await page.goto(/dashboard); // Simulate connection drop await context.setOffline(true); // App should show degraded state await expect(page.getByRole(alert)).toContainText(/offline|connection/i); // Connection restored await context.setOffline(false); // Retry should work await page.getByRole(button, { name: Retry }).click(); await expect(page.getByTestId(data)).toBeVisible(); });注意降级状态的断言用了正则/offline|connection/i——对离线提示的具体文案做了宽容匹配这是避免文案微调导致测试变脆的实用做法可结合 core/assertions-waiting.md 中关于断言与等待策略的讨论。加载态测试加载态Loading States经常被只测成功路径的套件忽略但它直接影响用户对错误的感知没有骨架屏时慢接口看起来就像挂了。骨架屏Skeleton技巧还是page.route加人为延迟然后按时间顺序断言先出现骨架屏再被内容替换test(shows skeleton during load, async ({ page }) { // Add delay to API response await page.route(**/api/posts, async (route) { await new Promise((resolve) setTimeout(resolve, 1000)); route.fulfill({ json: [{ id: 1, title: Post 1 }], }); }); await page.goto(/posts); // Skeleton should appear immediately await expect(page.getByTestId(skeleton)).toBeVisible(); // Then content replaces skeleton await expect(page.getByText(Post 1)).toBeVisible(); await expect(page.getByTestId(skeleton)).toBeHidden(); });SurfSense 的 smoke 测试 tests/smoke/dashboard.spec.ts 展示了另一种慢启动断言由于完整 E2E 栈Next.js 编译、认证、后端拉取首屏较慢断言显式放宽超时// Sidebar is aside (rolecomplementary); its visibility implies redirect auth fetch. await expect(page.getByRole(complementary).first()).toBeVisible({ timeout: 60_000 });这提示一个实践要点加载态测试的断言超时要与慢在哪里对齐——接口人为延迟 1 秒的用例用默认 15 秒expect超时即可而整栈冷启动的场景则像 Smoke 测试那样显式给出更宽的窗口而不是靠waitForTimeout硬等。操作按钮的加载指示test(shows loading state for actions, async ({ page }) { await page.route(**/api/save, async (route) { await new Promise((resolve) setTimeout(resolve, 500)); route.fulfill({ json: { success: true } }); }); await page.goto(/editor); await page.getByLabel(Content).fill(New content); const saveButton page.getByRole(button, { name: Save }); await saveButton.click(); // Button should show loading state await expect(saveButton).toBeDisabled(); await expect(page.getByTestId(spinner)).toBeVisible(); // Then success state await expect(saveButton).toBeEnabled(); await expect(page.getByText(Saved)).toBeVisible(); });toBeDisabled()的断言有双重价值既验证加载视觉状态也验证了加载中禁用按钮防止重复提交这一常见的健壮性要求——后者是加载态测试里最容易被忽略却最实际的收益。空状态Empty State空数据也是一种边界接口成功但返回空集合时UI 应展示引导性空状态而不是空白列表test(shows empty state when no data, async ({ page }) { await page.route(**/api/items, (route) route.fulfill({ json: [] })); await page.goto(/items); await expect(page.getByText(No items yet)).toBeVisible(); await expect( page.getByRole(button, { name: Create First Item }), ).toBeVisible(); });在 SurfSense 这类工作区 连接器 文档产品中空状态测试对应的是新工作区首页还没有文档/连接器提示与创建第一个入口按钮是否可见与 tests/helpers/ui/dashboard.ts 这类 UI helper 的职责衔接。表单校验测试客户端必填校验先提交空表单验证错误提示出现且表单没有真的发出去test(validates required fields, async ({ page }) { await page.goto(/signup); // Submit empty form await page.getByRole(button, { name: Sign Up }).click(); // Should show validation errors await expect(page.getByText(Email is required)).toBeVisible(); await expect(page.getByText(Password is required)).toBeVisible(); // Form should not submit await expect(page).toHaveURL(/signup); });最后的toHaveURL(/signup)是关键的负断言校验失败的表单不应发生路由跳转或页面刷新。格式校验失焦触发与修复后消失test(validates email format, async ({ page }) { await page.goto(/signup); await page.getByLabel(Email).fill(invalid-email); await page.getByLabel(Email).blur(); await expect(page.getByText(Invalid email address)).toBeVisible(); // Fix the error await page.getByLabel(Email).fill(validemail.com); await page.getByLabel(Email).blur(); await expect(page.getByText(Invalid email address)).toBeHidden(); });注意两个细节校验在blur()失焦后触发说明测试假设前端实现是失焦校验而非按键实时校验以及修复错误后提示消失也要断言——只测出现不测消失会漏掉最常见的脏状态 bug。服务器端校验422 逐字段错误服务器校验错误的特征是客户端校验全部通过提交后服务器返回 422且错误是逐字段的。测试要模拟这个响应并验证字段级错误被展示到对应输入项test(handles server validation errors, async ({ page }) { await page.route(**/api/register, (route) route.fulfill({ status: 422, json: { errors: { email: Email already exists, username: Username is taken, }, }, }), ); await page.goto(/signup); await page.getByLabel(Email).fill(takenemail.com); await page.getByLabel(Username).fill(takenuser); await page.getByLabel(Password).fill(password123); await page.getByRole(button, { name: Sign Up }).click(); // Server errors should display await expect(page.getByText(Email already exists)).toBeVisible(); await expect(page.getByText(Username is taken)).toBeVisible(); });这段示例与 SurfSense 的实际错误模型高度对应lib/error.ts 中ValidationErrorcode 为VALIDATION_ERROR专门携带fields?: ValidationFieldError[]其元素为{ loc: string[]; msg: string }——即 FastAPI/Pydantic 风格 422 响应detail: [{ loc, msg }]经前端归一化后的结构。因此在 SurfSense 中落地该测试时mock 的 JSON 可以改为后端真实的 422 形状{ detail: [{ loc: [body, email], msg: ... }] }断言字段错误被正确映射到对应输入项比文档中的扁平errors对象更贴近实际数据链路。反模式速查表原文档末尾以一张表格总结了必须避免的反模式此处完整保留反模式问题解决方式只测 happy path漏掉错误处理 bug覆盖所有错误场景没有网络故障测试弱网下应用崩溃测试离线/慢速/失败请求跳过加载态卡顿体验无法被发现断言加载 UI 出现忽视校验表单 bug 溜过去客户端与服务端校验都要测落地到 SurfSense运行环境与执行方式将上述模式落到 SurfSense 仓库时执行环境由 surfsense_web/tests/README.md 定义推荐流程是仅用 Docker 起 Postgres 与 Redisdocker compose -f docker/docker-compose.deps-only.yml up -d db redis后端与 Celery 跑在宿主机uv run python tests/e2e/run_backend.py与run_celery.py然后从surfsense_web/执行pnpm test:e2e # dev server快速迭代 pnpm test:e2e:headed # 显示浏览器 pnpm test:e2e:ui # Playwright UI 模式 pnpm test:e2e:debug # Playwright Inspector pnpm test:e2e:prod # build start与 CI 完全一致 pnpm test:e2e:report # 打开上一次的 HTML 报告单条用例调试支持直接指定 spec 文件例如pnpm test:e2e:headed connectors/composio/drive/journey.spec.ts。认证由tests/auth.setup.ts配置中名为setup的独立 project先行完成并把状态写入playwright/.auth/user.jsonchromium 项目通过dependencies: [setup]与storageState复用——因此新增错误测试时无需重复处理登录。结合该套件的设计哲学tests/README.md 的 Why API-driven? 一节一个合理的分工是后端路由级错误分支如过期 OAuth state、重复连接器、422 分类→ 放在surfsense_backend/tests/integration/等后端测试而非 Playwright浏览器侧异常 UI 与恢复错误边界、离线提示、上传中断、加载/空状态→ 正是本文error-testing.md各模式的用武之地可作为独立 spec 放在surfsense_web/tests/下复用helpers/mocks/中的page.route工具函数风格。相关参考网络 Mocking更完整的 mock 模式见 advanced/network-advanced.md断言与等待错误断言与等待策略见 core/assertions-waiting.mdPWA/Service Worker 离线离线优先应用的缓存与后台同步测试见 browser-apis/service-workers.md表单校验专项更细的表单交互与校验模式见 testing-patterns/forms-validation.md。【免费下载链接】SurfSenseOpen-source NotebookLM alternative. Research the open web with live data(Reddit, YT, IG, TikTok, Indeed, Google Search, Maps etc) through one platform, API or MCP server. Join our Discord: https://discord.gg/ejRNvftDp9项目地址: https://gitcode.com/GitHub_Trending/su/SurfSense创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表