Skip to content
Open
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
39 changes: 26 additions & 13 deletions dream-server/extensions/services/dashboard-api/routers/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,19 +154,32 @@ async def run_tests():
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
)
try:
async for line in process.stdout:
yield line.decode()
await process.wait()
# Emit the human-readable trailer AND the machine-readable sentinel
# as a SINGLE chunk. Starlette's StreamingResponse finalizes the
# HTTP stream as soon as the async generator exits; when trailer
# and sentinel are separate yields, the final sentinel bytes have
# been observed to never reach the client (the generator yields
# them but the transport drops the last chunk during close).
# Combining into one yield guarantees both land on the wire.
trailer = "All tests passed!" if process.returncode == 0 else "Some tests failed."
status = "PASS" if process.returncode == 0 else "FAIL"
yield f"\n{trailer}\n__DREAM_RESULT__:{status}:{process.returncode}\n"
try:
async for line in process.stdout:
yield line.decode()
await process.wait()
# Emit the human-readable trailer AND the machine-readable sentinel
# as a SINGLE chunk. Starlette's StreamingResponse finalizes the
# HTTP stream as soon as the async generator exits; when trailer
# and sentinel are separate yields, the final sentinel bytes have
# been observed to never reach the client (the generator yields
# them but the transport drops the last chunk during close).
# Combining into one yield guarantees both land on the wire.
trailer = "All tests passed!" if process.returncode == 0 else "Some tests failed."
status = "PASS" if process.returncode == 0 else "FAIL"
yield f"\n{trailer}\n__DREAM_RESULT__:{status}:{process.returncode}\n"
except (OSError, asyncio.CancelledError):
# Re-raise cancellation/disconnect — the client is gone, no point
# emitting a sentinel into a dead stream and CancelledError must
# propagate so the runtime can finalize the task tree.
raise
except Exception as exc: # noqa: BLE001 — sentinel contract requires *some* terminal signal
# The frontend SetupWizard parser treats absence of a sentinel as
# failure, so even when the runner blows up unexpectedly we still
# close the stream with a FAIL sentinel rather than leaving the
# client to fall back on best-effort log scraping.
logger.exception("run_setup_diagnostics generator raised: %s", exc)
yield f"\nDiagnostic runner error: {exc}\n__DREAM_RESULT__:FAIL:1\n"
finally:
if process.returncode is None:
try:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""Tests for the __DREAM_RESULT__ sentinel emission on /api/setup/test.

The frontend SetupWizard parser treats the sentinel as the source of truth
for diagnostic success/failure. Absence falls back to scraping log lines
for "All tests passed!", which is fragile, so the contract is that every
terminal state of the streaming endpoint MUST yield exactly one sentinel
line as its last line.
"""

from __future__ import annotations

import stat
from pathlib import Path

import pytest


SENTINEL_PREFIX = "__DREAM_RESULT__:"


def _last_sentinel_line(lines):
"""Return the parsed (status, rc) tuple for the last sentinel found.

Yields a tuple even when several sentinel lines exist in the stream
so we can assert the final terminator wins. ``rc`` is returned as a
string to keep the parse symmetric with the wire format.
"""
sentinel = None
for line in lines:
if line.startswith(SENTINEL_PREFIX):
sentinel = line
assert sentinel is not None, f"no __DREAM_RESULT__ line found in stream: {lines!r}"
payload = sentinel[len(SENTINEL_PREFIX):]
status, _, rc = payload.partition(":")
return status, rc


def _write_test_script(install_root: Path, body: str) -> Path:
"""Write a bash script to install_root/scripts/dream-test-functional.sh."""
scripts = install_root / "scripts"
scripts.mkdir(parents=True, exist_ok=True)
path = scripts / "dream-test-functional.sh"
path.write_text(body, encoding="utf-8")
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
return path


@pytest.fixture()
def setup_install_dir(tmp_path, monkeypatch):
"""Provide an isolated INSTALL_DIR for the setup router.

