From 6a91d206a558cb581fdedee19ca717d129dcef01 Mon Sep 17 00:00:00 2001 From: ulziibay-kernel <253135130+ulziibay-kernel@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:38:02 +0000 Subject: [PATCH 1/2] Seed headful window restore bounds and keep the window maximized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A --start-maximized headful window never records a normal-state geometry of its own. The first transition out of maximized — a CDP Browser.setWindowBounds demote, the viewport-resize restore step, a WM un-maximize — falls back to chromium's default initial bounds (~1050x1080 at +10+0 on a 1920x1080 root), leaving most of the desktop background visible beside a portrait sliver of browser. Two changes fix that: - chromium-launcher seeds --window-size/--window-position from the live X root when the caller passes --start-maximized, so the window manager and chromium both carry a sane geometry to restore to. Chromium 155 ships a --window-size window in normal state (it overrides the maximized startup state), so: - the api promotes the window back to maximized once per chromium start, driven by devtools upstream discovery (the upstream URL changes on every restart). SetWindowBoundsMaximized is idempotent and leaves fullscreen kiosk windows alone. Verified on a built image: boot and chromium restart end maximized as before; un-maximizing now restores to a root-sized window at +0+0 instead of the 1050px sliver; kiosk and headless paths are untouched. --- server/cmd/api/api/window_promotion.go | 108 +++++++++++++++++++ server/cmd/api/main.go | 4 + server/cmd/chromium-launcher/main.go | 43 ++++++++ server/cmd/chromium-launcher/main_test.go | 62 +++++++++++ server/e2e/e2e_display_resize_window_test.go | 10 +- server/e2e/e2e_unmaximize_restore_test.go | 101 +++++++++++++++++ server/lib/x11/x11.go | 47 ++++++++ server/lib/x11/x11_test.go | 45 ++++++++ 8 files changed, 418 insertions(+), 2 deletions(-) create mode 100644 server/cmd/api/api/window_promotion.go create mode 100644 server/e2e/e2e_unmaximize_restore_test.go diff --git a/server/cmd/api/api/window_promotion.go b/server/cmd/api/api/window_promotion.go new file mode 100644 index 00000000..dfda0089 --- /dev/null +++ b/server/cmd/api/api/window_promotion.go @@ -0,0 +1,108 @@ +package api + +import ( + "context" + "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 while the browser has no page target yet. Failures +// are 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() + + client, err := cdpclient.Dial(ctx, url) + if err != nil { + log.Warn("window maximize: dial devtools upstream failed", "error", err) + return + } + defer client.Close() + + for { + err := client.SetWindowBoundsMaximized(ctx) + if err == nil { + log.Info("window promoted to maximized state") + return + } + // A restart landed while this attempt was running; the newer + // upstream gets its own promotion. + if s.upstreamMgr.Current() != url || ctx.Err() != nil { + log.Warn("window maximize abandoned", "error", err) + return + } + select { + case <-ctx.Done(): + log.Warn("window maximize abandoned", "error", err) + return + case <-time.After(promoteRetryDelay): + } + } +} 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..ebffb61e --- /dev/null +++ b/server/e2e/e2e_unmaximize_restore_test.go @@ -0,0 +1,101 @@ +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. + 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") + + // 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) + } + }) + } +} From 07b8d7d6a4510a16ee8d0e1ffa08a63e40e3ca55 Mon Sep 17 00:00:00 2001 From: ulziibay-kernel <253135130+ulziibay-kernel@users.noreply.github.com> Date: Fri, 6 Nov 2026 02:45:58 +0000 Subject: [PATCH 2/2] Address review: retry promotion dials, redial after transport drop promoteWindowMaximized retried only the maximize command: a failed initial dial abandoned the url for good, and after a mid-attempt transport drop the loop kept issuing commands on the closed client. Dial failures and dropped transports now retry within the deadline, redialing on the next pass. New unit tests cover both paths against a fake devtools endpoint; they fail on the previous code. The unmaximize-restore e2e assertions bounded width/height from below and left/top from above, so a large window shifted off-screen (e.g. Left=-500) passed while exposing the desktop. Assert the window also reaches the right and bottom root edges. --- server/cmd/api/api/window_promotion.go | 69 +++++-- server/cmd/api/api/window_promotion_test.go | 192 ++++++++++++++++++++ server/e2e/e2e_unmaximize_restore_test.go | 6 +- 3 files changed, 247 insertions(+), 20 deletions(-) create mode 100644 server/cmd/api/api/window_promotion_test.go diff --git a/server/cmd/api/api/window_promotion.go b/server/cmd/api/api/window_promotion.go index dfda0089..c7df8eef 100644 --- a/server/cmd/api/api/window_promotion.go +++ b/server/cmd/api/api/window_promotion.go @@ -2,6 +2,7 @@ package api import ( "context" + "log/slog" "time" "github.com/kernel/kernel-images/server/lib/cdpclient" @@ -70,39 +71,69 @@ func (s *ApiService) promoteWindowsLoop(ch <-chan string, unsubscribe func()) { } // promoteWindowMaximized dials the upstream and re-asserts the maximized -// window state, retrying while the browser has no page target yet. Failures -// are 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. +// 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() - client, err := cdpclient.Dial(ctx, url) - if err != nil { - log.Warn("window maximize: dial devtools upstream failed", "error", err) - return - } - defer client.Close() + 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 restart landed while this attempt was running; the newer - // upstream gets its own promotion. - if s.upstreamMgr.Current() != url || ctx.Err() != nil { - log.Warn("window maximize abandoned", "error", err) - return + // A dropped transport never recovers on this client; clear it + // so the next pass redials. + if client.IsClosed() { + client.Close() + client = nil } - select { - case <-ctx.Done(): - log.Warn("window maximize abandoned", "error", err) + if !s.retryWindowPromotion(ctx, url, err, log) { return - case <-time.After(promoteRetryDelay): } } } + +// 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/e2e/e2e_unmaximize_restore_test.go b/server/e2e/e2e_unmaximize_restore_test.go index ebffb61e..58b045e8 100644 --- a/server/e2e/e2e_unmaximize_restore_test.go +++ b/server/e2e/e2e_unmaximize_restore_test.go @@ -88,11 +88,15 @@ func TestHeadfulUnmaximizeRestoreGeometry(t *testing.T) { // 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. + // 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.