generated from coulomb/repo-seed
declarative Markdown workflow layer
This commit is contained in:
228
tests/test_workflow_engine.py
Normal file
228
tests/test_workflow_engine.py
Normal file
@@ -0,0 +1,228 @@
|
||||
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
|
||||
Reference in New Issue
Block a user