diff --git a/.gitignore b/.gitignore index bdc6661a..744b61c8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,10 @@ dist/ # Logs *.log +# Local browser evaluation artifacts +evals/browser/results/ +evals/browser/agents.local.json + # macOS .DS_Store diff --git a/README.md b/README.md index 86f96ebe..136dc6b7 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,7 @@ The repository is a Cargo + pnpm workspace: - `apps/extension` — browser extension - `packages/ui` and `packages/i18n` — shared extension UI support - `packages/dsh-plugin-browserskill` — DeepSeek Harness plugin (`@wxg-prc-cpg/browser-skill-dsh-plugin`) +- [`evals/browser`](evals/browser/README.md) — deterministic local pages and agent-neutral browser capability evaluation ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index 4a2a1b11..d05705a0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -178,6 +178,7 @@ Agent 不直接与浏览器通信。它通过 `bsk` CLI 下发浏览器任务; - `apps/extension` — 浏览器扩展 - `packages/ui` 和 `packages/i18n` — 扩展 UI 共享支持 - `packages/dsh-plugin-browserskill` — DeepSeek Harness 插件(`@wxg-prc-cpg/browser-skill-dsh-plugin`) +- [`evals/browser`](evals/browser/README.zh-CN.md) — 确定性本地页面与 Agent 无关的浏览器能力测试台 ## 许可证 diff --git a/biome.json b/biome.json index 50401a91..ecfaf947 100644 --- a/biome.json +++ b/biome.json @@ -44,6 +44,7 @@ "!packages/dsh-plugin-browserskill/src/client/bsk-ui.nomodule.css", "!packages/dsh-plugin-browserskill/.bsk-ui.raw.css", "!packages/dsh-plugin-browserskill/.bsk-ui.input.css", + "!evals/browser/results/**", "!**/target/**", "!**/.wxt/**", "!**/.output/**", diff --git a/evals/browser/README.md b/evals/browser/README.md new file mode 100644 index 00000000..f571b4e6 --- /dev/null +++ b/evals/browser/README.md @@ -0,0 +1,259 @@ +# Browser capability evaluation corpus + +This directory is a deterministic, local browser evaluation environment for BrowserSkill, DSH, +and other command-line agents. Cases, fixture pages, direct `bsk` workflows, and assertions are +data-driven and discovered automatically. Adding a case does not require editing a central switch. + +[中文说明](README.zh-CN.md) + +## Design goals + +- Agent-neutral: prompts describe observable page goals so the same cases can run through different + agents and adapters. +- Deterministic: pages run locally and use stable markers and run-scoped events. +- Extensible: manifests, fixture modules, and workflows are auto-discovered. +- Reproducible: generated cases record a seed and derive every DOM variation from it. +- Honest verification: page-observable results, response markers, and adapter evidence are reported + separately. Missing adapter evidence is `unverified`, never silently treated as passed. +- Privacy-safe: user badcases must be reduced to synthetic DOM and anonymized data before commit. + +The corpus currently has six stable `core` cases and one seeded `matrix` case. The operation +inventory contains 28 browser operations; that count is capability coverage, not the number of +cases. Cases can grow independently and many cases may cover the same operation under different +page conditions. + +## Quick start + +These commands validate manifests and run unit/integration tests without launching a browser: + +```sh +pnpm eval:browser validate +pnpm eval:browser list +pnpm eval:browser coverage +pnpm eval:browser:test +``` + +Build the repository CLI and exercise the real CLI, daemon, extension, Agent Window, and pages: + +```sh +cargo build -p bsk +BSK_AUTO_UPDATE=off pnpm eval:browser smoke --suite core --bsk ./target/debug/bsk +BSK_AUTO_UPDATE=off pnpm eval:browser smoke --suite matrix --seed 4,7,14 --bsk ./target/debug/bsk +``` + +Every smoke case starts a fresh browser session and stops it in a `finally` cleanup path. Reports +and screenshots are written to the ignored `evals/browser/results/` directory. + +`pnpm run eval:browser -- coverage` is also supported. The CLI intentionally accepts pnpm's leading +`--` separator. + +## Suites and selection + +| Suite | Intended use | Default execution | +| --- | --- | --- | +| `core` | Small stable acceptance set for everyday smoke and CI | Yes | +| `matrix` | Seeded DOM, timing, and layout variations | Explicit | +| `regression` | Minimized user badcases tied to an issue or fix | Explicit | +| `stress` | Higher volume or slower boundary testing | Explicit | +| `manual` | Human-dependent capabilities that cannot be deterministic | Explicit | + +`smoke` and `run-agent` default to `core`, so adding a stress or regression case cannot silently +slow down existing CI. `list`, `coverage`, and `validate` inspect the full corpus by default. + +```sh +pnpm eval:browser list --suite core +pnpm eval:browser list --tag form +pnpm eval:browser smoke --case form-controls +pnpm eval:browser smoke --case form-controls,generated-form +pnpm eval:browser smoke --case all +``` + +Filters from different dimensions are combined. Repeated/comma-separated suites or case ids are +alternatives; every requested tag must be present. `--task` remains a backward-compatible alias for +`--case`. + +## Directory and discovery model + +```text +evals/browser/ + cases/ + core/*.case.json + matrix/*.case.json + regression// + .case.json + .fixture.mjs + README.md + fixtures/pages/*.fixture.mjs + schemas/case.schema.json + lib/ + tests/ +``` + +- Any `cases/**/*.case.json` file is loaded as a case manifest. +- Any `fixtures/**/*.fixture.mjs` or `cases/**/*.fixture.mjs` file is registered as a fixture. +- Manifests are ordered by suite, optional numeric `order`, then id. +- `validate` rejects duplicate case ids, duplicate routes, missing fixture routes, unknown + operations/actions, invalid workflow variables, and adapter assertions with no evidence producer. + +## Case manifest + +A case owns all information needed by both an agent evaluation and direct CLI smoke: + +```json +{ + "$schema": "../../schemas/case.schema.json", + "schemaVersion": 1, + "id": "example-case", + "title": "Example browser behavior", + "order": 10, + "suite": "core", + "tags": ["form", "keyboard"], + "seed": 17, + "fixture": { "startPath": "/example" }, + "prompts": { "en": "Open {url} ...", "zh-CN": "打开 {url}……" }, + "coverage": ["page.navigate", "interact.press"], + "assertions": { "site": [], "response": [], "adapter": [] }, + "smoke": { "steps": [{ "action": "navigate" }] } +} +``` + +Prompt placeholders are `{url}`, `{baseUrl}`, `{runId}`, and `{seed}`. Agent adapter arguments also +support `{prompt}`, `{caseId}` / `{taskId}`, and `{variant}`. + +Assertions have three independent sources: + +- `site`: events emitted by the local page, such as submitted values or a history navigation. +- `response`: stable text that must appear in the agent or CLI output. +- `adapter`: facts outside page JavaScript, such as a non-empty screenshot or confirmed session + cleanup. + +The declarative smoke workflow supports navigation/history, waits, observe/snapshot/HTML, +screenshot/console/network, click/hover/fill/select/press, tabs, resize/emulation, and the manual +ownership/help operations. A step may `saveAs` its JSON result and later reference values such as +`{child.tab_id}`. A step may set `evidence` for an adapter assertion. See existing manifests for +complete examples. + +## Adding a normal case + +1. Add a `*.fixture.mjs` module exporting `{ id, routes, render(context) }`. It can live in shared + `fixtures/pages/` or beside a self-contained regression case. +2. Add a `*.case.json` manifest with prompts, operation coverage, assertions, and smoke steps. +3. Make all page events run-scoped using the shared `browserEval.send(...)` client helper. +4. Run `pnpm eval:browser validate` and `pnpm eval:browser:test`. +5. Run the new case against the real local build with `smoke --case `. + +Prefer one behavior or failure mechanism per case. Stable synthetic markers make failures easier to +diagnose than assertions against incidental prose. + +## Turning a user badcase into a regression + +Create a self-contained starting point: + +```sh +pnpm eval:browser scaffold reported-timeout \ + --title "Navigation settles after a late frame" \ + --source "issue-123" +``` + +The command creates a manifest, fixture, and reproduction notes under +`cases/regression/reported-timeout/`. Then reduce the report to the smallest relevant mechanism: + +- Keep DOM structure, timing, redirects, frames, or browser state that triggers the bug. +- Replace names, account data, URLs, tokens, screenshots, and page copy with synthetic values. +- Do not commit HAR files, cookies, credentials, production HTML, or proprietary assets. +- Record the issue/PR reference, seed when relevant, expected result, and the fixing PR. +- Confirm the new case fails on the known-bad build and passes on the fixed build when practical. + +## Seeded matrix cases + +`generated-form` varies label association, nesting depth, hydration delay, decoy controls, field +order, and element ids while keeping the task and oracle stable. Inspect a seed without a browser: + +```sh +pnpm eval:browser generate --seed user-badcase-42 +pnpm eval:browser prompt generated-form --seed user-badcase-42 --run-id manual-42 +pnpm eval:browser smoke --case generated-form --seed user-badcase-42 +pnpm eval:browser smoke --case generated-form --seed 4,7,14 +``` + +Repeated/comma-separated seeds run as independent results. Reports store every normalized seed, so +a failure can be replayed exactly and promoted into a named regression case if it represents a +distinct bug. + +## Run any command-line agent + +Copy `agents.example.json` to the ignored `agents.local.json` and adapt its command. Arguments are +passed directly to the executable without a shell. + +```sh +pnpm eval:browser run-agent \ + --config evals/browser/agents.local.json \ + --agent dsh-local \ + --suite core \ + --repeat 3 +``` + +An adapter may define `command`, `args`, `cwd`, `env`, `timeoutMs`, `variant`, free-form `metadata`, +and optional `metrics.errorPattern` / `metrics.toolCallPattern` regular expressions. + +To compare 28 granular tools with domain tools, create two isolated adapter entries and run both +against the same selected cases and seeds: + +```sh +pnpm eval:browser run-agent \ + --config evals/browser/agents.local.json \ + --agent dsh-granular,dsh-domain \ + --suite core \ + --repeat 10 +``` + +Keep browser runs sequential to avoid session contention. Use fresh profiles and alternate adapter +order in larger experiments to reduce warm-cache bias. + +## Reading results + +```sh +pnpm eval:browser summarize evals/browser/results/*.json +``` + +The summary groups by adapter and variant and reports pass rate, fully verified rate, process +failures, matched error count, average tool calls, and average duration. The raw JSON retains case, +suite, tags, seed, prompt, command output, page event count, and individual oracle checks. + +A healthy run has: + +- no execution timeout or non-zero exit; +- no `executionError` in direct smoke; +- `verification.status` equal to `passed` (or intentionally `passed-with-unverified` for an agent + adapter that cannot expose external evidence); +- every session cleanup assertion verified; +- no unexpected increase in errors, tool calls, or duration versus the chosen baseline. + +## Development baseline + +Use these lanes as commit and PR gates: + +| Change | Required checks | +| --- | --- | +| Any case, fixture, oracle, or harness change | `pnpm eval:browser:check` | +| CLI, daemon, extension, DSH plugin, or browser-operation change | The check above, `cargo build -p bsk`, then real-browser `smoke --suite core` | +| DOM discovery, form interaction, timing, or waiting change | The checks above, then `smoke --suite matrix --seed 4,7,14` | +| A bug fix with a known reproduction | Add a `regression` case and run that case on the fixed build | + +Before committing this harness, the complete acceptance sequence is: + +```sh +pnpm eval:browser:check +cargo build -p bsk +BSK_AUTO_UPDATE=off pnpm eval:browser smoke --case all --bsk ./target/debug/bsk +BSK_AUTO_UPDATE=off pnpm eval:browser smoke --suite matrix --seed 4,7,14 --bsk ./target/debug/bsk +``` + +All commands must exit zero. The smoke summary must have 100% pass and fully verified rates, zero +execution failures/errors, and `bsk session list --json` must be `[]` afterward. Do not weaken an +oracle or replace a stable marker merely to make a regression pass; behavior changes should update +the case and expected baseline in the same reviewed change. + +The direct smoke lane covers 25 of 28 operations. `tabs.borrow`, `tabs.return`, and +`assist.request-help` stay manual because they require a real user tab or human interaction and +would make the default suite destructive or non-deterministic. diff --git a/evals/browser/README.zh-CN.md b/evals/browser/README.zh-CN.md new file mode 100644 index 00000000..9c9636e2 --- /dev/null +++ b/evals/browser/README.zh-CN.md @@ -0,0 +1,197 @@ +# 浏览器能力测试语料库 + +这里是一套供 BrowserSkill、DSH 以及其他命令行 Agent 共用的本地确定性浏览器测试环境。 +case、测试页面、`bsk` 直连流程和断言都会自动发现;新增 case 不需要修改中央 `switch`。 + +[English](README.md) + +## 核心原则 + +- 与 Agent 无关:prompt 只描述可观察的页面目标,同一套 case 可以被不同 Agent 和 + adapter 使用。 +- 本地且确定:页面只依赖本地服务,使用稳定 marker 和按 run 隔离的事件。 +- 可扩展:manifest、fixture 和 workflow 都从目录自动发现。 +- 可复现:生成式页面的所有变化由 seed 决定,报告会保留 seed。 +- 如实验证:页面事件、回答文本和 adapter 外部证据分别统计;拿不到的证据标为 + `unverified`,不会冒充通过。 +- 隐私安全:用户 badcase 必须先缩减为合成 DOM 和匿名数据,再进入仓库。 + +当前有 6 个稳定的 `core` case 和 1 个 seed 驱动的 `matrix` case。28 表示浏览器操作能力 +清单,并不是 case 数量;同一种操作可以在不同页面结构、时序和浏览器状态下由多个 case +重复覆盖。 + +## 快速使用 + +不启动浏览器的校验: + +```sh +pnpm eval:browser validate +pnpm eval:browser list +pnpm eval:browser coverage +pnpm eval:browser:test +``` + +使用仓库当前代码编译 `bsk`,再跑真实 CLI、daemon、扩展、小窗和页面链路: + +```sh +cargo build -p bsk +BSK_AUTO_UPDATE=off pnpm eval:browser smoke --suite core --bsk ./target/debug/bsk +BSK_AUTO_UPDATE=off pnpm eval:browser smoke --suite matrix --seed 4,7,14 --bsk ./target/debug/bsk +``` + +每个 smoke case 都会创建独立 session,并在 `finally` 中关闭。JSON 报告和截图输出到已被 +Git 忽略的 `evals/browser/results/`。 + +也可以写成 `pnpm run eval:browser -- coverage`;CLI 已兼容 pnpm 传入的前导 `--`。 + +## Suite 与筛选 + +| Suite | 用途 | 默认执行 | +| --- | --- | --- | +| `core` | 日常 smoke 和 CI 使用的小而稳定的验收集 | 是 | +| `matrix` | seed 驱动的 DOM、时序、布局组合 | 显式选择 | +| `regression` | 从用户 badcase 缩减并与 issue/修复关联的回归 | 显式选择 | +| `stress` | 更高次数或更慢的边界测试 | 显式选择 | +| `manual` | 必须依赖真人或真实用户标签页的能力 | 显式选择 | + +`smoke` 和 `run-agent` 不带筛选时只跑 `core`,所以以后新增 stress/badcase 不会突然拖慢 +已有 CI。`list`、`coverage`、`validate` 默认检查全部语料。 + +```sh +pnpm eval:browser list --suite core +pnpm eval:browser list --tag form +pnpm eval:browser smoke --case form-controls +pnpm eval:browser smoke --case form-controls,generated-form +pnpm eval:browser smoke --case all +``` + +不同维度的筛选会叠加;逗号分隔的 suite/case 是“或”,多个 tag 必须全部命中。旧参数 +`--task` 仍作为 `--case` 的兼容别名。 + +## 自动发现结构 + +```text +evals/browser/ + cases/ + core/*.case.json + matrix/*.case.json + regression// + .case.json + .fixture.mjs + README.md + fixtures/pages/*.fixture.mjs + schemas/case.schema.json + lib/ + tests/ +``` + +- `cases/**/*.case.json` 都会作为 case manifest 加载。 +- `fixtures/**/*.fixture.mjs` 和 `cases/**/*.fixture.mjs` 都会注册为页面 fixture。 +- 顺序按 suite、可选的数字 `order`、id 排列。 +- `validate` 会阻止重复 case id、重复路由、缺失 fixture、未知操作/action、无效 workflow + 变量,以及没有任何步骤产出的 adapter evidence。 + +一个 manifest 同时描述:中英文 prompt、能力 coverage、页面事件/回答/adapter 三类断言,以及 +`bsk` 直连 smoke 的声明式步骤。prompt 可用 `{url}`、`{baseUrl}`、`{runId}`、`{seed}`; +workflow 可以用 `saveAs` 保存 JSON 结果,并用 `{child.tab_id}` 之类的变量引用后续步骤。 +完整写法可直接参考 `cases/core/` 和 `cases/matrix/`。 + +## 新增普通 case + +1. 新增导出 `{ id, routes, render(context) }` 的 `*.fixture.mjs`,可放共享 fixture 目录,也可 + 和 regression case 放在一起。 +2. 新增 `*.case.json`,写好 prompt、operation coverage、断言和 smoke steps。 +3. 页面使用共享的 `browserEval.send(...)` 上报按 run 隔离的事件。 +4. 执行 `pnpm eval:browser validate` 和 `pnpm eval:browser:test`。 +5. 用 `smoke --case ` 验证真实本地编译链路。 + +建议一个 case 只表达一种行为或故障机制,并使用稳定的合成 marker,避免断言偶然文案。 + +## 把用户 badcase 固化成回归 + +先生成一个自包含模板: + +```sh +pnpm eval:browser scaffold reported-timeout \ + --title "Navigation settles after a late frame" \ + --source "issue-123" +``` + +它会在 `cases/regression/reported-timeout/` 下生成 manifest、fixture 和复现说明。随后只保留 +真正触发问题的 DOM 结构、时序、跳转、frame 或浏览器状态,并遵守: + +- 所有姓名、账号、网址、token、截图和文案都替换为合成值。 +- 不提交 HAR、cookie、凭据、生产 HTML 或私有素材。 +- 记录 issue/PR 来源、相关 seed、期望行为和后续修复 PR。 +- 条件允许时,证明新 case 在旧版本失败、修复版本通过。 + +## Seed 矩阵 + +`generated-form` 会确定性改变 label 关联方式、DOM 嵌套、hydration 延迟、干扰控件、字段 +顺序和 element id,但任务和 oracle 保持不变: + +```sh +pnpm eval:browser generate --seed user-badcase-42 +pnpm eval:browser prompt generated-form --seed user-badcase-42 --run-id manual-42 +pnpm eval:browser smoke --case generated-form --seed user-badcase-42 +pnpm eval:browser smoke --case generated-form --seed 4,7,14 +``` + +逗号分隔或重复的 seed 会生成相互独立的结果。任何失败都能按报告中的规范化 seed 精确重放; +若它代表独立 bug,再提升为命名 regression case。 + +## 测任意 Agent 与比较 28/6 工具 + +把 `agents.example.json` 复制为已被 Git 忽略的 `agents.local.json`,分别配置两个隔离的 Agent +profile/build。参数以数组直接传给可执行程序,不经过 shell。 + +```sh +pnpm eval:browser run-agent \ + --config evals/browser/agents.local.json \ + --agent dsh-granular,dsh-domain \ + --suite core \ + --repeat 10 +``` + +浏览器任务应串行运行,避免 session 竞争;更大实验中使用干净 profile 并轮换 adapter 顺序, +降低缓存偏差。 + +## 如何看结果 + +```sh +pnpm eval:browser summarize evals/browser/results/*.json +``` + +汇总按 adapter/variant 展示通过率、完全验证率、进程失败、错误数、平均工具调用数和耗时。 +原始 JSON 会保留 case、suite、tag、seed、prompt、命令输出、页面事件数和每条 oracle 结果。 + +健康运行应满足:没有 timeout/非零退出,direct smoke 没有 `executionError`,验证状态为 +`passed`(无法暴露外部证据的 Agent adapter 可明确为 `passed-with-unverified`),session cleanup +通过,并且错误数、调用数和耗时没有相对基线异常上涨。 + +## 开发基准线 + +后续开发统一按下面的门禁执行: + +| 改动范围 | 必跑检查 | +| --- | --- | +| 任意 case、fixture、oracle 或 harness 改动 | `pnpm eval:browser:check` | +| CLI、daemon、扩展、DSH 插件或浏览器操作改动 | 上述检查、`cargo build -p bsk`、真实浏览器 `smoke --suite core` | +| DOM 发现、表单交互、时序或等待逻辑改动 | 上述检查,再跑 `smoke --suite matrix --seed 4,7,14` | +| 修复已有明确复现的 bug | 新增 `regression` case,并在修复版本单独运行该 case | + +提交这套 harness 前,完整验收命令为: + +```sh +pnpm eval:browser:check +cargo build -p bsk +BSK_AUTO_UPDATE=off pnpm eval:browser smoke --case all --bsk ./target/debug/bsk +BSK_AUTO_UPDATE=off pnpm eval:browser smoke --suite matrix --seed 4,7,14 --bsk ./target/debug/bsk +``` + +所有命令必须以 0 退出;smoke 汇总必须达到 100% pass、100% fully verified、0 execution +failure/error,结束后 `bsk session list --json` 必须为 `[]`。不能为了让回归通过而削弱 oracle +或替换稳定 marker;如果产品行为确实改变,case 与预期基线必须在同一个受审查改动中更新。 + +直连 smoke 自动覆盖 28 项中的 25 项。`tabs.borrow`、`tabs.return`、`assist.request-help` 需要 +真实用户标签页或真人交互,因此保留在 manual lane,避免默认测试具有破坏性或不确定性。 diff --git a/evals/browser/agents.example.json b/evals/browser/agents.example.json new file mode 100644 index 00000000..e20737e0 --- /dev/null +++ b/evals/browser/agents.example.json @@ -0,0 +1,17 @@ +{ + "agents": { + "dsh-local": { + "variant": "granular-28", + "command": "dsh", + "args": ["--profile", "headless", "{prompt}"], + "cwd": "../..", + "timeoutMs": 300000, + "metadata": { + "toolCount": 28 + }, + "metrics": { + "toolCallPattern": "browser[_-][a-z_-]+" + } + } + } +} diff --git a/evals/browser/cases/README.md b/evals/browser/cases/README.md new file mode 100644 index 00000000..99a524b4 --- /dev/null +++ b/evals/browser/cases/README.md @@ -0,0 +1,9 @@ +# Browser evaluation cases + +Cases are discovered recursively from `*.case.json`; colocated `*.fixture.mjs` files are discovered +as fixtures. Use `core` for small deterministic acceptance checks, `matrix` for seeded variations, +and `regression//` for privacy-safe minimized user badcases. + +Run `pnpm eval:browser validate` after every case change. See the parent +[`README.md`](../README.md) for the manifest contract, workflow actions, scaffolding, and acceptance +criteria. diff --git a/evals/browser/cases/core/diagnostics.case.json b/evals/browser/cases/core/diagnostics.case.json new file mode 100644 index 00000000..dbfa7409 --- /dev/null +++ b/evals/browser/cases/core/diagnostics.case.json @@ -0,0 +1,48 @@ +{ + "$schema": "../../schemas/case.schema.json", + "schemaVersion": 1, + "id": "diagnostics", + "title": "Console and network inspection", + "order": 50, + "suite": "core", + "tags": ["console", "network", "diagnostics"], + "fixture": { "startPath": "/diagnostics" }, + "prompts": { + "en": "Open {url}. Use browser diagnostics to inspect the page console and recent network activity. Report the console marker and the token returned by the ping request, then close the browser session.", + "zh-CN": "打开 {url}。使用浏览器诊断能力检查页面 console 和最近的 network 活动,报告 console marker 以及 ping 请求返回的 token,最后关闭浏览器会话。" + }, + "coverage": [ + "session.start", + "session.stop", + "page.navigate", + "inspect.console", + "inspect.network" + ], + "assertions": { + "site": [ + { + "label": "diagnostic ping completed", + "type": "network.ping", + "minCount": 1 + } + ], + "response": [ + { "label": "reported console marker", "includes": "CONSOLE-61" }, + { "label": "reported network token", "includes": "NETWORK-73" } + ], + "adapter": [ + { "label": "console inspection was executed", "key": "consoleRead" }, + { "label": "network inspection was executed", "key": "networkRead" }, + { "label": "browser session was closed", "key": "sessionStopped" } + ] + }, + "smoke": { + "steps": [ + { "action": "navigate" }, + { "action": "wait", "duration": "300ms" }, + { "action": "console", "evidence": "consoleRead" }, + { "action": "network", "evidence": "networkRead" }, + { "action": "observe" } + ] + } +} diff --git a/evals/browser/cases/core/form-controls.case.json b/evals/browser/cases/core/form-controls.case.json new file mode 100644 index 00000000..a2942194 --- /dev/null +++ b/evals/browser/cases/core/form-controls.case.json @@ -0,0 +1,82 @@ +{ + "$schema": "../../schemas/case.schema.json", + "schemaVersion": 1, + "id": "form-controls", + "title": "Form, select, keyboard, and screenshot", + "order": 20, + "suite": "core", + "tags": ["form", "keyboard", "screenshot", "select"], + "fixture": { "startPath": "/form" }, + "prompts": { + "en": "Open {url}. Fill Text input with \"agent-parity\" and Textarea with \"BrowserSkill works\". Select the visible option \"Two\" from Dropdown. Capture a screenshot of the form, focus Submit, and press Enter to submit. Confirm that the result says \"Received!\", then close the browser session.", + "zh-CN": "打开 {url}。把 Text input 填成“agent-parity”,Textarea 填成“BrowserSkill works”,Dropdown 选择可见选项“Two”。截取表单截图,然后聚焦 Submit 并按 Enter 提交。确认结果页出现“Received!”,最后关闭浏览器会话。" + }, + "coverage": [ + "session.start", + "session.stop", + "page.navigate", + "interact.fill", + "interact.select", + "interact.press", + "inspect.screenshot" + ], + "assertions": { + "site": [ + { + "label": "Enter was pressed on Submit", + "type": "form.enter_pressed", + "minCount": 1 + }, + { + "label": "form values were submitted", + "type": "form.submitted", + "where": { + "data.text": "agent-parity", + "data.notes": "BrowserSkill works", + "data.choice": "two" + }, + "minCount": 1 + }, + { + "label": "result page was displayed", + "type": "page.shown", + "where": { "data.path": "/result" }, + "minCount": 1 + } + ], + "response": [{ "label": "confirmed result", "includes": "Received!" }], + "adapter": [ + { "label": "screenshot artifact was created", "key": "screenshotCreated" }, + { "label": "browser session was closed", "key": "sessionStopped" } + ] + }, + "smoke": { + "steps": [ + { "action": "navigate" }, + { + "action": "fill", + "selector": "#text-input", + "value": "agent-parity" + }, + { + "action": "fill", + "selector": "#notes", + "value": "BrowserSkill works" + }, + { "action": "select", "selector": "#choice", "values": ["two"] }, + { + "action": "screenshot", + "artifactName": "form.png", + "evidence": "screenshotCreated" + }, + { "action": "press", "key": "Enter", "selector": "#submit" }, + { + "action": "wait-site-event", + "type": "page.shown", + "where": { "path": "/result" } + }, + { "action": "wait", "duration": "200ms" }, + { "action": "observe" } + ] + } +} diff --git a/evals/browser/cases/core/hover-inspect.case.json b/evals/browser/cases/core/hover-inspect.case.json new file mode 100644 index 00000000..083fe7b0 --- /dev/null +++ b/evals/browser/cases/core/hover-inspect.case.json @@ -0,0 +1,59 @@ +{ + "$schema": "../../schemas/case.schema.json", + "schemaVersion": 1, + "id": "hover-inspect", + "title": "Hover, local HTML, and screenshot", + "order": 30, + "suite": "core", + "tags": ["hover", "html", "screenshot"], + "fixture": { "startPath": "/hover-inspect" }, + "prompts": { + "en": "Open {url}. Hover over Account, then choose Profile beta from the revealed menu. Inspect the local HTML around the Inspection card and capture a screenshot. Report both marker codes you find, then close the browser session.", + "zh-CN": "打开 {url}。把鼠标悬停在“Account”上,然后点击展开菜单中的“Profile beta”。检查 Inspection card 附近的局部 HTML,并截取页面截图。报告找到的两个标记代码,最后关闭浏览器会话。" + }, + "coverage": [ + "session.start", + "session.stop", + "page.navigate", + "interact.hover", + "interact.click", + "inspect.html", + "inspect.screenshot" + ], + "assertions": { + "site": [ + { + "label": "Account menu was hovered", + "type": "hover.menu_opened", + "minCount": 1 + }, + { + "label": "Profile beta was selected", + "type": "hover.profile_clicked", + "minCount": 1 + } + ], + "response": [ + { "label": "reported hover marker", "includes": "HOVER-17" }, + { "label": "reported HTML marker", "includes": "INSPECT-27" } + ], + "adapter": [ + { "label": "HTML inspection was executed", "key": "htmlInspected" }, + { "label": "screenshot artifact was created", "key": "screenshotCreated" }, + { "label": "browser session was closed", "key": "sessionStopped" } + ] + }, + "smoke": { + "steps": [ + { "action": "navigate" }, + { "action": "hover", "selector": "#account-menu" }, + { "action": "click", "selector": "#profile-link" }, + { "action": "html", "evidence": "htmlInspected" }, + { + "action": "screenshot", + "artifactName": "hover-inspect.png", + "evidence": "screenshotCreated" + } + ] + } +} diff --git a/evals/browser/cases/core/mobile-emulation.case.json b/evals/browser/cases/core/mobile-emulation.case.json new file mode 100644 index 00000000..afe5bbc2 --- /dev/null +++ b/evals/browser/cases/core/mobile-emulation.case.json @@ -0,0 +1,43 @@ +{ + "$schema": "../../schemas/case.schema.json", + "schemaVersion": 1, + "id": "mobile-emulation", + "title": "Mobile emulation", + "order": 60, + "suite": "core", + "tags": ["emulation", "mobile", "viewport"], + "fixture": { "startPath": "/responsive" }, + "prompts": { + "en": "Open {url}, emulate an iPhone 14, and report the viewport marker shown on the page. Close the browser session when finished.", + "zh-CN": "打开 {url},模拟 iPhone 14,并报告页面显示的 viewport marker。完成后关闭浏览器会话。" + }, + "coverage": ["session.start", "session.stop", "page.navigate", "assist.emulate"], + "assertions": { + "site": [ + { + "label": "iPhone-sized viewport was observed", + "type": "viewport.sample", + "where": { "data.width": 390, "data.height": 844 }, + "minCount": 1 + } + ], + "response": [{ "label": "reported viewport marker", "includes": "VIEWPORT-84" }], + "adapter": [ + { "label": "device emulation was executed", "key": "emulatedDevice" }, + { "label": "browser session was closed", "key": "sessionStopped" } + ] + }, + "smoke": { + "steps": [ + { "action": "navigate" }, + { "action": "resize", "width": 900, "height": 700 }, + { + "action": "emulate", + "device": "iphone-14", + "evidence": "emulatedDevice" + }, + { "action": "wait", "duration": "300ms" }, + { "action": "html" } + ] + } +} diff --git a/evals/browser/cases/core/navigation-history.case.json b/evals/browser/cases/core/navigation-history.case.json new file mode 100644 index 00000000..b2aafbb0 --- /dev/null +++ b/evals/browser/cases/core/navigation-history.case.json @@ -0,0 +1,67 @@ +{ + "$schema": "../../schemas/case.schema.json", + "schemaVersion": 1, + "id": "navigation-history", + "title": "Navigation, history, and reload", + "order": 10, + "suite": "core", + "tags": ["navigation", "history", "reload"], + "fixture": { "startPath": "/navigation/start" }, + "prompts": { + "en": "Open {url}. Read the main heading, follow the Details link, and report the code shown there. Then go back, go forward, reload the page, and close the browser session. Use only the browser capability for page operations.", + "zh-CN": "打开 {url}。读取页面主标题,点击“详情”,并报告详情页上的代码。然后依次后退、前进、刷新页面,最后关闭浏览器会话。页面操作只使用浏览器能力。" + }, + "coverage": [ + "session.start", + "session.stop", + "page.navigate", + "page.back", + "page.forward", + "page.reload", + "interact.click" + ], + "assertions": { + "site": [ + { + "label": "details link was clicked", + "type": "navigation.detail.clicked", + "minCount": 1 + }, + { + "label": "back returned to the start page", + "type": "page.shown", + "where": { "data.path": "/navigation/start" }, + "minCount": 2 + }, + { + "label": "forward returned to the detail page", + "type": "page.shown", + "where": { "data.path": "/navigation/detail" }, + "minCount": 2 + }, + { + "label": "detail page was reloaded", + "type": "page.shown", + "where": { + "data.path": "/navigation/detail", + "data.navigationType": "reload" + }, + "minCount": 1 + } + ], + "response": [{ "label": "reported detail code", "includes": "NAV-42" }], + "adapter": [{ "label": "browser session was closed", "key": "sessionStopped" }] + }, + "smoke": { + "steps": [ + { "action": "navigate" }, + { "action": "observe" }, + { "action": "snapshot" }, + { "action": "click", "selector": "#details-link" }, + { "action": "html" }, + { "action": "back" }, + { "action": "forward" }, + { "action": "reload" } + ] + } +} diff --git a/evals/browser/cases/core/tabs.case.json b/evals/browser/cases/core/tabs.case.json new file mode 100644 index 00000000..ca39ceda --- /dev/null +++ b/evals/browser/cases/core/tabs.case.json @@ -0,0 +1,71 @@ +{ + "$schema": "../../schemas/case.schema.json", + "schemaVersion": 1, + "id": "tabs", + "title": "Create, select, and close a tab", + "order": 40, + "suite": "core", + "tags": ["tabs", "ownership", "lifecycle"], + "fixture": { "startPath": "/tabs" }, + "prompts": { + "en": "Open {url}. Create a new browser tab at {baseUrl}/tabs/child?run={runId}, read its token, click \"Mark child visited\", close the child tab, return to the original tab, and close the browser session.", + "zh-CN": "打开 {url}。新建一个浏览器标签页并访问 {baseUrl}/tabs/child?run={runId},读取其中的 token,点击“Mark child visited”,关闭子标签页,回到原标签页,最后关闭浏览器会话。" + }, + "coverage": [ + "session.start", + "session.stop", + "page.navigate", + "interact.click", + "tabs.create", + "tabs.select", + "tabs.close" + ], + "assertions": { + "site": [ + { + "label": "child tab page was displayed", + "type": "page.shown", + "where": { "data.path": "/tabs/child" }, + "minCount": 1 + }, + { + "label": "child tab action ran", + "type": "tabs.child_marked", + "minCount": 1 + } + ], + "response": [{ "label": "reported child token", "includes": "TAB-29" }], + "adapter": [ + { "label": "child tab was closed", "key": "childTabClosed" }, + { "label": "browser session was closed", "key": "sessionStopped" } + ] + }, + "smoke": { + "steps": [ + { "action": "navigate", "saveAs": "original" }, + { "action": "tab-list" }, + { + "action": "tab-create", + "url": "{baseUrl}/tabs/child?run={runId}", + "saveAs": "child" + }, + { + "action": "navigate", + "url": "{baseUrl}/tabs/child?run={runId}", + "tabId": "{child.tab_id}" + }, + { + "action": "click", + "selector": "#mark-child", + "tabId": "{child.tab_id}" + }, + { "action": "html", "tabId": "{child.tab_id}" }, + { + "action": "tab-close", + "tabId": "{child.tab_id}", + "evidence": "childTabClosed" + }, + { "action": "tab-select", "tabId": "{original.tab_id}" } + ] + } +} diff --git a/evals/browser/cases/matrix/README.md b/evals/browser/cases/matrix/README.md new file mode 100644 index 00000000..54cb3f7e --- /dev/null +++ b/evals/browser/cases/matrix/README.md @@ -0,0 +1,5 @@ +# Seeded matrix cases + +Matrix cases vary page structure or timing deterministically from a recorded seed. The task, +assertions, and success marker remain stable so a failed seed can be replayed exactly. A seed that +exposes a distinct product bug should be minimized and promoted to a named regression case. diff --git a/evals/browser/cases/matrix/generated-form.case.json b/evals/browser/cases/matrix/generated-form.case.json new file mode 100644 index 00000000..4bedc448 --- /dev/null +++ b/evals/browser/cases/matrix/generated-form.case.json @@ -0,0 +1,77 @@ +{ + "$schema": "../../schemas/case.schema.json", + "schemaVersion": 1, + "id": "generated-form", + "title": "Seeded form structure and hydration variants", + "order": 10, + "suite": "matrix", + "tags": ["dynamic-dom", "form", "hydration", "seeded"], + "seed": 20260827, + "fixture": { "startPath": "/matrix/generated-form" }, + "prompts": { + "en": "Open {url}. Wait for the seeded form to finish hydrating. Fill Text input with \"seeded-agent\", select the visible option \"Two\", submit with Enter, and report the result marker and seed. Close the browser session.", + "zh-CN": "打开 {url}。等待 seeded form 完成 hydration,把 Text input 填成“seeded-agent”,选择可见选项“Two”,用 Enter 提交,并报告结果 marker 和 seed。最后关闭浏览器会话。" + }, + "coverage": [ + "session.start", + "session.stop", + "page.navigate", + "page.wait", + "inspect.observe", + "interact.fill", + "interact.select", + "interact.press" + ], + "assertions": { + "site": [ + { "label": "generated form hydrated", "type": "matrix.ready", "minCount": 1 }, + { + "label": "Enter was pressed on generated form", + "type": "matrix.enter_pressed", + "minCount": 1 + }, + { + "label": "generated values were submitted", + "type": "matrix.submitted", + "where": { "data.text": "seeded-agent", "data.choice": "two" }, + "minCount": 1 + }, + { + "label": "matrix result page was displayed", + "type": "page.shown", + "where": { "data.path": "/matrix/generated-result" }, + "minCount": 1 + } + ], + "response": [ + { "label": "reported matrix result", "includes": "MATRIX-OK" }, + { "label": "reported selected seed", "includes": "SEED-" } + ], + "adapter": [{ "label": "browser session was closed", "key": "sessionStopped" }] + }, + "smoke": { + "steps": [ + { "action": "navigate" }, + { + "action": "wait-site-event", + "type": "matrix.ready", + "timeoutMs": 5000 + }, + { "action": "observe" }, + { "action": "fill", "selector": "[name=text]", "value": "seeded-agent" }, + { "action": "select", "selector": "[name=choice]", "values": ["two"] }, + { + "action": "press", + "key": "Enter", + "selector": "button[type=submit]" + }, + { + "action": "wait-site-event", + "type": "page.shown", + "where": { "path": "/matrix/generated-result" } + }, + { "action": "wait", "duration": "200ms" }, + { "action": "observe" } + ] + } +} diff --git a/evals/browser/cases/regression/README.md b/evals/browser/cases/regression/README.md new file mode 100644 index 00000000..4cb85669 --- /dev/null +++ b/evals/browser/cases/regression/README.md @@ -0,0 +1,12 @@ +# Regression cases + +Each user-reported badcase should live in its own directory with a manifest, synthetic fixture, and +README describing the symptom, minimal reproduction, expected behavior, source reference, and fix. +Never commit credentials, cookies, HAR files, production HTML, private screenshots, or proprietary +assets. + +Create a starting point with: + +```sh +pnpm eval:browser scaffold --title "..." --source "issue-or-pr" +``` diff --git a/evals/browser/cli.mjs b/evals/browser/cli.mjs new file mode 100644 index 00000000..abe49000 --- /dev/null +++ b/evals/browser/cli.mjs @@ -0,0 +1,467 @@ +#!/usr/bin/env node + +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +import { loadAgentConfig, runCommandTask } from "./lib/agent-runner.mjs"; +import { runBskSmokeTask } from "./lib/bsk-runner.mjs"; +import { buildOperationCoverage } from "./lib/coverage.mjs"; +import { loadFixtureRegistry } from "./lib/fixture-registry.mjs"; +import { verifyTask } from "./lib/oracle.mjs"; +import { resultsDirectory } from "./lib/paths.mjs"; +import { repositorySummary, validateRepositoryCases } from "./lib/repository-validation.mjs"; +import { scaffoldCase } from "./lib/scaffold-case.mjs"; +import { describeSeed, normalizeSeed } from "./lib/seeded-generator.mjs"; +import { createEvalServer } from "./lib/server.mjs"; +import { printSummary, summarizeReports } from "./lib/summary.mjs"; +import { allTasks, getTask, listTasks, renderPrompt } from "./lib/tasks.mjs"; + +const booleanOptions = new Set(["help", "json"]); + +function parseArguments(argv) { + const normalized = argv[0] === "--" ? argv.slice(1) : argv; + let [command, ...rest] = normalized; + const options = new Map(); + const positionals = []; + if (command === "--help" || command === "-h") { + command = undefined; + options.set("help", [true]); + } + for (let index = 0; index < rest.length; index += 1) { + const argument = rest[index]; + if (!argument.startsWith("--")) { + positionals.push(argument); + continue; + } + const equals = argument.indexOf("="); + const name = argument.slice(2, equals === -1 ? undefined : equals); + let value = equals === -1 ? undefined : argument.slice(equals + 1); + if (value === undefined && !booleanOptions.has(name)) { + const candidate = rest[index + 1]; + if (candidate === undefined || candidate.startsWith("--")) { + throw new Error(`--${name} requires a value`); + } + value = candidate; + index += 1; + } + const values = options.get(name) ?? []; + values.push(value ?? true); + options.set(name, values); + } + return { command, options, positionals }; +} + +function option(parsed, name, fallback) { + return parsed.options.get(name)?.at(-1) ?? fallback; +} + +function optionList(parsed, name) { + return (parsed.options.get(name) ?? []) + .flatMap((value) => String(value).split(",")) + .map((value) => value.trim()) + .filter(Boolean); +} + +function numericOption(parsed, name, fallback) { + const value = Number(option(parsed, name, fallback)); + if (!Number.isFinite(value) || value < 0) throw new Error(`--${name} must be non-negative`); + return value; +} + +function seedOptions(parsed, fallback) { + const requested = optionList(parsed, "seed"); + return requested.length > 0 ? unique(requested.map(normalizeSeed)) : [fallback]; +} + +function seedOption(parsed, fallback) { + const seeds = seedOptions(parsed, fallback); + if (seeds.length > 1) throw new Error("this command accepts only one --seed value"); + return seeds[0]; +} + +function unique(values) { + return [...new Set(values)]; +} + +function selectCases(cases, parsed, { defaultSuite } = {}) { + const requestedIds = unique([...optionList(parsed, "case"), ...optionList(parsed, "task")]); + const requestedSuites = unique(optionList(parsed, "suite")); + const requestedTags = unique(optionList(parsed, "tag")); + const hasSelector = + requestedIds.length > 0 || requestedSuites.length > 0 || requestedTags.length > 0; + + if (requestedIds.includes("all") && requestedIds.length > 1) { + throw new Error("--case all cannot be combined with named cases"); + } + if (requestedSuites.includes("all") && requestedSuites.length > 1) { + throw new Error("--suite all cannot be combined with named suites"); + } + + const caseIds = requestedIds.includes("all") ? [] : requestedIds; + const suites = requestedSuites.includes("all") + ? [] + : requestedSuites.length > 0 + ? requestedSuites + : !hasSelector && defaultSuite + ? [defaultSuite] + : []; + + const knownIds = new Set(cases.map(({ id }) => id)); + const missingIds = caseIds.filter((id) => !knownIds.has(id)); + if (missingIds.length > 0) throw new Error(`unknown case(s): ${missingIds.join(", ")}`); + const knownSuites = new Set(cases.map(({ suite }) => suite)); + const missingSuites = suites.filter((suite) => !knownSuites.has(suite)); + if (missingSuites.length > 0) throw new Error(`unknown suite(s): ${missingSuites.join(", ")}`); + const knownTags = new Set(cases.flatMap(({ tags }) => tags)); + const missingTags = requestedTags.filter((tag) => !knownTags.has(tag)); + if (missingTags.length > 0) throw new Error(`unknown tag(s): ${missingTags.join(", ")}`); + + const selected = cases.filter( + (testCase) => + (caseIds.length === 0 || caseIds.includes(testCase.id)) && + (suites.length === 0 || suites.includes(testCase.suite)) && + requestedTags.every((tag) => testCase.tags.includes(tag)), + ); + if (selected.length === 0) throw new Error("case selectors matched no cases"); + return selected; +} + +function reportFilename(mode) { + return `${new Date().toISOString().replaceAll(/[:.]/g, "-")}-${mode}.json`; +} + +async function saveReport(report, outputDirectory) { + await mkdir(outputDirectory, { recursive: true }); + const path = join(outputDirectory, reportFilename(report.mode)); + await writeFile(path, `${JSON.stringify(report, null, 2)}\n`); + return path; +} + +async function loadValidatedCorpus() { + const [cases, fixtureRegistry] = await Promise.all([allTasks(), loadFixtureRegistry()]); + const errors = validateRepositoryCases(cases, fixtureRegistry); + if (errors.length > 0) throw new Error(`browser eval validation failed:\n${errors.join("\n")}`); + return { cases, fixtureRegistry }; +} + +function selectionMetadata(cases) { + return { + cases: cases.map(({ id }) => id), + suites: unique(cases.map(({ suite }) => suite)), + tags: unique(cases.flatMap(({ tags }) => tags)).sort(), + }; +} + +function usage() { + console.log(`BrowserSkill local browser evaluation + +Usage: + node evals/browser/cli.mjs validate [--json] + node evals/browser/cli.mjs list [--suite NAME] [--tag NAME] [--case ID] [--json] + node evals/browser/cli.mjs coverage [--suite NAME] [--tag NAME] [--case ID] [--json] + node evals/browser/cli.mjs serve [--host 127.0.0.1] [--port 4173] + node evals/browser/cli.mjs prompt [--base-url URL] [--run-id ID] [--seed VALUE] + node evals/browser/cli.mjs verify --base-url URL --run-id ID [--response TEXT] + node evals/browser/cli.mjs smoke [--bsk bsk] [--suite core] [--case ID] [--seed VALUE] + node evals/browser/cli.mjs run-agent --config FILE --agent NAME [--suite core] [--case ID] + node evals/browser/cli.mjs generate --seed VALUE [--json] + node evals/browser/cli.mjs scaffold [--suite regression] [--title TEXT] + node evals/browser/cli.mjs summarize [more reports...] + +Selection: + --case ID[,ID] Select case ids; --task is a backward-compatible alias + --suite NAME[,NAME] Select suites + --tag NAME[,NAME] Require every selected tag + --case all Explicitly include every suite (run commands default to core) + --seed VALUE[,VALUE] Run one or more reproducible matrix seeds + +run-agent options: + --agent NAME[,NAME] One or more entries from the config file + --variant NAME Override the report label (does not switch plugin code) + --locale en|zh-CN Prompt language (default zh-CN) + --repeat N Repeat every selected case (default 1) + --timeout MS Per-agent timeout (default 300000) + --out DIRECTORY Report/artifact directory +`); +} + +async function commandValidate(parsed) { + const { cases, fixtureRegistry } = await loadValidatedCorpus(); + const summary = { ok: true, ...repositorySummary(cases, fixtureRegistry) }; + if (option(parsed, "json", false)) console.log(JSON.stringify(summary, null, 2)); + else { + console.log(`Validated ${summary.cases} cases and ${summary.fixtureRoutes} fixture routes.`); + console.log( + `Suites: ${Object.entries(summary.suites) + .map(([suite, count]) => `${suite}=${count}`) + .join(", ")}`, + ); + } +} + +async function commandList(parsed) { + const cases = selectCases(await allTasks(), parsed); + const tasks = listTasks(cases); + if (option(parsed, "json", false)) console.log(JSON.stringify(tasks, null, 2)); + else { + for (const task of tasks) { + console.log(`${task.id}\t${task.suite}\t${task.tags.join(",")}\t${task.title}`); + } + } +} + +async function commandCoverage(parsed) { + const cases = selectCases(await allTasks(), parsed); + const coverage = buildOperationCoverage(cases); + if (option(parsed, "json", false)) { + console.log(JSON.stringify(coverage, null, 2)); + } else { + console.table( + coverage.map(({ operation, agentCases, smokeCases, directSmoke, note }) => ({ + operation, + agent_cases: agentCases.join(", ") || "-", + smoke_cases: smokeCases.join(", ") || "-", + direct_smoke: directSmoke ? "yes" : "manual", + note: note ?? "", + })), + ); + } +} + +async function commandServe(parsed) { + const { fixtureRegistry } = await loadValidatedCorpus(); + const server = createEvalServer({ + host: option(parsed, "host", "127.0.0.1"), + port: numericOption(parsed, "port", 4173), + fixtureRegistry, + }); + const info = await server.start(); + console.log(`Browser eval server listening at ${info.baseUrl}`); + console.log("Press Ctrl-C to stop."); + await new Promise((resolveSignal) => { + process.once("SIGINT", resolveSignal); + process.once("SIGTERM", resolveSignal); + }); + await server.stop(); +} + +async function commandPrompt(parsed) { + const cases = await allTasks(); + const task = getTask(parsed.positionals[0], cases); + const prompt = renderPrompt(task, { + baseUrl: option(parsed, "base-url", "http://127.0.0.1:4173"), + runId: option(parsed, "run-id", randomUUID()), + locale: option(parsed, "locale", "zh-CN"), + seed: seedOption(parsed, task.seed), + }); + console.log(prompt); +} + +async function commandVerify(parsed) { + const task = getTask(parsed.positionals[0], await allTasks()); + const baseUrl = option(parsed, "base-url"); + const runId = option(parsed, "run-id"); + if (!baseUrl || !runId) throw new Error("verify requires --base-url and --run-id"); + let responseText = option(parsed, "response", ""); + const responseFile = option(parsed, "response-file"); + if (responseFile) responseText = await readFile(resolve(responseFile), "utf8"); + const stateResponse = await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runId)}`); + if (!stateResponse.ok) throw new Error(`oracle request failed with ${stateResponse.status}`); + const state = await stateResponse.json(); + const verification = verifyTask(task, { events: state.events, responseText }); + console.log(JSON.stringify(verification, null, 2)); + if (verification.status === "failed") process.exitCode = 1; +} + +async function commandRunAgent(parsed) { + const configPath = option(parsed, "config"); + const agentNames = optionList(parsed, "agent"); + if (!configPath || agentNames.length === 0) { + throw new Error("run-agent requires --config and at least one --agent"); + } + const { cases, fixtureRegistry } = await loadValidatedCorpus(); + const selected = selectCases(cases, parsed, { defaultSuite: "core" }); + const repeat = numericOption(parsed, "repeat", 1); + if (!Number.isInteger(repeat) || repeat < 1) throw new Error("--repeat must be at least 1"); + const outputDirectory = resolve(option(parsed, "out", resultsDirectory)); + const server = createEvalServer({ fixtureRegistry }); + const serverInfo = await server.start(); + const results = []; + const startedAt = new Date().toISOString(); + try { + for (const agentName of agentNames) { + const { adapter, configDirectory } = await loadAgentConfig(configPath, agentName); + for (let iteration = 1; iteration <= repeat; iteration += 1) { + for (const task of selected) { + for (const seed of seedOptions(parsed, task.seed)) { + const seedLabel = seed === undefined ? "" : ` seed=${seed}`; + console.log(`[run] ${agentName} ${task.id}${seedLabel} (${iteration}/${repeat})`); + const result = await runCommandTask({ + server, + serverInfo, + task, + adapter, + adapterName: agentName, + configDirectory, + iteration, + locale: option(parsed, "locale", "zh-CN"), + seed, + timeoutMs: numericOption(parsed, "timeout", 300_000), + variantOverride: option(parsed, "variant"), + }); + results.push(result); + console.log(` ${result.verification.status} in ${result.execution.durationMs}ms`); + } + } + } + } + } finally { + await server.stop(); + } + const report = { + schemaVersion: 1, + mode: "agent", + startedAt, + finishedAt: new Date().toISOString(), + selection: selectionMetadata(selected), + seeds: unique(results.map(({ seed }) => seed).filter((seed) => seed !== undefined)), + results, + }; + const path = await saveReport(report, outputDirectory); + printSummary(summarizeReports([report])); + console.log(`Report: ${path}`); + if ( + results.some( + (result) => + result.verification.status === "failed" || + result.execution.timedOut || + result.execution.exitCode !== 0, + ) + ) { + process.exitCode = 1; + } +} + +async function commandSmoke(parsed) { + const { cases, fixtureRegistry } = await loadValidatedCorpus(); + const selected = selectCases(cases, parsed, { defaultSuite: "core" }); + const outputDirectory = resolve(option(parsed, "out", resultsDirectory)); + const server = createEvalServer({ fixtureRegistry }); + const serverInfo = await server.start(); + const results = []; + const startedAt = new Date().toISOString(); + try { + for (const task of selected) { + for (const seed of seedOptions(parsed, task.seed)) { + const runId = `bsk-${task.id}-${randomUUID().slice(0, 8)}`; + const seedLabel = seed === undefined ? "" : ` seed=${seed}`; + console.log(`[smoke] ${task.id}${seedLabel}`); + const result = await runBskSmokeTask({ + bskCommand: option(parsed, "bsk", "bsk"), + outputDirectory, + server, + serverInfo, + task, + runId, + seed, + timeoutMs: numericOption(parsed, "timeout", 60_000), + }); + results.push(result); + console.log( + ` ${result.verification.status}${result.executionError ? `: ${result.executionError}` : ""}`, + ); + } + } + } finally { + await server.stop(); + } + const report = { + schemaVersion: 1, + mode: "bsk-smoke", + startedAt, + finishedAt: new Date().toISOString(), + selection: selectionMetadata(selected), + seeds: unique(results.map(({ seed }) => seed).filter((seed) => seed !== undefined)), + results, + }; + const path = await saveReport(report, outputDirectory); + printSummary(summarizeReports([report])); + console.log(`Report: ${path}`); + if (results.some((result) => result.executionError || result.verification.status === "failed")) { + process.exitCode = 1; + } +} + +async function commandGenerate(parsed) { + if (!parsed.options.has("seed")) throw new Error("generate requires --seed"); + const dimensions = unique(optionList(parsed, "seed").map(normalizeSeed)).map(describeSeed); + const output = dimensions.length === 1 ? dimensions[0] : dimensions; + if (option(parsed, "json", false)) console.log(JSON.stringify(output, null, 2)); + else console.table(dimensions); +} + +async function commandScaffold(parsed) { + const id = parsed.positionals[0]; + if (!id) throw new Error("scaffold requires a case id"); + const result = await scaffoldCase({ + id, + title: option(parsed, "title", id), + suite: option(parsed, "suite", "regression"), + source: option(parsed, "source"), + }); + if (option(parsed, "json", false)) console.log(JSON.stringify(result, null, 2)); + else { + console.log(`Created ${result.directory}`); + console.log( + "Edit the fixture, prompt, workflow, and assertions, then run eval:browser validate.", + ); + } +} + +async function commandSummarize(parsed) { + const paths = [...parsed.positionals, ...optionList(parsed, "input")]; + if (paths.length === 0) throw new Error("summarize requires at least one report path"); + const reports = await Promise.all( + paths.map(async (path) => JSON.parse(await readFile(resolve(path), "utf8"))), + ); + const rows = summarizeReports(reports); + if (option(parsed, "json", false)) console.log(JSON.stringify(rows, null, 2)); + else printSummary(rows); +} + +async function main() { + const parsed = parseArguments(process.argv.slice(2)); + if (!parsed.command || option(parsed, "help", false)) return usage(); + switch (parsed.command) { + case "validate": + return commandValidate(parsed); + case "list": + return commandList(parsed); + case "coverage": + return commandCoverage(parsed); + case "serve": + return commandServe(parsed); + case "prompt": + return commandPrompt(parsed); + case "verify": + return commandVerify(parsed); + case "run-agent": + return commandRunAgent(parsed); + case "smoke": + return commandSmoke(parsed); + case "generate": + return commandGenerate(parsed); + case "scaffold": + return commandScaffold(parsed); + case "summarize": + return commandSummarize(parsed); + default: + throw new Error(`unknown command ${JSON.stringify(parsed.command)}`); + } +} + +main().catch((error) => { + console.error(`browser eval: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; +}); diff --git a/evals/browser/fixtures/pages/diagnostics.fixture.mjs b/evals/browser/fixtures/pages/diagnostics.fixture.mjs new file mode 100644 index 00000000..45dd4eec --- /dev/null +++ b/evals/browser/fixtures/pages/diagnostics.fixture.mjs @@ -0,0 +1,18 @@ +import { page } from "../../lib/fixtures.mjs"; + +export default { + id: "diagnostics", + routes: ["/diagnostics"], + render() { + return page({ + title: "Diagnostics Lab", + body: `

