From 27a0ebe1d7db710f2669cc5b1fa1e41bd69d9637 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 24 Jun 2026 09:45:48 +0800 Subject: [PATCH] Initial rrweb SGrobot skill package --- .gitignore | 1 + SKILL.md | 59 ++ .../plans/2026-06-12-sgrobot-adapter.md | 150 +++++ examples/basic-trace.json | 25 + examples/demo-site/customer-callback.html | 210 +++++++ .../recordings/customer-callback-trace.json | 31 + .../customer-callback-after-execute.png | Bin 0 -> 54250 bytes .../SKILL.toml | 23 + .../scripts/execute_trace.js | 240 ++++++++ .../trace.semantic.json | 31 + .../PlanTemplate.toml | 30 + package.json | 10 + references/sgrobot-adapter.md | 74 +++ scripts/generate_sgrobot_flow.mjs | 536 ++++++++++++++++++ scripts/validate_sgrobot_artifacts.mjs | 50 ++ tests/customer_callback_demo.test.mjs | 28 + tests/generate_sgrobot_flow.test.mjs | 56 ++ 17 files changed, 1554 insertions(+) create mode 100644 .gitignore create mode 100644 SKILL.md create mode 100644 docs/superpowers/plans/2026-06-12-sgrobot-adapter.md create mode 100644 examples/basic-trace.json create mode 100644 examples/demo-site/customer-callback.html create mode 100644 examples/recordings/customer-callback-trace.json create mode 100644 output/playwright/customer-callback-after-execute.png create mode 100644 output/sgrobot-customer-callback/.sgclaw-workspace/skills/rrweb-customer-callback-draft-execute/SKILL.toml create mode 100644 output/sgrobot-customer-callback/.sgclaw-workspace/skills/rrweb-customer-callback-draft-execute/scripts/execute_trace.js create mode 100644 output/sgrobot-customer-callback/.sgclaw-workspace/skills/rrweb-customer-callback-draft-execute/trace.semantic.json create mode 100644 output/sgrobot-customer-callback/plan_templates/rrweb-customer-callback-draft/PlanTemplate.toml create mode 100644 package.json create mode 100644 references/sgrobot-adapter.md create mode 100644 scripts/generate_sgrobot_flow.mjs create mode 100644 scripts/validate_sgrobot_artifacts.mjs create mode 100644 tests/customer_callback_demo.test.mjs create mode 100644 tests/generate_sgrobot_flow.test.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..1315865 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,59 @@ +--- +name: rrweb-sgrobot-adapter +description: Generate SGrobot-compatible PlanTemplate.toml and runtime SKILL.toml artifacts from rrweb semantic traces. Use when adapting recorded browser workflows, rrweb traces, semantic-trace JSON, or browser automation recordings for SGrobot/sgclaw plan_templates and .sgclaw-workspace skills without changing SGrobot core. +--- + +# RRWeb SGrobot Adapter + +Use this skill to turn one rrweb semantic trace into SGrobot runtime artifacts: + +```text +plan_templates//PlanTemplate.toml +.sgclaw-workspace/skills/-execute/SKILL.toml +.sgclaw-workspace/skills/-execute/scripts/execute_trace.js +.sgclaw-workspace/skills/-execute/trace.semantic.json +``` + +Do not treat `SKILL.md` as the SGrobot runtime artifact. In SGrobot: + +- `PlanTemplate.toml` is the user-facing business task orchestration. +- `.sgclaw-workspace/skills/**/SKILL.toml` is the executable runtime skill. +- `.agents/skills/**/SKILL.md` is only an agent/Codex helper. + +## Workflow + +1. Read the semantic trace JSON and confirm it has `id`, `name`, `startUrl`, and non-empty `steps`. +2. Read [references/sgrobot-adapter.md](references/sgrobot-adapter.md) when you need SGrobot mapping details. +3. Generate artifacts with: + +```bash +node scripts/generate_sgrobot_flow.mjs \ + --trace examples/basic-trace.json \ + --out /path/to/sgrobot-architecture-design \ + --default-mode dry-run +``` + +Use `--default-mode execute` only when the user explicitly wants the generated skill to mutate the target page. Generated execute-mode plans set `requires_approval = true` when the trace includes mutating actions. + +4. Inspect the generated `PlanTemplate.toml` and `SKILL.toml` before handing off. The plan step `skills = ["..."]` must match the generated `[skill].name`. +5. In a live SGrobot repo, verify `/api/debug/plans` sees the plan and `/api/skill` sees the skill before running the digital employee task. + +## Trace Shape + +Minimal trace: + +```json +{ + "id": "expense-approval", + "name": "费用审批", + "description": "打开审批页,填写意见并点击同意。", + "startUrl": "https://oa.example.test/expense/123", + "steps": [ + { "type": "assertText", "text": "费用审批", "label": "确认在审批页" }, + { "type": "fill", "selector": "textarea[name='comment']", "value": "同意", "label": "填写审批意见" }, + { "type": "click", "selector": "button[data-action='approve']", "label": "点击同意", "risk": "write" } + ] +} +``` + +Supported first-version action types are `assertText`, `extractText`, `click`, `fill`, `input`, `type`, `select`, `check`, `uncheck`, and `submit`. Prefer stable CSS selectors; XPath is supported with `xpath=` or leading `//`. diff --git a/docs/superpowers/plans/2026-06-12-sgrobot-adapter.md b/docs/superpowers/plans/2026-06-12-sgrobot-adapter.md new file mode 100644 index 0000000..8ed276a --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-sgrobot-adapter.md @@ -0,0 +1,150 @@ +# SGrobot Adapter Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a reusable generator that turns one rrweb semantic trace into SGrobot-compatible `PlanTemplate.toml` and runtime `SKILL.toml` artifacts without changing SGrobot core. + +**Architecture:** Keep this repository as a Codex skill plus deterministic generator. The generator writes one business-specific plan and one business-specific sgBrowser runtime skill per recording, with the semantic trace baked into the generated page script and saved beside it as evidence. + +**Tech Stack:** Node.js ESM, `node:test`, SGrobot `PlanTemplate.toml`, SGrobot runtime `SKILL.toml`, sgBrowser `sgBrowserExcuteJsCodeByDomain`. + +--- + +### Task 1: Generator Contract + +**Files:** +- Create: `tests/generate_sgrobot_flow.test.mjs` +- Create: `scripts/generate_sgrobot_flow.mjs` +- Create: `package.json` + +- [ ] **Step 1: Write the failing tests** + +```js +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { generateSgRobotArtifacts, writeArtifacts } from "../scripts/generate_sgrobot_flow.mjs"; + +const trace = { + id: "expense-approval", + name: "费用审批", + description: "打开审批页,填写意见并点击同意。", + startUrl: "https://oa.example.test/expense/123", + steps: [ + { type: "assertText", text: "费用审批", label: "确认在审批页" }, + { type: "fill", selector: "textarea[name='comment']", value: "同意", label: "填写审批意见" }, + { type: "click", selector: "button[data-action='approve']", label: "点击同意", risk: "write" } + ] +}; + +test("generates SGrobot plan and runtime skill artifacts", () => { + const result = generateSgRobotArtifacts(trace, { defaultMode: "execute" }); + assert.equal(result.summary.planId, "rrweb-expense-approval"); + assert.equal(result.summary.skillName, "rrweb-expense-approval-execute"); + assert.equal(result.summary.requiresApproval, true); + assert.equal(result.files.get("plan_templates/rrweb-expense-approval/PlanTemplate.toml").includes('skills = ["rrweb-expense-approval-execute"]'), true); + assert.equal(result.files.get(".sgclaw-workspace/skills/rrweb-expense-approval-execute/SKILL.toml").includes('command = "sgBrowserExcuteJsCodeByDomain"'), true); + assert.equal(result.files.get(".sgclaw-workspace/skills/rrweb-expense-approval-execute/SKILL.toml").includes('expected_domain = "oa.example.test"'), true); + assert.equal(result.files.get(".sgclaw-workspace/skills/rrweb-expense-approval-execute/scripts/execute_trace.js").includes('"default_mode": "execute"'), true); +}); + +test("writes generated artifacts under a SGrobot repository root", async () => { + const root = await mkdtemp(path.join(tmpdir(), "rrweb-sgrobot-")); + try { + const result = generateSgRobotArtifacts(trace); + await writeArtifacts(root, result.files); + const plan = await readFile(path.join(root, "plan_templates/rrweb-expense-approval/PlanTemplate.toml"), "utf8"); + const skill = await readFile(path.join(root, ".sgclaw-workspace/skills/rrweb-expense-approval-execute/SKILL.toml"), "utf8"); + const evidence = await readFile(path.join(root, ".sgclaw-workspace/skills/rrweb-expense-approval-execute/trace.semantic.json"), "utf8"); + assert.match(plan, /skills = \["browser\.open_url"\]/); + assert.match(skill, /script_path = "scripts\/execute_trace.js"/); + assert.equal(JSON.parse(evidence).id, "expense-approval"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/generate_sgrobot_flow.test.mjs` + +Expected: FAIL with `Cannot find module '../scripts/generate_sgrobot_flow.mjs'`. + +- [ ] **Step 3: Implement the generator** + +Create `scripts/generate_sgrobot_flow.mjs` exporting `generateSgRobotArtifacts(trace, options)` and `writeArtifacts(root, files)`. The generator must validate `id`, `name`, `startUrl`, and `steps`, derive `expected_domain` from `startUrl`, emit SGrobot paths, and support `defaultMode` of `dry-run` or `execute`. + +- [ ] **Step 4: Add package metadata** + +Create `package.json` with: + +```json +{ + "name": "rrweb-sgrobot-skill", + "version": "0.1.0", + "type": "module", + "private": true, + "scripts": { + "test": "node --test tests/*.test.mjs", + "generate:sgrobot": "node scripts/generate_sgrobot_flow.mjs" + } +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npm test` + +Expected: PASS with both generator tests green. + +### Task 2: Codex Skill Wrapper + +**Files:** +- Create: `SKILL.md` +- Create: `references/sgrobot-adapter.md` +- Create: `examples/basic-trace.json` + +- [ ] **Step 1: Write the skill instructions** + +Create a concise `SKILL.md` that tells Codex to use `scripts/generate_sgrobot_flow.mjs` when adapting rrweb semantic traces for SGrobot. It must state that `.agents/skills/**/SKILL.md` is only an agent helper, while SGrobot runtime artifacts are `PlanTemplate.toml` and `.sgclaw-workspace/skills/**/SKILL.toml`. + +- [ ] **Step 2: Add focused SGrobot mapping reference** + +Create `references/sgrobot-adapter.md` with the SGrobot-specific mapping rules: one generated plan per recording, one generated runtime skill per recording, `browser.open_url` first, generated sgBrowser execution skill second, optional `json.save_result` third, and step-level approval when execute mode includes mutating actions. + +- [ ] **Step 3: Add a realistic example trace** + +Create `examples/basic-trace.json` using the same schema covered by the test: `id`, `name`, `description`, `startUrl`, and `steps`. + +- [ ] **Step 4: Validate the skill folder** + +Run: `python3 /Users/zhaoyilun/.codex/skills/.system/skill-creator/scripts/quick_validate.py /Users/zhaoyilun/Documents/rrweb-skill` + +Expected: PASS. + +### Task 3: Verification Against SGrobot Shape + +**Files:** +- Read-only check: `/Users/zhaoyilun/projects/sgrobot-architecture-design/docs/concepts/skill-and-plan-template.md` +- Read-only check: `/Users/zhaoyilun/projects/sgrobot-architecture-design/docs/how-to/add-skill.md` + +- [ ] **Step 1: Generate sample output to a temp directory** + +Run: `node scripts/generate_sgrobot_flow.mjs --trace examples/basic-trace.json --out /tmp/rrweb-sgrobot-sample --default-mode dry-run --force` + +Expected: the command prints paths for the plan, skill, runtime script, and trace evidence. + +- [ ] **Step 2: Inspect generated files** + +Run: `find /tmp/rrweb-sgrobot-sample -type f | sort` + +Expected: files exist under `plan_templates/rrweb-expense-approval/` and `.sgclaw-workspace/skills/rrweb-expense-approval-execute/`. + +- [ ] **Step 3: Re-run tests** + +Run: `npm test` + +Expected: PASS. diff --git a/examples/basic-trace.json b/examples/basic-trace.json new file mode 100644 index 0000000..2151e95 --- /dev/null +++ b/examples/basic-trace.json @@ -0,0 +1,25 @@ +{ + "id": "expense-approval", + "name": "费用审批", + "description": "打开审批页,填写意见并点击同意。", + "startUrl": "https://oa.example.test/expense/123", + "steps": [ + { + "type": "assertText", + "text": "费用审批", + "label": "确认在审批页" + }, + { + "type": "fill", + "selector": "textarea[name='comment']", + "value": "同意", + "label": "填写审批意见" + }, + { + "type": "click", + "selector": "button[data-action='approve']", + "label": "点击同意", + "risk": "write" + } + ] +} diff --git a/examples/demo-site/customer-callback.html b/examples/demo-site/customer-callback.html new file mode 100644 index 0000000..7537434 --- /dev/null +++ b/examples/demo-site/customer-callback.html @@ -0,0 +1,210 @@ + + + + + + + 客户回访工单登记 + + + +
+
+

