Playwright 组件测试:用 Story Gallery 模式替代独立组件测试运行时
组件测试的常规路径需要专门的组件测试运行器(如 @playwright/experimental-ct-react),它编译测试文件中的 JSX 并将其"序列化搬运"到浏览器。Playwright 在移除这套实验性运行时后,给出了一条更轻的替代路线:用普通 e2e 测试 + 一个由应用自带 dev server 托管的 Story Gallery 页面来测试 React / Vue 组件。本指南以仓库内 SKILL.md 为骨架,结合 Gallery 契约、框架细则与 mount fixture 源码,讲解如何搭建 gallery、编写 story 与 spec、驱动 mount/update/unmount,并实现类型安全与网络 Mock——读完你可以在自己的 Vite/非 Vite 项目中落地整套组件测试方案,或完成从 @playwright/experimental-ct-* 的平滑迁移。
核心概念:story、gallery 与内置 mount fixture
整套方案的三个支柱缺一不可:
- story(故事):一个"微型包装组件",把被测组件嵌入某个具体场景——写死的 props、mock 数据、全局 provider、以及"记录型"回调。story 与被测组件放在一起,命名形如
*.story.tsx(也支持.ts/.jsx/.js/.vue),每个具名导出就是一个 story。 - gallery(画廊):你自己实现并拥有的单个页面,按 gallery-spec.md 定义的契约暴露
window.mount(params)/window.unmount(),负责从 story 文件(例如用import.meta.glob)解析出目标 story 并渲染到#root。它是唯一与框架耦合的胶水层,没有现成模板可抄。 mountfixture:@playwright/test内置的测试 fixture。它在测试里驱动 gallery 的window.mount,返回指向 gallery 根节点(#root)的Locator。无需额外脚手架。
三条贯穿始终的原则(来自 SKILL.md):
- 组件需要的一切依赖都必须在 story 内部准备好——story 运行在浏览器里;
- 测试要断言的一切都必须能 透过页面观察到——DOM、URL、网络;
- 组件接收回调时,由 story 创建状态、接上回调、把状态记录进一个隐藏表单,测试再对记录值断言。
从 fixture 实现 可以印证这一契约的底层行为:mount 内部先 page.goto(baseURL) 导航到 gallery,再通过 page.evaluate 调用页面的 window.mount,随后返回 page.locator('#root'),并在其上附加两个扩展方法——update(newProps)(不导航、原地以新 props 重渲染同一 story)和 unmount()(调用页面的 window.unmount)。因此 SKILL.md 反复强调:查询必须从 component 这个根 Locator 出发(component.getByRole('button').click()),而不是 component.click()——因为 #root 下面才是组件本体。
前提:这项技能在仓库中的定位
SKILL.md 位于 packages/playwright-core/src/tools/skills/playwright-component-testing/,其元数据描述表明它用于"用 Playwright 隔离测试 React/Vue 组件"或"从 @playwright/experimental-ct-react / -vue 迁移"。迁移文档同时明确:这两个 CT 包已在 Playwright 1.63 中移除且不再发布,因此 gallery 模式是当前的官方演进方向。mount fixture、reuseContext worker 级复用选项都已并入 packages/playwright/src/index.ts(mount 见 502-528 行,reuseContext 选项见 459 行),是 @playwright/test 的原生能力而非附加包。
Setup Workflow:六步搭好组件测试
SKILL.md 给出从零起步的完整流程,核心是:先探测框架与打包器,决定 gallery 由谁托管。
第一步:判断框架与托管方式
- React vs Vue:决定后续参考的框架笔记与 story 示例形态。
- 应用跑在 Vite 上(存在
vite.config.*):gallery 直接由现有 dev server 在/playwright/gallery/index.html提供服务。Vite 会为项目根目录下的任意.html提供托管,应用的插件/别名/CSS 自动生效,且vite build会忽略该页面——无需额外 server。 - 其他情况(Next.js、webpack、没有 dev server):用一个独立的迷你 dev server(例如 Vite)专门托管 gallery 页,并把
baseURL指向它。这要求把vite与对应的框架插件加入 devDependencies。
第二步:按契约实现 gallery
在 <project>/playwright/gallery/ 实现页面,把请求的 story 渲染进 #root。建议从 spec 的 worked example 起步,结合 react.md / vue.md 中的框架笔记调整。要点:
- story 发现(
import.meta.glob)与框架挂载逻辑留在这里——这是唯一与框架耦合的胶水,保持它足够小; - 应用自身的全局 CSS 要用与 app 入口一致的方式导入。
第三步:配置 Playwright
在 playwright.config.ts 中加入(注意:配置里可能已有 projects/webServer,应合并而非替换):
projects: [
{
name: 'components',
testDir: './tests/components',
use: { ...devices['Desktop Chrome'], baseURL: 'http://localhost:5173/playwright/gallery/index.html', serviceWorkers: 'block', reuseContext: true },
},
],
webServer: {
command: 'npm run dev', // 或: npx vite --config playwright/vite.config.ts
url: 'http://localhost:5173/playwright/gallery/index.html', // 独立 server 时为: http://localhost:3100/playwright/gallery/index.html
reuseExistingServer: !process.env.CI,
},
三个关键选项的含义(结合 index.ts 源码理解):
- 端口必须与 dev server 一致;因为
mount会导航到baseURL,所以baseURL必须就是 gallery 的 URL; serviceWorkers: 'block':阻止应用自带 service worker 用缓存响应"盖住"你的page.route()mock;reuseContext: true:在一个 worker 内的多个测试间复用浏览器 context(旧组件测试运行器即如此),对组件测试套件是显著的提速。源码中它由_reuseContext夹具承接(见 index.ts 459-467 行),复用逻辑会跳过为每个测试新建 context 的开销。
第四至六步:写 story、写 spec、运行
- 参照 templates/react/Button.story.tsx(Vue 见 templates/vue/Button.story.ts),在现有组件旁写出第一个 story;
- 参照 templates/react/button.spec.ts,从
@playwright/test引入test/expect写出第一个 spec; - 运行
npx playwright test --project=components。浏览器打开http://localhost:5173/playwright/gallery/index.html即可肉眼浏览全部 story。
模板 spec 已经示范了最基础的三种断言形态——渲染正确文本、disabled 状态、点击回调副作用:
test('button click fires callback', async ({ mount }) => {
const component = await mount('components/Button/CountsClicks');
await component.getByRole('button').click();
await expect(component.getByTestId('click-count')).toHaveValue('1');
});
Gallery 契约详解
gallery-spec.md 是实现 gallery 时必须先读的入口文档。核心契约:
window.mount(params):params是{ story, props },来自测试侧mount(story, props)。把解析到的组件以props渲染进#root,返回一个 Promise——组件挂载完成后 resolve,失败(未知 story、渲染抛错)时 reject。reject 会表现为测试里await mount(...)抛错并带真实堆栈,不存在 HTTP 状态码或 DOM 属性之类的信令。- 跨调用复用根节点:
component.update(props)会以相同 story、新 props 不导航地再次调用window.mount。若你渲染进同一个 root/实例而非每次重建,框架会进行 reconcile,组件内部状态得以保留——这就是 CT 的update()语义。每次重建 root(或导航)都会重置状态,所以应当:首次挂载时创建 root,之后的每次调用都渲染进它;只有当 story(组件类型)改变时框架才自行重挂载。 window.mount就是你的 setup/teardown 钩子:它是浏览器侧对 CT 的beforeMount/afterMount的等价物——安装 provider 或插件、预置 store、在渲染前启动浏览器内 mock server、渲染后做收尾,全部放进这一个函数,按测试传入的story/props分支执行。没有单独的钩子注册表,"你拥有的那个函数就是钩子"。window.unmount():把当前 story 从#root卸载并返回 Promise,由component.unmount()调用。只在需要断言卸载/清理副作用时用——因为每次mount都重新导航,测试天然隔离。#root:渲染目标元素id="root"。mount返回#root本身的 Locator,story 可以自由渲染 Fragment(例如组件外加一个记录状态的隐藏表单)。
Story id 语法(推荐)
gallery 拥有解析权,mount 原样透传 id。推荐文法:
<src 下路径(去掉 .story.* 后缀)>/<ExportName>——例如src/components/Button.story.tsx的具名导出Primary→components/Button/Primary;- 任何唯一的尾部后缀也能解析:
Button/Primary; - 单文件组件 story(
Button.story.vue)本身就是一个 story,用路径直接寻址(其default导出):components/Button。
React worked example(状态保留版)
spec 中的实例用 import.meta.glob 静态发现 story 文件,flushSync 包裹渲染以便渲染错误能正确 reject 而非被吞掉:
// playwright/gallery/main.tsx
import { flushSync } from 'react-dom';
import { createRoot, type Root } from 'react-dom/client';
const stories = import.meta.glob('../../src/**/*.story.{tsx,jsx}');
const id = (f: string) => f.replace(/^(\.\.\/)+src\//, '').replace(/\.story\.\w+$/, '');
async function resolve(storyId: string) {
const sep = storyId.lastIndexOf('/');
const [path, name] = [storyId.slice(0, sep), storyId.slice(sep + 1)];
const file = Object.keys(stories).find(f => id(f) === path || id(f).endsWith('/' + path));
const mod = (file && await stories[file]()) as Record<string, any> | undefined;
return mod?.[name] ?? mod?.default;
}
const rootEl = document.getElementById('root')!;
let root: Root | undefined;
(window as any).mount = async ({ story, props }: { story: string, props?: Record<string, any> }) => {
const Story = await resolve(story);
if (!Story)
throw new Error(`Unknown story: ${story}`);
root ??= createRoot(rootEl); // 复用 root 使 update() 走 reconcile 并保留状态
// flushSync 让渲染错误 reject 该 promise,而不是被吞掉
flushSync(() => root!.render(<Story {...props} />));
};
(window as any).unmount = async () => {
root?.unmount();
root = undefined;
};
对应入口页面 playwright/gallery/index.html:
<!DOCTYPE html>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
注意 import.meta.glob 必须内联在此文件:Vite 对它做静态分析、相对于该文件解析,因此不能挪进共享/发布代码——这正是"gallery 归你所有"的原因。该示例是契约的演示而非可复制文件,务必为你的技术栈实现等价物。
Vue 变体(状态保留)
Vue 的 createApp(...).mount() 每次调用都会构建全新实例,所以要先挂载一个小的响应式 host 并更新其 refs——更新 refs 会原地重渲染,这正是跨 update() 保留状态的机制:
// playwright/gallery/main.ts
import { createApp, h, shallowRef, type App, type Component } from 'vue';
// resolve() 与 import.meta.glob 与 React 示例相同。
const story = shallowRef<Component | null>(null);
const props = shallowRef<Record<string, any>>({});
const host = { render: () => (story.value ? h(story.value, props.value) : null) };
let app: App | undefined;
(window as any).mount = async ({ story: id, props: next }: { story: string, props?: Record<string, any> }) => {
const resolved = await resolve(id);
if (!resolved)
throw new Error(`Unknown story: ${id}`);
story.value = resolved;
props.value = next ?? {};
if (!app) { // 只挂载一次;上面的 ref 更新会原地重渲染
app = createApp(host);
app.mount('#root');
}
};
(window as any).unmount = async () => {
app?.unmount();
app = undefined;
};
约定(Conventions)
- Story id 即路径语义:
src/下的路径去掉.story.*后缀,再加上导出名——src/components/Button.story.tsx导出Primary→components/Button/Primary;任何唯一后缀也可用:mount('Button/Primary')。.story.vue单文件组件即一个 story,仅凭其路径寻址(其default导出)。使用 gallery 类型(见 typing.md)后,id 会带包名前缀:acme-ui/components/Button/Primary。 - 一个场景一个导出:倾向新增 story 导出,而非给现有 story 参数化——story 是对组件各状态的"可 grep、可评审的文档"。
测试模式(Testing Patterns)
示例均以 React 给出;Vue 仅在 story 语法上有差异。
回调与事件:story 拥有状态并记录之
当组件接收回调时,在 story 内创建状态、把回调接上去、并把状态记录到组件旁的隐藏表单里。测试执行操作后对记录值断言:
export const Stateful = () => {
const [expanded, setExpanded] = useState(false);
return <>
<Expandable expanded={expanded} setExpanded={setExpanded} title="Title">Details</Expandable>
<form hidden><input data-testid="expanded" readOnly value={String(expanded)} /></form>
</>;
};
test('click should expand', async ({ mount }) => {
const component = await mount('components/Expandable/Stateful');
await component.locator('.codicon-chevron-right').click();
await expect(component.getByTestId('expanded')).toHaveValue('true');
});
这种写法的收益:整个场景留在浏览器里——没有回调编组(callback marshalling),story 本身充当文档,肉眼浏览 gallery 时记录的状态也可见。实践要点:
- 每个被观测值记录进各自独立的
data-testidinput;标量用String(...),负载用JSON.stringify(...); - 用
toHaveValue()断言——它是 web-first 断言,会自动重试直至状态落定; - 负向场景同理:执行操作后断言值没有变化。
参考模板 Button.story.tsx 中的 CountsClicks:状态用 useState 由事件处理器更新,因此不受 React StrictMode 的双调用影响。
逐测试 props(Per-test props)
当场景确实参数化(例如边界值扫描)时,把 props 作为 mount 的第二参数传入;gallery 将其作为 props 交给 story。props 保持为纯可序列化数据——回调属于 story 内部:
export const WithTitle = ({ title = 'Default' }: { title?: string }) =>
<Button title={title} />;
const component = await mount('components/Button/WithTitle', { title: 'Hello' });
props 类型检查有两条可选路径,详见 typing.md:以 story 类型作模板参数(mount<typeof WithTitle>('components/Button/WithTitle', { title: 'Hello' }),零配置),或用小 Vite 插件生成 gallery 类型让 id 本身被类型化(mount('acme-ui/components/Button/WithTitle', { title: 'Hello' }),带自动补全与重命名安全)。Vue story 还必须在运行时声明 props——见 react.md / vue.md 的 Typed props 小节。
update() 驱动 props 过渡
要测试组件对 prop 变化(且不重挂载、状态保留)的响应,调用 component.update(newProps)——它在现有 root 上以新 props 重渲染同一个 story:
const component = await mount('components/Counter/Default', { value: 1 });
await expect(component.getByTestId('value')).toHaveText('1');
await component.update({ value: 2 });
await expect(component.getByTestId('value')).toHaveText('2');
这要求 gallery 复用其 root/实例(见 gallery-spec.md);只要 story 不变,状态即可存活。实现上,fixture 里的 update 只是不导航地再次 page.evaluate 调用 window.mount(见 index.ts 522 行)。
单测试多状态
每次 mount() 都是全新导航,测试完全隔离,因此一个测试内挂载多个 story 代价很低:
await expect(await mount('Button/Primary')).toHaveScreenshot('primary.png');
await expect(await mount('Button/Disabled')).toHaveScreenshot('disabled.png');
做视觉对比时,对返回的 root Locator 截图(如上),而不是对整个页面截图,避免把浏览器 UI 边框也断言进去。
网络 Mock
按常规用 page.route()——务必在 mount() 之前注册,因为挂载会触发导航。配置里的 serviceWorkers: 'block' 能防止应用自己的 service worker 用缓存响应遮蔽这些路由。团队如果已有 MSW handler 库,也可以改为在 story 或 decorator 内部启动 MSW 的 setupWorker。
调试 story
在浏览器打开 gallery URL(即 baseURL),在 devtools console 里执行 await window.mount({ story: 'components/Button/Primary' })——这正是 mount fixture 做的事。未知 story 会让 window.mount reject,表现为测试的 mount() 抛错并带真实堆栈。若想不用 console 也能浏览,可给 gallery 加一个可选的索引页。
React 专项要点
react.md 在通用工作流之上补充了 React 细节:
- 文件布局:
playwright/gallery/(一个index.html+main.tsx),需要react与react-dom18+(用到createRoot);story 放src/**/*.story.tsx(glob 也收.story.jsx)。 - StrictMode:在 gallery 中把渲染的 story 包进
<React.StrictMode>,以对齐多数应用的渲染方式。开发构建里 StrictMode 会刻意双调用 render 与 effect——对用事件处理器里的状态更新来记录(如CountsClicks)的 story 无影响;若某个 story 在 StrictMode 下行为异常,那往往是组件的真实问题,只有当应用本身不用 StrictMode 时才应去掉该包裹。 - 全局 provider(theme/store/i18n/router):建一个共享 decorator 并在 story 里使用,让每个 story 只声明自己的场景:
// src/stories/decorators.tsx
export function AppScaffold({ children, route = '/' }: { children: React.ReactNode, route?: string }) {
return (
<ThemeProvider theme="light">
<MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
</ThemeProvider>
);
}
export const LoggedIn = () => (
<AppScaffold route="/profile/42">
<ProfilePage user={{ id: 42, name: 'Test User' }} />
</AppScaffold>
);
不要把它做进 gallery——放在 story 文件里能让包裹关系可见,也允许 story 选择不用。
- CSS:全局样式表在 gallery 入口导入(
main.tsx里import '../../src/index.css'),镜像 app 入口的做法;若 Tailwind 按路径扫描内容,确保*.story.tsx被覆盖。 - 数据获取:对带客户端对象的库(React Query、Apollo),在 story 或 decorator 内创建客户端,让每次导航都从全新状态开始。
Vue 专项要点
vue.md 说明:Vue 下 gallery 以 app = createApp(h(story, props)); app.mount('#root') 挂载、app.unmount() 卸载,需要 Vue 3;story 放在 src/**/*.story.{ts,js,vue}。
两种 story 写法:
- 渲染函数 story(
Button.story.ts)——一个文件多个场景,一个具名导出一个 story,用defineComponent+h(),不涉及 SFC 编译;参考 templates/vue/Button.story.ts。 - 单文件组件 story(
Button.primary.story.vue)——一文件一 story,可用完整模板语法(含插槽),通过去掉扩展名的路径寻址:mount('components/Button.primary')。当场景需要插槽或较复杂的模板时优先 SFC story:
<script setup lang="ts">
import { ref } from 'vue';
import Button from './Button.vue';
const clicks = ref(0);
</script>
<template>
<Button title="Submit" @click="clicks++" />
<form hidden><input data-testid="click-count" readonly :value="String(clicks)" /></form>
</template>
全局插件:依赖 Pinia / vue-router / i18n 等插件的应用,用 decorator 包装每个 story,为每个 story 创建全新实例(如 withStore(story) 里 createPinia());必须挂在 app 实例上的插件(app.use(...))则在 gallery 里 createApp(...) 之后立刻添加——相当于应用自己的引导过程。
Typed props:接收逐测试 props 的 story 要声明两次——setup 签名(提供类型)与 props 选项(让 Vue 当作 props 而非 attrs 投递):
export const WithTitle = defineComponent(
(props: { title?: string }) => () => h(Button, { title: props.title ?? 'Default' }),
{ props: ['title'] },
);
Options-API story(defineComponent({ props: {...} }))推断方式相同。.story.vue SFC story 只有在 setup 生成 SFC 类型(Volar/vue-tsc)时才能推断 prop 类型;否则直接传 props 类型:mount<{ title?: string }>('components/Button.primary', { title: 'Hello' })。
给 mount() 加类型:两套可选方案
typing.md 把 mount(storyId, props?) 的两条类型增强路径做了对比:
| 显式 story 类型 | Gallery 类型 | |
|---|---|---|
| 调用 | mount<typeof WithTitle>('components/Button/WithTitle', { title }) |
mount('acme-ui/components/Button/WithTitle', { title }) |
| 检查范围 | props 与 update() |
props、update() 以及 id 本身(自动补全、重命名安全) |
| 需要 | spec 里 import type { WithTitle } from './Button.story' |
由 gallery 插件维护的生成文件 stories.d.ts |
| id 文法 | gallery 能解析的任何后缀 | 完整 id,前缀包名 |
显式 story 类型零配置、任何项目可用;用 import type 确保 story(连同其中的 React/Vue 与 CSS import)永不加载进 Node 测试进程。函数组件、类组件与 defineComponent story 都能推断其 props。
Gallery 类型:@playwright/test 导出一个空的 interface Stories {}——当 story id 是 Stories 的键时,mount 按该条目给 props 定类型;任何其他字符串维持未类型化 props,因此可部分采纳。gallery 旁的小 Vite 插件生成 stories.d.ts,通过模块增广填充 Stories:
// playwright/gallery/stories.d.ts (生成)
type StoriesOf<Prefix extends string, Mod> = { [K in keyof Mod & string as `${Prefix}/${K}`]: Mod[K] };
declare module '@playwright/test' {
interface Stories extends
StoriesOf<'acme-ui/components/Button', typeof import('../../src/components/Button.story')>,
StoriesOf<'acme-ui/components/Expandable', typeof import('../../src/components/Expandable.story')> {}
}
export {};
每行对应一个 story 文件;导出名与 props 类型来自 story 模块本身,所以该文件只在 story 文件新增/删除/移动时才变化。spec 侧于是无需任何类型 import:
const component = await mount('acme-ui/components/Button/WithTitle', { title: 'Hello' });
// ^ 自动补全已注册 id ^ 对 WithTitle 的 props 做类型检查
用包名给 id 加命名空间
Stories 按 TypeScript program 合并。在共享同一 tsconfig 的 monorepo 里,两个包都注册 components/Button/Default 且 props 不同,会在 Stories 声明处报 TS2320: Interface 'Stories' cannot simultaneously extend types ...,即使 id 不同也会互相污染自动补全。因此从一开始就给每个 id 加包名前缀(<package>/<src 下路径>/<Export>),并在两处派生 id 的地方都从 package.json 读取包名,避免漂移:
// playwright/gallery/main.tsx
import packageJSON from '../../package.json';
const id = (f: string) => packageJSON.name + '/' + f.replace(/^(\.\.\/)+src\//, '').replace(/\.story\.\w+$/, '');
后缀解析在运行时仍可用(mount('Button/Primary') 照常渲染),只是只有完整带前缀的 id 才被类型化。
生成插件
插件与框架无关——它只负责列出 story 文件。核心逻辑是 buildStart 时生成、dev server 运行期间监听文件新增/删除时重新生成,且内容确定:
// playwright/gallery/storyTypes.ts
const storyFile = /\.story\.(tsx|jsx)$/;
export function storyTypes(options: { prefix: string, src: string, outFile: string }): Plugin {
const generate = () => {
const content = render(listStoryFiles(options.src), options);
if (fs.existsSync(options.outFile) && fs.readFileSync(options.outFile, 'utf8') === content)
return;
fs.writeFileSync(options.outFile, content);
};
return {
name: 'story-types',
buildStart() {
this.addWatchFile(options.src);
generate();
},
configureServer(server) {
const onFile = (file: string) => {
if (storyFile.test(file))
generate();
};
server.watcher.on('add', onFile);
server.watcher.on('unlink', onFile);
},
};
}
listStoryFiles 递归扫描 src、render 把每个文件渲染成一行 StoriesOf<'前缀/相对路径', typeof import('...')>。注册在任意一个托管 gallery 的 Vite server 上(应用的 vite.config.ts 或独立 playwright/vite.config.ts):
export default defineConfig({
plugins: [
react(),
storyTypes({
prefix: packageJSON.name,
src: path.resolve(__dirname, 'src'),
outFile: path.resolve(__dirname, 'playwright/gallery/stories.d.ts'),
}),
],
});
该文件在每次 dev server 启动与构建时重新生成,运行期间 story 文件增删也会触发;内容确定,所以请提交它——CI 或编辑器中的类型检查不能依赖运行中的 dev server。没有任何代码 import 它,需在项目 tsconfig 中显式列出("files": ["playwright/gallery/stories.d.ts"])以便 tsc 与编辑器加载。对 .story.vue 单文件组件,在 StoriesOf 中把 default 导出映射到裸路径(K extends 'default' ? Prefix : \{K}`),并依赖 vue-tsc提供 SFC 的类型;无法推断 props 的 story 会以unknown` props 呈现——它接受任何值。
决策点(Decision Points)
- Monorepo / 非
src布局:改掉 gallery 里的 glob 与 id 派生逻辑(gallery-spec.md),并按 typing.md 给 id 加包名前缀。 - 全局 provider(theme、i18n、store、router):在 gallery 旁建共享
decorator帮助函数,在 story 里包裹组件;参见 react.md / vue.md。
从 @playwright/experimental-ct-react / -vue 迁移
migration.md 是存量 CT 用户的完整迁移手册。CT 包会编译测试文件里的 JSX 并编组进浏览器;gallery 模式把场景移入 story 导出、让其原生运行在浏览器:结构(哪个组件、子节点、provider)加行为(状态与回调、记录进隐藏表单供测试断言)。纯数据 props 经由 mount(storyId, props) 传递;update() / unmount() 语义不变。
概念映射表(CT → Gallery):
@playwright/experimental-ct-* |
Gallery 模式 |
|---|---|
mount(<Button title="…" onClick={spy} />) |
Stateful story:story 提供 onClick,把效果记录进隐藏表单输入;测试断言 toHaveValue() |
| 测试传入的纯数据 props | 精神不变:mount(id, props) |
| 测试里的 JSX 子节点/插槽 | 无法跨进程——把每种组合烘进各自的 story 导出(Vue 用 .story.vue 承载插槽密集场景) |
component.update(<Button count={2} />) |
component.update({ count: 2 })——保留状态,需 gallery 复用 root |
component.unmount() |
component.unmount()——由 gallery 的 window.unmount() 支撑 |
playwright/index.ts 中的 beforeMount/afterMount |
gallery window.mount 的函数体(全局),或 story decorator(按 story) |
按测试变化的 hooksConfig |
Props:mount('App/Routing', { route: '/dashboard' })——由 story/decorator 解释 |
Node 侧的 router fixture / MSW handlers |
测试里 page.route(),或 story/decorator 内 MSW setupWorker |
playwright/index.html(样式、字体、主题) |
gallery 的 index.html / 入口模块 imports |
ctViteConfig、ctPort、ctTemplateDir、ctCacheDir |
已消失——gallery 走应用自己的 dev server;端口在 webServer + baseURL;位置固定 playwright/gallery/ |
来自 CT 包的 defineConfig |
普通 @playwright/test 的 defineConfig,配 baseURL = gallery URL、serviceWorkers: 'block'、reuseContext: true |
迁移步骤:
- 按 SKILL.md 搭好 gallery 与配置,旧 CT 项目保持运行直到最后一个 spec 迁移完毕;
- 逐个 CT spec 拆分每个
mount(<…/>):JSX 结构变成组件旁的 story 导出;纯数据 props 留在测试里作为mount第二参数;回调 spy 变成记录进隐藏表单的 story 状态。只变化数据 props 的调用点通常只需要一个展开它们的通用 story:export const Default = (props: ButtonProps) => <Button title="Submit" {...props} />; - 重写 spec:从
@playwright/test引入test/expect;mount(<X a={1}/>)→mount('X/Default', { a: 1 });update(<X a={2}/>)→update({ a: 2 });unmount()不变。mount返回 gallery 根节点 Locator——查询要收敛范围(component.getByRole('button').click());spy 断言改为对 story 记录状态的toHaveValue(); - 迁移
beforeMount钩子:应用级 setup 进 gallery 的window.mount;按测试的hooksConfig分支改成由 story 或 decorator 解释的 props; - 全部 spec 变绿后,从配置删除 CT 项目、去掉
@playwright/experimental-ct-*依赖、删除playwright/index.html、playwright/index.ts*与playwright/.cache,并解除 Playwright 版本锁(迁移期间需固定于 1.62,因为 CT 包在 1.63 移除后不再发布)。
坑点提醒:
- Story id 是字符串:重命名或移动 story 会在运行时而非编译期破坏 spec——且后缀匹配解析在重命名后可能静默命中另一个 story。Gallery 类型能把这些已注册 id 变成编译期检查。
- 逐测试 JSX 不复存在:任何按测试构建不同 JSX 树的做法(子节点矩阵、内联包装)都要改成"每种组合一个 story 导出"。
迁移前后对照:
// Before (CT)
import { test, expect } from '@playwright/experimental-ct-react';
import Button from '../src/components/Button';
test('click', async ({ mount }) => {
const messages: string[] = [];
const component = await mount(<Button title="Submit" onClick={data => messages.push(data)} />);
await component.click();
expect(messages).toEqual(['hello']);
});
// After: src/components/Button.story.tsx
import Button from './Button';
export const Default = (props: { onClick?: (data: string) => void }) =>
<Button title="Submit" {...props} />;
// After: src/components/Button.spec.ts
import { test, expect } from '@playwright/test';
test('click', async ({ mount }) => {
const messages: string[] = [];
const component = await mount('components/Button/Default', { onClick: (data: string) => messages.push(data) });
await component.click();
expect(messages).toEqual(['hello']);
});
需要说明的是:SKILL.md 与 gallery-spec 推荐"纯数据 props、回调留在 story"的强约定,但 fixture 实现向 page.evaluate 传入了 { exposeFunctions: true }(见 index.ts 505-511 行),因此函数值 props 具备被编组为浏览器可调用函数的底层能力,迁移后仍保留这种写法;把状态记录在浏览器侧仍是推荐路径,因为它同时获得可观察性与"story 即文档"的价值。
参考资料索引(SKILL.md References 全量展开)
- references/gallery-spec.md——gallery 端点契约,从它开始读;
- references/typing.md——
mount的可选类型层:显式 story 类型 vs 生成 gallery 类型(含 Vite 插件); - references/react.md——React 走查:provider、StrictMode、CSS;
- references/vue.md——Vue 走查:
.story.ts与.story.vue两种 story、插件; - references/migration.md——从
@playwright/experimental-ct-react/-vue迁移手册; - 可直接参考的模板:templates/react/Button.story.tsx、templates/react/button.spec.ts、templates/vue/Button.story.ts、templates/vue/button.spec.ts;
mountfixture 的源码实现:packages/playwright/src/index.ts(含update/unmount的#rootLocator 扩展)。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00