Diagnostics Lab

Inspect console and network buffers.

Ping pending

`, + script: ` + console.log("Browser eval console marker: CONSOLE-61"); + fetch("/api/ping?run=" + encodeURIComponent(browserEval.runId)) + .then((response) => response.json()) + .then(({ token }) => { document.querySelector("#network-status").textContent = "Ping completed: " + token; }); + `, + }); + }, +}; diff --git a/evals/browser/fixtures/pages/form.fixture.mjs b/evals/browser/fixtures/pages/form.fixture.mjs new file mode 100644 index 00000000..0932e499 --- /dev/null +++ b/evals/browser/fixtures/pages/form.fixture.mjs @@ -0,0 +1,25 @@ +import { escapeHtml, page } from "../../lib/fixtures.mjs"; + +export default { + id: "form", + routes: ["/form", "/result"], + render({ pathname, runId, query, record }) { + if (pathname === "/form") { + return page({ + title: "Browser Eval Form", + body: `

Web form

`, + script: `document.querySelector("#submit").addEventListener("keydown", (event) => { if (event.key === "Enter") browserEval.send("form.enter_pressed"); });`, + }); + } + const values = { + text: query.get("text") ?? "", + notes: query.get("notes") ?? "", + choice: query.get("choice") ?? "", + }; + record("form.submitted", values, { path: pathname }); + return page({ + title: "Form Result", + body: `

