Skip to content

Commit

Permalink
Combine rust/js tests into one using the Client interface
Browse files Browse the repository at this point in the history
Rejig a bunch of stuff around as well.
  • Loading branch information
kegsay committed Nov 3, 2023
1 parent 79d8e94 commit 0dd4f1a
Show file tree
Hide file tree
Showing 10 changed files with 381 additions and 503 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
js-sdk/node_modules
js-sdk/dist
tests/dist
internal/api/dist
20 changes: 15 additions & 5 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,35 @@ import (
"github.com/matrix-org/complement/client"
)

type ClientType string

var (
ClientTypeRust ClientType = "rust"
ClientTypeJS ClientType = "js"
)

// Client represents a generic crypto client.
// It is an abstraction to allow tests to interact with JS and FFI bindings in an agnostic way.
type Client interface {
// Init is called prior to any test execution. Do any setup code here e.g run a browser.
// Call close() when the test terminates to clean up resources.
// TODO: will this be too slow if we spin up a browser for each test?
Init(t *testing.T) (close func())
// Close is called to clean up resources.
// Specifically, we need to shut off existing browsers and any FFI bindings.
// If we get callbacks/events after this point, tests may panic if the callbacks
// log messages.
Close(t *testing.T)
// StartSyncing to begin syncing from sync v2 / sliding sync.
// Tests should call stopSyncing() at the end of the test.
StartSyncing(t *testing.T) (stopSyncing func())
// IsRoomEncrypted returns true if the room is encrypted. May return an error e.g if you
// provide a bogus room ID.
IsRoomEncrypted(roomID string) (bool, error)
IsRoomEncrypted(t *testing.T, roomID string) (bool, error)
// SendMessage sends the given text as an m.room.message with msgtype:m.text into the given
// room. Returns the event ID of the sent event.
SendMessage(t *testing.T, roomID, text string)
// Wait until an event with the given body is seen. Not all impls expose event IDs
// hence needing to use body as a proxy.
WaitUntilEventInRoom(t *testing.T, roomID, wantBody string) Waiter

Type() ClientType
}

// ClientCreationOpts are generic opts to use when creating crypto clients.
Expand Down
236 changes: 222 additions & 14 deletions internal/api/js.go
Original file line number Diff line number Diff line change
@@ -1,40 +1,248 @@
package api

import (
"context"
"embed"
"encoding/json"
"fmt"
"io/fs"
"log"
"net"
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"

"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
"github.com/matrix-org/complement-crypto/internal/chrome"
"github.com/matrix-org/complement/must"
)

type JSClient struct{}
const CONSOLE_LOG_CONTROL_STRING = "CC:" // for "complement-crypto"

//go:embed dist

Check failure on line 27 in internal/api/js.go

View workflow job for this annotation

GitHub Actions / JS tests

pattern dist: no matching files found
var jsSDKDistDirectory embed.FS

