378 lines
12 KiB
Python
378 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from typer import Context as TyperContext
|
|
from typer.testing import CliRunner
|
|
|
|
from tests.wf_mcp.test_support import echo_tool
|
|
from tests.wf_mcp.workflow_surface.conftest import echo_artifact
|
|
from wf_artifacts import FileWorkflowArtifactStore, WorkflowDeployment
|
|
from wf_cli.app import app
|
|
from wf_cli.context import CliContext, config_path_from_context, load_cli_context
|
|
from wf_cli.formats import ListOutputFormat, render_list_payload
|
|
from wf_cli.io import CliInputError, parse_bindings, parse_json_value
|
|
|
|
|
|
def _load_cli_context_with_specs(ctx: TyperContext | str | Path) -> CliContext:
|
|
if isinstance(ctx, (str, Path)):
|
|
config_path = ctx
|
|
else:
|
|
config_path = config_path_from_context(ctx)
|
|
context = load_cli_context(config_path)
|
|
service = context.service
|
|
assert service is not None
|
|
service.register_specs("demo.personal", echo_tool)
|
|
return context
|
|
|
|
|
|
def test_render_list_payload_ids_uses_requested_id_field() -> None:
|
|
payload = {"capabilities": [{"name": "wf.std.truthy"}, {"name": "wf.std.add"}]}
|
|
|
|
rendered = render_list_payload(
|
|
payload,
|
|
collection_key="capabilities",
|
|
output_format=ListOutputFormat.IDS,
|
|
id_field="name",
|
|
)
|
|
|
|
assert rendered == "wf.std.truthy\nwf.std.add"
|
|
|
|
|
|
def test_render_list_payload_compact_includes_summary_fields() -> None:
|
|
payload = {
|
|
"nodes": [
|
|
{
|
|
"name": "workflow.echo.v1",
|
|
"kind": "workflow",
|
|
"display_name": "Echo",
|
|
}
|
|
]
|
|
}
|
|
|
|
rendered = render_list_payload(
|
|
payload,
|
|
collection_key="nodes",
|
|
output_format=ListOutputFormat.COMPACT,
|
|
id_field="name",
|
|
summary_fields=("kind", "display_name"),
|
|
)
|
|
|
|
assert rendered == "workflow.echo.v1\tkind=workflow\tdisplay_name=Echo"
|
|
|
|
|
|
def test_render_list_payload_json_returns_pretty_json() -> None:
|
|
payload = {"deployments": [{"id": "echo.personal"}]}
|
|
|
|
rendered = render_list_payload(
|
|
payload,
|
|
collection_key="deployments",
|
|
output_format=ListOutputFormat.JSON,
|
|
id_field="id",
|
|
)
|
|
|
|
parsed = json.loads(rendered)
|
|
assert parsed["deployments"][0]["id"] == "echo.personal"
|
|
|
|
|
|
def test_render_list_payload_rejects_missing_collection_key() -> None:
|
|
with pytest.raises(ValueError, match="missing required field 'nodes'"):
|
|
render_list_payload(
|
|
{"capabilities": []},
|
|
collection_key="nodes",
|
|
output_format=ListOutputFormat.IDS,
|
|
id_field="name",
|
|
)
|
|
|
|
|
|
def test_render_list_payload_rejects_non_array_collection() -> None:
|
|
with pytest.raises(ValueError, match="field 'nodes' must be an array"):
|
|
render_list_payload(
|
|
{"nodes": {}},
|
|
collection_key="nodes",
|
|
output_format=ListOutputFormat.IDS,
|
|
id_field="name",
|
|
)
|
|
|
|
|
|
def test_parse_json_value_accepts_arrays_for_json_patch() -> None:
|
|
value = parse_json_value(
|
|
input_json='[{"op":"replace","path":"/name","value":"x"}]', input_file=None
|
|
)
|
|
|
|
assert isinstance(value, list)
|
|
assert value[0]["op"] == "replace"
|
|
|
|
|
|
def test_parse_json_value_rejects_both_input_modes(tmp_path) -> None:
|
|
payload = tmp_path / "payload.json"
|
|
payload.write_text("{}", encoding="utf-8")
|
|
|
|
with pytest.raises(CliInputError, match="mutually exclusive"):
|
|
parse_json_value(input_json="{}", input_file=payload)
|
|
|
|
|
|
def test_parse_bindings_rejects_invalid_shape() -> None:
|
|
with pytest.raises(CliInputError, match="logical=concrete"):
|
|
parse_bindings(["demo.personal"])
|
|
|
|
|
|
def test_parse_bindings_rejects_duplicate_logical_source() -> None:
|
|
with pytest.raises(CliInputError, match="duplicate --binding"):
|
|
parse_bindings(["demo=demo.personal", "demo=demo.work"])
|
|
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
def _write_cli_config(root: Path) -> Path:
|
|
config_path = root / "wf_mcp.config.json"
|
|
config_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"store_root": ".wf_mcp_store",
|
|
"connections": [
|
|
{"id": "demo.personal", "server": "demo", "account": "personal"}
|
|
],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return config_path
|
|
|
|
|
|
def test_wf_cap_list_outputs_json(tmp_path: Path) -> None:
|
|
root = tmp_path / "wf_cli_cap_list"
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
config_path = _write_cli_config(root)
|
|
|
|
with patch(
|
|
"wf_cli.commands.caps.load_cli_context_from_typer", _load_cli_context_with_specs
|
|
):
|
|
result = runner.invoke(
|
|
app,
|
|
["--config", str(config_path), "cap", "list", "--source", "demo.personal"],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
payload = json.loads(result.output)
|
|
assert payload["capabilities"][0]["name"] == "demo.personal.echo_tool"
|
|
assert payload["capabilities"][0]["source_id"] == "demo.personal"
|
|
|
|
|
|
def test_wf_cap_list_ids_format(tmp_path: Path) -> None:
|
|
root = tmp_path / "wf_cli_cap_list_ids"
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
config_path = _write_cli_config(root)
|
|
|
|
with patch(
|
|
"wf_cli.commands.caps.load_cli_context_from_typer", _load_cli_context_with_specs
|
|
):
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"--config",
|
|
str(config_path),
|
|
"cap",
|
|
"list",
|
|
"--source",
|
|
"demo.personal",
|
|
"--format",
|
|
"ids",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert result.output.strip() == "demo.personal.echo_tool"
|
|
|
|
|
|
def test_wf_cap_inspect_outputs_detail(tmp_path: Path) -> None:
|
|
root = tmp_path / "wf_cli_cap_inspect"
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
config_path = _write_cli_config(root)
|
|
|
|
with patch(
|
|
"wf_cli.commands.caps.load_cli_context_from_typer", _load_cli_context_with_specs
|
|
):
|
|
result = runner.invoke(
|
|
app,
|
|
["--config", str(config_path), "cap", "inspect", "demo.personal.echo_tool"],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
payload = json.loads(result.output)
|
|
assert payload["name"] == "demo.personal.echo_tool"
|
|
assert payload["wrapper_hints"]["input_map"] == {"input.text": "text"}
|
|
|
|
|
|
def _seed_echo_artifact(root: Path) -> Path:
|
|
config_path = _write_cli_config(root)
|
|
store = FileWorkflowArtifactStore(root / ".wf_mcp_store")
|
|
store.save_artifact(echo_artifact())
|
|
return config_path
|
|
|
|
|
|
def _seed_echo_deployment(root: Path) -> Path:
|
|
config_path = _seed_echo_artifact(root)
|
|
store = FileWorkflowArtifactStore(root / ".wf_mcp_store")
|
|
store.save_deployment(
|
|
WorkflowDeployment(
|
|
id="echo.personal",
|
|
artifact_id="echo",
|
|
artifact_version=1,
|
|
bindings=[{"logical_source": "demo", "concrete_source": "demo.personal"}],
|
|
)
|
|
)
|
|
return config_path
|
|
|
|
|
|
def test_wf_artifact_list_and_inspect(tmp_path: Path) -> None:
|
|
root = tmp_path / "wf_cli_artifacts"
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
config_path = _seed_echo_artifact(root)
|
|
|
|
listed = runner.invoke(
|
|
app, ["--config", str(config_path), "artifact", "list", "--format", "ids"]
|
|
)
|
|
inspected = runner.invoke(
|
|
app, ["--config", str(config_path), "artifact", "inspect", "echo", "1"]
|
|
)
|
|
|
|
assert listed.exit_code == 0
|
|
assert listed.output.strip() == "workflow.echo.v1"
|
|
assert inspected.exit_code == 0
|
|
payload = json.loads(inspected.output)
|
|
assert payload["id"] == "echo"
|
|
assert payload["version"] == 1
|
|
|
|
|
|
def test_wf_deploy_list_inspect_save_delete(tmp_path: Path) -> None:
|
|
root = tmp_path / "wf_cli_deploy_lifecycle"
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
config_path = _seed_echo_deployment(root)
|
|
|
|
listed = runner.invoke(
|
|
app, ["--config", str(config_path), "deploy", "list", "--format", "ids"]
|
|
)
|
|
inspected = runner.invoke(
|
|
app, ["--config", str(config_path), "deploy", "inspect", "echo.personal"]
|
|
)
|
|
saved = runner.invoke(
|
|
app,
|
|
[
|
|
"--config",
|
|
str(config_path),
|
|
"deploy",
|
|
"save",
|
|
"echo.copy",
|
|
"--artifact",
|
|
"echo",
|
|
"--version",
|
|
"1",
|
|
"--binding",
|
|
"demo=demo.personal",
|
|
],
|
|
)
|
|
deleted = runner.invoke(
|
|
app, ["--config", str(config_path), "deploy", "delete", "echo.copy"]
|
|
)
|
|
|
|
assert listed.exit_code == 0
|
|
assert listed.output.strip() == "echo.personal"
|
|
assert inspected.exit_code == 0
|
|
assert json.loads(inspected.output)["id"] == "echo.personal"
|
|
assert saved.exit_code == 0
|
|
assert json.loads(saved.output)["deployment_id"] == "echo.copy"
|
|
assert deleted.exit_code == 0
|
|
assert json.loads(deleted.output)["deleted"] is True
|
|
|
|
|
|
def test_wf_draft_create_patch_validate_save(tmp_path: Path) -> None:
|
|
root = tmp_path / "wf_cli_draft_lifecycle"
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
config_path = _write_cli_config(root)
|
|
|
|
with patch("wf_cli.commands.drafts.load_cli_context", _load_cli_context_with_specs):
|
|
created = runner.invoke(
|
|
app,
|
|
[
|
|
"--config",
|
|
str(config_path),
|
|
"draft",
|
|
"create",
|
|
"echo_workspace",
|
|
"--capability",
|
|
"demo.personal.echo_tool",
|
|
"--name",
|
|
"echo_workspace",
|
|
],
|
|
)
|
|
listed = runner.invoke(
|
|
app, ["--config", str(config_path), "draft", "list", "--format", "ids"]
|
|
)
|
|
inspected = runner.invoke(
|
|
app,
|
|
[
|
|
"--config",
|
|
str(config_path),
|
|
"draft",
|
|
"inspect",
|
|
"echo_workspace",
|
|
"--include-draft",
|
|
],
|
|
)
|
|
revision = json.loads(created.output)["revision"]
|
|
patched = runner.invoke(
|
|
app,
|
|
[
|
|
"--config",
|
|
str(config_path),
|
|
"draft",
|
|
"patch",
|
|
"echo_workspace",
|
|
"--revision",
|
|
str(revision),
|
|
"--input",
|
|
'[{"op":"replace","path":"/name","value":"echo_workspace_renamed"}]',
|
|
],
|
|
)
|
|
validated = runner.invoke(
|
|
app, ["--config", str(config_path), "draft", "validate", "echo_workspace"]
|
|
)
|
|
saved = runner.invoke(
|
|
app,
|
|
[
|
|
"--config",
|
|
str(config_path),
|
|
"draft",
|
|
"save",
|
|
"echo_workspace",
|
|
"--artifact",
|
|
"echo_workspace",
|
|
"--version",
|
|
"1",
|
|
"--title",
|
|
"Echo Workspace",
|
|
"--outcome",
|
|
"completed",
|
|
"--binding",
|
|
"demo=demo.personal",
|
|
],
|
|
)
|
|
|
|
assert created.exit_code == 0
|
|
assert json.loads(created.output)["workspace_id"] == "echo_workspace"
|
|
assert listed.exit_code == 0
|
|
assert listed.output.strip() == "echo_workspace"
|
|
assert inspected.exit_code == 0
|
|
assert "draft" in json.loads(inspected.output)
|
|
assert patched.exit_code == 0
|
|
assert json.loads(patched.output)["revision"] == revision + 1
|
|
assert validated.exit_code == 0
|
|
assert json.loads(validated.output)["status"] == "valid"
|
|
assert saved.exit_code == 0
|
|
assert json.loads(saved.output)["saved"] is True
|