Brad Fitzpatrick 4cec06b8f2 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=<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 <bradfitz@tailscale.com>
2026-04-29 07:48:26 -07:00

210 lines
5.5 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package vmtest
import (
"embed"
"flag"
"fmt"
"hash/crc32"
"html/template"
"io"
"io/fs"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"github.com/robert-nix/ansihtml"
)
var vmtestWeb = flag.String("vmtest-web", "", "listen address for vmtest web UI (e.g. :0, localhost:0, :8080)")
//go:embed assets/*.html
var templatesSrc embed.FS
//go:embed assets/*.css
var staticAssets embed.FS
var tmpl = sync.OnceValue(func() *template.Template {
d, err := fs.Sub(templatesSrc, "assets")
if err != nil {
panic(fmt.Errorf("getting vmtest web templates subdir: %w", err))
}
return template.Must(template.New("").Funcs(template.FuncMap{
"formatDuration": formatDuration,
"ansi": ansiToHTML,
}).ParseFS(d, "*"))
})
// ansiToHTML converts a string with ANSI escape sequences to HTML with
// inline styles. Returns template.HTML so html/template doesn't double-escape it.
func ansiToHTML(s string) template.HTML {
return template.HTML(ansihtml.ConvertToHTML([]byte(s)))
}
// formatDuration returns a human-readable duration like "1.2s" or "45.3s".
func formatDuration(d time.Duration) string {
if d < time.Second {
return fmt.Sprintf("%dms", d.Milliseconds())
}
return fmt.Sprintf("%.1fs", d.Seconds())
}
// deterministicPort returns a deterministic port in the range [20000, 40000)
// based on the test name, so re-running the same test gets the same URL.
func deterministicPort(testName string) int {
return int(crc32.ChecksumIEEE([]byte(testName)))%20000 + 20000
}
// listenWeb listens on the given address. If the port is 0, it first tries a
// deterministic port based on the test name so re-runs get the same URL.
// Falls back to :0 (OS-assigned) on any listen error.
func (e *Env) listenWeb(addr string) (net.Listener, error) {
host, port, _ := net.SplitHostPort(addr)
if port == "0" {
detPort := deterministicPort(e.t.Name())
detAddr := net.JoinHostPort(host, fmt.Sprintf("%d", detPort))
if ln, err := net.Listen("tcp", detAddr); err == nil {
return ln, nil
}
// Deterministic port busy; fall back to OS-assigned.
}
return net.Listen("tcp", addr)
}
// maybeStartWebServer starts the web UI if --vmtest-web is set.
// Called at the very top of Env.Start(), before compilation or image downloads.
func (e *Env) maybeStartWebServer() {
addr := *vmtestWeb
if addr == "" {
return
}
ln, err := e.listenWeb(addr)
if err != nil {
e.t.Fatalf("vmtest-web listen: %v", err)
}
e.t.Cleanup(func() { ln.Close() })
actualAddr := ln.Addr().(*net.TCPAddr)
host, _, _ := net.SplitHostPort(addr)
if host == "" || host == "0.0.0.0" || host == "::" {
hostname, err := os.Hostname()
if err != nil {
hostname = "localhost"
}
e.t.Logf("Status at http://%s:%d/", hostname, actualAddr.Port)
} else {
e.t.Logf("Status at http://%s/", actualAddr.String())
}
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}
go srv.Serve(ln)
e.t.Cleanup(func() { srv.Close() })
}
func serveStaticAsset(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(name, ".css") {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/css")
f, err := staticAssets.Open(filepath.Join("assets", name))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer f.Close()
io.Copy(w, f)
}
}
func (e *Env) serveIndex(w http.ResponseWriter, r *http.Request) {
type indexData struct {
TestName string
TestStatus *TestStatus
Steps []*Step
Nodes []NodeStatus
}
data := indexData{
TestName: e.t.Name(),
TestStatus: e.testStatus,
Steps: e.Steps(),
}
for _, n := range e.nodes {
data.Nodes = append(data.Nodes, e.getNodeStatus(n.name))
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl().ExecuteTemplate(w, "index.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// 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 {
return
}
defer conn.CloseNow()
wsCtx := conn.CloseRead(r.Context())
sub := e.eventBus.Subscribe()
defer sub.Close()
for {
select {
case <-wsCtx.Done():
return
case <-sub.Done():
return
case ev := <-sub.Events():
msg, err := conn.Writer(r.Context(), websocket.MessageText)
if err != nil {
return
}
if err := tmpl().ExecuteTemplate(msg, "event.html", ev); err != nil {
msg.Close()
return
}
if err := msg.Close(); err != nil {
return
}
}
}
}