客户回访工单登记

+

低压故障抢修完成后,客服需要登记一次客户回访结果。

+
+ +
+
工单编号GD-20260612-0421
+
客户姓名李女士
+
回访任务待处理
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+
+ + + + diff --git a/examples/recordings/customer-callback-trace.json b/examples/recordings/customer-callback-trace.json new file mode 100644 index 0000000..d86523b --- /dev/null +++ b/examples/recordings/customer-callback-trace.json @@ -0,0 +1,31 @@ +{ + "id": "customer-callback-draft", + "name": "客户回访工单登记", + "description": "客服打开待回访工单,选择客户满意,填写回访备注,并保存草稿。", + "startUrl": "http://127.0.0.1:4187/customer-callback.html", + "steps": [ + { + "type": "assertText", + "text": "客户回访工单登记", + "label": "确认进入客户回访工单页面" + }, + { + "type": "select", + "selector": "[data-trace=\"callback-result\"]", + "value": "satisfied", + "label": "选择回访结果" + }, + { + "type": "fill", + "selector": "[data-trace=\"callback-remark\"]", + "value": "已电话回访客户,现场供电恢复正常,客户确认满意。", + "label": "填写回访备注" + }, + { + "type": "click", + "selector": "[data-action=\"save-draft\"]", + "label": "点击保存草稿", + "risk": "write" + } + ] +} diff --git a/output/playwright/customer-callback-after-execute.png b/output/playwright/customer-callback-after-execute.png new file mode 100644 index 0000000000000000000000000000000000000000..296ac201d3dae981c026ec44e255a4464f8a090a GIT binary patch literal 54250 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 cleanText(node.innerText || node.value || node.getAttribute("aria-label") || node.textContent).includes(needle)) || null; +} + +function findElement(action) { + for (const rawSelector of selectorList(action)) { + const selector = String(rawSelector).trim(); + if (!selector) { + continue; + } + if (selector.startsWith("xpath=")) { + const node = findByXPath(selector.slice("xpath=".length)); + if (node) { + return node; + } + continue; + } + if (selector.startsWith("//") || selector.startsWith("(//")) { + const node = findByXPath(selector); + if (node) { + return node; + } + continue; + } + try { + const node = document.querySelector(selector); + if (node) { + return node; + } + } catch (_) { + continue; + } + } + return findByText(action.text || action.label); +} + +function describeElement(node) { + if (!node) { + return null; + } + return { + tag: String(node.tagName || "").toLowerCase(), + id: node.id || "", + name: node.getAttribute ? node.getAttribute("name") || "" : "", + text: cleanText(node.innerText || node.value || node.textContent).slice(0, 120), + }; +} + +function dispatchFieldEvents(node) { + node.dispatchEvent(new Event("input", { bubbles: true })); + node.dispatchEvent(new Event("change", { bubbles: true })); +} + +function runAction(action, index, shouldExecute) { + const type = normalizeType(action.type); + const result = { + index, + type: action.type, + label: action.label || "", + mode: shouldExecute ? "execute" : "dry-run", + ok: true, + }; + + if (type === "asserttext" || type === "waitfortext") { + const needle = cleanText(action.text || action.value); + const haystack = cleanText(document.body ? document.body.innerText : document.documentElement.textContent); + result.ok = needle ? haystack.includes(needle) : false; + result.expected_text = needle; + return result; + } + + if (type === "extracttext") { + const target = findElement(action) || document.body || document.documentElement; + result.text = cleanText(target.innerText || target.textContent).slice(0, Number(action.max_chars || 2000)); + result.element = describeElement(target); + return result; + } + + const element = findElement(action); + result.element = describeElement(element); + if (!element) { + result.ok = false; + result.error = "target element not found"; + return result; + } + + if (type === "click" || type === "submit") { + if (shouldExecute) { + element.scrollIntoView({ block: "center", inline: "center" }); + if (typeof element.focus === "function") { + element.focus(); + } + if (type === "submit" && element.form) { + element.form.requestSubmit ? element.form.requestSubmit() : element.form.submit(); + } else { + element.click(); + } + } + return result; + } + + if (type === "fill" || type === "input" || type === "type") { + result.value = action.value; + if (shouldExecute) { + element.focus && element.focus(); + element.value = String(action.value); + dispatchFieldEvents(element); + } + return result; + } + + if (type === "select") { + result.value = action.value; + if (shouldExecute) { + element.value = String(action.value); + dispatchFieldEvents(element); + } + return result; + } + + if (type === "check" || type === "uncheck") { + result.checked = type === "check"; + if (shouldExecute) { + element.checked = type === "check"; + dispatchFieldEvents(element); + } + return result; + } + + result.ok = false; + result.error = "unsupported action type"; + return result; +} + +const mode = normalizeMode(input.mode || input.execution_mode || input.executionMode); +const shouldExecute = mode === "execute"; +const actionResults = []; +let ok = true; + +for (let index = 0; index < TRACE.actions.length; index += 1) { + const actionResult = runAction(TRACE.actions[index], index, shouldExecute); + actionResults.push(actionResult); + if (!actionResult.ok) { + ok = false; + break; + } +} + +return JSON.stringify({ + ok, + trace_id: TRACE.id, + trace_name: TRACE.name, + mode, + executed: shouldExecute, + expected_domain: TRACE.expected_domain, + url: window.location.href, + title: document.title || "", + action_count: TRACE.actions.length, + action_results: actionResults, + finished_at: new Date().toISOString(), +}); diff --git a/output/sgrobot-customer-callback/.sgclaw-workspace/skills/rrweb-customer-callback-draft-execute/trace.semantic.json b/output/sgrobot-customer-callback/.sgclaw-workspace/skills/rrweb-customer-callback-draft-execute/trace.semantic.json new file mode 100644 index 0000000..d86523b --- /dev/null +++ b/output/sgrobot-customer-callback/.sgclaw-workspace/skills/rrweb-customer-callback-draft-execute/trace.semantic.json @@ -0,0 +1,31 @@ +{ + "id": "customer-callback-draft", + "name": "客户回访工单登记", + "description": "客服打开待回访工单,选择客户满意,填写回访备注,并保存草稿。", + "startUrl": "http://127.0.0.1:4187/customer-callback.html", + "steps": [ + { + "type": "assertText", + "text": "客户回访工单登记", + "label": "确认进入客户回访工单页面" + }, + { + "type": "select", + "selector": "[data-trace=\"callback-result\"]", + "value": "satisfied", + "label": "选择回访结果" + }, + { + "type": "fill", + "selector": "[data-trace=\"callback-remark\"]", + "value": "已电话回访客户,现场供电恢复正常,客户确认满意。", + "label": "填写回访备注" + }, + { + "type": "click", + "selector": "[data-action=\"save-draft\"]", + "label": "点击保存草稿", + "risk": "write" + } + ] +} diff --git a/output/sgrobot-customer-callback/plan_templates/rrweb-customer-callback-draft/PlanTemplate.toml b/output/sgrobot-customer-callback/plan_templates/rrweb-customer-callback-draft/PlanTemplate.toml new file mode 100644 index 0000000..ec4ada0 --- /dev/null +++ b/output/sgrobot-customer-callback/plan_templates/rrweb-customer-callback-draft/PlanTemplate.toml @@ -0,0 +1,30 @@ +[plan] +id = "rrweb-customer-callback-draft" +name = "客户回访工单登记" +description = "客服打开待回访工单,选择客户满意,填写回访备注,并保存草稿。" +version = "1.0.0" + +[plan.trigger] +kind = "manual" + +[[plan.steps]] +id = "open-target-page" +title = "Open target page" +description = "Call browser.open_url to open http://127.0.0.1:4187/customer-callback.html." +skills = ["browser.open_url"] +depends_on = [] + +[[plan.steps]] +id = "run-recorded-flow" +title = "Run recorded browser flow" +description = "Execute the generated rrweb semantic trace through sgBrowser on 127.0.0.1. Default mode: execute." +skills = ["rrweb-customer-callback-draft-execute"] +depends_on = ["open-target-page"] +requires_approval = true + +[[plan.steps]] +id = "save-result" +title = "Save structured result" +description = "Save the generated flow result as a structured JSON artifact." +skills = ["json.save_result"] +depends_on = ["run-recorded-flow"] diff --git a/package.json b/package.json new file mode 100644 index 0000000..4793479 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "rrweb-sgrobot-skill", + "version": "0.1.0", + "type": "module", + "private": true, + "scripts": { + "test": "node --test tests/*.test.mjs", + "generate:sgrobot": "node scripts/generate_sgrobot_flow.mjs" + } +} diff --git a/references/sgrobot-adapter.md b/references/sgrobot-adapter.md new file mode 100644 index 0000000..7232a51 --- /dev/null +++ b/references/sgrobot-adapter.md @@ -0,0 +1,74 @@ +# SGrobot Adapter Reference + +## Boundary + +SGrobot uses two runtime artifact families: + +- `plan_templates//PlanTemplate.toml`: business orchestration, selectable as a digital employee task. +- `.sgclaw-workspace/skills//SKILL.toml`: atomic runtime capability loaded by Skill Catalog. + +The Codex-facing `SKILL.md` in this repository only teaches an agent how to generate those artifacts. + +## Generated Flow Shape + +For each rrweb semantic trace, generate one business-specific package: + +```text +plan_templates/rrweb-/PlanTemplate.toml +.sgclaw-workspace/skills/rrweb--execute/SKILL.toml +.sgclaw-workspace/skills/rrweb--execute/scripts/execute_trace.js +.sgclaw-workspace/skills/rrweb--execute/trace.semantic.json +``` + +Do not generate a single universal runtime skill that expects `trace_path` unless SGrobot core gains stable per-step argument passing. Current stable PlanTemplate usage binds a step to a skill name; the runtime resolves that skill's primary tool. + +## PlanTemplate Mapping + +Use three steps: + +1. `open-target-page`: `skills = ["browser.open_url"]`; description includes the trace `startUrl`. +2. `run-recorded-flow`: `skills = ["rrweb--execute"]`; executes generated page script with sgBrowser. +3. `save-result`: `skills = ["json.save_result"]`; saves structured output. + +Set `requires_approval = true` on `run-recorded-flow` when `--default-mode execute` is used and the trace includes mutating actions such as `click`, `fill`, `select`, `check`, `submit`, or `risk = "write"`. + +## Runtime Skill Mapping + +Generated `SKILL.toml` should use: + +```toml +[[tools]] +name = "execute_trace" +kind = "sgbrowser_action" +command = "sgBrowserExcuteJsCodeByDomain" +expected_domain = "" + +[tools.args] +script_path = "scripts/execute_trace.js" +``` + +Keep `sgBrowserExcuteJsCodeByDomain` and the `sgBrowerserOpenPage` spelling used by the existing SGrobot project. Do not replace this path with Playwright, CDP, or a generic browser action. + +## Runtime Script Rules + +The generated `execute_trace.js` runs inside the target page. It should: + +- Default to `dry-run` unless the generator is called with `--default-mode execute`. +- Resolve target elements by stable CSS selector first, XPath second, visible text fallback last. +- Return structured JSON with `ok`, `mode`, `trace_id`, `url`, `title`, and per-action results. +- Stop on the first failed action and include an error message. + +## Verification + +After generating into a SGrobot repo: + +```bash +node scripts/generate_sgrobot_flow.mjs --trace examples/basic-trace.json --out /Users/zhaoyilun/projects/sgrobot-architecture-design --default-mode dry-run +``` + +Then verify in SGrobot: + +```powershell +Invoke-RestMethod 'http://127.0.0.1:42617/api/debug/plans?limit=20' | ConvertTo-Json -Depth 5 +Invoke-RestMethod 'http://127.0.0.1:42617/api/skill' | ConvertTo-Json -Depth 4 +``` diff --git a/scripts/generate_sgrobot_flow.mjs b/scripts/generate_sgrobot_flow.mjs new file mode 100644 index 0000000..255b421 --- /dev/null +++ b/scripts/generate_sgrobot_flow.mjs @@ -0,0 +1,536 @@ +#!/usr/bin/env node +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const MUTATING_TYPES = new Set(["click", "fill", "input", "type", "select", "check", "uncheck", "submit", "press"]); +const WRITE_RISKS = new Set(["write", "dangerous", "destructive", "submit", "payment", "approval"]); + +export function generateSgRobotArtifacts(trace, options = {}) { + const normalized = normalizeTrace(trace, options); + const files = new Map(); + const skillRoot = `.sgclaw-workspace/skills/${normalized.skillName}`; + + files.set(`plan_templates/${normalized.planId}/PlanTemplate.toml`, renderPlanTemplate(normalized)); + files.set(`${skillRoot}/SKILL.toml`, renderSkillToml(normalized)); + files.set(`${skillRoot}/scripts/execute_trace.js`, renderRuntimeScript(normalized)); + files.set(`${skillRoot}/trace.semantic.json`, `${JSON.stringify(trace, null, 2)}\n`); + + return { + summary: { + planId: normalized.planId, + skillName: normalized.skillName, + expectedDomain: normalized.expectedDomain, + defaultMode: normalized.defaultMode, + requiresApproval: normalized.requiresApproval, + actionCount: normalized.actions.length, + }, + files, + }; +} + +export async function writeArtifacts(root, files, options = {}) { + const rootPath = path.resolve(root); + const written = []; + + for (const [relativePath, content] of files.entries()) { + if (path.isAbsolute(relativePath) || relativePath.includes("..")) { + throw new Error(`Refusing unsafe generated path: ${relativePath}`); + } + + const targetPath = path.resolve(rootPath, relativePath); + if (!targetPath.startsWith(`${rootPath}${path.sep}`)) { + throw new Error(`Refusing path outside output root: ${relativePath}`); + } + + if (!options.force) { + try { + await access(targetPath); + throw new Error(`Refusing to overwrite existing file without --force: ${relativePath}`); + } catch (error) { + if (error && error.code !== "ENOENT") { + throw error; + } + } + } + + await mkdir(path.dirname(targetPath), { recursive: true }); + await writeFile(targetPath, content, "utf8"); + written.push(targetPath); + } + + return written; +} + +function normalizeTrace(trace, options) { + if (!trace || typeof trace !== "object" || Array.isArray(trace)) { + throw new Error("Trace must be a JSON object."); + } + + const id = requireString(trace.id, "trace.id"); + const name = requireString(trace.name, "trace.name"); + const startUrl = requireString(trace.startUrl || trace.start_url, "trace.startUrl"); + const start = parseHttpUrl(startUrl, "trace.startUrl"); + const actions = Array.isArray(trace.steps) ? trace.steps : trace.actions; + + if (!Array.isArray(actions) || actions.length === 0) { + throw new Error("Trace must include a non-empty steps array."); + } + + const baseId = toKebabId(id); + const planId = cleanId(trace.planId || trace.plan_id || `rrweb-${baseId}`, "plan id"); + const skillName = cleanId(trace.skillName || trace.skill_name || `${planId}-execute`, "skill name"); + const expectedDomain = String(trace.expectedDomain || trace.expected_domain || start.hostname).trim(); + const defaultMode = normalizeMode(options.defaultMode || options.default_mode || trace.defaultMode || trace.default_mode || "dry-run"); + const normalizedActions = actions.map((action, index) => normalizeAction(action, index)); + const hasMutatingAction = normalizedActions.some(isMutatingAction); + const requiresApproval = + trace.requiresApproval === true || + trace.requires_approval === true || + (defaultMode === "execute" && hasMutatingAction); + + return { + id, + name, + description: String(trace.description || `${name} generated from rrweb semantic trace.`), + startUrl: start.href, + planId, + skillName, + expectedDomain, + defaultMode, + requiresApproval, + actions: normalizedActions, + }; +} + +function normalizeAction(action, index) { + if (!action || typeof action !== "object" || Array.isArray(action)) { + throw new Error(`Trace step ${index + 1} must be an object.`); + } + + const type = requireString(action.type || action.action, `trace.steps[${index}].type`); + const normalized = { + ...action, + type, + label: String(action.label || action.name || `${type} #${index + 1}`), + }; + + if (needsTarget(type) && !action.selector && !Array.isArray(action.selectors) && !action.text) { + throw new Error(`Trace step ${index + 1} (${type}) needs selector, selectors, or text.`); + } + + if (needsValue(type) && action.value === undefined) { + throw new Error(`Trace step ${index + 1} (${type}) needs value.`); + } + + return normalized; +} + +function renderPlanTemplate(trace) { + const approvalLine = trace.requiresApproval ? "\nrequires_approval = true" : ""; + + return `[plan] +id = ${tomlString(trace.planId)} +name = ${tomlString(trace.name)} +description = ${tomlString(trace.description)} +version = "1.0.0" + +[plan.trigger] +kind = "manual" + +[[plan.steps]] +id = "open-target-page" +title = "Open target page" +description = ${tomlString(`Call browser.open_url to open ${trace.startUrl}.`)} +skills = ["browser.open_url"] +depends_on = [] + +[[plan.steps]] +id = "run-recorded-flow" +title = "Run recorded browser flow" +description = ${tomlString(`Execute the generated rrweb semantic trace through sgBrowser on ${trace.expectedDomain}. Default mode: ${trace.defaultMode}.`)} +skills = [${tomlString(trace.skillName)}] +depends_on = ["open-target-page"]${approvalLine} + +[[plan.steps]] +id = "save-result" +title = "Save structured result" +description = "Save the generated flow result as a structured JSON artifact." +skills = ["json.save_result"] +depends_on = ["run-recorded-flow"] +`; +} + +function renderSkillToml(trace) { + return `[skill] +name = ${tomlString(trace.skillName)} +description = ${tomlString(`Run the generated rrweb semantic trace for ${trace.name} through sgBrowser.`)} +version = "0.1.0" +author = "rrweb-skill" +tags = ["rrweb", "semantic-trace", "sgbrowser-action", "generated"] + +prompts = [ + ${tomlString(`Use ${trace.skillName} only for the generated ${trace.name} browser flow.`)}, + "Run it after browser.open_url has opened the target page.", + "Return the structured JSON evidence emitted by the tool; do not use Playwright, CDP, or generic browser_action for this step." +] + +[[tools]] +name = "execute_trace" +description = ${tomlString(`Replay or dry-run the generated ${trace.name} semantic trace in sgBrowser.`)} +kind = "sgbrowser_action" +command = "sgBrowserExcuteJsCodeByDomain" +expected_domain = ${tomlString(trace.expectedDomain)} + +[tools.args] +script_path = "scripts/execute_trace.js" +mode = ${tomlString(`Baked default mode is ${trace.defaultMode}. Runtime may pass mode=execute or mode=dry-run if supported.`)} +`; +} + +function renderRuntimeScript(trace) { + const runtimeTrace = { + id: trace.id, + name: trace.name, + start_url: trace.startUrl, + expected_domain: trace.expectedDomain, + default_mode: trace.defaultMode, + actions: trace.actions, + }; + + return `const TRACE = ${JSON.stringify(runtimeTrace, null, 2)}; +const input = + (typeof args === "object" && args) || + (typeof tool_args === "object" && tool_args) || + (typeof sgclaw_args === "object" && sgclaw_args) || + {}; + +function normalizeMode(value) { + const raw = String(value || TRACE.default_mode || "dry-run").trim().toLowerCase(); + return raw === "execute" ? "execute" : "dry-run"; +} + +function normalizeType(value) { + return String(value || "").replace(/[\\s_-]+/g, "").toLowerCase(); +} + +function cleanText(value) { + return String(value || "").replace(/\\s+/g, " ").trim(); +} + +function selectorList(action) { + const selectors = []; + if (action.selector) { + selectors.push(action.selector); + } + if (Array.isArray(action.selectors)) { + for (const selector of action.selectors) { + if (selector) { + selectors.push(selector); + } + } + } + return selectors; +} + +function findByXPath(xpath) { + try { + return document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; + } catch (_) { + return null; + } +} + +function findByText(text) { + const needle = cleanText(text); + if (!needle) { + return null; + } + const candidates = Array.from(document.querySelectorAll("button,a,input,textarea,select,[role='button'],[role='link'],label")); + return candidates.find((node) => cleanText(node.innerText || node.value || node.getAttribute("aria-label") || node.textContent).includes(needle)) || null; +} + +function findElement(action) { + for (const rawSelector of selectorList(action)) { + const selector = String(rawSelector).trim(); + if (!selector) { + continue; + } + if (selector.startsWith("xpath=")) { + const node = findByXPath(selector.slice("xpath=".length)); + if (node) { + return node; + } + continue; + } + if (selector.startsWith("//") || selector.startsWith("(//")) { + const node = findByXPath(selector); + if (node) { + return node; + } + continue; + } + try { + const node = document.querySelector(selector); + if (node) { + return node; + } + } catch (_) { + continue; + } + } + return findByText(action.text || action.label); +} + +function describeElement(node) { + if (!node) { + return null; + } + return { + tag: String(node.tagName || "").toLowerCase(), + id: node.id || "", + name: node.getAttribute ? node.getAttribute("name") || "" : "", + text: cleanText(node.innerText || node.value || node.textContent).slice(0, 120), + }; +} + +function dispatchFieldEvents(node) { + node.dispatchEvent(new Event("input", { bubbles: true })); + node.dispatchEvent(new Event("change", { bubbles: true })); +} + +function runAction(action, index, shouldExecute) { + const type = normalizeType(action.type); + const result = { + index, + type: action.type, + label: action.label || "", + mode: shouldExecute ? "execute" : "dry-run", + ok: true, + }; + + if (type === "asserttext" || type === "waitfortext") { + const needle = cleanText(action.text || action.value); + const haystack = cleanText(document.body ? document.body.innerText : document.documentElement.textContent); + result.ok = needle ? haystack.includes(needle) : false; + result.expected_text = needle; + return result; + } + + if (type === "extracttext") { + const target = findElement(action) || document.body || document.documentElement; + result.text = cleanText(target.innerText || target.textContent).slice(0, Number(action.max_chars || 2000)); + result.element = describeElement(target); + return result; + } + + const element = findElement(action); + result.element = describeElement(element); + if (!element) { + result.ok = false; + result.error = "target element not found"; + return result; + } + + if (type === "click" || type === "submit") { + if (shouldExecute) { + element.scrollIntoView({ block: "center", inline: "center" }); + if (typeof element.focus === "function") { + element.focus(); + } + if (type === "submit" && element.form) { + element.form.requestSubmit ? element.form.requestSubmit() : element.form.submit(); + } else { + element.click(); + } + } + return result; + } + + if (type === "fill" || type === "input" || type === "type") { + result.value = action.value; + if (shouldExecute) { + element.focus && element.focus(); + element.value = String(action.value); + dispatchFieldEvents(element); + } + return result; + } + + if (type === "select") { + result.value = action.value; + if (shouldExecute) { + element.value = String(action.value); + dispatchFieldEvents(element); + } + return result; + } + + if (type === "check" || type === "uncheck") { + result.checked = type === "check"; + if (shouldExecute) { + element.checked = type === "check"; + dispatchFieldEvents(element); + } + return result; + } + + result.ok = false; + result.error = "unsupported action type"; + return result; +} + +const mode = normalizeMode(input.mode || input.execution_mode || input.executionMode); +const shouldExecute = mode === "execute"; +const actionResults = []; +let ok = true; + +for (let index = 0; index < TRACE.actions.length; index += 1) { + const actionResult = runAction(TRACE.actions[index], index, shouldExecute); + actionResults.push(actionResult); + if (!actionResult.ok) { + ok = false; + break; + } +} + +return JSON.stringify({ + ok, + trace_id: TRACE.id, + trace_name: TRACE.name, + mode, + executed: shouldExecute, + expected_domain: TRACE.expected_domain, + url: window.location.href, + title: document.title || "", + action_count: TRACE.actions.length, + action_results: actionResults, + finished_at: new Date().toISOString(), +}); +`; +} + +function isMutatingAction(action) { + const type = String(action.type || "").trim().toLowerCase(); + const risk = String(action.risk || "").trim().toLowerCase(); + return MUTATING_TYPES.has(type) || WRITE_RISKS.has(risk); +} + +function needsTarget(type) { + const normalized = String(type || "").replace(/[\s_-]+/g, "").toLowerCase(); + return !["asserttext", "waitfortext"].includes(normalized); +} + +function needsValue(type) { + const normalized = String(type || "").replace(/[\s_-]+/g, "").toLowerCase(); + return ["fill", "input", "type", "select"].includes(normalized); +} + +function normalizeMode(value) { + const mode = String(value || "").trim().toLowerCase(); + if (mode === "dry-run" || mode === "dryrun" || mode === "dry_run") { + return "dry-run"; + } + if (mode === "execute") { + return "execute"; + } + throw new Error(`Unsupported default mode: ${value}`); +} + +function cleanId(value, label) { + const cleaned = toKebabId(value); + if (!cleaned) { + throw new Error(`Invalid ${label}: ${value}`); + } + return cleaned; +} + +function toKebabId(value) { + return String(value || "") + .trim() + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +function requireString(value, label) { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${label} must be a non-empty string.`); + } + return value.trim(); +} + +function parseHttpUrl(value, label) { + try { + const url = new URL(value); + if (!["http:", "https:"].includes(url.protocol)) { + throw new Error("Only http/https URLs are supported."); + } + return url; + } catch (error) { + throw new Error(`${label} must be a valid http/https URL. ${error.message}`); + } +} + +function tomlString(value) { + return JSON.stringify(String(value)); +} + +function parseCliArgs(argv) { + const args = { + defaultMode: "dry-run", + force: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === "--trace") { + args.trace = argv[++index]; + } else if (token === "--out") { + args.out = argv[++index]; + } else if (token === "--default-mode" || token === "--mode") { + args.defaultMode = argv[++index]; + } else if (token === "--force") { + args.force = true; + } else if (token === "--help" || token === "-h") { + args.help = true; + } else { + throw new Error(`Unknown argument: ${token}`); + } + } + + return args; +} + +function usage() { + return `Usage: + node scripts/generate_sgrobot_flow.mjs --trace --out [--default-mode dry-run|execute] [--force] +`; +} + +async function main() { + const cli = parseCliArgs(process.argv.slice(2)); + if (cli.help) { + console.log(usage()); + return; + } + if (!cli.trace || !cli.out) { + throw new Error(`${usage()}Both --trace and --out are required.`); + } + + const trace = JSON.parse(await readFile(cli.trace, "utf8")); + const result = generateSgRobotArtifacts(trace, { defaultMode: cli.defaultMode }); + const written = await writeArtifacts(cli.out, result.files, { force: cli.force }); + + console.log(JSON.stringify({ ...result.summary, written }, null, 2)); +} + +const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isCli) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/scripts/validate_sgrobot_artifacts.mjs b/scripts/validate_sgrobot_artifacts.mjs new file mode 100644 index 0000000..5c8c904 --- /dev/null +++ b/scripts/validate_sgrobot_artifacts.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +const root = path.resolve(process.argv[2] || "."); +const planId = process.argv[3]; +const skillName = process.argv[4]; + +if (!planId || !skillName) { + console.error("Usage: node scripts/validate_sgrobot_artifacts.mjs "); + process.exit(1); +} + +const planPath = path.join(root, "plan_templates", planId, "PlanTemplate.toml"); +const skillPath = path.join(root, ".sgclaw-workspace", "skills", skillName, "SKILL.toml"); +const scriptPath = path.join(root, ".sgclaw-workspace", "skills", skillName, "scripts", "execute_trace.js"); +const tracePath = path.join(root, ".sgclaw-workspace", "skills", skillName, "trace.semantic.json"); + +const [plan, skill, script, traceText] = await Promise.all([ + readFile(planPath, "utf8"), + readFile(skillPath, "utf8"), + readFile(scriptPath, "utf8"), + readFile(tracePath, "utf8"), +]); + +const trace = JSON.parse(traceText); +const checks = [ + ["plan has [plan]", plan.includes("[plan]")], + ["plan id matches", plan.includes(`id = "${planId}"`)], + ["plan opens page via browser.open_url", plan.includes('skills = ["browser.open_url"]')], + ["plan calls generated skill", plan.includes(`skills = ["${skillName}"]`)], + ["plan saves result", plan.includes('skills = ["json.save_result"]')], + ["write flow requires approval", plan.includes("requires_approval = true")], + ["skill name matches", skill.includes(`name = "${skillName}"`)], + ["skill uses sgBrowser action", skill.includes('kind = "sgbrowser_action"')], + ["skill uses sgBrowser JS by domain", skill.includes('command = "sgBrowserExcuteJsCodeByDomain"')], + ["skill references execute script", skill.includes('script_path = "scripts/execute_trace.js"')], + ["runtime script contains trace", script.includes(`"id": "${trace.id}"`)], + ["runtime script returns structured JSON", script.includes("action_results")], + ["trace has realistic steps", Array.isArray(trace.steps) && trace.steps.length >= 4], +]; + +const failed = checks.filter(([, ok]) => !ok); +for (const [name, ok] of checks) { + console.log(`${ok ? "ok" : "not ok"} - ${name}`); +} + +if (failed.length > 0) { + process.exitCode = 1; +} diff --git a/tests/customer_callback_demo.test.mjs b/tests/customer_callback_demo.test.mjs new file mode 100644 index 0000000..63bbea7 --- /dev/null +++ b/tests/customer_callback_demo.test.mjs @@ -0,0 +1,28 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { generateSgRobotArtifacts } from "../scripts/generate_sgrobot_flow.mjs"; + +test("recorded customer callback flow generates SGrobot-compliant artifacts", async () => { + const trace = JSON.parse(await readFile("examples/recordings/customer-callback-trace.json", "utf8")); + const result = generateSgRobotArtifacts(trace, { defaultMode: "execute" }); + + const plan = result.files.get("plan_templates/rrweb-customer-callback-draft/PlanTemplate.toml"); + const skill = result.files.get(".sgclaw-workspace/skills/rrweb-customer-callback-draft-execute/SKILL.toml"); + const script = result.files.get(".sgclaw-workspace/skills/rrweb-customer-callback-draft-execute/scripts/execute_trace.js"); + const evidence = result.files.get(".sgclaw-workspace/skills/rrweb-customer-callback-draft-execute/trace.semantic.json"); + + assert.equal(result.summary.planId, "rrweb-customer-callback-draft"); + assert.equal(result.summary.skillName, "rrweb-customer-callback-draft-execute"); + assert.equal(result.summary.requiresApproval, true); + assert.match(plan, /skills = \["browser\.open_url"\]/); + assert.match(plan, /skills = \["rrweb-customer-callback-draft-execute"\]/); + assert.match(plan, /requires_approval = true/); + assert.match(skill, /kind = "sgbrowser_action"/); + assert.match(skill, /command = "sgBrowserExcuteJsCodeByDomain"/); + assert.match(skill, /script_path = "scripts\/execute_trace.js"/); + assert.match(script, /"type": "select"/); + assert.match(script, /"type": "fill"/); + assert.match(script, /"risk": "write"/); + assert.equal(JSON.parse(evidence).steps.length, 4); +}); diff --git a/tests/generate_sgrobot_flow.test.mjs b/tests/generate_sgrobot_flow.test.mjs new file mode 100644 index 0000000..5296732 --- /dev/null +++ b/tests/generate_sgrobot_flow.test.mjs @@ -0,0 +1,56 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { generateSgRobotArtifacts, writeArtifacts } from "../scripts/generate_sgrobot_flow.mjs"; + +const trace = { + id: "expense-approval", + name: "费用审批", + description: "打开审批页,填写意见并点击同意。", + startUrl: "https://oa.example.test/expense/123", + steps: [ + { type: "assertText", text: "费用审批", label: "确认在审批页" }, + { type: "fill", selector: "textarea[name='comment']", value: "同意", label: "填写审批意见" }, + { type: "click", selector: "button[data-action='approve']", label: "点击同意", risk: "write" }, + ], +}; + +test("generates SGrobot plan and runtime skill artifacts", () => { + const result = generateSgRobotArtifacts(trace, { defaultMode: "execute" }); + + assert.equal(result.summary.planId, "rrweb-expense-approval"); + assert.equal(result.summary.skillName, "rrweb-expense-approval-execute"); + assert.equal(result.summary.requiresApproval, true); + + const plan = result.files.get("plan_templates/rrweb-expense-approval/PlanTemplate.toml"); + const skill = result.files.get(".sgclaw-workspace/skills/rrweb-expense-approval-execute/SKILL.toml"); + const script = result.files.get(".sgclaw-workspace/skills/rrweb-expense-approval-execute/scripts/execute_trace.js"); + + assert.equal(plan.includes('skills = ["rrweb-expense-approval-execute"]'), true); + assert.equal(plan.includes("requires_approval = true"), true); + assert.equal(skill.includes('command = "sgBrowserExcuteJsCodeByDomain"'), true); + assert.equal(skill.includes('expected_domain = "oa.example.test"'), true); + assert.equal(script.includes('"default_mode": "execute"'), true); + assert.equal(script.includes('replace(/[\\s_-]+/g, "")'), true); + assert.equal(script.includes('replace(/[\\\\s_-]+/g, "")'), false); +}); + +test("writes generated artifacts under a SGrobot repository root", async () => { + const root = await mkdtemp(path.join(tmpdir(), "rrweb-sgrobot-")); + try { + const result = generateSgRobotArtifacts(trace); + await writeArtifacts(root, result.files); + + const plan = await readFile(path.join(root, "plan_templates/rrweb-expense-approval/PlanTemplate.toml"), "utf8"); + const skill = await readFile(path.join(root, ".sgclaw-workspace/skills/rrweb-expense-approval-execute/SKILL.toml"), "utf8"); + const evidence = await readFile(path.join(root, ".sgclaw-workspace/skills/rrweb-expense-approval-execute/trace.semantic.json"), "utf8"); + + assert.match(plan, /skills = \["browser\.open_url"\]/); + assert.match(skill, /script_path = "scripts\/execute_trace.js"/); + assert.equal(JSON.parse(evidence).id, "expense-approval"); + } finally { + await rm(root, { recursive: true, force: true }); + } +});