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")