diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index a5c71936..478f3f2c 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -476,7 +476,11 @@ def runtime_status_payload(workspace_id: str, instance: int = 1) -> dict: "backend_url": f"http://127.0.0.1:{rt.port}" if rt.running and rt.port else None, # New-mode only: where the Vite dev server is reachable. Old-mode workspaces report null and the editor falls back to the legacy /api/outputs/workspace/{ws}/serve/... path. "frontend_port": rt.frontend_port, - "frontend_url": rt.frontend_url if rt.running else None, + # Ask the property, do not re-gate on `running`: a serve-static app HAS no process, so the + # extra gate blanked its URL and the preview pane had nothing to navigate to, which is the + # "app loads forever until I open a second instance" report (a second instance skips serve + # mode entirely). The property already handles ready/suspended/dead-vite itself. + "frontend_url": rt.frontend_url, "is_new_mode": rt.is_new_mode, # The boot narration used to be WS-only, so a wedged boot left no queryable trace and one 120s field wedge stayed a shrug (ENG-305); the tail makes any status poll name what the boot is doing. "recent_log": [getattr(line, "text", str(line))[-200:] for line in list(rt.log_buffer)[-12:]], diff --git a/backend/tests/test_serve_static_reports_its_url.py b/backend/tests/test_serve_static_reports_its_url.py new file mode 100644 index 00000000..ab5f43b2 --- /dev/null +++ b/backend/tests/test_serve_static_reports_its_url.py @@ -0,0 +1,41 @@ +"""A serve-static app must report a URL (found 2026-08-14 by a long-run user simulation, and it is +the "app loads forever until I open a second instance" report). + +ENG-209 lets a fresh built bundle be served with NO process. `running` is defined as "has a live +process", so it is False for those by design, and the status payload re-gated `frontend_url` on +`running`: the API said there was no URL, the preview pane had nothing to navigate to, and the app +spun forever. A SECOND instance skips serve mode entirely (`instance == 1` gate), which is exactly +why opening another window "fixed" it. Measured live: one app in the corpus was permanently +unopenable this way; after the fix it serves real HTML. +""" + +import inspect + +from backend.apps.outputs import outputs as outputs_mod +from backend.apps.outputs.runtime import AppRuntime + + +def test_status_asks_the_property_instead_of_re_gating_on_running(): + src = inspect.getsource(outputs_mod.runtime_status_payload) + line = next(ln for ln in src.splitlines() if '"frontend_url"' in ln and "None," not in ln) + assert "rt.running" not in line, ( + "re-gating frontend_url on `running` blanks it for serve-static apps, which have no " + "process by design; the property already handles ready/suspended/dead-vite" + ) + assert "rt.frontend_url" in line + + +def test_the_property_itself_answers_for_serve_static(): + src = inspect.getsource(AppRuntime.frontend_url.fget) + i_serve = src.find("self.serve_static") + i_running = src.find("self.running") + assert i_serve != -1, "the property must special-case serve mode" + assert i_running == -1 or i_serve < i_running, ( + "serve mode must be answered BEFORE any running check, or the processless path returns None" + ) + + +def test_serve_mode_is_still_gated_to_the_primary_instance(): + # The second-instance escape hatch is what made the bug survivable; keep it working. + src = inspect.getsource(AppRuntime.p_start_new_mode) + assert "self.instance == 1" in src