Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Run JS SDK in separate tabs rather than browsers #108

Closed
wants to merge 6 commits into from
Closed
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
149 changes: 149 additions & 0 deletions internal/api/js/chrome/browser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
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{}
origins *Origins
)

// 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()
origins = NewOrigins()
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) Info() {
targets, err := chromedp.Targets(b.Ctx)
if err != nil {
fmt.Println("DEBUG: failed to get targets: ", err)
return
}
fmt.Printf("DEBUG: got %d targets\n", len(targets))
var urls []string
for _, t := range targets {
urls = append(urls, t.URL)
}
fmt.Println(urls)
}

func (b *Browser) NewTab(baseJSURL string, onConsoleLog func(s string)) (*Tab, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

document?

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
}

// For clients which want persistent storage, we need to ensure when the browser
// starts up a 2nd+ time we serve the same URL so the browser uses the same origin
type Origins struct {
clientToBaseURL map[string]string
mu *sync.RWMutex
}

func NewOrigins() *Origins {
return &Origins{
clientToBaseURL: make(map[string]string),
mu: &sync.RWMutex{},
}
}

func (o *Origins) StoreBaseURL(userID, deviceID, baseURL string) {
o.mu.Lock()
defer o.mu.Unlock()
o.clientToBaseURL[userID+deviceID] = baseURL
}

func (o *Origins) GetBaseURL(userID, deviceID string) (baseURL string) {
o.mu.RLock()
defer o.mu.RUnlock()
return o.clientToBaseURL[userID+deviceID]
}
118 changes: 23 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,36 @@ 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),
)
}
// 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)
}
}
})

// strip /dist so /index.html loads correctly as does /assets/xxx.js
c, err := fs.Sub(jsSDKDistDirectory, "dist")
// Run a headless JS SDK instance for the given user/device ID.
func RunHeadless(userID, deviceID string, onConsoleLog func(s string)) (*Tab, error) {
// Make, or acquire, a Chrome browser
browser, err := GlobalBrowser()
if err != nil {
return nil, fmt.Errorf("failed to strip /dist off JS SDK files: %s", err)
return nil, fmt.Errorf("GlobalBrowser: %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,
// Host the JS SDK
baseURL := origins.GetBaseURL(userID, deviceID)
opts, err := NewJSSDKInstanceOptsFromURL(baseURL, userID, deviceID)
if err != nil {
return nil, fmt.Errorf("NewJSSDKInstanceOptsFromURL: %v", 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")
baseJSURL, closeSDKInstance, err := NewJSSDKWebsite(opts)
if err != nil {
return nil, fmt.Errorf("failed to create new js sdk instance: %s", err)
}
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)
}
// we will have a random high numbered port now, so remember it.
origins.StoreBaseURL(userID, deviceID, baseJSURL)

// when we close the tab, close the hosted files too
tab.SetCloseServer(closeSDKInstance)

return &Browser{
Ctx: ctx,
Cancel: func() {
cancel()
allocCancel()
srv.Close()
},
BaseURL: baseJSURL,
}, nil
return tab, nil
}
Loading
Loading