1#!/usr/bin/env python3
2"""Systemview startup fixture (Plan 05 P4.2).
3
4Static gates always run headless: the shell must fetch the WASM exactly
5once (clone-based fallback), carry the readiness contract markers, and
6the generated (untracked) manifest must match disk artifacts.
7
8The live launch smoke starts the candidate binary with an isolated
9profile, watches for panics/fatal crashes, then terminates it gracefully.
10Without a display the live part skips cleanly; in-page branches
11(streaming fallback, HTTP/MIME/compile failures, mount/bind failure, Go
12panic exit, disabled JS) follow the printed display protocol, whose
13markers are asserted statically here.
14
15Exit codes: 0 static gates and launch smoke green (in-page branches stay
16MANUAL), 1 a gate failed, 2 static gates green with live parts skipped.
17"""
18
19import argparse
20import json
21import os
22import re
23import shutil
24import subprocess
25import sys
26import tempfile
27from pathlib import Path
28
29REPO_ROOT = Path(__file__).resolve().parent.parent
30SHELL_PATH = REPO_ROOT / "assets" / "systemviews" / "index.html"
31MANIFEST_PATH = REPO_ROOT / "assets" / "systemviews" / "asset-manifest.json"
32DOM_ADAPTER_PATH = REPO_ROOT / "internal" / "ui" / "systemviews" / "dom_js.go"
33GENERATOR_PKG = "./cmd/systemviews-assets"
34
35EXIT_OK = 0
36EXIT_FAIL = 1
37EXIT_SKIP_LIVE = 2
38
39
40def fail(message):
41 print(f"systemview-startup: FAIL: {message}", flush=True)
42 return EXIT_FAIL
43
44
45def check_shell_single_fetch():
46 """P4.1: one fetch per branch, clone-based fallback, no refetch."""
47 try:
48 shell = SHELL_PATH.read_text(encoding="utf-8")
49 except OSError as exc:
50 return fail(f"cannot read shell: {exc}")
51 problems = []
52 if "response.clone()" not in shell:
53 problems.append("missing response.clone() fallback")
54 if "fallback.arrayBuffer()" not in shell:
55 problems.append("fallback does not consume the retained clone")
56 if re.search(r"return\s+instantiateFromBytes\(\)", shell):
57 problems.append("zero-arg instantiateFromBytes() refetches (old double-fetch)")
58 if shell.count("fetch(wasmURL") != 2:
59 problems.append("expected exactly two fetch(wasmURL...) branch sites")
60 if "response.ok" not in shell:
61 problems.append("missing response.ok fast-fail")
62 if problems:
63 return fail("shell fetch structure: " + "; ".join(problems))
64 print("systemview-startup: shell fetches WASM once per branch, fallback reuses the clone")
65 return EXIT_OK
66
67
68def check_shell_readiness_contract():
69 """P4.2: aria-busy entry, data markers, terminal guard, single go.run."""
70 try:
71 shell = SHELL_PATH.read_text(encoding="utf-8")
72 except OSError as exc:
73 return fail(f"cannot read shell: {exc}")
74 required = [
75 'aria-busy="true"',
76 "removeAttribute(",
77 "aria-busy",
78 'data-failed',
79 "terminalErrorReported",
80 ]
81 missing = [marker for marker in required if marker not in shell]
82 if missing:
83 return fail("shell readiness contract missing: " + ", ".join(missing))
84 if shell.count("go.run(") != 1:
85 return fail("runtime must execute exactly once (single go.run call site)")
86 for marker in ("go.exit =", "goExitCode", "exit status"):
87 if marker not in shell:
88 return fail(f"shell exit hook missing: {marker}")
89 try:
90 adapter = DOM_ADAPTER_PATH.read_text(encoding="utf-8")
91 except OSError as exc:
92 return fail(f"cannot read DOM adapter: {exc}")
93 if '"data-ready"' not in adapter:
94 return fail("DOM adapter never marks data-ready")
95 print("systemview-startup: shell carries the readiness contract markers")
96 return EXIT_OK
97
98
99def check_manifest_on_disk():
100 """P3.1: generated manifest parses and matches generator schema."""
101 try:
102 raw = MANIFEST_PATH.read_bytes()
103 except OSError as exc:
104 return fail(f"cannot read manifest (run make build-systemviews): {exc}")
105 try:
106 manifest = json.loads(raw)
107 except json.JSONDecodeError as exc:
108 return fail(f"manifest is not JSON: {exc}")
109 if manifest.get("version") != 1:
110 return fail("manifest version != 1")
111 files = manifest.get("files", {})
112 if sorted(files) != ["systemviews.css", "systemviews.wasm", "wasm_exec.js"]:
113 return fail(f"manifest pins {sorted(files)}, want the three servables")
114 for name, entry in files.items():
115 digest = entry.get("sha256", "")
116 if not re.fullmatch(r"[0-9a-f]{64}", digest):
117 return fail(f"manifest pin for {name} is not a sha256 hex digest")
118 if not isinstance(entry.get("size"), int) or entry["size"] < 0:
119 return fail(f"manifest pin for {name} has a bad size")
120 print(f"systemview-startup: manifest pins {len(files)} assets with valid digests")
121 return EXIT_OK
122
123
124def check_manifest_generator():
125 """P3.1: generator --check against disk artifacts (needs go toolchain)."""
126 if shutil.which("go") is None:
127 print("systemview-startup: SKIP generator check (no go toolchain)")
128 return EXIT_SKIP_LIVE
129 proc = subprocess.run(
130 ["go", "run", GENERATOR_PKG, "-check", "-dir", "assets/systemviews"],
131 cwd=REPO_ROOT,
132 capture_output=True,
133 text=True,
134 timeout=300,
135 )
136 if proc.returncode != 0:
137 sys.stderr.write(proc.stderr)
138 return fail("generator --check rejected the working tree")
139 print(f"systemview-startup: {proc.stdout.strip()}")
140 return EXIT_OK
141
142
143CRASH_PATTERNS = ["panic:", "fatal error:", "SIGTRAP", "runtime error:", "Go panic"]
144
145
146def display_available():
147 return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
148
149
150def sanitized_launch_env(profile):
151 """Build the child's environment: owned XDG/HOME/CEF dirs, no dev-mode.
152
153 The fixture must not read the operator's configuration (XDG_CONFIG_HOME)
154 nor follow ENV=dev, which would redirect all profile directories under
155 the working directory instead of the isolated profile.
156 """
157 env = dict(os.environ)
158 env.pop("ENV", None)
159 env["HOME"] = str(profile / "home")
160 env["XDG_DATA_HOME"] = str(profile / "data")
161 env["XDG_STATE_HOME"] = str(profile / "state")
162 env["XDG_CACHE_HOME"] = str(profile / "cache")
163 env["XDG_CONFIG_HOME"] = str(profile / "config")
164 return env
165
166
167def run_launch_smoke(binary, cef_dir, timeout):
168 """Start the candidate with an isolated profile, watch, terminate.
169
170 This is a startup smoke test only: it proves the binary launches and
171 survives the window without crashes. It never navigates to a
172 systemview, so fetch counts, readiness inspection, streaming fallback
173 and failure injection stay MANUAL via the printed display protocol.
174 """
175 if not display_available():
176 print("systemview-startup: SKIP live launch (no DISPLAY or WAYLAND_DISPLAY)")
177 return EXIT_SKIP_LIVE
178 profile = Path(tempfile.mkdtemp(prefix="sv-startup-profile-"))
179 env = sanitized_launch_env(profile)
180 env["DUMBER_CEF_ROOT_CACHE_PATH"] = str(profile / "cef-cache")
181 env["DUMBER_CEF_DIR"] = str(cef_dir)
182 print(f"systemview-startup: launching {binary} with isolated profile {profile}")
183 try:
184 proc = subprocess.Popen(
185 [str(binary)],
186 env=env,
187 cwd=profile,
188 stdout=subprocess.PIPE,
189 stderr=subprocess.STDOUT,
190 text=True,
191 )
192 except OSError as exc:
193 return fail(f"cannot execute binary: {exc}")
194 try:
195 try:
196 output, _ = proc.communicate(timeout=timeout)
197 except subprocess.TimeoutExpired:
198 output = None
199 if output is not None:
200 # Any early exit is unexpected for the GUI binary: a crash
201 # without markers is still a failure, never a pass.
202 print(f"systemview-startup: binary exited during smoke with code {proc.returncode}")
203 print((output or "")[-4000:])
204 for pattern in CRASH_PATTERNS:
205 if pattern in (output or ""):
206 return fail(f"crash marker in startup output: {pattern}")
207 return fail(f"binary exited during the smoke window (code {proc.returncode}); expected it to stay alive")
208 # Still alive past the window: the startup path survived; terminate
209 # gracefully, then force-kill only our own child on refusal.
210 proc.terminate()
211 try:
212 output, _ = proc.communicate(timeout=15)
213 except subprocess.TimeoutExpired:
214 proc.kill()
215 output, _ = proc.communicate(timeout=15)
216 print("systemview-startup: WARN binary ignored SIGTERM and was killed")
217 for pattern in CRASH_PATTERNS:
218 if pattern in (output or ""):
219 print((output or "")[-4000:])
220 return fail(f"crash marker in startup output: {pattern}")
221 print("systemview-startup: launch smoke survived the window with no crash markers")
222 return EXIT_OK
223 finally:
224 shutil.rmtree(profile, ignore_errors=True)
225
226
227DISPLAY_PROTOCOL = """\
228systemview-startup display protocol -- MANUAL STEPS (not executed by this script):
229 1. Open dumb://history -> #app gains data-ready="true", aria-busy gone.
230 Shell HTML references .wasm/.js/.css with ?v=<64-hex> (view-source).
231 2. DevTools: exactly one systemviews.wasm request per load; reload ->
232 .wasm served 200 with Cache-Control immutable; shell answers no-store.
233 3. Streaming fallback: serve .wasm as octet-stream via a local proxy ->
234 console shows the streaming-fallback warning, page still mounts ready.
235 4. HTTP failure: point the shell at a missing .wasm -> terminal
236 "Failed to load systemviews WebAssembly: Error: HTTP 404" rendered
237 once in .sv-error, data-failed="true", aria-busy gone.
238 5. Compile failure: corrupt the .wasm bytes -> same terminal error path.
239 6. Disabled JS: noscript message visible, no console activity.
240 7. Go panic path: break bridge init -> nonzero process exit, console
241 carries the error, error renders exactly once.
242"""
243
244
245def main():
246 parser = argparse.ArgumentParser(description="systemview startup fixture")
247 parser.add_argument("--binary", required=True, help="candidate dumber binary")
248 parser.add_argument("--cef-dir", required=True, help="compatible CEF runtime dir")
249 parser.add_argument("--launch-timeout", type=int, default=20, help="smoke window in seconds")
250 args = parser.parse_args()
251
252 binary = Path(args.binary)
253 if not binary.is_file() or not os.access(binary, os.X_OK):
254 return fail(f"binary is not executable: {binary}")
255 cef_dir = Path(args.cef_dir)
256 if not cef_dir.is_dir():
257 return fail(f"CEF runtime dir missing: {cef_dir}")
258 # The child runs with cwd set to the isolated profile: resolve both
259 # paths now, while they still mean "relative to the invoker".
260 binary = binary.resolve()
261 cef_dir = cef_dir.resolve()
262
263 results = [
264 check_shell_single_fetch(),
265 check_shell_readiness_contract(),
266 check_manifest_on_disk(),
267 check_manifest_generator(),
268 ]
269 if any(result == EXIT_FAIL for result in results):
270 return EXIT_FAIL
271 live = run_launch_smoke(binary, cef_dir, args.launch_timeout)
272 print(DISPLAY_PROTOCOL)
273 if live == EXIT_FAIL:
274 return EXIT_FAIL
275 if live == EXIT_SKIP_LIVE or any(result == EXIT_SKIP_LIVE for result in results):
276 print("systemview-startup: static gates green, live parts skipped")
277 print("systemview-startup: in-page branches were NOT executed here; they are MANUAL via the protocol above")
278 return EXIT_SKIP_LIVE
279 print("systemview-startup: static gates and launch smoke green")
280 print("systemview-startup: in-page branches were NOT executed here; they are MANUAL via the protocol above")
281 return EXIT_OK
282
283
284if __name__ == "__main__":
285 sys.exit(main())