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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ frontend/coverage/
frontend/test-results/
frontend/playwright-report/
frontend/.cortex-*
/.cortex-frontend-build-*/

# Local application data
*.sqlite
Expand Down
34 changes: 30 additions & 4 deletions backend/cortex_backend/launcher/frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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)

Expand Down
51 changes: 51 additions & 0 deletions tests/test_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
):
Expand Down
Loading