Skip to content

Commit

Permalink
Run JS SDK in separate tabs rather than browsers
Browse files Browse the repository at this point in the history
Fixes #107 and should be faster to run.
  • Loading branch information
kegsay committed Jul 5, 2024
1 parent 8a00917 commit 97a779d
Show file tree
Hide file tree
Showing 4 changed files with 238 additions and 132 deletions.
108 changes: 108 additions & 0 deletions internal/api/js/chrome/browser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package chrome

import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"sync"
"time"

"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
)

// We only spin up a single chrome browser for all tests.
// Each test hosts the JS SDK HTML/JS on a random high-numbered port and opens it in a new tab
// for test isolation.
var (
browserInstance *Browser
browserInstanceMu = &sync.Mutex{}
)

// GlobalBrowser returns the browser singleton, making it if needed.
func GlobalBrowser() (*Browser, error) {
browserInstanceMu.Lock()
defer browserInstanceMu.Unlock()
if browserInstance == nil {
var err error
browserInstance, err = NewBrowser()
return browserInstance, err
}
return browserInstance, nil
}

type Browser struct {
Ctx context.Context // topmost chromedp context
ctxCancel func()
execAllocCancel func()
}

// Create and run a new Chrome browser.
func NewBrowser() (*Browser, error) {
ansiRedForeground := "\x1b[31m"
ansiResetForeground := "\x1b[39m"

colorifyError := func(format string, args ...any) {
format = ansiRedForeground + time.Now().Format(time.RFC3339) + " " + format + ansiResetForeground
fmt.Printf(format, args...)
}
opts := chromedp.DefaultExecAllocatorOptions[:]
os.Mkdir("chromedp", os.ModePerm) // ignore errors to allow repeated runs
wd, _ := os.Getwd()
userDir := filepath.Join(wd, "chromedp")
opts = append(opts,
chromedp.UserDataDir(userDir),
)
// increase the WS timeout from 20s (default) to 30s as we see timeouts with 20s in CI
opts = append(opts, chromedp.WSURLReadTimeout(30*time.Second))

allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...)
ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithBrowserOption(
chromedp.WithBrowserLogf(colorifyError), chromedp.WithBrowserErrorf(colorifyError), //chromedp.WithBrowserDebugf(log.Printf),
))

browser := &Browser{
Ctx: ctx,
ctxCancel: cancel,
execAllocCancel: allocCancel,
}
return browser, chromedp.Run(ctx)
}

func (b *Browser) Close() {
b.ctxCancel()
b.execAllocCancel()
}

func (b *Browser) NewTab(baseJSURL string, onConsoleLog func(s string)) (*Tab, error) {
tabCtx, closeTab := chromedp.NewContext(b.Ctx)
err := chromedp.Run(tabCtx,
chromedp.Navigate(baseJSURL),
)
if err != nil {
return nil, fmt.Errorf("NewTab: failed to navigate to %s: %s", baseJSURL, err)
}

// Listen for console logs for debugging AND to communicate live updates
chromedp.ListenTarget(tabCtx, 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 {
s = string(arg.Value)
}
onConsoleLog(s)
}
}
})

return &Tab{
BaseURL: baseJSURL,
Ctx: tabCtx,
browser: b,
cancel: closeTab,
}, nil
}
112 changes: 17 additions & 95 deletions internal/api/js/chrome/chrome.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,13 @@ package chrome

import (
"context"
"embed"
"fmt"
"io/fs"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"sync"
"time"

"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
"github.com/matrix-org/complement/ct"
)

//go:embed dist
var jsSDKDistDirectory embed.FS

// Void is a type which can be used when you want to run an async function without returning anything.
// It can stop large responses causing errors "Object reference chain is too long (-32000)"
// when we don't care about the response.
Expand Down Expand Up @@ -64,96 +52,30 @@ func MustRunAsyncFn[T any](t ct.TestLike, ctx context.Context, js string) *T {
return result
}

type Browser struct {
BaseURL string
Ctx context.Context
Cancel func()
}

