From db8daa7b3d3cc937cd5ef77395c9077b8842e5c4 Mon Sep 17 00:00:00 2001 From: Patrick Wyatt Date: Sun, 28 Sep 2025 11:29:51 -0700 Subject: [PATCH] Add NoGraphics field to tart get command --- .gitignore | 3 + Sources/tart/Commands/Get.swift | 103 +++++++++++++++++++++++++- integration-tests/test_no_graphics.py | 62 ++++++++++++++++ 3 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 integration-tests/test_no_graphics.py diff --git a/.gitignore b/.gitignore index 21d2e7b..95e002a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist/ # mkdocs-material site + +# Python cache files +*.pyc diff --git a/Sources/tart/Commands/Get.swift b/Sources/tart/Commands/Get.swift index 7ffada6..eb960f7 100644 --- a/Sources/tart/Commands/Get.swift +++ b/Sources/tart/Commands/Get.swift @@ -1,5 +1,7 @@ import ArgumentParser import Foundation +import CoreGraphics +import Darwin fileprivate struct VMInfo: Encodable { let OS: OS @@ -11,6 +13,29 @@ fileprivate struct VMInfo: Encodable { let Display: String let Running: Bool let State: String + let NoGraphics: Bool? + + enum CodingKeys: String, CodingKey { + case OS, CPU, Memory, Disk, DiskFormat, Size, Display, Running, State, NoGraphics + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(OS, forKey: .OS) + try container.encode(CPU, forKey: .CPU) + try container.encode(Memory, forKey: .Memory) + try container.encode(Disk, forKey: .Disk) + try container.encode(DiskFormat, forKey: .DiskFormat) + try container.encode(Size, forKey: .Size) + try container.encode(Display, forKey: .Display) + try container.encode(Running, forKey: .Running) + try container.encode(State, forKey: .State) + if let noGraphics = NoGraphics { + try container.encode(noGraphics, forKey: .NoGraphics) + } else { + try container.encodeNil(forKey: .NoGraphics) + } + } } struct Get: AsyncParsableCommand { @@ -27,7 +52,83 @@ struct Get: AsyncParsableCommand { let vmConfig = try VMConfig(fromURL: vmDir.configURL) let memorySizeInMb = vmConfig.memorySize / 1024 / 1024 - let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), DiskFormat: vmConfig.diskFormat.rawValue, Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue) + // Check if VM is running without graphics (no windows) + var noGraphics: Bool? = nil + if try vmDir.running() { + let lock = try vmDir.lock() + let pid = try lock.pid() + if pid > 0 { + noGraphics = try hasNoWindows(pid: pid) + } + } + + let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), DiskFormat: vmConfig.diskFormat.rawValue, Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue, NoGraphics: noGraphics) print(format.renderSingle(info)) } + + private func hasNoWindows(pid: pid_t) throws -> Bool { + // Check if the process and its children have any windows using Core Graphics Window Server + // This is more reliable than checking command-line arguments since there are + // multiple ways a VM might run without graphics (--no-graphics flag, CI environment, etc.) + + // Get all PIDs to check (parent + children) + var pidsToCheck = [pid] + pidsToCheck.append(contentsOf: try getChildProcesses(of: pid)) + + // Get all window information from the window server + guard let windowList = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { + // If we can't get window info, assume no graphics + return true + } + + // Check if any window belongs to our process or its children + for windowInfo in windowList { + if let windowPID = windowInfo[kCGWindowOwnerPID as String] as? Int32, + pidsToCheck.contains(windowPID) { + // Found a window for this process tree, so it has graphics + return false + } + } + + // No windows found for this process tree + return true + } + + private func getChildProcesses(of parentPID: pid_t) throws -> [pid_t] { + var children: [pid_t] = [] + + // Use sysctl to get process information + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_ALL, 0] + var size: size_t = 0 + + // Get size needed + if sysctl(&mib, 4, nil, &size, nil, 0) != 0 { + // If we can't get process list, return empty array + return children + } + + // Allocate memory and get process list + let count = size / MemoryLayout.size + var procs = Array(repeating: kinfo_proc(), count: count) + + if sysctl(&mib, 4, &procs, &size, nil, 0) != 0 { + // If we can't get process list, return empty array + return children + } + + // Find direct children of the given parent PID + for proc in procs { + let ppid = proc.kp_eproc.e_ppid + let pid = proc.kp_proc.p_pid + if ppid == parentPID && pid > 0 { + children.append(pid) + // Recursively get children of children + if let grandchildren = try? getChildProcesses(of: pid) { + children.append(contentsOf: grandchildren) + } + } + } + + return children + } } diff --git a/integration-tests/test_no_graphics.py b/integration-tests/test_no_graphics.py new file mode 100644 index 0000000..6894144 --- /dev/null +++ b/integration-tests/test_no_graphics.py @@ -0,0 +1,62 @@ +import json +import os +import subprocess +import time +import uuid + +import pytest + + +def check_json_format(tart, vm_name, running, noGraphics, expected): + stdout, _ = tart.run(["get", vm_name, "--format", "json"]) + vm_info = json.loads(stdout) + actual_running = vm_info["Running"] + assert actual_running is running, f"Running is {actual_running}, expected {running}" + assert vm_info.get("NoGraphics") is noGraphics, expected + +def check_text_format(tart, vm_name, running, noGraphics, expected): + stdout, _ = tart.run(["get", vm_name, "--format", "text"]) + assert "NoGraphics" in stdout, "NoGraphics field should be present in text output" + + # Text format is tab-separated with headers in first line + lines = stdout.strip().split('\n') + if len(lines) >= 2: + headers = lines[0].split() + values = lines[1].split() + info_dict = dict(zip(headers, values)) + else: + info_dict = {} + + # Convert "stopped" to false for Running field + actual_running = info_dict.get("State") != "stopped" + assert actual_running == running, f"Expected Running={running}, got State={actual_running}" + assert info_dict.get("NoGraphics") == noGraphics, expected + +@pytest.mark.skipif(os.environ.get("CI") == "true", reason="Normal graphics mode doesn't work in CI") +def test_no_graphics_normal(tart): + _test_no_graphics_impl(tart, [], False) + +def test_no_graphics_disabled(tart): + _test_no_graphics_impl(tart, ["--no-graphics"], True) + +def _test_no_graphics_impl(tart, graphics_mode, expected_no_graphics): + # Create a test VM (use Linux VM for faster tests) + vm_name = f"integration-test-no-graphics-{uuid.uuid4()}" + tart.run(["pull", "ghcr.io/cirruslabs/debian:latest"]) + tart.run(["clone", "ghcr.io/cirruslabs/debian:latest", vm_name]) + + # Test 1: VM not running - NoGraphics should be None in json format + check_json_format(tart, vm_name, False, None, "NoGraphics should be None when VM is not running") + + # Test 2: VM not running - NoGraphics should be NULL in text format + check_text_format(tart, vm_name, False, "NULL", "NoGraphics should be NULL when VM is not running") + + # Run VM with specified graphics mode + tart_run_process = tart.run_async(["run"] + graphics_mode + [vm_name]) + time.sleep(3) # Give VM time to start + + # Test 3: VM running - NoGraphics should be XX in json format + check_json_format(tart, vm_name, True, expected_no_graphics, f"NoGraphics should be {expected_no_graphics} (JSON) when VM is running") + + # Test 4: VM running - NoGraphics should be XX in text format + check_text_format(tart, vm_name, True, str(expected_no_graphics).lower(), f"NoGraphics should be {expected_no_graphics} (TEXT) when VM is running")