app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
fusion-verb-tests: standard daily E2E verb suite + dev skill
2 files changed
+313
dev-skills/fusion-verb-tests/SKILL.mdadded+80@@ -0,0 +1,80 @@+---+name: fusion-verb-tests+description: The standard end-to-end verb test suite for the fusion bridge - run the daily tier every day against a real box, the weekly tier before releases, triage failures, and keep the plan in lockstep with the verb catalog so no verb ships untested+---++# fusion-verb-tests - the standard daily verb test++Every described verb of this bridge, tested end-to-end the way a user's AI actually calls+it: through `adom-bridge --target <box>` over the real relay, against real Fusion, real+APS, and the canonical BQ25792 board. Built for daily runs ahead of the public release.++## Run it++```bash+cd ~/project/adom-desktop-fusion-bridge+python3 tests/run_verb_tests.py --target AdomLapper --tier daily # every day, ~5-10 min+python3 tests/run_verb_tests.py --target AdomLapper --tier weekly # before releases+python3 tests/run_verb_tests.py --target AdomLapper --only fusion_aps_search # one verb+```++Exit code 0 = all green. Reports land in `tests/reports/verb-test-<stamp>.json`.+Use the ai-thread the runner sets (`fusion-verb-tests`) when investigating by hand, so+the user's Activity Log shows the test run as one coherent actor.++## The tiers, and why a verb is where it is++| Tier | What qualifies | Examples |+|---|---|---|+| **daily** | Read-only, or state-changing but reversible with the runner restoring what it touched | readiness, all APS reads, open BQ25792 + the three views, inspect_bodies, assembly_bom, run_modeling_script returning a constant, uninstall dryRun |+| **negative** | Verbs that MUST refuse - a refusal is the PASS | the two deprecated crash verbs; the `expectDocument` guard fed a wrong name on both a mutating and a READ verb |+| **weekly** | Heavy or write-bearing flows: exports to the scratch dir, library opens, electron view commands, cloud reads that walk hubs | export_step/gerbers/bom, open_lbr, generate_package, list_cloud_projects |+| **manual** | Anything that needs a human present or touches their session: sign-in flows, notify toasts, real uninstall, prefs UI driving, doc-closing verbs that could eat their tabs | fusion_signin, fusion_kill, fusion_close_all_documents, fusion_demo |++**Catalog drift is a failing test.** The runner diffs `describe._T` against its own plan;+a verb described but untriaged fails the run with `untriaged`. Adding a verb to the+bridge means adding a line to the plan in the same commit.++## What the runner cannot judge - the AI's part of the ritual++The runner asserts response shapes. Four things still need eyes each daily run:++1. **The board actually rendered.** After the open + view stages, take a background+ `desktop_screenshot_window` of Fusion and LOOK: BQ25792 in the 3D view, dark canvas,+ not a dialog, not an empty tab.+2. **Timing drift.** Each result carries `sec`. A verb that passed but took 5x its+ usual time is tomorrow's failure; compare against the previous report.+3. **The box was left as found.** Fusion running-state restored, the test board tab+ closed, no stray dialogs. `fusion_get_app_state` at the end should match the start.+4. **Failures get filed, not just logged.** A red verb gets an issue on+ adom/fusion-bridge with the runner's JSON snippet, the bridge + add-in versions+ (`fusion_readiness`), and repro args, per the issue-reporting skill.++## Fixtures++- **Cloud**: `Molecules/XRP/Power/BQ25792.fprj` in project `Main` - the canonical board.+ Nothing in the daily tier writes to the cloud.+- **Local samples**: `examples/ADS8588SIPM.step` + `examples/ADS8588SIPM.fusion.lbr`+ (weekly library flows; staged to the box via `send_files` at run time).+- **Scratch on the box**: `%TEMP%/fusion-verb-tests` - all export outputs; safe to wipe.+- **Cloud writes** (weekly library builds only) go to the Adom AI Workspace folder and+ are cleaned with `fusion_cleanup_cloud_files`; never the project root, per the+ fusion-cloud-hygiene skill.++## Reading a failure++- `wrong_document` where a PASS was expected: the guard fired - which document was+ actually active? The runner opens BQ25792 first; a failure here usually means the+ open itself failed upstream.+- `bridge_restarting`: ab respawned the bridge mid-run (normal right after an install);+ re-run the verb before calling it a failure.+- `fusion_addin_not_responding` + a dialogs array: a modal is up; screenshot it - that+ is the real finding.+- Every refusal from the negative tier passing means the safety rails hold; a+ deprecated verb suddenly SUCCEEDING is a sev-1 finding, not a pass.++## Cadence++Daily: `--tier daily` (a cron on the container works; see the cron-watchdogs skill).+Before any release: `--tier weekly` plus a by-hand pass of the manual tier's+release-rehearsal subset (make_3d_package, build_library_3d) on a box you can watch.
tests/run_verb_tests.pyadded+233@@ -0,0 +1,233 @@+#!/usr/bin/env python3+"""End-to-end verb test runner for the fusion bridge (dev-skills/fusion-verb-tests).++Runs IN THE CONTAINER, through the real relay, exactly the way a user's AI calls verbs:+ adom-bridge --target <box> --ai-thread "fusion-verb-tests" <verb> '<json>'++Tiers:+ daily read-only + reversible state (safe every day, leaves the box as found)+ weekly heavy flows (library builds, cloud writes into the test workspace, exports)+ manual human-present only (sign-in, uninstall-for-real, notify, install, demo)+ negative always run: verbs that MUST refuse (deprecated verbs, the document guard)++Usage:+ python3 tests/run_verb_tests.py --target AdomLapper [--tier daily] [--only verb1,verb2]++Every described verb MUST appear in PLAN. If describe.py grows a verb the plan does not+know, the run FAILS with 'untriaged' - catalog drift becomes a failing test, not a gap.+"""+import argparse, datetime, json, os, pathlib, subprocess, sys, time++REPO = pathlib.Path(__file__).resolve().parent.parent+sys.path.insert(0, str(REPO))+import describe # noqa: E402++THREAD = "fusion-verb-tests"+SCRATCH = "C:/Users/john/AppData/Local/Temp/fusion-verb-tests"++def call(target, verb, args=None, timeout=90):+ cmd = ["adom-bridge", "--target", target, "--ai-thread", THREAD, verb, json.dumps(args or {})]+ t0 = time.time()+ try:+ p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)+ raw = p.stdout.strip() or p.stderr.strip()+ d = json.loads(raw[raw.index("{"):]) if "{" in raw else {"error": raw[:200]}+ except subprocess.TimeoutExpired:+ return {"_testTimeout": True}, time.time() - t0+ except Exception as e:+ return {"error": f"runner: {e}"}, time.time() - t0+ # unwrap the nested output envelope when present+ out = d.get("output")+ if isinstance(out, str) and out.strip().startswith("{"):+ try:+ inner = json.loads(out)+ merged = dict(d); merged.update(inner if not isinstance(inner.get("data"), dict) else inner["data"])+ if isinstance(inner.get("data"), dict): merged.update(inner["data"])+ d = merged+ except Exception:+ pass+ if isinstance(d.get("data"), dict):+ merged = dict(d); merged.update(d["data"]); d = merged+ return d, time.time() - t0++def expect(d, spec):+ """spec: {'ok':True} needs success/status ok; {'fields':[...]} need present;+ {'errorCode':'x'} exact; {'pred':name} named predicate."""+ errs = []+ if spec.get("ok"):+ if not (d.get("success") is True or d.get("status") == "ok" or d.get("ok") is True):+ errs.append("expected success, got error=%s code=%s" % (str(d.get("error"))[:80], d.get("errorCode")))+ for f in spec.get("fields", []):+ if f not in d: errs.append("missing field %r" % f)+ if "errorCode" in spec and d.get("errorCode") != spec["errorCode"]:+ errs.append("expected errorCode=%s got %s (error=%s)" % (spec["errorCode"], d.get("errorCode"), str(d.get("error"))[:60]))+ if spec.get("pred") == "count_gt_0" and not (d.get("count", 0) > 0 or len(d.get("results") or []) > 0):+ errs.append("expected >0 results")+ if spec.get("pred") == "verbs_match_plan":+ got = {v["name"] if isinstance(v, dict) else v for v in (d.get("verbs") or [])}+ planned = set(PLAN.keys())+ if got and (got - planned): errs.append("verbs described but UNTRIAGED in test plan: %s" % sorted(got - planned)[:6])+ if d.get("_testTimeout"): errs.append("timed out")+ return errs++# tier, args, expectation, [cleanup verb, cleanup args]+P = {}+def t(verb, tier, args=None, exp=None, note=""):+ P[verb] = {"tier": tier, "args": args or {}, "exp": exp or {"ok": True}, "note": note}++# ── stage 0: bridge + catalog ────────────────────────────────────────────────+t("fusion_readiness", "daily", {}, {"ok": True, "fields": ["bridgeVersion"]})+t("fusion_describe", "daily", {}, {"ok": True, "pred": "verbs_match_plan"})+t("fusion_get_app_state", "daily", {}, {}) # shape only; content depends on Fusion state+# must-refuse (deprecated, hard-blocked):+t("fusion_search_cloud_files", "negative", {"query": "x"}, {"errorCode": None, "pred": None}, "expect refusal text")+t("fusion_walk_cloud_tree", "negative", {}, {}, "expect refusal text")++# ── stage 1: APS, no Fusion needed ───────────────────────────────────────────+t("fusion_aps_status", "daily", {}, {"ok": True})+t("fusion_aps_get_browser", "daily", {}, {"ok": True})+t("fusion_aps_search", "daily", {"query": "BQ25792", "limit": 5}, {"ok": True, "pred": "count_gt_0"})+t("fusion_aps_recent", "daily", {"limit": 5}, {"ok": True, "pred": "count_gt_0"})+t("fusion_aps_browse", "daily", {}, {"ok": True, "fields": ["items"]})+t("fusion_aps_file_info", "daily", {"query": "BQ25792"}, {"ok": True, "fields": ["versionCount"]})+t("fusion_aps_versions", "weekly", {"query": "BQ25792"}, {"ok": True, "fields": ["versionCount"]})+t("fusion_aps_download", "weekly", {"query": "BQ25792", "saveDir": SCRATCH}, {"ok": True})+t("fusion_aps_get", "daily", {"path": "/project/v1/hubs"}, {"ok": True})+t("fusion_uninstall", "daily", {"dryRun": True}, {"ok": True, "fields": ["removed", "skipped", "dryRun"]})+# APS mutations: only into the AI workspace, weekly, with cleanup+t("fusion_aps_create_folder", "manual", note="needs projectId/parentFolderId of the test workspace")+t("fusion_aps_upload", "manual", note="cloud write; exercised via build flows weekly")+t("fusion_aps_set_client_id", "manual", note="would clobber org auth config")+t("fusion_aps_set_browser", "manual"); t("fusion_aps_forget_browser", "manual")+t("fusion_aps_signin", "manual", note="opens a browser on the user's machine")++# ── stage 2: Fusion lifecycle + prefs + MCP ─────────────────────────────────+t("fusion_start", "daily", {}, {"ok": True}, "runner restores prior running-state at the end")+t("fusion_get_preferences", "daily", {}, {"ok": True, "fields": ["current"]})+t("fusion_set_preference", "daily", {"theme": "darkblue"}, {"ok": True}, "sets the value it already has")+t("fusion_mcp_status", "daily", {}, {"ok": True, "fields": ["enabled"]})+t("fusion_mcp_tools", "weekly", {}, {"ok": True}, "only meaningful when MCP enabled")+t("fusion_mcp_call", "weekly", {"tool": "fusion_mcp_read", "arguments": {"queryType": "summary"}}, {"ok": True})+t("fusion_mcp_resources", "weekly", {}, {"ok": True})+t("fusion_mcp_enable", "manual", note="drives the prefs UI, takes foreground")+t("fusion_prefs_open", "manual", note="opens a dialog on the user's screen")+t("fusion_prefs_close", "manual")+t("fusion_set_auto_update", "daily", {}, {"ok": True}, "no args = read-only")++# ── stage 3: document flows on the canonical board ──────────────────────────+OPEN = {"fileName": "BQ25792", "projectName": "Main", "folderPath": "Molecules/XRP/Power"}+t("fusion_open_cloud_file", "daily", OPEN, {"ok": True}, "the canonical demo board")+t("fusion_show_schematic", "daily", {}, {"ok": True})+t("fusion_show_2d_board", "daily", {}, {"ok": True})+t("fusion_show_3d_board", "daily", {}, {"ok": True})+t("fusion_board_info", "daily", {}, {"ok": True, "fields": ["rawXml"]})+t("fusion_inspect_bodies", "daily", {}, {"ok": True, "fields": ["bodies", "units"]})+t("fusion_assembly_bom", "daily", {}, {"ok": True, "fields": ["parts", "collapsedUnits", "possibleUnderCollapse"]})+t("fusion_physical_properties", "daily", {}, {"ok": True})+t("fusion_get_design_info", "daily", {}, {"ok": True})+t("fusion_get_parameters", "daily", {}, {"ok": True})+t("fusion_document_info", "daily", {}, {"ok": True})+t("fusion_list_cloud_projects", "weekly", {}, {"ok": True}, "slow hub walk")+t("fusion_list_cloud_files", "weekly", {"projectName": "Main"}, {"ok": True})+# THE GUARD (issue #289): mismatch must refuse - on a READ verb too+t("fusion_run_modeling_script#guard", "negative",+ {"script": "result=1", "expectDocument": "ZZZ_NoSuchDoc_TEST"}, {"errorCode": "wrong_document"})+t("fusion_assembly_bom#guard", "negative",+ {"expectDocument": "ZZZ_NoSuchDoc_TEST"}, {"errorCode": "wrong_document"})+t("fusion_run_modeling_script", "daily",+ {"script": "result = {'ok': True}", "expectDocument": "BQ25792"}, {"ok": True})+t("fusion_execute_text_command", "daily", {"command": "Commands.List", "expectDocument": "BQ25792"}, {"ok": True})+t("fusion_electron_run", "weekly", {"command": "WINDOW FIT;"}, {"ok": True}, "board view must be active")+t("fusion_electron_zoom", "weekly", {"factor": 1.0}, {"ok": True})+t("fusion_electron_pan", "weekly", {"dx": 0, "dy": 0}, {"ok": True})+t("fusion_electron_select", "manual", note="selection state is user-visible")+t("fusion_open_by_urn", "weekly", note="URN fetched from aps_search at runtime", args={"_dynamic": "urn_from_search"})+t("fusion_aps_open", "weekly", {"query": "BQ25792", "wait": False}, {"ok": True})+t("fusion_open_design", "manual", note="local-file open; superseded by cloud flows")+t("fusion_close_window", "daily", {}, {"ok": True}, "closes the test board tab, restores tab state")+t("fusion_close_document", "manual"); t("fusion_close_all_documents", "manual", note="would close the user's own tabs")++# ── stage 4: exports (local writes to scratch) ──────────────────────────────+for fmt in ("step", "stl", "3mf", "f3d", "iges", "obj", "sat"):+ t(f"fusion_export_{fmt}", "weekly", {"outputPath": f"{SCRATCH}/verbtest.{fmt}"}, {"ok": True},+ "needs a DESIGN doc active; runner opens the 3D PCB first")+for fmt in ("fbx", "usdz", "dxf", "dwg", "skp"):+ t(f"fusion_export_{fmt}", "manual", note="format needs specific doc types; verify quarterly")+t("fusion_export_gerbers", "weekly", {"outputDir": SCRATCH}, {"ok": True}, "board view active")+t("fusion_export_bom", "weekly", {"outputPath": f"{SCRATCH}/bom.csv"}, {"ok": True})+t("fusion_export_cpl", "weekly", {"outputPath": f"{SCRATCH}/cpl.csv"}, {"ok": True})+t("fusion_export_optimized_glb", "weekly", {"outputPath": f"{SCRATCH}/board.glb"}, {"ok": True})+t("fusion_set_design_rules", "manual", note="mutates the board's DRC rules")+t("fusion_import_step", "weekly", {"_dynamic": "staged_step"}, {"ok": True}, "examples/ADS8588SIPM.step staged to the box")+t("fusion_web_export", "manual")++# ── stage 5: libraries (weekly; cloud writes cleaned by cleanup_cloud_files) ─+t("fusion_open_lbr", "weekly", {"_dynamic": "staged_lbr"}, {"ok": True})+t("fusion_capture_library_views", "weekly", {}, {"ok": True}, "after open_lbr")+t("fusion_generate_package", "weekly", {"type": "chip", "params": {"D": 1.6, "E": 0.8, "A": 0.45}}, {"ok": True})+t("fusion_attach_3d_package", "manual", note="modal Save dialog needs a human eye")+t("fusion_make_3d_package", "manual", note="cloud upload; run in release rehearsals")+t("fusion_build_library_3d", "manual", note="long multi-part build; release rehearsals")+t("fusion_cleanup_cloud_files", "weekly", {"dryRun": True}, {"ok": True})+t("fusion_board_stackup", "weekly", {}, {"ok": True})+t("fusion_new_electronics_from_eagle", "manual", note="drives native dialogs")+t("fusion_update_libraries", "manual")++# ── never in automation ──────────────────────────────────────────────────────+for v in ("fusion_stop", "fusion_kill", "fusion_signin", "fusion_signin_2fa", "fusion_demo",+ "fusion_install_fusion", "fusion_notify_owner", "fusion_open_by_urn_note"):+ t(v, "manual", note="lifecycle/human-wall verbs; manual tier")++PLAN = P++def main():+ ap = argparse.ArgumentParser()+ ap.add_argument("--target", required=True)+ ap.add_argument("--tier", default="daily", choices=["daily", "weekly", "all"])+ ap.add_argument("--only", default="")+ a = ap.parse_args()+ tiers = {"daily": {"daily", "negative"}, "weekly": {"daily", "weekly", "negative"},+ "all": {"daily", "weekly", "negative"}}[a.tier]+ only = set(x.strip() for x in a.only.split(",") if x.strip())++ described = {v[0] for v in describe._T}+ untriaged = described - {k.split("#")[0] for k in PLAN}+ results, t0 = [], time.time()+ for name in sorted(untriaged):+ results.append({"verb": name, "status": "FAIL", "why": "described but untriaged in tests/run_verb_tests.py"})++ for name, spec in PLAN.items():+ if spec["tier"] == "manual" or spec["tier"] not in tiers or (only and name not in only):+ if spec["tier"] == "manual" and not only:+ results.append({"verb": name, "status": "MANUAL", "why": spec.get("note", "")})+ continue+ if "_dynamic" in spec["args"]:+ results.append({"verb": name, "status": "SKIP", "why": "dynamic fixture not staged this run"})+ continue+ verb = name.split("#")[0]+ d, dt = call(a.target, verb, spec["args"])+ errs = expect(d, spec["exp"])+ if name.startswith("fusion_search_cloud_files") or name.startswith("fusion_walk_cloud_tree"):+ # must refuse: success:true here is the FAILURE+ errs = [] if (d.get("success") is False or "disabled" in str(d.get("error", "")).lower()+ or "DISABLED" in str(d.get("error", ""))) else ["deprecated verb did NOT refuse"]+ results.append({"verb": name, "status": "PASS" if not errs else "FAIL",+ "why": "; ".join(errs), "sec": round(dt, 1)})+ print(" %-42s %s %s" % (name, "PASS" if not errs else "FAIL", ("- " + "; ".join(errs)) if errs else ""))++ passed = sum(1 for r in results if r["status"] == "PASS")+ failed = [r for r in results if r["status"] == "FAIL"]+ stamp = datetime.datetime.now().strftime("%Y-%m-%d_%H%M")+ report = {"date": stamp, "target": a.target, "tier": a.tier,+ "pass": passed, "fail": len(failed), "results": results,+ "durationSec": round(time.time() - t0, 1)}+ out = REPO / "tests" / "reports" / f"verb-test-{stamp}.json"+ out.write_text(json.dumps(report, indent=1))+ print(f"\n{passed} PASS, {len(failed)} FAIL "+ f"({sum(1 for r in results if r['status']=='MANUAL')} manual-tier, "+ f"{sum(1 for r in results if r['status']=='SKIP')} skipped) -> {out}")+ for r in failed: print(" FAIL:", r["verb"], "-", r["why"])+ sys.exit(1 if failed else 0)++if __name__ == "__main__":+ main()