上一篇主要介绍了以计算机视觉方案为主的 AI 驱动框架 -- midsence.js ,他需要传递一个多模态大模型。 而今天介绍的是另一个开源框架,基于单模态大模型的方案, 对比计算机视觉,更加节省 token。
Stagehand 是 Browserbase 开源的 AI 驱动浏览器自动化框架。它的核心理念是:用自然语言描述操作意图,由 LLM 来理解页面并执行操作,而不是让你手写 CSS 选择器。
当然他也支持混合方案,既用户可以根据自己的需要来选择使用传统定位方式还是使用 AI 驱动定位。
| 能力 | 说明 |
|---|---|
stagehand.act(prompt) |
找到元素并执行操作(点击、输入等) |
stagehand.extract(prompt, schema) |
从页面提取结构化数据 |
page.locator(selector) |
传统 Playwright 定位(零 Token 消耗) |
page.type(text) |
键盘逐字输 |
const stagehand = createStagehand();
await stagehand.init();
// AI 驱动:自然语言描述操作
await stagehand.act('在用户名输入框中输入 "100000000"');
await stagehand.act("点击登录按钮");
// 传统定位:有稳定属性时优先用
await page.locator('input[name="loginName"]').fill("100000000");
await page.locator('button[type="submit"]').click();
// AI 提取:结构化提取页面信息
const result = await stagehand.extract(
"判断登录是否成功",
z.object({ success: z.boolean() })
);
传统方式:手动打开浏览器 → 看 DOM 结构 → 手写选择器 → 写用例代码,流程割裂、效率低。
新方式:AI IDE 全程辅助,从需求到代码一气呵成。
AI IDE
↓ 理解需求意图
Skills (.codebuddy/rules.md 项目架构规范)
↓ 注入定位优先级、分层规则
MCP Playwright 工具
↓ 实时打开目标页面,抓取真实 DOM
生成符合规范的 TypeScript 测试代码
典型对话流程:
你:我要测试平台新建 Agent 模式应用的功能,
点击"新建应用"按钮,弹窗里填写应用名称,选择 Agent 模式,
点击"新建",验证应用出现在列表中。
注意:弹窗中有多个 type=submit 的图标按钮,"新建"按钮需要用文字精确匹配。
仍然推荐四层架构设计来完成 ui 自动化测试:
src/
├── components/ # 【组件层】可复用 UI 控件原子操作
│ ├── BaseComponent.ts # 组件基类(持有 stagehand + page)
│ ├── InputComponent.ts # 输入框封装(AI 定位)
│ ├── ButtonComponent.ts # 按钮封装(AI 定位)
│ ├── AlertComponent.ts # 错误提示提取
│ └── NavComponent.ts # 导航操作
│
├── pages/ # 【页面层】单页面操作封装
│ ├── BasePage.ts # 页面基类(持有三个通用组件)
│ ├── LoginPage.ts # 登录页操作
│ ├── HomePage.ts # 主页(应用列表)
│ ├── CreateAppDialog.ts # 新建应用弹窗
│ └── ChatPanel.ts # 右侧对话调试面板
│
├── services/ # 【业务逻辑层】完整业务流程
│ ├── BaseService.ts # 服务基类
│ ├── AuthService.ts # 登录流程
│ └── AppService.ts # 创建应用 + 对话流程
│
├── fixtures/ # 【测试基础设施】
│ └── stagehand.fixture.ts # Stagehand 生命周期管理 + Cookie 注入
│
├── setup/ # 【全局初始化】
│ └── globalSetup.ts # 一次性登录,保存 auth-state.json
│
└── tests/ # 【用例层】测试用例 + 断言
├── login.test.ts # 登录功能用例
└── app.test.ts # 应用管理 + 对话用例
| 层 | 职责 | 禁止 |
|---|---|---|
| 组件层 | UI 控件原子操作封装 | 不得依赖 pages/services |
| 页面层 | 单页面操作组合,持有组件实例 | 不得跨页面,不得写 expect |
| 业务逻辑层 | 跨页面完整流程编排 | 不得直接操作 DOM |
| 用例层 | 业务编排 + 断言 | 不得直接 new XxxPage(),只调用 Service |
这是本项目最重要的经验总结。
不是所有定位都需要 AI,AI 定位有真实的 Token 成本和时间成本。
当元素有稳定的 HTML 属性时,永远优先用传统定位, 而当页面变化或其他原因导致传统定位失败时,再使用 AI 驱动定位进行兜底。这样最节省 token。比如下面这个核心函数:
export class CommunityPage extends BasePage {
readonly url = "https://testerhome.com/topics";
/**
* 容错定位:先用传统 CSS 选择器,失败后自动降级为 AI act
*/
private async withFallback(
selector: string,
action: (locator: ReturnType<typeof this.page.locator>) => Promise<void>,
aiFallback: string
): Promise<void> {
try {
const exists = await this.page.evaluate(
(sel: string) => !!document.querySelector(sel),
selector
);
if (!exists) throw new Error("element not found");
console.log(`[CommunityPage] CSS 定位成功: ${selector}`);
await action(this.page.locator(selector));
} catch {
console.warn(`[CommunityPage] CSS 定位失败 (${selector}),降级为 AI act: ${aiFallback}`);
await this.stagehand.act(aiFallback);
}
}
/**
* 点击筛选 Tab
*
* CSS 定位:ul.filter.nav-pills a[href*="<path>"]
* AI 降级:点击"<tabName>" 筛选标签
*/
async selectFilter(filter: TopicFilter): Promise<void> {
const path = FILTER_PATH_MAP[filter];
await this.withFallback(
`ul.filter.nav-pills a[href*="${path}"]`,
(loc) => loc.click(),
`点击帖子筛选标签"${filter}"`
);
await this.wait(2000);
}
再上面的逻辑里,我们先试用 css selector 定位控件(ul.filter.nav-pills a[href*="${path}"]),而当传统方式定位失败后,再使用 this.stagehand.act(aiFallback); 这样的方法进行 AI 定位。 并且在失败的时候打印日志,以便后续工具修正传统定位方式。
mkdir my-ui-test && cd my-ui-test
npm init -y
# Stagehand(含 Playwright 底层)
npm install @browserbasehq/stagehand
# LLM 客户端(用于 CustomOpenAIClient)
npm install openai zod
# 测试框架
npm install -D vitest tsx typescript @types/node @vitest/ui
在 src/config.ts 中配置大模型,Stagehand 通过 CustomOpenAIClient 支持任何兼容 OpenAI 接口的模型:
// src/config.ts
import { Stagehand, CustomOpenAIClient } from "@browserbasehq/stagehand";
import OpenAI from "openai";
export function createStagehand(options?: { headless?: boolean }) {
const openaiClient = new OpenAI({
apiKey: process.env.API_KEY || "your-api-key",
baseURL: process.env.BASE_URL || "https://api.openai.com/v1",
});
return new Stagehand({
env: "LOCAL",
localBrowserLaunchOptions: {
headless: options?.headless ?? false,
args: [
"--disable-web-security", // 微前端必须
"--no-sandbox",
"--disable-setuid-sandbox",
],
ignoreHTTPSErrors: true,
},
llmClient: new CustomOpenAIClient({
modelName: process.env.MODEL_NAME || "gpt-4o",
client: openaiClient,
}),
logger: (msg) => process.env.DEBUG && console.log(`[${msg.category}] ${msg.message}`),
});
}
支持的模型方案:
| 方案 | BASE_URL | MODEL_NAME | 说明 |
|---|---|---|---|
| OpenAI GPT-4o | https://api.openai.com/v1 |
gpt-4o |
官方,能力最强 |
| DeepSeek-v3 | https://api.deepseek.com/v1 |
deepseek-chat |
国内,性价比高 |
| 通义千问 | https://dashscope.aliyuncs.com/compatible-mode/v1 |
qwen-max |
阿里云 |
| 企业私有化 | 你的内网地址 | 模型名 | 适合内网测试环境 |
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globalSetup: ["./src/setup/globalSetup.ts"], // 全局一次性登录
testTimeout: 120_000, // AI 推理慢,超时要长
hookTimeout: 90_000,
sequence: { concurrent: false }, // 串行,避免浏览器冲突
},
});
组件层(最底层,封装原子操作):
// src/components/InputComponent.ts
export class InputComponent extends BaseComponent {
async fill(label: string, value: string) {
// 优先 AI 定位(input 通常无稳定 name)
await this.stagehand.act(`在${label}输入框中输入 "${value}"`);
}
}
页面层(单页面操作组合):
// src/pages/LoginPage.ts
export class LoginPage extends BasePage {
readonly url = `${ADP_BASE_URL}/portal/`;
async fillUsername(value: string) { await this.input.fill("用户名", value); }
async fillPassword(value: string) { await this.input.fill("密码", value); }
async fillCaptcha(value: string) { await this.input.fill("验证码", value); }
async clickLoginButton() { await this.button.click("登录"); }
async getErrorMessage() { return this.alert.getMessage(); }
}
服务层(完整业务流程):
// src/services/AuthService.ts
export class AuthService extends BaseService {
async login({ username, password, captcha }) {
const loginPage = new LoginPage(this.stagehand, this.page);
await loginPage.goto();
await loginPage.fillUsername(username);
await loginPage.fillPassword(password);
await loginPage.fillCaptcha(captcha);
await loginPage.clickLoginButton();
// AI 判断是否登录成功
const { isLoginPage } = await this.stagehand.extract(
"判断当前页面是否还是登录页",
z.object({ isLoginPage: z.boolean() })
);
const errorMessage = isLoginPage ? await loginPage.getErrorMessage() : undefined;
return { success: !isLoginPage, errorMessage };
}
}
整个过程不详细演示了, 其实都是可以用 AI IDE 来实现的,比如 cursor 和 codebuddy。
这里我主要分享一下核心的 skill 文件,或者说是 rules 文件的必要元素和模板。 应该说整个 UI 自动化项目中最重要的一个文件。
# ADP UI 自动化测试项目规范
> CodeBuddy 在生成代码时必须严格遵守以下规范。
---
## 一、元素定位策略:传统 CSS 优先,AI act 降级容错
**核心原则**:所有元素定位优先使用传统 CSS 选择器(零 Token、快速、稳定),**仅当 CSS 定位失败时**自动降级为 AI act(`stagehand.act()`)。
### 必须使用 `withFallback` 模式
每个页面层的元素操作方法都必须遵循以下容错模式:
private async withFallback(
selector: string,
action: (locator: ReturnType<typeof this.page.locator>) => Promise<void>,
aiFallback: string
): Promise<void> {
try {
const exists = await this.page.evaluate(
(sel: string) => !!document.querySelector(sel),
selector
);
if (!exists) throw new Error("element not found");
console.log(`[${this.constructor.name}] CSS 定位成功: ${selector}`);
await action(this.page.locator(selector));
} catch {
console.warn(`[${this.constructor.name}] CSS 定位失败 (${selector}),降级为 AI act: ${aiFallback}`);
await this.stagehand.act(aiFallback);
}
}
### 使用示例
// ✅ 正确:传统定位 + AI 容错
async fillUsername(value: string) {
await this.withFallback(
"input#user_login",
(loc) => loc.fill(value),
`在用户名输入框中输入 "${value}"`
);
}
// ❌ 禁止:直接使用 AI act(无传统定位尝试)
async fillUsername(value: string) {
await this.stagehand.act(`在用户名输入框中输入 "${value}"`);
}
// ❌ 禁止:直接使用传统定位(无 AI 容错)
async fillUsername(value: string) {
await this.page.locator("input#user_login").fill(value);
}
### CSS 选择器优先级
| 优先级 | 方式 | 示例 |
|---|---|---|
| 1 | id | `#user_login` |
| 2 | name | `input[name="q"]` |
| 3 | type + value | `input[type=radio][value="agent"]` |
| 4 | 语义 class | `input.v-input--default__input` |
| 5 | textContent 精确匹配 | `evaluate + textContent?.trim() === "新建"` |
### 日志规范
- CSS 定位成功时打印:`[类名] CSS 定位成功: <selector>`
- CSS 定位失败降级时打印:`[类名] CSS 定位失败 (<selector>),降级为 AI act: <指令>`
- 日志必须包含类名(使用 `this.constructor.name`),便于排查问题来源
---
## 二、四层分层架构(严格遵守)
src/
├── components/ # 【组件层】可复用 UI 控件原子操作
├── pages/ # 【页面层】单页面操作封装
├── services/ # 【服务层】跨页面完整业务流程
└── tests/ # 【用例层】测试用例 + 断言
### 2.1 组件层(components/)
**职责**:封装可复用的 UI 控件原子操作。前端组件化开发意味着同一个组件(如按钮、输入框、下拉选择器)会出现在多个页面上,定位方式相同,因此需要统一封装。
**规则**:
- 每个组件类继承 `BaseComponent`
- 只封装单个 UI 控件的操作(输入、点击、获取值等)
- **禁止**依赖 pages/ 或 services/
- **禁止**写 `expect` 断言
- 组件方法应使用 `withFallback` 容错模式(如果组件内部需要定位元素)
**文件命名**:`XxxComponent.ts`,如 `InputComponent.ts`、`ButtonComponent.ts`
// ✅ 正确示例
export class InputComponent extends BaseComponent {
async fill(label: string, value: string) {
await this.stagehand.act(`在 ${label} 输入框中输入 "${value}"`);
}
}
### 2.2 页面层(pages/)
**职责**:封装单个页面内的操作组合。每个页面类对应一个实际的 Web 页面。
**规则**:
- 每个页面类继承 `BasePage`
- 持有组件层实例(`InputComponent`、`ButtonComponent` 等),通过组件层操作 UI
- 页面自有的元素操作**必须使用 `withFallback` 容错模式**
- **禁止**跨页面操作(如页面 A 的方法操作页面 B 的 DOM)
- **禁止**写 `expect` 断言
- 必须定义 `readonly url` 属性
**文件命名**:`XxxPage.ts`,如 `LoginPage.ts`、`TesterHomePage.ts`
// ✅ 正确示例
export class TesterHomePage extends BasePage {
readonly url = "https://testerhome.com/account/sign_in";
async fillUsername(value: string) {
await this.withFallback(
"input#user_login",
(loc) => loc.fill(value),
`在用户名或邮箱输入框中输入 "${value}"`
);
}
}
### 2.3 服务层(services/)
**职责**:封装跨页面的完整业务流程。可能调用多个 Page 层的方法来完成一个完整的业务操作。
**规则**:
- 每个服务类继承 `BaseService`
- 内部创建和持有多个 Page 对象
- 对外提供语义清晰的业务方法(如 `login()`、`createApp()`)
- 需要默认参数的场景(如账号密码),在服务层提供默认值
- **禁止**直接操作 DOM(必须通过 Page 层)
- **禁止**写 `expect` 断言
**文件命名**:`XxxService.ts`,如 `AuthService.ts`、`AppService.ts`
// ✅ 正确示例
export class TesterHomeAuthService extends BaseService {
private readonly loginPage: TesterHomePage;
async login(credentials?: TesterHomeCredentials): Promise<TesterHomeLoginResult> {
const creds = { ...DEFAULT_CREDENTIALS, ...credentials };
await this.loginPage.goto();
await this.loginPage.fillUsername(creds.username!);
await this.loginPage.fillPassword(creds.password!);
await this.loginPage.clickLoginButton();
// ... 判断登录结果
}
}
### 2.4 用例层(tests/)
**职责**:编写测试用例,包含业务编排和断言。
**规则**:
- 使用 Vitest 的 `describe` + `test` 组织用例
- **只调用 Service 层**的方法,不直接 `new XxxPage()`
- 断言(`expect`)只在用例层写
- 每个 test 块必须有 JSDoc 注释说明步骤和预期
- 通过 `useStagehand()` 或 `useStagehandWithAuth()` fixture 获取上下文
**文件命名**:`xxx.test.ts`,如 `login.test.ts`、`testerhome.test.ts`
// ✅ 正确示例
test("登录后点击【社区】应进入社区页面", async () => {
const thAuth = new TesterHomeAuthService(ctx.stagehand, ctx.page);
const result = await thAuth.login();
expect(result.success).toBe(true);
// ...
});
// ❌ 禁止:用例层直接 new Page
test("xxx", async () => {
const page = new TesterHomePage(ctx.stagehand, ctx.page); // ❌
await page.fillUsername("xxx");
});
## 三、层间调用关系(严格单向依赖)
用例层 (tests/)
↓ 只调用
服务层 (services/)
↓ 只调用
页面层 (pages/)
↓ 只调用
组件层 (components/)
- **禁止**反向依赖(如组件层调用页面层)
- **禁止**跨层调用(如用例层直接调用组件层或页面层)
---
## 四、其他规范
### 浏览器配置
- 必须在 `config.ts` 的 `createStagehand()` 中统一配置浏览器参数
- 必须禁用 Chrome 密码管理器弹框(通过 `--user-data-dir` + Preferences)
- 微前端场景必须加 `--disable-web-security` 等参数
### 导出规范
- 每层目录下必须有 `index.ts` 统一导出
- 新增文件后必须更新对应的 `index.ts`
### 命名规范
- 组件类:`XxxComponent`(如 `InputComponent`)
- 页面类:`XxxPage`(如 `TesterHomePage`)
- 服务类:`XxxService`(如 `TesterHomeAuthService`)
- 测试文件:`xxx.test.ts`
- 接口类型以用途命名:`XxxCredentials`、`XxxResult`
### 超时配置
- Vitest `testTimeout`: 120_000(AI 推理较慢)
- Vitest `hookTimeout`: 90_000
- `withFallback` 中 CSS 检测超时:即时检测(evaluate)
- AI act 后等待页面响应:按业务场景设置(登录 3s、搜索 5s 等)
以上 rules 文件,可以作为一个模板,大家根据自己的需求修改后加入项目就可以。
本篇仍然是一个入门介绍,我把自己觉得重要的东西写了出来。 建议大家多参考我写的控件定位策略(就是先用传统定位,再用 AI 定位的逻辑),以及 rules 文件。 这两部分我认为是最重要的。
其实还有很多的与大模型进行交互的技巧,但这些东西我目前为止很难写成一个体系出来,这些就放到本周末的直播里跟大家说吧。感兴趣的同学也可以加入我得星球,里面有更多现成的提示词和模板:
