diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e3499c2..49955fbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -313,6 +313,120 @@ jobs: echo "pgtest: checked $(printf '%s\n' "$pkgs" | wc -l) real-Postgres packages against the service database" exit "$rc" + - name: Multi-window gtk3 e2e gate + # The ONE CI lane that compiles + runs the native app (Wails v3, + # go/cmd/compass-app) — the multi-window smoke gate (design record + # compass-multi-window §M4). It is a DEDICATED step, not a moon-battery + # task, for the same reason the pgtest suite above is: it is build-tagged + # (unix,gtk3) so `moon`'s untagged `go test ./...` never compiles it, and + # it needs an environment the bare moon gate has no business realizing — + # a virtual framebuffer (the real GTK3/WebKit shell opens windows) and + # the heavy WebKitGTK cgo-link closure. The per-PR moon battery stays + # GTK-free; this step realizes the closure out of band via + # tools/toolchain/gtk-e2e-env.nix (pinned to the SAME devenv.lock nixpkgs + # the dev shell links against), so a dev box and CI run byte-for-byte the + # same libraries. + # + # AFFECTED-GUARDED to match the one-job affected posture: on a PR it runs + # only when go/cmd/compass-app/ actually changed (the native app is the + # only tree whose behavior it gates), so a Go/UI/docs PR never pays the + # WebKitGTK realization. push-to-main and the nightly schedule always run + # it (the full-sweep backstop), matching the moon affected/full split. + if: success() + working-directory: go + env: + CGO_ENABLED: '1' + run: | + # Decide whether this event must run the gate. + run_it=1 + if [ "$GITHUB_EVENT_NAME" = pull_request ]; then + base="origin/${GITHUB_BASE_REF}" + # fetch-depth:0 (job checkout) gives the base ref for the diff. Fail + # LOUD if it does not resolve rather than swallowing the error as + # "not affected": an unresolvable base (e.g. a stacked PR's base + # churning under a pull_request.edited re-point) must red the gate, + # never skip it green. No `|| true` on the diff for the same reason — + # under `bash -e` a genuine diff failure aborts the step. + if ! git -C .. rev-parse --verify --quiet "$base" >/dev/null; then + echo "::error::gtk3 e2e affected gate: base ref $base does not resolve — cannot compute the affected set" + exit 1 + fi + changed=$(git -C .. diff --name-only "$base"...HEAD -- go/cmd/compass-app/) + if [ -z "$changed" ]; then + run_it=0 + fi + fi + if [ "$run_it" = 0 ]; then + echo "gtk3 e2e: PR does not touch go/cmd/compass-app/ — skipping (affected gate)" + exit 0 + fi + + # Realize the out-of-band gtk3 env, ALL parts via `nix build` (not + # `nix eval`): xvfb-run (+ its Xvfb) + pkg-config on PATH, the + # WebKitGTK pkg-config closure, and the nixpkgs C toolchain. `nix + # build` is what actually builds each into this runner's store; a `nix + # eval --raw` of a search-path string strips nix's store context, so + # the `.pc` paths it prints would name derivations nix never built and + # the cgo link fails "No package 'glib-2.0' found". All pinned to + # devenv.lock's nixpkgs via the helper. + helper=../tools/toolchain/gtk-e2e-env.nix + binenv=$(nix build --no-link --print-out-paths -f "$helper" bin) + pcenv=$(nix build --no-link --print-out-paths -f "$helper" pkgConfig) + # cc.out, not the bare `cc`: the cc-wrapper is multi-output (out, man, + # info) and `nix build --print-out-paths` of the derivation prints + # ALL of them, so a bare `cc` captures two lines and CC resolves to + # the `-man` dir. The `.out` selector pins the one output with bin/cc. + ccenv=$(nix build --no-link --print-out-paths -f "$helper" cc.out) + PKG_CONFIG_PATH="$pcenv/lib/pkgconfig:$pcenv/share/pkgconfig" + export PKG_CONFIG_PATH + export PATH="$binenv/bin:$PATH" + # Link the cgo objects with the nixpkgs toolchain, NOT the runner's + # /usr/bin/gcc: WebKitGTK from this nixpkgs is built against glibc + # 2.42, so the system gcc's older libc fails to link + # libwebkit2gtk-4.1.so ("undefined reference to + # `__inet_pton_chk@GLIBC_2.42'"). The cc-wrapper carries the matching + # glibc and rpaths + interp-stamps the store libs, so the test binary + # is self-contained on a non-NixOS runner. This is the toolchain a dev + # box links with implicitly, so CI now matches it byte-for-byte. + export CC="$ccenv/bin/cc" + export CXX="$ccenv/bin/c++" + + # xvfb-run -a auto-picks a free display. The Xvfb SERVER log goes to a + # real file (-e), NOT /dev/stdout: with the step's own `>… 2>&1` + # redirect already pointing fd 1 at the go-test log, `-e /dev/stdout` + # makes xvfb-run reopen that fd for the server log and the fd juggling + # swallows the child's stdout — the go-test output never reaches the + # file, so the PASS-line guard below fires on an empty log even though + # the e2e passed. A dedicated server-log file avoids the collision and + # is replayed only on failure, where a bring-up error is what matters. + # Capture-then-replay + explicit exit so a FAIL is never masked (same + # discipline as the pgtest step above). + # + # No -race here (the pgtest step uses it): this is a cgo+GTK smoke lane + # where -race is slow and noisy, and the driver-goroutine / InvokeSync + # handoffs it would cover are already exercised under -race by the + # untagged unit suite. The omission is deliberate. + rc=0 + xvfb-run -a -e /tmp/xvfb-server.log \ + go test -tags 'unix gtk3' -run 'E2E' -count=1 -v -timeout 10m \ + ./cmd/compass-app/ >/tmp/gtk3-e2e.log 2>&1 || rc=$? + cat /tmp/gtk3-e2e.log + # Guard against a silent no-op: the gate must have PASSED the e2e, not + # merely started it. Assert the PASS line, not `=== RUN`: `go test -v` + # prints `=== RUN ` BEFORE the body runs, so a `t.Skip` (no + # display came up, or any future skip condition) still emits RUN — a + # RUN-grep would read a skipped, assertion-free run as green. The PASS + # grep fails on skip, on no-run, and on a build-tag regression that + # drops the test; a genuine FAIL is still caught by `exit "$rc"`. This + # is the polarity the pgtest step uses (it fails on the SKIP message). + if ! grep -q -- '--- PASS: TestMultiWindowCloseCancelsOnlyClosingWindowE2E' /tmp/gtk3-e2e.log; then + echo "::error::gtk3 e2e gate did not PASS TestMultiWindowCloseCancelsOnlyClosingWindowE2E — it skipped (no display came up), the build tag broke, or the test did not run" + echo "--- Xvfb server log (bring-up diagnostics) ---" + cat /tmp/xvfb-server.log 2>/dev/null || echo "(no Xvfb server log)" + exit 1 + fi + exit "$rc" + - name: Retrospect # Collapse the single job's flat task fan-out into per-task sections in # the Actions log, so a failure is one expand instead of a scroll. Reads diff --git a/devenv.nix b/devenv.nix index c3a9ed95..0043d582 100644 --- a/devenv.nix +++ b/devenv.nix @@ -123,7 +123,16 @@ in toolchainTools.node toolchainTools.moon goToolchain - ]; + ] + # xvfb-run: a virtual framebuffer wrapper for the multi-window gtk3 e2e + # (go/cmd/compass-app, design record compass-multi-window §M4) — the real + # GTK3/WebKit shell needs a display to open windows, and dev boxes + CI + # runners are headless. Linux-only (X11) and appended OUTSIDE the parsed + # `packages` literal: the toolchain-parity gate resolves every bare attr in + # that literal on macOS too, where xvfb-run does not exist. The package is + # self-contained (its wrapper prepends its own Xvfb to PATH). CI's dedicated + # e2e step gets it via tools/toolchain/gtk-e2e-env.nix, not this shell. + ++ lib.optionals pkgs.stdenv.isLinux [ pkgs.xvfb-run ]; env = { } # The Compass native app (Wails v3, go/cmd/compass-app) links the Linux @@ -141,29 +150,18 @@ in # Linux-only, and set in `env` rather than `packages`: on macOS the app links # the system WebKit framework, so the closure is Linux's alone; and keeping it # out of the parsed `packages` list means the heavy WebKitGTK closure is never - # realized by the toolchain-parity gate on a CI runner — it is a dev-box build - # input, and compass CI does not compile the native app. + # realized by the toolchain-parity gate on a CI runner. The per-PR moon gate + # still never compiles the native app; the one CI lane that does — the + # multi-window gtk3 e2e (compass-multi-window §M4) — is a dedicated, + # affected-guarded ci.yml step that realizes this same closure out of band via + # tools/toolchain/gtk-e2e-env.nix, so the moon battery stays GTK-free. // lib.optionalAttrs pkgs.stdenv.isLinux { PKG_CONFIG_PATH = let - pcClosure = lib.closePropagation ( - with pkgs; - [ - dbus - openssl - glib - gtk3 - webkitgtk_4_1 - libsoup_3 - cairo - pango - gdk-pixbuf - atk - harfbuzz - librsvg - gobject-introspection - ] - ); + # The frozen SEA-1172 GTK3/WebKitGTK set, imported from the shared + # module so the dev shell and the gtk3 e2e CI helper + # (tools/toolchain/gtk-e2e-env.nix) resolve one closure and cannot drift. + pcClosure = lib.closePropagation (import ./tools/toolchain/gtk-closure.nix pkgs); in lib.concatStringsSep ":" [ (lib.makeSearchPathOutput "dev" "lib/pkgconfig" pcClosure) diff --git a/go/cmd/compass-app/multiwindow_e2e_helpers_test.go b/go/cmd/compass-app/multiwindow_e2e_helpers_test.go new file mode 100644 index 00000000..c9055bda --- /dev/null +++ b/go/cmd/compass-app/multiwindow_e2e_helpers_test.go @@ -0,0 +1,51 @@ +//go:build unix && gtk3 + +// gtk3-only e2e helpers: the real-window plumbing the multi-window smoke gate +// needs and the unix (non-gtk3) unit suite has no equivalent for, because they +// touch application.Window and application.WindowKey — the very symbols the +// windowDispatcher seam exists to keep out of the unix build. +package main + +import ( + "context" + "testing" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +// getWindow looks the named window up on the real app, on the main thread (the +// window manager is mutated from the GTK loop, so a read races it otherwise). +func getWindow(name string) (application.Window, bool) { + return application.InvokeSyncWithResultAndOther(func() (application.Window, bool) { + return e2eApp.Window.GetByName(name) + }) +} + +// mustGetWindow returns the named window or fails: after newAppWindow created +// it, the shell must have registered it under that name. +func mustGetWindow(t *testing.T, name string) application.Window { + t.Helper() + win, ok := getWindow(name) + if !ok { + t.Fatalf("window %q not found after newAppWindow", name) + } + return win +} + +// launchWindowedCall registers and runs one in-flight bridge call attributed to +// win through the PRODUCTION capture path: register reads the window off a ctx +// carrying application.WindowKey (exactly what Wails hands a bound method, +// messageprocessor_call.go:136) via the real windowFromContext, so the stored +// inflightCall.window is a real wailsWindowDispatcher{win} — the value winA's +// close handler must match to sweep this call. The returned channel closes when +// run returns (terminal frame or cancel), the event gate for the sweep. +func launchWindowedCall(svc *bridgeService, win application.Window, requestID string) <-chan struct{} { + ctx := context.WithValue(context.Background(), application.WindowKey, win) + callCtx, call := svc.register(ctx, requestID) + done := make(chan struct{}) + go func() { + defer close(done) + svc.run(callCtx, call, rpcRequest{RequestID: requestID, Path: "/e2e/" + requestID}) + }() + return done +} diff --git a/go/cmd/compass-app/multiwindow_e2e_test.go b/go/cmd/compass-app/multiwindow_e2e_test.go new file mode 100644 index 00000000..c7d393ed --- /dev/null +++ b/go/cmd/compass-app/multiwindow_e2e_test.go @@ -0,0 +1,144 @@ +//go:build unix && gtk3 + +// The Compass multi-window smoke gate (design record §M4). This is the ONE test +// that drives the REAL GTK3/WebKit Wails shell — the window factory, real +// application.Window handles, and a real WindowClosing event — rather than the +// windowDispatcher seam's fakes. It covers exactly the gap the unix (non-gtk3) +// unit suite cannot reach and the M3/M3b reviews flagged as architecturally +// forced: windowFromContext reads application.WindowKey (nil in the nogtk3 +// build, so the unit test injects call.window by hand), and the newAppWindow +// close handler fires cancelWindow only on a real WindowClosing. +// +// It runs only under a display: TestMain hosts app.Run() (the blocking GTK loop +// MUST own the main goroutine / OS thread 0) and the Test* bodies run on a +// driver goroutine gated on ApplicationStarted. With no display the gtk3 tests +// self-skip, so a container-less sandbox skips rather than fails — the posture +// of the podman e2e legs. The ci.yml "Multi-window gtk3 e2e gate" step wraps the +// run in xvfb-run so CI and the dev box both have a framebuffer. +// +// No sleeps gate an assertion: window creation and the count after a close are +// observed on the main thread via GetByName (the Window methods' own InvokeSync +// makes the mutate-then-read ordering deterministic), and the bridge-service +// inflight map is read under its mutex (assertInflight/assertNotInflight, the +// unix-tagged unit helpers, reused here). +package main + +import ( + "net/http" + "os" + "testing" + + "github.com/sealedsecurity/compass/go/internal/bridge" + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" +) + +// e2eApp is the single real Wails application the gate drives; it is created and +// run by TestMain and consumed by the Test* bodies once e2eReady closes. +var ( + e2eApp *application.App + e2eReady = make(chan struct{}) + e2eExitCode int +) + +// hasDisplay reports whether a display (real or Xvfb) is available. Without one +// the GTK loop cannot start, so the gate self-skips. +func hasDisplay() bool { + return os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != "" +} + +func TestMain(m *testing.M) { + if !hasDisplay() { + // No framebuffer: run the (self-skipping) tests without a GTK loop. + os.Exit(m.Run()) + } + + e2eApp = application.New(application.Options{Name: "compass-multiwindow-e2e"}) + e2eApp.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { + close(e2eReady) + }) + + go func() { + <-e2eReady + e2eExitCode = m.Run() + e2eApp.Quit() // unblocks e2eApp.Run() on the main thread + }() + + if err := e2eApp.Run(); err != nil { + // A failed GTK bring-up is a hard gate failure, not a skip. + os.Stderr.WriteString("compass-app multi-window e2e: app.Run: " + err.Error() + "\n") + os.Exit(1) + } + os.Exit(e2eExitCode) +} + +// TestMultiWindowCloseCancelsOnlyClosingWindowE2E is the leak-gate proof through +// the REAL shell: two real Bridge windows each own an in-flight bridge call +// (registered through the real windowFromContext, which reads the window off +// application.WindowKey — the path the nogtk3 unit test cannot exercise); +// closing one window fires its real WindowClosing handler, which calls +// cancelWindow and sweeps ONLY that window's call, while the other window's call +// stays in-flight. This is the daemon-observable half of the §M4 checklist +// (a closed window's bridge subscription terminates) driven end to end. +func TestMultiWindowCloseCancelsOnlyClosingWindowE2E(t *testing.T) { + if !hasDisplay() { + t.Skip("no DISPLAY/WAYLAND_DISPLAY; run under xvfb-run (the ci.yml gtk3 e2e gate, or `xvfb-run go test -tags 'unix gtk3'` locally)") + } + + // A stub bridge target whose handler blocks until released, so each + // registered call stays in-flight until the test drains it. stubServer is + // the unix-tagged unit helper (real h2c UDS), reused here. + release := make(chan struct{}) + socket := stubServer(t, func(w http.ResponseWriter, _ *http.Request) { + <-release + w.Header().Set("Content-Type", "application/grpc-web+proto") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + t.Cleanup(func() { close(release) }) + + svc := newBridgeService(bridge.NewPump(bridge.NewUnixTarget(socket)), e2eApp.Event, nil, nil) + + // Create two REAL Bridge windows through the production factory, which + // attaches the real WindowClosing → cancelWindow handler to each. + const nameA = "e2e-winA" + const nameB = "e2e-winB" + // Distinct titles per window: the two windows are meant to be + // distinguishable, and it keeps the factory's title parameter genuinely + // varied rather than a package-wide constant. + application.InvokeSync(func() { + newAppWindow(e2eApp, svc, nameA, "Compass — "+nameA, "") + newAppWindow(e2eApp, svc, nameB, "Compass — "+nameB, "") + }) + + winA := mustGetWindow(t, nameA) + winB := mustGetWindow(t, nameB) + + // Register an in-flight call for each window through the REAL capture path: + const idA = "e2e-req-A" + const idB = "e2e-req-B" + doneA := launchWindowedCall(svc, winA, idA) + launchWindowedCall(svc, winB, idB) + + assertInflight(t, svc, idA) + assertInflight(t, svc, idB) + + // Close winA for real: its WindowClosing handler runs cancelWindow(winA). + application.InvokeSync(func() { winA.Close() }) + + // The closing window's call is swept: cancelWindow cancels its context, the + // pump returns, run's finish drops the entry and closes doneA. waitDone gates + // on that (no sleep); winB's call is untouched and stays in-flight. + waitDone(t, doneA) + assertNotInflight(t, svc, idA) + assertInflight(t, svc, idB) + + // The other window is untouched — still live in the shell (the "close one + // leaves the other live" checklist row). winA's removal from the window + // manager is GTK-async, so it is NOT asserted here — a GetByName race would + // need a sleep the no-sleep rule forbids; that winA closed is already proven + // above by its call being swept (assertNotInflight), the contract §M4 gates. + if _, ok := getWindow(nameB); !ok { + t.Errorf("winB gone after closing winA") + } +} diff --git a/tools/toolchain/gtk-closure.nix b/tools/toolchain/gtk-closure.nix new file mode 100644 index 00000000..b766a98d --- /dev/null +++ b/tools/toolchain/gtk-closure.nix @@ -0,0 +1,32 @@ +# The GTK3/WebKitGTK package set the Compass native app (Wails v3, +# go/cmd/compass-app) links through cgo on Linux — the frozen SEA-1172 closure +# (docs/designs/platform/ci-toolchain-shared-defs.md). ONE definition, imported +# by two consumers so they cannot drift: +# +# - devenv.nix's `env` block builds PKG_CONFIG_PATH over `lib.closePropagation` +# of this set for the dev shell (and a local gtk3 build/test); +# - tools/toolchain/gtk-e2e-env.nix realizes the same closure on a CI runner +# for the multi-window gtk3 e2e gate (design record compass-multi-window +# §M4), the ONE CI lane that compiles + runs the native app. +# +# Kept as a bare name list (not the resolved derivations) so a consumer applies +# it against whichever pinned `pkgs` it already resolves — the dev shell's, or +# the e2e helper's devenv.lock-pinned nixpkgs — without this module taking a +# nixpkgs of its own. +pkgs: +with pkgs; +[ + dbus + openssl + glib + gtk3 + webkitgtk_4_1 + libsoup_3 + cairo + pango + gdk-pixbuf + atk + harfbuzz + librsvg + gobject-introspection +] diff --git a/tools/toolchain/gtk-e2e-env.nix b/tools/toolchain/gtk-e2e-env.nix new file mode 100644 index 00000000..e68269cc --- /dev/null +++ b/tools/toolchain/gtk-e2e-env.nix @@ -0,0 +1,82 @@ +# The out-of-band environment for the multi-window gtk3 e2e gate (design record +# compass-multi-window §M4) — the ONE CI lane that compiles + runs the Compass +# native app (Wails v3, go/cmd/compass-app). The per-PR moon battery is +# deliberately GTK-free (devenv.nix's `env` block owns that rationale), so the +# dedicated ci.yml e2e step realizes this closure itself rather than teaching +# the shared gate-tools.nix / toolchain-parity machinery about the heavy +# WebKitGTK set. +# +# Pins nixpkgs to the SAME devenv.lock revision the dev shell and gate-tools.nix +# resolve, so the e2e runner links byte-for-byte the libraries a dev box does. +# The GTK3/WebKitGTK package set is imported from tools/toolchain/gtk-closure.nix +# — the one definition devenv.nix's PKG_CONFIG_PATH is also built from — so the +# two cannot drift. +# +# Three outputs the ci.yml step consumes, ALL realized with `nix build` (never +# `nix eval`, which strips the store context that would build the closure): +# +# bin a buildEnv whose bin/ holds xvfb-run (and its bundled Xvfb) + +# pkg-config; the step prepends it to PATH so `xvfb-run` wraps +# `go test` and pkg-config is on hand for the cgo link. +# pkgConfig a buildEnv over the closure; the step sets PKG_CONFIG_PATH to its +# lib/pkgconfig + share/pkgconfig subdirs so the cgo link resolves +# the gtk+-3.0 / webkit2gtk-4.1 `.pc` files (and the libs they +# reference) — the same two-subdir shape devenv.nix uses. +# cc the nixpkgs cc-wrapper; the step sets CC/CXX to its bin/cc,bin/c++ +# so the cgo link uses the glibc WebKitGTK was built against (not +# the runner's system gcc) and the test binary is self-contained. +let + lock = builtins.fromJSON (builtins.readFile ../../devenv.lock); + node = lock.nodes.nixpkgs.locked; + nixpkgsSrc = builtins.fetchTarball { + url = "https://github.com/${node.owner}/${node.repo}/archive/${node.rev}.tar.gz"; + sha256 = node.narHash; + }; + pkgs = import nixpkgsSrc { }; + lib = pkgs.lib; + + pcClosure = lib.closePropagation (import ./gtk-closure.nix pkgs); +in +{ + bin = pkgs.buildEnv { + name = "compass-gtk-e2e-bin"; + # xvfb-run (with its bundled Xvfb) + pkg-config, from the same pinned + # nixpkgs, so the step needs nothing off the ambient CI PATH to link + run. + paths = [ pkgs.xvfb-run pkgs.pkg-config ]; + }; + + # A REALIZABLE pkg-config tree over the closure — ci.yml `nix build`s this so + # the WebKitGTK closure is actually built into the runner's store. The dev + # shell can set a bare PKG_CONFIG_PATH *string* because nix's string-context + # pulls those derivations into the shell's input closure and realizes them + # when direnv builds the environment; `nix eval --raw` STRIPS that context, so + # a plain search-path string would name `.pc` files nix never built and the + # cgo link fails "No package 'glib-2.0' found". buildEnv makes the closure a + # first-class build output whose `.pc` files (dev output) and the libraries + # they reference (out output) are realized as its dependencies. Same pcClosure + # the dev shell's PKG_CONFIG_PATH is built from, so the two cannot drift; same + # lib/pkgconfig + share/pkgconfig two-subdir shape (searched by the step). + pkgConfig = pkgs.buildEnv { + name = "compass-gtk-e2e-pkgconfig"; + paths = pcClosure; + extraOutputsToInstall = [ "dev" ]; + # Curated, uniquely-named `.pc` set; keep a stray duplicate from aborting + # the merge (a search-path string would just pick the first anyway). + ignoreCollisions = true; + }; + + # The nixpkgs C toolchain (cc-wrapper) the step points CC/CXX at for the cgo + # link — NOT the runner's /usr/bin/gcc. WebKitGTK from this pinned nixpkgs is + # built against glibc 2.42; a stock GitHub runner's system gcc links against + # its older system glibc, so `ld` fails "undefined reference to + # `__inet_pton_chk@GLIBC_2.42'" against libwebkit2gtk-4.1.so. The cc-wrapper + # bundles the matching glibc and, via its binutils ld-wrapper, stamps the + # binary's ELF interpreter to that glibc's ld-linux and rpaths the store lib + # dirs — so the test binary is self-contained (right libc + every GTK/WebKit + # .so at runtime) regardless of the runner's system libc. A dev box links this + # same toolchain implicitly (its `cc` IS the nix wrapper), so CI now matches. + # Referenced by its own store path, never merged into a buildEnv: a cc-wrapper + # resolves its siblings through nix-support/ files + relative symlinks a + # symlink-merge would break. + cc = pkgs.stdenv.cc; +}