Received!

${escapeHtml(values.text)} | ${escapeHtml(values.notes)} | ${escapeHtml(values.choice)}

`, + }); + }, +}; diff --git a/evals/browser/fixtures/pages/generated-form.fixture.mjs b/evals/browser/fixtures/pages/generated-form.fixture.mjs new file mode 100644 index 00000000..9fd0112a --- /dev/null +++ b/evals/browser/fixtures/pages/generated-form.fixture.mjs @@ -0,0 +1,71 @@ +import { escapeHtml, page } from "../../lib/fixtures.mjs"; +import { describeSeed } from "../../lib/seeded-generator.mjs"; + +function nest(content, depth) { + let nested = content; + for (let index = 0; index < depth; index += 1) { + nested = `
${nested}
`; + } + return nested; +} + +function textField(dimensions) { + const id = `matrix-text-${dimensions.idSuffix}`; + const input = ``; + if (dimensions.labelMode === "wrapped") return ``; + if (dimensions.labelMode === "for") return `${input}`; + return `Text input`; +} + +function formMarkup(dimensions, runId) { + const text = textField(dimensions); + const select = ``; + const fields = dimensions.fieldOrder === "text-first" ? `${text}${select}` : `${select}${text}`; + const decoy = + dimensions.decoy === "disabled-input" + ? `` + : dimensions.decoy === "duplicate-text" + ? `

