Files
github-spec-kit/tests/workflows/test_overlay_schema.py
Markus 14a8d2fadb feat(workflows): add standalone WorkflowResolver and overlay subsystem
Implement PR 1 of the workflow-overlays plan: a concrete, standalone
WorkflowResolver for downstream workflow extensibility without touching the
Preset subsystem.

- Add overlay manifest schema (Overlay, OverlayEdit, validate_overlay_yaml)
- Add pure-function merge engine (find_step, apply_edit, merge_steps,
  validate_edits) with recursive anchor search and higher-wins semantics
- Add StepListComposer and tiered layer sources (project, installed, base)
- Add WorkflowResolver facade with inline HIGHER_WINS priority sorting
- Add CLI verbs: workflow overlay add/set-priority/enable/disable/remove/list
  and workflow resolve <id>
- Wire WorkflowEngine.load_workflow through WorkflowResolver
- Extend workflow add to copy optional overlays/ subdirectory from local
  workflow directories
- Add comprehensive unit, integration, and security tests

Refs: discussion #3473 (https://github.com/github/spec-kit/discussions/3473)

Assisted-by: Kimi (model: opencode-go/kimi-k2.7-code, autonomous)
2026-07-16 10:34:38 +02:00

219 lines
6.8 KiB
Python

"""Tests for overlay YAML schema normalization, especially shorthand edits."""
from __future__ import annotations
import pytest
from specify_cli.workflows.overlays.schema import (
OverlayEdit,
validate_overlay_yaml,
)
class TestShorthandEdits:
"""Requirements-compliant shorthand edit format."""
def test_shorthand_insert_after(self):
overlay, errors = validate_overlay_yaml(
{
"id": "lint",
"extends": "wf",
"priority": 10,
"edits": [
{
"insert_after": "implement",
"step": {"id": "lint", "type": "shell", "command": "npm run lint"},
}
],
}
)
assert not errors, errors
assert overlay is not None
assert overlay.edits == [
OverlayEdit("insert_after", "implement", {"id": "lint", "type": "shell", "command": "npm run lint"})
]
def test_shorthand_insert_before(self):
overlay, errors = validate_overlay_yaml(
{
"id": "ov",
"extends": "wf",
"priority": 10,
"edits": [
{
"insert_before": "a",
"step": {"id": "b", "type": "command", "command": "echo"},
}
],
}
)
assert not errors, errors
assert overlay is not None
assert overlay.edits == [
OverlayEdit("insert_before", "a", {"id": "b", "type": "command", "command": "echo"})
]
def test_shorthand_replace(self):
overlay, errors = validate_overlay_yaml(
{
"id": "ov",
"extends": "wf",
"priority": 10,
"edits": [
{
"replace": "a",
"step": {"id": "a", "type": "command", "command": "echo"},
}
],
}
)
assert not errors, errors
assert overlay is not None
assert overlay.edits == [
OverlayEdit("replace", "a", {"id": "a", "type": "command", "command": "echo"})
]
def test_shorthand_remove(self):
overlay, errors = validate_overlay_yaml(
{
"id": "ov",
"extends": "wf",
"priority": 10,
"edits": [{"remove": "a"}],
}
)
assert not errors, errors
assert overlay is not None
assert overlay.edits == [OverlayEdit("remove", "a")]
def test_explicit_operation_format_still_valid(self):
overlay, errors = validate_overlay_yaml(
{
"id": "ov",
"extends": "wf",
"priority": 10,
"edits": [
{
"operation": "insert_after",
"anchor": "a",
"step": {"id": "b", "type": "command", "command": "echo"},
}
],
}
)
assert not errors, errors
assert overlay is not None
assert overlay.edits == [
OverlayEdit("insert_after", "a", {"id": "b", "type": "command", "command": "echo"})
]
def test_multiple_operation_fields_rejected(self):
overlay, errors = validate_overlay_yaml(
{
"id": "ov",
"extends": "wf",
"priority": 10,
"edits": [
{
"insert_after": "a",
"remove": "a",
}
],
}
)
assert overlay is None
assert any("multiple" in e.lower() for e in errors), errors
def test_invalid_operation_field_rejected(self):
overlay, errors = validate_overlay_yaml(
{
"id": "ov",
"extends": "wf",
"priority": 10,
"edits": [{"destroy": "a"}],
}
)
assert overlay is None
assert any("operation" in e.lower() for e in errors), errors
def test_shorthand_and_explicit_mixed_list(self):
overlay, errors = validate_overlay_yaml(
{
"id": "ov",
"extends": "wf",
"priority": 10,
"edits": [
{"insert_after": "a", "step": {"id": "b", "type": "command", "command": "echo"}},
{
"operation": "remove",
"anchor": "c",
},
],
}
)
assert not errors, errors
assert overlay is not None
assert overlay.edits == [
OverlayEdit("insert_after", "a", {"id": "b", "type": "command", "command": "echo"}),
OverlayEdit("remove", "c"),
]
def test_shorthand_remove_must_not_include_step(self):
overlay, errors = validate_overlay_yaml(
{
"id": "ov",
"extends": "wf",
"priority": 10,
"edits": [
{
"remove": "a",
"step": {"id": "b", "type": "command", "command": "echo"},
}
],
}
)
assert overlay is None
assert any("remove" in e.lower() and "step" in e.lower() for e in errors), errors
class TestOverlayIdValidation:
"""Overlay and workflow IDs must be safe path segments."""
@pytest.mark.parametrize("overlay_id", ["../ov", "a/b", "a\\\\b", ".", "..", ""])
def test_invalid_overlay_id_rejected(self, overlay_id):
overlay, errors = validate_overlay_yaml(
{
"id": overlay_id,
"extends": "wf",
"priority": 10,
"edits": [{"remove": "a"}],
}
)
assert overlay is None
assert any("id" in e.lower() for e in errors), errors
@pytest.mark.parametrize("extends", ["../wf", "a/b", "a\\\\b", ".", "..", ""])
def test_invalid_extends_rejected(self, extends):
overlay, errors = validate_overlay_yaml(
{
"id": "ov",
"extends": extends,
"priority": 10,
"edits": [{"remove": "a"}],
}
)
assert overlay is None
assert any("extends" in e.lower() for e in errors), errors
def test_valid_dashed_id_accepted(self):
overlay, errors = validate_overlay_yaml(
{
"id": "my-overlay",
"extends": "my-workflow",
"priority": 10,
"edits": [{"remove": "a"}],
}
)
assert not errors, errors
assert overlay is not None