738c3b5eda
CI / lint (pull_request) Successful in 15s
CI / benchmark-publish (pull_request) Has been skipped
CI / quality (pull_request) Successful in 32s
CI / security (pull_request) Successful in 33s
CI / typecheck (pull_request) Successful in 35s
CI / build (pull_request) Successful in 14s
CI / unit_tests (pull_request) Successful in 2m30s
CI / docker (pull_request) Successful in 39s
CI / integration_tests (pull_request) Successful in 3m0s
CI / coverage (pull_request) Successful in 4m47s
CI / lint (push) Successful in 13s
CI / build (push) Successful in 17s
CI / quality (push) Successful in 17s
CI / security (push) Successful in 32s
CI / typecheck (push) Successful in 34s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 2m11s
CI / docker (push) Successful in 40s
CI / integration_tests (push) Successful in 2m55s
CI / coverage (push) Successful in 4m47s
CI / benchmark-regression (pull_request) Successful in 25m3s
CI / benchmark-publish (push) Successful in 14m7s
Add domain models, application service, CLI integration, and full test coverage for multi-project subplan orchestration. New components: - MultiProjectMetadata, ProjectScope, ProjectDependency domain models - MultiProjectService for creating/managing multi-project plans - CLI display of multi-project metadata in plan commands - Behave BDD tests (20 scenarios), Robot Framework integration tests, ASV benchmarks, and reference documentation Also fixes pre-existing test flakiness in cli_core, core_cli_commands, cli_plan_context_commands, and helper_server_stubs caused by environment leakage and path-with-spaces issues. ISSUES CLOSED: #199
349 lines
12 KiB
Python
349 lines
12 KiB
Python
"""Step definitions for the multi-project subplan feature."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
|
|
from cleveragents.application.services.multi_project_service import (
|
|
MultiProjectService,
|
|
)
|
|
from cleveragents.domain.models.core.multi_project import (
|
|
ChangeSetSummary,
|
|
CrossProjectDependency,
|
|
ProjectScopeResolver,
|
|
)
|
|
from cleveragents.domain.models.core.plan import (
|
|
NamespacedName,
|
|
Plan,
|
|
PlanIdentity,
|
|
PlanPhase,
|
|
ProcessingState,
|
|
ProjectLink,
|
|
)
|
|
|
|
# --- helpers ---------------------------------------------------------------
|
|
|
|
_PLAN_ID = "01HXAAAAAAAAAAAAAAAAAAAAAA"
|
|
|
|
|
|
def _make_plan(project_links: list[ProjectLink]) -> Plan:
|
|
"""Build a minimal Plan with the given project links."""
|
|
return Plan(
|
|
identity=PlanIdentity(plan_id=_PLAN_ID),
|
|
namespaced_name=NamespacedName.parse("local/multi-test"),
|
|
description="Multi-project test plan",
|
|
action_name="local/test-action",
|
|
phase=PlanPhase.STRATEGIZE,
|
|
processing_state=ProcessingState.QUEUED,
|
|
project_links=project_links,
|
|
)
|
|
|
|
|
|
def _mock_decision_service() -> MagicMock:
|
|
"""Create a mock DecisionService."""
|
|
mock = MagicMock()
|
|
mock.list_by_type.return_value = []
|
|
return mock
|
|
|
|
|
|
# --- Background ------------------------------------------------------------
|
|
|
|
|
|
@given("a multi-project service")
|
|
def step_create_service(context: Context) -> None:
|
|
context.decision_service = _mock_decision_service()
|
|
context.service = MultiProjectService(
|
|
decision_service=context.decision_service,
|
|
)
|
|
context.resolver = ProjectScopeResolver()
|
|
context.available_resources: dict[str, list[str]] = {}
|
|
|
|
|
|
@given('a plan with projects "{proj1}" and "{proj2}"')
|
|
def step_plan_two_projects(context: Context, proj1: str, proj2: str) -> None:
|
|
context.project_links = [
|
|
ProjectLink(project_name=proj1),
|
|
ProjectLink(project_name=proj2),
|
|
]
|
|
context.plan = _make_plan(context.project_links)
|
|
|
|
|
|
@given('a plan with only project "{proj}"')
|
|
def step_plan_single_project(context: Context, proj: str) -> None:
|
|
context.project_links = [ProjectLink(project_name=proj)]
|
|
context.plan = _make_plan(context.project_links)
|
|
|
|
|
|
@given('a plan with project "{proj}" aliased as "{alias}"')
|
|
def step_plan_aliased(context: Context, proj: str, alias: str) -> None:
|
|
link = ProjectLink(project_name=proj, alias=alias)
|
|
# Keep shared-lib from background
|
|
other_links = [pl for pl in context.project_links if pl.project_name != proj]
|
|
context.project_links = [link, *other_links]
|
|
context.plan = _make_plan(context.project_links)
|
|
|
|
|
|
@given('a plan with "{rw}" writable and "{ro}" read-only')
|
|
def step_plan_mixed_access(context: Context, rw: str, ro: str) -> None:
|
|
context.project_links = [
|
|
ProjectLink(project_name=rw, read_only=False),
|
|
ProjectLink(project_name=ro, read_only=True),
|
|
]
|
|
context.plan = _make_plan(context.project_links)
|
|
|
|
|
|
# --- Resource setup --------------------------------------------------------
|
|
|
|
|
|
@given('available resources for "{proj}" are "{resource_csv}"')
|
|
def step_set_resources(context: Context, proj: str, resource_csv: str) -> None:
|
|
context.available_resources[proj] = [
|
|
r.strip() for r in resource_csv.split(",") if r.strip()
|
|
]
|
|
|
|
|
|
@given("no available resources")
|
|
def step_no_resources(context: Context) -> None:
|
|
context.available_resources = {}
|
|
|
|
|
|
# --- When clauses ----------------------------------------------------------
|
|
|
|
|
|
@when("I initialize multi-project scopes")
|
|
def step_initialize_scopes(context: Context) -> None:
|
|
context.plan = context.service.initialize_scopes(
|
|
context.plan, context.available_resources
|
|
)
|
|
|
|
|
|
@when("I initialize multi-project scopes with allow_mixed")
|
|
def step_initialize_scopes_mixed(context: Context) -> None:
|
|
context.plan = context.service.initialize_scopes(
|
|
context.plan, context.available_resources
|
|
)
|
|
# Override allow_mixed on the metadata
|
|
if context.plan.multi_project_metadata is not None:
|
|
updated_mp = context.plan.multi_project_metadata.model_copy(
|
|
update={"allow_mixed_access": True}
|
|
)
|
|
context.plan = context.plan.model_copy(
|
|
update={"multi_project_metadata": updated_mp}
|
|
)
|
|
|
|
|
|
@when("I validate access modes without allow_mixed")
|
|
def step_validate_access_no_mixed(context: Context) -> None:
|
|
context.access_errors = context.resolver.validate_access_modes(
|
|
context.project_links, allow_mixed=False
|
|
)
|
|
|
|
|
|
@when("I validate access modes with allow_mixed")
|
|
def step_validate_access_mixed(context: Context) -> None:
|
|
context.access_errors = context.resolver.validate_access_modes(
|
|
context.project_links, allow_mixed=True
|
|
)
|
|
|
|
|
|
@when('I add a cross-project dependency from "{src}" to "{tgt}" type "{dep_type}"')
|
|
def step_add_dependency(context: Context, src: str, tgt: str, dep_type: str) -> None:
|
|
dep = CrossProjectDependency(
|
|
source_project=src,
|
|
target_project=tgt,
|
|
dependency_type=dep_type,
|
|
)
|
|
mp = context.plan.multi_project_metadata
|
|
assert mp is not None
|
|
updated_deps = [*list(mp.cross_project_dependencies), dep]
|
|
updated_mp = mp.model_copy(update={"cross_project_dependencies": updated_deps})
|
|
context.plan = context.plan.model_copy(
|
|
update={"multi_project_metadata": updated_mp}
|
|
)
|
|
|
|
|
|
@when('I record a changeset for "{proj}" with {n:d} files changed and {lines:d} lines')
|
|
def step_record_changeset(context: Context, proj: str, n: int, lines: int) -> None:
|
|
summary = ChangeSetSummary(
|
|
project_name=proj,
|
|
files_changed=n,
|
|
total_lines_changed=lines,
|
|
)
|
|
context.plan = context.service.record_changeset(context.plan, proj, summary)
|
|
|
|
|
|
# --- Then clauses ----------------------------------------------------------
|
|
|
|
|
|
@then("the plan should have {count:d} project scopes")
|
|
def step_check_scope_count(context: Context, count: int) -> None:
|
|
mp = context.plan.multi_project_metadata
|
|
assert mp is not None, "multi_project_metadata is None"
|
|
assert len(mp.project_scopes) == count, (
|
|
f"Expected {count} scopes, got {len(mp.project_scopes)}"
|
|
)
|
|
|
|
|
|
@then('scope "{proj}" should have {count:d} resource IDs')
|
|
def step_check_scope_resources(context: Context, proj: str, count: int) -> None:
|
|
scope = context.plan.get_project_scope(proj)
|
|
assert scope is not None, f"Scope for {proj} not found"
|
|
assert len(scope.resource_ids) == count, (
|
|
f"Expected {count} resources for {proj}, got {len(scope.resource_ids)}"
|
|
)
|
|
|
|
|
|
@then("the plan should be multi-project")
|
|
def step_is_multi_project(context: Context) -> None:
|
|
assert context.plan.is_multi_project is True
|
|
|
|
|
|
@then("the plan should not be multi-project")
|
|
def step_is_not_multi_project(context: Context) -> None:
|
|
assert context.plan.is_multi_project is False
|
|
|
|
|
|
@then('resolving alias "{alias}" should return project "{proj}"')
|
|
def step_resolve_alias(context: Context, alias: str, proj: str) -> None:
|
|
link = context.resolver.resolve_alias(context.plan.project_links, alias)
|
|
assert link is not None, f"Alias '{alias}' not resolved"
|
|
assert link.project_name == proj
|
|
|
|
|
|
@then('resolving alias "{alias}" should return nothing')
|
|
def step_resolve_alias_none(context: Context, alias: str) -> None:
|
|
link = context.resolver.resolve_alias(context.plan.project_links, alias)
|
|
assert link is None, f"Expected None, got {link}"
|
|
|
|
|
|
@then('resolving context view for "{proj}" should succeed')
|
|
def step_context_view_success(context: Context, proj: str) -> None:
|
|
context.resolved_scope = context.service.resolve_context_view(context.plan, proj)
|
|
assert context.resolved_scope is not None
|
|
|
|
|
|
@then('the context view should have project name "{proj}"')
|
|
def step_context_view_name(context: Context, proj: str) -> None:
|
|
assert context.resolved_scope.project_name == proj
|
|
|
|
|
|
@then('resolving context view for "{proj}" should raise KeyError')
|
|
def step_context_view_key_error(context: Context, proj: str) -> None:
|
|
try:
|
|
context.service.resolve_context_view(context.plan, proj)
|
|
raise AssertionError("Expected KeyError")
|
|
except KeyError:
|
|
pass
|
|
|
|
|
|
@then("resolving context view without metadata should raise ValueError")
|
|
def step_context_view_no_metadata(context: Context) -> None:
|
|
# Use a plan without multi_project_metadata
|
|
bare_plan = _make_plan(context.project_links)
|
|
try:
|
|
context.service.resolve_context_view(bare_plan, "api-service")
|
|
raise AssertionError("Expected ValueError")
|
|
except ValueError:
|
|
pass
|
|
|
|
|
|
@then("there should be {count:d} validation error about mixed access")
|
|
def step_check_mixed_error_count(context: Context, count: int) -> None:
|
|
assert len(context.access_errors) == count, (
|
|
f"Expected {count} errors, got {len(context.access_errors)}"
|
|
)
|
|
|
|
|
|
@then("there should be {count:d} validation errors")
|
|
def step_check_error_count(context: Context, count: int) -> None:
|
|
assert len(context.access_errors) == count, (
|
|
f"Expected {count} errors, got {len(context.access_errors)}"
|
|
)
|
|
|
|
|
|
@then("the metadata should have {count:d} cross-project dependency")
|
|
def step_check_dep_count(context: Context, count: int) -> None:
|
|
mp = context.plan.multi_project_metadata
|
|
assert mp is not None
|
|
assert len(mp.cross_project_dependencies) == count
|
|
|
|
|
|
@then('the dependency should be from "{src}" to "{tgt}"')
|
|
def step_check_dep_endpoints(context: Context, src: str, tgt: str) -> None:
|
|
mp = context.plan.multi_project_metadata
|
|
assert mp is not None
|
|
dep = mp.cross_project_dependencies[0]
|
|
assert dep.source_project == src
|
|
assert dep.target_project == tgt
|
|
|
|
|
|
@then("cross-project validation should report an error about unknown project")
|
|
def step_cross_project_unknown(context: Context) -> None:
|
|
errors = context.service.validate_cross_project(context.plan)
|
|
assert any("unknown" in e.lower() for e in errors), (
|
|
f"Expected unknown project error, got: {errors}"
|
|
)
|
|
|
|
|
|
@then('scope "{proj}" should have a changeset summary')
|
|
def step_scope_has_changeset(context: Context, proj: str) -> None:
|
|
scope = context.plan.get_project_scope(proj)
|
|
assert scope is not None
|
|
assert scope.changeset_summary is not None
|
|
|
|
|
|
@then("the changeset should show {count:d} files changed")
|
|
def step_changeset_files(context: Context, count: int) -> None:
|
|
# Find the scope with a changeset
|
|
mp = context.plan.multi_project_metadata
|
|
assert mp is not None
|
|
for scope in mp.project_scopes:
|
|
if scope.changeset_summary is not None:
|
|
assert scope.changeset_summary.files_changed == count
|
|
return
|
|
raise AssertionError("No changeset summary found")
|
|
|
|
|
|
@then('recording a changeset for "{proj}" should raise KeyError')
|
|
def step_record_changeset_error(context: Context, proj: str) -> None:
|
|
summary = ChangeSetSummary(
|
|
project_name=proj,
|
|
files_changed=1,
|
|
total_lines_changed=1,
|
|
)
|
|
try:
|
|
context.service.record_changeset(context.plan, proj, summary)
|
|
raise AssertionError("Expected KeyError")
|
|
except KeyError:
|
|
pass
|
|
|
|
|
|
@then("cross-project validation should report sandbox isolation violation")
|
|
def step_sandbox_violation(context: Context) -> None:
|
|
errors = context.service.validate_cross_project(context.plan)
|
|
assert any("sandbox isolation" in e.lower() for e in errors), (
|
|
f"Expected sandbox isolation error, got: {errors}"
|
|
)
|
|
|
|
|
|
@then("cross-project validation should pass")
|
|
def step_cross_project_valid(context: Context) -> None:
|
|
errors = context.service.validate_cross_project(context.plan)
|
|
assert len(errors) == 0, f"Expected no errors, got: {errors}"
|
|
|
|
|
|
@then('the plan cli dict should contain a "{key}" key')
|
|
def step_cli_dict_key(context: Context, key: str) -> None:
|
|
cli_dict = context.plan.as_cli_dict()
|
|
assert key in cli_dict, f"Key '{key}' not in cli_dict: {list(cli_dict.keys())}"
|
|
|
|
|
|
@then("the multi_project section should have {count:d} project scopes")
|
|
def step_cli_dict_scope_count(context: Context, count: int) -> None:
|
|
cli_dict = context.plan.as_cli_dict()
|
|
mp = cli_dict["multi_project"]
|
|
assert len(mp["project_scopes"]) == count
|