SurfSense 前端 E2E 进阶:Playwright 高级网络拦截实战指南 📅 发布时间:2026/9/14 12:32:07 👁 浏览次数: SurfSense 前端 E2E 进阶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导读在 Playwright 端到端测试中网络层是决定测试稳定性与真实性的关键分水岭。本文基于 SurfSense 仓库内.cursor/skills/playwright-testing/advanced/network-advanced.md的完整技术脉络系统讲解请求修改Request Modification、GraphQL 模拟GraphQL Mocking、HAR 录制与回放、条件模拟Conditional Mocking与网络限速Network Throttling五大高级拦截技术同时结合surfsense_web下真实的 Playwright 配置与测试辅助代码说明如何在 SurfSenseNext.js FastAPI Celery 全栈这样依赖实时数据、第三方 SDK 与复杂 API 的产品中落地这些模式。读完本文你将能够写出既“拦截精准”又不失真实性的确定性 E2E 测试并掌握如何在 CI 环境中通过三层防护机制杜绝真实外呼。目录拦截 API 的三种基本姿势GraphQL 精确模拟HAR 录制与回放条件模拟的进阶玩法网络限速与离线模拟必须避开的反模式相关参考一、拦截 API 的三种基本姿势Request Modification网络拦截的入口是page.route()它的核心思想是把浏览器发出的所有匹配请求拦下来由测试代码决定“放行continue”“改写后放行”还是“直接伪造响应fulfill”。route.continue()与route.fulfill()是两条核心路径前者把请求继续发往真实网络后者则完全在浏览器内终结请求并返回伪响应。1.1 修改请求头最常见的需求是给所有 API 请求注入认证信息或测试标识。下面的示例拦截所有/api/**请求在原始请求头之上叠加Authorization与自定义测试头test(add auth header to requests, async ({ page }) { await page.route(**/api/**, (route) { const headers { ...route.request().headers(), Authorization: Bearer test-token, X-Test-Header: test-value, }; route.continue({ headers }); }); await page.goto(/dashboard); });值得注意route.request().headers()返回的是浏览器实际发送的请求头集合展开后追加字段可保证不丢失原有头如Content-Type、Cookie。在 SurfSense 的真实 E2E 工程中这类“测试标识头”被提升到了全局配置层。查看 playwright.config.ts可以看到每个请求默认携带extraHTTPHeaders: { x-playwright-test: true, },这一全局头与page.route()级别的头注入互为补充全局头标记“这是测试流量”便于后端与日志系统识别而page.route()则适合在单个用例内做细粒度改写。1.2 修改请求体当需要验证“提交给后端的 POST 数据是否符合预期”时可以读取原始请求体、注入测试元数据后再放行test(modify POST body, async ({ page }) { await page.route(**/api/orders, async (route) { if (route.request().method() POST) { const postData route.request().postDataJSON(); // Add test metadata const modifiedData { ...postData, testMode: true, testTimestamp: Date.now(), }; await route.continue({ postData: JSON.stringify(modifiedData), }); } else { await route.continue(); } }); await page.goto(/checkout); await page.getByRole(button, { name: Place Order }).click(); });这里的两个关键实践点务必检查route.request().method()拦截模式**/api/orders会同时命中 GET、POST、OPTIONS 等所有方法只有显式判断方法改写逻辑才不会误伤其他请求postDataJSON()与JSON.stringify配对使用读取时解析为对象便于修改放行时序列化回字符串若请求体不是 JSON如表单、multipart需改用postData()获取原始字符串。1.3 转换响应Transform Response比改写请求更常用的是“先放行到真实后端取回真实响应再在浏览器侧篡改后返回”route.fetch()正是为这个场景设计的——它在测试进程内发起一次真实请求与page.request独立不经过页面上下文test(modify API response, async ({ page }) { await page.route(**/api/products, async (route) { // Fetch real response const response await route.fetch(); const json await response.json(); // Modify response const modified json.map((product: any) ({ ...product, price: product.price * 0.9, // 10% discount testMode: true, })); await route.fulfill({ response, json: modified, }); }); await page.goto(/products); });这种“真实获取 本地篡改”模式Pass-through Mock价值极大它保留了后端字段结构、状态码、响应头等真实细节只改写测试关心的字段比完全伪造响应更接近生产环境。route.fulfill({ response, json: modified })中传入response会继承原响应的状态码与头json则替换响应体。SurfSense 实战佐证仓库中 composio-oauth.ts 提供了一个真实的路由伪造案例——mockComposioOAuthRedirect拦截所有指向composio.dev的请求并伪造 302 重定向await page.route(/composio\.dev/, async (route) { await route.fulfill({ status: 302, headers: { Location: options.rewriteTo }, body: , }); });这个辅助函数被保留用于“未来对篡改/外部 auth_url 的负向测试”例如验证前端不会盲目跟随站外重定向——正是route.fulfill在安全边界测试中的典型应用。同时注意其注释说明了 SurfSense 的正常 E2E 路径并不需要它后端 Composio 假实现返回同源auth_url浏览器根本不会导航到第三方域名。二、GraphQL 精确模拟GraphQL MockingGraphQL 与 REST 最大的不同在于所有查询/变更都打在同一个端点通常是**/graphql区分它们靠的是请求体里的operationName与variables。因此 GraphQL 模拟的核心思路是——解析postData按操作名分发。2.1 按操作名模拟test(mock GraphQL query, async ({ page }) { await page.route(**/graphql, async (route) { const postData route.request().postDataJSON(); if (postData.operationName GetUser) { return route.fulfill({ json: { data: { user: { id: 1, name: Test User, email: testexample.com, }, }, }, }); } if (postData.operationName GetProducts) { return route.fulfill({ json: { data: { products: [ { id: 1, name: Product A, price: 29.99 }, { id: 2, name: Product B, price: 49.99 }, ], }, }, }); } // Pass through unmocked operations return route.continue(); }); await page.goto(/dashboard); });注意route.fulfill({ json })不需要手动设置Content-Type——Playwright 会自动将其序列化为application/json响应。末尾的route.continue()是保证“只模拟部分操作、其余透传”的关键避免模拟器成为整个 GraphQL 端点的黑洞。2.2 封装为可复用的 GraphQL Mock Fixture把模拟逻辑从用例中抽出来封装为 Playwright 自定义 fixture是让多个用例共享同一套模拟基础设施的工程化做法// fixtures/graphql.fixture.ts type GraphQLMock { operation: string; variables?: Recordstring, any; response: { data?: any; errors?: any[] }; }; type GraphQLFixtures { mockGraphQL: (mocks: GraphQLMock[]) Promisevoid; }; export const test base.extendGraphQLFixtures({ mockGraphQL: async ({ page }, use) { await use(async (mocks) { await page.route(**/graphql, async (route) { const postData route.request().postDataJSON(); const mock mocks.find((m) { if (m.operation ! postData.operationName) return false; // Optionally match variables if (m.variables) { return ( JSON.stringify(m.variables) JSON.stringify(postData.variables) ); } return true; }); if (mock) { return route.fulfill({ json: mock.response }); } return route.continue(); }); }); }, }); // Usage test(dashboard with mocked GraphQL, async ({ page, mockGraphQL }) { await mockGraphQL([ { operation: GetDashboardStats, response: { data: { stats: { users: 100, revenue: 50000 } }, }, }, { operation: GetUser, variables: { id: 1 }, response: { data: { user: { id: 1, name: John } }, }, }, ]); await page.goto(/dashboard); await expect(page.getByText(100 users)).toBeVisible(); });这一封装的巧妙之处在于variables匹配采用JSON.stringify全等比较当提供variables时精确匹配参数未提供时仅按操作名匹配。返回值Promisevoid意味着测试可以在page.goto之前一次性声明多个操作query 与 mutation 皆可的预期响应然后让页面自由发起请求。2.3 模拟 GraphQL MutationMutation 的模拟与 Query 基本一致但通常需要根据variables动态计算返回结果以验证前端对提交结果的渲染逻辑test(mock GraphQL mutation, async ({ page }) { await page.route(**/graphql, async (route) { const postData route.request().postDataJSON(); if (postData.operationName CreateOrder) { const { input } postData.variables; return route.fulfill({ json: { data: { createOrder: { id: order-123, status: PENDING, items: input.items, total: input.items.reduce( (sum: number, item: any) sum item.price * item.quantity, 0, ), }, }, }, }); } return route.continue(); }); await page.goto(/checkout); await page.getByRole(button, { name: Place Order }).click(); await expect(page.getByText(Order #order-123)).toBeVisible(); });这里响应中的total是根据提交的input.items现场计算得出的模拟出的行为与真实后端完全一致比写死一个固定 total 更能暴露前端计算/展示逻辑的缺陷。该用例还演示了一个完整的“用户操作闭环”测试填写表单 → 点击提交 → 断言服务端返回 ID 被渲染到页面。实战说明虽然 SurfSense 主 API 是 REST 风格FastAPI 路由如surfsense_backend/app/routes/但其前端大量使用 Zero 实时同步层见 playwright.config.ts 中的NEXT_PUBLIC_ZERO_CACHE_URL与 WebSocket 推送。GraphQL 模拟模式的价值在于凡是“单端点多操作”的 API 形态GraphQL 或类似的 RPC 聚合端点都可套用“解析 postData → 按 operationName/variables 分发 → 未命中透传”这套模板。相关实时通道的模拟方式可参考 websockets.md。三、HAR 录制与回放HAR Recording PlaybackHARHTTP Archive是一种标准化的网络流量记录格式。Playwright 提供context.routeFromHAR()将“录制真实流量 → 离线回放”变成一行配置是制作回归基线、演示环境与离线测试的利器。3.1 录制 HAR 文件// Record network traffic test(record HAR, async ({ page, context }) { // Start recording await context.routeFromHAR(./recordings/checkout.har, { update: true, // Create/update HAR file url: **/api/**, }); await page.goto(/checkout); await page.getByRole(button, { name: Place Order }).click(); // HAR file is saved automatically });update: true表示运行期间把匹配**/api/**的真实请求/响应写入 HAR 文件若文件已存在则更新。url参数是过滤器只录制关心的 API 流量避免把图片、字体等静态资源也塞进 HAR 造成体积膨胀。测试结束时 HAR 自动落盘无需手动保存。3.2 回放 HAR 文件// Use recorded HAR for offline testing test(playback HAR, async ({ page, context }) { await context.routeFromHAR(./recordings/checkout.har, { url: **/api/**, update: false, // Dont update, just playback }); await page.goto(/checkout); // All API calls served from HAR file await expect(page.getByText(Order confirmed)).toBeVisible(); });update: false时所有匹配请求都由 HAR 中的记录直接应答浏览器不会触碰真实网络。回放模式是“无后端跑前端测试”的基础——录制的 HAR 可以作为版本化资产提交进仓库保证即使在后端宕机、断网或未启动的环境中回归测试依然可运行。3.3 HAR 回放 实时回退FallbackHAR 中不可能覆盖所有请求因此 Playwright 提供了notFound选项test(HAR with live fallback, async ({ page, context }) { await context.routeFromHAR(./recordings/api.har, { url: **/api/**, update: false, notFound: fallback, // Use real network if not in HAR }); await page.goto(/dashboard); });notFound的两种取值语义如下取值行为适用场景abort默认HAR 中未命中的请求直接中止严格的离线/封闭环境不允许任何真实流量fallbackHAR 未命中则走真实网络混合模式核心接口用录制数据长尾请求实时获取在 SurfSense 的 E2E 工程里这种“默认拒绝 显式放行”的哲学被推向了极致。查看 docker-compose.e2e.yml测试栈的internal网络被设置为internal: true——这是 Docker 层面上的 L3 出口封锁Celery worker 与数据库容器根本没有任何通往宿主或互联网的路由而即使 backend 通过ingress网络暴露给宿主docker-compose.e2e.yml 仍为它设置了HTTPS_PROXY: http://127.0.0.1:1作为纵深防御——任何泄漏的 Python 出站 HTTP 调用都会立刻 Connection refused。这与notFound: abort是同一思想在不同层的体现网络层面的确定性比代码约定更可靠。四、条件模拟的进阶玩法Conditional Mocking条件模拟解决的是“同一端点不同输入要返回不同响应”的问题常用于覆盖错误分支、空数据、重试逻辑等边界场景。4.1 按请求体条件模拟test(conditional mock by body, async ({ page }) { await page.route(**/api/search, async (route) { const body route.request().postDataJSON(); if (body.query error) { return route.fulfill({ status: 500, json: { error: Search failed }, }); } if (body.query empty) { return route.fulfill({ json: { results: [] }, }); } // Default response return route.fulfill({ json: { results: [{ id: 1, title: Result for: ${body.query} }], }, }); }); await page.goto(/search); // Test different scenarios await page.getByLabel(Search).fill(error); await page.getByLabel(Search).press(Enter); await expect(page.getByText(Search failed)).toBeVisible(); });三个分支各司其职error触发 500 错误信息渲染empty触发空态 UI默认分支返回“原样回显”的数据。这个模式的核心收益是——一个用例内完成多条用户路径的状态覆盖无需为每个分支编写独立的测试文件。4.2 按第 N 次请求模拟重试逻辑真实的分布式系统普遍存在瞬时故障前端往往实现了自动重试。用闭包计数器即可精确模拟“前两次失败、第三次成功”的重试场景test(different response on retry, async ({ page }) { let callCount 0; await page.route(**/api/status, (route) { callCount; if (callCount 3) { return route.fulfill({ status: 503, json: { error: Service unavailable }, }); } // Succeed on 3rd attempt return route.fulfill({ json: { status: ok }, }); }); await page.goto(/dashboard); // App should retry and eventually succeed await expect(page.getByText(Connected)).toBeVisible(); });测试意图非常明确断言“应用会重试且最终能从 503 恢复”。配合 assertions-waiting.md 中的自动等待机制toBeVisible()会一直轮询直到“Connected”出现天然验证了重试的全过程。4.3 模拟延迟Loading 态测试加载态是前端体验的重要组成用setTimeout在route.fulfill前人为插入延迟即可稳定复现 Loading 骨架屏test(slow network simulation, async ({ page }) { await page.route(**/api/data, async (route) { // Simulate 2 second delay await new Promise((resolve) setTimeout(resolve, 2000)); return route.fulfill({ json: { data: loaded }, }); }); await page.goto(/dashboard); // Loading state should appear await expect(page.getByText(Loading...)).toBeVisible(); // Then data appears await expect(page.getByText(loaded)).toBeVisible(); });该用例验证的是“加载态出现 → 数据到达后消失”的完整时序。相比waitForTimeout这类反模式硬编码等待这里的 2 秒延迟与 Playwright 的自动重试断言配合既稳定又无需猜测执行耗时。SurfSense 实践提醒仓库的 E2E 规范明确反对在用例里使用waitForTimeout。查看 indexing.ts 的注释“ReplaceswaitForTimeout(which is a Playwright anti-pattern) with deterministic polling on real signals”。该文件中的waitForIndexingComplete通过轮询后端last_indexed_at与就绪文档数来等待索引完成而非盲目 sleep——这与“延迟模拟用于验证 UI 状态、确定性轮询用于等待真实后端状态”的分工完全一致。五、网络限速与离线模拟Network Throttling除了请求级拦截Playwright 还能模拟整个网络环境的带宽、延迟与连通性用于验证弱网体验与离线降级。5.1 Slow 3G 模拟CDP 网络仿真通过 CDPChrome DevTools Protocol会话调用Network.emulateNetworkConditions可精确控制吞吐量与延迟test(slow network experience, async ({ page, context }) { // Create CDP session for network throttling const client await context.newCDPSession(page); await client.send(Network.emulateNetworkConditions, { offline: false, downloadThroughput: (500 * 1024) / 8, // 500 Kbps uploadThroughput: (500 * 1024) / 8, latency: 400, // 400ms }); await page.goto(/); // Test loading states appear await expect(page.getByTestId(skeleton-loader)).toBeVisible(); });注意吞吐量单位是字节/秒(500 * 1024) / 8即 500 Kbps500 千比特每秒1 字节 8 比特。该方案适合验证骨架屏、进度条等加载态组件在真实慢网下的表现。5.2 离线模式Playwright 提供了比 CDP 更简单的第一方 APIUse context.setOffline(true/false) to simulate network connectivity changes.context.setOffline(true)会立即使浏览器所有网络请求失败模拟用户断网。它常与以下两个主题配合使用形成完整的离线测试矩阵网络故障模拟错误恢复、优雅降级见 error-testing.md离线优先 / PWA 测试Service Worker、缓存、后台同步见 service-workers.md5.3 封装网络限速 Fixture与 GraphQL Fixture 一样网络条件也应封装为可复用的自定义 fixture让不同用例以声明式方式切换网络档位// fixtures/network.fixture.ts type NetworkCondition slow3g | fast3g | offline; const conditions { slow3g: { downloadThroughput: 50000, uploadThroughput: 50000, latency: 2000 }, fast3g: { downloadThroughput: 180000, uploadThroughput: 75000, latency: 150 }, }; type NetworkFixtures { setNetworkCondition: (condition: NetworkCondition) Promisevoid; }; export const test base.extendNetworkFixtures({ setNetworkCondition: async ({ page, context }, use) { const client await context.newCDPSession(page); await use(async (condition) { if (condition offline) { await context.setOffline(true); } else { await client.send(Network.emulateNetworkConditions, { offline: false, ...conditions[condition], }); } }); // Reset await context.setOffline(false); }, });该 Fixture 提供了两档 3G 预设slow3g50 KB/s 下行、2s 延迟fast3g180 KB/s 下行、150ms 延迟外加offline档并且内置了重置逻辑——use()回调返回后执行setOffline(false)确保测试间的网络状态不互相污染。这也呼应了 fixtures-hooks.md 中“fixture 自带清理”的最佳实践。SurfSense 网络确定性全景在 SurfSense 的 E2E 体系中网络确定性是分层构建的浏览器侧的拦截只是最后一层。完整的“防真实外呼”三层防线记录在 tests/README.md第一层run_backend.py与run_celery.py在导入应用前通过sys.modules劫持composio等 SDK并挂载X-E2E-Scenario中间件第二层假实现是“严格”的——每个类都通过__getattr__对未知接口抛NotImplementedError新增 SDK 调用点若不同步更新假实现CI 会直接失败第三层CI 设置HTTPS_PROXYhttp://127.0.0.1:1与哨兵 API Key如COMPOSIO_API_KEYe2e-deny-real-call-sentinel任何泄漏的出站调用在触网前即被拒。这与本文介绍的浏览器层拦截形成纵深配合路由拦截管“前端看到的”网络封锁管“后端发出的”。六、必须避开的反模式Anti-Patterns反模式问题解决方案Mocking all requests全量模拟测试不反映真实情况只模拟必要的请求No cleanup of routes路由不清理路由跨测试残留使用带清理逻辑的 FixtureIgnoring request method忽略请求方法模拟作用到错误的请求检查route.request().method()Hardcoded mock responses硬编码响应脆弱、难维护使用数据工厂factory生成 mock 数据逐条展开解读只模拟必要的请求模拟的初衷是消除环境不确定性而不是把测试变成“对脚本的复述”。全量模拟会让回归测试丧失发现真实集成问题的能力。判断标准是只有当真实响应会破坏测试确定性时才模拟例如第三方支付、邮件验证、外部 OAuth。路由清理page.route()注册的处理器会随页面/上下文销毁而失效但如果复用了持久化上下文或跨用例共享页面未清理的路由可能泄漏到后续用例。将其封装进 fixture并让 fixture 在use()之后恢复原状参考上文网络 Fixture 中的setOffline(false)重置是最稳妥的做法。检查请求方法这是最容易踩的坑——URL 匹配模式通常同时命中 GET/POST/OPTIONS若模拟逻辑不分方法可能出现“GET 请求被 POST 的 mock 命中”这类诡异故障。统一在处理器开头做方法分派。用数据工厂代替硬编码把 mock 数据的构造逻辑收敛到工厂函数中通过参数控制字段变化价格、状态、数量等既减少重复又让“改一处影响多处”成为可能。更多数据构造模式可参考 test-data.md。七、相关参考基础模拟简单 API 模拟与测试套件结构参见 test-suite-structure.mdWebSocket 实时模拟SurfSense 依赖实时数据推送参见 websockets.md离线/网络故障测试完整的网络失败与恢复模式参见 error-testing.md 与 service-workers.mdSurfSense E2E 工程落地测试运行与三层防真实外呼机制surfsense_web/tests/README.md全局 Playwright 配置超时、trace、截图、项目依赖、webServersurfsense_web/playwright.config.ts路由伪造实战Composio OAuth 重定向拦截surfsense_web/tests/helpers/mocks/composio-oauth.ts确定性等待替代waitForTimeout的轮询工具surfsense_web/tests/helpers/waits/indexing.ts容器级网络隔离internal: true网络 哨兵代理docker/docker-compose.e2e.yml落地建议将本文的五类能力组合成一个渐进式策略——先用page.route拦截改造请求为不稳定接口引入 Pass-through Mock再对核心业务流程录制 HAR 作为回归基线接着用条件模拟补齐错误与重试分支最后用网络限速 Fixture 验证弱网与离线体验。在 SurfSense 这类“实时数据驱动”的全栈应用中网络层的确定性就是测试套件稳定性的基石。【免费下载链接】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),仅供参考