From 4cec06b8f2c65c832ed71a902481c80dbe80695c Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Tue, 28 Apr 2026 12:46:39 -0700 Subject: [PATCH] tstest/natlab/vmtest: add macOS VM screenshot streaming to web UI When --vmtest-web is set, Host.app is launched with --screenshot-port 0 to start a localhost HTTP server that captures the VZVirtualMachineView display. The Go test harness parses the SCREENSHOT_PORT= line from stdout, then polls every 2 seconds for JPEG thumbnails and pushes them over WebSocket to the web dashboard. Clicking a screenshot thumbnail opens a full-resolution image proxied through the web UI's /screenshot/{node} endpoint. Screenshot events are excluded from the EventBus history (they're large and only the latest matters, stored in NodeStatus.Screenshot). Updates #13038 Change-Id: I9bc67ddd1cc72948b33c555d4be3d8db06a41f6d Signed-off-by: Brad Fitzpatrick --- tstest/natlab/vmtest/assets/event.html | 6 ++ tstest/natlab/vmtest/assets/index.html | 1 + tstest/natlab/vmtest/assets/style.css | 14 ++++ tstest/natlab/vmtest/tailmac.go | 89 +++++++++++++++++++++++-- tstest/natlab/vmtest/vmstatus.go | 21 ++++-- tstest/natlab/vmtest/vmtest.go | 28 ++++++++ tstest/natlab/vmtest/web.go | 20 ++++++ tstest/tailmac/Makefile | 4 +- tstest/tailmac/Swift/Host/HostCli.swift | 6 -- 9 files changed, 168 insertions(+), 21 deletions(-) diff --git a/tstest/natlab/vmtest/assets/event.html b/tstest/natlab/vmtest/assets/event.html index 70d8e69cf..a5f596673 100644 --- a/tstest/natlab/vmtest/assets/event.html +++ b/tstest/natlab/vmtest/assets/event.html @@ -35,5 +35,11 @@ {{.Detail}} {{end}} +{{if eq .Type "screenshot"}} +
+{{end}} + +{{if ne .Type "screenshot"}}
{{.Time.Format "15:04:05.000"}} {{if .NodeName}}[{{.NodeName}}] {{end}}{{.Message}}{{if .Detail}} {{.Detail}}{{end}}
+{{end}} diff --git a/tstest/natlab/vmtest/assets/index.html b/tstest/natlab/vmtest/assets/index.html index 093cb6a38..044efffee 100644 --- a/tstest/natlab/vmtest/assets/index.html +++ b/tstest/natlab/vmtest/assets/index.html @@ -47,6 +47,7 @@ {{end}} +
{{if $node.Screenshot}}{{end}}
{{range $node.Console}}{{ansi .}} {{end}}
diff --git a/tstest/natlab/vmtest/assets/style.css b/tstest/natlab/vmtest/assets/style.css index fff676dd5..5970598b8 100644 --- a/tstest/natlab/vmtest/assets/style.css +++ b/tstest/natlab/vmtest/assets/style.css @@ -120,6 +120,20 @@ h2 { color: #4af; } +/* VM display screenshot */ +.screenshot:empty { display: none; } +.screenshot { + margin-bottom: 4px; +} +.screenshot img { + width: 100%; + height: auto; + display: block; + border-radius: 4px; + border: 1px solid #222; + cursor: pointer; +} + /* Console output */ .console { background: #0a0a0a; diff --git a/tstest/natlab/vmtest/tailmac.go b/tstest/natlab/vmtest/tailmac.go index 44f5648ec..0ad47a9af 100644 --- a/tstest/natlab/vmtest/tailmac.go +++ b/tstest/natlab/vmtest/tailmac.go @@ -4,9 +4,13 @@ package vmtest import ( + "bufio" + "context" "encoding/base64" "encoding/json" "fmt" + "io" + "net/http" "os" "os/exec" "path/filepath" @@ -168,13 +172,16 @@ func (e *Env) startTailMacVM(n *Node) error { } e.t.Cleanup(func() { os.RemoveAll(cloneDir) }) - // Launch Host.app in headless mode with disconnected NIC, - // then hot-swap to the vnet dgram socket after boot. hostBin := filepath.Join(e.tailmacDir, "Host.app", "Contents", "MacOS", "Host") args := []string{ "run", "--id", testID, "--headless", } + wantScreenshots := *vmtestWeb != "" + if wantScreenshots { + args = append(args, "--screenshot-port", "0") + } + logPath := filepath.Join(e.tempDir, n.name+"-tailmac.log") logFile, err := os.Create(logPath) if err != nil { @@ -182,11 +189,22 @@ func (e *Env) startTailMacVM(n *Node) error { } cmd := exec.Command(hostBin, args...) - // NSUnbufferedIO forces Swift/Foundation to unbuffer stdout so we can - // see output in the log file as it happens. cmd.Env = append(os.Environ(), "NSUnbufferedIO=YES") - cmd.Stdout = logFile - cmd.Stderr = logFile + + // If screenshots are enabled, we need to parse stdout for the + // SCREENSHOT_PORT= line, while also logging everything to file. + var stdoutPipe io.ReadCloser + if wantScreenshots { + stdoutPipe, err = cmd.StdoutPipe() + if err != nil { + logFile.Close() + return fmt.Errorf("stdout pipe: %w", err) + } + cmd.Stderr = logFile + } else { + cmd.Stdout = logFile + cmd.Stderr = logFile + } devNull, err := os.Open(os.DevNull) if err != nil { logFile.Close() @@ -201,6 +219,36 @@ func (e *Env) startTailMacVM(n *Node) error { } e.t.Logf("[%s] launched tailmac (pid %d), log: %s", n.name, cmd.Process.Pid, logPath) + // Parse screenshot port from stdout and start polling goroutine. + if wantScreenshots { + screenshotPortCh := make(chan int, 1) + go func() { + scanner := bufio.NewScanner(stdoutPipe) + for scanner.Scan() { + line := scanner.Text() + fmt.Fprintln(logFile, line) // tee to log file + if port := 0; strings.HasPrefix(line, "SCREENSHOT_PORT=") { + fmt.Sscanf(line, "SCREENSHOT_PORT=%d", &port) + if port > 0 { + screenshotPortCh <- port + } + } + } + }() + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + select { + case port := <-screenshotPortCh: + e.t.Logf("[%s] screenshot server on port %d", n.name, port) + e.setNodeScreenshotPort(n.name, port) + e.tailScreenshots(n.name, port) + case <-ctx.Done(): + e.t.Logf("[%s] screenshot port not received", n.name) + } + }() + } + clientSock := fmt.Sprintf("/tmp/qemu-dgram-%s.sock", testID) e.t.Cleanup(func() { @@ -234,3 +282,32 @@ func (e *Env) startTailMacVM(n *Node) error { return nil } + +// tailScreenshots polls the Host.app screenshot HTTP server every 2 seconds +// and publishes each screenshot as a base64 data URI to the web UI. +func (e *Env) tailScreenshots(name string, port int) { + url := fmt.Sprintf("http://127.0.0.1:%d/screenshot", port) + client := &http.Client{Timeout: 5 * time.Second} + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for range ticker.C { + resp, err := client.Get(url) + if err != nil { + continue + } + data, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != 200 || len(data) == 0 { + continue + } + b64 := base64.StdEncoding.EncodeToString(data) + dataURI := "data:image/jpeg;base64," + b64 + e.setNodeScreenshot(name, dataURI) + e.eventBus.Publish(VMEvent{ + NodeName: name, + Type: EventScreenshot, + Message: b64, + }) + } +} diff --git a/tstest/natlab/vmtest/vmstatus.go b/tstest/natlab/vmtest/vmstatus.go index 4b855e721..38269a780 100644 --- a/tstest/natlab/vmtest/vmstatus.go +++ b/tstest/natlab/vmtest/vmstatus.go @@ -139,6 +139,7 @@ const ( EventDHCPOffer EventType = "dhcp_offer" // server sent DHCP Offer EventDHCPRequest EventType = "dhcp_request" // VM sent DHCP Request EventDHCPAck EventType = "dhcp_ack" // server sent DHCP Ack + EventScreenshot EventType = "screenshot" // VM display screenshot (JPEG, base64) EventTailscale EventType = "tailscale" // Tailscale status change EventTestStatus EventType = "test_status" // test Running/Passed/Failed ) @@ -212,12 +213,14 @@ type NICStatus struct { // NodeStatus tracks the current DHCP and Tailscale state of a VM node // for rendering on the web UI's initial page load. type NodeStatus struct { - Name string - OS string - NICs []NICStatus // one per NIC; index matches NIC index - JoinsTailnet bool // whether this node runs Tailscale - Tailscale string // "--", "Up (100.64.0.1)", etc. - Console []string // recent console output lines (ring buffer) + Name string + OS string + NICs []NICStatus // one per NIC; index matches NIC index + JoinsTailnet bool // whether this node runs Tailscale + Tailscale string // "--", "Up (100.64.0.1)", etc. + Console []string // recent console output lines (ring buffer) + Screenshot string // latest screenshot as data URI, or "" + ScreenshotPort int // Host.app screenshot server port, or 0 } const maxConsoleLines = 200 @@ -249,7 +252,11 @@ func (b *EventBus) Publish(ev VMEvent) { } b.mu.Lock() defer b.mu.Unlock() - b.history = append(b.history, ev) + // Don't store screenshots in history — they're large and only the + // latest one matters (stored in NodeStatus.Screenshot instead). + if ev.Type != EventScreenshot { + b.history = append(b.history, ev) + } if len(b.history) > eventBusHistorySize { // Trim old events. copy(b.history, b.history[len(b.history)-eventBusHistorySize:]) diff --git a/tstest/natlab/vmtest/vmtest.go b/tstest/natlab/vmtest/vmtest.go index 5690fbb75..3eb42c2de 100644 --- a/tstest/natlab/vmtest/vmtest.go +++ b/tstest/natlab/vmtest/vmtest.go @@ -1198,6 +1198,34 @@ func (e *Env) HTTPGet(from *Node, targetURL string) string { return "" } +// setNodeScreenshot stores the latest screenshot data URI for a node. +func (e *Env) setNodeScreenshot(name, dataURI string) { + e.nodeStatusMu.Lock() + if ns := e.nodeStatus[name]; ns != nil { + ns.Screenshot = dataURI + } + e.nodeStatusMu.Unlock() +} + +// setNodeScreenshotPort stores the Host.app screenshot server port for a node. +func (e *Env) setNodeScreenshotPort(name string, port int) { + e.nodeStatusMu.Lock() + if ns := e.nodeStatus[name]; ns != nil { + ns.ScreenshotPort = port + } + e.nodeStatusMu.Unlock() +} + +// nodeScreenshotPort returns the Host.app screenshot server port for a node, or 0. +func (e *Env) nodeScreenshotPort(name string) int { + e.nodeStatusMu.Lock() + defer e.nodeStatusMu.Unlock() + if ns := e.nodeStatus[name]; ns != nil { + return ns.ScreenshotPort + } + return 0 +} + // Agent returns the node's TTA agent client, or nil if NoAgent is set. func (n *Node) Agent() *vnet.NodeAgentClient { return n.agent diff --git a/tstest/natlab/vmtest/web.go b/tstest/natlab/vmtest/web.go index ddd929752..d512740e6 100644 --- a/tstest/natlab/vmtest/web.go +++ b/tstest/natlab/vmtest/web.go @@ -108,6 +108,7 @@ func (e *Env) maybeStartWebServer() { mux := http.NewServeMux() mux.HandleFunc("GET /", e.serveIndex) mux.HandleFunc("GET /ws", e.serveWebSocket) + mux.HandleFunc("GET /screenshot/{node}", e.serveScreenshot) mux.HandleFunc("GET /style.css", serveStaticAsset("style.css")) srv := &http.Server{Handler: mux} @@ -155,6 +156,25 @@ func (e *Env) serveIndex(w http.ResponseWriter, r *http.Request) { } } +// serveScreenshot proxies a full-resolution screenshot from the Host.app +// screenshot server. Returns raw JPEG with no HTML wrapper. +func (e *Env) serveScreenshot(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("node") + port := e.nodeScreenshotPort(name) + if port == 0 { + http.Error(w, "no screenshot server for node", http.StatusNotFound) + return + } + resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/screenshot?full=1", port)) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + w.Header().Set("Content-Type", "image/jpeg") + io.Copy(w, resp.Body) +} + func (e *Env) serveWebSocket(w http.ResponseWriter, r *http.Request) { conn, err := websocket.Accept(w, r, nil) if err != nil { diff --git a/tstest/tailmac/Makefile b/tstest/tailmac/Makefile index b87e44ed1..303f72c1f 100644 --- a/tstest/tailmac/Makefile +++ b/tstest/tailmac/Makefile @@ -5,12 +5,12 @@ endif .PHONY: tailmac tailmac: - xcodebuild -scheme tailmac -destination 'platform=macOS,arch=arm64' -derivedDataPath build -configuration Release build | $(XCPRETTIFIER) + set -o pipefail && xcodebuild -scheme tailmac -destination 'platform=macOS,arch=arm64' -derivedDataPath build -configuration Release build | $(XCPRETTIFIER) cp -r ./build/Build/Products/Release/tailmac ./bin/tailmac .PHONY: host host: - xcodebuild -scheme host -destination 'platform=macOS,arch=arm64' -derivedDataPath build -configuration Release build | $(XCPRETTIFIER) + set -o pipefail && xcodebuild -scheme host -destination 'platform=macOS,arch=arm64' -derivedDataPath build -configuration Release build | $(XCPRETTIFIER) cp -r ./build/Build/Products/Release/Host.app ./bin/Host.app .PHONY: clean diff --git a/tstest/tailmac/Swift/Host/HostCli.swift b/tstest/tailmac/Swift/Host/HostCli.swift index 008d60507..578722520 100644 --- a/tstest/tailmac/Swift/Host/HostCli.swift +++ b/tstest/tailmac/Swift/Host/HostCli.swift @@ -202,12 +202,6 @@ class ScreenshotHTTPServer: NSObject { let n = read(fd, &buf, buf.count) let requestLine = n > 0 ? String(bytes: buf[.. — send a key event to the VM. - if requestLine.contains("/keypress") { - handleKeypress(fd, requestLine) - return - } - // Route: GET /screenshot — capture the VM display. let wantFull = requestLine.contains("full=1")