979 lines
40 KiB
Python
979 lines
40 KiB
Python
"""Standard-library-only harness for Pebrel's public Runtime API."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
import fnmatch
|
|
import glob
|
|
import json
|
|
import os
|
|
import plistlib
|
|
import re
|
|
import shutil
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import zipfile
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
PROTOCOL_NAME = "nebula.runtime"
|
|
PROTOCOL_VERSION = 1
|
|
MAX_RESPONSE_BYTES = 32 * 1024 * 1024
|
|
DEFAULT_STARTUP_TIMEOUT = 20.0
|
|
|
|
# The Windows gate cannot start the app until its parent assigns the exact
|
|
# process HANDLE to a job. -I -S disables user/site startup hooks in this gate.
|
|
_WINDOWS_LAUNCH_GATE = (
|
|
"import subprocess, sys\n"
|
|
"if sys.stdin.buffer.read(1) != b'G':\n"
|
|
" raise SystemExit(125)\n"
|
|
"raise SystemExit(subprocess.call(sys.argv[1:], stdin=subprocess.DEVNULL))\n"
|
|
)
|
|
|
|
|
|
class ConformanceError(RuntimeError):
|
|
"""A test precondition or behavioral assertion failed."""
|
|
|
|
|
|
class ApiFailure(ConformanceError):
|
|
def __init__(self, method: str, error: dict[str, Any]) -> None:
|
|
self.method = method
|
|
self.error = error
|
|
code = error.get("code", "unknown_error")
|
|
message = error.get("message", "runtime request failed")
|
|
super().__init__(f"{method}: {code}: {message}")
|
|
|
|
|
|
class SkipCase(RuntimeError):
|
|
"""The environment cannot exercise an optional conformance case."""
|
|
|
|
|
|
def require(condition: bool, message: str) -> None:
|
|
if not condition:
|
|
raise ConformanceError(message)
|
|
|
|
|
|
def normalize_text(value: str) -> str:
|
|
lines = value.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
return "\n".join(line.rstrip() for line in lines).rstrip()
|
|
|
|
|
|
def find_last_line_match(pattern: re.Pattern[str], text: str) -> re.Match[str] | None:
|
|
for line in reversed(text.splitlines()):
|
|
if match := pattern.search(line.strip()):
|
|
return match
|
|
return None
|
|
|
|
|
|
def normalize_path(value: str) -> str:
|
|
value = value.replace("\\", "/")
|
|
match = re.match(r"^([A-Za-z]):(?:/|$)", value)
|
|
if match:
|
|
drive = match.group(1).lower()
|
|
value = f"/{drive}/{value[match.end():]}"
|
|
value = re.sub(r"/{2,}", "/", value)
|
|
return value.rstrip("/") or "/"
|
|
|
|
|
|
def path_shape(value: str) -> str:
|
|
normalized = normalize_path(value)
|
|
if re.match(r"^/[a-z](?:/|$)", normalized):
|
|
return "drive_absolute"
|
|
if normalized.startswith("/"):
|
|
return "posix_absolute"
|
|
return "relative"
|
|
|
|
|
|
def shell_category(value: str) -> str | None:
|
|
token = value.strip().strip("-").replace("\\", "/").rsplit("/", 1)[-1].lower()
|
|
if token.endswith(".exe"):
|
|
token = token[:-4]
|
|
if "powershell" in token:
|
|
return "powershell"
|
|
if token in {"pwsh", "cmd", "bash", "zsh", "fish", "nu", "sh", "dash"}:
|
|
return token
|
|
if "nushell" in token:
|
|
return "nu"
|
|
return None
|
|
|
|
|
|
def flatten(value: Any, prefix: str = "") -> dict[str, Any]:
|
|
result: dict[str, Any] = {}
|
|
if isinstance(value, dict):
|
|
for key in sorted(value):
|
|
path = f"{prefix}.{key}" if prefix else key
|
|
result.update(flatten(value[key], path))
|
|
elif isinstance(value, list):
|
|
for index, item in enumerate(value):
|
|
path = f"{prefix}.{index}" if prefix else str(index)
|
|
result.update(flatten(item, path))
|
|
else:
|
|
result[prefix] = value
|
|
return result
|
|
|
|
|
|
def filter_flat(values: dict[str, Any], patterns: list[str]) -> dict[str, Any]:
|
|
return {
|
|
path: value
|
|
for path, value in values.items()
|
|
if not any(fnmatch.fnmatchcase(path, pattern) for pattern in patterns)
|
|
}
|
|
|
|
|
|
def compare_flat(
|
|
expected: dict[str, Any], actual: dict[str, Any], label: str
|
|
) -> list[str]:
|
|
errors: list[str] = []
|
|
for path in sorted(expected.keys() - actual.keys()):
|
|
errors.append(f"{label}: missing field {path}")
|
|
for path in sorted(actual.keys() - expected.keys()):
|
|
errors.append(f"{label}: unexpected field {path}={actual[path]!r}")
|
|
for path in sorted(expected.keys() & actual.keys()):
|
|
if expected[path] != actual[path]:
|
|
errors.append(
|
|
f"{label}: {path}: expected {expected[path]!r}, got {actual[path]!r}"
|
|
)
|
|
return errors
|
|
|
|
|
|
class ResolvedApp:
|
|
"""Resolve packaged and unpackaged application paths to one executable."""
|
|
|
|
def __init__(self, source: str | Path) -> None:
|
|
matches = sorted(glob.glob(os.fspath(source)))
|
|
if len(matches) != 1:
|
|
raise ConformanceError(
|
|
f"--app must resolve to exactly one path, got {len(matches)}: {source}"
|
|
)
|
|
self.source = Path(matches[0]).resolve()
|
|
self._temporary: tempfile.TemporaryDirectory[str] | None = None
|
|
self.executable = self._resolve(self.source)
|
|
|
|
def _resolve(self, source: Path) -> Path:
|
|
if source.is_dir() and source.suffix.lower() == ".app":
|
|
with (source / "Contents" / "Info.plist").open("rb") as stream:
|
|
name = plistlib.load(stream).get("CFBundleExecutable")
|
|
require(name in {"pebrel", "nebula"}, f"unexpected app executable: {name}")
|
|
executable = source / "Contents" / "MacOS" / name
|
|
require(executable.is_file(), f"app bundle has no executable: {executable}")
|
|
return executable
|
|
if source.is_file() and source.suffix.lower() == ".zip":
|
|
self._temporary = tempfile.TemporaryDirectory(prefix="pebrel-conformance-app-")
|
|
root = Path(self._temporary.name).resolve()
|
|
try:
|
|
with zipfile.ZipFile(source) as archive:
|
|
for member in archive.infolist():
|
|
target = (root / member.filename).resolve()
|
|
require(
|
|
target == root or root in target.parents,
|
|
f"archive member escapes extraction root: {member.filename}",
|
|
)
|
|
archive.extractall(root)
|
|
candidates = [
|
|
path
|
|
for path in root.rglob("*")
|
|
if path.is_file() and path.name.lower() in {"pebrel", "pebrel.exe", "nebula", "nebula.exe"}
|
|
]
|
|
require(
|
|
len(candidates) == 1,
|
|
f"archive contains {len(candidates)} product executables",
|
|
)
|
|
return candidates[0]
|
|
except Exception:
|
|
self.close()
|
|
raise
|
|
if source.is_dir():
|
|
candidates = [
|
|
path
|
|
for path in source.rglob("*")
|
|
if path.is_file() and path.name.lower() in {"pebrel", "pebrel.exe", "nebula", "nebula.exe"}
|
|
]
|
|
require(len(candidates) == 1, f"directory contains {len(candidates)} product executables")
|
|
return candidates[0]
|
|
require(source.is_file(), f"application path does not exist: {source}")
|
|
return source
|
|
|
|
def close(self) -> None:
|
|
if self._temporary is not None:
|
|
self._temporary.cleanup()
|
|
self._temporary = None
|
|
|
|
|
|
class RuntimeClient:
|
|
def __init__(self, port: int, token: str, timeout: float = 5.0) -> None:
|
|
self.port = port
|
|
self.token = token
|
|
self.timeout = timeout
|
|
self.sequence = 0
|
|
|
|
@classmethod
|
|
def from_port_file(cls, path: Path, timeout: float = 5.0) -> RuntimeClient:
|
|
parts = path.read_text(encoding="utf-8").split()
|
|
require(len(parts) >= 2, f"invalid runtime discovery record: {path}")
|
|
try:
|
|
port = int(parts[0])
|
|
except ValueError as error:
|
|
raise ConformanceError(f"invalid runtime port in {path}: {parts[0]!r}") from error
|
|
require(0 < port <= 65535, f"runtime port is out of range: {port}")
|
|
return cls(port, parts[1], timeout)
|
|
|
|
def request(
|
|
self,
|
|
method: str,
|
|
params: dict[str, Any] | None = None,
|
|
*,
|
|
timeout: float | None = None,
|
|
allow_error: bool = False,
|
|
) -> dict[str, Any]:
|
|
self.sequence += 1
|
|
request = {
|
|
"protocol": PROTOCOL_NAME,
|
|
"version": PROTOCOL_VERSION,
|
|
"id": f"conformance-{os.getpid()}-{self.sequence}",
|
|
"token": self.token,
|
|
"method": method,
|
|
"params": params or {},
|
|
}
|
|
payload = json.dumps(request, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
request_timeout = timeout if timeout is not None else self.timeout
|
|
with socket.create_connection(("127.0.0.1", self.port), request_timeout) as stream:
|
|
stream.settimeout(request_timeout)
|
|
stream.sendall(payload + b"\n")
|
|
stream.shutdown(socket.SHUT_WR)
|
|
reader = stream.makefile("rb")
|
|
line = reader.readline(MAX_RESPONSE_BYTES + 1)
|
|
require(line, f"{method}: runtime closed the connection without a response")
|
|
require(len(line) <= MAX_RESPONSE_BYTES, f"{method}: response exceeds byte limit")
|
|
try:
|
|
response = json.loads(line.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
raise ConformanceError(f"{method}: invalid UTF-8 JSON response") from error
|
|
require(response.get("protocol") == PROTOCOL_NAME, f"{method}: wrong response protocol")
|
|
require(response.get("version") == PROTOCOL_VERSION, f"{method}: wrong protocol version")
|
|
require(response.get("id") == request["id"], f"{method}: response id mismatch")
|
|
if not response.get("ok") and not allow_error:
|
|
raise ApiFailure(method, response.get("error") or {})
|
|
return response
|
|
|
|
|
|
class _WindowsJob:
|
|
"""Own one harness launch and its descendants, including orphaned shells.
|
|
|
|
Killing only Nebula can leave ConPTY children holding the test directory.
|
|
Job membership is established before the gated app launch, inherited by
|
|
descendants, and retained after the parent exits. No machine-wide process
|
|
enumeration or process-name matching is used. The job kills its members if the
|
|
harness itself exits unexpectedly and Windows closes its handle.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
class BasicLimits(ctypes.Structure):
|
|
_fields_ = [
|
|
("PerProcessUserTimeLimit", ctypes.c_int64),
|
|
("PerJobUserTimeLimit", ctypes.c_int64),
|
|
("LimitFlags", wintypes.DWORD),
|
|
("MinimumWorkingSetSize", ctypes.c_size_t),
|
|
("MaximumWorkingSetSize", ctypes.c_size_t),
|
|
("ActiveProcessLimit", wintypes.DWORD),
|
|
("Affinity", ctypes.c_size_t),
|
|
("PriorityClass", wintypes.DWORD),
|
|
("SchedulingClass", wintypes.DWORD),
|
|
]
|
|
|
|
class ExtendedLimits(ctypes.Structure):
|
|
_fields_ = [
|
|
("BasicLimitInformation", BasicLimits),
|
|
("IoInfo", ctypes.c_uint64 * 6),
|
|
("ProcessMemoryLimit", ctypes.c_size_t),
|
|
("JobMemoryLimit", ctypes.c_size_t),
|
|
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
|
("PeakJobMemoryUsed", ctypes.c_size_t),
|
|
]
|
|
|
|
class Accounting(ctypes.Structure):
|
|
_fields_ = [
|
|
("TotalUserTime", ctypes.c_int64),
|
|
("TotalKernelTime", ctypes.c_int64),
|
|
("ThisPeriodTotalUserTime", ctypes.c_int64),
|
|
("ThisPeriodTotalKernelTime", ctypes.c_int64),
|
|
("TotalPageFaultCount", wintypes.DWORD),
|
|
("TotalProcesses", wintypes.DWORD),
|
|
("ActiveProcesses", wintypes.DWORD),
|
|
("TotalTerminatedProcesses", wintypes.DWORD),
|
|
]
|
|
|
|
self._accounting = Accounting
|
|
self._api = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
self._api.CreateJobObjectW.argtypes = (ctypes.c_void_p, wintypes.LPCWSTR)
|
|
self._api.CreateJobObjectW.restype = wintypes.HANDLE
|
|
self._api.SetInformationJobObject.argtypes = (
|
|
wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD,
|
|
)
|
|
self._api.SetInformationJobObject.restype = wintypes.BOOL
|
|
self._api.AssignProcessToJobObject.argtypes = (wintypes.HANDLE, wintypes.HANDLE)
|
|
self._api.AssignProcessToJobObject.restype = wintypes.BOOL
|
|
self._api.TerminateJobObject.argtypes = (wintypes.HANDLE, wintypes.UINT)
|
|
self._api.TerminateJobObject.restype = wintypes.BOOL
|
|
self._api.QueryInformationJobObject.argtypes = (
|
|
wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD,
|
|
ctypes.POINTER(wintypes.DWORD),
|
|
)
|
|
self._api.QueryInformationJobObject.restype = wintypes.BOOL
|
|
self._api.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
|
|
self._api.OpenProcess.restype = wintypes.HANDLE
|
|
self._api.IsProcessInJob.argtypes = (
|
|
wintypes.HANDLE, wintypes.HANDLE, ctypes.POINTER(wintypes.BOOL),
|
|
)
|
|
self._api.IsProcessInJob.restype = wintypes.BOOL
|
|
self._api.WaitForSingleObject.argtypes = (wintypes.HANDLE, wintypes.DWORD)
|
|
self._api.WaitForSingleObject.restype = wintypes.DWORD
|
|
self._api.CloseHandle.argtypes = (wintypes.HANDLE,)
|
|
self._api.CloseHandle.restype = wintypes.BOOL
|
|
self._process_handles: list[int] = []
|
|
self._handle = self._api.CreateJobObjectW(None, None)
|
|
if not self._handle:
|
|
raise ctypes.WinError(ctypes.get_last_error(), "CreateJobObjectW failed")
|
|
limits = ExtendedLimits()
|
|
limits.BasicLimitInformation.LimitFlags = 0x2000 # KILL_ON_JOB_CLOSE
|
|
if not self._api.SetInformationJobObject(
|
|
self._handle, 9, ctypes.byref(limits), ctypes.sizeof(limits)
|
|
):
|
|
error = ctypes.WinError(ctypes.get_last_error(), "SetInformationJobObject failed")
|
|
self.close()
|
|
raise error
|
|
|
|
def assign(self, process: subprocess.Popen[bytes]) -> None:
|
|
# CPython retains the native process handle on Windows. Using that
|
|
# handle avoids opening a PID that could have been recycled.
|
|
if not self._api.AssignProcessToJobObject(self._handle, int(process._handle)):
|
|
raise ctypes.WinError(ctypes.get_last_error(), "AssignProcessToJobObject failed")
|
|
|
|
def terminate(self, timeout: float = 5.0) -> None:
|
|
deadline = time.monotonic() + timeout
|
|
self._capture_process_handles()
|
|
if not self._api.TerminateJobObject(self._handle, 1):
|
|
raise ctypes.WinError(ctypes.get_last_error())
|
|
self._capture_process_handles()
|
|
while True:
|
|
accounting = self._accounting()
|
|
if not self._api.QueryInformationJobObject(
|
|
self._handle, 1, ctypes.byref(accounting), ctypes.sizeof(accounting), None
|
|
):
|
|
raise ctypes.WinError(ctypes.get_last_error())
|
|
if accounting.ActiveProcesses == 0:
|
|
break
|
|
if time.monotonic() >= deadline:
|
|
raise ConformanceError(
|
|
f"Windows launch job still has {accounting.ActiveProcesses} "
|
|
f"processes after {timeout:.1f}s of cleanup"
|
|
)
|
|
time.sleep(0.01)
|
|
# ActiveProcesses can reach zero before final process teardown closes
|
|
# directory handles. Wait on the retained identities, not just that
|
|
# accounting value, including when the launch parent exited earlier.
|
|
for handle in self._process_handles:
|
|
remaining_ms = max(0, int((deadline - time.monotonic()) * 1000))
|
|
result = self._api.WaitForSingleObject(handle, remaining_ms)
|
|
if result == 0xFFFFFFFF:
|
|
raise ctypes.WinError(ctypes.get_last_error())
|
|
if result != 0:
|
|
raise ConformanceError(f"Windows job member did not exit within {timeout:.1f}s")
|
|
|
|
def _capture_process_handles(self) -> None:
|
|
capacity = 16
|
|
while True:
|
|
buffer = ctypes.create_string_buffer(8 + ctypes.sizeof(ctypes.c_size_t) * capacity)
|
|
if self._api.QueryInformationJobObject(
|
|
self._handle, 3, buffer, ctypes.sizeof(buffer), None
|
|
):
|
|
count = wintypes.DWORD.from_buffer(buffer, 4).value
|
|
pids = (ctypes.c_size_t * count).from_buffer(buffer, 8)
|
|
break
|
|
error = ctypes.get_last_error()
|
|
if error != 234: # ERROR_MORE_DATA: members grew beyond the buffer.
|
|
raise ctypes.WinError(error)
|
|
capacity = max(capacity * 2, wintypes.DWORD.from_buffer(buffer).value)
|
|
for pid in pids:
|
|
# These are only candidates from this job, never a machine-wide
|
|
# process tree. Verify membership after opening to reject PID reuse.
|
|
handle = self._api.OpenProcess(0x00101000, False, pid)
|
|
if not handle:
|
|
error = ctypes.get_last_error()
|
|
if error == 87: # The process exited before its handle was opened.
|
|
continue
|
|
raise ctypes.WinError(error)
|
|
member = wintypes.BOOL()
|
|
if not self._api.IsProcessInJob(handle, self._handle, ctypes.byref(member)):
|
|
error = ctypes.WinError(ctypes.get_last_error())
|
|
self._api.CloseHandle(handle)
|
|
raise error
|
|
if member.value:
|
|
self._process_handles.append(handle)
|
|
elif not self._api.CloseHandle(handle):
|
|
raise ctypes.WinError(ctypes.get_last_error())
|
|
|
|
def close(self) -> None:
|
|
if self._handle is not None:
|
|
if not self._api.CloseHandle(self._handle):
|
|
raise ctypes.WinError(ctypes.get_last_error())
|
|
self._handle = None
|
|
while self._process_handles:
|
|
if not self._api.CloseHandle(self._process_handles[-1]):
|
|
raise ctypes.WinError(ctypes.get_last_error())
|
|
self._process_handles.pop()
|
|
|
|
|
|
class ConformanceContext:
|
|
def __init__(
|
|
self,
|
|
app: ResolvedApp,
|
|
platform: str,
|
|
config_dir: Path,
|
|
work_dir: Path,
|
|
artifact_dir: Path,
|
|
startup_timeout: float = DEFAULT_STARTUP_TIMEOUT,
|
|
) -> None:
|
|
self.app = app
|
|
self.platform = platform
|
|
self.platform_family = platform.split("-", 1)[0]
|
|
self.config_dir = config_dir.resolve()
|
|
self.work_dir = work_dir.resolve()
|
|
self.artifact_dir = artifact_dir.resolve()
|
|
self.startup_timeout = startup_timeout
|
|
self.process: subprocess.Popen[bytes] | None = None
|
|
self._windows_job: _WindowsJob | None = None
|
|
self.client: RuntimeClient | None = None
|
|
self.description: dict[str, Any] = {}
|
|
self.startup_ms = 0
|
|
self.window_id = 0
|
|
self.pane_id = 0
|
|
self.shell = "unknown"
|
|
self._launch_number = 0
|
|
self._log_handle: Any = None
|
|
self._marker_sequence = 0
|
|
|
|
@property
|
|
def port_file(self) -> Path:
|
|
return self.config_dir / "runtime.port"
|
|
|
|
@property
|
|
def session_file(self) -> Path:
|
|
return self.config_dir / "session.json"
|
|
|
|
def prepare(self) -> None:
|
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
self.work_dir.mkdir(parents=True, exist_ok=True)
|
|
self.artifact_dir.mkdir(parents=True, exist_ok=True)
|
|
settings = "\n".join(
|
|
[
|
|
"keep_session=0",
|
|
"tray=0",
|
|
"restore_session=1",
|
|
"resume_ai=0",
|
|
"fetch=0",
|
|
"auto_check_updates=0",
|
|
"ai_hooks=0",
|
|
"windowing_behavior=use_new",
|
|
"",
|
|
]
|
|
)
|
|
(self.config_dir / "pebrel_settings.txt").write_text(
|
|
settings, encoding="utf-8", newline="\n"
|
|
)
|
|
|
|
def start(self, *, explicit_working_directory: bool = True) -> None:
|
|
require(
|
|
self.process is None and self._windows_job is None,
|
|
"Pebrel is already running in this context",
|
|
)
|
|
self._launch_number += 1
|
|
log_path = self.artifact_dir / f"pebrel-{self._launch_number}.log"
|
|
self._log_handle = log_path.open("wb")
|
|
env = os.environ.copy()
|
|
env["PEBREL_CONFIG_DIR"] = os.fspath(self.config_dir)
|
|
env["NEBULA_CONFIG_DIR"] = os.fspath(self.config_dir)
|
|
env["RUST_BACKTRACE"] = "1"
|
|
if self.app.executable.suffix.lower() == ".appimage":
|
|
env.setdefault("APPIMAGE_EXTRACT_AND_RUN", "1")
|
|
command = [os.fspath(self.app.executable)]
|
|
if explicit_working_directory:
|
|
command.extend(["--working-directory", os.fspath(self.work_dir)])
|
|
started = time.monotonic()
|
|
try:
|
|
self.process = self._spawn_process(command, env)
|
|
except OSError as error:
|
|
self._close_log()
|
|
raise ConformanceError(f"could not launch {self.app.executable}: {error}") from error
|
|
|
|
deadline = started + self.startup_timeout
|
|
last_error = "runtime.port has not appeared"
|
|
while time.monotonic() < deadline:
|
|
if self.process.poll() is not None:
|
|
code = self.process.returncode
|
|
self.stop(force=True)
|
|
raise ConformanceError(f"Nebula exited during startup with code {code}")
|
|
if self.port_file.is_file():
|
|
try:
|
|
candidate = RuntimeClient.from_port_file(self.port_file, timeout=5.0)
|
|
response = candidate.request("runtime.describe", timeout=1.0)
|
|
remaining = max(0.1, deadline - time.monotonic())
|
|
snapshot_response = candidate.request(
|
|
"runtime.snapshot", timeout=min(5.0, remaining)
|
|
)
|
|
snapshot = snapshot_response.get("result")
|
|
require(isinstance(snapshot, dict), "runtime snapshot is not an object")
|
|
if os.name == "posix":
|
|
runtime_pid = snapshot.get("process_id")
|
|
require(
|
|
type(runtime_pid) is int and runtime_pid > 0
|
|
and os.getpgid(runtime_pid) == self.process.pid,
|
|
"runtime process does not belong to this launch",
|
|
)
|
|
windows = snapshot.get("windows") or []
|
|
ready = any(
|
|
any(
|
|
tab.get("kind") == "shell" and bool(tab.get("panes"))
|
|
for tab in window.get("tabs") or []
|
|
)
|
|
for window in windows
|
|
)
|
|
require(ready, "runtime snapshot has no ready shell pane")
|
|
self.client = candidate
|
|
self.description = response["result"]
|
|
self.startup_ms = round((time.monotonic() - started) * 1000)
|
|
return
|
|
except (OSError, ConformanceError) as error:
|
|
last_error = str(error)
|
|
time.sleep(0.05)
|
|
self.stop(force=True)
|
|
raise ConformanceError(f"Nebula runtime did not become ready: {last_error}")
|
|
|
|
def _spawn_process(self, command: list[str], env: dict[str, str]) -> subprocess.Popen[bytes]:
|
|
options = {
|
|
"cwd": self.work_dir,
|
|
"env": env,
|
|
"stdout": self._log_handle,
|
|
"stderr": subprocess.STDOUT,
|
|
}
|
|
if os.name != "nt":
|
|
return subprocess.Popen(
|
|
command, stdin=subprocess.DEVNULL, start_new_session=True, **options,
|
|
)
|
|
job = _WindowsJob()
|
|
process = None
|
|
try:
|
|
process = subprocess.Popen(
|
|
[sys.executable, "-I", "-S", "-c", _WINDOWS_LAUNCH_GATE, *command],
|
|
stdin=subprocess.PIPE, **options,
|
|
)
|
|
job.assign(process)
|
|
assert process.stdin is not None
|
|
with process.stdin:
|
|
process.stdin.write(b"G")
|
|
except BaseException:
|
|
try:
|
|
# Before assignment the gate has no child; after assignment
|
|
# the job owns anything it could have started.
|
|
if process is not None:
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.wait(timeout=5)
|
|
if process.stdin is not None:
|
|
process.stdin.close()
|
|
job.terminate()
|
|
finally:
|
|
job.close()
|
|
raise
|
|
self._windows_job = job
|
|
return process
|
|
|
|
def stop(self, *, force: bool, timeout: float = 5.0) -> None:
|
|
process = self.process
|
|
try:
|
|
if self._windows_job is not None:
|
|
deadline = time.monotonic() + timeout
|
|
self._windows_job.terminate(timeout)
|
|
if process is not None:
|
|
process.wait(timeout=max(0, deadline - time.monotonic()))
|
|
self._windows_job.close()
|
|
elif process is not None:
|
|
# An AppImage launcher forks the application. Its private
|
|
# group remains ours even after the launcher itself exits.
|
|
try:
|
|
os.killpg(process.pid, signal.SIGKILL if force else signal.SIGTERM)
|
|
except ProcessLookupError:
|
|
pass
|
|
try:
|
|
process.wait(timeout=timeout)
|
|
except subprocess.TimeoutExpired:
|
|
try:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
pass
|
|
process.wait(timeout=timeout)
|
|
finally:
|
|
self._close_log()
|
|
# Preserve ownership if cleanup fails, so a caller can retry and the
|
|
# failure is not replaced by a silent temporary-directory exclusion.
|
|
self.process = None
|
|
self._windows_job = None
|
|
self.client = None
|
|
|
|
def restart(self) -> int:
|
|
self.stop(force=True)
|
|
# An explicit --working-directory intentionally suppresses Nebula's
|
|
# session restore. A cold-restore check must therefore be a plain
|
|
# launch; the process cwd remains the isolated work directory.
|
|
self.start(explicit_working_directory=False)
|
|
return self.startup_ms
|
|
|
|
def _close_log(self) -> None:
|
|
if self._log_handle is not None:
|
|
self._log_handle.close()
|
|
self._log_handle = None
|
|
|
|
def api(
|
|
self,
|
|
method: str,
|
|
params: dict[str, Any] | None = None,
|
|
*,
|
|
timeout: float | None = None,
|
|
allow_error: bool = False,
|
|
) -> Any:
|
|
require(self.client is not None, "Nebula runtime is not connected")
|
|
response = self.client.request(method, params, timeout=timeout, allow_error=allow_error)
|
|
return response if allow_error else response.get("result")
|
|
|
|
def best_effort_api(self, method: str, params: dict[str, Any]) -> None:
|
|
"""Run cleanup without replacing the case's original failure."""
|
|
try:
|
|
self.api(method, params)
|
|
except (OSError, ConformanceError):
|
|
pass
|
|
|
|
def snapshot(self) -> dict[str, Any]:
|
|
snapshot = self.api("runtime.snapshot")
|
|
require(isinstance(snapshot, dict), "runtime.snapshot did not return an object")
|
|
return snapshot
|
|
|
|
def refresh_targets(self, snapshot: dict[str, Any] | None = None) -> None:
|
|
snapshot = snapshot or self.snapshot()
|
|
windows = snapshot.get("windows") or []
|
|
require(windows, "runtime snapshot has no window")
|
|
window = next((item for item in windows if item.get("focused")), windows[0])
|
|
tabs = [item for item in window.get("tabs") or [] if item.get("kind") == "shell"]
|
|
require(tabs, "runtime window has no shell tab")
|
|
active_index = window.get("active_tab", 0)
|
|
tab = next((item for item in tabs if item.get("index") == active_index), tabs[0])
|
|
panes = tab.get("panes") or []
|
|
require(panes, "runtime shell tab has no pane")
|
|
focused = tab.get("focused_pane_id") or window.get("focused_pane_id")
|
|
pane = next((item for item in panes if item.get("id") == focused), panes[0])
|
|
self.window_id = int(window["id"])
|
|
self.pane_id = int(pane["id"])
|
|
|
|
def tab_for_pane(self, snapshot: dict[str, Any], pane_id: int) -> dict[str, Any]:
|
|
for window in snapshot.get("windows") or []:
|
|
for tab in window.get("tabs") or []:
|
|
if any(pane.get("id") == pane_id for pane in tab.get("panes") or []):
|
|
return tab
|
|
raise ConformanceError(f"pane {pane_id} is absent from the runtime snapshot")
|
|
|
|
def read(self, pane_id: int | None = None, lines: int = 120) -> dict[str, Any]:
|
|
result = self.api(
|
|
"pane.read",
|
|
{
|
|
"window_id": self.window_id,
|
|
"pane_id": pane_id or self.pane_id,
|
|
"lines": lines,
|
|
},
|
|
)
|
|
require(isinstance(result, dict), "pane.read did not return an object")
|
|
result["text"] = normalize_text(result.get("text", ""))
|
|
return result
|
|
|
|
def poll(
|
|
self,
|
|
probe: Callable[[], Any],
|
|
predicate: Callable[[Any], bool],
|
|
message: str,
|
|
timeout: float = 10.0,
|
|
) -> Any:
|
|
deadline = time.monotonic() + timeout
|
|
last: Any = None
|
|
while time.monotonic() < deadline:
|
|
last = probe()
|
|
if predicate(last):
|
|
return last
|
|
time.sleep(0.1)
|
|
raise ConformanceError(f"{message}; last observation: {last!r}")
|
|
|
|
def prompt(self, text: str, pane_id: int | None = None, submit: bool = True) -> Any:
|
|
params = {
|
|
"window_id": self.window_id,
|
|
"pane_id": pane_id or self.pane_id,
|
|
"text": text,
|
|
"submit": submit,
|
|
}
|
|
deadline = time.monotonic() + 3.0
|
|
while True:
|
|
response = self.api("pane.prompt", params, allow_error=True)
|
|
if response.get("ok"):
|
|
return response.get("result")
|
|
error = response.get("error") or {}
|
|
if error.get("code") != "input_in_progress" or time.monotonic() >= deadline:
|
|
raise ApiFailure("pane.prompt", error)
|
|
time.sleep(0.05)
|
|
|
|
def paste(self, text: str, pane_id: int | None = None, submit: bool = False) -> Any:
|
|
params = {
|
|
"window_id": self.window_id,
|
|
"pane_id": pane_id or self.pane_id,
|
|
"text": text,
|
|
"submit": submit,
|
|
}
|
|
deadline = time.monotonic() + 10.0
|
|
while True:
|
|
response = self.api("pane.paste", params, allow_error=True)
|
|
if response.get("ok"):
|
|
return response.get("result")
|
|
error = response.get("error") or {}
|
|
retryable = error.get("code") in {"input_in_progress", "unsafe_input_mode"}
|
|
if not retryable or time.monotonic() >= deadline:
|
|
raise ApiFailure("pane.paste", error)
|
|
time.sleep(0.1)
|
|
|
|
def send_key(
|
|
self,
|
|
key: str,
|
|
pane_id: int | None = None,
|
|
*,
|
|
control: bool = False,
|
|
alt: bool = False,
|
|
shift: bool = False,
|
|
) -> Any:
|
|
return self.api(
|
|
"pane.send_key",
|
|
{
|
|
"window_id": self.window_id,
|
|
"pane_id": pane_id or self.pane_id,
|
|
"key": key,
|
|
"modifiers": {"control": control, "alt": alt, "shift": shift},
|
|
"repeat": 1,
|
|
},
|
|
)
|
|
|
|
def wait_for_line(
|
|
self, pattern: re.Pattern[str], pane_id: int | None = None, timeout: float = 10.0
|
|
) -> tuple[re.Match[str], dict[str, Any]]:
|
|
target = pane_id or self.pane_id
|
|
|
|
def find(read: dict[str, Any]) -> re.Match[str] | None:
|
|
# Terminal output can begin on the same physical row as a wrapped
|
|
# prompt or command echo. Callers construct markers so the echoed
|
|
# source does not contain the complete expected token.
|
|
return find_last_line_match(pattern, read["text"])
|
|
|
|
read = self.poll(
|
|
lambda: self.read(target, 160),
|
|
lambda value: find(value) is not None,
|
|
f"pane {target} never produced {pattern.pattern!r}",
|
|
timeout,
|
|
)
|
|
match = find(read)
|
|
assert match is not None
|
|
return match, read
|
|
|
|
def detect_shell(self) -> str:
|
|
candidates: list[str] = []
|
|
try:
|
|
processes = self.api(
|
|
"pane.procs", {"window_id": self.window_id, "pane_id": self.pane_id}
|
|
)
|
|
candidates.extend(
|
|
process.get("executable", "")
|
|
for process in processes.get("processes") or []
|
|
if process.get("depth") == 0
|
|
)
|
|
except ApiFailure:
|
|
pass
|
|
snapshot = self.snapshot()
|
|
tab = self.tab_for_pane(snapshot, self.pane_id)
|
|
pane = next(item for item in tab["panes"] if item["id"] == self.pane_id)
|
|
candidates.extend(
|
|
[pane.get("running_program") or "", pane.get("title") or "", tab.get("label") or ""]
|
|
)
|
|
for candidate in candidates:
|
|
category = shell_category(candidate)
|
|
if category:
|
|
self.shell = category
|
|
return category
|
|
fallback = {"windows": "powershell", "macos": "zsh", "linux": "sh"}
|
|
self.shell = fallback.get(self.platform_family, "sh")
|
|
return self.shell
|
|
|
|
def marker_command(self, prefix: str, suffix: str) -> str:
|
|
shell = self.shell
|
|
if shell in {"powershell", "pwsh"}:
|
|
return f"Write-Output ('{prefix}' + '{suffix}')"
|
|
if shell == "cmd":
|
|
return f"echo {prefix}^{suffix}"
|
|
if shell == "nu":
|
|
return f"print ('{prefix}' + '{suffix}')"
|
|
return f"printf '%s%s\\n' '{prefix}' '{suffix}'"
|
|
|
|
def columns_command(self, marker: str) -> str:
|
|
shell = self.shell
|
|
if shell in {"powershell", "pwsh"}:
|
|
return f"Write-Output ('{marker}' + $Host.UI.RawUI.WindowSize.Width)"
|
|
if shell == "cmd":
|
|
return (
|
|
"powershell -NoProfile -Command \"Write-Output "
|
|
f"('{marker}' + $Host.UI.RawUI.WindowSize.Width)\""
|
|
)
|
|
if shell == "fish":
|
|
return f"printf '{marker}%s\\n' (tput cols)"
|
|
if shell == "nu":
|
|
return f"print ('{marker}' + ((term size).columns | into string))"
|
|
return f"printf '{marker}%s\\n' \"$(tput cols)\""
|
|
|
|
def measure_columns(self, pane_id: int) -> int:
|
|
self._marker_sequence += 1
|
|
marker = f"NEBULA_COLS_{self._marker_sequence}_"
|
|
self.prompt(self.columns_command(marker), pane_id)
|
|
match, _ = self.wait_for_line(re.compile(re.escape(marker) + r"(\d+)"), pane_id)
|
|
return int(match.group(1))
|
|
|
|
def scrollback_command(self, lines: int, marker: str) -> str:
|
|
shell = self.shell
|
|
require(len(marker) >= 2, "completion marker must contain at least two characters")
|
|
# The command is echoed before its output arrives. Only execution may
|
|
# assemble the complete token that wait_for_line uses as completion.
|
|
completion = self.marker_command(marker[:-1], marker[-1])
|
|
if shell in {"powershell", "pwsh"}:
|
|
return f"1..{lines} | ForEach-Object {{ 'NEBULA_SCROLL_' + $_ }}; {completion}"
|
|
if shell == "cmd":
|
|
return f"(for /L %i in (1,1,{lines}) do @echo NEBULA_SCROLL_%i) & {completion}"
|
|
if shell == "fish":
|
|
return f"for i in (seq 1 {lines}); echo NEBULA_SCROLL_$i; end; {completion}"
|
|
if shell == "nu":
|
|
return (
|
|
f"1..{lines} | each {{ |i| print $'NEBULA_SCROLL_($i)' }}; "
|
|
f"{completion}"
|
|
)
|
|
return (
|
|
f"i=1; while [ \"$i\" -le {lines} ]; do printf 'NEBULA_SCROLL_%s\\n' \"$i\"; "
|
|
f"i=$((i + 1)); done; {completion}"
|
|
)
|
|
|
|
def ensure_single_tab(self) -> None:
|
|
snapshot = self.snapshot()
|
|
window = next(item for item in snapshot["windows"] if item["id"] == self.window_id)
|
|
keep = next((tab for tab in window["tabs"] if tab["index"] == 0), None)
|
|
require(keep is not None, "runtime window has no tab at index zero")
|
|
panes = keep.get("panes") or []
|
|
require(panes, "tab zero has no pane to retain")
|
|
keep_pane = keep.get("focused_pane_id") or panes[0]["id"]
|
|
for tab in sorted(window["tabs"], key=lambda item: item["index"], reverse=True):
|
|
if tab["index"] == 0:
|
|
continue
|
|
self.api(
|
|
"tab.close", {"window_id": self.window_id, "tab_index": tab["index"]}
|
|
)
|
|
self.pane_id = int(keep_pane)
|
|
self.api("window.focus", {"window_id": self.window_id, "pane_id": self.pane_id})
|
|
|
|
def wait_for_session(self, predicate: Callable[[dict[str, Any]], bool]) -> dict[str, Any]:
|
|
def read_session() -> dict[str, Any] | None:
|
|
try:
|
|
return json.loads(self.session_file.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return None
|
|
|
|
return self.poll(
|
|
read_session,
|
|
lambda value: isinstance(value, dict) and predicate(value),
|
|
"session.json did not converge",
|
|
timeout=5.0,
|
|
)
|
|
|
|
def close_and_wait(self, timeout: float = 5.0) -> int:
|
|
require(self.process is not None, "Nebula process is not running")
|
|
process = self.process
|
|
started = time.monotonic()
|
|
deadline = started + timeout
|
|
try:
|
|
self.api("window.close", {"window_id": self.window_id})
|
|
except ApiFailure:
|
|
raise
|
|
except (OSError, ConformanceError):
|
|
# A successful close may tear down the listener before its final
|
|
# response is observed. The owned process exit is authoritative.
|
|
pass
|
|
try:
|
|
process.wait(timeout=max(0, deadline - time.monotonic()))
|
|
except subprocess.TimeoutExpired as error:
|
|
raise ConformanceError(f"window.close did not exit within {timeout:.1f}s") from error
|
|
self.stop(force=True, timeout=max(0, deadline - time.monotonic()))
|
|
if process.returncode != 0:
|
|
raise ConformanceError(f"Nebula exited with code {process.returncode}")
|
|
return round((time.monotonic() - started) * 1000)
|
|
|
|
|
|
def platform_family(platform: str) -> str:
|
|
return platform.split("-", 1)[0]
|
|
|
|
|
|
def load_rules(golden_dir: Path) -> dict[str, Any]:
|
|
path = golden_dir / "whitelist.json"
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def stable_flat(report: dict[str, Any], golden_dir: Path) -> dict[str, Any]:
|
|
rules = load_rules(golden_dir)
|
|
flat = filter_flat(flatten(report), list(rules.get("volatile", {})))
|
|
# One golden per family serves every architecture of that platform.
|
|
flat["platform"] = platform_family(str(report["platform"]))
|
|
return flat
|
|
|
|
|
|
def compare_platform_golden(report: dict[str, Any], golden_dir: Path) -> list[str]:
|
|
family = platform_family(str(report["platform"]))
|
|
path = golden_dir / f"{family}.json"
|
|
if not path.is_file():
|
|
return [f"platform golden is missing: {path}"]
|
|
expected = json.loads(path.read_text(encoding="utf-8"))
|
|
return compare_flat(expected, stable_flat(report, golden_dir), family)
|
|
|
|
|
|
def compare_reports(reports: list[dict[str, Any]], golden_dir: Path) -> list[str]:
|
|
require(len(reports) >= 2, "--compare requires at least two reports")
|
|
rules = load_rules(golden_dir)
|
|
ignored = list(rules.get("volatile", {})) + list(rules.get("cross_platform", {}))
|
|
flattened = [filter_flat(flatten(report), ignored) for report in reports]
|
|
baseline = flattened[0]
|
|
baseline_name = str(reports[0].get("platform", "report-1"))
|
|
errors: list[str] = []
|
|
for report, actual in zip(reports[1:], flattened[1:]):
|
|
name = str(report.get("platform", "report"))
|
|
errors.extend(compare_flat(baseline, actual, f"{baseline_name} vs {name}"))
|
|
return errors
|
|
|
|
|
|
def validate_common(report: dict[str, Any], golden_dir: Path) -> list[str]:
|
|
common = json.loads((golden_dir / "common.json").read_text(encoding="utf-8"))
|
|
errors: list[str] = []
|
|
if report.get("schema_version") != common.get("schema_version"):
|
|
errors.append("report schema version does not match common.json")
|
|
flat = flatten(report)
|
|
for path, expected in common.get("required", {}).items():
|
|
actual = flat.get(path)
|
|
if actual != expected:
|
|
errors.append(f"common: {path}: expected {expected!r}, got {actual!r}")
|
|
return errors
|