pbi-cli/tests/test_custom_visual_backend.py
Mina Saad bc9a1ab3d0
feat: add custom visual authoring (vibe-code .pbiviz) (#4)
* feat: add custom visual authoring (vibe-code .pbiviz)

Ship the Power BI Custom Visuals workflow as a Claude skill plus three
thin pbi-cli commands. The skill scaffolds a TypeScript project sibling
to the .pbip via npx pbiviz new, iterates against the SDK with
tsc --noEmit between every change, packages a .pbiviz, and embeds it
into the report.

Commands:
- pbi visual import-custom <pbiviz> [--replace]: copy locally-built
  .pbiviz into StaticResources/RegisteredResources/ and register in
  report.json (customVisuals + resourcePackages).
- pbi visual list-custom: list embedded and public custom visuals,
  distinguished by kind.
- pbi visual remove-custom <guid-or-name>: deregister and physically
  delete the .pbiviz.

Skill (power-bi-custom-visuals/):
- Auto-installs Node and pbiviz with user consent on first run.
- Pins powerbi-visuals-tools and powerbi-visuals-api to known-good
  versions matched against the AGENTS.md crib.
- Sibling-directory scaffold; never contaminates PBIR folders.
- Plan-then-code on fresh scaffolds (data roles + render approach).
- Agent-driven inner loop on tsc --noEmit with 5-turn no-progress cap
  and oscillation detection.
- Auto-bumps pbiviz.json patch on every package to invalidate Power BI
  Desktop's GUID+version cache; respects manual user version overrides.
- npm allowlist baked into SKILL.md (D3, Lodash, date-fns,
  powerbi-visuals-utils-*); off-list installs require explicit
  approval with name + version + reason + bundle-size justification.

Tests:
- 25 unit tests with synthetic in-memory .pbiviz fixtures, no Node
  required.
- New nightly + on-touch GitHub workflow runs the full pbiviz toolchain
  end-to-end on Windows runners to catch SDK-version drift.

Out of scope (deferred): pbiviz start live preview, AppSource
register-public flow, freeze-version publish helper, inspect-custom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version to 3.11.0

Minor version bump for the additive custom visual authoring feature
(new power-bi-custom-visuals skill plus three new pbi visual commands).
No breaking changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: visual names must be letters/digits only

The pbiviz new command rejects names containing anything other than
letters and digits, with the error "The visual name can contain only
letters and numbers". The smoke workflow and docs used hyphenated names
(smoke-visual, my-gauge-visual) that fail at scaffold time.

- Rename smoke workflow's visual to smokevisual
- Document the naming constraint in SKILL.md so Claude strips hyphens
  and underscores from user-provided names before scaffolding
- Update README example to mygaugevisual

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 23:59:29 +03:00

371 lines
14 KiB
Python

"""Tests for pbi_cli.core.custom_visual_backend.
Covers import-custom, list-custom, remove-custom on PBIR, plus the
pbiviz.json patch-bump helper used by the power-bi-custom-visuals skill.
Fixture .pbiviz files are built in-memory with ``zipfile`` rather than
shelled out to ``pbiviz package`` so the unit suite stays Node-free.
"""
from __future__ import annotations
import io
import json
import zipfile
from pathlib import Path
from typing import Any
import pytest
from pbi_cli.core.custom_visual_backend import (
custom_visual_import,
custom_visual_list,
custom_visual_remove,
pbiviz_bump_patch,
)
from pbi_cli.core.errors import PbiCliError
# ---------------------------------------------------------------------------
# Fixture helpers
# ---------------------------------------------------------------------------
def _build_pbiviz(
path: Path,
*,
guid: str = "MyVisual1234ABCD",
name: str = "MyVisual",
display_name: str | None = None,
version: str = "1.0.0",
api_version: str = "5.11.0",
omit_package_json: bool = False,
bad_zip: bool = False,
bad_json: bool = False,
missing_visual_block: bool = False,
missing_guid: bool = False,
) -> Path:
"""Write a synthetic .pbiviz to *path* and return it.
Knobs let tests exercise validation paths without maintaining
multiple checked-in binary fixtures.
"""
if bad_zip:
path.write_bytes(b"this is not a zip file")
return path
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
if not omit_package_json:
visual_block: dict[str, Any] = {}
if not missing_visual_block:
visual_block = {
"name": name,
"displayName": display_name or name,
"version": version,
"apiVersion": api_version,
}
if not missing_guid:
visual_block["guid"] = guid
manifest: dict[str, Any] = {} if missing_visual_block else {"visual": visual_block}
payload = "this is not json" if bad_json else json.dumps(manifest)
zf.writestr("package.json", payload)
# Minimal resources file so the zip isn't suspiciously empty.
zf.writestr("resources/visual.js", "// stub")
path.write_bytes(buf.getvalue())
return path
def _make_report(tmp_path: Path) -> Path:
"""Build a tiny PBIR-shaped folder and return the *definition* path."""
report = tmp_path / "Demo.Report"
definition = report / "definition"
definition.mkdir(parents=True)
(definition / "report.json").write_text(
json.dumps({"layoutOptimization": "Disabled"}, indent=2),
encoding="utf-8",
)
return definition
def _read_report_json(definition: Path) -> dict[str, Any]:
return json.loads((definition / "report.json").read_text(encoding="utf-8"))
# ---------------------------------------------------------------------------
# import-custom
# ---------------------------------------------------------------------------
class TestImport:
def test_imports_a_valid_pbiviz(self, tmp_path: Path) -> None:
defn = _make_report(tmp_path)
pbiviz = _build_pbiviz(tmp_path / "MyVisual.pbiviz")
result = custom_visual_import(defn, pbiviz, replace=False)
assert result["status"] == "added"
assert result["guid"] == "MyVisual1234ABCD"
assert result["version"] == "1.0.0"
assert result["replaced"] is False
# File landed in RegisteredResources/
resources = defn.parent / "StaticResources" / "RegisteredResources"
assert resources.is_dir()
landed = list(resources.glob("*.pbiviz"))
assert len(landed) == 1
assert "MyVisual1234ABCD" in landed[0].name
# report.json registered both the resource and the customVisual
rj = _read_report_json(defn)
assert rj["customVisuals"] == [{"name": "MyVisual1234ABCD", "version": "1.0.0"}]
pkg = next(p for p in rj["resourcePackages"] if p["name"] == "RegisteredResources")
assert any(i["type"] == 5 and "MyVisual1234ABCD" in i["name"] for i in pkg["items"])
def test_rejects_duplicate_without_replace(self, tmp_path: Path) -> None:
defn = _make_report(tmp_path)
pbiviz = _build_pbiviz(tmp_path / "v1.pbiviz")
custom_visual_import(defn, pbiviz, replace=False)
# Second import with same GUID
pbiviz2 = _build_pbiviz(tmp_path / "v2.pbiviz", version="1.0.1")
with pytest.raises(PbiCliError, match="already registered"):
custom_visual_import(defn, pbiviz2, replace=False)
def test_replace_overwrites_in_place(self, tmp_path: Path) -> None:
defn = _make_report(tmp_path)
custom_visual_import(defn, _build_pbiviz(tmp_path / "v1.pbiviz"), replace=False)
result = custom_visual_import(
defn,
_build_pbiviz(tmp_path / "v2.pbiviz", version="1.0.42"),
replace=True,
)
assert result["status"] == "added"
assert result["replaced"] is True
assert result["version"] == "1.0.42"
rj = _read_report_json(defn)
assert rj["customVisuals"] == [{"name": "MyVisual1234ABCD", "version": "1.0.42"}]
# No duplicate items in the resource package
pkg = next(p for p in rj["resourcePackages"] if p["name"] == "RegisteredResources")
custom_items = [i for i in pkg["items"] if i["type"] == 5]
assert len(custom_items) == 1
def test_missing_pbiviz_raises(self, tmp_path: Path) -> None:
defn = _make_report(tmp_path)
with pytest.raises(PbiCliError, match="not found"):
custom_visual_import(defn, tmp_path / "nope.pbiviz", replace=False)
def test_bad_zip_raises(self, tmp_path: Path) -> None:
defn = _make_report(tmp_path)
pbiviz = _build_pbiviz(tmp_path / "bad.pbiviz", bad_zip=True)
with pytest.raises(PbiCliError, match="not a valid zip"):
custom_visual_import(defn, pbiviz, replace=False)
def test_missing_package_json_raises(self, tmp_path: Path) -> None:
defn = _make_report(tmp_path)
pbiviz = _build_pbiviz(tmp_path / "no-manifest.pbiviz", omit_package_json=True)
with pytest.raises(PbiCliError, match="package.json not found"):
custom_visual_import(defn, pbiviz, replace=False)
def test_bad_json_raises(self, tmp_path: Path) -> None:
defn = _make_report(tmp_path)
pbiviz = _build_pbiviz(tmp_path / "badjson.pbiviz", bad_json=True)
with pytest.raises(PbiCliError, match="not valid JSON"):
custom_visual_import(defn, pbiviz, replace=False)
def test_missing_visual_block_raises(self, tmp_path: Path) -> None:
defn = _make_report(tmp_path)
pbiviz = _build_pbiviz(tmp_path / "noblock.pbiviz", missing_visual_block=True)
with pytest.raises(PbiCliError, match="missing 'visual'"):
custom_visual_import(defn, pbiviz, replace=False)
def test_missing_guid_raises(self, tmp_path: Path) -> None:
defn = _make_report(tmp_path)
pbiviz = _build_pbiviz(tmp_path / "noguid.pbiviz", missing_guid=True)
with pytest.raises(PbiCliError, match="visual.guid"):
custom_visual_import(defn, pbiviz, replace=False)
def test_no_report_json_raises(self, tmp_path: Path) -> None:
# Definition folder exists but report.json doesn't
report = tmp_path / "Demo.Report"
defn = report / "definition"
defn.mkdir(parents=True)
pbiviz = _build_pbiviz(tmp_path / "v.pbiviz")
with pytest.raises(PbiCliError, match="report.json not found"):
custom_visual_import(defn, pbiviz, replace=False)
# ---------------------------------------------------------------------------
# list-custom
# ---------------------------------------------------------------------------
class TestList:
def test_empty_report(self, tmp_path: Path) -> None:
defn = _make_report(tmp_path)
result = custom_visual_list(defn)
assert result == {"embedded": [], "public": [], "total": 0}
def test_lists_embedded(self, tmp_path: Path) -> None:
defn = _make_report(tmp_path)
custom_visual_import(defn, _build_pbiviz(tmp_path / "v.pbiviz"), replace=False)
result = custom_visual_list(defn)
assert result["total"] == 1
assert result["public"] == []
assert len(result["embedded"]) == 1
e = result["embedded"][0]
assert e["kind"] == "embedded"
assert e["guid"] == "MyVisual1234ABCD"
assert e["version"] == "1.0.0"
assert e["file"] is not None and e["file"].endswith(".pbiviz")
def test_lists_public_visuals(self, tmp_path: Path) -> None:
defn = _make_report(tmp_path)
rj_path = defn / "report.json"
data = json.loads(rj_path.read_text(encoding="utf-8"))
data["publicCustomVisuals"] = ["PublicGuid1", "PublicGuid2"]
rj_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
result = custom_visual_list(defn)
assert result["total"] == 2
assert result["embedded"] == []
assert {p["guid"] for p in result["public"]} == {"PublicGuid1", "PublicGuid2"}
assert all(p["kind"] == "public" for p in result["public"])
def test_lists_mixed(self, tmp_path: Path) -> None:
defn = _make_report(tmp_path)
custom_visual_import(defn, _build_pbiviz(tmp_path / "v.pbiviz"), replace=False)
rj_path = defn / "report.json"
data = json.loads(rj_path.read_text(encoding="utf-8"))
data["publicCustomVisuals"] = ["PublicGuid1"]
rj_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
result = custom_visual_list(defn)
assert result["total"] == 2
assert len(result["embedded"]) == 1
assert len(result["public"]) == 1
# ---------------------------------------------------------------------------
# remove-custom
# ---------------------------------------------------------------------------
class TestRemove:
def test_removes_by_guid(self, tmp_path: Path) -> None:
defn = _make_report(tmp_path)
custom_visual_import(defn, _build_pbiviz(tmp_path / "v.pbiviz"), replace=False)
result = custom_visual_remove(defn, "MyVisual1234ABCD")
assert result["status"] == "removed"
assert result["guid"] == "MyVisual1234ABCD"
assert result["file_deleted"] is True
# File gone, registration gone
resources = defn.parent / "StaticResources" / "RegisteredResources"
assert list(resources.glob("*.pbiviz")) == []
rj = _read_report_json(defn)
assert rj.get("customVisuals", []) == []
def test_removes_by_name(self, tmp_path: Path) -> None:
defn = _make_report(tmp_path)
custom_visual_import(
defn,
_build_pbiviz(tmp_path / "v.pbiviz", name="MyVisual"),
replace=False,
)
result = custom_visual_remove(defn, "MyVisual")
assert result["status"] == "removed"
assert result["guid"] == "MyVisual1234ABCD"
def test_ambiguous_name_raises(self, tmp_path: Path) -> None:
defn = _make_report(tmp_path)
custom_visual_import(
defn,
_build_pbiviz(tmp_path / "a.pbiviz", guid="GuidA1234567890", name="Same"),
replace=False,
)
custom_visual_import(
defn,
_build_pbiviz(tmp_path / "b.pbiviz", guid="GuidB1234567890", name="Same"),
replace=False,
)
with pytest.raises(PbiCliError, match="matches 2 custom visuals"):
custom_visual_remove(defn, "Same")
def test_unknown_identifier_raises(self, tmp_path: Path) -> None:
defn = _make_report(tmp_path)
with pytest.raises(PbiCliError, match="No embedded custom visual found"):
custom_visual_remove(defn, "NotARealGuid")
def test_empty_identifier_raises(self, tmp_path: Path) -> None:
defn = _make_report(tmp_path)
with pytest.raises(PbiCliError, match="required"):
custom_visual_remove(defn, " ")
# ---------------------------------------------------------------------------
# pbiviz.json patch bump
# ---------------------------------------------------------------------------
class TestPatchBump:
def _write(self, path: Path, version: str) -> None:
path.write_text(
json.dumps({"visual": {"version": version}}, indent=2),
encoding="utf-8",
)
def test_bumps_clean_semver(self, tmp_path: Path) -> None:
pj = tmp_path / "pbiviz.json"
self._write(pj, "1.0.5")
result = pbiviz_bump_patch(pj)
assert result == {"status": "bumped", "previous": "1.0.5", "version": "1.0.6"}
data = json.loads(pj.read_text(encoding="utf-8"))
assert data["visual"]["version"] == "1.0.6"
def test_bumps_zero_patch(self, tmp_path: Path) -> None:
pj = tmp_path / "pbiviz.json"
self._write(pj, "0.1.0")
assert pbiviz_bump_patch(pj)["version"] == "0.1.1"
def test_skips_user_override_pattern(self, tmp_path: Path) -> None:
pj = tmp_path / "pbiviz.json"
self._write(pj, "1.0.0-rc.1")
result = pbiviz_bump_patch(pj)
assert result["status"] == "skipped"
assert "user override" in result["reason"]
# File untouched
data = json.loads(pj.read_text(encoding="utf-8"))
assert data["visual"]["version"] == "1.0.0-rc.1"
def test_missing_file_raises(self, tmp_path: Path) -> None:
with pytest.raises(PbiCliError, match="not found"):
pbiviz_bump_patch(tmp_path / "missing.json")
def test_missing_visual_block_raises(self, tmp_path: Path) -> None:
pj = tmp_path / "pbiviz.json"
pj.write_text(json.dumps({"other": "stuff"}), encoding="utf-8")
with pytest.raises(PbiCliError, match="missing 'visual'"):
pbiviz_bump_patch(pj)
def test_missing_version_raises(self, tmp_path: Path) -> None:
pj = tmp_path / "pbiviz.json"
pj.write_text(json.dumps({"visual": {"name": "x"}}), encoding="utf-8")
with pytest.raises(PbiCliError, match="visual.version"):
pbiviz_bump_patch(pj)