Skip to content

Commit

Permalink
refactor(test): helper_test.go as a useful example (#410)
Browse files Browse the repository at this point in the history
canstand authored Mar 11, 2024
1 parent 24eab96 commit 7f49fdf
Showing 3 changed files with 331 additions and 288 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -105,6 +105,7 @@ func main() {

## More examples

* Refer to [helper_test.go](./tests/helper_test.go) for End-To-End testing
* [Downloading files](./examples/download/main.go)
* [End-To-End testing a website](./examples/end-to-end-testing/main.go)
* [Executing JavaScript in the browser](./examples/javascript/main.go)
324 changes: 36 additions & 288 deletions tests/helper_test.go
Original file line number Diff line number Diff line change
@@ -1,27 +1,14 @@
package playwright_test

import (
"archive/zip"
"bytes"
"errors"
"fmt"
"io"
"log"
"mime"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"testing"

"github.com/playwright-community/playwright-go"
"github.com/stretchr/testify/require"
)

// global variables, can be used in any tests
var (
pw *playwright.Playwright
browser playwright.Browser
@@ -32,24 +19,27 @@ var (
isFirefox bool
isWebKit bool
browserName = getBrowserName()
server *testServer
browserType playwright.BrowserType
utils *testUtils
)

func init() {
if err := mime.AddExtensionType(".js", "application/javascript"); err != nil {
log.Fatalf("could not add mime extension type: %v", err)
}
// default context options for most tests
var DEFAULT_CONTEXT_OPTIONS = playwright.BrowserNewContextOptions{
AcceptDownloads: playwright.Bool(true),
HasTouch: playwright.Bool(true),
}

// TestMain is used to setup and teardown the tests
func TestMain(m *testing.M) {
BeforeAll()
code := m.Run()
AfterAll()
os.Exit(code)
}

// BeforeAll prepares the environment, including
// - start Playwright driver
// - launch browser depends on BROWSER env
// - init web-first assertions, alias as `expect`
func BeforeAll() {
var err error
pw, err = playwright.Run()
@@ -63,57 +53,54 @@ func BeforeAll() {
} else if browserName == "webkit" {
browserType = pw.WebKit
}
// launch browser, headless or not depending on HEADFUL env
browser, err = browserType.Launch(playwright.BrowserTypeLaunchOptions{
Headless: playwright.Bool(os.Getenv("HEADFUL") == ""),
})
if err != nil {
log.Fatalf("could not launch: %v", err)
}
// init web-first assertions with 1s timeout instead of default 5s
expect = playwright.NewPlaywrightAssertions(1000)
isChromium = browserName == "chromium" || browserName == ""
isFirefox = browserName == "firefox"
isWebKit = browserName == "webkit"

// for playwright-go tests
server = newTestServer()
utils = &testUtils{}
}

// AfterAll does cleanup, e.g. stop playwright driver
func AfterAll() {
server.testServer.Close()
if server != nil {
server.testServer.Close()
}
if err := pw.Stop(); err != nil {
log.Fatalf("could not start Playwright: %v", err)
}
}

var DEFAULT_CONTEXT_OPTIONS = playwright.BrowserNewContextOptions{
AcceptDownloads: playwright.Bool(true),
HasTouch: playwright.Bool(true),
}

// BeforeEach creates a new context and page for each test,
// so each test has isolated environment. Usage:
//
// Func TestFoo(t *testing.T) {
// BeforeEach(t)
// defer AfterEach(t)
// // your test code
// }
func BeforeEach(t *testing.T, contextOptions ...playwright.BrowserNewContextOptions) {
t.Helper()
if len(contextOptions) == 1 {
newContextWithOptions(t, contextOptions[0])
return
}
newContextWithOptions(t, DEFAULT_CONTEXT_OPTIONS)
}

func newContextWithOptions(t *testing.T, contextOptions playwright.BrowserNewContextOptions) {
var err error
context, err = browser.NewContext(contextOptions)
require.NoError(t, err)
page, err = context.NewPage()
require.NoError(t, err)
}

func Asset(path string) string {
cwd, err := os.Getwd()
if err != nil {
log.Fatalf("could not get cwd: %v", err)
}
return filepath.Join(cwd, "assets", path)
}

// AfterEach closes the context and page after each test
func AfterEach(t *testing.T, closeContext ...bool) {
t.Helper()
if len(closeContext) == 0 {
if err := context.Close(); err != nil {
t.Errorf("could not close context: %v", err)
@@ -122,205 +109,6 @@ func AfterEach(t *testing.T, closeContext ...bool) {
server.AfterEach()
}

func newTestServer(tls ...bool) *testServer {
ts := &testServer{
routes: make(map[string]http.HandlerFunc),
requestSubscriberes: make(map[string][]chan *http.Request),
}
if len(tls) > 0 && tls[0] {
ts.testServer = httptest.NewTLSServer(http.HandlerFunc(ts.serveHTTP))
} else {
ts.testServer = httptest.NewServer(http.HandlerFunc(ts.serveHTTP))
}
ts.PREFIX = ts.testServer.URL
ts.EMPTY_PAGE = ts.testServer.URL + "/empty.html"
ts.CROSS_PROCESS_PREFIX = strings.Replace(ts.testServer.URL, "127.0.0.1", "localhost", 1)
return ts
}

type testServer struct {
sync.Mutex
testServer *httptest.Server
routes map[string]http.HandlerFunc
requestSubscriberes map[string][]chan *http.Request
PREFIX string
EMPTY_PAGE string
CROSS_PROCESS_PREFIX string
}

func (t *testServer) AfterEach() {
t.Lock()
defer t.Unlock()
t.routes = make(map[string]http.HandlerFunc)
t.requestSubscriberes = make(map[string][]chan *http.Request)
}

func (t *testServer) serveHTTP(w http.ResponseWriter, r *http.Request) {
t.Lock()
defer t.Unlock()
body, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("Error reading body: %v", err)
http.Error(w, "can't read body", http.StatusBadRequest)
return
}
r.Body = io.NopCloser(bytes.NewBuffer(body))
if handlers, ok := t.requestSubscriberes[r.URL.Path]; ok {
for _, handler := range handlers {
handler <- r
}
}
if route, ok := t.routes[r.URL.Path]; ok {
route(w, r)
return
}
w.Header().Add("Cache-Control", "no-cache, no-store")
http.FileServer(http.Dir("assets")).ServeHTTP(w, r)
}

func (s *testServer) SetBasicAuth(path, username, password string) {
s.SetRoute(path, func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
if !ok || u != username || p != password {
w.Header().Add("WWW-Authenticate", "Basic") // needed or playwright will do not send auth header
http.Error(w, "unauthorized", http.StatusUnauthorized)
}
})
}

func (s *testServer) SetRoute(path string, f http.HandlerFunc) {
s.Lock()
defer s.Unlock()
s.routes[path] = f
}

func (s *testServer) SetRedirect(from, to string) {
s.SetRoute(from, func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, to, http.StatusFound)
})
}

func (s *testServer) WaitForRequestChan(path string) <-chan *http.Request {
s.Lock()
defer s.Unlock()
if _, ok := s.requestSubscriberes[path]; !ok {
s.requestSubscriberes[path] = make([]chan *http.Request, 0)
}
channel := make(chan *http.Request, 1)
s.requestSubscriberes[path] = append(s.requestSubscriberes[path], channel)
return channel
}

func Map[T any, R any](vs []T, f func(T) R) []R {
vsm := make([]R, len(vs))
for i, v := range vs {
vsm[i] = f(v)
}
return vsm
}

// ChanToSlice reads amount of values from the channel, returns them as a slice
func ChanToSlice[T any](ch chan T, amount int) []T {
data := make([]T, 0)
for i := 0; i < amount; i++ {
data = append(data, <-ch)
}
return data
}

type syncSlice[T any] struct {
sync.Mutex
slice []T
}

func (s *syncSlice[T]) Append(v T) {
s.Lock()
defer s.Unlock()
s.slice = append(s.slice, v)
}

func (s *syncSlice[T]) Get() []T {
s.Lock()
defer s.Unlock()
return s.slice
}

func newSyncSlice[T any]() *syncSlice[T] {
return &syncSlice[T]{
slice: make([]T, 0),
}
}

type testUtils struct{}

func (t *testUtils) AttachFrame(page playwright.Page, frameId string, url string) (playwright.Frame, error) {
handle, err := page.EvaluateHandle(`async ({ frame_id, url }) => {
const frame = document.createElement('iframe');
frame.src = url;
frame.id = frame_id;
document.body.appendChild(frame);
await new Promise(x => frame.onload = x);
return frame;
}`, map[string]interface{}{
"frame_id": frameId,
"url": url,
})
if err != nil {
return nil, err
}
elem := handle.AsElement()
if elem == nil {
return nil, errors.New("frame not found")
}
return elem.ContentFrame()
}

func (t *testUtils) DetachFrame(page playwright.Page, frameId string) error {
_, err := page.Evaluate(`frame_id => document.getElementById(frame_id).remove()`, frameId)
return err
}

func (tu *testUtils) DumpFrames(frame playwright.Frame, indentation string) []string {
desc := strings.Replace(frame.URL(), server.PREFIX, "http://localhost:<PORT>", 1)
if frame.Name() != "" {
desc = fmt.Sprintf("%s (%s)", desc, frame.Name())
}
result := []string{
indentation + desc,
}
sortedFrames := frame.ChildFrames()
sort.SliceStable(sortedFrames, func(i, j int) bool {
return (sortedFrames[i].URL() + sortedFrames[i].Name()) < (sortedFrames[j].URL() + sortedFrames[j].Name())
})
for _, f := range sortedFrames {
result = append(result, tu.DumpFrames(f, " "+indentation)...)
}
return result
}

func (tu *testUtils) VerifyViewport(t *testing.T, page playwright.Page, width, height int) {
require.Equal(t, page.ViewportSize().Width, width)
require.Equal(t, page.ViewportSize().Height, height)
innerWidth, err := page.Evaluate("window.innerWidth")
require.NoError(t, err)
require.Equal(t, innerWidth, width)
innerHeight, err := page.Evaluate("window.innerHeight")
require.NoError(t, err)
require.Equal(t, innerHeight, height)
}

func (tu *testUtils) AssertEval(t *testing.T, page playwright.Page, script string, expected interface{}) {
result, err := page.Evaluate(script)
require.NoError(t, err)
require.Equal(t, expected, result)
}

func (tu *testUtils) AssertResult(t *testing.T, fn func() (interface{}, error), expected interface{}) {
result, err := fn()
require.NoError(t, err)
require.Equal(t, expected, result)
}

func getBrowserName() string {
browserName, hasEnv := os.LookupEnv("BROWSER")
if hasEnv {
@@ -329,55 +117,15 @@ func getBrowserName() string {
return "chromium"
}

func getFreePort() (int, error) {
addr, err := net.ResolveTCPAddr("tcp", "localhost:0")
if err != nil {
return 0, err
}

l, err := net.ListenTCP("tcp", addr)
if err != nil {
return 0, err
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port, nil
}

func readFromZip(zipFile string, fileName string) ([]byte, error) {
r, err := zip.OpenReader(zipFile)
func newContextWithOptions(t *testing.T, contextOptions playwright.BrowserNewContextOptions) {
t.Helper()
var err error
context, err = browser.NewContext(contextOptions)
if err != nil {
return nil, err
t.Fatalf("could not create new context: %v", err)
}
defer r.Close()

for _, f := range r.File {
if f.Name == fileName {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()

buf := new(bytes.Buffer)
_, err = io.Copy(buf, rc)
if err != nil {
return nil, err
}

return buf.Bytes(), nil
}
}

return nil, fmt.Errorf("file %s not found in %s", fileName, zipFile)
}

func getFileLastModifiedTimeMs(path string) (int64, error) {
info, err := os.Stat(path)
page, err = context.NewPage()
if err != nil {
return 0, err
}
if info.IsDir() {
return 0, fmt.Errorf("%s is a directory", path)
t.Fatalf("could not create new page: %v", err)
}
return info.ModTime().UnixMilli(), nil
}
294 changes: 294 additions & 0 deletions tests/utils_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
package playwright_test

import (
"archive/zip"
"bytes"
"errors"
"fmt"
"io"
"log"
"mime"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"testing"

"github.com/playwright-community/playwright-go"
"github.com/stretchr/testify/require"
)

var (
server *testServer
utils *testUtils
)

func init() {
if err := mime.AddExtensionType(".js", "application/javascript"); err != nil {
log.Fatalf("could not add mime extension type: %v", err)
}
}

func Asset(path string) string {
cwd, err := os.Getwd()
if err != nil {
log.Fatalf("could not get cwd: %v", err)
}
return filepath.Join(cwd, "assets", path)
}

func newTestServer(tls ...bool) *testServer {
ts := &testServer{
routes: make(map[string]http.HandlerFunc),
requestSubscriberes: make(map[string][]chan *http.Request),
}
if len(tls) > 0 && tls[0] {
ts.testServer = httptest.NewTLSServer(http.HandlerFunc(ts.serveHTTP))
} else {
ts.testServer = httptest.NewServer(http.HandlerFunc(ts.serveHTTP))
}
ts.PREFIX = ts.testServer.URL
ts.EMPTY_PAGE = ts.testServer.URL + "/empty.html"
ts.CROSS_PROCESS_PREFIX = strings.Replace(ts.testServer.URL, "127.0.0.1", "localhost", 1)
return ts
}

type testServer struct {
sync.Mutex
testServer *httptest.Server
routes map[string]http.HandlerFunc
requestSubscriberes map[string][]chan *http.Request
PREFIX string
EMPTY_PAGE string
CROSS_PROCESS_PREFIX string
}

func (t *testServer) AfterEach() {
t.Lock()
defer t.Unlock()
t.routes = make(map[string]http.HandlerFunc)
t.requestSubscriberes = make(map[string][]chan *http.Request)
}

func (t *testServer) serveHTTP(w http.ResponseWriter, r *http.Request) {
t.Lock()
defer t.Unlock()
body, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("Error reading body: %v", err)
http.Error(w, "can't read body", http.StatusBadRequest)
return
}
r.Body = io.NopCloser(bytes.NewBuffer(body))
if handlers, ok := t.requestSubscriberes[r.URL.Path]; ok {
for _, handler := range handlers {
handler <- r
}
}
if route, ok := t.routes[r.URL.Path]; ok {
route(w, r)
return
}
w.Header().Add("Cache-Control", "no-cache, no-store")
http.FileServer(http.Dir("assets")).ServeHTTP(w, r)
}

func (s *testServer) SetBasicAuth(path, username, password string) {
s.SetRoute(path, func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
if !ok || u != username || p != password {
w.Header().Add("WWW-Authenticate", "Basic") // needed or playwright will do not send auth header
http.Error(w, "unauthorized", http.StatusUnauthorized)
}
})
}

func (s *testServer) SetRoute(path string, f http.HandlerFunc) {
s.Lock()
defer s.Unlock()
s.routes[path] = f
}

func (s *testServer) SetRedirect(from, to string) {
s.SetRoute(from, func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, to, http.StatusFound)
})
}

func (s *testServer) WaitForRequestChan(path string) <-chan *http.Request {
s.Lock()
defer s.Unlock()
if _, ok := s.requestSubscriberes[path]; !ok {
s.requestSubscriberes[path] = make([]chan *http.Request, 0)
}
channel := make(chan *http.Request, 1)
s.requestSubscriberes[path] = append(s.requestSubscriberes[path], channel)
return channel
}

func Map[T any, R any](vs []T, f func(T) R) []R {
vsm := make([]R, len(vs))
for i, v := range vs {
vsm[i] = f(v)
}
return vsm
}

// ChanToSlice reads amount of values from the channel, returns them as a slice
func ChanToSlice[T any](ch chan T, amount int) []T {
data := make([]T, 0)
for i := 0; i < amount; i++ {
data = append(data, <-ch)
}
return data
}

type syncSlice[T any] struct {
sync.Mutex
slice []T
}

func (s *syncSlice[T]) Append(v T) {
s.Lock()
defer s.Unlock()
s.slice = append(s.slice, v)
}

func (s *syncSlice[T]) Get() []T {
s.Lock()
defer s.Unlock()
return s.slice
}

func newSyncSlice[T any]() *syncSlice[T] {
return &syncSlice[T]{
slice: make([]T, 0),
}
}

type testUtils struct{}

func (t *testUtils) AttachFrame(page playwright.Page, frameId string, url string) (playwright.Frame, error) {
handle, err := page.EvaluateHandle(`async ({ frame_id, url }) => {
const frame = document.createElement('iframe');
frame.src = url;
frame.id = frame_id;
document.body.appendChild(frame);
await new Promise(x => frame.onload = x);
return frame;
}`, map[string]interface{}{
"frame_id": frameId,
"url": url,
})
if err != nil {
return nil, err
}
elem := handle.AsElement()
if elem == nil {
return nil, errors.New("frame not found")
}
return elem.ContentFrame()
}

func (t *testUtils) DetachFrame(page playwright.Page, frameId string) error {
_, err := page.Evaluate(`frame_id => document.getElementById(frame_id).remove()`, frameId)
return err
}

func (tu *testUtils) DumpFrames(frame playwright.Frame, indentation string) []string {
desc := strings.Replace(frame.URL(), server.PREFIX, "http://localhost:<PORT>", 1)
if frame.Name() != "" {
desc = fmt.Sprintf("%s (%s)", desc, frame.Name())
}
result := []string{
indentation + desc,
}
sortedFrames := frame.ChildFrames()
sort.SliceStable(sortedFrames, func(i, j int) bool {
return (sortedFrames[i].URL() + sortedFrames[i].Name()) < (sortedFrames[j].URL() + sortedFrames[j].Name())
})
for _, f := range sortedFrames {
result = append(result, tu.DumpFrames(f, " "+indentation)...)
}
return result
}

func (tu *testUtils) VerifyViewport(t *testing.T, page playwright.Page, width, height int) {
require.Equal(t, page.ViewportSize().Width, width)
require.Equal(t, page.ViewportSize().Height, height)
innerWidth, err := page.Evaluate("window.innerWidth")
require.NoError(t, err)
require.Equal(t, innerWidth, width)
innerHeight, err := page.Evaluate("window.innerHeight")
require.NoError(t, err)
require.Equal(t, innerHeight, height)
}

func (tu *testUtils) AssertEval(t *testing.T, page playwright.Page, script string, expected interface{}) {
result, err := page.Evaluate(script)
require.NoError(t, err)
require.Equal(t, expected, result)
}

func (tu *testUtils) AssertResult(t *testing.T, fn func() (interface{}, error), expected interface{}) {
result, err := fn()
require.NoError(t, err)
require.Equal(t, expected, result)
}

func getFreePort() (int, error) {
addr, err := net.ResolveTCPAddr("tcp", "localhost:0")
if err != nil {
return 0, err
}

l, err := net.ListenTCP("tcp", addr)
if err != nil {
return 0, err
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port, nil
}

func readFromZip(zipFile string, fileName string) ([]byte, error) {
r, err := zip.OpenReader(zipFile)
if err != nil {
return nil, err
}
defer r.Close()

for _, f := range r.File {
if f.Name == fileName {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()

buf := new(bytes.Buffer)
_, err = io.Copy(buf, rc)
if err != nil {
return nil, err
}

return buf.Bytes(), nil
}
}

return nil, fmt.Errorf("file %s not found in %s", fileName, zipFile)
}

func getFileLastModifiedTimeMs(path string) (int64, error) {
info, err := os.Stat(path)
if err != nil {
return 0, err
}
if info.IsDir() {
return 0, fmt.Errorf("%s is a directory", path)
}
return info.ModTime().UnixMilli(), nil
}

0 comments on commit 7f49fdf

Please sign in to comment.