dt_automate/vendor/github.com/playwright-community/playwright-go/run.go
2025-02-19 18:30:19 +08:00

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
}