• 简体中文
  • 扩展和维护 Test Runner

    @midscene/test 支持注册业务 Node(节点)、管理运行资源和定义执行生命周期。框架维护者可以使用这些能力,接入浏览器、Agent、外部工具和业务接口,打造面向团队的定制化测试底座。

    如果你想先了解整体设计,请阅读 Test Runner 概览

    快速开始

    下面通过一个简单的示例项目,展示如何快速搭建一个测试项目,并在其中切换浏览器 UA 语言。

    1. 安装依赖

    创建一个空项目,并安装 Test Runner 的核心依赖及驱动工具:

    pnpm add -D @midscene/test @midscene/web playwright

    注意:在使用 Midscene Agent 之前,请先参考 模型配置 妥善设置相应的模型环境变量(如 API Key)。

    2. 创建项目文件

    建议采用以下基本目录结构:

    team-test-runner/
    ├── cases/
    │   └── midscene.yaml
    └── midscene.config.ts

    3. 配置 Test Project 与注册 Node

    在项目根目录下创建 midscene.config.ts,这个配置文件负责注册可复用的 Node,并定义执行环境(Project):

    import { defineNode, z } from '@midscene/test';
    import {
      defineProjectSetup,
      defineTestProject,
    } from '@midscene/test/config';
    import { createMidsceneNodes } from '@midscene/test/midscene';
    import { createPlaywrightNodes } from '@midscene/test/playwright';
    import { PlaywrightAgent } from '@midscene/web/playwright';
    import { chromium, type Browser, type Page } from 'playwright';
    
    interface ProjectContext {
      browser: Browser;
      page: Page;
      agent?: PlaywrightAgent;
    }
    
    const geoInputSchema = z.strictObject({
      latitude: z.number().describe('模拟位置的纬度。'),
      longitude: z.number().describe('模拟位置的经度。'),
    });
    
    // 1. 注册自定义 Node:模拟地理位置 (GPS 定位)
    const mockLocation = defineNode<
      typeof geoInputSchema,
      unknown,
      ProjectContext
    >({
      name: 'browser.mockLocation',
      title: '模拟地理位置',
      description: '伪造浏览器的 GPS 地理位置定位。',
      inputSchema: geoInputSchema,
      async execute({ input, context }) {
        const browserContext = context.page.context();
        // 注入地理位置模拟权限并设置经纬度
        await browserContext.grantPermissions(['geolocation']);
        await browserContext.setGeolocation({
          latitude: input.latitude,
          longitude: input.longitude,
        });
      },
    });
    
    // 集成 Midscene 内置的 AI 能力节点(如 aiAct, aiAssert 等)
    const midsceneNodes = createMidsceneNodes<ProjectContext>({
      getAgent: ({ context }) => {
        context.agent ??= new PlaywrightAgent(context.page);
        return context.agent;
      },
      includeLaunch: false,
    });
    
    const playwrightNodes = createPlaywrightNodes<ProjectContext>({
      getPage: ({ context }) => context.page,
    });
    
    // 2. 声明浏览器环境的启动与清理
    const playwrightSetup = defineProjectSetup<ProjectContext>({
      name: 'playwright',
      platform: 'web',
      async setup({ onTeardown }) {
        const browser = await chromium.launch({ headless: true });
        onTeardown(() => browser.close());
        const browserContext = await browser.newContext();
        const page = await browserContext.newPage();
        return { browser, page };
      },
    });
    
    // 3. 导出项目配置
    export default defineTestProject<ProjectContext>({
      projects: [
        {
          name: 'chromium',
          platform: 'web',
          setup: playwrightSetup,
          files: { include: ['cases/**/*.{yaml,yml}'] },
        },
      ],
      nodes: [mockLocation, ...midsceneNodes, ...playwrightNodes],
    });

    4. 编写测试用例并运行

    创建 cases/midscene.yaml 文件:

    cases:
      - name: 模拟地理位置并展示特定地区门店
        tags: [smoke]
        steps:
          - browser.mockLocation:
              latitude: 35.6762
              longitude: 139.6503   # 模拟在东京 (Tokyo)
          - gotoUrl:
              url: https://yoursite.com/stores
          - aiAssert:
              prompt: 页面上成功加载并展示了“东京”或其临近区域的门店推荐列表
              message: 地理位置 Mock 未能正确生效

    在项目根目录下执行测试:

    pnpm exec midscene-test

    运行器会自动加载 midscene.config.ts,查找满足条件的测试用例并执行。

    注册自定义业务 Node

    通过 defineNode(),你可以将复杂的接口调用、数据库操作、清理任务或特定的浏览器交互封装为具名 Node。封装后,用例作者可以直接在测试用例中调用它们。

    基本业务 Node 示例

    以下示例展示了如何封装一个通过 HTTP 接口创建测试订单的 Node:

    import { defineNode, z } from '@midscene/test';
    
    const orderInputSchema = z.strictObject({
      sku: z.string().min(1).describe('要下单的商品 SKU。'),
      quantity: z.number().int().positive().describe('购买数量。'),
    });
    
    interface ProjectContext {
      apiBaseUrl: string;
    }
    
    const createOrder = defineNode<
      typeof orderInputSchema,
      { id: string },
      ProjectContext
    >({
      name: 'order.create',
      title: '创建订单',
      description: '通过测试接口创建一个商品订单。',
      inputSchema: orderInputSchema,
    
      async execute({ input, context, signal }) {
        const response = await fetch(`${context.apiBaseUrl}/test/orders`, {
          method: 'POST',
          headers: {
            'content-type': 'application/json',
          },
          body: JSON.stringify({
            sku: input.sku,
            quantity: input.quantity,
          }),
          signal,
        });
    
        if (!response.ok) {
          throw new Error(`创建订单失败:HTTP ${response.status}`);
        }
    
        const order = (await response.json()) as { id: string };
        return {
          summary: `已创建订单 ${order.id}`,
          data: order,
        };
      },
    });

    把 Node 加入到配置文件中的 nodes 数组后,用例作者就可以在用例中直接消费它:

    cases:
      - name: 下单流程测试
        steps:
          - order.create:
              sku: midscene-mug
              quantity: 1

    使用 Zod 声明输入与强校验

    inputSchema 是可选字段,我们强烈建议你声明此字段。

    1. 类型推导与校验:声明后,运行器会在进入 execute() 前自动执行 Zod 校验。若输入不匹配,将直接抛出 NodeInputValidationError 异常,并在编译期提供强类型推导(无需额外声明 TypeScript 接口)。
    2. 拒绝未知参数:推荐使用 z.strictObject()。若用例传入了多余的未知字段,运行器会及时拦截并报错。
    3. 说明书自动集成:字段上的 .describe() 信息会直接编译进自动生成的 Node 说明书,作为 AI Agent 或人类用例编写者的参考手册。
    const refundInputSchema = z.strictObject({
      orderId: z.string().min(1).describe('需要退款的订单 ID。'),
      reason: z.string().optional().describe('退款原因。'),
    });
    
    const refundOrder = defineNode({
      name: 'order.refund',
      description: '为已有订单发起退款。',
      inputSchema: refundInputSchema,
      async execute({ input }) {
        // input 会被推导为 { orderId: string; reason?: string }
        await refund(input.orderId, input.reason);
      },
    });

    Node 执行上下文环境

    execute(ctx) 接收的 ctx 包含以下常用字段:

    • input:从 YAML 传入并经过 Zod 校验后的业务参数。
    • $:由运行器控制的通用 Step 属性(如规范化后的 timeoutcontinue-on-error)。
    • signal:超时或运行取消时触发的 AbortSignal,建议在内部异步请求或长耗时任务中使用它以实现提前优雅退出。
    • context:在 defineProjectSetup() 中返回并共享的项目级运行时资源。
    • history:深度只读、JSON 兼容的已运行 Node 历史。AI Agent 节点会自动读取并利用此字段理解上下文。
    • onTeardown():注册当前 Node 所创建资源的清理函数,支持 attempt 级别或 Document 级,按 LIFO(后进先出)顺序执行。
    • scope:标记当前 Node 执行的上下文边界,值为 casedocument
    • casedocument:当前执行位置的详细运行信息。

    多节点协作与状态共享

    在实际业务测试中,多个 Node 常常需要共享上下文状态。例如,订单退款用例需要在 beforeEach 中创建订单并记录订单 ID,在 steps 中访问该 ID 进行退款,最后在 afterEach 中进行数据清理。

    通过在自定义 ProjectContext 中定义状态属性,可以轻松实现这种协作:

    import { defineNode, z } from '@midscene/test';
    import type { PlaywrightAgent } from '@midscene/web/playwright';
    import type { Page } from 'playwright';
    
    interface ProjectContext {
      agent: PlaywrightAgent;
      appBaseUrl: string;
      page: Page;
      orderId?: string; // 用于在 Node 之间共享测试状态
      orderService: {
        create(input: { status: 'paid' }): Promise<{ id: string }>;
        remove(orderId: string): Promise<void>;
      };
    }
    
    const emptyInputSchema = z.strictObject({});
    const prepareOrderInputSchema = z.strictObject({
      status: z.literal('paid').describe('待创建订单的状态。'),
    });
    
    const getOrderId = (context: ProjectContext) => {
      if (!context.orderId) {
        throw new Error('测试订单尚未创建。');
      }
      return context.orderId;
    };
    
    // 1. 准备订单环境
    const prepareOrder = defineNode<
      typeof prepareOrderInputSchema,
      { orderId: string },
      ProjectContext
    >({
      name: 'order.prepare',
      description: '调用订单服务创建测试订单。',
      inputSchema: prepareOrderInputSchema,
      async execute({ input, context }) {
        const order = await context.orderService.create(input);
        context.orderId = order.id; // 将 ID 保存至上下文
        return {
          summary: `已创建测试订单 ${order.id}`,
          data: { orderId: order.id },
        };
      },
    });
    
    // 2. 访问已保存的订单状态
    const openRefundPage = defineNode<
      typeof emptyInputSchema,
      unknown,
      ProjectContext
    >({
      name: 'browser.openRefundPage',
      description: '打开当前测试订单的退款页面。',
      inputSchema: emptyInputSchema,
      async execute({ context }) {
        const orderId = getOrderId(context);
        await context.page.goto(`${context.appBaseUrl}/orders/${orderId}/refund`);
      },
    });
    
    // 3. 业务环境清理
    const cleanupOrder = defineNode<
      typeof emptyInputSchema,
      unknown,
      ProjectContext
    >({
      name: 'order.cleanup',
      description: '删除当前测试订单。',
      inputSchema: emptyInputSchema,
      async execute({ context }) {
        const orderId = getOrderId(context);
        await context.orderService.remove(orderId);
        delete context.orderId; // 恢复上下文干净状态
      },
    });
    
    export const refundNodes = [prepareOrder, openRefundPage, cleanupOrder];

    集成 Midscene Agent

    @midscene/test/midscene 导出了六个系统内置 Node:aiActaiAssertrecordToReportlaunchwaitagent

    你可以通过调用 createMidsceneNodes() 并传入 getAgent 回调,将其方便地集成到你的测试运行器配置中:

    import { createMidsceneNodes } from '@midscene/test/midscene';
    
    const midsceneNodes = createMidsceneNodes<ProjectContext>({
      getAgent: ({ context }) => {
        context.agent ??= new PlaywrightAgent(context.page);
        return context.agent;
      },
      // Web 使用 gotoUrl,不注册兼容旧 Agent 的 launch Node。
      includeLaunch: false,
    });

    createMidsceneNodes() 保留 launch,用于兼容已有的 Agent 集成。Android 和 iOS 项目应使用各自平台预置提供的生命周期 Node,并在这里设置 includeLaunch: false, 避免重复注册 launch

    注册平台预置 Node

    Test Runner 通过独立入口发布各平台的预置 Node factory。Factory 使用 getter 获取 运行时资源,不要求 Project Context 使用固定字段名。

    Playwright factory 注册 gotoUrlsetCookiesclearCookiessetViewportSizeplaywright@midscene/test 的 optional peer dependency,使用该预置能力的项目需要安装它:

    pnpm add -D playwright

    然后创建预置 Node:

    import { createPlaywrightNodes } from '@midscene/test/playwright';
    
    const playwrightNodes = createPlaywrightNodes<ProjectContext>({
      getPage: ({ context }) => context.page,
      getBaseUrl: ({ context }) => context.baseUrl,
      getEnv: () => process.env,
    });

    setCookies 不允许在 YAML 中直接填写 Cookie value。Test Runner 会把 Node 输入保存到 运行结果和 workflow history。如果直接填写 Cookie,这些敏感信息也会被保存。

    请使用 cookiesEnvprofilestorageStatePath 引用 Cookie,三者必须选择一个。 Node 只在执行时读取实际的 Cookie,并将它直接传给 Playwright BrowserContext。Node 结果只记录引用名称和 Cookie 数量,不会记录 Cookie 的名称、value 和作用域。因此, Cookie 不会进入 Test Runner 的运行结果和 workflow history。

    环境变量可以包含 Cookie header、Cookie JSON 数组或 Playwright storage-state JSON。 相对的 storage-state 路径默认从当前工作目录解析。如果项目需要使用其他根目录,请配置 resolveStorageStatePath。引用方式只能避免 Cookie 进入 Test Runner 的持久化数据; 环境变量、profile 和 storage-state 文件本身仍需妥善保管。请勿将包含真实 Cookie 的 storage-state 文件提交到代码仓库。

    beforeEach:
      - clearCookies: {}
      - setCookies:
          cookiesEnv: E2E_COOKIES
          url: https://example.com
      - setViewportSize:
          width: 1440
          height: 900
      - gotoUrl:
          url: /chat
          waitUntil: domcontentloaded

    gotoUrl 遵循 Playwright 的导航语义。只要导航完成,HTTP 4xx 和 5xx response 也会作为成功的 Node 结果返回,并保留状态码,后续步骤可以继续检查错误页面。网络错误和 导航超时仍会让 Node 失败。

    Android 平台预置会注册 launchterminaterunAdbShell,并要求 Agent 同时提供这三个能力:

    import { createAndroidNodes } from '@midscene/test/android';
    
    const androidNodes = createAndroidNodes<ProjectContext>({
      getAgent: ({ context }) => context.agent,
    });
    beforeEach:
      - runAdbShell:
          command: pm clear com.example.app
      - launch:
          uri: com.example.app

    iOS 平台预置会注册 launchterminaterunWdaRequest,并要求 Agent 同时提供这三个能力:

    import { createIOSNodes } from '@midscene/test/ios';
    
    const iosNodes = createIOSNodes<ProjectContext>({
      getAgent: ({ context }) => context.agent,
    });
    steps:
      - launch:
          uri: com.example.app
      - runWdaRequest:
          method: GET
          endpoint: /status
      - terminate:
          uri: com.example.app

    runAdbShellrunWdaRequest 会在 Node 结果和 workflow history 中保留完整 response。Test Runner 只限制传给后续 Midscene Agent 的 history 内容:过大的 value 会变成有长度限制的预览,并标明原始字符数;当 history 总量过大时,优先保留最近的记录。 如果运行结果不需要完整输出,请直接在命令中进行过滤。

    launchgotoUrl 不互为 alias。launch 通过设备 Agent 启动 App、URL 或 URI; gotoUrl 在当前 Playwright Page 中导航,并提供 Web 专属的 baseUrl、生命周期和 HTTP response 语义。

    一键生成 Node 说明书

    为了让测试用例编写者(包括 AI Agent)清晰、直观地检索当前项目注册了哪些 Node 及其参数规范,运行器提供了 describe-nodes 描述工具。它会自动将 Node 上的 titledescription 和 Zod inputSchema 自动编译生成一份标准的 Markdown 说明书文档。

    执行以下命令直接生成团队专属说明书:

    pnpm exec midscene-test describe-nodes > midscene-nodes.md

    或者指定测试目录或专属配置文件:

    pnpm exec midscene-test describe-nodes ./e2e --config ./config/midscene.config.ts

    生成的说明书包含当前 Test Project 实际注册的全部 Node(包含 createMidsceneNodes() 返回的六个系统内置 Node),按名称排序。运行器会自动将 Zod inputSchema 转换为标准的 JSON Schema。

    配置和管理 Test Project

    defineTestProject() 是测试底座的核心配置入口,支持声明一个或多个 Execution Project:

    export default defineTestProject<ProjectContext>({
      projects: [
        {
          name: 'android-smoke',
          platform: 'android',
          setup: doraAndroidSetup,
          files: {
            include: ['cases/**/*.{yaml,yml}'],
            exclude: ['cases/**/*.draft.yaml'],
          },
          tags: { include: ['smoke'], exclude: ['manual'] },
          retry: 1,
          variables: { appUri: 'com.example.app' },
        },
      ],
      test: {
        maxConcurrency: 1, // 控制 active 的 Execution Project 的最大并发数
        bail: 0,           // 失败阈值拦截。配置为 > 0 时,达到失败用例数会自动停止新任务调度
        testTimeout: 120_000,
      },
      output: {
        reportDir: './midscene_run/report',
      },
      nodes: [createOrder, ...midsceneNodes],
    });

    关键配置策略

    1. 环境与资源隔离:每个 Execution Project 有其独立的 setup 运行环境(如启动单独的浏览器或绑定特定测试设备)。
    2. 多 Project 并发与生命周期槽test.maxConcurrency 参数控制同时活跃(Active)的 Project 数量(默认值为 1)。
      • 一个并发槽(slot)覆盖从 Project setup 开始到 teardown 完成的完整生命周期。
      • 单个 Project 内部,所有的工作流文档(Workflow Documents)、用例(Cases)和步骤(Steps)依然严格串行执行,以保证测试的确定性。
      • 当你需要同时驱动多台手机设备或多个浏览器实例时,应当创建多个不同的 projects 声明并增大并发数。
    3. 生命周期清理:使用 defineProjectSetup() 声明环境准备动作。利用 onTeardown() 注册销毁钩子,确保即便测试意外中断或运行中途出错,已获取的长生命周期资源也能按照后进先出的逆序被安全释放。

    编程式 API

    大多数团队只需使用项目配置、YAML 和 CLI。如果需要将运行器嵌入其他工具(如开发一个本地的 GUI 运行面板),可以使用以下导出的编程式 API:

    • loadTestProject():异步加载 midscene.config.ts 中的 TypeScript 项目配置(运行器不支持同步加载)。
    • runTestProject():异步发现、运行并汇总整个项目(从 @midscene/test/config 导出)。
    • CaseRunner / createCaseRunner():直接执行纯对象形式的单个用例(不含文件解析和生命周期控制)。
    • runWorkflowDocument():执行单个文档的完整生命周期及内部全部 Case。

    配置完成后,请继续阅读 编写和运行测试用例,熟悉具体的用例语法与参数。