diff --git a/.gitignore b/.gitignore index 61db99f..1beba21 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ frontend/coverage/ frontend/test-results/ frontend/playwright-report/ frontend/.cortex-* +/.cortex-frontend-build-*/ # Local application data *.sqlite diff --git a/backend/cortex_backend/launcher/frontend.py b/backend/cortex_backend/launcher/frontend.py index d406df6..e85bb67 100644 --- a/backend/cortex_backend/launcher/frontend.py +++ b/backend/cortex_backend/launcher/frontend.py @@ -149,6 +149,30 @@ def _run(command: list[str], *, cwd: Path) -> None: ) from exc +def _stage_frontend_source(frontend_root: Path) -> Path: + """Copy build inputs beside the source tree so live installs stay untouched.""" + staging = frontend_root.parent / f".cortex-frontend-build-{uuid.uuid4().hex}" + try: + shutil.copytree( + frontend_root, + staging, + ignore=shutil.ignore_patterns( + "node_modules", + "dist", + ".cortex-*", + "coverage", + "test-results", + "playwright-report", + ), + ) + except OSError as exc: + shutil.rmtree(staging, ignore_errors=True) + raise FrontendBuildError( + "Could not stage frontend sources for an isolated build." + ) from exc + return staging + + def _install_if_needed(frontend_root: Path, expected_lock_digest: str) -> None: node_modules = frontend_root / "node_modules" marker = node_modules / INSTALL_MANIFEST_NAME @@ -179,15 +203,15 @@ def build_frontend( lock = lock_digest(frontend_root) node_major = _major_version("node") npm_major = _major_version("npm") - _install_if_needed(frontend_root, lock) - - staging = frontend_root / f".cortex-dist-staging-{uuid.uuid4().hex}" + build_root = _stage_frontend_source(frontend_root) + staging = build_root / f".cortex-dist-staging-{uuid.uuid4().hex}" dist = frontend_root / "dist" backup = frontend_root / f".cortex-dist-backup-{uuid.uuid4().hex}" try: + _install_if_needed(build_root, lock) _run( [_tool_name("npm"), "run", "build", "--", "--outDir", str(staging)], - cwd=frontend_root, + cwd=build_root, ) if not (staging / "index.html").is_file(): raise FrontendBuildError("Frontend build completed without index.html.") @@ -221,6 +245,8 @@ def build_frontend( finally: if staging.exists(): shutil.rmtree(staging, ignore_errors=True) + if build_root.exists(): + shutil.rmtree(build_root, ignore_errors=True) if backup.exists() and not dist.exists(): os.replace(backup, dist) diff --git a/tests/test_launcher.py b/tests/test_launcher.py index ac07845..603fa59 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -431,6 +431,57 @@ def fake_run(command: list[str], *, cwd: Path) -> None: assert not list(root.glob(".cortex-dist-*")) +def test_frontend_build_stages_sources_outside_live_node_modules( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + root = _frontend_fixture(tmp_path) + installed_roots: list[Path] = [] + monkeypatch.setattr(frontend_module, "_major_version", lambda _: 24) + + def fake_install(frontend_root: Path, _expected_lock_digest: str) -> None: + installed_roots.append(frontend_root) + + def fake_run(command: list[str], *, cwd: Path) -> None: + assert installed_roots == [cwd] + assert cwd != root + assert (cwd / "src" / "App.tsx").is_file() + output = Path(command[-1]) + output.mkdir(parents=True) + (output / "index.html").write_text("isolated", encoding="utf-8") + + monkeypatch.setattr(frontend_module, "_install_if_needed", fake_install) + monkeypatch.setattr(frontend_module, "_run", fake_run) + + dist = frontend_module.build_frontend(root) + + assert dist == root / "dist" + assert (dist / "index.html").read_text(encoding="utf-8") == "isolated" + assert not list(tmp_path.glob(".cortex-frontend-build-*")) + + +def test_frontend_install_failure_leaves_live_node_modules_untouched( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + root = _frontend_fixture(tmp_path) + live_install = root / "node_modules" + live_install.mkdir() + sentinel = live_install / "still-running.node" + sentinel.write_bytes(b"live") + monkeypatch.setattr(frontend_module, "_major_version", lambda _: 24) + + def fail_run(_command: list[str], *, cwd: Path) -> None: + assert cwd != root + raise FrontendBuildError("synthetic npm failure") + + monkeypatch.setattr(frontend_module, "_run", fail_run) + + with pytest.raises(FrontendBuildError, match="synthetic npm failure"): + frontend_module.build_frontend(root) + + assert sentinel.read_bytes() == b"live" + assert not list(tmp_path.glob(".cortex-frontend-build-*")) + + def test_frontend_build_failure_preserves_existing_bundle( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ):