diff --git a/src/entrypoints/web_cli.py b/src/entrypoints/web_cli.py index 7835da450..77a641668 100644 --- a/src/entrypoints/web_cli.py +++ b/src/entrypoints/web_cli.py @@ -103,7 +103,7 @@ def _build_parser() -> argparse.ArgumentParser: parser.add_argument("--no-open", action="store_true", dest="no_open", help="Print the URL without launching a browser.") parser.add_argument("--build", action="store_true", - help="Build ui-web before serving (source checkouts).") + help="Install locked dependencies and build ui-web before serving.") parser.add_argument("--allow-remote", action="store_true", dest="allow_remote", help="Permit a non-loopback --host. The page hands out the " "session token, so only do this behind your own auth.") @@ -111,16 +111,18 @@ def _build_parser() -> argparse.ArgumentParser: def build_web_app(app_dir: Path) -> int: - """Run ``npm install`` (when needed) then ``npm run build`` in ``ui-web``.""" + """Sync locked dependencies with ``npm ci``, then build ``ui-web``.""" if not (app_dir / "package.json").is_file(): print(f"web: no web app at {app_dir}", file=sys.stderr) return 1 npm = "npm.cmd" if os.name == "nt" else "npm" - plan: list[list[str]] = [] - if not (app_dir / "node_modules").is_dir(): - plan.append([npm, "ci", "--no-audit", "--no-fund"]) - plan.append([npm, "run", "build"]) + # Installer upgrades update the lockfile but can leave old node_modules. + # Always sync before an explicit build, including incomplete installs. + plan = [ + [npm, "ci", "--no-audit", "--no-fund"], + [npm, "run", "build"], + ] for command in plan: print(f"web: {' '.join(command)}", file=sys.stderr) @@ -140,7 +142,7 @@ def _missing_bundle_message(app_dir: Path) -> str: return ( "web: the browser client is not built yet.\n" f" Run `clawcodex web --build`, or build it directly:\n" - f" cd {app_dir} && npm install && npm run build" + f" cd {app_dir} && npm ci && npm run build" ) return ( "web: no web client bundle found. This install does not ship one; " diff --git a/tests/entrypoints/test_web_cli.py b/tests/entrypoints/test_web_cli.py index 37a2dd664..5fd9fe1f0 100644 --- a/tests/entrypoints/test_web_cli.py +++ b/tests/entrypoints/test_web_cli.py @@ -10,6 +10,7 @@ from __future__ import annotations import argparse +import subprocess from pathlib import Path import pytest @@ -17,6 +18,53 @@ from src.entrypoints import web_cli +# ── web build ──────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("has_node_modules", [False, True], ids=["fresh", "upgrade"]) +def test_web_build_syncs_locked_dependencies( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, has_node_modules: bool, +) -> None: + (tmp_path / "package.json").write_text("{}", encoding="utf-8") + if has_node_modules: + # An installer upgrade can leave dependencies from the previous version. + (tmp_path / "node_modules").mkdir() + calls: list[list[str]] = [] + + def fake_run(command: list[str], *, cwd: str, check: bool): + assert cwd == str(tmp_path) + assert check is False + calls.append(command) + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr(web_cli.subprocess, "run", fake_run) + + assert web_cli.build_web_app(tmp_path) == 0 + npm = "npm.cmd" if web_cli.os.name == "nt" else "npm" + assert calls == [ + [npm, "ci", "--no-audit", "--no-fund"], + [npm, "run", "build"], + ] + + +@pytest.mark.parametrize("failure_step", ["ci", "run"]) +def test_web_build_stops_and_propagates_command_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure_step: str, +) -> None: + (tmp_path / "package.json").write_text("{}", encoding="utf-8") + (tmp_path / "node_modules").mkdir() + calls: list[str] = [] + + def fake_run(command: list[str], *, cwd: str, check: bool): + calls.append(command[1]) + return subprocess.CompletedProcess(command, 23 if command[1] == failure_step else 0) + + monkeypatch.setattr(web_cli.subprocess, "run", fake_run) + + assert web_cli.build_web_app(tmp_path) == 23 + assert calls == (["ci"] if failure_step == "ci" else ["ci", "run"]) + + # ── loopback classification ──────────────────────────────────────────────────