测试开发之路 AI 驱动 UI 自动化的实践心得 - 基于 Stagehand

孙高飞 · 2026年04月03日 · 334 次阅读

前言

上一篇主要介绍了以计算机视觉方案为主的 AI 驱动框架 -- midsence.js ,他需要传递一个多模态大模型。 而今天介绍的是另一个开源框架,基于单模态大模型的方案, 对比计算机视觉,更加节省 token。

一、Stagehand 是什么?

Stagehand 是 Browserbase 开源的 AI 驱动浏览器自动化框架。它的核心理念是:用自然语言描述操作意图,由 LLM 来理解页面并执行操作,而不是让你手写 CSS 选择器。

当然他也支持混合方案,既用户可以根据自己的需要来选择使用传统定位方式还是使用 AI 驱动定位。

核心 API

能力 说明
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() })
);

二、用 AI IDE 辅助编写测试用例

传统方式:手动打开浏览器 → 看 DOM 结构 → 手写选择器 → 写用例代码,流程割裂、效率低。

新方式:AI IDE 全程辅助,从需求到代码一气呵成。

工具链架构

AI IDE
    ↓ 理解需求意图
Skills (.codebuddy/rules.md 项目架构规范)
    ↓ 注入定位优先级、分层规则
MCP Playwright 工具
    ↓ 实时打开目标页面,抓取真实 DOM
生成符合规范的 TypeScript 测试代码

典型对话流程:

你:我要测试平台新建 Agent 模式应用的功能,
    点击"新建应用"按钮,弹窗里填写应用名称,选择 Agent 模式,
    点击"新建",验证应用出现在列表中。
    注意:弹窗中有多个 type=submit 的图标按钮,"新建"按钮需要用文字精确匹配。

这部分的交互与上一篇介绍 midsence 很像, 就不多过的赘述了。

三、四层分层架构设计

仍然推荐四层架构设计来完成 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,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 定位。 并且在失败的时候打印日志,以便后续工具修正传统定位方式。


从零搭建:完整操作手册

Step 1:初始化项目

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

Step 2:配置模型接入

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 阿里云
企业私有化 你的内网地址 模型名 适合内网测试环境

Step 3:配置 vitest

// 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 },               // 串行,避免浏览器冲突
  },
});

Step 4:编写分层代码

组件层(最底层,封装原子操作):

// 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 文件。 这两部分我认为是最重要的。

其实还有很多的与大模型进行交互的技巧,但这些东西我目前为止很难写成一个体系出来,这些就放到本周末的直播里跟大家说吧。感兴趣的同学也可以加入我得星球,里面有更多现成的提示词和模板:

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册