|
| 1 | +"""Tests for the __DREAM_RESULT__ sentinel emission on /api/setup/test. |
| 2 | +
|
| 3 | +The frontend SetupWizard parser treats the sentinel as the source of truth |
| 4 | +for diagnostic success/failure. Absence falls back to scraping log lines |
| 5 | +for "All tests passed!", which is fragile, so the contract is that every |
| 6 | +terminal state of the streaming endpoint MUST yield exactly one sentinel |
| 7 | +line as its last line. |
| 8 | +""" |
| 9 | + |
| 10 | +from __future__ import annotations |
| 11 | + |
| 12 | +import os |
| 13 | +import stat |
| 14 | +from pathlib import Path |
| 15 | + |
| 16 | +import pytest |
| 17 | + |
| 18 | + |
| 19 | +SENTINEL_PREFIX = "__DREAM_RESULT__:" |
| 20 | + |
| 21 | + |
| 22 | +def _last_sentinel_line(lines): |
| 23 | + """Return the parsed (status, rc) tuple for the last sentinel found. |
| 24 | +
|
| 25 | + Yields a tuple even when several sentinel lines exist in the stream |
| 26 | + so we can assert the final terminator wins. ``rc`` is returned as a |
| 27 | + string to keep the parse symmetric with the wire format. |
| 28 | + """ |
| 29 | + sentinel = None |
| 30 | + for line in lines: |
| 31 | + if line.startswith(SENTINEL_PREFIX): |
| 32 | + sentinel = line |
| 33 | + assert sentinel is not None, f"no __DREAM_RESULT__ line found in stream: {lines!r}" |
| 34 | + payload = sentinel[len(SENTINEL_PREFIX):] |
| 35 | + status, _, rc = payload.partition(":") |
| 36 | + return status, rc |
| 37 | + |
| 38 | + |
| 39 | +def _write_test_script(install_root: Path, body: str) -> Path: |
| 40 | + """Write a bash script to install_root/scripts/dream-test-functional.sh.""" |
| 41 | + scripts = install_root / "scripts" |
| 42 | + scripts.mkdir(parents=True, exist_ok=True) |
| 43 | + path = scripts / "dream-test-functional.sh" |
| 44 | + path.write_text(body, encoding="utf-8") |
| 45 | + path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) |
| 46 | + return path |
| 47 | + |
| 48 | + |
| 49 | +@pytest.fixture() |
| 50 | +def setup_install_dir(tmp_path, monkeypatch): |
| 51 | + """Provide an isolated INSTALL_DIR for the setup router. |
| 52 | +
|
| 53 | + The setup endpoint resolves the diagnostic script via |
| 54 | + ``Path(INSTALL_DIR) / "scripts" / "dream-test-functional.sh"``; if it's |
| 55 | + not there it falls back to ``Path(os.getcwd()) / "dream-test-functional.sh"``. |
| 56 | + We point INSTALL_DIR at a tmp dir and chdir to another tmp dir so the |
| 57 | + cwd fallback is also empty unless the test opts in. |
| 58 | + """ |
| 59 | + install_root = tmp_path / "dream-server" |
| 60 | + install_root.mkdir() |
| 61 | + cwd_root = tmp_path / "cwd" |
| 62 | + cwd_root.mkdir() |
| 63 | + |
| 64 | + monkeypatch.setattr("routers.setup.INSTALL_DIR", str(install_root)) |
| 65 | + monkeypatch.chdir(cwd_root) |
| 66 | + return install_root |
| 67 | + |
| 68 | + |
| 69 | +def test_setup_test_emits_pass_sentinel_on_success(test_client, setup_install_dir): |
| 70 | + """A diagnostic script that exits 0 must terminate with PASS:0.""" |
| 71 | + _write_test_script(setup_install_dir, "#!/bin/bash\necho 'check 1 ok'\necho 'check 2 ok'\nexit 0\n") |
| 72 | + |
| 73 | + with test_client.stream("POST", "/api/setup/test", headers=test_client.auth_headers) as response: |
| 74 | + assert response.status_code == 200 |
| 75 | + lines = [line for line in response.iter_lines() if line] |
| 76 | + |
| 77 | + status, rc = _last_sentinel_line(lines) |
| 78 | + assert status == "PASS" |
| 79 | + assert rc == "0" |
| 80 | + # Sentinel must be the LAST non-empty line on the wire. |
| 81 | + assert lines[-1].startswith(SENTINEL_PREFIX), ( |
| 82 | + f"sentinel was emitted but not last; tail was: {lines[-3:]!r}" |
| 83 | + ) |
| 84 | + |
| 85 | + |
| 86 | +def test_setup_test_emits_fail_sentinel_with_returncode_on_failure(test_client, setup_install_dir): |
| 87 | + """A diagnostic script that exits non-zero must terminate with FAIL:<rc>.""" |
| 88 | + _write_test_script(setup_install_dir, "#!/bin/bash\necho 'check failed'\nexit 3\n") |
| 89 | + |
| 90 | + with test_client.stream("POST", "/api/setup/test", headers=test_client.auth_headers) as response: |
| 91 | + assert response.status_code == 200 |
| 92 | + lines = [line for line in response.iter_lines() if line] |
| 93 | + |
| 94 | + status, rc = _last_sentinel_line(lines) |
| 95 | + assert status == "FAIL" |
| 96 | + assert rc == "3", f"expected the script's literal exit code, got {rc!r}" |
| 97 | + assert lines[-1].startswith(SENTINEL_PREFIX) |
| 98 | + |
| 99 | + |
| 100 | +def test_setup_test_emits_sentinel_when_script_missing(test_client, setup_install_dir): |
| 101 | + """When neither INSTALL_DIR/scripts nor cwd has the script, the |
| 102 | + error_stream() fallback runs aiohttp probes against configured |
| 103 | + services and still terminates with a sentinel.""" |
| 104 | + # No script written — both lookup paths miss; error_stream() runs. |
| 105 | + # In the test environment the dashboard SERVICES map is populated but |
| 106 | + # nothing is actually listening, so every probe will fail and the |
| 107 | + # sentinel should be FAIL:1. |
| 108 | + |
| 109 | + with test_client.stream("POST", "/api/setup/test", headers=test_client.auth_headers) as response: |
| 110 | + assert response.status_code == 200 |
| 111 | + lines = [line for line in response.iter_lines() if line] |
| 112 | + |
| 113 | + status, rc = _last_sentinel_line(lines) |
| 114 | + assert status in {"PASS", "FAIL"}, f"unknown sentinel status {status!r}" |
| 115 | + # The FAIL path is the realistic outcome — services aren't running in |
| 116 | + # the test harness — but assert only the structural contract here so |
| 117 | + # this test stays robust against fixture changes. |
| 118 | + assert rc.lstrip("-").isdigit(), f"sentinel rc must be numeric, got {rc!r}" |
| 119 | + assert lines[-1].startswith(SENTINEL_PREFIX) |
| 120 | + |
| 121 | + |
| 122 | +def test_setup_test_sentinel_format_is_machine_parseable(test_client, setup_install_dir): |
| 123 | + """The on-the-wire format must match the regex the SetupWizard frontend |
| 124 | + pins: ``^__DREAM_RESULT__:(PASS|FAIL):(-?\\d+)$``. This test guards |
| 125 | + against accidental whitespace, prefix, or trailing-character drift on |
| 126 | + either side of the contract.""" |
| 127 | + import re |
| 128 | + |
| 129 | + _write_test_script(setup_install_dir, "#!/bin/bash\nexit 0\n") |
| 130 | + sentinel_re = re.compile(r"^__DREAM_RESULT__:(PASS|FAIL):(-?\d+)$") |
| 131 | + |
| 132 | + with test_client.stream("POST", "/api/setup/test", headers=test_client.auth_headers) as response: |
| 133 | + assert response.status_code == 200 |
| 134 | + lines = [line for line in response.iter_lines() if line] |
| 135 | + |
| 136 | + sentinel_lines = [line for line in lines if line.startswith(SENTINEL_PREFIX)] |
| 137 | + assert len(sentinel_lines) == 1, ( |
| 138 | + f"expected exactly one sentinel line, got {len(sentinel_lines)}: {sentinel_lines!r}" |
| 139 | + ) |
| 140 | + assert sentinel_re.match(sentinel_lines[0]), ( |
| 141 | + f"sentinel does not match frontend parser regex: {sentinel_lines[0]!r}" |
| 142 | + ) |
| 143 | + |
| 144 | + |
| 145 | +def test_setup_test_requires_auth(test_client): |
| 146 | + """Unauthenticated POST must fail before anything is streamed.""" |
| 147 | + response = test_client.post("/api/setup/test") |
| 148 | + assert response.status_code == 401 |
0 commit comments