Text input is intentionally repeated in helper copy.

` + : ""; + return nest( + `
${decoy}${fields}
`, + dimensions.nestingDepth, + ); +} + +export default { + id: "generated-form", + routes: ["/matrix/generated-form", "/matrix/generated-result"], + render({ pathname, runId, query, record }) { + const dimensions = describeSeed(query.get("seed") ?? 1); + if (pathname === "/matrix/generated-form") { + const markup = JSON.stringify(formMarkup(dimensions, runId)); + const dimensionsJson = JSON.stringify(dimensions); + return page({ + title: "Seeded Matrix Form", + body: `

Seeded Matrix Form

SEED-${dimensions.seed}

Hydrating…
`, + script: ` + const hydrate = () => { + const root = document.querySelector("#matrix-root"); + root.innerHTML = ${markup}; + root.querySelector("button[type=submit]").addEventListener("keydown", (event) => { + if (event.key === "Enter") browserEval.send("matrix.enter_pressed"); + }); + browserEval.send("matrix.ready", ${dimensionsJson}); + }; + setTimeout(hydrate, ${dimensions.hydrationDelayMs}); + `, + }); + } + const values = { + seed: dimensions.seed, + text: query.get("text") ?? "", + choice: query.get("choice") ?? "", + }; + record("matrix.submitted", values, { path: pathname }); + return page({ + title: "Matrix Result", + body: `

Matrix Result

MATRIX-OK

${escapeHtml(values.text)} | ${escapeHtml(values.choice)} | seed ${values.seed}

`, + }); + }, +}; diff --git a/evals/browser/fixtures/pages/hover-inspect.fixture.mjs b/evals/browser/fixtures/pages/hover-inspect.fixture.mjs new file mode 100644 index 00000000..12498778 --- /dev/null +++ b/evals/browser/fixtures/pages/hover-inspect.fixture.mjs @@ -0,0 +1,17 @@ +import { page } from "../../lib/fixtures.mjs"; + +export default { + id: "hover-inspect", + routes: ["/hover-inspect"], + render() { + return page({ + title: "Hover and Inspect", + body: `

Hover and Inspect

Inspection card

Local HTML marker: INSPECT-27

`, + script: ` + const menu = document.querySelector("#account-menu"); + menu.addEventListener("mouseenter", () => { menu.classList.add("open"); browserEval.send("hover.menu_opened"); }); + document.querySelector("#profile-link").addEventListener("click", () => { document.querySelector("#hover-result").hidden = false; browserEval.send("hover.profile_clicked"); }); + `, + }); + }, +}; diff --git a/evals/browser/fixtures/pages/index.fixture.mjs b/evals/browser/fixtures/pages/index.fixture.mjs new file mode 100644 index 00000000..517f2c49 --- /dev/null +++ b/evals/browser/fixtures/pages/index.fixture.mjs @@ -0,0 +1,12 @@ +import { page } from "../../lib/fixtures.mjs"; + +export default { + id: "fixture-index", + routes: ["/"], + render() { + return page({ + title: "Browser Eval Fixtures", + body: `

Browser Eval Fixtures

Deterministic local pages for agent-neutral browser evaluation.

`, + }); + }, +}; diff --git a/evals/browser/fixtures/pages/navigation.fixture.mjs b/evals/browser/fixtures/pages/navigation.fixture.mjs new file mode 100644 index 00000000..af103da2 --- /dev/null +++ b/evals/browser/fixtures/pages/navigation.fixture.mjs @@ -0,0 +1,19 @@ +import { page, withRun } from "../../lib/fixtures.mjs"; + +export default { + id: "navigation", + routes: ["/navigation/start", "/navigation/detail"], + render({ pathname, runId }) { + if (pathname === "/navigation/start") { + return page({ + title: "Navigation Start", + body: `

Navigation Start

Use the link to open the deterministic detail page.

Details
`, + script: `document.querySelector("#details-link").addEventListener("click", () => browserEval.send("navigation.detail.clicked"));`, + }); + } + return page({ + title: "Navigation Detail", + body: `

Navigation Detail

Stable detail code:

`, + }); + }, +}; diff --git a/evals/browser/fixtures/pages/responsive.fixture.mjs b/evals/browser/fixtures/pages/responsive.fixture.mjs new file mode 100644 index 00000000..7dafa6dc --- /dev/null +++ b/evals/browser/fixtures/pages/responsive.fixture.mjs @@ -0,0 +1,21 @@ +import { page } from "../../lib/fixtures.mjs"; + +export default { + id: "responsive", + routes: ["/responsive"], + render() { + return page({ + title: "Responsive Lab", + body: `

Responsive Lab

VIEWPORT-84

`, + script: ` + const sample = () => { + const data = { width: innerWidth, height: innerHeight, touchPoints: navigator.maxTouchPoints, userAgent: navigator.userAgent }; + document.querySelector("#viewport-value").textContent = data.width + "x" + data.height; + browserEval.send("viewport.sample", data); + }; + window.addEventListener("resize", sample); + sample(); + `, + }); + }, +}; diff --git a/evals/browser/fixtures/pages/tabs.fixture.mjs b/evals/browser/fixtures/pages/tabs.fixture.mjs new file mode 100644 index 00000000..c1cebedc --- /dev/null +++ b/evals/browser/fixtures/pages/tabs.fixture.mjs @@ -0,0 +1,19 @@ +import { escapeHtml, page, withRun } from "../../lib/fixtures.mjs"; + +export default { + id: "tabs", + routes: ["/tabs", "/tabs/child"], + render({ pathname, runId }) { + if (pathname === "/tabs") { + return page({ + title: "Tab Lab", + body: `

Tab Lab

Keep this original tab open while visiting the child page in a new tab.

Child URL: ${escapeHtml(withRun("/tabs/child", runId))}

