generated from coulomb/repo-seed
229 lines
6.7 KiB
Python
229 lines
6.7 KiB
Python
from pathlib import Path
|
|
|
|
from click.testing import CliRunner
|
|
|
|
from markitect_tool.cli import main
|
|
from markitect_tool.generation import GenerationHookRequest, GenerationHookResult
|
|
from markitect_tool.workflow import (
|
|
WorkflowRunner,
|
|
load_workflow_file,
|
|
resolve_workflow_bindings,
|
|
)
|
|
|
|
|
|
WORKFLOW = """# ADR Release Workflow
|
|
|
|
```yaml workflow
|
|
metadata:
|
|
id: adr-release
|
|
title: ADR Release Notes
|
|
intent:
|
|
summary: Collect accepted ADR decisions.
|
|
permissions:
|
|
filesystem:
|
|
read: [adrs, templates]
|
|
write: [out]
|
|
responsibilities:
|
|
agent:
|
|
may_run_deterministic_steps: true
|
|
inputs:
|
|
adrs:
|
|
glob: adrs/*.md
|
|
where:
|
|
frontmatter.status: accepted
|
|
extract:
|
|
decisions:
|
|
selector: sections[heading=Decision]
|
|
steps:
|
|
render:
|
|
kind: template
|
|
template: templates/release.md
|
|
data:
|
|
decisions: ${sources.adrs.extracts.decisions}
|
|
outputs:
|
|
release_notes:
|
|
path: out/release-notes.md
|
|
content: ${steps.render.markdown}
|
|
observability:
|
|
events: [workflow.started, workflow.completed]
|
|
```
|
|
"""
|
|
|
|
|
|
def _write_workflow_fixture(tmp_path: Path) -> Path:
|
|
(tmp_path / "adrs").mkdir()
|
|
(tmp_path / "templates").mkdir()
|
|
(tmp_path / "adrs" / "one.md").write_text(
|
|
"---\nstatus: accepted\n---\n# One\n\n## Decision\n\nUse the workflow engine.\n",
|
|
encoding="utf-8",
|
|
)
|
|
(tmp_path / "adrs" / "two.md").write_text(
|
|
"---\nstatus: proposed\n---\n# Two\n\n## Decision\n\nIgnore this one.\n",
|
|
encoding="utf-8",
|
|
)
|
|
(tmp_path / "templates" / "release.md").write_text(
|
|
"# Release Notes\n\n{{decisions}}\n",
|
|
encoding="utf-8",
|
|
)
|
|
workflow = tmp_path / "workflow.md"
|
|
workflow.write_text(WORKFLOW, encoding="utf-8")
|
|
return workflow
|
|
|
|
|
|
def test_load_workflow_file_preserves_standard_sections(tmp_path: Path):
|
|
workflow = _write_workflow_fixture(tmp_path)
|
|
|
|
plan = load_workflow_file(workflow)
|
|
|
|
assert plan.id == "adr-release"
|
|
assert plan.intent["summary"] == "Collect accepted ADR decisions."
|
|
assert plan.permissions["filesystem"]["read"] == ["adrs", "templates"]
|
|
assert plan.responsibilities["agent"]["may_run_deterministic_steps"] is True
|
|
assert plan.observability["events"] == ["workflow.started", "workflow.completed"]
|
|
assert plan.steps[0]["id"] == "render"
|
|
|
|
|
|
def test_workflow_runner_collects_sources_and_renders_output(tmp_path: Path):
|
|
workflow = _write_workflow_fixture(tmp_path)
|
|
plan = load_workflow_file(workflow)
|
|
|
|
result = WorkflowRunner(plan).run(dry_run=True)
|
|
|
|
assert result.valid
|
|
assert result.sources["adrs"]["count"] == 1
|
|
assert "Use the workflow engine." in result.sources["adrs"]["extracts"]["decisions"][0]
|
|
assert result.steps["render"]["complete"]
|
|
assert result.outputs[0].path.endswith("out/release-notes.md")
|
|
assert not result.outputs[0].written
|
|
assert "Ignore this one" not in result.outputs[0].content
|
|
|
|
|
|
def test_workflow_runner_writes_outputs_under_output_dir(tmp_path: Path):
|
|
workflow = _write_workflow_fixture(tmp_path)
|
|
plan = load_workflow_file(workflow)
|
|
output_dir = tmp_path / "build"
|
|
|
|
result = WorkflowRunner(plan, output_dir=output_dir).run()
|
|
|
|
assert result.valid
|
|
output = output_dir / "out" / "release-notes.md"
|
|
assert output.exists()
|
|
assert "Use the workflow engine" in output.read_text(encoding="utf-8")
|
|
assert result.outputs[0].written
|
|
|
|
|
|
def test_resolve_workflow_bindings_preserves_native_types_and_projects_lists():
|
|
context = {
|
|
"sources": {
|
|
"docs": {
|
|
"items": [
|
|
{"path": "a.md", "frontmatter": {"status": "accepted"}},
|
|
{"path": "b.md", "frontmatter": {"status": "proposed"}},
|
|
],
|
|
}
|
|
}
|
|
}
|
|
|
|
value = resolve_workflow_bindings(
|
|
{
|
|
"paths": "${sources.docs.items.path}",
|
|
"sentence": "Files: ${sources.docs.items.path}",
|
|
},
|
|
context,
|
|
)
|
|
|
|
assert value["paths"] == ["a.md", "b.md"]
|
|
assert value["sentence"] == "Files: - a.md\n- b.md"
|
|
|
|
|
|
def test_optional_assisted_step_skips_without_hook(tmp_path: Path):
|
|
workflow = tmp_path / "workflow.yaml"
|
|
workflow.write_text(
|
|
"""
|
|
metadata:
|
|
id: assisted-demo
|
|
steps:
|
|
review:
|
|
kind: assisted
|
|
optional: true
|
|
prompt_text: Review this.
|
|
outputs:
|
|
review:
|
|
content: ${steps.review.skipped}
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = WorkflowRunner(load_workflow_file(workflow)).run(dry_run=True)
|
|
|
|
assert result.valid
|
|
assert result.steps["review"]["skipped"] is True
|
|
assert result.steps["review"]["diagnostics"][0]["code"] == "workflow.assisted_unavailable"
|
|
|
|
|
|
class _FakeHook:
|
|
def generate(self, request: GenerationHookRequest) -> GenerationHookResult:
|
|
return GenerationHookResult(
|
|
markdown=f"Reviewed: {request.prompt}",
|
|
provider="fake",
|
|
)
|
|
|
|
|
|
def test_assisted_step_uses_injected_hook(tmp_path: Path):
|
|
workflow = tmp_path / "workflow.yaml"
|
|
workflow.write_text(
|
|
"""
|
|
metadata:
|
|
id: assisted-demo
|
|
steps:
|
|
review:
|
|
kind: assisted
|
|
optional: false
|
|
prompt_text: Review this.
|
|
outputs:
|
|
review:
|
|
content: ${steps.review.markdown}
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = WorkflowRunner(load_workflow_file(workflow), assisted_hook=_FakeHook()).run(dry_run=True)
|
|
|
|
assert result.valid
|
|
assert result.steps["review"]["provider"] == "fake"
|
|
assert result.outputs[0].content == "Reviewed: Review this."
|
|
|
|
|
|
def test_mkt_workflow_inspect_plan_and_run(tmp_path: Path):
|
|
workflow = _write_workflow_fixture(tmp_path)
|
|
runner = CliRunner()
|
|
|
|
inspected = runner.invoke(main, ["workflow", "inspect", str(workflow)])
|
|
planned = runner.invoke(main, ["workflow", "plan", str(workflow)])
|
|
run = runner.invoke(main, ["workflow", "run", str(workflow), "--output-dir", str(tmp_path / "build")])
|
|
|
|
assert inspected.exit_code == 0
|
|
assert "valid" in inspected.output
|
|
assert planned.exit_code == 0
|
|
assert "outputs: 1" in planned.output
|
|
assert run.exit_code == 0
|
|
assert "written" in run.output
|
|
|
|
|
|
def test_example_adr_release_notes_workflow_runs(tmp_path: Path):
|
|
workflow = Path("examples/workflows/adr-release-notes.workflow.md")
|
|
result = WorkflowRunner(load_workflow_file(workflow), output_dir=tmp_path).run()
|
|
|
|
assert result.valid
|
|
assert result.sources["adrs"]["count"] == 2
|
|
assert (tmp_path / "out" / "release-notes.md").exists()
|
|
assert "local SQLite backend" in (tmp_path / "out" / "release-notes.md").read_text(encoding="utf-8")
|
|
|
|
|
|
def test_example_assisted_review_workflow_is_deterministic_without_hook(tmp_path: Path):
|
|
workflow = Path("examples/workflows/assisted-review.workflow.md")
|
|
result = WorkflowRunner(load_workflow_file(workflow), output_dir=tmp_path).run(dry_run=True)
|
|
|
|
assert result.valid
|
|
assert result.steps["review"]["skipped"] is True
|