component-tests 是 anomalyco/opencode 仓库里用于编写、修改、调试或审查 Storybook 驱动 Playwright 组件测试的技能。其核心运行模型是:每个包测试套件启动一个 Storybook/Vite 开发服务器,所有 worker 共享该服务器;CI 使用 2 个 worker,并以 fullyParallel 让同一文件的测试在多个 worker 上乱序运行。测试没有后端,server、SDK 与 sync 模块均为页面内 mock;每个测试获得全新上下文,mount 是一次完整的 iframe 导航。
故事与 mount:故事应放在生产组件旁的 src/ 目录(ui、session-ui、app 或 gui-extensions),Storybook 仅索引 main.ts 中配置的 stories glob。故事 ID 优先取 meta id,否则为 kebab 化标题加“--”加 kebab 化导出名;ID 错误会导致 mount 等满 30 秒根等待后失败。mount(id, { args, globals }) 把参数写入 URL,但 Storybook 会丢弃未在 args/argTypes 声明的键,以及值不符合 [A-Za-z0-9 _-]*、数字、十六进制颜色或 rgb()/hsl() 的参数;控制台警告“Omitted potentially unsafe URL args.”是唯一线索。建议为每个参数提供带类型的默认值,对含标点的值在故事内通过场景式参数选取。globals 包括 theme、direction 和 locale,mount 还会设置 a11y.manual(调用方 globals 可覆盖)。传送门类 UI(对话框、菜单、抽屉)渲染在 #storybook-root 之外,应从 page 定位。服务器状态来自 viteFinal 的 resolve.alias 列表,指向 Storybook 的 mocks;应扩展 mock 而非新增服务器,并在 onCleanup 中恢复 mock 状态,测试每次 mount 会重置。mock 未应答的请求可用 page.route 处理(如 /api/fs/read、favicon 等外部 URL)。
fixture(按 URL 导入):当契约无法在故事中体现(如构造次数、卸载竞态、缓存状态)时使用。将 *.fixture.tsx 放在 component-tests/ 或被测源码旁,导出 mountX() 并渲染到带 data-testid 的宿主中;文件名不得以 .spec 或 .test 结尾。挂载分三步:先挂一个轻量故事以加载应用样式,然后在 page.evaluate 中导入 fixture 并调用,最后用 page.getByTestId 定位。fixture 在 preview 装饰器之外渲染,必须自带 providers。模块 URL 用 sourceURL(new URL("<相对路径带扩展名>", import.meta.url)) 构造,并写成字符串字面量;其他写法可能加载失败或产生第二个模块实例。通过 URL 导入的是真实文件,而 main.ts 别名得到的是 mock,需按契约选择。spec 中导入源码模块应使用 import type。异步顺序由 fixture 暴露的 deferred 控制,或使用 until 轮询页内状态,不要复制固定的 setTimeout 等待。
Vite 预打包与重载:viteFinal 移除 storybook:optimize-deps-plugin 并自行设置 optimizeDeps,条目包括四棵 src/ 下所有 *.stories.tsx 与 *.worker.ts、组件 spec 中以 ./ 或 ../ 字符串字面量命名并以 .ts/.tsx 结尾的模块,以及 preview.tsx;include 加入 Storybook 预览运行时。扫描遗漏的依赖在首次导入时才被发现,若改变已服务分块,Vite 会记录“✨ optimized dependencies changed. reloading”并向所有 worker 的所有页面发送全量重载,带过期 ?v= 的请求会得到 504。目标是零重载;冷运行两套件都不应打印“new dependencies optimized”或“optimized dependencies changed”。新代码应保持可被扫描覆盖:运行时构造的模块路径、模板字面量、缺扩展名、非 *.spec.ts 的 spec 都不会被收集;故事应命名为 *.stories.tsx,worker 命名为 *.worker.ts;仅对扫描无法到达的依赖添加 optimizeDeps.include。重载症状包括“page.evaluate: Execution context was destroyed”、动态导入模块失败、fixture 状态挂载后消失等。只有 mount() 能重载后幸存,因为它导航回同一 URL;page.evaluate 导入、已挂 fixture、先前点击状态和定位元素都会丢失。修复重载应从扫描入手,绝不依赖重试、超时或导入顺序。每当条目相关时应清缓存:Vite 的 getConfigHash 不覆盖 entries,有效 _metadata.json 会跳过扫描,热缓存不会扫描新故事或 fixture。解析应走包解析而非指向 node_modules 的路径(如 main.ts 去重 @tanstack/solid-query,而非别名进 app/node_modules)。
冷/热启动与 mount 成本:CI 始终冷启动,每个套件在独立 leg 的全新 runner 上运行。不要统计第一个测试:某次 CI 中每 worker 第一个测试 Linux 为 5.7-7.0 秒,Windows 为 14-15 秒;热测试中位数 Linux 上 app 1.5 秒、session-ui 2.0 秒,Windows 上 2.4-2.6 秒;热 mount 中位数 Linux 上 app 1.0 秒、session-ui 1.5 秒,Windows 上 1.7-1.9 秒,占典型测试大部分。本地 Playwright 会复用端口上应答者(默认 6006),这会掩盖冷成本与重载,多工作树时可能由别的检出服务你的测试;可设置 PLAYWRIGHT_STORYBOOK_PORT 或 PLAYWRIGHT_STORYBOOK_URL。Windows 上选择低于 49152 的端口。同一检出中两个 Storybook 会竞争 packages/storybook/node_modules/.cache/.../sb-vite,需使用单独工作树。保持 mount 数量少:每个测试一个场景,用 page.setViewportSize 原地调整视口。a11y.manual 使 addon-a11y 跳过 axe 运行,因此 preview.tsx 中 a11y.test 的更改不被这些测试强制执行;无障碍作为契约时应在 spec 中断言。CI 仅首次重试时录制视频,本地保留 retain-on-failure。
移动端、触摸与抽屉:在 story.describe 内使用 story.use({ hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 } }),这些是测试级选项,共享 worker 不变;用 matchMedia("(hover: none)") 证明模拟生效,并用 tap() 或 page.touchscreen.tap 交互。不要用 launchOptions(worker 级,会导致文件独占 worker)。低于 768px 时选择器在底部抽屉中打开;@corvu/drawer 0.2.4 在打开后最初帧请求关闭会丢失(焦点困在屏外、body 保持 pointer-events: none),根 package.json 的 patches/@corvu%2Fdrawer@0.2.4.patch 修复了它,测试无需在按 Escape 或点击外部前等待。composer.spec.ts:375-429 守护该补丁;升级 @corvu/drawer 会丢弃补丁,需重跑这些测试。测量或截图抽屉前,等待其完全进入视口且无 data-transitioning。卡住的覆盖层是产品缺陷,应修复产品并在页内守护,而非等待。
几何、平台与已知失败:Inter 的 @font-face 无 font-display,故事可能先用回退度量布局再偏移;测量前运行 await page.evaluate(() => document.fonts.ready.then(() => undefined))。非全屏时预览帧为 calc(100vh - 2rem),不要给故事根 100vh;高于视口的故事仍会滚动,应相对同一时刻捕获的容器测量,或用 parameters: { layout: "fullscreen" }。快捷键写成 ControlOrMeta+…,期望标签从 navigator.platform 推导。macOS 上 browser-pane-restore.spec.ts:123 在 ControlOrMeta+w 后失败而 CI(Control+W)通过,这不是你的回归。story.fail 每次运行都会等满 10 秒 expect 超时,应修复产品或让预期失败在状态稳定后立即检查。
推送前:在所属包中重复 CI 的 worker 数运行(本地重试已为 0),每次尝试都必须通过,例如 bun run test:components -- component-tests/<file>.spec.ts --workers=2 --repeat-each=10;用 bunx playwright show-report component-tests/playwright-report 读取时长并与 CI 中位数比较。通过 CDP Emulation.setCPUThrottlingRate 限速或在另一套件运行时重复,以演练负载下晚字体交换、菜单关闭后焦点恢复等竞态。每当新增故事、fixture、依赖、worker、惰性导入或更改 packages/storybook 时,从仓库根对每个套件各冷运行一次(如 rm -rf packages/storybook/node_modules/.cache 后以 PLAYWRIGHT_STORYBOOK_PORT=6216 运行并 grep 优化事件,grep 必须无输出;按优化器事件计数而非时长判断)。Windows 上检查平台键、标签与字体相关几何,Windows 组件 leg 是权威检查;不要在 Windows 跳过,也不要提高 config.ts 的超时、重试或 mount 等待。确认真实昂贵工作的测试用 story.setTimeout(...) 并注释说明。确认 CI 运行了组件 leg,因为推送到 dev 不运行 E2E。