# Windup — full documentation > 自然语言 E2E 测试,确定性回放 —— LLM 只规划一次,回放无需它参与。每次回放 ~1s、$0。 This file concatenates the entire Windup documentation as clean markdown for LLM consumption. Individual pages are also available at https://windup.run/zh/docs/.md and mapped in https://windup.run/zh/llms.txt. --- # 快速开始 **自然语言 E2E 测试,确定性回放 —— LLM 只规划一次,回放无需它参与。** 用纯自然语言描述一个测试 —— *"以测试账户登录,把商品 X 加入购物车,结账并验证订单确认信息"* —— Windup 就会把它变成一份确定性的浏览器操作 JSON 计划。从第二次运行起,测试**以零 LLM 调用**回放:~1 秒、$0,结果稳定。 ```bash npm i -D windupjs # Chromium is provisioned automatically (one-time, machine-wide cache) npx windup init # 3 questions → windup.config.ts + example scenario npx windup scan # index your app's routes & elements from source code npx windup new "log in as admin and create an invoice" # LLM-assisted scenario authoring npx windup run checkout # 1st run: the LLM plans · every run after: ~1s replay, $0 ``` ## 环境要求 - **Node ≥ 20。** - 在 `.env.local` 或 `.env` 中为规划器 LLM 准备一个 **API 密钥**(`.env.local` 优先 —— 当你的 `.env` 已提交到仓库时用它):Google(默认)用 `GOOGLE_GENERATIVE_AI_API_KEY`,OpenAI 用 `OPENAI_API_KEY`。 密钥仅用于规划;缓存回放从不调用 LLM。若要使用已有的 Chrome 而非自动下载的 Chromium,设置 `CHROME_PATH`;若要完全跳过下载,设置 `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1`。 ## 五分钟上手 在一个全新项目上的完整工作流,以及你应当看到的结果: ```bash # 1. Install — Chromium is provisioned automatically npm i -D windupjs # 2. Initialize — 3 questions (base URL, model, scenarios dir) npx windup init # → windup.config.ts + e2e/scenarios/ + .windup/ (gitignored) # 3. Index your app from source — before anything ever runs npx windup scan # scan complete (full): framework=react-router routes=106 elements=1125 # The site map now knows your real routes and selectors; the planner # will use them instead of guessing. Re-run after big changes # (windup scan --update re-indexes only files changed since, via git). # 4. Register test credentials once — values never touch git npx windup secret set admin # hidden prompts → .env.local + mapping # 5. Author a scenario from a rough instruction npx windup new "log in with the admin account and create an invoice for ACME" # → e2e/scenarios/create-invoice-acme.json — precise task grounded in # your real screens, account referenced by name, final verification # 6. First run — the LLM plans once (~3s, ~$0.002) npx windup run create-invoice-acme # PASS create-invoice-acme cache=miss llm_calls=1 ... cost=$0.0024 # 7. Every run after — deterministic replay, zero LLM npx windup run create-invoice-acme # PASS create-invoice-acme cache=hit llm_calls=0 total=600ms cost=$0 # 8. Read results like a human, ship reports to CI npx windup run --all --summary --reporter html npx windup costs # AI spend: totals, per provider/model ``` 如果某次运行在应用变更后失败,缓存的计划会失效,并在下一次运行时自动重新规划 —— **你编辑的是场景,而非选择器。** --- # 工作原理 ``` natural-language task ──▶ planner (LLM, 1 call) ──▶ JSON action plan │ trajectory cache ◀── cheap verification ◀── deterministic executor │ └──▶ subsequent runs: zero LLM, ~1s, $0 ``` 最昂贵的部分 —— 弄清楚该执行哪些浏览器操作 —— 只发生一次,并被转化为缓存的、可验证的数据。 - **计划是数据,而非代码** —— 经过 schema 校验的 JSON;没有生成的脚本,没有条件分支。 - **廉价验证** —— 每个操作之后检查 DOM/URL 后置条件。验证失败会使缓存的计划失效,并触发一次自动重新规划。 - **站点地图** —— 每次执行都会补充一张页面与跳转的图;`windup scan` 在首次运行前就直接从你的源代码为这张图播种,因此规划器使用的是你应用的*真实*选择器,而不是靠猜。 - **片段(Fragments)** —— 经过验证的操作块(例如登录),规划器通过 `{ "type": "use" }` 组合它们,而非重新生成。 - **零硬编码的站点知识** —— 引擎懂的是各类框架和 Web 平台,从不懂*你的*站点。所有站点知识都作为输入(场景、配置、清单)到来,或在运行时被发现。 ## 为什么选 Windup 手写脚本运行成本低,但维护成本高。每次运行都调用 AI 的智能体易于编写,但速度慢且不确定。Windup 各取二者之长。 | | 手写脚本 | 每次运行都用 AI 智能体 | **Windup** | |---|---|---|---| | 编写方式 | 手写代码 + 选择器 | 纯自然语言 | 纯自然语言 | | 运行成本 | $0 | **每次**运行都调用 LLM | 仅**首次**运行调用 LLM | | 运行速度 | 快 | 慢(模型在回路中) | ~1s 回放 | | 确定性 | 高 | 低 —— 每次即兴发挥 | 高 —— 每次回放都是同一份计划 | | 应用发生变化 | 你去修脚本 | 可能悄无声息地做别的事 | 验证失败 → 自动重新规划 | 想深入了解运行机制 —— 模块边界、数据格式、成本与安全态势 —— 参见[架构与规范](/zh/docs/architecture)。 --- # 测试场景 一个场景就是位于你场景目录(默认 `e2e/scenarios/`)中的一个 JSON 文件: ```json { "scenario_id": "checkout", "start_url": "/", "task": "Log in as the qa account, add 'Backpack' to the cart, check out and verify the order confirmation message appears.", "hints": ["Optional site-specific tips for the planner. Delete if not needed."] } ``` - `start_url` 是**可选的**(默认为 `/`),且应保持与环境无关:只是一个路径,相对于生效的 base URL 解析。 - 在任务末尾写上**要验证什么** —— 那会成为计划的最终后置条件。 - 切勿把密钥放进任务里。请按名称引用项目清单中的账户(参见[测试凭据](/zh/docs/credentials));计划会使用 `value_ref: "ENV:VAR"`,真实值仅在运行时解析,从不缓存。 ## 场景依赖(`depends_on`) 流程很少从零开始 —— 创建一个银行账户需要先登录。声明前置条件,每个场景就能保持小巧、聚焦,并可单独缓存: ```json { "scenario_id": "create-bank-account", "depends_on": ["login"], "task": "Already on the dashboard, open Settings > Bank accounts, create an account named 'Inter' and verify it appears in the list." } ``` - 依赖在**同一浏览器会话中**按顺序运行,各自拥有自己的缓存 —— 一个热套件会以零 LLM 调用回放整条链路。 - 在没有 `start_url` 时,被依赖的场景会**从上一个依赖结束的地方继续** —— 首次规划时 LLM 看到的是那个真实页面(登录后的仪表盘),而不是盲目规划。 - 链式依赖可用(`login` → `select-company` → `create-account`),环依赖会被拒绝,而失败的依赖会以 `dependency` 类别让本次运行失败,且发生在场景本身开始之前。 - 每个依赖都保留自己的自愈能力:如果它缓存的计划失效,它会重新规划并重新缓存 —— 依赖它的场景自动受益。 - 编辑场景的 `task` 会使其缓存的计划失效(重写过的测试就是另一个测试)。 `windup new` 以两种方式处理依赖:`--depends-on login` 显式声明它们,而**编写用的 LLM 也会自行建议依赖** —— 它会看到每个已有场景(id + task),当指令预设了其中某个场景所产生的状态("已经登录……")时,就自动产出 `depends_on`(会机械地与真实的场景 id 过滤比对 —— 绝不凭空捏造)。 ## 用 `windup new` 编写 你不必手写详尽的任务。给 `windup new` 一句粗略的指令,LLM 就充当测试编写者 —— 它会利用**站点地图**(来自 `windup scan` 和历史运行的真实页面、菜单和元素)以及**项目清单**(按名称引用的账户,从不写明字面凭据),把它改写成一个精确、可验证的场景: ```bash npx windup new "log in with the qa user, add the backpack to the cart and check out" # → e2e/scenarios/purchase-backpack-qa.json — real screen names, concrete fake # form data, account referenced as "the qa account", explicit final verification ``` 它会生成 `scenario_id`,从已知路由中挑选 `start_url`(回退到 `/` —— 它从不凭空捏造路径),并在有帮助时从地图中加入选择器提示。加上 **`--validate`**,它就会运行所生成的场景,若失败则根据失败原因加以改进并重试(最多 3 次尝试)—— 你拿回的是一个*已经通过过一次*、缓存已预热的场景: ```bash npx windup new "log in and create a cost center named Marketing" --validate # attempt 1: FAIL — element button:has-text('Save') not visible # attempt 2: PASSED # ✓ validated in 2 attempts — the plan is cached ``` **指令中的凭据绝不会落入场景文件**:它们会被自动注册为一个具名账户(值存入 `.env.local`,映射存入 `windup.credentials.json`),任务引用的是该账户 —— 参见[测试凭据](/zh/docs/credentials)。 标志:`--id `、`--force`(覆盖)、`--depends-on `、`--llm `。输出是一个**供你审阅、编辑并提交**的文件 —— 编写是被辅助的,测试仍归你所有。一次 LLM 调用(~$0.001),记录在 `windup costs` 账本的 `authoring` 项下。 --- # 测试凭据 凭据从不存在于场景文件、计划、缓存或 git 中 —— 只有**引用**。值留在 `.env.local`(已加入 gitignore)或 CI 密钥中;账户 → ENV 名称的映射存在 `windup.credentials.json` 中(已提交 —— 它不含任何值),并自动合并进项目清单。 ```bash npx windup secret set admin # hidden interactive prompts → .env.local + mapping npx windup secret list # accounts + whether each ENV is set (never prints values) ``` 任务随后按名称引用账户 —— *"以 admin 账户登录"* —— 计划使用 `value_ref: "ENV:WINDUP_ADMIN_PASSWORD"`,仅在执行时解析。 `windup new` 会自动完成这件事:指令中输入的凭据会被检测、注册并清除 —— 生成的场景提到的是账户,绝非那些值。在 CI 中,把相同的变量名定义为流水线密钥即可。 --- # 环境(dev / staging / CI) 起始 URL 的 origin 按以下优先级顺序确定: 1. `--base-url` 标志 2. `WINDUP_BASE_URL` 环境变量 3. `windup.config.ts` 中的 `baseUrl` 4. 场景中一个绝对的 `start_url` 显式覆盖会对绝对的场景 URL 也进行重定基(路径和查询串会被保留)。 计划缓存**可跨环境移植**:缓存标识使用起始 URL 的*路径*,而非主机/端口。针对 `localhost:8080` 生成的计划,可在 staging 或 CI 上以零 LLM 调用回放。 ```bash npx windup run checkout --base-url https://staging.example.com WINDUP_BASE_URL=http://localhost:8080 npx windup run --all ``` --- # LLM 提供商 规划器与提供商无关。支持 Google Gemini 和 OpenAI;可同时配置多个,并按次运行选择其一: ```ts // windup.config.ts llm: { provider: "google", // default for runs without --llm model: "gemini-3.1-flash-lite", providers: { openai: { model: "gpt-5-mini" }, // default model when --llm openai is used // openai: { apiKeyEnv: "MY_OPENAI_KEY", baseUrl: "https://my-proxy/v1" }, }, }, ``` ```bash npx windup run checkout # config default (google) npx windup run checkout --llm openai # provider default model (gpt-5-mini) npx windup run checkout --llm openai:gpt-5-nano # explicit provider:model WINDUP_LLM=openai:gpt-5-mini npx windup run --all # same thing via env (CI) ``` - `--llm` 适用于 `run`、`bench`(在同一场景上对比多个提供商)和 `scan`(LLM 辅助层)。 - API 密钥:默认为 `GOOGLE_GENERATIVE_AI_API_KEY` / `OPENAI_API_KEY`;用 `apiKeyEnv` 覆盖环境变量名。 - `baseUrl`(仅 OpenAI)指向任何兼容 OpenAI 的端点 —— Azure、代理,或本地模型服务器。 - 切换提供商从不使计划缓存失效:计划是数据,无论由谁规划,回放都无需 LLM。 - `windup costs` **按提供商和按模型**拆分花费,因此在多个 LLM 之间轮换时,各厂商的花费始终一目了然。 --- # CI / CD ```bash npx windup run --all --reporter junit --report-file reports/windup.xml ``` - `--all` 运行目录中的每个场景(整个套件共用一个热浏览器)。 - 任何场景失败时退出码为非零。 - `--concurrency ` 在一个共享的热浏览器上并行运行场景(混合套件下约快 2 倍);`--browser firefox|webkit` 跨浏览器运行整个套件。 - `--reporter junit` 产出 JUnit XML(GitHub Actions、GitLab 和 Jenkins 原生消费它);`--reporter json` 产出机器可读的摘要;`--reporter html` 产出一个自包含、对人类友好的页面(零 JS/依赖 —— 可作为 CI 制品上传或本地打开)。默认输出:`.windup/reports/`。 - `windup costs --json` 汇报 AI 花费,用于流水线追踪。 ## 示例:GitHub Actions ```yaml - run: npm ci && npx playwright install chromium - run: npx windup run --all --base-url http://localhost:8080 --reporter junit --report-file reports/windup.xml env: GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GEMINI_KEY }} - uses: dorny/test-reporter@v1 if: always() with: { name: windup, path: reports/windup.xml, reporter: java-junit } ``` --- # 配置(`windup.config.ts`) ```ts import { defineConfig } from "windupjs"; export default defineConfig({ baseUrl: "http://localhost:3000", llm: { provider: "google", model: "gemini-3.1-flash-lite", // Several providers at once — pick per run with --llm (see "LLM providers"): providers: { openai: { model: "gpt-5-mini" } }, }, scenarios: "e2e/scenarios", framework: "react-router", // detected by init; used by scan // browser: "chromium", // or "firefox" / "webkit" (need: npx playwright install ) scan: { llmAssist: { enabled: true, maxCalls: 20 }, // hard cost cap per scan }, // Project manifest: team-provided knowledge injected into the planner prompt. context: { conventions: ["every interactive element has a data-testid"], credentials: { qa: { user: "ENV:QA_USER", password: "ENV:QA_PASSWORD" }, }, vocabulary: { "order": "the Order entity, screen /orders" }, }, }); ``` - **`context.credentials`** 把账户名映射到 ENV 引用。当任务提到该账户时,计划使用 `value_ref` —— 即便页面显示了值,清单中的凭据也优先,并且禁止规划器凭空捏造 ENV 名称。 - **LLM 辅助**(扫描的第 3 层)会读取静态层无法解析的文件(动态构建的路由、间接组件),并受 `maxCalls` 限额约束。结果按文件哈希记忆 —— 未变更的文件不会再次产生费用。费用记录在账本中,并由 `windup costs` 展示。 ## 各文件归属 | 路径 | 内容 | 提交? | |---|---|---| | `windup.config.ts` | 配置 | ✅ | | `e2e/scenarios/*.json` | 你的测试,以自然语言编写 | ✅ | | `e2e/fragments/*.json` | 精选的可复用块 | ✅ | | `windup.credentials.json` | 账户 → ENV 名称映射(无值) | ✅ | | `.env.local` | 凭据值 | ❌(自动加入 gitignore;CI 用同名密钥) | | `.windup/` | 派生状态:计划缓存、运行账本、站点地图、报告 | ❌(init 会把它加入 `.gitignore`) | --- # 编程式 API 与测试运行器 从代码中运行一个场景,并拿回运行指标: ```ts import { run } from "windupjs"; const result = await run("checkout"); // RunMetrics: result, llm_calls, cost, per-action timing ``` 接入 **vitest**(兼容 jest 的契约)—— 每个场景一个原生测试,共享预热的引擎: ```ts // e2e/windup.test.ts — vitest import { windupSuite } from "windupjs/vitest"; await windupSuite(); // one native test per scenario ``` --- # 命令 | 命令 | 说明 | |---|---| | `windup init` | 创建 `windup.config.ts`、`.windup/`(已加入 gitignore)和一个示例场景 | | `windup new "" [--id x] [--force] [--depends-on ids] [--validate]` | 从一句粗略的指令生成场景;`--validate` 会运行并改进它,直到通过(≤3 次尝试) | | `windup run [scenario]` | 运行一个场景(命中缓存则回放,未命中则规划) | | `windup run --all` | 运行每个场景 —— CI 模式 | | `windup scan [--update] [--no-assist]` | 静态地把路由和交互元素索引进站点地图;`--update` 仅重新索引自上次扫描以来变更的文件(git diff);`--no-assist` 跳过 LLM 层(零成本) | | `windup costs [--last n] [--days n] [--json]` | 来自运行账本的 AI 使用报告:总计、免费回放、按提供商、按模型、按场景的细分,以及扫描和编写花费 | | `windup status` | 站点地图页面(按来源)、陈旧度、已缓存的场景、片段 | | `windup fragment extract --id --description ` | 把缓存计划中的一段提升为可复用片段 | | `windup secret set [--user u] [--password p]` | 注册测试凭据:值 → `.env.local`,映射 → `windup.credentials.json` | | `windup secret list` | 账户 + 每个 ENV 是否已设置(从不打印值) | | `windup sig [--repeat n]` | 页面结构签名(诊断用) | | `windup bench ` | 完整验证协议(生成、回放确定性、失败恢复) | | `windup cache clear` | 清空轨迹缓存(后续运行会重新规划) | ### `run` 标志 | 标志 | 作用 | |---|---| | `--all` | 运行目录中的每个场景 —— CI 模式,整个套件共用一个热浏览器。任何场景失败则退出码为非零。 | | `--concurrency ` | 在一个共享的热浏览器上以隔离的上下文并行运行最多 `n` 个场景 —— 混合套件下约快 2 倍。默认串行。 | | `--no-cache` | 忽略缓存的计划并从头重新规划(强制一次 LLM 调用),即便存在有效轨迹。用于有意重新生成计划。 | | `--no-map` | 不带站点地图图进行规划 —— 跳过已索引的路由和选择器。适合调试规划器或全新环境。 | | `--repeat ` | 在同一个热浏览器上把场景连续运行 `n` 次 —— 稳定性和抖动检查。 | | `--headed` | 显示浏览器窗口,而非以无头模式运行。 | | `--slowmo ` | 在操作之间加入延迟,让你能观察每一步 —— 演示和调试节奏。 | | `--base-url ` | 为本次运行覆盖起始 URL 的 origin(dev / staging / CI)。对绝对的场景 URL 也进行重定基,并保留路径和查询串。 | | `--browser chromium\|firefox\|webkit` | 在所选引擎上运行(默认 Chromium)。同一份计划可在三者上回放 —— 编写一次,处处运行。 | | `--llm ` | 为本次运行选择规划器 LLM(例如 `openai:gpt-5-mini`)。仅影响规划;缓存回放从不调用 LLM。 | | `--summary` | 运行结束后,额外一次 LLM 调用撰写一份人类可读的复盘,引用最终页面上观察到的真实值。默认关闭,以保持回放为 $0。 | | `--suggest` | 在**失败**的运行上,额外一次 LLM 调用为场景提出具体的修复。仅在失败时触发。 | | `--reporter junit\|json\|html` | 产出 CI 报告 —— JUnit XML、机器可读的 JSON 摘要,或一个自包含的 HTML 页面。 | | `--report-file ` | 把报告写到指定路径(默认 `.windup/reports/`)。 | ## AI 复盘(`--summary`) 面向阅读结果的人(而非 CI),`--summary` 在每次运行后增加一次 LLM 调用,撰写一份简短的复盘:测试做了什么、结果如何、**最终页面上观察到的具体值**(价格、消息、商品名 —— 从页面逐字引用),以及任何困难(缓慢的步骤、重新规划、失败)。它会打印到终端、落入运行账本,并在 HTML/JSON 报告中作为一个高亮块展示。 ```bash npx windup run checkout --summary --reporter html # summary: "The test logged in and completed checkout for 3 items; the # confirmation page showed 'Thank you for your order'. Prices observed: ..." ``` 有意默认关闭 —— 缓存回放保持零 LLM 调用和 $0。复盘费用(默认模型上约 $0.0005)在运行指标中单独追踪,并计入 `estimated_cost_usd`。 ## 失败时的修复建议(`--suggest`) 当一次运行**失败**时,`--suggest` 增加一次 LLM 调用,充当一位调试它的资深 QA 工程师:它把已执行的计划和失败的步骤与**真实的最终页面**以及站点地图中已知的选择器进行对比,然后为场景提出具体的修复 —— 错误的选择器和真实的选择器、一个并不包含任务所预期内容的目标页面、一个缺失的步骤,或一个对慢页面而言过短的超时。 ```bash npx windup run create-invoice --suggest # FAIL create-invoice ... element button:has-text('Save') not visible # suggested fix: The 'Save' button does not exist; the dialog's real button # is labeled 'Create'. Change the hint to button:has-text('Create'). ``` 它把一次失败(红色)的运行变成一处具体的编辑 —— 而不必手工逆向工程整个应用。仅在失败时触发(成功的运行不花一分钱),从不编辑场景本身,并在 HTML/JSON 报告中作为一个高亮块展示。与 `--summary` 天然搭配。 --- # 架构与规范 [活规范(living specification)](https://github.com/windupjs/windup/blob/main/docs/specs/SPEC.md)的浓缩视图。它描述的是已发布的系统。 ## 论点 浏览器 E2E 测试可以**只在规划时**(以及失败时的重新规划)使用 LLM,配合确定性执行和廉价验证 —— 于是重复运行**零 LLM 调用**。已端到端验证:首次运行以数秒完成规划,成本只有几分之一美分;回放耗时 ~0.5–1s,成本 $0。 ## 原则 1. **LLM 是例外,而非常规。** 每次缓存未命中调用一次;回放从不调用它。 2. **零硬编码的站点知识。** 引擎可以懂*框架*(Next.js、react-router、设计系统命名)和 Web 平台 —— 但从不懂某个具体站点。所有站点知识都作为输入到来,或在运行时被发现。 3. **每次执行也是一次采集。** 执行器本就会访问一个流程的每个页面;持久化它所见到的内容,成本约为每个操作一次 `evaluate`。 4. **知识是缓存,而非真理。** 站点地图或静态扫描断言的任何内容都可能陈旧;它会降级为运行时发现。优先级:`execution > static > llm`。 5. **成本从不意外。** 每个 LLM 触点都有明确的上限;每次调用都记录在账本中;重复的静态/辅助工作会被记忆化。 6. **计划是数据,而非程序。** 没有条件分支,没有循环,没有自由形式的代码。如果一个流程需要分支,就拆分场景。 ## 架构 ``` scenario (natural language, versioned) │ ▼ ┌─ runner ─────────────────────────┐ │ cache lookup (path-keyed) │ │ miss → planner (LLM, 1 call) │ │ hit → cached plan (rebased) │ │ → executor (deterministic) │ │ → verifier (postconditions) │─▶ fail → invalidate → re-plan │ → cache save + run ledger │ └───────────────────────────────────┘ site map (graph) ◀── windup scan (static + LLM-assist) ◀── passive collection (every run) ``` 模块边界(全部位于 `packages/windup/src/`): | 模块 | 职责 | |---|---| | `browser.ts` | 单一引擎边界(Playwright)。在惰性的 Chromium 单例上,每个会话使用全新的 `BrowserContext`;配合原生可操作性的可信点击;`ariaSnapshot()` 喂给规划器。以第一个*可见*匹配为目标,而非第一个 DOM 匹配。 | | `llm.ts` | 多提供商 LLM 边界。一个 `LlmClient` 接口;Google Gemini(SDK)和 OpenAI(纯 REST)实现。可同时配置多个提供商;按次运行选择。切换提供商从不触及计划缓存。 | | `planner.ts` | 规划逻辑(与提供商无关)。提示词 = 任务 + 页面无障碍树 + 真实元素 + 站点地图切片 + 片段目录 + 清单 + 提示。结构化输出;两级重试(语义 + 瞬时);输出先清洗后校验(Ajv 是权威)。 | | `executor.ts` | 确定性循环:goto → 逐个操作(以可见性为闸门 → 执行 → 验证)。产出被动的站点地图观察。 | | `verifier.ts` | 后置条件,全部无需 LLM:元素可见、URL glob、输入值。以对帧安全的等待进行轮询。 | | `cache.ts` | 以 `scenario_id` + 起始 URL 的**路径**为键的轨迹缓存(可跨环境移植)。仅在完整验证执行后保存;失败的回放会使其失效并重新规划,同时把陈旧条目保留为证据。 | | `signature.ts` | 页面结构身份:规范化交互元素的 SHA-256 —— 不含文本、不含数据 —— 因此环境噪声不会拆分身份。 | | `sitemap.ts` | 页面/跳转图。节点携带 `source: execution\|static\|llm`,附带陈旧度和来源。提示词切片:BFS(深度 ≤ 3),按词打分,控制在字符预算内。 | | `scan/` | 项目索引。第 1 层:按约定识别路由(Next.js、react-router)。第 2 层:通过感知花括号的 JSX 解析识别交互元素(原始标签 + 设计系统组件)。第 3 层:受限的 LLM 辅助,按文件哈希记忆化。 | | `fragments.ts` | 可复用的、经测试的子轨迹,提交在 `e2e/fragments/` 中。计划按 id 引用它们;缓存存储引用(更新会传播)。 | | `authoring.ts` | `windup new` —— LLM 把一句粗略的指令改写成一个扎根于站点地图和清单的精确场景;凭据自动注册并清除;输出是一个供审阅的已提交文件。 | | `metrics.ts` / `costs.ts` | 每次运行写入一条账本记录(token、调用次数、模型、计时、失败类别)。价格是一张带日期的按模型表;`windup costs` 重新计算,因此历史保持正确。 | | `summary.ts` / `suggest.ts` | 可选启用的运行后 AI 复盘和失败后修复建议 —— 各额外一次 LLM 调用,单独追踪,从不影响运行结果。 | | `reporters.ts` | JUnit XML、JSON 和自包含的 HTML 报告;失败时退出码非零。 | | `secrets.ts` | 无需提交密钥的凭据:值在 `.env.local` 中,账户 → ENV 名称映射已提交;计划仅携带 `value_ref`,在运行时解析。 | ## 数据格式 **操作计划**(`plan_version: "0.1"`):`{ plan_version, scenario_id, task, start_url, generated_by, actions[] }`。一个操作是 `{ id, type: goto|click|fill|wait_for|use, target?, value? | value_ref?, url?, use?, expect?, timeout_ms }`。语义规则:click/fill/wait_for 需要 `target.selector`;fill 需要 value/value_ref 中恰好一个;`value_ref` 必须被 task/hints/manifest 提到(不得凭空捏造 ENV 名称);最后一个操作必须携带 `expect`(或本身是一个 `use`)。 **缓存条目**(`cache_version: "0.2"`):`{ key: {scenario_id, start_url(path), start_sig?}, plan, status: active|stale, stats }`。 **站点地图**(`map_version: "0.1"`):`{ last_scan_sha, pages, transitions, assist_seen }`。 ## 模型与成本态势 默认规划器模型:**`gemini-3.1-flash-lite`**(实测:每次生成 1 次干净调用,~3–4s,≈ $0.0025/次生成)。与提供商无关:Google Gemini 和 OpenAI 开箱即用,位于 `llm.ts` 边界之后。每条账本记录都携带 provider+model;价格是一张扁平的按模型表。增加一个厂商 = 一个客户端实现。 ## 安全态势 从被测应用捕获的页面内容被喂给 LLM 时,会**加定界符并标记为不可信数据**,并附带明确指令要求把它当作数据、绝不当作指令。由于计划是经过 schema 校验的数据、以确定性方式执行,页面无法让 Windup 运行任意代码。凭据值从不进入场景、计划、缓存或 LLM 提示词。完整威胁模型:[SECURITY.md](https://github.com/windupjs/windup/blob/main/SECURITY.md)。 ## 已知限制 - 嵌套的 react-router 相对路径以尽力而为的方式采集(由执行观察加以纠正)。 - 扫描感知单个包;monorepo 会被检测并给出警告,按应用分别索引是未来的工作。 - CLI 调用之间没有守护进程(有意为之);热池是按进程的。 - 尚未构建片段自动检测;提取是手动的。 ## 验证 - `npm test`(packages/windup):约 97 个单元/集成测试,与 LLM 隔离。CI 在 Ubuntu 上用真实 Chromium 运行它们。 - `windup bench `:验证协议 —— 5 次生成(≥ 4/5 有效)、10 次回放(10/10 且 `llm_calls=0`)、回放快 ≥ 5×、回放 $0、破坏选择器后的恢复。 - 真实项目自用:一个有 106 条路由的 react-router 应用 —— 计划以 ~0.5s/$0 回放,缓存可跨环境移植。 --- # 工程说明 —— Windup 背后的技术 对那些让自然语言测试既确定又廉价的方法的总结: - **规划一次,免费回放。** LLM 每个场景恰好使用一次(外加应用变更时的自动重新规划)。它的输出是一份经过 schema 校验的 **JSON 操作计划 —— 数据,而非代码**:没有生成的脚本、没有条件分支、没有运行时即兴发挥。回放以零模型调用执行缓存的计划。 - **确定性执行。** 计划在 Playwright 上运行,配合原生可操作性检查和可信输入事件。每个操作都携带一个明确的后置条件(`expect`:元素可见 / URL glob / 输入值),并**无需 LLM**地验证 —— 验证的成本是一次 DOM 查询,而非 token。 - **自愈缓存。** 轨迹以场景 + 起始 URL 的*路径*为键缓存(可在 dev/staging/CI 主机间移植)。验证失败会使计划失效,把陈旧条目保留为证据,并以失败作为上下文触发一次重新规划。 - **结构化页面签名。** 页面由其规范化交互元素的 SHA-256 标识 —— 不含文本、不含数据 —— 因此环境噪声不会拆分身份,且起始页面的漂移会在回放时被(宽松地)检测到。 - **分层的站点知识。** 一张站点地图图为规划器提供真实的路由和选择器,由三个来源以严格优先级构建 —— 运行时观察 > 静态源码扫描 > 受限的 LLM 辅助。知识是缓存,而非真理:任何陈旧的东西都降级为运行时发现。 - **提示词预算纪律。** 规划提示词保持近似恒定的大小(~32k 字符):页面树、地图切片、片段目录和清单各有硬性字符预算。长提示词会可测量地拖累小模型 —— 预算是一项正确性特性,而非优化。 - **机械式规范化胜过对提示词的指望。** 模型输出被确定性地清洗:丢弃空字段、id 重新编号、`wait_for`⇄`expect` 归一化、去重片段回声操作、从编写的场景中清除凭据。跨提供商的 A/B 测试表明,单靠提示词指令无法在各模型间站稳 —— 代码才有最终决定权。 - **两级重试。** 语义失败(无效计划)获得一次携带校验错误的短重试;瞬时的 API 异常(token 循环退化、网络)以不同的随机种子重新调用。避免全提示词重试 —— 它们会可靠地再次触发退化。 - **可组合的构建块。** 片段是精选的、已提交的子轨迹(例如登录),计划按 id 引用它们 —— 更新一次,处处传播,在运行时展开。项目清单把团队知识(约定、账户、术语)注入每一份计划。 - **按引用管理密钥。** 凭据值存在 `.env.local`/CI 密钥中;已提交的文件只携带账户 → ENV 名称的映射。计划使用 `value_ref`,在执行时解析 —— 密钥从不抵达 LLM、缓存或 git。 - **与提供商无关的 LLM 边界。** 一个接口,Google 和 OpenAI 两种实现(OpenAI 客户端是纯 REST —— 没有 SDK 负担),可按次运行选择。更换浏览器引擎和增加一个提供商各是一处单文件改动 —— 这些边界就是架构本身。 - **可审计的成本。** 每个 LLM 触点都有明确上限,并落入一份带 token、模型和提供商的按次运行账本;`windup costs` 从一张带日期的价格表重新计算,因此随着价格变动,历史依然准确。