Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ numeric next development line is tracked in `DEVELOPMENT_VERSION`.

## [Unreleased]

### Fixed

- Make `basectl workspace test` preflight manifest-command trust across the
entire selected set before running any project test, and report explicit
blocked, not-run, start, completion, and dry-run states.
- Stability compatibility: publish workspace-test JSON schema version 2 so
automation can distinguish trust-blocked and not-run projects from tests
that actually executed.

## [1.10.5] - 2026-10-14

### Added
Expand Down
2 changes: 2 additions & 0 deletions cli/bash/commands/basectl/tests/manifest-command-trust.bats
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ if [[ "${1:-}" == "-m" && "${2:-}" == "base_trust" && "${3:-}" == "require" && "
exit 0
fi
cat >&2 <<TRUST
EXECUTION BLOCKED: No project command was executed.
ERROR: Manifest-declared commands are not allowed for project 'demo' on this machine.
Project root: ${BASE_TEST_PROJECT_ROOT:?}
Manifest: ${BASE_TEST_PROJECT_ROOT:?}/base_manifest.yaml
Expand Down Expand Up @@ -107,6 +108,7 @@ EOF

assert_untrusted_block() {
[ "$status" -eq 1 ]
[[ "$output" == *"EXECUTION BLOCKED: No project command was executed."* ]]
[[ "$output" == *"Manifest-declared commands are not allowed for project 'demo'"* ]]
[[ "$output" == *"Review first:"* ]]
[[ "$output" == *"basectl run demo --list"* ]]
Expand Down
2 changes: 1 addition & 1 deletion cli/python/base_projects/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ def main(argv: list[str] | None = None) -> int:
"--dry-run",
is_flag=True,
dry_run=True,
help="Show planned clone, pull, or configure work without writing.",
help="Show a workspace test plan or planned clone, pull, or configure work without execution or writing.",
)
@base_cli.option("--apply", is_flag=True, help="Apply workspace configure changes after the plan is shown.")
@base_cli.option("--yes", is_flag=True, help="Approve workspace setup or configure changes that require confirmation.")
Expand Down
18 changes: 15 additions & 3 deletions cli/python/base_projects/test_preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,26 @@
from base_setup.manifest_model import BaseManifest
from base_setup.test_requirements import check_test_requirements
from base_setup.manifest_trust import (
ManifestCommandTrustStore, compute_trust_identity, manifest_command_surfaces_from_manifest,
ManifestCommandTrustStore,
TrustStatus,
compute_trust_identity,
manifest_command_surfaces_from_manifest,
)
from base_setup.manifest_trust_guidance import print_blocked_command_text


def project_test_preflight(ctx: base_cli.Context, manifest: BaseManifest) -> bool:
def project_test_trust_status(
manifest: BaseManifest,
*,
trust_store: ManifestCommandTrustStore | None = None,
) -> TrustStatus:
"""Return the trust status used by project and workspace test preflight."""
identity = compute_trust_identity(manifest)
trust_status = ManifestCommandTrustStore().status(identity)
return (trust_store or ManifestCommandTrustStore()).status(identity)


def project_test_preflight(ctx: base_cli.Context, manifest: BaseManifest) -> bool:
trust_status = project_test_trust_status(manifest)
if not trust_status.is_allowed:
print_blocked_command_text(
trust_status, manifest_command_surfaces_from_manifest(manifest), stream=sys.stderr,
Expand Down
204 changes: 198 additions & 6 deletions cli/python/base_projects/tests/test_workspace_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
from base_projects.workspace_manifest import WorkspaceManifestRepo
from base_projects.workspace_repo_inspection import WorkspaceRepoInspection
from base_projects.workspace_test import workspace_test_manifest_target
from base_setup.manifest import read_manifest
from base_setup.manifest_trust import ManifestCommandTrustStore
from base_setup.manifest_trust import compute_trust_identity


def write_workspace_manifest(path: Path, repositories: tuple[str, ...]) -> None:
Expand Down Expand Up @@ -57,6 +60,15 @@ def write_fake_basectl(base_home: Path) -> Path:
return basectl


def allow_workspace_test_manifests(workspace: Path, trusted_projects: tuple[str, ...] | None) -> None:
trust_store = ManifestCommandTrustStore()
for manifest_path in workspace.rglob("base_manifest.yaml"):
manifest = read_manifest(manifest_path)
if trusted_projects is not None and manifest.project_name not in trusted_projects:
continue
trust_store.allow(compute_trust_identity(manifest), base_version="test")


def write_binary_output_basectl(base_home: Path) -> Path:
basectl = base_home / "bin" / "basectl"
basectl.parent.mkdir(parents=True, exist_ok=True)
Expand All @@ -75,6 +87,8 @@ def invoke_workspace_test(
args: list[str],
base_home: Path,
home: Path,
*,
trusted_projects: tuple[str, ...] | None = None,
) -> tuple[int, str, str]:
stdout = StringIO()
stderr = StringIO()
Expand All @@ -85,6 +99,8 @@ def invoke_workspace_test(
"BASE_PROJECT_MANIFEST": "",
}
with mock.patch.dict(os.environ, env):
workspace = Path(args[args.index("--workspace") + 1])
allow_workspace_test_manifests(workspace, trusted_projects)
with redirect_stdout(stdout), redirect_stderr(stderr):
status = engine.main(args)
return status, stdout.getvalue(), stderr.getvalue()
Expand Down Expand Up @@ -137,7 +153,7 @@ def test_workspace_test_runs_all_projects_and_reports_json_results(self) -> None
payload = json.loads(stdout)
self.assertEqual(status, 0)
self.assertEqual(stderr, "")
self.assertEqual(payload["counts"], {"passed": 2, "failed": 0, "skipped": 0})
self.assertEqual(payload["counts"], {"passed": 2, "failed": 0, "blocked": 0, "not_run": 0, "skipped": 0})
self.assertEqual(payload["selected_projects"], ["alpha", "beta"])
self.assertEqual([project["status"] for project in payload["projects"]], ["passed", "passed"])
self.assertEqual(run.call_count, 2)
Expand Down Expand Up @@ -235,7 +251,7 @@ def test_workspace_test_continues_after_a_project_failure(self) -> None:

payload = json.loads(stdout)
self.assertEqual(status, 1)
self.assertEqual(payload["counts"], {"passed": 2, "failed": 1, "skipped": 0})
self.assertEqual(payload["counts"], {"passed": 2, "failed": 1, "blocked": 0, "not_run": 0, "skipped": 0})
self.assertEqual(payload["projects"][1]["exit_code"], 7)
self.assertEqual(payload["projects"][1]["stderr"], "beta failed\n")
self.assertEqual(stderr, "")
Expand Down Expand Up @@ -276,9 +292,9 @@ def test_workspace_test_fail_fast_skips_remaining_projects(self) -> None:
payload = json.loads(stdout)
self.assertEqual(status, 1)
self.assertEqual(run.call_count, 1)
self.assertEqual(payload["counts"], {"passed": 0, "failed": 1, "skipped": 2})
self.assertEqual(payload["projects"][1]["status"], "skipped")
self.assertIn("--fail-fast", payload["projects"][1]["detail"])
self.assertEqual(payload["counts"], {"passed": 0, "failed": 1, "blocked": 0, "not_run": 2, "skipped": 0})
self.assertEqual(payload["projects"][1]["status"], "not_run")
self.assertIn("execution was stopped", payload["projects"][1]["detail"])
self.assertEqual(stderr, "")

def test_workspace_test_projects_filter_runs_only_selected_project(self) -> None:
Expand Down Expand Up @@ -350,6 +366,182 @@ def test_workspace_test_skips_project_without_test_command(self) -> None:
self.assertEqual(status, 0)
self.assertEqual(stderr, "")
self.assertEqual(run.call_count, 1)
self.assertEqual(payload["counts"], {"passed": 1, "failed": 0, "skipped": 1})
self.assertEqual(payload["counts"], {"passed": 1, "failed": 0, "blocked": 0, "not_run": 0, "skipped": 1})
self.assertEqual(payload["projects"][1]["status"], "skipped")
self.assertIn("does not declare a test command", payload["projects"][1]["detail"])

def test_workspace_test_preflights_all_projects_before_running_any_test(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
home = root / "home"
base_home = root / "base"
workspace = root / "workspace"
manifest = root / "workspace.yaml"
home.mkdir()
base_home.mkdir()
write_fake_basectl(base_home)
write_workspace_manifest(manifest, ("trusted", "blocked"))
write_test_manifest(workspace / "trusted", "trusted")
write_test_manifest(workspace / "blocked", "blocked")

with mock.patch(
"base_projects.workspace_test.run_workspace_subprocess",
return_value=subprocess.CompletedProcess([], 0, "should not run\n", ""),
) as run:
status, stdout, stderr = invoke_workspace_test(
["test", "--workspace", str(workspace), "--manifest", str(manifest), "--format", "json"],
base_home,
home,
trusted_projects=("trusted",),
)
payload = json.loads(stdout)
self.assertEqual(status, 1)
self.assertEqual(stderr, "")
self.assertEqual(run.call_count, 0)
self.assertEqual(payload["preflight"], "blocked")
self.assertFalse(payload["execution_started"])
self.assertEqual(
payload["counts"],
{"passed": 0, "failed": 0, "blocked": 1, "not_run": 1, "skipped": 0},
)
self.assertEqual(
[project["status"] for project in payload["projects"]], ["not_run", "blocked"]
)

manifest.write_text(
"\n".join(
[
"schema_version: 1",
"workspace:",
" name: test-workspace",
"repos:",
" - name: blocked",
" - name: trusted",
"",
]
),
encoding="utf-8",
)
run.reset_mock()
status, stdout, stderr = invoke_workspace_test(
["test", "--workspace", str(workspace), "--manifest", str(manifest), "--format", "json"],
base_home,
home,
trusted_projects=("trusted",),
)
payload = json.loads(stdout)
self.assertEqual(status, 1)
self.assertEqual(stderr, "")
self.assertEqual(run.call_count, 0)
self.assertEqual(
[project["status"] for project in payload["projects"]], ["blocked", "not_run"]
)

def test_workspace_test_dry_run_reports_plan_without_running_tests(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
home = root / "home"
base_home = root / "base"
workspace = root / "workspace"
manifest = root / "workspace.yaml"
home.mkdir()
base_home.mkdir()
write_fake_basectl(base_home)
write_workspace_manifest(manifest, ("alpha",))
write_test_manifest(workspace / "alpha", "alpha")

with mock.patch("base_projects.workspace_test.run_workspace_subprocess") as run:
status, stdout, stderr = invoke_workspace_test(
[
"test",
"--workspace",
str(workspace),
"--manifest",
str(manifest),
"--dry-run",
"--format",
"json",
],
base_home,
home,
)

payload = json.loads(stdout)
self.assertEqual(status, 0)
self.assertEqual(stderr, "")
self.assertFalse(run.called)
self.assertTrue(payload["dry_run"])
self.assertFalse(payload["execution_started"])
self.assertEqual(payload["projects"][0]["status"], "not_run")

def test_workspace_test_dry_run_fails_for_required_missing_project(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
home = root / "home"
base_home = root / "base"
workspace = root / "workspace"
manifest = root / "workspace.yaml"
home.mkdir()
base_home.mkdir()
workspace.mkdir()
write_workspace_manifest(manifest, ("missing",))

status, stdout, stderr = invoke_workspace_test(
[
"test",
"--workspace",
str(workspace),
"--manifest",
str(manifest),
"--dry-run",
"--format",
"json",
],
base_home,
home,
)

payload = json.loads(stdout)
self.assertEqual(status, 1)
self.assertEqual(stderr, "")
self.assertEqual(payload["counts"], {"passed": 0, "failed": 1, "blocked": 0, "not_run": 0, "skipped": 0})
self.assertEqual(payload["projects"][0]["status"], "failed")
self.assertIn("repository is missing", payload["projects"][0]["detail"])
self.assertFalse(payload["execution_started"])

def test_workspace_test_text_reports_start_and_completion_progress(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
home = root / "home"
base_home = root / "base"
workspace = root / "workspace"
manifest = root / "workspace.yaml"
home.mkdir()
base_home.mkdir()
write_fake_basectl(base_home)
write_workspace_manifest(manifest, ("alpha", "beta"))
write_test_manifest(workspace / "alpha", "alpha")
write_test_manifest(workspace / "beta", "beta")

with mock.patch(
"base_projects.workspace_test.run_workspace_subprocess",
side_effect=[
subprocess.CompletedProcess([], 0, "alpha tests\n", ""),
subprocess.CompletedProcess([], 0, "beta tests\n", ""),
],
):
status, stdout, stderr = invoke_workspace_test(
["test", "--workspace", str(workspace), "--manifest", str(manifest)],
base_home,
home,
)

self.assertEqual(status, 0)
self.assertEqual(stderr, "")
self.assertIn("Workspace test preflight: 2 selected projects.", stdout)
self.assertIn("[1/2] START project 'alpha' (trust=allowed)", stdout)
self.assertIn("[2/2] START project 'beta' (trust=allowed)", stdout)
self.assertIn("PASS project 'alpha'", stdout)
self.assertIn("PASS project 'beta'", stdout)
self.assertIn("Workspace test completed:", stdout)
self.assertIn("execution_started=yes", stdout)
Loading
Loading