diff --git a/server/cmd/api/api/window_promotion.go b/server/cmd/api/api/window_promotion.go new file mode 100644 index 00000000..c7df8eef --- /dev/null +++ b/server/cmd/api/api/window_promotion.go @@ -0,0 +1,139 @@ +package api + +import ( + "context" + "log/slog" + "time" + + "github.com/kernel/kernel-images/server/lib/cdpclient" + "github.com/kernel/kernel-images/server/lib/logger" +) + +const ( + // promoteTimeout bounds a single promotion attempt: a fresh browser can + // take a few seconds to open its first page target, which + // SetWindowBoundsMaximized needs to resolve the window. + promoteTimeout = 30 * time.Second + // promoteRetryDelay spaces the retries while the first page target is + // still missing. + promoteRetryDelay = 500 * time.Millisecond +) + +// StartWindowPromotion puts the browser window back into the maximized state +// after every chromium start on the headful image. +// +// The launcher seeds --window-size/--window-position to the X root so an +// un-maximized window restores to a sane, root-sized geometry instead of +// chromium's ~1050x1080 default sliver. Chromium 155 ships the window in +// normal state when --window-size is present — it overrides the maximized +// startup state — and an openbox normal window does not auto-track RANDR +// resizes the way a maximized one does. This promotion runs once per browser +// start (the devtools upstream URL changes on every chromium restart) and +// restores the maximized contract. It is a no-op on headless (Xvfb) images. +// +// SetWindowBoundsMaximized is idempotent and leaves fullscreen windows alone, +// so the promotion is safe on kiosk sessions and on starts that somehow +// already ended up maximized. +func (s *ApiService) StartWindowPromotion() { + if s.detectDisplayMode(context.Background()) != "xorg" { + return + } + ch, unsubscribe := s.upstreamMgr.Subscribe() + go s.promoteWindowsLoop(ch, unsubscribe) +} + +// promoteWindowsLoop promotes the window on each newly discovered upstream +// URL. The initial URL is read through Current before entering the select so +// an upstream that was already discovered before Subscribe ran is not missed; +// the subscription's latest-wins channel covers every later restart. +func (s *ApiService) promoteWindowsLoop(ch <-chan string, unsubscribe func()) { + defer unsubscribe() + seen := "" + for { + if url := s.upstreamMgr.Current(); url != "" && url != seen { + seen = url + s.promoteWindowMaximized(url) + } + select { + case <-s.lifecycleCtx.Done(): + return + case url, ok := <-ch: + if !ok { + return + } + if url == seen { + continue + } + seen = url + s.promoteWindowMaximized(url) + } + } +} + +// promoteWindowMaximized dials the upstream and re-asserts the maximized +// window state, retrying within the deadline. A fresh browser can take a +// few seconds to open its first page target (SetWindowBoundsMaximized needs +// one), the devtools endpoint can refuse an early connection while chromium +// is still binding its socket, and a connected client can drop mid-attempt — +// each is retried, redialing once the transport is gone. Past the deadline +// the attempt is logged and abandoned: a window left in normal state still +// covers the desktop (that is what the seeded bounds are for); only the +// exact-root geometry and the RANDR auto-reflow are at stake. +func (s *ApiService) promoteWindowMaximized(url string) { + log := logger.FromContext(s.lifecycleCtx) + ctx, cancel := context.WithTimeout(s.lifecycleCtx, promoteTimeout) + defer cancel() + + var client *cdpclient.Client + defer func() { + if client != nil { + client.Close() + } + }() + + for { + if client == nil { + c, err := cdpclient.Dial(ctx, url) + if err != nil { + if !s.retryWindowPromotion(ctx, url, err, log) { + return + } + continue + } + client = c + } + + err := client.SetWindowBoundsMaximized(ctx) + if err == nil { + log.Info("window promoted to maximized state") + return + } + // A dropped transport never recovers on this client; clear it + // so the next pass redials. + if client.IsClosed() { + client.Close() + client = nil + } + if !s.retryWindowPromotion(ctx, url, err, log) { + return + } + } +} + +// retryWindowPromotion reports whether another promotion pass is worth +// running, spacing passes with promoteRetryDelay. It returns false once the +// deadline is up or a newer upstream — a chromium restart — has taken over, +// abandoning this promotion; the newer URL gets its own. +func (s *ApiService) retryWindowPromotion(ctx context.Context, url string, cause error, log *slog.Logger) bool { + if s.upstreamMgr.Current() != url || ctx.Err() != nil { + log.Warn("window maximize abandoned", "error", cause) + return false + } + select { + case <-ctx.Done(): + log.Warn("window maximize abandoned", "error", cause) + return false + case <-time.After(promoteRetryDelay): + return true + } +} diff --git a/server/cmd/api/api/window_promotion_test.go b/server/cmd/api/api/window_promotion_test.go new file mode 100644 index 00000000..188d552e --- /dev/null +++ b/server/cmd/api/api/window_promotion_test.go @@ -0,0 +1,192 @@ +package api + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/kernel/kernel-images/server/lib/devtoolsproxy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeDevTools serves the CDP surface promoteWindowMaximized needs. +// failDials HTTP requests are rejected outright, reproducing a devtools +// endpoint that refuses early connections while chromium binds its socket. +// When dropFirstConn is set, the first upgraded websocket reads one command +// and then drops without responding, reproducing a mid-promotion transport +// drop. +type fakeDevTools struct { + mu sync.Mutex + + failDials int + dropFirstConn bool + pageTargetID string + windowState string + + dials int + conns int + getTargets int + getWindow int + setMaximized int +} + +func (f *fakeDevTools) handler(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + f.dials++ + fail := f.dials <= f.failDials + f.mu.Unlock() + if fail { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}}) + if err != nil { + return + } + defer conn.CloseNow() + + f.mu.Lock() + f.conns++ + drop := f.dropFirstConn && f.conns == 1 + f.mu.Unlock() + if drop { + _, _, _ = conn.Read(r.Context()) + return + } + + for { + _, msg, err := conn.Read(r.Context()) + if err != nil { + return + } + var req struct { + ID int64 `json:"id"` + Method string `json:"method"` + } + if err := json.Unmarshal(msg, &req); err != nil { + continue + } + + var result any + switch req.Method { + case "Target.getTargets": + f.mu.Lock() + f.getTargets++ + f.mu.Unlock() + var targets []map[string]string + if f.pageTargetID != "" { + targets = append(targets, map[string]string{ + "targetId": f.pageTargetID, + "type": "page", + "url": "https://example.com/", + }) + } + result = map[string]any{"targetInfos": targets} + case "Browser.getWindowForTarget": + f.mu.Lock() + f.getWindow++ + f.mu.Unlock() + result = map[string]any{ + "windowId": 7, + "bounds": map[string]any{"width": 800, "height": 600, "windowState": f.windowState}, + } + case "Browser.setWindowBounds": + f.mu.Lock() + f.setMaximized++ + f.mu.Unlock() + result = map[string]any{} + default: + result = map[string]any{} + } + + resp := map[string]any{"id": req.ID, "result": result} + b, _ := json.Marshal(resp) + if err := conn.Write(r.Context(), websocket.MessageText, b); err != nil { + return + } + } +} + +func (f *fakeDevTools) start(t *testing.T) string { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(f.handler)) + t.Cleanup(srv.Close) + return "ws" + strings.TrimPrefix(srv.URL, "http") +} + +// newPromotionService builds an ApiService whose upstream manager has +// discovered the given devtools URL, ready for promoteWindowMaximized. +func newPromotionService(t *testing.T, url string) *ApiService { + t.Helper() + logPath := filepath.Join(t.TempDir(), "chromium.log") + line := "2026-11-05T00:00:00 chromium DevTools listening on " + url + "\n" + require.NoError(t, os.WriteFile(logPath, []byte(line), 0o644)) + + um := devtoolsproxy.NewUpstreamManager(logPath, slog.Default()) + ctx, cancel := context.WithCancel(context.Background()) + um.Start(ctx) + t.Cleanup(cancel) + t.Cleanup(um.Stop) + + _, err := um.WaitForInitial(10 * time.Second) + require.NoError(t, err, "upstream manager never discovered the fake url") + require.Equal(t, url, um.Current()) + + return &ApiService{lifecycleCtx: ctx, upstreamMgr: um} +} + +func TestPromoteWindowMaximizedHappyPath(t *testing.T) { + f := &fakeDevTools{pageTargetID: "target-1", windowState: "normal"} + url := f.start(t) + s := newPromotionService(t, url) + + s.promoteWindowMaximized(url) + + f.mu.Lock() + defer f.mu.Unlock() + assert.Equal(t, 1, f.dials, "dial count") + assert.Equal(t, 1, f.setMaximized, "setWindowBounds maximized calls") +} + +func TestPromoteWindowMaximizedRetriesFailedDial(t *testing.T) { + // The devtools endpoint refuses the first connection (chromium still + // binding its socket); the promotion must redial within its deadline + // instead of giving up on the url. + f := &fakeDevTools{failDials: 1, pageTargetID: "target-1", windowState: "normal"} + url := f.start(t) + s := newPromotionService(t, url) + + s.promoteWindowMaximized(url) + + f.mu.Lock() + defer f.mu.Unlock() + assert.Equal(t, 2, f.dials, "the failed dial must be retried") + assert.Equal(t, 1, f.setMaximized, "setWindowBounds maximized calls") +} + +func TestPromoteWindowMaximizedRedialsAfterTransportDrop(t *testing.T) { + // The first connection accepts, reads one command, then drops without + // responding. Commands on that client will never succeed; the promotion + // must redial and complete on a fresh connection. + f := &fakeDevTools{dropFirstConn: true, pageTargetID: "target-1", windowState: "normal"} + url := f.start(t) + s := newPromotionService(t, url) + + s.promoteWindowMaximized(url) + + f.mu.Lock() + defer f.mu.Unlock() + assert.Equal(t, 2, f.dials, "the dropped transport must be redialed") + assert.Equal(t, 1, f.setMaximized, "setWindowBounds maximized calls") +} diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index ee3b0ce2..c5515778 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -237,6 +237,10 @@ func main() { os.Exit(1) } + // Headful: keep the browser window maximized across chromium restarts + // (no-op on the headless image). + apiService.StartWindowPromotion() + // api_call event emission. Off until the telemetry handlers flip it on. r.Use(api.TelemetryHTTPMiddleware(telemetrySession.Publish)) r.Use(api.WebMCPRequestSizeMiddleware) diff --git a/server/cmd/chromium-launcher/main.go b/server/cmd/chromium-launcher/main.go index 4dc1cdb2..d39032f9 100644 --- a/server/cmd/chromium-launcher/main.go +++ b/server/cmd/chromium-launcher/main.go @@ -92,6 +92,11 @@ func main() { final = chromiumflags.TranslateKernelDisableFeatures(final) final = withDefaultPrivateNetworkBypass(final) final = withAcceptedLanguages(final, accepted) + if !*headless { + if w, h, ok := x11.RootSize(":1", 2*time.Second); ok { + final = withSeededRestoreBounds(final, w, h) + } + } // Diagnostics for parity with previous scripts fmt.Printf("BASE_FLAGS: %s\n", baseFlags) @@ -180,6 +185,44 @@ func withDefaultPrivateNetworkBypass(flags []string) []string { return append(flags, defaultPrivateNetworkBypassFlag) } +// withSeededRestoreBounds appends --window-size/--window-position sized to the +// X root when the caller asked for --start-maximized. A window that boots +// maximized never records a normal-state geometry of its own: the first +// transition out of maximized (CDP Browser.setWindowBounds, a viewport resize +// that demotes the window, a WM restore) falls back to chromium's tiny default +// initial bounds, which on a 1920x1080 root is a ~1050x1080 sliver with the +// desktop background showing beside it. Seeding the startup bounds gives the +// window manager a sane geometry to restore to — un-maximizing then lands on a +// root-sized window instead of the default sliver. +// +// Explicit --window-size/--window-position in the merged flags keep their own +// geometry, and --kiosk is left alone: kiosk runs fullscreen and must never +// receive normal-state bounds. +func withSeededRestoreBounds(flags []string, width, height int) []string { + if width <= 0 || height <= 0 { + return flags + } + maximized, explicitGeometry, kiosk := false, false, false + for _, flag := range flags { + switch { + case flag == "--start-maximized": + maximized = true + case flag == "--kiosk": + kiosk = true + case flag == "--window-size" || strings.HasPrefix(flag, "--window-size=") || + flag == "--window-position" || strings.HasPrefix(flag, "--window-position="): + explicitGeometry = true + } + } + if !maximized || kiosk || explicitGeometry { + return flags + } + return append(flags, + fmt.Sprintf("--window-size=%d,%d", width, height), + "--window-position=0,0", + ) +} + // execLookPath helps satisfy syscall.Exec's requirement to pass an absolute path. func execLookPath(file string) (string, error) { if strings.ContainsRune(file, os.PathSeparator) { diff --git a/server/cmd/chromium-launcher/main_test.go b/server/cmd/chromium-launcher/main_test.go index f2854d0d..c4fdee3b 100644 --- a/server/cmd/chromium-launcher/main_test.go +++ b/server/cmd/chromium-launcher/main_test.go @@ -11,6 +11,68 @@ import ( "github.com/kernel/kernel-images/server/lib/chromiumflags" ) +func TestWithSeededRestoreBounds(t *testing.T) { + tests := []struct { + name string + flags []string + width int + height int + want []string + }{ + { + name: "start-maximized gets root-sized bounds", + flags: []string{"--start-maximized", "--test-type"}, + width: 1920, + height: 1080, + want: []string{"--start-maximized", "--test-type", "--window-size=1920,1080", "--window-position=0,0"}, + }, + { + name: "not maximized keeps chromium's own startup geometry", + flags: []string{"--test-type"}, + width: 1920, + height: 1080, + want: []string{"--test-type"}, + }, + { + name: "kiosk is left alone", + flags: []string{"--kiosk", "--start-maximized"}, + width: 1920, + height: 1080, + want: []string{"--kiosk", "--start-maximized"}, + }, + { + name: "explicit window-size keeps its geometry", + flags: []string{"--start-maximized", "--window-size=1440,900"}, + width: 1920, + height: 1080, + want: []string{"--start-maximized", "--window-size=1440,900"}, + }, + { + name: "explicit window-position keeps its geometry", + flags: []string{"--start-maximized", "--window-position=100,50"}, + width: 1920, + height: 1080, + want: []string{"--start-maximized", "--window-position=100,50"}, + }, + { + name: "unreadable root size skips seeding", + flags: []string{"--start-maximized"}, + width: 0, + height: 0, + want: []string{"--start-maximized"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := withSeededRestoreBounds(tt.flags, tt.width, tt.height) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("withSeededRestoreBounds() mismatch:\n got: %#v\nwant: %#v", got, tt.want) + } + }) + } +} + func TestWithDefaultPrivateNetworkBypass(t *testing.T) { tests := []struct { name string diff --git a/server/e2e/e2e_display_resize_window_test.go b/server/e2e/e2e_display_resize_window_test.go index 8009d9b6..06238720 100644 --- a/server/e2e/e2e_display_resize_window_test.go +++ b/server/e2e/e2e_display_resize_window_test.go @@ -448,8 +448,14 @@ func TestDisplayResizeChromiumWindow(t *testing.T) { t.Logf("[%s] baseline cdp=%+v", sc.name, baseCDP) if sc.requireBaselineState != "" { - require.Equal(t, sc.requireBaselineState, baseCDP.WindowState, - "baseline windowState mismatch: chromium did not come up in the expected state — production behaviour relies on this invariant") + // The api promotes a normal-state window to maximized once the + // devtools upstream appears (the launcher's seeded bounds ship + // the window in normal state first), so the state settles + // asynchronously rather than at first map. + waitForWindowState(t, ctx, c, sc.requireBaselineState, sc.name+":baseline-state") + baseCDP, err = getChromiumWindowBoundsCDP(ctx, c) + require.NoError(t, err, "settled baseline cdp window") + t.Logf("[%s] settled baseline cdp=%+v", sc.name, baseCDP) } wantState := baseCDP.WindowState if sc.resizedState != "" { diff --git a/server/e2e/e2e_unmaximize_restore_test.go b/server/e2e/e2e_unmaximize_restore_test.go new file mode 100644 index 00000000..58b045e8 --- /dev/null +++ b/server/e2e/e2e_unmaximize_restore_test.go @@ -0,0 +1,105 @@ +package e2e + +import ( + "context" + "os/exec" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setWindowBoundsCDP sends Browser.setWindowBounds for the first page target's +// window. +func setWindowBoundsCDP(ctx context.Context, c *TestContainer, bounds map[string]any) error { + cdp, err := newCDPClient(ctx, c.CDPURL()) + if err != nil { + return err + } + defer cdp.Close() + win, err := getChromiumWindowBoundsCDP(ctx, c) + if err != nil { + return err + } + _, err = cdp.Call(ctx, "Browser.setWindowBounds", map[string]any{ + "windowId": win.WindowID, + "bounds": bounds, + }, "") + return err +} + +// waitForWindowState polls the CDP window bounds until the window reports the +// wanted state, returning the matching bounds. +func waitForWindowState(t *testing.T, ctx context.Context, c *TestContainer, state string, label string) chromiumWindowBounds { + t.Helper() + deadline := time.Now().Add(20 * time.Second) + for { + win, err := getChromiumWindowBoundsCDP(ctx, c) + if err == nil && win.WindowState == state { + return win + } + if time.Now().After(deadline) { + t.Fatalf("[%s] window never reached state %q: last=%+v err=%v", label, state, win, err) + } + select { + case <-ctx.Done(): + t.Fatalf("[%s] context cancelled waiting for state %q: %v", label, state, ctx.Err()) + case <-time.After(250 * time.Millisecond): + } + } +} + +// TestHeadfulUnmaximizeRestoreGeometry covers the restore geometry of a +// --start-maximized headful window. Chromium boots maximized without any +// normal-state geometry of its own; the launcher seeds --window-size to the X +// root so the first un-maximize (CDP Browser.setWindowBounds from an +// automation, or a demote before a viewport resize) restores to a root-sized +// window instead of chromium's default ~1050x1080 sliver with the desktop +// background showing beside it. +func TestHeadfulUnmaximizeRestoreGeometry(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skipf("docker not available: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + c := NewTestContainer(t, headfulImage) + require.NoError(t, c.Start(ctx, ContainerConfig{Env: map[string]string{ + // Production-equivalent: the platform passes --start-maximized with + // no explicit window geometry. container.go:51 always appends + // --no-sandbox. + "CHROMIUM_FLAGS": "--user-data-dir=/home/kernel/user-data --disable-dev-shm-usage --disable-gpu --start-maximized --remote-allow-origins=*", + }}), "failed to start container") + defer c.Stop(ctx) + require.NoError(t, c.WaitReady(ctx), "api not ready") + require.NoError(t, c.WaitDevTools(ctx), "devtools not ready") + + waitForWindowState(t, ctx, c, "maximized", "baseline") + rootW, rootH, err := getXRootResolution(ctx, c) + require.NoError(t, err, "x root resolution") + t.Logf("x root: %dx%d", rootW, rootH) + + // Demote the window exactly the way customer CDP traffic (or the + // viewport-resize demote) does. + require.NoError(t, setWindowBoundsCDP(ctx, c, map[string]any{"windowState": "normal"}), "setWindowBounds normal") + restored := waitForWindowState(t, ctx, c, "normal", "un-maximized") + + // The restored window must cover the desktop: no desktop background may + // show beside it. The seeded bounds are the root size, and the CSD window + // carries no WM decoration, so demand near-exact coverage of every edge — + // a large window shifted off-screen (e.g. Left=-500) also exposes the + // desktop, so the far edges are checked directly. + assert.GreaterOrEqual(t, restored.Width, rootW-40, "restored width leaves desktop background visible") + assert.GreaterOrEqual(t, restored.Height, rootH-40, "restored height leaves desktop background visible") + assert.LessOrEqual(t, restored.Left, 40, "restored window starts off-screen horizontally") + assert.LessOrEqual(t, restored.Top, 40, "restored window starts off-screen vertically") + assert.GreaterOrEqual(t, restored.Left+restored.Width, rootW-40, "restored window does not reach the right edge; desktop background visible") + assert.GreaterOrEqual(t, restored.Top+restored.Height, rootH-40, "restored window does not reach the bottom edge; desktop background visible") + + // Maximizing again must still work — un-maximize never wedges the + // window in normal state. + require.NoError(t, setWindowBoundsCDP(ctx, c, map[string]any{"windowState": "maximized"}), "setWindowBounds maximized") + waitForWindowState(t, ctx, c, "maximized", "re-maximized") +} diff --git a/server/lib/x11/x11.go b/server/lib/x11/x11.go index b1454d2a..7b5b3682 100644 --- a/server/lib/x11/x11.go +++ b/server/lib/x11/x11.go @@ -5,6 +5,7 @@ import ( "context" "net" "os/exec" + "strconv" "strings" "time" ) @@ -37,6 +38,52 @@ func WaitForDisplay(display string, timeout time.Duration) time.Duration { return time.Since(start) } +// RootSize reads the root window geometry from xwininfo. The headful +// launcher uses it to size chromium's startup bounds to the display (see +// withSeededRestoreBounds in cmd/chromium-launcher). Bounded by timeout so a +// wedged X server can't stall the caller. +func RootSize(display string, timeout time.Duration) (int, int, bool) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + out, err := exec.CommandContext(ctx, "xwininfo", "-display", display, "-root").Output() + if err != nil { + return 0, 0, false + } + return rootSize(string(out)) +} + +// rootSize parses the " Width: W" / " Height: H" lines from xwininfo's +// root-window dump. Both lines must parse for the size to be reported. +func rootSize(out string) (int, int, bool) { + width, height := 0, 0 + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if v, ok := leadingInt(line, "Width:"); ok { + width = v + } + if v, ok := leadingInt(line, "Height:"); ok { + height = v + } + } + if width <= 0 || height <= 0 { + return 0, 0, false + } + return width, height, true +} + +// leadingInt parses the integer after prefix in line, e.g. "Width: 1920". +func leadingInt(line, prefix string) (int, bool) { + rest, ok := strings.CutPrefix(line, prefix) + if !ok { + return 0, false + } + v, err := strconv.Atoi(strings.TrimSpace(rest)) + if err != nil { + return 0, false + } + return v, true +} + // WaitForWindowManager blocks until an EWMH-compliant window manager has // registered with the X server, returning time spent waiting. Chromium // negotiates CSD (no WM titlebar) at window-map time; if the WM isn't up yet, diff --git a/server/lib/x11/x11_test.go b/server/lib/x11/x11_test.go index 4b195db3..9b78bde0 100644 --- a/server/lib/x11/x11_test.go +++ b/server/lib/x11/x11_test.go @@ -42,3 +42,48 @@ func TestSupportingWindowID(t *testing.T) { }) } } + +func TestRootSize(t *testing.T) { + tests := []struct { + name string + out string + width int + height int + ok bool + }{ + { + name: "root dump", + out: `xwininfo: Window id: 0x572 (the root window) (has no name) + + Absolute upper-left X: 0 + Absolute upper-left Y: 0 + Width: 1920 + Height: 1080 + Depth: 24 +`, + width: 1920, + height: 1080, + ok: true, + }, + { + name: "xwininfo failure output", + out: "xwininfo: unable to open display :1\n", + ok: false, + }, + { + // Both dimensions must be present; a partial dump is not a size. + name: "width without height", + out: ` Width: 1920 +`, + ok: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w, h, ok := rootSize(tt.out) + if ok != tt.ok || (ok && (w != tt.width || h != tt.height)) { + t.Errorf("got (%d, %d, %v), want (%d, %d, %v) for %q", w, h, ok, tt.width, tt.height, tt.ok, tt.out) + } + }) + } +}