Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` 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
Expand Down
40 changes: 19 additions & 21 deletions devenv.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
51 changes: 51 additions & 0 deletions go/cmd/compass-app/multiwindow_e2e_helpers_test.go
Original file line number Diff line number Diff line change
@@ -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
}
144 changes: 144 additions & 0 deletions go/cmd/compass-app/multiwindow_e2e_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading