diff --git a/resources/tester/test_570_1_preview_up.py b/resources/tester/test_570_1_preview_up.py new file mode 100644 index 000000000..427408bcb --- /dev/null +++ b/resources/tester/test_570_1_preview_up.py @@ -0,0 +1,72 @@ +"""570-1 빌드판 자리 — 기본 = 개발 포트 − 1000 · --replace-dev = 개발 포트 · 빌드 실패면 안 끔.""" + +import importlib.util +import subprocess +from pathlib import Path + +import pytest + +SRC = Path(__file__).resolve().parents[2] / "tools" / "preview_up.py" + + +@pytest.fixture +def pu(monkeypatch, tmp_path): + spec = importlib.util.spec_from_file_location("preview_up_570", SRC) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + calls: list = [] + monkeypatch.setattr(mod, "TMP", str(tmp_path)) + monkeypatch.setattr(mod, "PID_FILE", str(tmp_path / "preview.pid")) + monkeypatch.setattr(mod, "LOG", str(tmp_path / "preview.log")) + monkeypatch.setattr(mod, "owners_ports", lambda: (8001, 5174)) + monkeypatch.setattr(mod, "stop", lambda: calls.append("stop")) + monkeypatch.setattr(mod, "free_port", lambda port: calls.append(("free", port))) + monkeypatch.setattr(mod, "run", lambda cmd, env: None) + monkeypatch.setattr(mod.time, "sleep", lambda s: None) + monkeypatch.setattr(mod.urllib.request, "urlopen", lambda url, timeout: calls.append(url)) + + class Proc: + pid = 1 + + monkeypatch.setattr(mod, "spawn", lambda cmd, env: calls.append(("spawn", cmd)) or Proc()) + monkeypatch.delenv("AISLO_VITE_PORT", raising=False) + monkeypatch.delenv("AISLO_API_PORT", raising=False) + mod.calls = calls + return mod + + +def spawned_port(calls) -> str: + cmd = next(c[1] for c in calls if isinstance(c, tuple) and c[0] == "spawn") + return cmd[cmd.index("--port") + 1] + + +def test_default_dev_minus_1000(pu, monkeypatch): + monkeypatch.setattr(pu.sys, "argv", ["preview_up.py"]) + assert pu.main() == 0 + assert spawned_port(pu.calls) == "4174" + assert not any(isinstance(c, tuple) and c[0] == "free" for c in pu.calls) + + +def test_port_arg(pu, monkeypatch): + monkeypatch.setattr(pu.sys, "argv", ["preview_up.py", "--port", "4999"]) + assert pu.main() == 0 + assert spawned_port(pu.calls) == "4999" + + +def test_replace_dev_takes_dev_port_after_build(pu, monkeypatch): + monkeypatch.setattr(pu.sys, "argv", ["preview_up.py", "--replace-dev"]) + assert pu.main() == 0 + assert spawned_port(pu.calls) == "5174" + assert pu.calls.index(("free", 5174)) < next( + i for i, c in enumerate(pu.calls) if isinstance(c, tuple) and c[0] == "spawn" + ) + + +def test_replace_dev_build_fail_kills_nothing(pu, monkeypatch): + def boom(cmd, env): + raise subprocess.CalledProcessError(1, cmd) + + monkeypatch.setattr(pu, "run", boom) + monkeypatch.setattr(pu.sys, "argv", ["preview_up.py", "--replace-dev"]) + assert pu.main() == 1 + assert pu.calls == [] diff --git a/tools/preview_up.py b/tools/preview_up.py index d745fef31..cf253c66d 100644 --- a/tools/preview_up.py +++ b/tools/preview_up.py @@ -3,6 +3,10 @@ 실행: ./venv/Scripts/python.exe tools/preview_up.py (빌드 + 띄움 · 수십 초) ./venv/Scripts/python.exe tools/preview_up.py --stop (끄기만) + --port N : 띄울 포트(기본 = 개발 포트 − 1000) + --replace-dev : 개발판 자리(개발 포트)에 띄움 — 빌드가 된 뒤에만 그 포트의 vite dev 를 끔 + · 빌드 실패면 아무것도 안 끄고 1 로 끝남(개발판 그대로) + · `.claude/dev_up.py --build` 가 부름 - 개발 서버(vite)는 파일 수백 개를 하나씩 보내 느린 연결(Tailscale · 폰)에서 첫 화면이 늦다 — 빌드판은 묶은 파일 몇 개만 보낸다. @@ -54,6 +58,36 @@ def stop() -> None: os.remove(PID_FILE) +def free_port(port: int) -> None: + """그 포트를 물고 있는 프로세스(개발판 vite)를 끈다 — 짝 = `_free_dev_port`(config).""" + out = subprocess.run( + ["netstat", "-ano", "-p", "TCP"], capture_output=True, text=True, errors="replace" + ).stdout + for line in out.splitlines(): + cols = line.split() + if ( + len(cols) >= 5 + and cols[1].endswith(f":{port}") + and cols[3] == "LISTENING" + and cols[4].isdigit() + ): + subprocess.run( + ["taskkill", "/T", "/F", "/PID", cols[4]], capture_output=True, check=False + ) + + +def arg(name: str) -> str | None: + return sys.argv[sys.argv.index(name) + 1] if name in sys.argv[:-1] else None + + +def spawn(cmd: list[str], env: dict[str, str]) -> subprocess.Popen: + log = open(LOG, "ab") # noqa: SIM115 — 자식 프로세스가 물려받는다 + flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP + return subprocess.Popen( + cmd, cwd=ROOT, env=env, stdout=log, stderr=subprocess.STDOUT, creationflags=flags + ) + + def run(cmd: list[str], env: dict[str, str]) -> None: print("RUN", " ".join(cmd), flush=True) subprocess.run(cmd, cwd=ROOT, env=env, check=True, shell=os.name == "nt" and cmd[0] == "npm") @@ -61,7 +95,9 @@ def run(cmd: list[str], env: dict[str, str]) -> None: def main() -> int: os.makedirs(TMP, exist_ok=True) - stop() + replace = "--replace-dev" in sys.argv + if not replace: + stop() if "--stop" in sys.argv: print("PREVIEW=stopped") return 0 @@ -69,21 +105,31 @@ def main() -> int: api, vite = owners_ports() or (8000, 5173) api = int(os.environ.get("AISLO_API_PORT") or api) vite = int(os.environ.get("AISLO_VITE_PORT") or vite) - port = vite - 1000 + port = int(arg("--port") or (vite if replace else vite - 1000)) env = {**os.environ, "AISLO_API_PORT": str(api), "FRONTEND_DEV_PORT": str(vite)} - run(["node", VITE, "build", "--configLoader", "runner"], env) - run(["npm", "run", "build:b07-cad"], env) + try: + run(["node", VITE, "build", "--configLoader", "runner"], env) + run(["npm", "run", "build:b07-cad"], env) + except subprocess.CalledProcessError as exc: + # 옛 빌드판이 자리에 있으면 파일 목록이 낡음 — 끄고 개발판으로 + if replace and os.path.exists(PID_FILE): + stop() + free_port(port) + time.sleep(1) + spawn(["node", VITE, "--configLoader", "runner", "--host", "--port", str(port)], env) + print(f"PREVIEW=BUILD_FAIL ({exc.returncode}) — 옛 빌드판을 끄고 {port} 개발판") + else: + print(f"PREVIEW=BUILD_FAIL ({exc.returncode}) — 아무것도 안 끔") + return 1 + if replace: # 빌드가 된 뒤에만 — 옛 빌드판 · 그 포트의 개발판을 끔 + stop() + free_port(port) + time.sleep(1) - log = open(LOG, "ab") # noqa: SIM115 — 자식 프로세스가 물려받는다 - flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP - process = subprocess.Popen( - ["node", VITE, "preview", "--configLoader", "runner"], - cwd=ROOT, - env=env, - stdout=log, - stderr=subprocess.STDOUT, - creationflags=flags, + process = spawn( + ["node", VITE, "preview", "--configLoader", "runner", "--port", str(port), "--strictPort"], + env, ) with open(PID_FILE, "w", encoding="utf-8") as f: f.write(str(process.pid))