Supabase Studio E2E 测试实践:Playwright 运行、选择器策略、竞态消除与 CI 调试指南
Supabase Studio E2E 测试实践Playwright 运行、选择器策略、竞态消除与 CI 调试指南【免费下载链接】supabaseThe Postgres development platform. Supabase gives you a dedicated Postgres database to build your web, mobile, and AI applications.项目地址: https://gitcode.com/GitHub_Trending/supa/supabase本文基于 Supabase 仓库中的 Skill 文档 SKILL.md 展开系统讲解在e2e/studio目录下编写和运行 Playwright 端到端测试的完整方法论包括测试的运行命令与环境自启动机制、健壮选择器的优先级排序、先挂等待器再触发动作的竞态消除模式、等待策略与测试清理规范以及 CI 冷启动与本地热状态的差异排查。读完本文你可以独立完成 Studio 的 E2E 测试编写、本地复现 CI 失败并定位 flaky 测试的根本原因。运行 E2E 测试所有测试都必须从e2e/studio目录发起cd e2e/studio pnpm run e2e其中e2e脚本定义在 e2e/studio/package.json实际执行playwright test依赖playwright/test^1.59.1。针对具体场景文档给出三类常用变体运行单个测试文件文件位于e2e/studio/features/*.spec.tscd e2e/studio pnpm run e2e -- features/cron-jobs.spec.ts用 grep 过滤测试名cd e2e/studio pnpm run e2e -- --grep test name patternUI 模式调试cd e2e/studio pnpm run e2e -- --ui从 e2e/studio/playwright.config.ts 可以看到这些命令背后的关键配置单测试超时120 * 1000毫秒expect断言超时20_000毫秒maxFailures: 3用于尽早中止失控的批次CI 环境下retries: 5自动重试、forbidOnly禁止提交test.only本地则不重试、不禁止fullyParallel: !env.IS_PLATFORM、workers: env.IS_PLATFORM ? 1 : 3——即自托管模式下 3 个 worker 并行平台模式下因 API 限流改为串行 1 workere2e/studio/README.md 中5 workers的描述相对当前配置已偏旧以源码中的3为准失败时保留视频与 tracevideo: retain-on-failure、trace: retain-on-failure这正是后文调试章节的基础。环境配置与容器自启动文档的核心承诺是自托管模式无需手动搭建环境。测试会通过 web server 配置自动拉起 Supabase 本地容器自托管模式IS_PLATFORMfalse自动启动容器并以 3 个 worker 并行执行平台模式IS_PLATFORMtrue则走串行 认证流程。这一自动启动由 e2e/studio/playwright.config.ts 中的createWebServerConfig()实现本地非 CI 环境下执行pnpm --workspace-root run e2e:setup:selfhosted监听端口默认8082WEB_SERVER_PORT可覆盖启动超时默认 10 分钟WEB_SERVER_TIMEOUT可覆盖并设置reuseExistingServer: true以复用已运行的 Studio 实例CI 上则由专门 job 启动容器Playwright 只执行e2e:setup:selfhosted:start-studio。环境变量的读取集中在 e2e/studio/env.config.ts它先用 dotenv 加载e2e/studio/.env.localoverride: true再暴露一组env字段变量默认值作用STUDIO_URLhttp://localhost:8082Studio 地址也是 PlaywrightbaseURLAPI_URLhttp://127.0.0.1:54321Supabase API 端点IS_PLATFORMfalse平台/自托管模式开关PROJECT_REF无fallbackdefault项目引用全局 setup 后写入EMAIL/PASSWORD无平台邮箱认证两者同时存在时启用认证GITHUB_USER/GITHUB_PASS/GITHUB_TOTP无GitHub OAuth TOTP 双因素认证ORG_SLUG/SUPA_REGION/SUPA_PAT/BRANCH_NAMEdefault/us-east-1/test/e2e-test-local平台测试专属其中AUTHENTICATION是推导值邮箱密码齐全、或 GitHub 三件套齐全时自动开启e2e/studio/features/_global.setup.ts 会在 setup 阶段依次执行清理 once-per-file 锁 → 检查 Studio 可达 → 检查 API 可达 → 创建/获取测试项目写入PROJECT_REF→ 按需登录登录产物写入 storage stateplaywright/.auth/user.json供 Features project 复用。自托管场景零前置依赖是日常开发调试测试的首选姿势。测试文件结构与自定义 test fixture测试统一放在e2e/studio/features/*.spec.ts当前仓库包含 table-editor、cron-jobs、logs、rls-policies、storage、sql-editor 等 29 个 spec 文件并且必须导入仓库自定义的 test 工具而不是裸 Playwrightimport { test } from ../utils/test.js从 e2e/studio/utils/test.ts 源码看它在playwright/test的base.extend上扩展了三个 fixtureenvSTUDIO_URL、refPROJECT_REF缺省default、apiUrlAPI_URL并重写了pagefixture——通过page.addInitScript在页面加载前预置若干 localStorage 项如队列操作横幅已关闭、服务条款已更新避免每次测试都要手动关掉打扰性 UI。这解释了文档中Test fixtures providepage,ref, and other helpers的具体含义拿到test后({ page, ref })解构即可直接使用不需要自己去process.env里翻变量。该文件还导出withSetupCleanup返回一个实现了Symbol.asyncDispose的可弃对象配合await using语法保证 setup/cleanup 无论测试成败都会执行——这是比afterAll更细粒度每条测试级的资源清理手段。健壮选择器的编写规范文档给出了明确的选择器优先级从最佳到最差getByRole 可访问名称——最健壮同时验证了可访问性page.getByRole(button, { name: Save }) page.getByRole(button, { name: Configure API privileges })getByTestId——稳定的显式测试钩子page.getByTestId(table-editor-side-panel)getByText精确匹配——适合唯一文本page.getByText(Data API access, { exact: true })CSSlocator——尽量少用更脆弱page.locator([data-stateopen])应当避免的模式XPath 选择器——对 DOM 变更极其脆弱// BAD locator(xpathancestor::div[contains(class, space-y)])locator(..)向上爬父级——结构一变即断// BAD element.locator(..).getByRole(button)在通用元素上做宽泛的filter({ hasText })——可能命中多个元素例如 popover 里可能有不止一个 combobox应缩小容器范围或更具体地过滤。给组件补可访问名称当被测组件缺少合适的 accessible name 时正确做法是在源码里补上而不是在测试里写脆弱选择器// In the React component Button aria-labelConfigure API privileges Settings / /Button测试侧即可page.getByRole(button, { name: Configure API privileges })收窄搜索范围把选择器限定到具体容器避免匹配到错误元素// Good - scoped to side panel const sidePanel page.getByTestId(table-editor-side-panel) const toggle sidePanel.getByRole(switch) // Good - find unique element, then scope from there const popover page.locator([data-radix-popper-content-wrapper]) const roleSection popover.getByText(Anonymous (anon), { exact: true })避免竞态等待器必须先于动作建立这是文档强调的最常见的 flaky 测试来源——在触发 UI 动作之前就要挂好 API 等待器。因为响应可能在 waiter 注册完成之前就返回了// ❌ Race condition — response may complete before waiter is set up await page.getByRole(button, { name: Save }).click() await waitForApiResponse(page, pg-meta, ref, query?keytable-create) // ✅ Waiter is ready before the action const apiPromise waitForApiResponse(page, pg-meta, ref, query?keytable-create) await page.getByRole(button, { name: Save }).click() await apiPromise同样适用于页面导航前const loadPromise waitForTableToLoad(page, ref) await page.goto(toUrl(/project/${ref}/editor?schemapublic)) await loadPromise若一个动作触发多个 API 调用用Promise.all等齐所有响应const createTablePromise waitForApiResponseWithTimeout(page, (r) r.url().includes(query?keytable-create) ) const tablesPromise waitForApiResponseWithTimeout(page, (r) r.url().includes(tables?include_columnstrue) ) await page.getByRole(button, { name: Save }).click() await Promise.all([createTablePromise, tablesPromise])从源码实现看e2e/studio/utils/wait-for-response.ts 中的waitForApiResponse内部委托给createApiResponseWaiter后者用buildUrlMatcher构建容错匹配器URL 必须同时包含basePath如pg-meta、项目 ref兼容default与 action 路径段且 action 中携带的 query 参数需逐项匹配它返回page.waitForResponse(matcher, { timeout: 30_000 }).then(() {})的 Promise——调用即注册监听不消耗 await这正是先挂后点能消除竞态的底层原因。此外该文件还封装了面向领域的快捷等待器均等待pg-meta上特定的 SQL 查询 keywaitForTableToLoad(page, ref, schema?)→query?keyentity-types-schema-waitForGridDataToLoad(page, ref)→query?keytable-rows-waitForDatabaseToLoad(page, ref, schema?)→query?keyproject:default-schema:schema-infinite_tables而 e2e/studio/utils/wait-for-response-with-timeout.ts 中的waitForApiResponseWithTimeout接受任意 URL matcher字符串/正则/函数默认 5 秒超时不抛错而是返回null适合可能不发生的旁路请求。等待策略原则Playwright 自带对元素可操作性的自动等待优先使用它而非手动 sleep。动态状态变化用expect.poll自带轮询重试await expect.poll(async () await page.getByLabel(View ${tableName}).count()).toBe(0)元素生命周期用waitForSelector指定 stateawait page.waitForSelector([data-testidside-panel], { state: detached })避免networkidle改用具体的 API 等待// ❌ Unreliable and slow await page.waitForLoadState(networkidle) // ✅ Specific API response await waitForApiResponse(page, pg-meta, ref, tables)waitForTimeout唯一可接受的用途是客户端防抖await page.getByRole(textbox).fill(search term) await page.waitForTimeout(300) // allow debounce避免waitForTimeout与force: true绝不用waitForTimeout等 UI 或网络永远等待某个具体目标// BAD await page.waitForTimeout(1000) // GOOD - wait for UI element await expect(page.getByText(Success)).toBeVisible() // GOOD - wait for API response const apiPromise waitForApiResponse(page, pg-meta, ref, query?keytable-create) await saveButton.click() await apiPromise // GOOD - wait for toast indicating operation complete await expect(page.getByText(Table created successfully)).toBeVisible({ timeout: 15000 })对隐藏元素不要用force: true硬点而是先把元素弄出来// BAD await menuButton.click({ force: true }) // GOOD - hover to reveal, then click await tableRow.hover() await expect(menuButton).toBeVisible() await menuButton.click()测试结构一次性 setup、toast 处理与清理withFileOnceSetup实现每文件一次的高成本 setup。在 3 worker 并行的自托管模式下同一文件的多个测试可能分布在不同 worker若各自建库建表既浪费又相互干扰。文档要求test.beforeAll(async ({ browser, ref }) { await withFileOnceSetup(import.meta.url, async () { const ctx await browser.newContext() const page await ctx.newPage() await deleteTestTables(page, ref) }) }) test.afterAll(async () { await releaseFileOnceCleanup(import.meta.url) })从 e2e/studio/utils/once-per-file.ts 源码看它是一套跨进程的文件锁协议以import.meta.url的绝对路径 SHA1 为 key在系统临时目录playwright-locks下建立每文件目录每个 worker 先写一个租约文件随后用fs.open(lockfile, wx)原子抢锁——抢到的执行 setup 并写setup.done.json完成标记抢不到的指数退避轮询直到完成标记出现默认 120 秒超时。releaseFileOnceCleanup则删除本 pid 的租约、抢 cleanup 锁后确认无剩余租约才删除完成标记保证最后一个离场的 worker负责清理。配套地_global.setup.ts 在每次运行开始时删除整个playwright-locks目录避免上一轮残留的锁导致新运行跳过 setup。交互前先关掉 toast——它们可能覆盖住按钮const dismissToastsIfAny async (page: Page) { const closeButtons page.getByRole(button, { name: Close toast }) const count await closeButtons.count() for (let i 0; i count; i) { await closeButtons.nth(i).click() } } await dismissToastsIfAny(page) await page.getByRole(button, { name: New table }).click()清理遵循先检查再删除以优雅处理已存在的状态const bucketRow page.getByRole(row).filter({ hasText: bucketName }) if ((await bucketRow.count()) 0) return // proceed with deletion修改过 localStorage 的测试事后要重置e2e/studio/utils/reset-local-storage.ts 中resetLocalStorage会删除dashboard-history-*与last-selected-schema-*等 keyimport { resetLocalStorage } from ../utils/reset-local-storage.js await resetLocalStorage(page, ref)断言规范断言必须带描述性消息失败时才有上下文// ❌ No context on failure await expect(page.getByRole(button, { name: Save })).toBeVisible() // ✅ Clear message on failure await expect( page.getByRole(button, { name: Save }), Save button should be visible after form is filled ).toBeVisible()慢操作显式加大超时await expect( page.getByText(Table ${tableName} is good to go!), Success toast should be visible after table creation ).toBeVisible({ timeout: 50000 })可复用 Helper 与 API Mocking可复用操作应抽取为领域 helper例如 e2e/studio/utils/storage-helpers.ts并复用现成的等待工具import { createApiResponseWaiter, waitForApiResponse, waitForGridDataToLoad, waitForTableToLoad, } from ../utils/wait-for-response.js剪贴板断言用expectClipboardValue而不是手动读取 硬编码 sleep// ❌ Brittle await page.evaluate(() navigator.clipboard.readText()) await page.waitForTimeout(500) // ✅ Uses Playwright auto-retries await expectClipboardValue({ page, value: expectedValue })其实现见 e2e/studio/utils/clipboard.ts内部用expect(...).toPass({ timeout })轮询剪贴板内容支持exact精确匹配与默认包含匹配注意 Playwright 上下文需声明permissions: [clipboard-read, clipboard-write]这一点已在 e2e/studio/playwright.config.ts 的use块中配置。API Mocking用page.route拦截并直接 fulfillawait page.route(*/**/logs.all*, async (route) { await route.fulfill({ body: JSON.stringify(mockAPILogs) }) })可选的 API 调用用 soft 等待——超时不抛错仅告警并可选择性地 fallback 等待后继续await waitForApiResponse(page, pg-meta, ref, optional-endpoint, { soft: true, fallbackWaitMs: 1000, })这与createApiResponseWaiter的Optionsmethod/timeout/soft/fallbackWaitMs一一对应。调试手段查看 tracecd e2e/studio pnpm exec playwright show-trace path-to-trace.zip查看 HTML 报告cd e2e/studio pnpm exec playwright show-report错误上下文文件保存在test-results/目录本地还会生成test-results/test-results.json见配置中的 json reporter。此外文档建议配合 Playwright MCP 工具在本地检查 UI 状态e2e/studio/README.md 补充了PWDEBUG1 pnpm run e2e -- --ui的入口调试方式。CI 与本地开发冷启动 vs 热状态两者的核心差异是冷启动cold start与热状态warm stateCI冷启动测试从空白数据库开始每次运行重置数据库、全新容器pg_cron等扩展默认未启用。本地pnpm dev:studio-local调试用的 dev server 长期运行数据库可能残留上次运行的状态扩展已启用、测试数据还在。由此产生的典型本地通过、CI 挂掉的冷启动 bug扩展未启用——必须在测试 setup 中显式启用并行测试争抢共享状态——用test.describe.configure({ mode: serial })串行化test.describe.configure({ mode: serial })定位器匹配错元素——页面结构因状态未建立而与预期不同。在本地复现 CI 行为直接pnpm run e2e时测试框架会自动重置数据库与 CI 的冷启动一致但若改用pnpm dev:studio-local Playwright MCP 调试要牢记 dev server 的状态与 CI 不同。CI 失败排查工作流文档给出的标准四步本地以冷启动方式运行单个文件pnpm run e2e -- features/file.spec.ts检查test-results/目录中的错误上下文trace、视频需要检查 UI 状态时启动pnpm dev:studio-local并配合 Playwright MCP 工具始终记住dev server 里看到的状态在 CI 里可能并不存在——任何本地才好使的修复都应回到第 1 步的冷启动验证。小结这套 E2E 方法论的要点可以归纳为用 e2e/studio/utils/test.ts 的自定义 fixture 代替裸 test选择器按 role testid 精确文本 CSS 的优先级书写缺可访问名称就去补aria-label所有网络等待先挂 waiter 再触发动作并落到 e2e/studio/utils/wait-for-response.ts 的具体 endpoint 上setup 用withFileOnceSetup保证每文件只跑一次断言带消息、慢操作带超时最后用本地冷启动 CI这条纪律去复现和验证修复。仓库内 e2e/studio/features 下的 29 个 spec 文件table-editor、cron-jobs、logs、rls-policies 等是这些模式可直接对照的活示例。【免费下载链接】supabaseThe Postgres development platform. Supabase gives you a dedicated Postgres database to build your web, mobile, and AI applications.项目地址: https://gitcode.com/GitHub_Trending/supa/supabase创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
上一篇/下一篇内容由系统自动关联
返回资讯列表 →