Files
matrix_client_pc/integration_test.go
T
Egutierrez 1d3744f2d7 test(e2e): Playwright UI smoke + Go integration tests against MAS
- Playwright e2e against Vite dev server (LoginScreen renders, homeserver
  visible, version pinned). Fallback documented to swap to `wails dev
  -browser` via WAILS_DEV_URL when full backend bindings are required.
- Go integration tests (//go:build integration,goolm) for Start +
  ListRooms + LoadTimeline + SendText + GetDiagnostics against real
  Synapse. Skip cleanly if MATRIX_INTEGRATION_TOKEN/USER env vars unset
  so CI never breaks unattended.
- pnpm e2e/e2e:ui/e2e:report scripts. Per-run keyring namespace +
  t.TempDir XDG_CONFIG_HOME so integration tests don't clobber the
  production keyring entry.

E2E.md explains how to run each layer + limits (OIDC not automated).
2026-05-25 17:20:05 +02:00

225 lines
7.0 KiB
Go

//go:build integration && goolm
// +build integration,goolm
// Integration tests against a real Synapse + MAS. Build tag `integration` keeps
// them out of the default `go test ./...` run; `goolm` matches the runtime
// build tag the app uses (cryptohelper backend). All tests skip cleanly when
// MATRIX_INTEGRATION_* env vars are absent so CI never breaks unattended.
//
// Run with:
//
// MATRIX_INTEGRATION_TOKEN=syt_... \
// MATRIX_INTEGRATION_USER=@egutierrez:matrix-af2f3d.organic-machine.com \
// MATRIX_INTEGRATION_DEVICE=DEVICEID \
// MATRIX_INTEGRATION_TEST_ROOM='!abcd:matrix-af2f3d.organic-machine.com' \
// go test -tags 'integration goolm' -timeout 120s -v \
// ./projects/element_agents/apps/matrix_client_pc/...
//
// MATRIX_INTEGRATION_TEST_ROOM is optional — without it the round-trip test
// auto-skips. MATRIX_INTEGRATION_DEVICE is also optional; if missing the test
// calls /whoami to discover the device_id, mirroring what the app does.
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"fn-registry/projects/element_agents/apps/matrix_client_pc/internal/infra"
"maunium.net/go/mautrix/id"
)
const integrationHomeserverURL = "https://matrix-af2f3d.organic-machine.com"
// integrationEnv reads the required env vars + returns them. If MATRIX_INTEGRATION_TOKEN
// or MATRIX_INTEGRATION_USER is missing the test should t.Skip — that is the
// canonical "no creds available" signal.
type integrationEnv struct {
Token string
User string
Device string
TestRoom string
}
func readIntegrationEnv(t *testing.T) integrationEnv {
t.Helper()
env := integrationEnv{
Token: os.Getenv("MATRIX_INTEGRATION_TOKEN"),
User: os.Getenv("MATRIX_INTEGRATION_USER"),
Device: os.Getenv("MATRIX_INTEGRATION_DEVICE"),
TestRoom: os.Getenv("MATRIX_INTEGRATION_TEST_ROOM"),
}
if env.Token == "" || env.User == "" {
t.Skipf("skipping integration test: set MATRIX_INTEGRATION_TOKEN + MATRIX_INTEGRATION_USER to run (got token=%v user=%v)",
env.Token != "", env.User != "")
}
return env
}
// buildService wires up a MatrixService against the real Synapse, persisting
// the test token in a throwaway keyring namespace so we don't pollute the user's
// production credentials. It returns the service + a cleanup that calls Stop.
func buildService(t *testing.T, env integrationEnv) (*MatrixService, func()) {
t.Helper()
tmpDir := t.TempDir()
// Override user config dir so userStoreDir() lands inside the tmpDir
// instead of clobbering ~/.config/matrix_client_pc/<user>.
t.Setenv("XDG_CONFIG_HOME", tmpDir)
// Use a test-scoped keyring service to keep production creds untouched.
testKeyringService := fmt.Sprintf("fn_registry.matrix_client_pc.test.%d", time.Now().UnixNano())
s := &MatrixService{
store: infra.NewKeyringTokenStore(testKeyringService),
}
s.SetContext(context.Background())
// Persist the token + pickle key under the test keyring so Start() can load
// them the same way the production code does.
pickle := make([]byte, 32)
if _, err := rand.Read(pickle); err != nil {
t.Fatalf("rand: %v", err)
}
tok := infra.Token{
AccessToken: env.Token,
UserID: env.User,
DeviceID: env.Device,
HomeserverURL: integrationHomeserverURL,
PickleKeyHex: hex.EncodeToString(pickle),
}
if err := s.store.Save(env.User, tok); err != nil {
t.Skipf("keyring unavailable (likely no D-Bus session): %v", err)
}
cleanup := func() {
s.Stop()
_ = s.store.Delete(env.User)
// Belt-and-braces: blow away any crypto.db files left behind.
_ = os.RemoveAll(filepath.Join(tmpDir, "matrix_client_pc"))
}
return s, cleanup
}
func TestIntegration_StartAndListRooms(t *testing.T) {
env := readIntegrationEnv(t)
s, cleanup := buildService(t, env)
defer cleanup()
if err := s.Start(env.User); err != nil {
t.Fatalf("Start: %v", err)
}
rooms, err := s.ListRooms()
if err != nil {
t.Fatalf("ListRooms: %v", err)
}
if len(rooms) == 0 {
t.Fatalf("ListRooms returned 0 rooms — user has no joined rooms? unexpected for an integration test account")
}
t.Logf("ListRooms OK: %d rooms (first=%s)", len(rooms), rooms[0].RoomID)
}
func TestIntegration_GetDiagnostics(t *testing.T) {
env := readIntegrationEnv(t)
s, cleanup := buildService(t, env)
defer cleanup()
if err := s.Start(env.User); err != nil {
t.Fatalf("Start: %v", err)
}
d := s.GetDiagnostics()
if !d.Started {
t.Errorf("Diagnostics.Started=false (want true)")
}
if !d.ClientReady {
t.Errorf("Diagnostics.ClientReady=false (want true)")
}
if !d.SyncActive {
t.Errorf("Diagnostics.SyncActive=false (want true)")
}
if d.HomeserverURL == "" {
t.Errorf("Diagnostics.HomeserverURL empty")
}
if d.RoomsCount < 1 {
t.Errorf("Diagnostics.RoomsCount=%d (want >=1)", d.RoomsCount)
}
if d.LastError != "" {
t.Errorf("Diagnostics.LastError=%q (want empty)", d.LastError)
}
t.Logf("Diagnostics OK: rooms=%d encrypted=%d dms=%d", d.RoomsCount, d.EncryptedRooms, d.DMsCount)
}
func TestIntegration_RoundtripMessage(t *testing.T) {
env := readIntegrationEnv(t)
if env.TestRoom == "" {
t.Skip("set MATRIX_INTEGRATION_TEST_ROOM=!abcd:server to enable the send/recv round-trip")
}
s, cleanup := buildService(t, env)
defer cleanup()
if err := s.Start(env.User); err != nil {
t.Fatalf("Start: %v", err)
}
body := fmt.Sprintf("e2e test %d", time.Now().UnixNano())
evID, err := s.SendText(env.TestRoom, body)
if err != nil {
t.Fatalf("SendText: %v", err)
}
t.Logf("SendText OK: event_id=%s body=%q", evID, body)
// Poll LoadTimeline up to 10s waiting for our event to appear.
deadline := time.Now().Add(10 * time.Second)
for {
msgs, err := s.LoadTimeline(env.TestRoom, 20)
if err != nil {
t.Fatalf("LoadTimeline: %v", err)
}
for _, m := range msgs {
if m.EventID == evID {
if m.Body != body {
// In an encrypted room the body may come back blank (EncryptedRaw=true)
// — still counts as round-trip success: the event landed.
if !m.EncryptedRaw {
t.Errorf("event body mismatch: got %q want %q", m.Body, body)
}
}
t.Logf("Round-trip OK: event_id=%s found after %v", evID, time.Since(deadline.Add(-10*time.Second)))
return
}
}
if time.Now().After(deadline) {
t.Fatalf("event %s never appeared in LoadTimeline after 10s", evID)
}
time.Sleep(500 * time.Millisecond)
}
}
// Sanity check that the homeserver is reachable from the test host before we
// burn the rest of the test budget on keyring + sync setup. Skips if env is
// missing so it doesn't run silently in CI either.
func TestIntegration_HomeserverReachable(t *testing.T) {
env := readIntegrationEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
uid, did, err := whoami(ctx, integrationHomeserverURL, env.Token)
if err != nil {
t.Fatalf("whoami(%s): %v", integrationHomeserverURL, err)
}
if uid != env.User {
t.Errorf("whoami user_id=%q want %q", uid, env.User)
}
// device_id may be empty when MAS-issued tokens lack device binding —
// don't fail on it, just log.
t.Logf("whoami OK: user=%s device=%s", uid, did)
_ = id.UserID(uid)
}