124 lines
6.0 KiB
Python
124 lines
6.0 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[3]
|
|
|
|
|
|
def module(name: str, path: Path):
|
|
spec = importlib.util.spec_from_file_location(name, path)
|
|
value = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(value)
|
|
return value
|
|
|
|
|
|
SCAFFOLD = module("casan_project_scaffold", ROOT / "packages/casan-devkit/project-scaffold.py")
|
|
MANIFEST = module("casan_project_manifest_test", ROOT / "packages/casan-harness/scripts/bash/project_manifest.py")
|
|
|
|
|
|
def options(target: str, **overrides):
|
|
values = {
|
|
"target": target,
|
|
"project": "inventory-app",
|
|
"name": "Inventory App",
|
|
"feature_id": "001-inventory-app",
|
|
"module_id": "MOD-01",
|
|
"template": "nestjs-react",
|
|
"with_harness": False,
|
|
}
|
|
values.update(overrides)
|
|
return argparse.Namespace(**values)
|
|
|
|
|
|
class ProjectScaffoldTests(unittest.TestCase):
|
|
def test_scaffold_is_complete_valid_and_idempotent(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
first = SCAFFOLD.scaffold(options(directory))
|
|
second = SCAFFOLD.scaffold(options(directory))
|
|
self.assertGreater(first["created"], 20)
|
|
self.assertEqual(second["created"], 0)
|
|
self.assertEqual(second["unchanged"], first["created"])
|
|
project = MANIFEST.load(directory, "apps/inventory-app/domain/project.manifest.json")
|
|
self.assertEqual(project["project_id"], "inventory-app")
|
|
self.assertEqual(len(project["verification"]), 2)
|
|
required = [
|
|
"package.json",
|
|
".github/workflows/ci.yml",
|
|
"docker-compose.yml",
|
|
"apps/inventory-app/backend/src/main.ts",
|
|
"apps/inventory-app/frontend/src/App.tsx",
|
|
"apps/inventory-app/domain/golden-runs/plan.golden.txt",
|
|
]
|
|
self.assertTrue(all((Path(directory) / item).is_file() for item in required))
|
|
tokens = [path for path in Path(directory).rglob("*") if path.is_file() and "__PROJECT_" in path.read_text(encoding="utf-8", errors="ignore")]
|
|
self.assertEqual(tokens, [])
|
|
|
|
def test_existing_different_file_is_never_overwritten(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = Path(directory) / "package.json"
|
|
path.write_text('{"owned_by":"user"}\n', encoding="utf-8")
|
|
with self.assertRaisesRegex(SCAFFOLD.ScaffoldError, "no files were overwritten"):
|
|
SCAFFOLD.scaffold(options(directory))
|
|
self.assertEqual(path.read_text(encoding="utf-8"), '{"owned_by":"user"}\n')
|
|
|
|
def test_harness_install_registers_only_real_target_projects_and_is_idempotent(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
args = options(directory, with_harness=True)
|
|
first = SCAFFOLD.scaffold(args)
|
|
second = SCAFFOLD.scaffold(args)
|
|
registry = json.loads((Path(directory) / "packages/casan-harness/config/project-registry.json").read_text(encoding="utf-8"))
|
|
self.assertEqual([item["project_id"] for item in registry["projects"]], ["inventory-app"])
|
|
self.assertGreater(first["created"], 100)
|
|
self.assertEqual(second["created"], 0)
|
|
self.assertFalse((Path(directory) / "packages/casan-harness/tests").exists())
|
|
self.assertFalse((Path(directory) / "packages/casan-harness/level5").exists())
|
|
self.assertFalse((Path(directory) / "packages/casan-harness/scripts/bash/ci-harness-gate.sh").exists())
|
|
|
|
def test_invalid_identifiers_and_broad_target_fail_closed(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
with self.assertRaisesRegex(SCAFFOLD.ScaffoldError, "lowercase slug"):
|
|
SCAFFOLD.scaffold(options(directory, project="../escape"))
|
|
with self.assertRaisesRegex(SCAFFOLD.ScaffoldError, "safe punctuation"):
|
|
SCAFFOLD.scaffold(options(directory, name='Broken "Template"'))
|
|
with self.assertRaisesRegex(SCAFFOLD.ScaffoldError, "broad target"):
|
|
SCAFFOLD.scaffold(options("/"))
|
|
|
|
def test_manifest_rejects_path_escape_and_shell_executable(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
SCAFFOLD.scaffold(options(directory))
|
|
path = Path(directory) / "apps/inventory-app/domain/project.manifest.json"
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
data["requirements"] = "../outside.md"
|
|
path.write_text(json.dumps(data), encoding="utf-8")
|
|
with self.assertRaisesRegex(MANIFEST.ManifestError, "escapes"):
|
|
MANIFEST.load(directory, "apps/inventory-app/domain/project.manifest.json")
|
|
|
|
data["requirements"] = "apps/inventory-app/domain/input/requirement.md"
|
|
data["commands"]["test"] = [["sh", "-c", "exit 0"]]
|
|
path.write_text(json.dumps(data), encoding="utf-8")
|
|
with self.assertRaisesRegex(MANIFEST.ManifestError, "not allowed"):
|
|
MANIFEST.load(directory, "apps/inventory-app/domain/project.manifest.json")
|
|
|
|
@unittest.skipUnless((ROOT / "node_modules/.bin/tsc").is_file(), "workspace dependencies are not installed")
|
|
def test_generated_shell_builds_and_tests_with_approved_workspace_dependencies(self):
|
|
(ROOT / "tmp").mkdir(exist_ok=True)
|
|
with tempfile.TemporaryDirectory(dir=ROOT / "tmp") as directory:
|
|
SCAFFOLD.scaffold(options(directory))
|
|
env = {**os.environ, "PATH": f"{ROOT / 'node_modules/.bin'}{os.pathsep}{os.environ.get('PATH', '')}"}
|
|
for command in (["npm", "run", "build"], ["npm", "test"]):
|
|
result = subprocess.run(command, cwd=directory, env=env, capture_output=True, text=True, timeout=120)
|
|
self.assertEqual(result.returncode, 0, f"{' '.join(command)} failed:\n{result.stdout}\n{result.stderr}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|