func NewJSClient(opts ClientCreationOpts) Client {
return nil
type JSClient struct {
ctx context.Context
cancel func()
baseJSURL string
listeners map[int32]func(roomID, text string)
listenerID atomic.Int32
userID string
}

// Init is called prior to any test execution. Do any setup code here e.g run a browser.
// Call close() when the test terminates to clean up resources.
// TODO: will this be too slow if we spin up a browser for each test?
func (c *JSClient) Init(t *testing.T) (close func()) {
return
func NewJSClient(t *testing.T, opts ClientCreationOpts) (Client, error) {
// start a headless chrome
ctx, cancel := chromedp.NewContext(context.Background(), chromedp.WithBrowserOption(
chromedp.WithBrowserLogf(log.Printf), chromedp.WithBrowserErrorf(log.Printf), //chromedp.WithBrowserDebugf(log.Printf),
))
jsc := &JSClient{
listeners: make(map[int32]func(roomID, text string)),
userID: opts.UserID,
}
// Listen for console logs for debugging AND to communicate live updates
chromedp.ListenTarget(ctx, func(ev interface{}) {
switch ev := ev.(type) {
case *runtime.EventConsoleAPICalled:
for _, arg := range ev.Args {
s, err := strconv.Unquote(string(arg.Value))
if err != nil {
log.Println(err)
continue
}
// TODO: debug mode only?
fmt.Printf("[%s] console.log %s\n", opts.UserID, s)

if strings.HasPrefix(s, CONSOLE_LOG_CONTROL_STRING) {
val := strings.TrimPrefix(s, CONSOLE_LOG_CONTROL_STRING)
// for now the format is always 'room_id||text'
segs := strings.Split(val, "||")
for _, l := range jsc.listeners {
l(segs[0], segs[1])
}
}
}
}
})

// strip /dist so /index.html loads correctly as does /assets/xxx.js
c, err := fs.Sub(jsSDKDistDirectory, "dist")
if err != nil {
return nil, fmt.Errorf("failed to strip /dist off JS SDK files: %s", err)
}

baseJSURL := ""
// run js-sdk (need to run this as a web server to avoid CORS errors you'd otherwise get with file: URLs)
var wg sync.WaitGroup
wg.Add(1)
mux := &http.ServeMux{}
mux.Handle("/", http.FileServer(http.FS(c)))
startServer := func() {
srv := &http.Server{
Addr: "127.0.0.1:0",
Handler: mux,
}
ln, err := net.Listen("tcp", srv.Addr)
if err != nil {
panic(err)
}
baseJSURL = "http://" + ln.Addr().String()
fmt.Println("JS SDK listening on", baseJSURL)
wg.Done()
srv.Serve(ln)
fmt.Println("JS SDK closing webserver")
}
go startServer()
wg.Wait()

// now login
createClientOpts := map[string]interface{}{
"baseUrl": opts.BaseURL,
"useAuthorizationHeader": true,
"userId": opts.UserID,
}
if opts.DeviceID != "" {
createClientOpts["deviceId"] = opts.DeviceID
}
createClientOptsJSON, err := json.Marshal(createClientOpts)
if err != nil {
return nil, fmt.Errorf("failed to serialise login info: %s", err)
}
val := fmt.Sprintf("window.__client = matrix.createClient(%s);", string(createClientOptsJSON))
fmt.Println(val)
// TODO: move to chrome package
var r *runtime.RemoteObject
err = chromedp.Run(ctx,
chromedp.Navigate(baseJSURL),
chromedp.Evaluate(val, &r),
)
if err != nil {
return nil, fmt.Errorf("failed to go to %s and createClient: %s", baseJSURL, err)
}
// cannot use loginWithPassword as this generates a new device ID
chrome.AwaitExecute(t, ctx, fmt.Sprintf(`window.__client.login("m.login.password", {
user: "%s",
password: "%s",
device_id: "%s",
});`, opts.UserID, opts.Password, opts.DeviceID))
chrome.AwaitExecute(t, ctx, `window.__client.initRustCrypto();`)

// any events need to log the control string so we get notified
chrome.MustExecute(t, ctx, fmt.Sprintf(`window.__client.on("Event.decrypted", function(event) {
if (event.getType() !== "m.room.message") {
return; // only use messages
}
console.log("%s"+event.getRoomId()+"||"+event.getEffectiveEvent().content.body);
});`, CONSOLE_LOG_CONTROL_STRING))

jsc.ctx = ctx
jsc.cancel = cancel
jsc.baseJSURL = baseJSURL
return jsc, nil
}

// Close is called to clean up resources.
// Specifically, we need to shut off existing browsers and any FFI bindings.
// If we get callbacks/events after this point, tests may panic if the callbacks
// log messages.
func (c *JSClient) Close(t *testing.T) {
c.cancel()
c.listeners = make(map[int32]func(roomID string, text string))
}

// StartSyncing to begin syncing from sync v2 / sliding sync.
// Tests should call stopSyncing() at the end of the test.
func (c *JSClient) StartSyncing(t *testing.T) (stopSyncing func()) {
return
chrome.AwaitExecute(t, c.ctx, `window.__client.startClient({});`)
return func() {
chrome.AwaitExecute(t, c.ctx, `window.__client.stopClient();`)
}
}

// IsRoomEncrypted returns true if the room is encrypted. May return an error e.g if you
// provide a bogus room ID.
func (c *JSClient) IsRoomEncrypted(roomID string) (bool, error) {
return false, nil
func (c *JSClient) IsRoomEncrypted(t *testing.T, roomID string) (bool, error) {
isEncrypted, err := chrome.ExecuteInto[bool](
t, c.ctx, fmt.Sprintf(`window.__client.isRoomEncrypted("%s")`, roomID),
)
if err != nil {
return false, err
}
return *isEncrypted, nil
}

// SendMessage sends the given text as an m.room.message with msgtype:m.text into the given
// room. Returns the event ID of the sent event.
// room.
func (c *JSClient) SendMessage(t *testing.T, roomID, text string) {
return
err := chrome.AwaitExecute(t, c.ctx, fmt.Sprintf(`window.__client.sendMessage("%s", {
"msgtype": "m.text",
"body": "%s"
});`, roomID, text))
must.NotError(t, "failed to sendMessage", err)
}

func (c *JSClient) WaitUntilEventInRoom(t *testing.T, roomID, wantBody string) Waiter {
return nil
// TODO: check if this event already exists
return &jsTimelineWaiter{
roomID: roomID,
wantBody: wantBody,
client: c,
}
}

func (c *JSClient) Type() ClientType {
return ClientTypeJS
}

func (c *JSClient) listenForUpdates(callback func(roomID, gotText string)) (cancel func()) {
id := c.listenerID.Add(1)
c.listeners[id] = callback
return func() {
delete(c.listeners, id)
}
}

type jsTimelineWaiter struct {
roomID string
wantBody string
client *JSClient
}

func (w *jsTimelineWaiter) Wait(t *testing.T, s time.Duration) {
updates := make(chan bool, 3)
cancel := w.client.listenForUpdates(func(roomID, gotText string) {
if w.roomID != roomID {
return
}
if w.wantBody != gotText {
return
}
updates <- true
})
defer cancel()

start := time.Now()
for {
timeLeft := s - time.Since(start)
if timeLeft <= 0 {
t.Fatalf("%s: Wait[%s]: timed out", w.client.userID, w.roomID)
}
select {
case <-time.After(timeLeft):
t.Fatalf("%s: Wait[%s]: timed out", w.client.userID, w.roomID)
case <-updates:
return
}
}
}

func (w *jsTimelineWaiter) callback(gotRoomID, gotText string) {
if w.roomID == gotRoomID && w.wantBody == gotText {

}
}
18 changes: 10 additions & 8 deletions internal/api/rust.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ type RustClient struct {
syncService *matrix_sdk_ffi.SyncService
}

func NewRustClient(opts ClientCreationOpts, ssURL string) (Client, error) {
func NewRustClient(t *testing.T, opts ClientCreationOpts, ssURL string) (Client, error) {
ab := matrix_sdk_ffi.NewClientBuilder().HomeserverUrl(opts.BaseURL).SlidingSyncProxy(&ssURL)
client, err := ab.Build()
if err != nil {
Expand All @@ -46,11 +46,8 @@ func NewRustClient(opts ClientCreationOpts, ssURL string) (Client, error) {
}, nil
}

// Init is called prior to any test execution. Do any setup code here e.g run a browser.
// Call close() when the test terminates to clean up resources.
// TODO: will this be too slow if we spin up a browser for each test?
func (c *RustClient) Init(t *testing.T) (close func()) {
return func() {}
func (c *RustClient) Close(t *testing.T) {
c.FFIClient.Destroy()
}

// StartSyncing to begin syncing from sync v2 / sliding sync.
Expand All @@ -69,7 +66,7 @@ func (c *RustClient) StartSyncing(t *testing.T) (stopSyncing func()) {

// IsRoomEncrypted returns true if the room is encrypted. May return an error e.g if you
// provide a bogus room ID.
func (c *RustClient) IsRoomEncrypted(roomID string) (bool, error) {
func (c *RustClient) IsRoomEncrypted(t *testing.T, roomID string) (bool, error) {
r := c.findRoom(roomID)
if r == nil {
rooms := c.FFIClient.Rooms()
Expand All @@ -87,10 +84,15 @@ func (c *RustClient) WaitUntilEventInRoom(t *testing.T, roomID, wantBody string)
}
}

func (c *RustClient) Type() ClientType {
return ClientTypeRust
}

// SendMessage sends the given text as an m.room.message with msgtype:m.text into the given
// room. Returns the event ID of the sent event.
func (c *RustClient) SendMessage(t *testing.T, roomID, text string) {
// we need a timeline listener before we can send messages
// we need a timeline listener before we can send messages, AND that listener must be attached to the
// same *Room you call .Send on :S
r := c.ensureListening(t, roomID)
t.Logf("%s: SendMessage[%s]: '%s'", c.userID, roomID, text)
r.Send(matrix_sdk_ffi.MessageEventContentFromHtml(text, text))
Expand Down
10 changes: 10 additions & 0 deletions internal/chrome/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,13 @@ func MustAwaitExecute(t *testing.T, ctx context.Context, js string) {
err := AwaitExecute(t, ctx, js)
must.NotError(t, js, err)
}

func MustExecute(t *testing.T, ctx context.Context, js string) {
t.Helper()
var r *runtime.RemoteObject // stop large responses causing errors "Object reference chain is too long (-32000)"
t.Log(js)
err := chromedp.Run(ctx,
chromedp.Evaluate(js, &r),
)
must.NotError(t, js, err)
}
4 changes: 2 additions & 2 deletions rebuild_js_sdk.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@ then
fi

(cd js-sdk && yarn add $1 && yarn install && yarn build)
rm -rf ./tests/dist || echo 'no dist directory detected';
cp -r ./js-sdk/dist/. ./tests/dist
rm -rf ./internal/api/dist || echo 'no dist directory detected';
cp -r ./js-sdk/dist/. ./internal/api/dist
Loading

0 comments on commit 0dd4f1a

Please sign in to comment.