`, + }); + } + return page({ + title: "Child Tab", + body: `

Child Tab

TAB-29

`, + script: `document.querySelector("#mark-child").addEventListener("click", () => { document.querySelector("#child-status").hidden = false; browserEval.send("tabs.child_marked"); });`, + }); + }, +}; diff --git a/evals/browser/lib/agent-runner.mjs b/evals/browser/lib/agent-runner.mjs new file mode 100644 index 00000000..ce3184eb --- /dev/null +++ b/evals/browser/lib/agent-runner.mjs @@ -0,0 +1,99 @@ +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +import { verifyTask } from "./oracle.mjs"; +import { countPattern, runProcess, substituteArgs } from "./process.mjs"; +import { renderPrompt } from "./tasks.mjs"; + +function safeRunPart(value) { + return value.replaceAll(/[^A-Za-z0-9._-]/g, "-").slice(0, 48); +} + +export async function loadAgentConfig(configPath, agentName) { + const absolute = resolve(configPath); + const config = JSON.parse(await readFile(absolute, "utf8")); + const adapter = config.agents?.[agentName]; + if (!adapter) { + const names = Object.keys(config.agents ?? {}).join(", "); + throw new Error(`unknown agent ${JSON.stringify(agentName)}; configured agents: ${names}`); + } + if (typeof adapter.command !== "string" || !Array.isArray(adapter.args)) { + throw new Error(`agent ${JSON.stringify(agentName)} must define command and args`); + } + return { adapter, configDirectory: dirname(absolute) }; +} + +export async function runCommandTask({ + server, + serverInfo, + task, + adapter, + adapterName, + configDirectory, + iteration, + locale, + seed = task.seed, + timeoutMs, + variantOverride, +}) { + const variant = variantOverride ?? adapter.variant ?? "unspecified"; + const runId = [ + safeRunPart(adapterName), + safeRunPart(variant), + safeRunPart(task.id), + iteration, + randomUUID().slice(0, 8), + ].join("-"); + server.reset(runId); + const prompt = renderPrompt(task, { baseUrl: serverInfo.baseUrl, runId, locale, seed }); + const values = { + prompt, + caseId: task.id, + taskId: task.id, + runId, + baseUrl: serverInfo.baseUrl, + seed: seed ?? "", + variant, + }; + const args = substituteArgs(adapter.args, values); + const execution = await runProcess(adapter.command, args, { + cwd: adapter.cwd ? resolve(configDirectory, adapter.cwd) : configDirectory, + env: adapter.env, + timeoutMs: adapter.timeoutMs ?? timeoutMs, + }); + + await new Promise((resolveDelay) => setTimeout(resolveDelay, 250)); + const responseText = execution.stdout; + const combinedOutput = `${execution.stdout}\n${execution.stderr}`; + const verification = verifyTask(task, { + events: server.snapshot(runId).events, + responseText, + }); + const defaultErrorPattern = "(?:\\bError:|timed out|unfinished command|session already has)"; + return { + runId, + caseId: task.id, + taskId: task.id, + suite: task.suite, + tags: task.tags, + seed, + iteration, + adapter: adapterName, + variant, + adapterMetadata: adapter.metadata ?? {}, + locale, + prompt, + execution, + metrics: { + errorCount: countPattern( + combinedOutput, + adapter.metrics?.errorPattern ?? defaultErrorPattern, + ), + toolCallCount: countPattern(combinedOutput, adapter.metrics?.toolCallPattern), + responseChars: responseText.length, + }, + eventCount: server.snapshot(runId).events.length, + verification, + }; +} diff --git a/evals/browser/lib/bsk-runner.mjs b/evals/browser/lib/bsk-runner.mjs new file mode 100644 index 00000000..25a40b5c --- /dev/null +++ b/evals/browser/lib/bsk-runner.mjs @@ -0,0 +1,309 @@ +import { mkdir, stat } from "node:fs/promises"; +import { join } from "node:path"; + +import { verifyTask } from "./oracle.mjs"; +import { runProcess } from "./process.mjs"; +import { taskUrl } from "./tasks.mjs"; + +function parseJsonOutput(step) { + try { + return JSON.parse(step.stdout); + } catch (error) { + throw new Error(`could not parse bsk JSON output: ${error.message}`); + } +} + +function renderStepError(step) { + const raw = step.stderr.trim() || step.stdout.trim(); + if (!raw) return `exit ${step.exitCode}`; + try { + const parsed = JSON.parse(raw); + return parsed.hint ? `${parsed.message} (hint: ${parsed.hint})` : parsed.message; + } catch { + return raw; + } +} + +async function hasNonemptyFile(path) { + try { + return (await stat(path)).size > 0; + } catch { + return false; + } +} + +function readPath(value, path) { + return path.split(".").reduce((current, part) => current?.[part], value); +} + +function resolveValue(value, variables) { + if (typeof value !== "string") return value; + const exact = value.match(/^\{([^{}]+)}$/); + if (exact) { + const resolved = readPath(variables, exact[1]); + if (resolved === undefined) throw new Error(`unknown workflow variable ${exact[1]}`); + return resolved; + } + return value.replaceAll(/\{([^{}]+)\}/g, (_match, path) => { + const resolved = readPath(variables, path); + if (resolved === undefined) throw new Error(`unknown workflow variable ${path}`); + return String(resolved); + }); +} + +function targetArgs(step, variables) { + if (step.selector) return ["--selector", String(resolveValue(step.selector, variables))]; + if (step.ref) return ["--ref", String(resolveValue(step.ref, variables))]; + return []; +} + +function tabArgs(step, variables) { + return step.tabId === undefined ? [] : ["--tab-id", String(resolveValue(step.tabId, variables))]; +} + +function matchesWhere(value, where = {}) { + return Object.entries(where).every(([path, expected]) => readPath(value, path) === expected); +} + +export async function runBskSmokeTask({ + bskCommand, + outputDirectory, + server, + serverInfo, + task, + runId, + seed = task.seed, + timeoutMs = 60_000, +}) { + const steps = []; + const evidence = {}; + const variables = { + baseUrl: serverInfo.baseUrl, + runId, + seed: seed ?? "", + url: taskUrl(task, serverInfo.baseUrl, runId, seed), + }; + let sessionId; + let executionError; + await mkdir(outputDirectory, { recursive: true }); + + async function bsk(args, { timeout = timeoutMs } = {}) { + const execution = await runProcess(bskCommand, [...args, "--json"], { timeoutMs: timeout }); + steps.push(execution); + if (execution.exitCode !== 0 || execution.timedOut || execution.error) { + throw new Error( + `bsk ${args.join(" ")} failed: ${execution.error ?? renderStepError(execution)}`, + ); + } + return parseJsonOutput(execution); + } + + async function waitForSiteEvent(step) { + const timeout = step.timeoutMs ?? 3_000; + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if ( + server + .snapshot(runId) + .events.some( + (event) => event.type === step.type && matchesWhere(event.data ?? {}, step.where ?? {}), + ) + ) { + return { observed: true }; + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, 50)); + } + throw new Error(`local fixture did not report ${step.type} within ${timeout}ms`); + } + + async function executeWorkflowStep(step) { + const session = ["--session", sessionId]; + let result; + switch (step.action) { + case "navigate": + result = await bsk([ + "navigate", + String(resolveValue(step.url ?? "{url}", variables)), + ...session, + ...tabArgs(step, variables), + ]); + break; + case "back": + case "forward": + result = await bsk([`navigate-${step.action}`, ...session, ...tabArgs(step, variables)]); + break; + case "reload": + result = await bsk(["reload", ...session, ...tabArgs(step, variables)]); + break; + case "wait": + result = await bsk(["wait-ms", String(resolveValue(step.duration, variables))]); + break; + case "wait-site-event": + result = await waitForSiteEvent(step); + break; + case "observe": + case "snapshot": + case "html": { + const command = step.action === "html" ? "get-html" : step.action; + result = await bsk([command, ...session, ...tabArgs(step, variables)]); + break; + } + case "screenshot": { + const name = resolveValue(step.artifactName ?? "screenshot.png", variables); + const path = join(outputDirectory, `${runId}-${name}`); + result = await bsk(["screenshot", ...session, ...tabArgs(step, variables), "--out", path]); + if (step.evidence) evidence[step.evidence] = await hasNonemptyFile(path); + break; + } + case "console": + case "network": + result = await bsk([step.action, ...session, ...tabArgs(step, variables)]); + break; + case "click": + case "hover": + result = await bsk([ + step.action, + ...targetArgs(step, variables), + ...session, + ...tabArgs(step, variables), + ]); + break; + case "fill": + result = await bsk([ + "fill", + ...targetArgs(step, variables), + "--value", + String(resolveValue(step.value, variables)), + ...session, + ...tabArgs(step, variables), + ]); + break; + case "select": + result = await bsk([ + "select", + ...targetArgs(step, variables), + ...step.values.flatMap((value) => ["--value", String(resolveValue(value, variables))]), + ...session, + ...tabArgs(step, variables), + ]); + break; + case "press": + result = await bsk([ + "press", + String(resolveValue(step.key, variables)), + ...targetArgs(step, variables), + ...session, + ...tabArgs(step, variables), + ]); + break; + case "tab-list": + result = await bsk(["tab", "list", ...session]); + break; + case "tab-create": + result = await bsk([ + "tab", + "create", + ...session, + ...(step.url ? ["--url", String(resolveValue(step.url, variables))] : []), + ]); + break; + case "tab-select": + case "tab-close": + result = await bsk([ + "tab", + step.action.slice(4), + String(resolveValue(step.tabId, variables)), + ...session, + ]); + break; + case "borrow": + case "return": + result = await bsk([ + "tab", + step.action, + String(resolveValue(step.tabId, variables)), + ...session, + ]); + break; + case "resize": + result = await bsk([ + "window", + "resize", + ...session, + "--width", + String(step.width), + "--height", + String(step.height), + ]); + break; + case "emulate": + result = await bsk([ + "emulate", + ...session, + ...tabArgs(step, variables), + "--device", + String(resolveValue(step.device, variables)), + ]); + break; + case "request-help": + result = await bsk([ + "request-help", + ...session, + "--prompt", + String(resolveValue(step.prompt, variables)), + ...(step.title ? ["--title", String(resolveValue(step.title, variables))] : []), + ...(step.timeout ? ["--timeout", String(step.timeout)] : []), + ]); + break; + default: + throw new Error(`unsupported smoke workflow action ${step.action}`); + } + if (step.saveAs) variables[step.saveAs] = result; + if (step.evidence && step.action !== "screenshot") evidence[step.evidence] = true; + } + + server.reset(runId); + try { + if (task.smokeSteps.length === 0) throw new Error(`case ${task.id} has no smoke workflow`); + const started = await bsk(["session", "start", "--no-focus"], { timeout: 90_000 }); + sessionId = started.session_id; + variables.sessionId = sessionId; + await bsk(["session", "list"]); + for (const step of task.smokeSteps) await executeWorkflowStep(step); + } catch (error) { + executionError = error instanceof Error ? error.message : String(error); + } finally { + if (sessionId) { + try { + const stopped = await bsk(["session", "stop", sessionId], { timeout: 90_000 }); + evidence.sessionStopped = + stopped.stopped?.includes(sessionId) === true && (stopped.failed?.length ?? 0) === 0; + if (!evidence.sessionStopped) { + executionError ??= `bsk did not confirm that session ${sessionId} stopped`; + } + } catch (error) { + evidence.sessionStopped = false; + executionError ??= error instanceof Error ? error.message : String(error); + } + } + } + + await new Promise((resolveDelay) => setTimeout(resolveDelay, 350)); + const responseText = steps.map(({ stdout }) => stdout).join("\n"); + const events = server.snapshot(runId).events; + return { + runId, + caseId: task.id, + taskId: task.id, + suite: task.suite, + tags: task.tags, + seed, + adapter: "bsk-direct", + variant: "cli-direct", + executionError, + steps, + evidence, + eventCount: events.length, + verification: verifyTask(task, { events, responseText, adapterEvidence: evidence }), + }; +} diff --git a/evals/browser/lib/case-loader.mjs b/evals/browser/lib/case-loader.mjs new file mode 100644 index 00000000..d3b98dd5 --- /dev/null +++ b/evals/browser/lib/case-loader.mjs @@ -0,0 +1,216 @@ +import { readFile } from "node:fs/promises"; + +import { discoverFiles } from "./discovery.mjs"; +import { OPERATION_NAMES, WORKFLOW_ACTIONS } from "./operations.mjs"; +import { casesDirectory as defaultCasesDirectory } from "./paths.mjs"; + +const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const EVIDENCE_PATTERN = /^[A-Za-z][A-Za-z0-9]*$/; +const TOP_LEVEL_KEYS = new Set([ + "$schema", + "schemaVersion", + "id", + "title", + "order", + "suite", + "tags", + "seed", + "source", + "fixture", + "prompts", + "coverage", + "assertions", + "smoke", +]); + +function isObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function checkString(errors, value, path) { + if (typeof value !== "string" || value.trim() === "") errors.push(`${path} must be a string`); +} + +function validateAssertion(errors, assertion, path, kind) { + if (!isObject(assertion)) { + errors.push(`${path} must be an object`); + return; + } + checkString(errors, assertion.label, `${path}.label`); + if (kind === "site") { + checkString(errors, assertion.type, `${path}.type`); + if (!Number.isInteger(assertion.minCount) || assertion.minCount < 1) { + errors.push(`${path}.minCount must be a positive integer`); + } + if (assertion.where !== undefined && !isObject(assertion.where)) { + errors.push(`${path}.where must be an object`); + } + } else if (kind === "response") { + checkString(errors, assertion.includes, `${path}.includes`); + } else { + checkString(errors, assertion.key, `${path}.key`); + } +} + +function validateWorkflowStep(errors, step, path) { + if (!isObject(step)) { + errors.push(`${path} must be an object`); + return; + } + if (!WORKFLOW_ACTIONS.has(step.action)) { + errors.push(`${path}.action is unsupported: ${JSON.stringify(step.action)}`); + } + if (step.saveAs !== undefined && !ID_PATTERN.test(step.saveAs)) { + errors.push(`${path}.saveAs must use lowercase kebab-case`); + } + if (step.evidence !== undefined && !EVIDENCE_PATTERN.test(step.evidence)) { + errors.push(`${path}.evidence must be an identifier`); + } + if (["click", "hover", "fill", "select"].includes(step.action) && !step.selector && !step.ref) { + errors.push(`${path} requires selector or ref`); + } + if (step.action === "fill") checkString(errors, step.value, `${path}.value`); + if (step.action === "select") { + if (!Array.isArray(step.values) || step.values.length === 0) { + errors.push(`${path}.values must be a non-empty array`); + } else if (step.values.some((value) => typeof value !== "string")) { + errors.push(`${path}.values must contain only strings`); + } + } + if (step.action === "press") checkString(errors, step.key, `${path}.key`); + if (step.action === "wait") checkString(errors, step.duration, `${path}.duration`); + if (step.action === "wait-site-event") checkString(errors, step.type, `${path}.type`); + if (["tab-select", "tab-close", "borrow", "return"].includes(step.action)) { + checkString(errors, step.tabId, `${path}.tabId`); + } + if (step.action === "resize") { + if ( + !Number.isInteger(step.width) || + step.width < 1 || + !Number.isInteger(step.height) || + step.height < 1 + ) { + errors.push(`${path}.width and height must be positive integers`); + } + } + if (step.action === "emulate") checkString(errors, step.device, `${path}.device`); + if (step.action === "request-help") checkString(errors, step.prompt, `${path}.prompt`); + if (step.timeoutMs !== undefined && (!Number.isInteger(step.timeoutMs) || step.timeoutMs < 1)) { + errors.push(`${path}.timeoutMs must be a positive integer`); + } +} + +export function validateCaseManifest(manifest, { source = "case manifest" } = {}) { + const errors = []; + if (!isObject(manifest)) return [`${source} must contain a JSON object`]; + for (const key of Object.keys(manifest)) { + if (!TOP_LEVEL_KEYS.has(key)) errors.push(`unknown top-level property ${key}`); + } + if (manifest.schemaVersion !== 1) errors.push("schemaVersion must equal 1"); + if (!ID_PATTERN.test(manifest.id ?? "")) errors.push("id must use lowercase kebab-case"); + checkString(errors, manifest.title, "title"); + if (manifest.order !== undefined && (!Number.isInteger(manifest.order) || manifest.order < 0)) { + errors.push("order must be a non-negative integer"); + } + if (!ID_PATTERN.test(manifest.suite ?? "")) errors.push("suite must use lowercase kebab-case"); + if (!Array.isArray(manifest.tags) || manifest.tags.some((tag) => !ID_PATTERN.test(tag))) { + errors.push("tags must be an array of lowercase kebab-case strings"); + } else if (new Set(manifest.tags).size !== manifest.tags.length) { + errors.push("tags must not contain duplicates"); + } + if (!isObject(manifest.fixture)) errors.push("fixture must be an object"); + else if ( + typeof manifest.fixture.startPath !== "string" || + !manifest.fixture.startPath.startsWith("/") + ) { + errors.push("fixture.startPath must be an absolute URL path"); + } + if (!isObject(manifest.prompts)) errors.push("prompts must be an object"); + else { + checkString(errors, manifest.prompts.en, "prompts.en"); + checkString(errors, manifest.prompts["zh-CN"], "prompts.zh-CN"); + } + if (!Array.isArray(manifest.coverage)) errors.push("coverage must be an array"); + else { + for (const operation of manifest.coverage) { + if (!OPERATION_NAMES.has(operation)) + errors.push(`coverage contains unknown operation ${operation}`); + } + if (new Set(manifest.coverage).size !== manifest.coverage.length) { + errors.push("coverage must not contain duplicates"); + } + } + if (!isObject(manifest.assertions)) errors.push("assertions must be an object"); + else { + for (const kind of ["site", "response", "adapter"]) { + if (!Array.isArray(manifest.assertions[kind])) { + errors.push(`assertions.${kind} must be an array`); + } else { + manifest.assertions[kind].forEach((assertion, index) => + validateAssertion(errors, assertion, `assertions.${kind}[${index}]`, kind), + ); + } + } + } + if (!isObject(manifest.smoke) || !Array.isArray(manifest.smoke.steps)) { + errors.push("smoke.steps must be an array"); + } else { + manifest.smoke.steps.forEach((step, index) => + validateWorkflowStep(errors, step, `smoke.steps[${index}]`), + ); + } + if (manifest.seed !== undefined && !Number.isSafeInteger(manifest.seed)) { + errors.push("seed must be a safe integer"); + } + if (manifest.source !== undefined && !isObject(manifest.source)) { + errors.push("source must be an object"); + } + return errors.map((error) => `${source}: ${error}`); +} + +function normalizeCase(manifest, sourceFile) { + return Object.freeze({ + ...manifest, + startPath: manifest.fixture.startPath, + siteAssertions: manifest.assertions.site, + responseAssertions: manifest.assertions.response, + adapterAssertions: manifest.assertions.adapter, + smokeSteps: manifest.smoke.steps, + sourceFile, + }); +} + +export async function loadCaseManifests({ casesDirectory = defaultCasesDirectory } = {}) { + const files = await discoverFiles(casesDirectory, (path) => path.endsWith(".case.json")); + const cases = []; + const ids = new Map(); + const errors = []; + for (const file of files) { + let manifest; + try { + manifest = JSON.parse(await readFile(file, "utf8")); + } catch (error) { + errors.push(`${file}: invalid JSON: ${error.message}`); + continue; + } + const manifestErrors = validateCaseManifest(manifest, { source: file }); + errors.push(...manifestErrors); + if (typeof manifest?.id === "string") { + if (ids.has(manifest.id)) { + errors.push(`${file}: duplicate id also declared by ${ids.get(manifest.id)}`); + } else { + ids.set(manifest.id, file); + } + } + if (manifestErrors.length === 0) cases.push(normalizeCase(manifest, file)); + } + if (files.length === 0) errors.push(`${casesDirectory}: no .case.json files found`); + if (errors.length > 0) throw new Error(`case validation failed:\n${errors.join("\n")}`); + cases.sort( + (left, right) => + left.suite.localeCompare(right.suite) || + (left.order ?? Number.MAX_SAFE_INTEGER) - (right.order ?? Number.MAX_SAFE_INTEGER) || + left.id.localeCompare(right.id), + ); + return Object.freeze(cases); +} diff --git a/evals/browser/lib/coverage.mjs b/evals/browser/lib/coverage.mjs new file mode 100644 index 00000000..56c91974 --- /dev/null +++ b/evals/browser/lib/coverage.mjs @@ -0,0 +1,26 @@ +import { OPERATION_CATALOG, WORKFLOW_ACTION_OPERATIONS } from "./operations.mjs"; + +export function buildOperationCoverage(cases) { + return OPERATION_CATALOG.map(({ operation, manualNote }) => { + const agentCases = cases + .filter((testCase) => testCase.coverage.includes(operation)) + .map(({ id }) => id); + let smokeCases; + if (["session.start", "session.stop", "session.list"].includes(operation)) { + smokeCases = cases.filter(({ smokeSteps }) => smokeSteps.length > 0).map(({ id }) => id); + } else { + smokeCases = cases + .filter(({ smokeSteps }) => + smokeSteps.some((step) => WORKFLOW_ACTION_OPERATIONS[step.action] === operation), + ) + .map(({ id }) => id); + } + return { + operation, + agentCases, + smokeCases, + directSmoke: smokeCases.length > 0, + note: smokeCases.length === 0 && manualNote ? `manual lane: ${manualNote}` : undefined, + }; + }); +} diff --git a/evals/browser/lib/discovery.mjs b/evals/browser/lib/discovery.mjs new file mode 100644 index 00000000..8dc5db99 --- /dev/null +++ b/evals/browser/lib/discovery.mjs @@ -0,0 +1,24 @@ +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; + +export async function discoverFiles(root, predicate) { + const files = []; + + async function visit(directory) { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return; + throw error; + } + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + const path = join(directory, entry.name); + if (entry.isDirectory()) await visit(path); + else if (entry.isFile() && predicate(path)) files.push(path); + } + } + + await visit(root); + return files; +} diff --git a/evals/browser/lib/fixture-registry.mjs b/evals/browser/lib/fixture-registry.mjs new file mode 100644 index 00000000..0afd5e03 --- /dev/null +++ b/evals/browser/lib/fixture-registry.mjs @@ -0,0 +1,68 @@ +import { pathToFileURL } from "node:url"; + +import { discoverFiles } from "./discovery.mjs"; +import { casesDirectory, fixturesDirectory } from "./paths.mjs"; + +export async function loadFixtureRegistry({ roots = [fixturesDirectory, casesDirectory] } = {}) { + const files = ( + await Promise.all( + roots.map((root) => discoverFiles(root, (path) => path.endsWith(".fixture.mjs"))), + ) + ).flat(); + const routes = new Map(); + const definitions = []; + const errors = []; + + for (const file of files.sort()) { + let definition; + try { + definition = (await import(pathToFileURL(file).href)).default; + } catch (error) { + errors.push(`${file}: could not import fixture: ${error.message}`); + continue; + } + if (!definition || typeof definition.id !== "string") { + errors.push(`${file}: default export must define id`); + continue; + } + if (!Array.isArray(definition.routes) || definition.routes.length === 0) { + errors.push(`${file}: default export must define non-empty routes`); + continue; + } + if (typeof definition.render !== "function") { + errors.push(`${file}: default export must define render(context)`); + continue; + } + definitions.push({ ...definition, sourceFile: file }); + for (const route of definition.routes) { + if (typeof route !== "string" || !route.startsWith("/")) { + errors.push(`${file}: invalid route ${JSON.stringify(route)}`); + } else if (routes.has(route)) { + errors.push( + `${file}: route ${route} is already registered by ${routes.get(route).sourceFile}`, + ); + } else { + routes.set(route, { definition, sourceFile: file }); + } + } + } + + if (errors.length > 0) throw new Error(`fixture validation failed:\n${errors.join("\n")}`); + return Object.freeze({ + definitions: Object.freeze(definitions), + routes: Object.freeze([...routes.keys()].sort()), + hasRoute(pathname) { + return routes.has(pathname); + }, + render(pathname, context) { + const entry = routes.get(pathname); + return entry ? entry.definition.render({ ...context, pathname }) : null; + }, + }); +} + +export function validateCaseFixtureLinks(cases, fixtureRegistry) { + return cases + .filter(({ startPath }) => !fixtureRegistry.hasRoute(startPath)) + .map(({ id, startPath }) => `case ${id} references unregistered fixture route ${startPath}`); +} diff --git a/evals/browser/lib/fixtures.mjs b/evals/browser/lib/fixtures.mjs new file mode 100644 index 00000000..7fc59b29 --- /dev/null +++ b/evals/browser/lib/fixtures.mjs @@ -0,0 +1,78 @@ +const stylesheet = ` +:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, sans-serif; } +body { margin: 0; background: #f4f7fb; color: #182033; } +main { width: min(760px, calc(100% - 40px)); margin: 48px auto; } +.card { background: white; border: 1px solid #d9e1ef; border-radius: 14px; padding: 24px; box-shadow: 0 8px 28px #20304a12; } +h1 { margin-top: 0; } +a, button { font: inherit; } +button, .button { display: inline-block; border: 0; border-radius: 8px; padding: 10px 16px; background: #3157d5; color: white; cursor: pointer; text-decoration: none; } +label { display: block; margin: 16px 0; font-weight: 600; } +input, textarea, select { display: block; box-sizing: border-box; width: 100%; margin-top: 6px; border: 1px solid #9caac2; border-radius: 7px; padding: 9px; font: inherit; } +textarea { min-height: 90px; } +.marker { border-left: 4px solid #3157d5; padding: 8px 12px; background: #eef2ff; font-family: ui-monospace, monospace; } +.muted { color: #5c667a; } +#account-menu { position: relative; display: inline-block; padding-bottom: 12px; } +#hover-panel { display: none; position: absolute; z-index: 2; top: 34px; left: 0; width: 190px; padding: 10px; background: white; border: 1px solid #9caac2; border-radius: 8px; box-shadow: 0 8px 24px #20304a22; } +#account-menu:hover #hover-panel, #account-menu.open #hover-panel { display: block; } +#hover-panel button { width: 100%; } +#inspect-card { margin-top: 80px; } +code { font-family: ui-monospace, monospace; } +`; + +const clientScript = ` +(() => { + const runId = new URLSearchParams(location.search).get("run"); + const navigation = performance.getEntriesByType("navigation")[0]; + const send = (type, data = {}) => { + if (!runId) return Promise.resolve(); + return fetch("/api/events", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ runId, type, path: location.pathname, data }), + keepalive: true, + }).catch(() => undefined); + }; + window.browserEval = { runId, send }; + window.addEventListener("pageshow", (event) => { + send("page.shown", { + path: location.pathname, + persisted: event.persisted, + navigationType: navigation?.type ?? "unknown", + }); + }); +})(); +`; + +export function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +export function withRun(path, runId, extra = {}) { + const url = new URL(path, "http://browser-eval.local"); + url.searchParams.set("run", runId); + for (const [name, value] of Object.entries(extra)) { + if (value !== undefined) url.searchParams.set(name, String(value)); + } + return `${url.pathname}${url.search}`; +} + +export function page({ title, body, script = "" }) { + return ` + + + + + ${escapeHtml(title)} + + + +
${body}
+ + ${script ? `` : ""} + +`; +} diff --git a/evals/browser/lib/operations.mjs b/evals/browser/lib/operations.mjs new file mode 100644 index 00000000..da698e12 --- /dev/null +++ b/evals/browser/lib/operations.mjs @@ -0,0 +1,68 @@ +export const OPERATION_CATALOG = [ + { operation: "session.start" }, + { operation: "session.stop" }, + { operation: "session.list" }, + { operation: "page.navigate" }, + { operation: "page.back" }, + { operation: "page.forward" }, + { operation: "page.reload" }, + { operation: "page.wait" }, + { operation: "inspect.observe" }, + { operation: "inspect.snapshot" }, + { operation: "inspect.html" }, + { operation: "inspect.screenshot" }, + { operation: "inspect.console" }, + { operation: "inspect.network" }, + { operation: "interact.click" }, + { operation: "interact.hover" }, + { operation: "interact.fill" }, + { operation: "interact.select" }, + { operation: "interact.press" }, + { operation: "tabs.list" }, + { operation: "tabs.create" }, + { operation: "tabs.select" }, + { operation: "tabs.close" }, + { + operation: "tabs.borrow", + manualNote: "requires an existing user tab and ownership confirmation", + }, + { operation: "tabs.return", manualNote: "paired with borrowing a real user tab" }, + { operation: "assist.resize" }, + { operation: "assist.emulate" }, + { operation: "assist.request-help", manualNote: "intentionally waits for human interaction" }, +]; + +export const WORKFLOW_ACTION_OPERATIONS = { + navigate: "page.navigate", + back: "page.back", + forward: "page.forward", + reload: "page.reload", + wait: "page.wait", + observe: "inspect.observe", + snapshot: "inspect.snapshot", + html: "inspect.html", + screenshot: "inspect.screenshot", + console: "inspect.console", + network: "inspect.network", + click: "interact.click", + hover: "interact.hover", + fill: "interact.fill", + select: "interact.select", + press: "interact.press", + "tab-list": "tabs.list", + "tab-create": "tabs.create", + "tab-select": "tabs.select", + "tab-close": "tabs.close", + borrow: "tabs.borrow", + return: "tabs.return", + resize: "assist.resize", + emulate: "assist.emulate", + "request-help": "assist.request-help", +}; + +export const WORKFLOW_ACTIONS = new Set([ + ...Object.keys(WORKFLOW_ACTION_OPERATIONS), + "wait-site-event", +]); + +export const OPERATION_NAMES = new Set(OPERATION_CATALOG.map(({ operation }) => operation)); diff --git a/evals/browser/lib/oracle.mjs b/evals/browser/lib/oracle.mjs new file mode 100644 index 00000000..09be6999 --- /dev/null +++ b/evals/browser/lib/oracle.mjs @@ -0,0 +1,63 @@ +function readPath(value, path) { + return path.split(".").reduce((current, part) => current?.[part], value); +} + +function eventMatches(event, assertion) { + if (event.type !== assertion.type) return false; + return Object.entries(assertion.where ?? {}).every( + ([path, expected]) => readPath(event, path) === expected, + ); +} + +export function verifyTask(task, { events = [], responseText = "", adapterEvidence = {} } = {}) { + const site = task.siteAssertions.map((assertion) => { + const actual = events.filter((event) => eventMatches(event, assertion)).length; + return { + kind: "site", + label: assertion.label, + status: actual >= assertion.minCount ? "passed" : "failed", + expected: `at least ${assertion.minCount}`, + actual, + }; + }); + + const response = task.responseAssertions.map((assertion) => ({ + kind: "response", + label: assertion.label, + status: responseText.includes(assertion.includes) ? "passed" : "failed", + expected: `response includes ${JSON.stringify(assertion.includes)}`, + })); + + const adapter = task.adapterAssertions.map((assertion) => { + if (!Object.hasOwn(adapterEvidence, assertion.key)) { + return { + kind: "adapter", + label: assertion.label, + status: "unverified", + expected: assertion.key, + }; + } + return { + kind: "adapter", + label: assertion.label, + status: adapterEvidence[assertion.key] ? "passed" : "failed", + expected: assertion.key, + actual: adapterEvidence[assertion.key], + }; + }); + + const checks = [...site, ...response, ...adapter]; + const failed = checks.filter(({ status }) => status === "failed").length; + const unverified = checks.filter(({ status }) => status === "unverified").length; + return { + taskId: task.id, + status: failed > 0 ? "failed" : unverified > 0 ? "passed-with-unverified" : "passed", + summary: { + passed: checks.length - failed - unverified, + failed, + unverified, + total: checks.length, + }, + checks, + }; +} diff --git a/evals/browser/lib/paths.mjs b/evals/browser/lib/paths.mjs new file mode 100644 index 00000000..b84c92c2 --- /dev/null +++ b/evals/browser/lib/paths.mjs @@ -0,0 +1,8 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const evalDirectory = dirname(dirname(fileURLToPath(import.meta.url))); +export const casesDirectory = join(evalDirectory, "cases"); +export const fixturesDirectory = join(evalDirectory, "fixtures"); +export const schemasDirectory = join(evalDirectory, "schemas"); +export const resultsDirectory = join(evalDirectory, "results"); diff --git a/evals/browser/lib/process.mjs b/evals/browser/lib/process.mjs new file mode 100644 index 00000000..d322e95d --- /dev/null +++ b/evals/browser/lib/process.mjs @@ -0,0 +1,71 @@ +import { spawn } from "node:child_process"; + +const MAX_CAPTURE_BYTES = 4 * 1024 * 1024; + +function appendCaptured(current, chunk) { + if (current.length >= MAX_CAPTURE_BYTES) return current; + return `${current}${chunk}`.slice(0, MAX_CAPTURE_BYTES); +} + +export function runProcess(command, args, { cwd, env, timeoutMs = 300_000 } = {}) { + return new Promise((resolve) => { + const startedAt = Date.now(); + let stdout = ""; + let stderr = ""; + let timedOut = false; + let settled = false; + const child = spawn(command, args, { + cwd, + env: { ...process.env, ...env }, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }); + + child.stdout.on("data", (chunk) => { + stdout = appendCaptured(stdout, chunk.toString()); + }); + child.stderr.on("data", (chunk) => { + stderr = appendCaptured(stderr, chunk.toString()); + }); + + const timeout = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + setTimeout(() => child.kill("SIGKILL"), 2_000).unref(); + }, timeoutMs); + + const finish = (result) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve({ + command, + args, + stdout, + stderr, + timedOut, + durationMs: Date.now() - startedAt, + ...result, + }); + }; + + child.once("error", (error) => finish({ exitCode: null, signal: null, error: error.message })); + child.once("close", (exitCode, signal) => finish({ exitCode, signal })); + }); +} + +export function substituteArgs(args, values) { + return args.map((argument) => { + let result = argument; + for (const [key, value] of Object.entries(values)) { + result = result.replaceAll(`{${key}}`, String(value)); + } + return result; + }); +} + +export function countPattern(text, pattern) { + if (!pattern) return null; + const matches = text.match(new RegExp(pattern, "g")); + return matches?.length ?? 0; +} diff --git a/evals/browser/lib/repository-validation.mjs b/evals/browser/lib/repository-validation.mjs new file mode 100644 index 00000000..5104c060 --- /dev/null +++ b/evals/browser/lib/repository-validation.mjs @@ -0,0 +1,64 @@ +import { relative, sep } from "node:path"; + +import { validateCaseFixtureLinks } from "./fixture-registry.mjs"; +import { casesDirectory } from "./paths.mjs"; + +function templateRoots(value) { + if (typeof value === "string") { + return [...value.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1].split(".")[0]); + } + if (Array.isArray(value)) return value.flatMap(templateRoots); + if (value && typeof value === "object") return Object.values(value).flatMap(templateRoots); + return []; +} + +export function validateRepositoryCases(cases, fixtureRegistry) { + const errors = [...validateCaseFixtureLinks(cases, fixtureRegistry)]; + for (const testCase of cases) { + const relativePath = relative(casesDirectory, testCase.sourceFile); + const suiteDirectory = relativePath.split(sep)[0]; + if (suiteDirectory !== testCase.suite) { + errors.push( + `case ${testCase.id} declares suite ${testCase.suite} but is stored under ${suiteDirectory}`, + ); + } + + const availableVariables = new Set(["baseUrl", "runId", "seed", "sessionId", "url"]); + const evidence = new Set(["sessionStopped"]); + for (const [index, step] of testCase.smokeSteps.entries()) { + for (const root of templateRoots(step)) { + if (!availableVariables.has(root)) { + errors.push( + `case ${testCase.id} smoke.steps[${index}] references unavailable variable ${root}`, + ); + } + } + if (step.saveAs) availableVariables.add(step.saveAs); + if (step.evidence) evidence.add(step.evidence); + } + for (const assertion of testCase.adapterAssertions) { + if (!evidence.has(assertion.key)) { + errors.push( + `case ${testCase.id} expects adapter evidence ${assertion.key} but no smoke step produces it`, + ); + } + } + } + return errors; +} + +export function repositorySummary(cases, fixtureRegistry) { + const suites = Object.fromEntries( + [...new Set(cases.map(({ suite }) => suite))] + .sort() + .map((suite) => [suite, cases.filter((testCase) => testCase.suite === suite).length]), + ); + const tags = [...new Set(cases.flatMap(({ tags }) => tags))].sort(); + return { + cases: cases.length, + suites, + tags: tags.length, + fixtureModules: fixtureRegistry.definitions.length, + fixtureRoutes: fixtureRegistry.routes.length, + }; +} diff --git a/evals/browser/lib/scaffold-case.mjs b/evals/browser/lib/scaffold-case.mjs new file mode 100644 index 00000000..85ca3467 --- /dev/null +++ b/evals/browser/lib/scaffold-case.mjs @@ -0,0 +1,120 @@ +import { mkdir, realpath, stat, writeFile } from "node:fs/promises"; +import { join, relative, sep } from "node:path"; + +import { casesDirectory, evalDirectory } from "./paths.mjs"; + +const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +async function exists(path) { + try { + await stat(path); + return true; + } catch (error) { + if (error?.code === "ENOENT") return false; + throw error; + } +} + +async function moduleSpecifier(fromDirectory, target) { + const path = relative(await realpath(fromDirectory), await realpath(target)) + .split(sep) + .join("/"); + return path.startsWith(".") ? path : `./${path}`; +} + +export async function scaffoldCase({ + id, + title = id, + suite = "regression", + source, + rootCasesDirectory = casesDirectory, +}) { + if (!ID_PATTERN.test(id ?? "")) throw new Error("case id must use lowercase kebab-case"); + if (!ID_PATTERN.test(suite ?? "")) throw new Error("suite must use lowercase kebab-case"); + const directory = join(rootCasesDirectory, suite, id); + if (await exists(directory)) throw new Error(`case directory already exists: ${directory}`); + await mkdir(directory, { recursive: true }); + + const marker = `CASE-${id.toUpperCase().replaceAll("-", "_")}`; + const startPath = `/cases/${id}`; + const manifest = { + $schema: "../../../schemas/case.schema.json", + schemaVersion: 1, + id, + title, + order: 10, + suite, + tags: ["badcase", "regression"], + ...(source ? { source: { type: "user-badcase", reference: source } } : {}), + fixture: { startPath }, + prompts: { + en: `Open {url}, complete the minimal regression scenario, report ${marker}, and close the browser session.`, + "zh-CN": `打开 {url},完成最小回归场景,报告 ${marker},最后关闭浏览器会话。`, + }, + coverage: ["session.start", "session.stop", "page.navigate", "inspect.observe"], + assertions: { + site: [ + { + label: "regression fixture was displayed", + type: "page.shown", + where: { "data.path": startPath }, + minCount: 1, + }, + ], + response: [{ label: "reported regression marker", includes: marker }], + adapter: [{ label: "browser session was closed", key: "sessionStopped" }], + }, + smoke: { steps: [{ action: "navigate" }, { action: "observe" }] }, + }; + + const fixtureHelper = await moduleSpecifier( + directory, + join(evalDirectory, "lib", "fixtures.mjs"), + ); + const fixture = `import { escapeHtml, page } from ${JSON.stringify(fixtureHelper)}; + +const title = ${JSON.stringify(title)}; + +export default { + id: ${JSON.stringify(id)}, + routes: [${JSON.stringify(startPath)}], + render() { + return page({ + title, + body: \`

\${escapeHtml(title)}

${marker}

Replace this page with a privacy-safe minimal reproduction.

\`, + }); + }, +}; +`; + const readme = `# ${title} + +## Original symptom + +Describe what the user observed without including private data or proprietary page content. + +## Minimal reproduction + +Explain the smallest DOM, timing, or browser-state condition that reproduces the failure. + +## Expected behavior + +Describe the observable success condition and update the case assertions accordingly. + +## Regression history + +- Source: ${source ?? "add issue, PR, or anonymized user report reference"} +- Fix: add the fixing PR when available +`; + + const files = { + manifest: join(directory, `${id}.case.json`), + fixture: join(directory, `${id}.fixture.mjs`), + readme: join(directory, "README.md"), + }; + await Promise.all([ + writeFile(files.manifest, `${JSON.stringify(manifest, null, 2)}\n`, { flag: "wx" }), + writeFile(files.fixture, fixture, { flag: "wx" }), + writeFile(files.readme, readme, { flag: "wx" }), + ]); + return { directory, files }; +} diff --git a/evals/browser/lib/seeded-generator.mjs b/evals/browser/lib/seeded-generator.mjs new file mode 100644 index 00000000..a4f2abd6 --- /dev/null +++ b/evals/browser/lib/seeded-generator.mjs @@ -0,0 +1,40 @@ +function hashSeed(value) { + const text = String(value); + let hash = 2166136261; + for (const character of text) { + hash ^= character.codePointAt(0); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +function mulberry32(seed) { + let state = seed >>> 0; + return () => { + state += 0x6d2b79f5; + let value = state; + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296; + }; +} + +export function normalizeSeed(value) { + const numeric = Number(value); + return Number.isSafeInteger(numeric) ? numeric : hashSeed(value); +} + +export function describeSeed(value) { + const seed = normalizeSeed(value); + const random = mulberry32(seed); + const choose = (values) => values[Math.floor(random() * values.length)]; + return { + seed, + labelMode: choose(["wrapped", "for", "aria"]), + nestingDepth: 1 + Math.floor(random() * 4), + hydrationDelayMs: choose([0, 40, 120, 250]), + decoy: choose(["none", "disabled-input", "duplicate-text"]), + fieldOrder: choose(["text-first", "select-first"]), + idSuffix: hashSeed(`${seed}:id`).toString(36).slice(0, 6), + }; +} diff --git a/evals/browser/lib/server.mjs b/evals/browser/lib/server.mjs new file mode 100644 index 00000000..673ac103 --- /dev/null +++ b/evals/browser/lib/server.mjs @@ -0,0 +1,152 @@ +import { createServer } from "node:http"; + +import { loadFixtureRegistry } from "./fixture-registry.mjs"; + +const RUN_ID_PATTERN = /^[A-Za-z0-9._-]{1,120}$/; + +function validRunId(value) { + return typeof value === "string" && RUN_ID_PATTERN.test(value); +} + +function json(response, status, value) { + response.writeHead(status, { + "cache-control": "no-store", + "content-type": "application/json; charset=utf-8", + }); + response.end(`${JSON.stringify(value, null, 2)}\n`); +} + +function html(response, status, value) { + response.writeHead(status, { + "cache-control": "no-store", + "content-type": "text/html; charset=utf-8", + }); + response.end(value); +} + +async function readJson(request) { + let raw = ""; + for await (const chunk of request) { + raw += chunk; + if (raw.length > 64 * 1024) { + throw new Error("request body exceeds 64 KiB"); + } + } + return raw ? JSON.parse(raw) : {}; +} + +export function createEvalServer({ host = "127.0.0.1", port = 0, fixtureRegistry } = {}) { + const runs = new Map(); + let sequence = 0; + let server; + let registry = fixtureRegistry; + + function eventsFor(runId) { + if (!runs.has(runId)) runs.set(runId, []); + return runs.get(runId); + } + + function record(runId, type, data = {}, metadata = {}) { + if (!validRunId(runId)) return null; + const event = { + sequence: ++sequence, + timestamp: new Date().toISOString(), + type, + data, + ...metadata, + }; + eventsFor(runId).push(event); + return event; + } + + function snapshot(runId) { + return { runId, events: [...(runs.get(runId) ?? [])] }; + } + + function reset(runId) { + runs.set(runId, []); + } + + async function handle(request, response) { + const origin = `http://${request.headers.host ?? `${host}:${port}`}`; + const url = new URL(request.url ?? "/", origin); + + if (request.method === "GET" && url.pathname === "/healthz") { + return json(response, 200, { ok: true }); + } + + if (request.method === "POST" && url.pathname === "/api/events") { + const body = await readJson(request); + if (!validRunId(body.runId) || typeof body.type !== "string") { + return json(response, 400, { error: "runId and type are required" }); + } + const event = record(body.runId, body.type, body.data ?? {}, { + path: typeof body.path === "string" ? body.path : undefined, + }); + return json(response, 202, { accepted: true, sequence: event.sequence }); + } + + const runMatch = url.pathname.match(/^\/api\/runs\/([^/]+)$/); + if (runMatch) { + const runId = decodeURIComponent(runMatch[1]); + if (!validRunId(runId)) return json(response, 400, { error: "invalid run id" }); + if (request.method === "GET") return json(response, 200, snapshot(runId)); + if (request.method === "DELETE") { + reset(runId); + return json(response, 200, { runId, reset: true }); + } + } + + if (request.method === "GET" && url.pathname === "/api/ping") { + const runId = url.searchParams.get("run"); + if (!validRunId(runId)) return json(response, 400, { error: "invalid run id" }); + record(runId, "network.ping", { token: "NETWORK-73" }, { path: url.pathname }); + return json(response, 200, { ok: true, token: "NETWORK-73" }); + } + + if (request.method !== "GET") return json(response, 405, { error: "method not allowed" }); + + const runId = url.searchParams.get("run") ?? "manual"; + if (!validRunId(runId)) return json(response, 400, { error: "invalid run id" }); + + const fixture = registry.render(url.pathname, { + runId, + query: url.searchParams, + record: (type, data, metadata) => record(runId, type, data, metadata), + }); + if (!fixture) return html(response, 404, "

Not found

"); + record(runId, "page.request", { path: url.pathname }, { path: url.pathname }); + return html(response, 200, fixture); + } + + return { + record, + reset, + snapshot, + async start() { + if (server) throw new Error("eval server is already running"); + registry ??= await loadFixtureRegistry(); + server = createServer((request, response) => { + handle(request, response).catch((error) => { + json(response, 500, { error: error instanceof Error ? error.message : String(error) }); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("unexpected server address"); + return { host, port: address.port, baseUrl: `http://${host}:${address.port}` }; + }, + async stop() { + if (!server) return; + const active = server; + server = undefined; + await new Promise((resolve, reject) => { + active.close((error) => (error ? reject(error) : resolve())); + active.closeAllConnections?.(); + }); + }, + }; +} diff --git a/evals/browser/lib/summary.mjs b/evals/browser/lib/summary.mjs new file mode 100644 index 00000000..3f1c6fb9 --- /dev/null +++ b/evals/browser/lib/summary.mjs @@ -0,0 +1,73 @@ +function durationFor(result) { + if (result.execution?.durationMs !== undefined) return result.execution.durationMs; + return (result.steps ?? []).reduce((sum, step) => sum + step.durationMs, 0); +} + +export function summarizeReports(reports) { + const groups = new Map(); + for (const report of reports) { + for (const result of report.results ?? []) { + const adapter = result.adapter ?? report.adapter ?? "unknown"; + const variant = result.variant ?? report.variant ?? "unspecified"; + const key = `${adapter}\u0000${variant}`; + if (!groups.has(key)) { + groups.set(key, { + adapter, + variant, + runs: 0, + passed: 0, + fullyVerified: 0, + executionFailures: 0, + errors: 0, + durationMs: 0, + toolCalls: 0, + toolCallSamples: 0, + }); + } + const group = groups.get(key); + group.runs += 1; + group.durationMs += durationFor(result); + group.errors += result.metrics?.errorCount ?? (result.executionError ? 1 : 0); + if (result.metrics?.toolCallCount !== null && result.metrics?.toolCallCount !== undefined) { + group.toolCalls += result.metrics.toolCallCount; + group.toolCallSamples += 1; + } + const executionFailed = + Boolean(result.executionError) || + result.execution?.timedOut || + (result.execution?.exitCode !== undefined && result.execution.exitCode !== 0); + if (executionFailed) group.executionFailures += 1; + if (result.verification?.status !== "failed" && !executionFailed) group.passed += 1; + if (result.verification?.status === "passed" && !executionFailed) group.fullyVerified += 1; + } + } + + return [...groups.values()].map((group) => ({ + adapter: group.adapter, + variant: group.variant, + runs: group.runs, + passRate: group.runs ? group.passed / group.runs : 0, + fullyVerifiedRate: group.runs ? group.fullyVerified / group.runs : 0, + executionFailures: group.executionFailures, + errors: group.errors, + averageToolCalls: group.toolCallSamples + ? Math.round((group.toolCalls / group.toolCallSamples) * 10) / 10 + : null, + averageDurationMs: group.runs ? Math.round(group.durationMs / group.runs) : 0, + })); +} + +export function printSummary(rows) { + const printable = rows.map((row) => ({ + adapter: row.adapter, + variant: row.variant, + runs: row.runs, + pass: `${Math.round(row.passRate * 100)}%`, + verified: `${Math.round(row.fullyVerifiedRate * 100)}%`, + exec_failures: row.executionFailures, + errors: row.errors, + avg_tools: row.averageToolCalls ?? "-", + avg_ms: row.averageDurationMs, + })); + console.table(printable); +} diff --git a/evals/browser/lib/tasks.mjs b/evals/browser/lib/tasks.mjs new file mode 100644 index 00000000..543e6210 --- /dev/null +++ b/evals/browser/lib/tasks.mjs @@ -0,0 +1,48 @@ +import { loadCaseManifests } from "./case-loader.mjs"; + +let casesPromise; + +export function allTasks({ reload = false } = {}) { + if (reload || !casesPromise) casesPromise = loadCaseManifests(); + return casesPromise; +} + +export function listTasks(cases) { + return cases.map(({ id, title, suite, tags, seed }) => ({ id, title, suite, tags, seed })); +} + +export function getTask(taskId, cases) { + const task = cases.find(({ id }) => id === taskId); + if (!task) { + const ids = cases.map(({ id }) => id).join(", "); + throw new Error(`unknown case ${JSON.stringify(taskId)}; expected one of: ${ids}`); + } + return task; +} + +export function taskUrl(task, baseUrl, runId, seed = task.seed) { + const url = new URL(task.startPath, baseUrl); + url.searchParams.set("run", runId); + if (seed !== undefined) url.searchParams.set("seed", String(seed)); + return url.toString(); +} + +function expandTemplate(template, values) { + return Object.entries(values).reduce( + (result, [name, value]) => result.replaceAll(`{${name}}`, String(value)), + template, + ); +} + +export function renderPrompt(task, { baseUrl, runId, locale = "zh-CN", seed = task.seed }) { + const template = task.prompts[locale]; + if (!template) { + throw new Error(`unsupported locale ${JSON.stringify(locale)}; expected en or zh-CN`); + } + return expandTemplate(template, { + baseUrl, + runId: encodeURIComponent(runId), + seed: seed ?? "", + url: taskUrl(task, baseUrl, runId, seed), + }); +} diff --git a/evals/browser/schemas/case.schema.json b/evals/browser/schemas/case.schema.json new file mode 100644 index 00000000..d27c8775 --- /dev/null +++ b/evals/browser/schemas/case.schema.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://browser-skill.local/schemas/browser-eval-case.schema.json", + "title": "BrowserSkill browser evaluation case", + "type": "object", + "required": [ + "schemaVersion", + "id", + "title", + "suite", + "tags", + "fixture", + "prompts", + "coverage", + "assertions", + "smoke" + ], + "properties": { + "$schema": { "type": "string" }, + "schemaVersion": { "const": 1 }, + "id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "title": { "type": "string", "minLength": 1 }, + "order": { "type": "integer", "minimum": 0 }, + "suite": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "tags": { + "type": "array", + "items": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "uniqueItems": true + }, + "fixture": { + "type": "object", + "required": ["startPath"], + "properties": { "startPath": { "type": "string", "pattern": "^/" } }, + "additionalProperties": false + }, + "prompts": { + "type": "object", + "required": ["en", "zh-CN"], + "properties": { + "en": { "type": "string", "minLength": 1 }, + "zh-CN": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "coverage": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, + "assertions": { + "type": "object", + "required": ["site", "response", "adapter"], + "properties": { + "site": { "type": "array", "items": { "type": "object" } }, + "response": { "type": "array", "items": { "type": "object" } }, + "adapter": { "type": "array", "items": { "type": "object" } } + }, + "additionalProperties": false + }, + "smoke": { + "type": "object", + "required": ["steps"], + "properties": { + "steps": { + "type": "array", + "items": { + "type": "object", + "required": ["action"], + "properties": { "action": { "type": "string" } }, + "additionalProperties": true + } + } + }, + "additionalProperties": false + }, + "seed": { "type": "integer" }, + "source": { "type": "object" } + }, + "additionalProperties": false +} diff --git a/evals/browser/tests/eval.test.mjs b/evals/browser/tests/eval.test.mjs new file mode 100644 index 00000000..2bc73de7 --- /dev/null +++ b/evals/browser/tests/eval.test.mjs @@ -0,0 +1,328 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; + +import { runCommandTask } from "../lib/agent-runner.mjs"; +import { loadCaseManifests, validateCaseManifest } from "../lib/case-loader.mjs"; +import { buildOperationCoverage } from "../lib/coverage.mjs"; +import { loadFixtureRegistry } from "../lib/fixture-registry.mjs"; +import { verifyTask } from "../lib/oracle.mjs"; +import { runProcess } from "../lib/process.mjs"; +import { repositorySummary, validateRepositoryCases } from "../lib/repository-validation.mjs"; +import { scaffoldCase } from "../lib/scaffold-case.mjs"; +import { describeSeed } from "../lib/seeded-generator.mjs"; +import { createEvalServer } from "../lib/server.mjs"; +import { summarizeReports } from "../lib/summary.mjs"; +import { allTasks, getTask, renderPrompt } from "../lib/tasks.mjs"; + +const cases = await allTasks(); +const fixtureRegistry = await loadFixtureRegistry(); + +async function withServer(run) { + const server = createEvalServer({ fixtureRegistry }); + const info = await server.start(); + try { + await run(server, info); + } finally { + await server.stop(); + } +} + +test("case manifests are discovered, ordered, and grouped into suites", () => { + assert.deepEqual( + cases.map(({ id }) => id), + [ + "navigation-history", + "form-controls", + "hover-inspect", + "tabs", + "diagnostics", + "mobile-emulation", + "generated-form", + ], + ); + assert.deepEqual(repositorySummary(cases, fixtureRegistry).suites, { core: 6, matrix: 1 }); +}); + +test("repository validation links every case to a fixture and valid workflow evidence", () => { + assert.deepEqual(validateRepositoryCases(cases, fixtureRegistry), []); + const summary = repositorySummary(cases, fixtureRegistry); + assert.equal(summary.cases, 7); + assert.equal(summary.fixtureModules, 8); + assert.equal(summary.fixtureRoutes, 12); +}); + +test("manifest validation rejects unknown operations and incomplete workflow steps", () => { + const invalid = { + schemaVersion: 1, + id: "invalid-case", + title: "Invalid", + suite: "regression", + tags: ["regression"], + fixture: { startPath: "/invalid" }, + prompts: { en: "Open {url}", "zh-CN": "打开 {url}" }, + coverage: ["page.teleport"], + assertions: { site: [], response: [], adapter: [] }, + smoke: { steps: [{ action: "select", selector: "#choice", values: [] }] }, + typo: true, + }; + const errors = validateCaseManifest(invalid); + assert.ok(errors.some((error) => error.includes("unknown top-level property typo"))); + assert.ok(errors.some((error) => error.includes("unknown operation page.teleport"))); + assert.ok(errors.some((error) => error.includes("values must be a non-empty array"))); +}); + +test("fixture server exposes deterministic task and seeded matrix markers", async () => { + await withServer(async (_server, { baseUrl }) => { + const fixtures = [ + ["/navigation/detail?run=test-markers", "NAV-42"], + ["/form?run=test-markers", ''], + ["/hover-inspect?run=test-markers", "INSPECT-27"], + ["/tabs/child?run=test-markers", "TAB-29"], + ["/diagnostics?run=test-markers", "CONSOLE-61"], + ["/responsive?run=test-markers", "VIEWPORT-84"], + ["/matrix/generated-form?run=test-markers&seed=17", "SEED-17"], + ]; + for (const [path, marker] of fixtures) { + const response = await fetch(`${baseUrl}${path}`); + assert.equal(response.status, 200); + assert.match(await response.text(), new RegExp(marker)); + } + }); +}); + +test("server records and resets run-scoped events", async () => { + await withServer(async (server, { baseUrl }) => { + const response = await fetch(`${baseUrl}/api/events`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ runId: "event-test", type: "example", data: { value: 7 } }), + }); + assert.equal(response.status, 202); + assert.equal(server.snapshot("event-test").events[0].data.value, 7); + const resetResponse = await fetch(`${baseUrl}/api/runs/event-test`, { method: "DELETE" }); + assert.equal(resetResponse.status, 200); + assert.deepEqual(server.snapshot("event-test").events, []); + }); +}); + +test("form case oracle checks submitted values and separates adapter evidence", () => { + const task = getTask("form-controls", cases); + const events = [ + { type: "form.enter_pressed", data: {} }, + { + type: "form.submitted", + data: { text: "agent-parity", notes: "BrowserSkill works", choice: "two" }, + }, + { type: "page.shown", data: { path: "/result" } }, + ]; + const partial = verifyTask(task, { events, responseText: "The page says Received!" }); + assert.equal(partial.status, "passed-with-unverified"); + assert.equal(partial.summary.unverified, 2); + + const complete = verifyTask(task, { + events, + responseText: "The page says Received!", + adapterEvidence: { screenshotCreated: true, sessionStopped: true }, + }); + assert.equal(complete.status, "passed"); +}); + +test("navigation case fails when reload is missing", () => { + const task = getTask("navigation-history", cases); + const verification = verifyTask(task, { + events: [ + { type: "navigation.detail.clicked", data: {} }, + { type: "page.shown", data: { path: "/navigation/start" } }, + { type: "page.shown", data: { path: "/navigation/start" } }, + { type: "page.shown", data: { path: "/navigation/detail" } }, + { type: "page.shown", data: { path: "/navigation/detail" } }, + ], + responseText: "NAV-42", + }); + assert.equal(verification.status, "failed"); + assert.equal( + verification.checks.find(({ label }) => label === "detail page was reloaded").status, + "failed", + ); +}); + +test("prompt rendering is run-scoped, localized, and seed-aware", () => { + const task = getTask("generated-form", cases); + const input = { baseUrl: "http://127.0.0.1:4173", runId: "prompt-test", seed: 17 }; + assert.match(renderPrompt(task, { ...input, locale: "en" }), /seed=17/); + assert.match(renderPrompt(task, { ...input, locale: "zh-CN" }), /seeded form/); +}); + +test("command adapter carries a run from process launch through oracle verification", async () => { + await withServer(async (server, serverInfo) => { + const script = ` + const [baseUrl, runId] = process.argv.slice(1); + const send = (type, data) => fetch(baseUrl + "/api/events", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ runId, type, data }), + }); + await send("navigation.detail.clicked", {}); + await send("page.shown", { path: "/navigation/start" }); + await send("page.shown", { path: "/navigation/start" }); + await send("page.shown", { path: "/navigation/detail" }); + await send("page.shown", { path: "/navigation/detail" }); + await send("page.shown", { path: "/navigation/detail", navigationType: "reload" }); + console.log("NAV-42"); + `; + const result = await runCommandTask({ + server, + serverInfo, + task: getTask("navigation-history", cases), + adapter: { + command: process.execPath, + args: ["--input-type=module", "-e", script, "{baseUrl}", "{runId}"], + variant: "fake-test", + }, + adapterName: "fake-agent", + configDirectory: process.cwd(), + iteration: 1, + locale: "en", + timeoutMs: 5_000, + }); + assert.equal(result.execution.exitCode, 0); + assert.equal(result.verification.status, "passed-with-unverified"); + assert.equal(result.eventCount, 6); + }); +}); + +test("report summary keeps partial verification distinct from full verification", () => { + const rows = summarizeReports([ + { + results: [ + { + adapter: "agent-a", + variant: "granular-28", + execution: { exitCode: 0, durationMs: 100 }, + metrics: { errorCount: 0, toolCallCount: 4 }, + verification: { status: "passed-with-unverified" }, + }, + { + adapter: "agent-a", + variant: "granular-28", + execution: { exitCode: 0, durationMs: 300 }, + metrics: { errorCount: 1, toolCallCount: 6 }, + verification: { status: "passed" }, + }, + ], + }, + ]); + assert.equal(rows[0].passRate, 1); + assert.equal(rows[0].fullyVerifiedRate, 0.5); + assert.equal(rows[0].averageDurationMs, 200); + assert.equal(rows[0].averageToolCalls, 5); + assert.equal(rows[0].errors, 1); +}); + +test("coverage inventory contains all 28 operations and three manual lanes", () => { + const coverage = buildOperationCoverage(cases); + const operations = coverage.map(({ operation }) => operation); + assert.equal(operations.length, 28); + assert.equal(new Set(operations).size, 28); + assert.deepEqual( + coverage.filter(({ directSmoke }) => !directSmoke).map(({ operation }) => operation), + ["tabs.borrow", "tabs.return", "assist.request-help"], + ); + assert.deepEqual(coverage.find(({ operation }) => operation === "interact.fill").smokeCases, [ + "form-controls", + "generated-form", + ]); +}); + +test("seeded dimensions are reproducible and vary across seeds", () => { + assert.deepEqual(describeSeed("badcase-17"), describeSeed("badcase-17")); + assert.notDeepEqual(describeSeed("badcase-17"), describeSeed("badcase-18")); + assert.equal(describeSeed(17).seed, 17); +}); + +test("badcase scaffold creates a loadable, escaped, self-contained case", async () => { + const temporaryRoot = await mkdtemp(join(tmpdir(), "browser-eval-scaffold-")); + try { + const title = "Quoted `title` ${notCode} "; + const result = await scaffoldCase({ + id: "reported-timeout", + title, + suite: "regression", + source: "issue-123", + rootCasesDirectory: temporaryRoot, + }); + const loaded = await loadCaseManifests({ casesDirectory: temporaryRoot }); + assert.equal(loaded[0].id, "reported-timeout"); + assert.equal(loaded[0].source.reference, "issue-123"); + + const generatedRegistry = await loadFixtureRegistry({ roots: [temporaryRoot] }); + const html = generatedRegistry.render("/cases/reported-timeout", { + runId: "scaffold-test", + query: new URLSearchParams(), + record() {}, + }); + assert.match(html, /Quoted `title` \$\{notCode\} <unsafe>/); + assert.match(await readFile(result.files.readme, "utf8"), /issue-123/); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +}); + +test("CLI accepts pnpm separator and supports suite and tag filters", async () => { + const cli = resolve("evals/browser/cli.mjs"); + const coverageResult = await runProcess(process.execPath, [cli, "--", "coverage", "--json"], { + timeoutMs: 5_000, + }); + assert.equal(coverageResult.exitCode, 0, coverageResult.stderr); + assert.equal(JSON.parse(coverageResult.stdout).length, 28); + + const coreResult = await runProcess( + process.execPath, + [cli, "list", "--suite", "core", "--json"], + { + timeoutMs: 5_000, + }, + ); + assert.equal(coreResult.exitCode, 0, coreResult.stderr); + assert.equal(JSON.parse(coreResult.stdout).length, 6); + + const formResult = await runProcess(process.execPath, [cli, "list", "--tag", "form", "--json"], { + timeoutMs: 5_000, + }); + assert.equal(formResult.exitCode, 0, formResult.stderr); + assert.deepEqual( + JSON.parse(formResult.stdout).map(({ id }) => id), + ["form-controls", "generated-form"], + ); +}); + +test("CLI validation and seed generation are machine-readable", async () => { + const cli = resolve("evals/browser/cli.mjs"); + const validation = await runProcess(process.execPath, [cli, "validate", "--json"], { + timeoutMs: 5_000, + }); + assert.equal(validation.exitCode, 0, validation.stderr); + assert.equal(JSON.parse(validation.stdout).ok, true); + + const generated = await runProcess( + process.execPath, + [cli, "generate", "--seed", "known-badcase", "--json"], + { timeoutMs: 5_000 }, + ); + assert.equal(generated.exitCode, 0, generated.stderr); + assert.equal(typeof JSON.parse(generated.stdout).hydrationDelayMs, "number"); + + const generatedSet = await runProcess( + process.execPath, + [cli, "generate", "--seed", "4,7,14", "--json"], + { timeoutMs: 5_000 }, + ); + assert.equal(generatedSet.exitCode, 0, generatedSet.stderr); + assert.deepEqual( + JSON.parse(generatedSet.stdout).map(({ seed }) => seed), + [4, 7, 14], + ); +}); diff --git a/package.json b/package.json index 214c278c..dbc588fa 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "packageManager": "pnpm@10.17.0", "scripts": { "cli:build": "cargo run -p bsk-protocol --bin dump-schema --locked && pnpm -r build", + "eval:browser": "node evals/browser/cli.mjs", + "eval:browser:check": "node evals/browser/cli.mjs validate && node --test evals/browser/tests/*.test.mjs", + "eval:browser:test": "node --test evals/browser/tests/*.test.mjs", "ext:build": "pnpm --filter @browser-skill/extension build", "ext:build:zip": "pnpm --filter @browser-skill/extension zip", "ext:dev": "pnpm --filter @browser-skill/extension dev",