func RunHeadless(onConsoleLog func(s string), requiresPersistance bool, listenPort int) (*Browser, error) {
ansiRedForeground := "\x1b[31m"
ansiResetForeground := "\x1b[39m"

colorifyError := func(format string, args ...any) {
format = ansiRedForeground + time.Now().Format(time.RFC3339) + " " + format + ansiResetForeground
fmt.Printf(format, args...)
}
opts := chromedp.DefaultExecAllocatorOptions[:]
if requiresPersistance {
os.Mkdir("chromedp", os.ModePerm) // ignore errors to allow repeated runs
wd, _ := os.Getwd()
userDir := filepath.Join(wd, "chromedp")
opts = append(opts,
chromedp.UserDataDir(userDir),
)
func RunHeadless(onConsoleLog func(s string), listenPort int) (*Tab, error) {
// make a Chrome browser
browser, err := GlobalBrowser()
if err != nil {
return nil, fmt.Errorf("GlobalBrowser: %s", err)
}
// increase the WS timeout from 20s (default) to 30s as we see timeouts with 20s in CI
opts = append(opts, chromedp.WSURLReadTimeout(30*time.Second))

allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...)
ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithBrowserOption(
chromedp.WithBrowserLogf(colorifyError), chromedp.WithBrowserErrorf(colorifyError), //chromedp.WithBrowserDebugf(log.Printf),
))

// 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 {
s = string(arg.Value)
}
onConsoleLog(s)
}
}
// Host the JS SDK
baseJSURL, closeSDKInstance, err := NewJSSDKWebsite(JSSDKInstanceOpts{
Port: listenPort,
})

// 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)))
srv := &http.Server{
Addr: fmt.Sprintf("127.0.0.1:%d", listenPort),
Handler: mux,
return nil, fmt.Errorf("failed to create new js sdk instance: %s", err)
}
startServer := func() {
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()

// navigate to the page
err = chromedp.Run(ctx,
chromedp.Navigate(baseJSURL),
)
// Make a tab
tab, err := browser.NewTab(baseJSURL, onConsoleLog)
if err != nil {
return nil, fmt.Errorf("failed to navigate to %s: %s", baseJSURL, err)
closeSDKInstance()
return nil, fmt.Errorf("failed to create new tab: %s", err)
}

return &Browser{
Ctx: ctx,
Cancel: func() {
cancel()
allocCancel()
srv.Close()
},
BaseURL: baseJSURL,
}, nil
// when we close the tab, close the hosted files too
tab.SetCloseServer(closeSDKInstance)

return tab, nil
}
76 changes: 76 additions & 0 deletions internal/api/js/chrome/tab.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package chrome

import (
"context"
"embed"
"fmt"
"io/fs"
"net"
"net/http"
"sync"
)

//go:embed dist
var jsSDKDistDirectory embed.FS

type JSSDKInstanceOpts struct {
// The specific port this instance should be hosted on.
// This is crucial for persistent storage which relies on a stable port number
// across restarts.
Port int
}

// NewJSSDKWebsite hosts the JS SDK HTML/JS on a random high-numbered port
// and runs a Go web server to serve those files.
func NewJSSDKWebsite(opts JSSDKInstanceOpts) (baseURL string, close func(), err error) {
// 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)
}
// 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)))
srv := &http.Server{
Addr: fmt.Sprintf("127.0.0.1:%d", opts.Port),
Handler: mux,
}
startServer := func() {
ln, err := net.Listen("tcp", srv.Addr)
if err != nil {
panic(err)
}
baseURL = "http://" + ln.Addr().String()
fmt.Println("JS SDK listening on", baseURL)
wg.Done()
srv.Serve(ln)
fmt.Println("JS SDK closing webserver")
}
go startServer()
wg.Wait()
return baseURL, func() {
srv.Close()
}, nil
}

// Tab represents an open JS SDK instance tab
type Tab struct {
BaseURL string
Ctx context.Context // tab context
browser *Browser // a ref to the browser which made this tab
closeServer func()
cancel func() // closes the tab
}

func (t *Tab) Close() {
t.cancel()
if t.closeServer != nil {
t.closeServer()
}
}

func (t *Tab) SetCloseServer(close func()) {
t.closeServer = close
}
Loading

0 comments on commit 97a779d

Please sign in to comment.