> For AI agents: the complete documentation index is available at /zh/llms.txt, the full documentation bundle is available at /zh/llms-full.txt.

# 开发自定义 Node

自定义 Node 用于将业务操作封装为可在 YAML 用例中直接调用的步骤。本文介绍如何定义与校验输入参数、在多个 Node 之间共享上下文数据，以及处理异步取消与资源清理。

关于平台环境、Agent 接入和运行参数配置，请参阅[配置测试工程](/zh/midscene-test/configuration.md)。

- [注册自定义业务 Node](#注册自定义业务-node)：定义业务操作、在 YAML 中调用，并通过生成 Node 说明书验证注册结果。
- [Node 执行参数](#node-执行参数)：区分业务输入参数（`input`）与框架级步骤控制参数（`$`）。
- [跨 Node 共享上下文](#跨-node-共享上下文)：在多个步骤之间创建、读取和清理共享测试数据。
- [进阶用法](#进阶用法)：返回结构化结果、处理错误与取消信号，以及管理资源生命周期。

## 注册自定义业务 Node

自定义 Node 从 YAML 步骤中接收参数并执行对应的 TypeScript 逻辑。下面以创建测试用户数据为例，演示如何将用户信息写入本地 JSON 文件（实际业务中可替换为调用后端接口或写入数据库）。

### 1. 定义并注册 Node

在 `midscene.config.ts` 中使用 `defineNode()` 定义节点，并将其加入 `nodes` 数组：

```ts filename=midscene.config.ts
import { randomUUID } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { defineNode, z } from '@midscene/test';
import { defineTestProject } from '@midscene/test/config';

const createUser = defineNode({
  name: 'user.create',
  description: '创建测试用户数据文件。',
  inputSchema: z.strictObject({
    name: z.string().min(1).describe('测试用户姓名。'),
    email: z.string().email().describe('测试用户邮箱。'),
  }),
  async execute({ input }) {
    const user = { id: randomUUID(), name: input.name, email: input.email };
    const filePath = `fixtures/users/${user.id}.json`;
    await mkdir('fixtures/users', { recursive: true });
    await writeFile(filePath, JSON.stringify(user, null, 2));
  },
});

export default defineTestProject({
  nodes: [createUser],
});
```

各字段的作用如下：

- `name`：在 YAML 中调用该操作时使用的名称。
- `inputSchema`（可选）：使用 Zod 定义输入参数结构。定义后，Midscene Test 会在调用 `execute()` 前自动校验参数——例如示例中的 `z.string().min(1)` 要求姓名非空，`.email()` 校验邮箱格式，`z.strictObject()` 拒绝未声明的多余字段。若参数校验不通过，框架会直接抛出 `NodeInputValidationError`。同时，TypeScript 会根据该 Schema 自动推导 `execute({ input })` 中 `input` 的类型，而 `.describe()` 中的说明文字则会输出到自动生成的 Node 说明书中。
- `execute({ input })`：接收校验通过的参数并执行具体逻辑。将定义好的 Node 加入配置的 `nodes` 数组后，即可在 YAML 用例中调用（扩展已有工程时，将其追加到原有的 `nodes` 数组即可）。

### 2. 在 YAML 中调用

创建 `cases/user.yaml` 并调用刚刚注册的 `user.create`：

```yaml filename=cases/user.yaml
cases:
  - name: 创建测试用户数据
    steps:
      - user.create:
          name: Alice
          email: alice@example.com
```

运行测试时，Midscene Test 会解析 YAML，找到名为 `user.create` 的 Node，并将 `{ name: "Alice", email: "alice@example.com" }` 作为 `input` 传给 `execute()`。

示例中使用 `async execute({ input })` 配合 `await` 等待文件写入完成；如果写入过程中抛出异常，该步骤即会标记为失败。

### 3. 生成 Node 说明书，验证注册结果

保存配置后，运行以下命令生成 Node 说明书：

```bash
pnpm exec midscene-test nodes
```

该命令会加载项目配置，并在当前目录生成 `midscene-node-spec.md`（若配置了 `projects`，则会为每个执行项目生成 `midscene-node-spec.<project-name>.md`）。打开生成的说明书，确认其中包含以下内容：

- 可用 Node 列表（Available Nodes）中包含 `user.create`。
- 节点详情中包含“创建测试用户数据文件。”这一功能说明。
- 包含 `name`、`email` 两个输入字段及其描述信息。

这一步仅用于验证 Node 是否成功注册、输入 Schema 是否可正常导出，不会实际执行 `execute()`。后续每当修改了 Node 定义或注册配置，都可以重新运行该命令更新说明书，供测试工程师和 AI Agent 查阅。详见[生成当前项目的 Node 说明书](/zh/midscene-test/writing.md#生成当前项目的-node-说明书)。

### 为 Node 声明字符串简写

如果某个 Node 只有一个必填的字符串参数，可以通过 `stringInputKey` 指定字符串简写映射的目标字段名：

```ts
const logMessage = defineNode({
  name: 'message.log',
  stringInputKey: 'message',
  inputSchema: z.strictObject({ message: z.string() }),
  execute({ input }) {
    return { summary: input.message };
  },
});
```

配置 `stringInputKey: 'message'` 后，在 YAML 中直接传入字符串即可映射到 `input.message`：

```yaml
steps:
  - message.log: Checkout started
```

由于字符串简写只能提供一个字段的值，若 `inputSchema` 中还包含其他没有默认值的必填字段，简写调用仍会因缺少字段而校验失败。省略 `stringInputKey` 或将其设为 `false` 时，该 Node 不支持字符串简写，必须使用键值对对象传参。

## Node 执行参数

Midscene Test 调用 `execute()` 时，会传入一个包含业务参数与运行上下文的参数对象，你可以通过 `execute({ input, $, context })` 按需解构所需的字段。

### 业务参数与步骤配置

在参数对象中，`input` 存放由 Node `inputSchema` 校验后的业务参数，而 `$` 存放由框架控制的步骤级配置（如超时时间、失败后是否继续执行）。

例如，在 YAML 中为 `user.create` 同时传入业务参数和 `$` 配置：

```yaml
steps:
  - user.create:
      name: Alice
      email: alice@example.com
      $:
        timeout: 30000
        continue-on-error: true
```

框架会自动将 `$` 从业务参数中剥离（因此 `inputSchema` 中无需声明 `$`），并将字段名转换为驼峰格式。在 `execute({ input, $ })` 中可以分别读取到：

```ts
input.name;        // 'Alice'
input.email;       // 'alice@example.com'
$.timeoutMs;       // 30000
$.continueOnError; // true
```

步骤超时与 `continue-on-error` 行为均由框架统一调度。注意，开启 `continue-on-error` 虽然允许当前阶段的后续步骤继续执行，但仍受[资源安全限制](/zh/midscene-test/execution.md#失败时如何执行)约束，且该步骤本身的失败依然会使整条用例最终标记为 `failed`。

### 可用的执行字段

`execute()` 接收的参数对象包含以下字段：

- `input`：从 YAML 传入并经过 Zod 校验后的业务参数。
- `$`：由 Midscene Test 控制的步骤级配置（包含规范化后的 `timeoutMs` 和 `continueOnError`）。
- `signal`：当步骤超时或整次运行被取消时触发的 `AbortSignal`，可在异步请求或长耗时循环中用于响应取消。
- `context`：当前运行环境的上下文对象（若配置了 `fileSetup`，则为 `fileSetup` 的返回值；否则为项目级 `setup` 的返回值）。
- `onTeardown()`：注册当前 Node 创建资源的清理回调（根据所在阶段自动绑定到单次用例尝试或整个文件级别，按后进先出顺序执行）。
- `scope`：标识当前 Node 所处的执行范围，值为 `'case'` 或 `'document'`。
- `case` / `document`：当前正在执行的用例或工作流文档的元信息。

其中，`context` 是跨步骤传递运行资源与业务状态的桥梁。下一节将通过完整示例演示如何利用 `context` 在多个 Node 之间共享数据。

## 跨 Node 共享上下文

每个 Node 的每次执行都是独立调用，框架不会把前一个步骤的返回值自动传给下一个步骤。当一条完整的测试链路需要由多个 Node 协作完成时（例如：先调用接口创建测试订单 → 打开该订单的退款页面 → 测试结束后删除该测试订单），可以通过读写共享的 `context` 对象来传递数据。

### 创建共享上下文

下面的示例使用 `fileSetup` 为每份 YAML 文件创建独立的 Browser、Page 和 `context` 对象（由于没有跨文件共享的资源，这里无需配置项目级 `setup`；如需在多个文件间共享同一个 Browser，请参阅[用 fileSetup 隔离资源](/zh/midscene-test/configuration.md#用-filesetup-隔离资源)）。

示例中的 `orderService` 代表你自己的测试数据服务（包含 `create` 和 `remove` 方法），实际使用时请将应用地址与服务实现替换为你的业务代码：

```ts filename=setup.ts
import { defineFileSetup } from '@midscene/test/config';
import { chromium, type Page } from 'playwright';
import { orderService } from './order-service';

export interface Context {
  appBaseUrl: string;
  page: Page;
  orderId?: string; // 用于在 Node 之间共享测试订单 ID
  orderService: {
    create(input: { status: 'paid' }): Promise<{ id: string }>;
    remove(orderId: string): Promise<void>;
  };
}

export const fileSetup = defineFileSetup<Context, undefined>({
  name: 'refund',
  async setup({ onTeardown }) {
    const browser = await chromium.launch();
    onTeardown(() => browser.close());
    const page = await browser.newPage();

    const context: Context = {
      page,
      appBaseUrl: 'https://yoursite.com',
      orderService,
    };
    return context;
  },
});
```

每份 YAML 文件都会获得一个独立的 `Context` 实例，其中的 `orderId` 字段将在后续步骤创建订单时写入。

### 在 Node 中写入数据并注册清理

接着在 `nodes.ts` 中定义 `order.prepare`：它调用 `context.orderService.create()` 创建测试订单，将生成的订单 ID 存入 `context.orderId`，并同时通过 `onTeardown()` 注册订单清理逻辑：

```ts filename=nodes.ts
import { defineNode, z } from '@midscene/test';
import type { Context } from './setup';

const emptyInputSchema = z.strictObject({});
const prepareOrderInputSchema = z.strictObject({
  status: z.literal('paid').describe('待创建订单的状态。'),
});

// 1. 创建测试订单并写入上下文
const prepareOrder = defineNode<
  typeof prepareOrderInputSchema,
  { orderId: string },
  Context
>({
  name: 'order.prepare',
  description: '调用订单服务创建测试订单。',
  inputSchema: prepareOrderInputSchema,
  async execute({ input, context, onTeardown }) {
    const order = await context.orderService.create(input);
    context.orderId = order.id; // 将订单 ID 保存至共享上下文
    onTeardown(async () => {
      await context.orderService.remove(order.id);
      delete context.orderId;
    });
    return {
      summary: `已创建测试订单 ${order.id}`,
      data: { orderId: order.id },
    };
  },
});
```

这里有两点值得注意：

1. **显式写入 `context`**：`execute()` 返回的 `data` 仅用于记录到测试报告中，不会自动合并到 `context`；必须显式执行 `context.orderId = order.id`，后续步骤才能从 `context` 中读到该值。
2. **创建与清理内聚**：创建订单后立即调用 `onTeardown()` 注册删除逻辑（闭包直接捕获本次生成的 `order.id`）。这样一来，订单的创建与清理完全封装在 `order.prepare` 内部，YAML 用例无需再手动编写 `afterEach` 清理步骤。

### 在后续 Node 中读取数据

接着定义 `browser.openRefundPage`：它从 `context.orderId` 读取刚刚创建的订单 ID，并拼接 URL 打开对应的退款页面（若 `orderId` 为空则直接抛错）：

```ts filename=nodes.ts
const getOrderId = (context: Context) => {
  if (!context.orderId) {
    throw new Error('测试订单尚未创建。');
  }
  return context.orderId;
};

// 2. 读取已保存的订单 ID 并打开页面
const openRefundPage = defineNode<
  typeof emptyInputSchema,
  unknown,
  Context
>({
  name: 'browser.openRefundPage',
  description: '打开当前测试订单的退款页面。',
  inputSchema: emptyInputSchema,
  async execute({ context }) {
    const orderId = getOrderId(context);
    await context.page.goto(`${context.appBaseUrl}/orders/${orderId}/refund`);
  },
});

export const refundNodes = [prepareOrder, openRefundPage];
```

在 `midscene.config.ts` 中将 `fileSetup` 与 `refundNodes` 一起导出：

```ts filename=midscene.config.ts
import { defineTestProject } from '@midscene/test/config';
import { refundNodes } from './nodes';
import { fileSetup, type Context } from './setup';

export default defineTestProject<Context, undefined>({
  fileSetup,
  nodes: refundNodes,
});
```

现在，在 YAML 用例中只需在 `beforeEach` 调用 `order.prepare`，随后在 `steps` 中调用 `browser.openRefundPage` 即可：

```yaml
beforeEach:
  - order.prepare:
      status: paid
cases:
  - name: 打开退款页面
    steps:
      - browser.openRefundPage: {}
```

在 `beforeEach`（或用例 `steps`）中通过 `onTeardown()` 注册的清理回调，会在当前用例的 `afterEach` 阶段结束后自动执行（即便 YAML 中未声明 `afterEach` 也会执行）。哪怕后续准备步骤或用例正文步骤失败，已注册的清理回调依然会运行；如果用例触发了重试，每次尝试都有各自独立的清理周期。

## 进阶用法

### 记录执行结果

在上面的示例中，`order.prepare` 返回了 `{ summary, data }`：

- `summary`（可选）：简短的执行摘要文本，用于在测试报告中展示。
- `data`（可选）：结构化的输出数据，会保存在运行结果文件中。

如果不需要在报告中记录额外输出，`execute()` 也可以不返回任何值。

### 异步操作、错误与取消

异步 Node 请使用 `async execute()` 并通过 `await` 等待异步任务完成；当业务校验或接口调用失败时，直接抛出 Error 即可使该步骤失败。

为了响应步骤超时或用户中断（如 Ctrl+C），Midscene Test 会在调用 `execute()` 时传入 `signal`（`AbortSignal`）：

- 调用支持取消的底层 API 时，请将 `signal` 透传进去，例如 `fetch(url, { signal })`。
- 执行长耗时轮询或循环时，可在每次迭代间调用 `signal.throwIfAborted()` 及时退出。

**标记项目级共享资源故障**：在开启文件并发且多个文件共享项目级资源（如共享同一个 Browser）时，如果 `fileSetup` 或某个 Node 确认该共享资源已损坏，必须显式抛出带有 `PROJECT_RESOURCE_ERROR` 错误码的 `WorkflowError`（从 `@midscene/test` 导入，也可放入错误对象的 `cause` 或 `AggregateError.errors` 中），框架才会立即停止该项目下其余文件的执行。由于框架无法仅凭浏览器关闭的报错文本推断该 Browser 是文件独占还是项目共享，未标记 `PROJECT_RESOURCE_ERROR` 的报错默认仅按单文件失败处理，详见[失败时如何执行](/zh/midscene-test/execution.md#失败时如何执行)。

### 资源的生命周期与清理

在 Node 内部通过 `onTeardown()` 注册的清理函数，其触发时机取决于该 Node 是在哪个阶段被调用的：

- **在 `beforeEach`、用例 `steps` 或 `afterEach` 中调用的 Node**：清理函数在**当次用例尝试（attempt）的 `afterEach` 结束后**运行；每次失败重试都有独立的清理周期。
- **在 `beforeAll` 或 `afterAll` 中调用的 Node**：清理函数在**当前 YAML 文件的 `afterAll` 结束后**运行。

同一范围内的多个清理函数按后进先出（LIFO）顺序依次执行。注意，注册清理函数仅用于释放你在 Node 中创建的资源（或收尾日志/报告），不会让框架自动重建 Agent 或重置缓存。

关于异常情况下的清理保护机制：

- **等待超时操作退出**：如果某个步骤因超时被取消、但底层异步操作尚未真正退出，框架会推迟执行清理函数，避免释放仍被占用的资源。
- **清理容错与超时**：单个清理回调抛错不会阻断后续清理回调的执行；若某个清理回调耗时过长，框架最多等待 `teardownTimeout`（默认 30 秒），超时后会将状态记录为 `pending` 并停止等待，而不会将其视为清理成功。
- **运行中断保护**：即使整次测试运行被外部中断（如收到 `SIGINT`），框架仍会尽力执行 `afterEach`、`afterAll` 以及已注册的 `onTeardown()` 清理函数。

关于超时与取消的完整规则，请参阅[超时与取消机制](/zh/midscene-test/execution.md#超时与取消机制)；项目级 Browser 与 Agent 的清理方式请参阅[使用 setup 创建和清理共享资源](/zh/midscene-test/configuration.md#使用-setup-创建和清理共享资源)。
