410 lines
11 KiB
Go
410 lines
11 KiB
Go
package playwright
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
)
|
|
|
|
const playwrightCliVersion = "1.50.1"
|
|
|
|
var (
|
|
logger = slog.Default()
|
|
playwrightCDNMirrors = []string{
|
|
"https://playwright.azureedge.net",
|
|
"https://playwright-akamai.azureedge.net",
|
|
"https://playwright-verizon.azureedge.net",
|
|
}
|
|
)
|
|
|
|
// PlaywrightDriver wraps the Playwright CLI of upstream Playwright.
|
|
//
|
|
// It's required for playwright-go to work.
|
|
type PlaywrightDriver struct {
|
|
Version string
|
|
options *RunOptions
|
|
}
|
|
|
|
func NewDriver(options ...*RunOptions) (*PlaywrightDriver, error) {
|
|
transformed, err := transformRunOptions(options...) // get default values
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &PlaywrightDriver{
|
|
options: transformed,
|
|
Version: playwrightCliVersion,
|
|
}, nil
|
|
}
|
|
|
|
func getDefaultCacheDirectory() (string, error) {
|
|
userHomeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("could not get user home directory: %w", err)
|
|
}
|
|
switch runtime.GOOS {
|
|
case "windows":
|
|
return filepath.Join(userHomeDir, "AppData", "Local"), nil
|
|
case "darwin":
|
|
return filepath.Join(userHomeDir, "Library", "Caches"), nil
|
|
case "linux":
|
|
return filepath.Join(userHomeDir, ".cache"), nil
|
|
}
|
|
return "", errors.New("could not determine cache directory")
|
|
}
|
|
|
|
func (d *PlaywrightDriver) isUpToDateDriver() (bool, error) {
|
|
if _, err := os.Stat(d.options.DriverDirectory); os.IsNotExist(err) {
|
|
if err := os.MkdirAll(d.options.DriverDirectory, 0o777); err != nil {
|
|
return false, fmt.Errorf("could not create driver directory: %w", err)
|
|
}
|
|
}
|
|
if _, err := os.Stat(getDriverCliJs(d.options.DriverDirectory)); os.IsNotExist(err) {
|
|
return false, nil
|
|
} else if err != nil {
|
|
return false, fmt.Errorf("could not check if driver is up2date: %w", err)
|
|
}
|
|
cmd := d.Command("--version")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return false, fmt.Errorf("could not run driver: %w", err)
|
|
}
|
|
if bytes.Contains(output, []byte(d.Version)) {
|
|
return true, nil
|
|
}
|
|
// avoid triggering downloads and accidentally overwriting files
|
|
return false, fmt.Errorf("driver exists but version not %s in : %s", d.Version, d.options.DriverDirectory)
|
|
}
|
|
|
|
// Command returns an exec.Cmd for the driver.
|
|
func (d *PlaywrightDriver) Command(arg ...string) *exec.Cmd {
|
|
cmd := exec.Command(getNodeExecutable(d.options.DriverDirectory), append([]string{getDriverCliJs(d.options.DriverDirectory)}, arg...)...)
|
|
cmd.SysProcAttr = defaultSysProcAttr
|
|
return cmd
|
|
}
|
|
|
|
// Install downloads the driver and the browsers depending on [RunOptions].
|
|
func (d *PlaywrightDriver) Install() error {
|
|
if err := d.DownloadDriver(); err != nil {
|
|
return fmt.Errorf("could not install driver: %w", err)
|
|
}
|
|
if d.options.SkipInstallBrowsers {
|
|
return nil
|
|
}
|
|
|
|
d.log("Downloading browsers...")
|
|
if err := d.installBrowsers(); err != nil {
|
|
return fmt.Errorf("could not install browsers: %w", err)
|
|
}
|
|
d.log("Downloaded browsers successfully")
|
|
|
|
return nil
|
|
}
|
|
|
|
// Uninstall removes the driver and the browsers.
|
|
func (d *PlaywrightDriver) Uninstall() error {
|
|
d.log("Removing browsers...")
|
|
if err := d.uninstallBrowsers(); err != nil {
|
|
return fmt.Errorf("could not uninstall browsers: %w", err)
|
|
}
|
|
|
|
d.log("Removing driver...")
|
|
if err := os.RemoveAll(d.options.DriverDirectory); err != nil {
|
|
return fmt.Errorf("could not remove driver directory: %w", err)
|
|
}
|
|
|
|
d.log("Uninstall driver successfully")
|
|
return nil
|
|
}
|
|
|
|
// DownloadDriver downloads the driver only
|
|
func (d *PlaywrightDriver) DownloadDriver() error {
|
|
up2Date, err := d.isUpToDateDriver()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if up2Date {
|
|
return nil
|
|
}
|
|
|
|
d.log("Downloading driver", "path", d.options.DriverDirectory)
|
|
|
|
body, err := downloadDriver(d.getDriverURLs())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
zipReader, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
|
if err != nil {
|
|
return fmt.Errorf("could not read zip content: %w", err)
|
|
}
|
|
|
|
for _, zipFile := range zipReader.File {
|
|
zipFileDiskPath := filepath.Join(d.options.DriverDirectory, zipFile.Name)
|
|
if zipFile.FileInfo().IsDir() {
|
|
if err := os.MkdirAll(zipFileDiskPath, os.ModePerm); err != nil {
|
|
return fmt.Errorf("could not create directory: %w", err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
outFile, err := os.Create(zipFileDiskPath)
|
|
if err != nil {
|
|
return fmt.Errorf("could not create driver: %w", err)
|
|
}
|
|
file, err := zipFile.Open()
|
|
if err != nil {
|
|
return fmt.Errorf("could not open zip file: %w", err)
|
|
}
|
|
if _, err = io.Copy(outFile, file); err != nil {
|
|
return fmt.Errorf("could not copy response body to file: %w", err)
|
|
}
|
|
if err := outFile.Close(); err != nil {
|
|
return fmt.Errorf("could not close file (driver): %w", err)
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
return fmt.Errorf("could not close file (zip file): %w", err)
|
|
}
|
|
if zipFile.Mode().Perm()&0o100 != 0 && runtime.GOOS != "windows" {
|
|
if err := makeFileExecutable(zipFileDiskPath); err != nil {
|
|
return fmt.Errorf("could not make executable: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
d.log("Downloaded driver successfully")
|
|
|
|
return nil
|
|
}
|
|
|
|
func (d *PlaywrightDriver) log(msg string, args ...any) {
|
|
if d.options.Verbose {
|
|
logger.Info(msg, args...)
|
|
}
|
|
}
|
|
|
|
func (d *PlaywrightDriver) run() (*connection, error) {
|
|
transport, err := newPipeTransport(d, d.options.Stderr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
connection := newConnection(transport)
|
|
return connection, nil
|
|
}
|
|
|
|
func (d *PlaywrightDriver) installBrowsers() error {
|
|
additionalArgs := []string{"install"}
|
|
if d.options.Browsers != nil {
|
|
additionalArgs = append(additionalArgs, d.options.Browsers...)
|
|
}
|
|
|
|
if d.options.OnlyInstallShell {
|
|
additionalArgs = append(additionalArgs, "--only-shell")
|
|
}
|
|
|
|
if d.options.DryRun {
|
|
additionalArgs = append(additionalArgs, "--dry-run")
|
|
}
|
|
|
|
cmd := d.Command(additionalArgs...)
|
|
cmd.Stdout = d.options.Stdout
|
|
cmd.Stderr = d.options.Stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
func (d *PlaywrightDriver) uninstallBrowsers() error {
|
|
cmd := d.Command("uninstall")
|
|
cmd.Stdout = d.options.Stdout
|
|
cmd.Stderr = d.options.Stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
// RunOptions are custom options to run the driver
|
|
type RunOptions struct {
|
|
// DriverDirectory points to the playwright driver directory.
|
|
// It should have two subdirectories: node and package.
|
|
// You can also specify it using the environment variable PLAYWRIGHT_DRIVER_PATH.
|
|
//
|
|
// Default is user cache directory + "/ms-playwright-go/x.xx.xx":
|
|
// - Windows: %USERPROFILE%\AppData\Local
|
|
// - macOS: ~/Library/Caches
|
|
// - Linux: ~/.cache
|
|
DriverDirectory string
|
|
// OnlyInstallShell only downloads the headless shell. (For chromium browsers only)
|
|
OnlyInstallShell bool
|
|
SkipInstallBrowsers bool
|
|
// if not set and SkipInstallBrowsers is false, will download all browsers (chromium, firefox, webkit)
|
|
Browsers []string
|
|
Verbose bool // default true
|
|
Stdout io.Writer
|
|
Stderr io.Writer
|
|
Logger *slog.Logger
|
|
// DryRun does not install browser/dependencies. It will only print information.
|
|
DryRun bool
|
|
}
|
|
|
|
// Install does download the driver and the browsers.
|
|
//
|
|
// Use this before playwright.Run() or use playwright cli to install the driver and browsers
|
|
func Install(options ...*RunOptions) error {
|
|
driver, err := NewDriver(options...)
|
|
if err != nil {
|
|
return fmt.Errorf("could not get driver instance: %w", err)
|
|
}
|
|
if err := driver.Install(); err != nil {
|
|
return fmt.Errorf("could not install driver: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Run starts a Playwright instance.
|
|
//
|
|
// Requires the driver and the browsers to be installed before.
|
|
// Either use Install() or use playwright cli.
|
|
func Run(options ...*RunOptions) (*Playwright, error) {
|
|
driver, err := NewDriver(options...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not get driver instance: %w", err)
|
|
}
|
|
up2date, err := driver.isUpToDateDriver()
|
|
if err != nil || !up2date {
|
|
return nil, fmt.Errorf("please install the driver (v%s) first: %w", playwrightCliVersion, err)
|
|
}
|
|
connection, err := driver.run()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
playwright, err := connection.Start()
|
|
return playwright, err
|
|
}
|
|
|
|
func transformRunOptions(options ...*RunOptions) (*RunOptions, error) {
|
|
option := &RunOptions{
|
|
Verbose: true,
|
|
}
|
|
if len(options) == 1 {
|
|
option = options[0]
|
|
}
|
|
if option.DriverDirectory == "" { // if user did not set it, try to get it from env
|
|
option.DriverDirectory = os.Getenv("PLAYWRIGHT_DRIVER_PATH")
|
|
}
|
|
if option.DriverDirectory == "" {
|
|
cacheDirectory, err := getDefaultCacheDirectory()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not get default cache directory: %w", err)
|
|
}
|
|
option.DriverDirectory = filepath.Join(cacheDirectory, "ms-playwright-go", playwrightCliVersion)
|
|
}
|
|
if option.Stdout == nil {
|
|
option.Stdout = os.Stdout
|
|
}
|
|
if option.Stderr == nil {
|
|
option.Stderr = os.Stderr
|
|
} else if option.Logger == nil {
|
|
log.SetOutput(option.Stderr)
|
|
}
|
|
if option.Logger != nil {
|
|
logger = option.Logger
|
|
}
|
|
return option, nil
|
|
}
|
|
|
|
func getNodeExecutable(driverDirectory string) string {
|
|
envPath := os.Getenv("PLAYWRIGHT_NODEJS_PATH")
|
|
if envPath != "" {
|
|
return envPath
|
|
}
|
|
|
|
node := "node"
|
|
if runtime.GOOS == "windows" {
|
|
node = "node.exe"
|
|
}
|
|
return filepath.Join(driverDirectory, node)
|
|
}
|
|
|
|
func getDriverCliJs(driverDirectory string) string {
|
|
return filepath.Join(driverDirectory, "package", "cli.js")
|
|
}
|
|
|
|
func (d *PlaywrightDriver) getDriverURLs() []string {
|
|
platform := ""
|
|
switch runtime.GOOS {
|
|
case "windows":
|
|
platform = "win32_x64"
|
|
case "darwin":
|
|
if runtime.GOARCH == "arm64" {
|
|
platform = "mac-arm64"
|
|
} else {
|
|
platform = "mac"
|
|
}
|
|
case "linux":
|
|
if runtime.GOARCH == "arm64" {
|
|
platform = "linux-arm64"
|
|
} else {
|
|
platform = "linux"
|
|
}
|
|
}
|
|
|
|
baseURLs := []string{}
|
|
pattern := "%s/builds/driver/playwright-%s-%s.zip"
|
|
if !d.isReleaseVersion() {
|
|
pattern = "%s/builds/driver/next/playwright-%s-%s.zip"
|
|
}
|
|
|
|
if hostEnv := os.Getenv("PLAYWRIGHT_DOWNLOAD_HOST"); hostEnv != "" {
|
|
baseURLs = append(baseURLs, fmt.Sprintf(pattern, hostEnv, d.Version, platform))
|
|
} else {
|
|
for _, mirror := range playwrightCDNMirrors {
|
|
baseURLs = append(baseURLs, fmt.Sprintf(pattern, mirror, d.Version, platform))
|
|
}
|
|
}
|
|
return baseURLs
|
|
}
|
|
|
|
// isReleaseVersion checks if the version is not a beta or alpha release
|
|
// this helps to determine the url from where to download the driver
|
|
func (d *PlaywrightDriver) isReleaseVersion() bool {
|
|
return !strings.Contains(d.Version, "beta") && !strings.Contains(d.Version, "alpha") && !strings.Contains(d.Version, "next")
|
|
}
|
|
|
|
func makeFileExecutable(path string) error {
|
|
stats, err := os.Stat(path)
|
|
if err != nil {
|
|
return fmt.Errorf("could not stat driver: %w", err)
|
|
}
|
|
if err := os.Chmod(path, stats.Mode()|0x40); err != nil {
|
|
return fmt.Errorf("could not set permissions: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func downloadDriver(driverURLs []string) (body []byte, e error) {
|
|
for _, driverURL := range driverURLs {
|
|
resp, err := http.Get(driverURL)
|
|
if err != nil {
|
|
e = errors.Join(e, fmt.Errorf("could not download driver from %s: %w", driverURL, err))
|
|
continue
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
e = errors.Join(e, fmt.Errorf("error: got non 200 status code: %d (%s) from %s", resp.StatusCode, resp.Status, driverURL))
|
|
continue
|
|
}
|
|
body, err = io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
e = errors.Join(e, fmt.Errorf("could not read response body: %w", err))
|
|
continue
|
|
}
|
|
return body, nil
|
|
}
|
|
return nil, e
|
|
}
|