package playwrightcigo import ( "context" "fmt" "sync" "github.com/playwright-community/playwright-go" ) var mutexBrowser sync.Mutex type browser struct { instanceOf string instancePort int uri string cancel context.CancelFunc count int } func (b *browser) connect() (playwright.Browser, error) { mutexBrowser.Lock() defer mutexBrowser.Unlock() if b.count > 0 { b.count++ return connect(b.instanceOf, b.uri) } if browsers == nil { return nil, fmt.Errorf("container is not running") } uri, cancel, err := browsers.Exec(b.instanceOf, b.instancePort) if err != nil { return nil, fmt.Errorf("could not exec chromium: %w", err) } b.uri = uri b.cancel = func() { mutexBrowser.Lock() defer mutexBrowser.Unlock() b.count-- if b.count == 0 { cancel() } } b.count++ return connect(b.instanceOf, b.uri) } func connect(instanceOf, uri string) (playwright.Browser, error) { switch instanceOf { case "chromium": return pw.Chromium.Connect(uri) case "firefox": return pw.Firefox.Connect(uri) case "webkit": return pw.WebKit.Connect(uri) default: return nil, fmt.Errorf("unknown browser instance: %s", instanceOf) } } var chromium = browser{ instanceOf: "chromium", instancePort: 1024 + 3, } // Chromium launches a Chromium browser instance in the container // and returns a browser object that can be used to create pages, // navigate to websites, and perform browser automation. // // The connection to the browser is established via WebSockets. // You should call browser.Close() when you're done with the browser. // The API of the returned browser object is the Playwright API. func Chromium() (playwright.Browser, error) { return chromium.connect() } var firefox = browser{ instanceOf: "firefox", instancePort: 1024 + 1, } // Firefox launches a Firefox browser instance in the container // and returns a browser object that can be used to create pages, // navigate to websites, and perform browser automation. // // The connection to the browser is established via WebSockets. // You should call browser.Close() when you're done with the browser. // The API of the returned browser object is the Playwright API. func Firefox() (playwright.Browser, error) { return firefox.connect() } var webkit = browser{ instanceOf: "webkit", instancePort: 1024 + 2, } // Webkit launches a WebKit browser instance in the container // and returns a browser object that can be used to create pages, // navigate to websites, and perform browser automation. // // The connection to the browser is established via WebSockets. // You should call browser.Close() when you're done with the browser. // The API of the returned browser object is the Playwright API. func Webkit() (playwright.Browser, error) { return webkit.connect() }
package playwrightcigo import ( "context" "encoding/json" "fmt" "io" "log" "os/exec" "runtime/debug" "strconv" "strings" "time" "github.com/docker/go-connections/nat" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" ) type config struct { ctx context.Context timeout time.Duration sleeping time.Duration repository string tag string retry int verbose bool } type container struct { context context.Context proxy string proxyPort int proxyClose func() browsers testcontainers.Container terminate func() } type module struct { Path string Version string Main bool } func new(version string, opts ...Option) (*container, error) { c := &config{ timeout: 5 * time.Minute, sleeping: 200 * time.Millisecond, retry: 15, ctx: context.Background(), repository: "ghcr.io/mountain-reverie/playwright-ci-go", tag: "", verbose: false, } for _, opt := range opts { opt.apply(c) } if c.tag == "" { tag, err := noTagVersion(version, c.verbose) if err != nil { return nil, err } c.tag = tag } ctx, cancel := context.WithTimeout(c.ctx, c.timeout) timeoutSecond := int(c.timeout.Seconds()) proxy, proxyPort, close := transparentProxy(c.retry, c.sleeping, c.verbose) if c.verbose { log.Println("Starting browser container", fmt.Sprintf("%s:%s", c.repository, c.tag)) } genericContainerReq := testcontainers.GenericContainerRequest{ ContainerRequest: testcontainers.ContainerRequest{ Image: fmt.Sprintf("%s:%s", c.repository, c.tag), HostAccessPorts: []int{int(proxyPort)}, WorkingDir: "/src", ExposedPorts: []string{"1025/tcp", "1026/tcp", "1027/tcp"}, Cmd: []string{fmt.Sprintf("sleep %v", timeoutSecond+10)}, WaitingFor: wait.ForExec([]string{"echo", "ready"}), }, Started: true, } browsers, err := testcontainers.GenericContainer(ctx, genericContainerReq) if err != nil { cancel() return nil, fmt.Errorf("could not start browser container: %w", err) } return &container{ context: ctx, proxy: proxy, proxyPort: proxyPort, proxyClose: close, browsers: browsers, terminate: cancel, }, nil } // Close terminates the container and cleans up associated resources. func (c *container) Close() error { if err := c.browsers.Terminate(context.Background()); err != nil { return fmt.Errorf("could not terminate browser container: %w", err) } c.proxyClose() c.terminate() return nil } // Exec executes a browser command in the container and returns a WebSocket connection URL. // The browser parameter should be one of: "chromium", "firefox", or "webkit". // It also returns a cancel function to terminate the browser session. func (c *container) Exec(browser string, containerPort int) (string, context.CancelFunc, error) { execCtx, execCancel := context.WithCancel(c.context) go func() { code, stuff, err := c.browsers.Exec(execCtx, []string{"node", browser + ".js", c.proxy, strconv.Itoa(c.proxyPort)}) // Check that the context is not expired select { case <-execCtx.Done(): return default: } if err != nil { log.Fatalf("Could not exec in browser container: %s", err) } if code != 0 { s, err := io.ReadAll(stuff) if err != nil { fmt.Println("Could not read stdout/stderr from browser container:", err) } else { fmt.Println("Browser container output:", string(s)) } log.Fatalf("Exec failed in browser container: %d", code) } }() host, err := c.browsers.Host(c.context) if err != nil { execCancel() return "", nil, fmt.Errorf("could not get browser host: %w", err) } p, err := port(c.context, c.browsers, host, containerPort) if err != nil { execCancel() return "", nil, fmt.Errorf("could not get %s port: %w", browser, err) } return fmt.Sprintf("ws://%s:%d/"+browser, host, p), execCancel, nil } func port(ctx context.Context, container testcontainers.Container, host string, port int) (int, error) { p, err := container.MappedPort(ctx, nat.Port(fmt.Sprintf("%d/tcp", port))) if err != nil { return 0, fmt.Errorf("could not get browser port: %w", err) } if err := Wait4Port(fmt.Sprintf("http://%s:%d", host, p.Int())); err != nil { return 0, fmt.Errorf("timeout, could not connect to browser container: %w", err) } return p.Int(), nil } func noTagVersion(version string, verbose bool) (string, error) { splitted := strings.Split(version, ".") if len(splitted) != 3 { return "", fmt.Errorf("invalid version format: %s", version) } imageVersion := fmt.Sprintf("v0.%s%02s.0", splitted[1], splitted[2]) if imageVersion == "v0.5101.0" { // Workaround our CI having failed publishing this version imageVersion = "v0.5101.1" } found := false found, imageVersion = getPlaywrightCIGoFromBuildInfo(imageVersion, verbose) if !found { _, imageVersion = getPlaywrightCIGoFromGoList(imageVersion, verbose) } return imageVersion, nil } func filterVersion(version string) string { parts := strings.Split(version, "-") if len(parts) > 0 { return parts[0] } return version } func getPlaywrightCIGoGitVersion(imageVersion string, verbose bool) (bool, string) { cmd := exec.Command("git", "describe", "--tags") output, err := cmd.Output() if err != nil { if verbose { log.Printf("could not get git version: %v\n", err) } return false, imageVersion } imageVersion = filterVersion(strings.TrimSpace(string(output))) if verbose { log.Println("Using version from git:", imageVersion) } return true, imageVersion } func getPlaywrightCIGoFromBuildInfo(imageVersion string, verbose bool) (bool, string) { if info, ok := debug.ReadBuildInfo(); !ok { for _, deps := range info.Deps { if strings.Contains(deps.Path, "github.com/mountain-reverie/playwright-ci-go") { if len(deps.Version) > 0 && deps.Version[0] == 'v' { if verbose { log.Println("Using version from build info:", deps.Version) } return true, deps.Version } } } } return false, imageVersion } func getPlaywrightCIGoFromGoList(imageVersion string, verbose bool) (bool, string) { cmd := exec.Command("go", "list", "-json", "-m", "all") output, err := cmd.StdoutPipe() if err != nil { if verbose { log.Printf("could not get stdout pipe: %v\n", err) } return false, imageVersion } if err := cmd.Start(); err != nil { if verbose { log.Printf("could not start command: %v\n", err) } return false, imageVersion } defer func() { _ = cmd.Wait() }() return parseGoListJSONStream(output, imageVersion, verbose) } func parseGoListJSONStream(output io.Reader, imageVersion string, verbose bool) (bool, string) { decoder := json.NewDecoder(output) for { var mod module if err := decoder.Decode(&mod); err != nil { if err == io.EOF { break } if verbose { log.Printf("could not decode module: %v\n", err) } return false, imageVersion } if strings.Contains(mod.Path, "github.com/mountain-reverie/playwright-ci-go") { if mod.Main { return getPlaywrightCIGoGitVersion(imageVersion, verbose) } else if len(mod.Version) > 0 && mod.Version[0] == 'v' { if verbose { log.Println("Using version from go list:", mod.Version) } return true, mod.Version } else { if verbose { log.Println("No version found in go list for playwright-ci-go module") } return false, imageVersion } } } if verbose { log.Println("No build, module or git info found. Keeping version as:", imageVersion) } return false, imageVersion }
// Package playwrightcigo provides a containerized solution for running // Playwright browser tests in Go with consistent behavior across environments. package playwrightcigo import ( "fmt" "sync" "github.com/playwright-community/playwright-go" ) var pw *playwright.Playwright var browsers *container var count = 0 var mutex sync.Mutex // Install sets up the containerized Playwright environment. // It installs the Playwright driver and creates a container to run the browsers. // This function should be called once before using any browser. // Options can be provided to customize the installation behavior. // Multiple calls to Install are supported, but only the first one will // perform the actual installation. // Generally you want to call this in your TestMain function. func Install(opts ...Option) error { mutex.Lock() defer mutex.Unlock() if count > 0 { count++ return nil } c := config{} for _, opt := range opts { opt.apply(&c) } driver, err := playwright.NewDriver(&playwright.RunOptions{SkipInstallBrowsers: true, Verbose: c.verbose}) if err != nil { return fmt.Errorf("error while setting up driver: %w", err) } if err := driver.Install(); err != nil { return fmt.Errorf("error while installing driver: %w", err) } pw, err = playwright.Run() if err != nil { return fmt.Errorf("error while starting to run playwright: %w", err) } browsers, err = new(driver.Version, opts...) if err != nil { return err } count++ return nil } // Uninstall cleans up resources created by Install. // This function should be called when you're finished with all browser testing. // There should be as many calls to Uninstall as there were calls to Install. // Generally you want to call this in your TestMain function. func Uninstall() error { mutex.Lock() defer mutex.Unlock() count-- if count > 0 { return nil } for chromium.count > 0 { chromium.cancel() } for firefox.count > 0 { firefox.cancel() } for webkit.count > 0 { webkit.cancel() } if err := browsers.Close(); err != nil { return fmt.Errorf("could not close container: %w", err) } if err := pw.Stop(); err != nil { return fmt.Errorf("could not stop playwright: %w", err) } return nil }
package playwrightcigo import ( "context" "time" ) // Option is an interface for configuring the behavior of playwright-ci-go functions. type Option interface { apply(*config) } var _ Option = (*optionFunc)(nil) type optionFunc func(*config) func (f optionFunc) apply(c *config) { f(c) } // WithContext provides a context for cancellation and timeout control. // This option allows you to control when operations should be canceled. func WithContext(ctx context.Context) Option { return optionFunc(func(c *config) { c.ctx = ctx }) } // WithTimeout sets the timeout of the container. // The default timeout is 5 minutes. func WithTimeout(timeout time.Duration) Option { return optionFunc(func(c *config) { c.timeout = timeout }) } // WithRetry configures the number of retry attempts when setting up the proxy. // The default is 15 retries. func WithRetry(count int) Option { return optionFunc(func(c *config) { if count > 0 { c.retry = count } }) } // WithSleeping sets the sleep duration between retry attempts when setting up the proxy. // The default is 200 milliseconds. func WithSleeping(sleeping time.Duration) Option { return optionFunc(func(c *config) { if sleeping > 0 { c.sleeping = sleeping } }) } // WithRepository sets a custom container repository and tag. // This is useful for using custom container images or specific versions. // The default repository is "ghcr.io/mountain-reverie/playwright-ci-go". func WithRepository(repository, tag string) Option { return optionFunc(func(c *config) { if repository != "" { c.repository = repository } if tag != "" { c.tag = tag } }) } func WithVerbose() Option { return optionFunc(func(c *config) { c.verbose = true }) }
package playwrightcigo import ( "context" "errors" "fmt" "log" "net" "net/http" "strconv" "time" "github.com/elazarl/goproxy" "github.com/testcontainers/testcontainers-go" ) func transparentProxy(retry int, sleeping time.Duration, verbose bool) (string, int, func()) { // Listen for incoming connections l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { log.Fatal("Error listening:", err) } proxy := goproxy.NewProxyHttpServer() proxy.Verbose = verbose srv := &http.Server{ Handler: proxy, ReadHeaderTimeout: time.Second * 5, // Set a reasonable ReadHeaderTimeout value } go func() { err := srv.Serve(l) if err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("Error serving proxy: %v", err) } }() _, portStr, err := net.SplitHostPort(l.Addr().String()) if err != nil { log.Fatalf("Failed to parse address %s: %v", l.Addr().String(), err) } port, err := strconv.ParseInt(portStr, 10, 64) if err != nil { log.Fatalf("Failed to parse port number from address %s: %v", l.Addr().String(), err) } // Ensure the port number is within the valid range for a 16-bit unsigned integer if port < 0 || port > 65535 { log.Fatalf("Parsed port number %d is out of valid range (0-65535)", port) } if err := Wait4Port("http://"+l.Addr().String(), WithRetry(retry), WithSleeping(sleeping)); err != nil { log.Fatalf("Could not connect to proxy: %s", err) } return "http://" + testcontainers.HostInternal + ":" + portStr, int(port), func() { _ = srv.Shutdown(context.Background()) _ = l.Close() } } // Wait4Port checks if a network service is available at the given address. // It retries according to the provided options. // This is useful for ensuring that servers are ready before connecting to them. // // Parameters: // - addr: The URL to check (e.g. "http://localhost:8080") // - opts: Configuration options for retries and timeout func Wait4Port(addr string, opts ...Option) error { c := &config{ sleeping: 200 * time.Millisecond, retry: 15, verbose: false, ctx: context.Background(), } for _, opt := range opts { opt.apply(c) } if err := SleepWithContext(c.ctx, c.sleeping); err != nil { return err } for i := 0; i < c.retry; i++ { req, err := http.NewRequestWithContext(c.ctx, "GET", addr, nil) if err != nil { return fmt.Errorf("could not create a request to %s: %w", addr, err) } resp, err := http.DefaultClient.Do(req) if err != nil { if c.verbose { log.Println("could not connect to", addr, "yet, error", err, "retrying in", c.sleeping) } if err := SleepWithContext(c.ctx, c.sleeping); err != nil { return err } continue } if err := resp.Body.Close(); err != nil { return fmt.Errorf("could not close response body: %w", err) } return nil } return fmt.Errorf("could not connect to %s after retry and timeout", addr) } // SleepWithContext sleeps for the specified duration or until the context is canceled. // It returns nil if the sleep completes or the context's error if canceled early. func SleepWithContext(ctx context.Context, d time.Duration) error { select { case <-time.After(d): return nil case <-ctx.Done(): return ctx.Err() } }