自动化约 14 分钟
自动化
三种宿主(测试 root、浏览器页面、子进程)共用一套客户端,含定位器、鼠标与时钟控制。
用 testId 标记元素,然后像 Playwright 那样驱动它们。同一个客户端可在 vitest、浏览器页面内,以及针对子进程时使用。鼠标操作在三种宿主里都走普通的 GPUI 输入路径。
<div testId="sidebar-collapse" onClick={onCollapse}>‹</div>
<textarea testId="composer" value={draft} onChange={...} />
<div testId="send" onClick={onSend}>↑</div>
import { createTestRoot } from '@gpuix/react'
import { connectTest } from '@gpuix/react/automation'
import { ChatApp } from './chat'
const { render, renderer } = createTestRoot()
render(<ChatApp />)
const app = await connectTest(renderer)
await app.screenshot({ path: 'open.png' })
await app.clock.pause()
await app.getByTestId('sidebar-collapse').click()
await app.clock.fastForward(200)
await app.screenshot({ path: 'collapsed.png' })
await app.getByTestId('composer').fill('hello gpuix')
await app.getByTestId('send').click()
await app.screenshot({ path: 'sent.png' })
这就是聊天示例。真正的测试位于 examples/chat.test.tsx。
createTestRoot() browser render() launch({ command, args })
│ │ │
▼ ▼ ▼
connectTest(renderer) globalThis.gpuix child stdin / stdout
│ │ │
└─────────────────────────┴──► App / Locator ◄───────────┘
click, fill, query, clock
浏览器应用
每次浏览器渲染都会把自动化 App 安装为 globalThis.gpuix。在 render() 返回后它始终可用,无需设置标志或独立的传输层。
await page.evaluate(async () => {
await globalThis.gpuix
.getByTestId('sidebar-collapse')
.click()
await globalThis.gpuix
.getByTestId('composer')
.fill('hello from Playwriter')
await globalThis.gpuix.clock.pause()
await globalThis.gpuix.clock.fastForward(200)
})
浏览器全局对象支持定位器、输入、树与文本查询、边界、选区、滚动、焦点以及时钟控制。浏览器页面无法写入任意的本地截图路径,请用控制浏览器的工具来做:
await page.screenshot({ path: 'review/chat.png', scale: 'css' })
边界返回的是画布像素而非 CSS 像素,因为那是 GPUI 布局所用的坐标系。在 2x 屏幕上,坐标 x: 44 的 locator 对应 CSS x: 22。在把矩形交给浏览器工具之前要先做转换:
const scale = await page.evaluate(() => {
const canvas = document.querySelector('canvas')!
return canvas.width / canvas.clientWidth
})
const { bounds } = await page.evaluate(() =>
globalThis.gpuix.getByText('New Task').waitFor(),
)
await page.screenshot({
scale: 'css',
clip: {
x: bounds.x / scale,
y: bounds.y / scale,
width: bounds.width / scale,
height: bounds.height / scale,
},
})
不要为此去读 window.devicePixelRatio。自动化工具可能在 GPUI 已经确定画布尺寸之后覆盖了视口缩放因子,那时两者就会产生分歧。
定位器
| 调用 | 匹配 |
|---|---|
app.getByTestId('send') |
testId 属性 |
app.getByText('New chat') |
节点自身的文本 |
app.getByType('textarea') |
宿主元素类型 |
locator.getByText('...') |
另一个定位器的后代 |
click() 命中最后一次绘制边界的中心。fill(text) 替换获得焦点的编辑器内容。press('enter') 发送一个按键。waitFor() 会轮询直到恰好存在一个匹配。textContent() 返回节点自身及所有后代的文本,类似 DOM 的 textContent。
鼠标、滚轮与拖拽
| 调用 | 作用 |
|---|---|
locator.hover() |
把指针移动到中心,从而触发悬停样式与工具提示 |
locator.wheel(dx, dy) |
在中心上触发一次滚轮事件 |
locator.dragBy(dx, dy) |
在中心按下、移动、松开 |
locator.dragTo(target) |
同上,但结束于另一个定位器或一个 { x, y } 点 |
app.mouse.move / down / up / click |
窗口坐标系下的原始指针输入 |
app.mouse.wheel(target, dx, dy) |
在某个点或定位器上的滚轮事件 |
app.mouse.drag(from, to) |
在两个点、两个定位器或混合之间拖拽 |
拖拽发送的是插值的移动而非一次跳跃,因为吸附、实时预览以及按移动提交只有在指针真正移动时才会出现。传入 steps 控制步数,传入 offset 让按下位置偏离中心。
await app.getByTestId('clip-7').dragBy(120, 0, { steps: 6 })
await app.getByTestId('clip-7-trim-end').dragTo(app.getByTestId('clip-8'))
await app.mouse.drag({ x: 240, y: 500 }, { x: 700, y: 620 })
每个鼠标调用都接受与 press('cmd-a') 相同语法的 modifiers,因此 cmd-滚轮缩放、shift-点击范围选择,以及 alt-拖拽复制都可被测试:
await app.getByTestId('canvas').wheel(0, 120, { modifiers: 'cmd' })
await app.getByTestId('clip-8').click({ modifiers: 'shift' })
截图与时钟
app.screenshot({ path }) 把当前 GPU 帧写成 PNG。
app.clock.pause()、set(ms) 与 fastForward(ms) 会冻结原生动画时间。用它可以在已知时间戳处捕获侧边栏动画:
const startedAt = await app.clock.pause()
await app.getByTestId('sidebar-collapse').click()
await app.captureFrames('review/sidebar', [
startedAt,
startedAt + 100,
startedAt + 200,
])
实时应用
launch({ command, args }) 启动应用,并通过 stdin 以与 SSE data: 行相同的方式发送命令。应用仅在 stdin 是管道时才监听,因此普通的终端运行不受影响。没有 data: 前缀的行会被忽略,console.log 不会破坏消息。
import { launch } from '@gpuix/react/automation'
const app = await launch({
command: 'bun',
args: ['examples/chat.tsx'],
env: { GPUIX_BACKGROUND: '1' },
})
await app.getByTestId('composer').fill('hello')
await app.getByTestId('composer').press('enter')
await app.getByText('hello').waitFor()
await app.screenshot({ path: 'live.png' })
await app.close()
fill() 与 press() 经由实时 GPUI 窗口输入管线派发,因此原生 <input> 与 <textarea> 元素接收的是 GPUI 的键盘与 IME 处理,而非仅供测试的输入路径。
更多背景见后台启动与 agent 驱动。