The setup endpoint resolves the diagnostic script via
``Path(INSTALL_DIR) / "scripts" / "dream-test-functional.sh"``; if it's
not there it falls back to ``Path(os.getcwd()) / "dream-test-functional.sh"``.
We point INSTALL_DIR at a tmp dir and chdir to another tmp dir so the
cwd fallback is also empty unless the test opts in.
"""
install_root = tmp_path / "dream-server"
install_root.mkdir()
cwd_root = tmp_path / "cwd"
cwd_root.mkdir()

monkeypatch.setattr("routers.setup.INSTALL_DIR", str(install_root))
monkeypatch.chdir(cwd_root)
return install_root


def test_setup_test_emits_pass_sentinel_on_success(test_client, setup_install_dir):
"""A diagnostic script that exits 0 must terminate with PASS:0."""
_write_test_script(setup_install_dir, "#!/bin/bash\necho 'check 1 ok'\necho 'check 2 ok'\nexit 0\n")

with test_client.stream("POST", "/api/setup/test", headers=test_client.auth_headers) as response:
assert response.status_code == 200
lines = [line for line in response.iter_lines() if line]

status, rc = _last_sentinel_line(lines)
assert status == "PASS"
assert rc == "0"
# Sentinel must be the LAST non-empty line on the wire.
assert lines[-1].startswith(SENTINEL_PREFIX), (
f"sentinel was emitted but not last; tail was: {lines[-3:]!r}"
)


def test_setup_test_emits_fail_sentinel_with_returncode_on_failure(test_client, setup_install_dir):
"""A diagnostic script that exits non-zero must terminate with FAIL:<rc>."""
_write_test_script(setup_install_dir, "#!/bin/bash\necho 'check failed'\nexit 3\n")

with test_client.stream("POST", "/api/setup/test", headers=test_client.auth_headers) as response:
assert response.status_code == 200
lines = [line for line in response.iter_lines() if line]

status, rc = _last_sentinel_line(lines)
assert status == "FAIL"
assert rc == "3", f"expected the script's literal exit code, got {rc!r}"
assert lines[-1].startswith(SENTINEL_PREFIX)


def test_setup_test_emits_sentinel_when_script_missing(test_client, setup_install_dir):
"""When neither INSTALL_DIR/scripts nor cwd has the script, the
error_stream() fallback runs aiohttp probes against configured
services and still terminates with a sentinel."""
# No script written — both lookup paths miss; error_stream() runs.
# In the test environment the dashboard SERVICES map is populated but
# nothing is actually listening, so every probe will fail and the
# sentinel should be FAIL:1.

with test_client.stream("POST", "/api/setup/test", headers=test_client.auth_headers) as response:
assert response.status_code == 200
lines = [line for line in response.iter_lines() if line]

status, rc = _last_sentinel_line(lines)
assert status in {"PASS", "FAIL"}, f"unknown sentinel status {status!r}"
# The FAIL path is the realistic outcome — services aren't running in
# the test harness — but assert only the structural contract here so
# this test stays robust against fixture changes.
assert rc.lstrip("-").isdigit(), f"sentinel rc must be numeric, got {rc!r}"
assert lines[-1].startswith(SENTINEL_PREFIX)


def test_setup_test_sentinel_format_is_machine_parseable(test_client, setup_install_dir):
"""The on-the-wire format must match the regex the SetupWizard frontend
pins: ``^__DREAM_RESULT__:(PASS|FAIL):(-?\\d+)$``. This test guards
against accidental whitespace, prefix, or trailing-character drift on
either side of the contract."""
import re

_write_test_script(setup_install_dir, "#!/bin/bash\nexit 0\n")
sentinel_re = re.compile(r"^__DREAM_RESULT__:(PASS|FAIL):(-?\d+)$")

with test_client.stream("POST", "/api/setup/test", headers=test_client.auth_headers) as response:
assert response.status_code == 200
lines = [line for line in response.iter_lines() if line]

sentinel_lines = [line for line in lines if line.startswith(SENTINEL_PREFIX)]
assert len(sentinel_lines) == 1, (
f"expected exactly one sentinel line, got {len(sentinel_lines)}: {sentinel_lines!r}"
)
assert sentinel_re.match(sentinel_lines[0]), (
f"sentinel does not match frontend parser regex: {sentinel_lines[0]!r}"
)


def test_setup_test_requires_auth(test_client):
"""Unauthenticated POST must fail before anything is streamed."""
response = test_client.post("/api/setup/test")
assert response.status_code == 401
Loading
Loading