feat(node,python): opt-in Playwright browser installation (#653)

* feat: automatically install packages required for playwright, and playwright browser

This reverts commit 2a5567ff09fa044cf391b1d79ecf1fe7846e8828.

* docs: update environment variable test configuration property name

Generated-by: aiautocommit

* docs(node): add production deployment guide link to start command help

Generated-by: aiautocommit

* feat(python-playwright): install playwright only when requested via env

- Enable Playwright browser installation conditionally using `RAILPACK_PYTHON_PLAYWRIGHT_INSTALL`.
- Update python-playwright example to test against example.com.

Generated-by: aiautocommit

* feat(python): make Playwright browser installation opt-in

- Require `RAILPACK_PYTHON_PLAYWRIGHT_INSTALL=1` to install Playwright browsers and their runtime dependencies automatically.
- Prevent unintended deployment size increases and breaking changes for existing users since automatic installation can cause issues.
- Add test coverage verifying opt-in behavior and production dependency resolution.

Generated-by: aiautocommit

* docs(python): document playwright install configuration variable

Generated-by: aiautocommit

* feat(node): add support for checking production-only dependencies

Generated-by: aiautocommit

* feat(node): make Playwright browser installation opt-in

- Restrict automatic Playwright detection to production dependencies and require an explicit environment variable to install browsers, preventing unexpected side effects for existing users.
- Add tests to verify Playwright installation behavior based on environment configuration.

Generated-by: aiautocommit

* docs: add unit testing guideline against testing log output

Generated-by: aiautocommit

* chore(deps): update playwright dependency to v1.60 in node-playwright example

Generated-by: aiautocommit

* feat(node,python): update Playwright runtime dependencies for Debian 12

- Align Playwright runtime dependencies with the official nativeDeps.ts list for Debian 12 Chromium.
- Ensure production runtime images include all required system libraries for Playwright without relying on builder-only installation flags.

Generated-by: aiautocommit

* feat(examples): navigate to example.com in playwright node example

Generated-by: aiautocommit

* docs(node): document opt-in Playwright browser installation

Generated-by: aiautocommit

* test(core): update test snapshots for playwright production-only dependencies

Generated-by: aiautocommit

* docs: add instruction regarding snapshot updates to PR template

Generated-by: aiautocommit

* feat(node): remove libatomic1 requirement for Node.js 25+

Generated-by: aiautocommit

* test(core): update playwright snapshots for mise version and apt caches

Generated-by: aiautocommit
This commit is contained in:
Michael Bianco
2026-07-28 08:10:48 -06:00
committed by GitHub
parent e016c11725
commit 94402f3684
27 changed files with 1153 additions and 30 deletions
+2 -2
View File
@@ -13,8 +13,8 @@
## Test
<!-- how is this change tested? -->
<!-- how is this change tested? Do not indicate that snapshots were updated -->
## Links
<!-- link(s) to any relevant external documentation, github issues this fixes or is connected to , etc-->
<!-- link(s) to any relevant external documentation, github issues this fixes or is connected to , etc-->
+5 -1
View File
@@ -72,7 +72,11 @@ There are normal unit tests, snapshot tests, and integration tests. The integrat
* In `test.json` we should avoid using `justBuild` for all but the most simple projects. `justBuild` does not test `expectedOutput` or any other assertions.
* If the project has a server component, we should use a `httpCheck` test. Read the @docs/src/content/docs/guides/developing-locally.md guide, specifically the `### HTTP Checks` section for more information.
* `httpCheck` assertions assume that `$PORT` is respected.
* You can use `"env": { "SECRET": "123"}` to add a required environment variable to a test case.
* You can use `"envs": { "SECRET": "123"}` to add a required environment variable to a test case.
## Unit tests
* Do not test log output.
# File Conventions
@@ -0,0 +1,186 @@
{
"caches": {
"apt": {
"directory": "/var/cache/apt",
"type": "locked"
},
"apt-lists": {
"directory": "/var/lib/apt/lists",
"type": "locked"
},
"node-modules": {
"directory": "/app/node_modules/.cache",
"type": "shared"
},
"pnpm-install": {
"directory": "/opt/pnpm/store",
"type": "shared"
}
},
"deploy": {
"base": {
"step": "packages:apt:runtime"
},
"inputs": [
{
"include": [
"/mise/shims",
"/mise/installs",
"/usr/local/bin/mise",
"/etc/mise/config.toml",
"/root/.local/state/mise"
],
"step": "packages:mise"
},
{
"include": [
"/app/node_modules"
],
"step": "build"
},
{
"exclude": [
"node_modules",
".yarn"
],
"include": [
"/root/.cache",
"."
],
"step": "build"
}
],
"startCommand": "node index.js",
"variables": {
"CI": "true",
"NODE_ENV": "production",
"NPM_CONFIG_FETCH_RETRIES": "5",
"NPM_CONFIG_FUND": "false",
"NPM_CONFIG_PRODUCTION": "false",
"NPM_CONFIG_UPDATE_NOTIFIER": "false",
"RAILPACK_VERSION": "dev"
}
},
"steps": [
{
"assets": {
"generated-mise-toml": "[generated-mise-toml]"
},
"commands": [
{
"path": "/mise/shims"
},
{
"dest": "mise.toml",
"src": "mise.toml"
},
{
"customName": "create mise config",
"name": "generated-mise-toml",
"path": "/etc/mise/config.toml"
},
{
"cmd": "mise install",
"customName": "install mise packages: node, pnpm"
}
],
"inputs": [
{
"image": "ghcr.io/railwayapp/railpack-builder:mise-2026.7.15"
}
],
"name": "packages:mise",
"variables": {
"MISE_CACHE_DIR": "/mise/cache",
"MISE_CONFIG_DIR": "/mise",
"MISE_DATA_DIR": "/mise",
"MISE_INSTALLS_DIR": "/mise/installs",
"MISE_SHIMS_DIR": "/mise/shims"
}
},
{
"caches": [
"pnpm-install"
],
"commands": [
{
"path": "/app/node_modules/.bin"
},
{
"cmd": "mkdir -p /app/node_modules/.cache"
},
{
"dest": "package.json",
"src": "package.json"
},
{
"dest": "pnpm-lock.yaml",
"src": "pnpm-lock.yaml"
},
{
"path": "/opt/pnpm/bin"
},
{
"cmd": "pnpm add -g node-gyp"
},
{
"cmd": "pnpm install --frozen-lockfile --prefer-offline"
}
],
"inputs": [
{
"step": "packages:mise"
}
],
"name": "install",
"variables": {
"CI": "true",
"NODE_ENV": "production",
"NPM_CONFIG_FETCH_RETRIES": "5",
"NPM_CONFIG_FUND": "false",
"NPM_CONFIG_PRODUCTION": "false",
"NPM_CONFIG_UPDATE_NOTIFIER": "false",
"PNPM_HOME": "/opt/pnpm",
"PNPM_STORE_DIR": "/opt/pnpm/store"
}
},
{
"caches": [
"node-modules"
],
"inputs": [
{
"step": "install"
},
{
"include": [
"."
],
"local": true
}
],
"name": "build",
"secrets": [
"*"
]
},
{
"caches": [
"apt",
"apt-lists"
],
"commands": [
{
"cmd": "sh -c 'apt-get update \u0026\u0026 apt-get install -y libatomic1'",
"customName": "install apt packages: libatomic1"
}
],
"inputs": [
{
"image": "ghcr.io/railwayapp/railpack-runtime:mise-2026.7.15"
}
],
"name": "packages:apt:runtime"
}
]
}
@@ -0,0 +1,153 @@
{
"caches": {
"uv": {
"directory": "/opt/uv-cache",
"type": "shared"
}
},
"deploy": {
"base": {
"image": "ghcr.io/railwayapp/railpack-runtime:mise-2026.7.15"
},
"inputs": [
{
"include": [
"/mise/shims",
"/mise/installs",
"/usr/local/bin/mise",
"/etc/mise/config.toml",
"/root/.local/state/mise"
],
"step": "packages:mise"
},
{
"include": [
"/app/.venv"
],
"step": "build"
},
{
"exclude": [
".venv"
],
"include": [
"."
],
"step": "build"
}
],
"startCommand": "python main.py",
"variables": {
"PIP_DEFAULT_TIMEOUT": "100",
"PIP_DISABLE_PIP_VERSION_CHECK": "1",
"PYTHONDONTWRITEBYTECODE": "1",
"PYTHONFAULTHANDLER": "1",
"PYTHONHASHSEED": "random",
"PYTHONUNBUFFERED": "1",
"RAILPACK_VERSION": "dev"
}
},
"steps": [
{
"assets": {
"generated-mise-toml": "[generated-mise-toml]"
},
"commands": [
{
"path": "/mise/shims"
},
{
"dest": "mise.toml",
"src": "mise.toml"
},
{
"customName": "create mise config",
"name": "generated-mise-toml",
"path": "/etc/mise/config.toml"
},
{
"cmd": "mise install",
"customName": "install mise packages: python, uv"
}
],
"inputs": [
{
"image": "ghcr.io/railwayapp/railpack-builder:mise-2026.7.15"
}
],
"name": "packages:mise",
"variables": {
"MISE_CACHE_DIR": "/mise/cache",
"MISE_CONFIG_DIR": "/mise",
"MISE_DATA_DIR": "/mise",
"MISE_INSTALLS_DIR": "/mise/installs",
"MISE_SHIMS_DIR": "/mise/shims"
}
},
{
"caches": [
"uv"
],
"commands": [
{
"dest": "pyproject.toml",
"src": "pyproject.toml"
},
{
"dest": "uv.lock",
"src": "uv.lock"
},
{
"path": "/root/.local/bin"
},
{
"path": "/app/.venv/bin"
},
{
"cmd": "uv sync --locked --no-dev --no-install-project"
}
],
"inputs": [
{
"step": "packages:mise"
}
],
"name": "install",
"variables": {
"PIP_DEFAULT_TIMEOUT": "100",
"PIP_DISABLE_PIP_VERSION_CHECK": "1",
"PYTHONDONTWRITEBYTECODE": "1",
"PYTHONFAULTHANDLER": "1",
"PYTHONHASHSEED": "random",
"PYTHONUNBUFFERED": "1",
"UV_CACHE_DIR": "/opt/uv-cache",
"UV_COMPILE_BYTECODE": "1",
"UV_LINK_MODE": "copy",
"UV_PYTHON_DOWNLOADS": "never",
"VIRTUAL_ENV": "/app/.venv"
}
},
{
"commands": [
{
"cmd": "uv sync --locked --no-dev --no-editable"
}
],
"inputs": [
{
"step": "install"
},
{
"include": [
"."
],
"local": true
}
],
"name": "build",
"secrets": [
"*"
]
}
]
}
+64 -6
View File
@@ -20,12 +20,45 @@ const (
DEFAULT_NODE_VERSION = "22"
DEFAULT_BUN_VERSION = "latest"
COREPACK_HOME = "/opt/corepack"
// not used by npm, but many other tools: next, jest, webpack, etc
NODE_MODULES_CACHE = "/app/node_modules/.cache"
NODE_MODULES_CACHE = "/app/node_modules/.cache"
COREPACK_HOME = "/opt/corepack"
PLAYWRIGHT_CACHE_DIR = "/root/.cache/ms-playwright"
PLAYWRIGHT_INSTALL_VAR = "NODE_PLAYWRIGHT_INSTALL"
)
var nodeRuntimeDepRequirements = map[string][]string{
// To find the latest list: run `npx puppeteer@latest install --help` and inspect docs
"puppeteer": {"xvfb", "gconf-service", "libasound2", "libatk1.0-0", "libc6", "libcairo2", "libcups2", "libdbus-1-3", "libexpat1", "libfontconfig1", "libgbm1", "libgcc1", "libgconf-2-4", "libgdk-pixbuf2.0-0", "libglib2.0-0", "libgtk-3-0", "libnspr4", "libpango-1.0-0", "libpangocairo-1.0-0", "libstdc++6", "libx11-6", "libx11-xcb1", "libxcb1", "libxcomposite1", "libxcursor1", "libxdamage1", "libxext6", "libxfixes3", "libxi6", "libxrandr2", "libxrender1", "libxss1", "libxtst6", "ca-certificates", "fonts-liberation", "libappindicator1", "libnss3", "lsb-release", "xdg-utils", "wget"},
}
// Keep this aligned with the Debian 12 Chromium list in Playwright's nativeDeps.ts:
// https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/server/registry/nativeDeps.ts
// Keep these explicit because --with-deps installs them in the builder, not the runtime image.
var nodePlaywrightRuntimeDependencies = []string{
"libasound2",
"libatk-bridge2.0-0",
"libatk1.0-0",
"libatspi2.0-0",
"libcairo2",
"libcups2",
"libdbus-1-3",
"libdrm2",
"libgbm1",
"libglib2.0-0",
"libnspr4",
"libnss3",
"libpango-1.0-0",
"libx11-6",
"libxcb1",
"libxcomposite1",
"libxdamage1",
"libxext6",
"libxfixes3",
"libxkbcommon0",
"libxrandr2",
}
var (
// bunCommandRegex matches "bun" or "bunx" as a command (not part of another word)
bunCommandRegex = regexp.MustCompile(`(^|\s|;|&|&&|\||\|\|)bunx?\s`)
@@ -143,9 +176,29 @@ func (p *NodeProvider) Plan(ctx *generate.GenerateContext) error {
}
runtimeAptPackages := []string{}
if p.usesPuppeteer() {
ctx.Logger.LogInfo("Installing puppeteer dependencies")
runtimeAptPackages = append(runtimeAptPackages, "xvfb", "gconf-service", "libasound2", "libatk1.0-0", "libc6", "libcairo2", "libcups2", "libdbus-1-3", "libexpat1", "libfontconfig1", "libgbm1", "libgcc1", "libgconf-2-4", "libgdk-pixbuf2.0-0", "libglib2.0-0", "libgtk-3-0", "libnspr4", "libpango-1.0-0", "libpangocairo-1.0-0", "libstdc++6", "libx11-6", "libx11-xcb1", "libxcb1", "libxcomposite1", "libxcursor1", "libxdamage1", "libxext6", "libxfixes3", "libxi6", "libxrandr2", "libxrender1", "libxss1", "libxtst6", "ca-certificates", "fonts-liberation", "libappindicator1", "libnss3", "lsb-release", "xdg-utils", "wget")
ctx.Logger.LogInfo("Installing puppeteer packages")
runtimeAptPackages = append(runtimeAptPackages, nodeRuntimeDepRequirements["puppeteer"]...)
}
usesPlaywright := p.usesProductionPlaywright()
installPlaywright := ctx.Env.IsConfigVariableTruthy(PLAYWRIGHT_INSTALL_VAR)
// Automatic browser installation caused issues for an existing user, so keep this opt-in.
if usesPlaywright && !installPlaywright {
ctx.Logger.LogSuggestion(
"Set `RAILPACK_NODE_PLAYWRIGHT_INSTALL=1` to install Playwright browsers",
"/languages/node#playwright",
)
}
if installPlaywright {
ctx.Logger.LogInfo("Installing playwright packages and headless browser")
runtimeAptPackages = append(runtimeAptPackages, nodePlaywrightRuntimeDependencies...)
// --only-shell installs the smaller Chromium headless shell, which is
// more appropriate for server environments than full Chromium.
install.AddCommand(plan.NewExecCommand(p.packageManager.ExecCommand("playwright install --only-shell")))
}
nodeModulesLayer := plan.NewStepLayer(build.Name(), plan.Filter{
@@ -185,7 +238,8 @@ func (p *NodeProvider) StartCommandHelp() string {
"4. An Nx workspace with a Next.js app (nx.json + next)\n\n" +
"If you have a static site, you can set the RAILPACK_SPA_OUTPUT_DIR environment variable\n" +
"containing the directory of your built static files.\n\n" +
"For multi-app Nx workspaces, set RAILPACK_NX_APP to select which app to deploy."
"For multi-app Nx workspaces, set RAILPACK_NX_APP to select which app to deploy.\n\n" +
"For more information, see the Node.js production deployment guide: https://railway.app/docs/deploy/node"
}
func (p *NodeProvider) GetStartCommand(ctx *generate.GenerateContext) string {
@@ -450,6 +504,10 @@ func (p *NodeProvider) usesPuppeteer() bool {
return p.workspace.HasDependency("puppeteer")
}
func (p *NodeProvider) usesProductionPlaywright() bool {
return p.workspace.HasProductionDependency("playwright")
}
// determine the major version of yarn from a version string. These major versions are installed and managed quite
// differently which is why we need to distinguish them here.
func parseYarnPackageManager(pmVersion string) PackageManager {
+57
View File
@@ -6,6 +6,7 @@ import (
"github.com/railwayapp/railpack/core/app"
"github.com/railwayapp/railpack/core/generate"
"github.com/railwayapp/railpack/core/plan"
testingUtils "github.com/railwayapp/railpack/core/testing"
"github.com/stretchr/testify/require"
)
@@ -315,3 +316,59 @@ func TestUsesPnpmBinSubdir(t *testing.T) {
})
}
}
func TestPlaywrightInstallationIsOptIn(t *testing.T) {
t.Run("does not install by default", func(t *testing.T) {
ctx := testingUtils.CreateGenerateContext(t, "../../../examples/node-playwright")
provider := NodeProvider{}
require.NoError(t, provider.Initialize(ctx))
require.NoError(t, provider.Plan(ctx))
require.False(t, nodeStepHasExecCommand(
ctx,
"install",
"pnpm exec playwright install --only-shell",
))
require.NotContains(t, ctx.Deploy.AptPackages, "libnss3")
})
t.Run("installs when enabled", func(t *testing.T) {
ctx := testingUtils.CreateGenerateContext(t, "../../../examples/node-playwright")
ctx.Env.Variables["RAILPACK_NODE_PLAYWRIGHT_INSTALL"] = "1"
provider := NodeProvider{}
require.NoError(t, provider.Initialize(ctx))
require.NoError(t, provider.Plan(ctx))
require.True(t, nodeStepHasExecCommand(
ctx,
"install",
"pnpm exec playwright install --only-shell",
))
require.Contains(t, ctx.Deploy.AptPackages, "libnss3")
})
}
func nodeStepHasExecCommand(
ctx *generate.GenerateContext,
stepName string,
command string,
) bool {
step := ctx.GetStepByName(stepName)
if step == nil {
return false
}
commandStep, ok := (*step).(*generate.CommandStepBuilder)
if !ok {
return false
}
for _, candidate := range commandStep.Commands {
execCommand, ok := candidate.(plan.ExecCommand)
if ok && execCommand.Cmd == command {
return true
}
}
return false
}
+11 -4
View File
@@ -47,10 +47,8 @@ func (p *PackageJson) BuildScriptContains(value string) bool {
}
func (p *PackageJson) hasDependency(dependency string) bool {
if p.Dependencies != nil {
if _, ok := p.Dependencies[dependency]; ok {
return true
}
if p.hasProductionDependency(dependency) {
return true
}
if p.DevDependencies != nil {
@@ -62,6 +60,15 @@ func (p *PackageJson) hasDependency(dependency string) bool {
return false
}
func (p *PackageJson) hasProductionDependency(dependency string) bool {
if p.Dependencies == nil {
return false
}
_, ok := p.Dependencies[dependency]
return ok
}
func (p *PackageJson) hasLocalDependency() bool {
allDeps := make(map[string]string)
maps.Copy(allDeps, p.Dependencies)
+11
View File
@@ -71,6 +71,17 @@ func (p PackageManager) misePackageName() string {
}
}
func (p PackageManager) ExecCommand(cmd string) string {
switch p {
case PackageManagerPnpm:
return fmt.Sprintf("pnpm exec %s", cmd)
case PackageManagerBun:
return fmt.Sprintf("bunx %s", cmd)
default:
return fmt.Sprintf("npx %s", cmd)
}
}
func (p PackageManager) installDependencies(ctx *generate.GenerateContext, workspace *Workspace, install *generate.CommandStepBuilder, usingCorepack bool) {
packageJsons := workspace.AllPackageJson()
+14
View File
@@ -140,6 +140,20 @@ func (w *Workspace) HasDependency(dependency string) bool {
return false
}
func (w *Workspace) HasProductionDependency(dependency string) bool {
if w.Root.PackageJson.hasProductionDependency(dependency) {
return true
}
for _, pkg := range w.Packages {
if pkg.PackageJson.hasProductionDependency(dependency) {
return true
}
}
return false
}
func (w *Workspace) AllPackageJson() []*PackageJson {
packageJsons := []*PackageJson{w.Root.PackageJson}
+38
View File
@@ -158,3 +158,41 @@ func TestWorkspaceAllPackageJson(t *testing.T) {
})
}
}
func TestWorkspaceHasProductionDependency(t *testing.T) {
tests := []struct {
name string
path string
dependency string
want bool
}{
{
name: "root production dependency",
path: "../../../examples/node-playwright",
dependency: "playwright",
want: true,
},
{
name: "root development dependency",
path: "../../../examples/node-npm",
dependency: "typescript",
want: false,
},
{
name: "workspace production dependency",
path: "../../../examples/node-npm-workspaces",
dependency: "express",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := testingUtils.CreateGenerateContext(t, tt.path)
workspace, err := NewWorkspace(ctx.App)
require.NoError(t, err)
require.Equal(t, tt.want, workspace.HasProductionDependency(tt.dependency))
})
}
}
+115
View File
@@ -0,0 +1,115 @@
package python
import (
"regexp"
"strings"
"github.com/railwayapp/railpack/core/generate"
)
var (
// Extract the leading distribution name from requirements.txt and PEP 508 dependency specs.
// This is not used for Poetry or Pipenv tables, where the distribution name is the TOML key.
pythonDependencyNameRegex = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*`)
// Normalize the interchangeable package-name separators defined by Python packaging conventions.
pythonDependencyNameSeparatorRegex = regexp.MustCompile(`[-_.]+`)
)
type pyprojectManifest struct {
Project struct {
Dependencies []string `toml:"dependencies"`
} `toml:"project"`
Tool struct {
Poetry struct {
Dependencies map[string]any `toml:"dependencies"`
} `toml:"poetry"`
} `toml:"tool"`
}
type pipfileManifest struct {
Packages map[string]any `toml:"packages"`
}
func (p *PythonProvider) usesProductionDep(ctx *generate.GenerateContext, dep string) bool {
if p.hasRequirements(ctx) {
return requirementsUseDependency(ctx, dep)
}
if p.hasPyproject(ctx) {
return pyprojectsUseProductionDependency(ctx, dep)
}
if p.hasPipfile(ctx) {
return pipfileUsesProductionDependency(ctx, dep)
}
return false
}
func requirementsUseDependency(ctx *generate.GenerateContext, dep string) bool {
contents, err := ctx.App.ReadFile("requirements.txt")
if err != nil {
return false
}
for line := range strings.Lines(contents) {
if dependencySpecMatches(line, dep) {
return true
}
}
return false
}
func pyprojectsUseProductionDependency(ctx *generate.GenerateContext, dep string) bool {
files, err := ctx.App.FindFiles("**/pyproject.toml")
if err != nil {
return false
}
for _, file := range files {
var manifest pyprojectManifest
if err := ctx.App.ReadTOML(file, &manifest); err != nil {
continue
}
for _, dependency := range manifest.Project.Dependencies {
if dependencySpecMatches(dependency, dep) {
return true
}
}
for dependency := range manifest.Tool.Poetry.Dependencies {
if normalizedDependencyName(dependency) == normalizedDependencyName(dep) {
return true
}
}
}
return false
}
func pipfileUsesProductionDependency(ctx *generate.GenerateContext, dep string) bool {
var manifest pipfileManifest
if err := ctx.App.ReadTOML("Pipfile", &manifest); err != nil {
return false
}
for dependency := range manifest.Packages {
if normalizedDependencyName(dependency) == normalizedDependencyName(dep) {
return true
}
}
return false
}
func dependencySpecMatches(spec string, dep string) bool {
name := pythonDependencyNameRegex.FindString(strings.TrimSpace(spec))
return normalizedDependencyName(name) == normalizedDependencyName(dep)
}
func normalizedDependencyName(name string) string {
return pythonDependencyNameSeparatorRegex.ReplaceAllString(strings.ToLower(name), "-")
}
+66 -6
View File
@@ -18,8 +18,36 @@ const (
PIP_CACHE_DIR = "/opt/pip-cache"
VENV_PATH = "/app/.venv"
LOCAL_BIN_PATH = "/root/.local/bin"
PLAYWRIGHT_CACHE_DIR = "/root/.cache/ms-playwright"
PLAYWRIGHT_INSTALL_VAR = "PYTHON_PLAYWRIGHT_INSTALL"
)
// Keep this aligned with the Debian 12 Chromium list in Playwright's nativeDeps.ts:
// https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/server/registry/nativeDeps.ts
var pythonPlaywrightRuntimeDependencies = []string{
"libasound2",
"libatk-bridge2.0-0",
"libatk1.0-0",
"libatspi2.0-0",
"libcairo2",
"libcups2",
"libdbus-1-3",
"libdrm2",
"libgbm1",
"libglib2.0-0",
"libnspr4",
"libnss3",
"libpango-1.0-0",
"libx11-6",
"libxcb1",
"libxcomposite1",
"libxdamage1",
"libxext6",
"libxfixes3",
"libxkbcommon0",
"libxrandr2",
}
type PythonProvider struct{}
func (p *PythonProvider) Name() string {
@@ -67,6 +95,26 @@ func (p *PythonProvider) Plan(ctx *generate.GenerateContext) error {
installOutputs = p.InstallPipenv(ctx, install)
}
usesPlaywright := p.usesProductionDep(ctx, "playwright")
installPlaywright := ctx.Env.IsConfigVariableTruthy(PLAYWRIGHT_INSTALL_VAR)
// Automatic browser installation caused issues for an existing user, so keep this opt-in.
if usesPlaywright && !installPlaywright {
ctx.Logger.LogSuggestion(
"Set `RAILPACK_PYTHON_PLAYWRIGHT_INSTALL=1` to install Playwright browsers",
"/languages/python#playwright",
)
}
if installPlaywright {
ctx.Logger.LogInfo("Installing Playwright chromium browser")
// --only-shell installs the smaller Chromium headless shell, which is
// more appropriate for server environments than full Chromium.
install.AddCommand(plan.NewExecCommand("playwright install --only-shell"))
// Include the browser cache so its binaries are available in the deploy stage.
installOutputs = append(installOutputs, PLAYWRIGHT_CACHE_DIR)
}
p.addMetadata(ctx)
build.AddInput(plan.NewStepLayer(install.Name()))
@@ -157,14 +205,16 @@ func (p *PythonProvider) InstallUv(ctx *generate.GenerateContext, install *gener
install.AddEnvVars(p.GetPythonEnvVars(ctx))
p.copyInstallFiles(ctx, install)
install.AddCommands([]plan.Command{
installCommands := []plan.Command{
plan.NewPathCommand(LOCAL_BIN_PATH),
plan.NewPathCommand(VENV_PATH + "/bin"),
// if we exclude workspace packages, uv.lock will fail the frozen test and the user will get an error
// to avoid this, we (a) detect if workspace packages are required (b) if they aren't, we don't include project
// source in order to optimize layer caching (c) install project in the build phase.
plan.NewExecCommand("uv sync --locked --no-dev --no-install-project"),
})
}
install.AddCommands(installCommands)
return []string{VENV_PATH}
}
@@ -209,11 +259,13 @@ func (p *PythonProvider) InstallPDM(ctx *generate.GenerateContext, install *gene
})
p.copyInstallFiles(ctx, install)
install.AddCommands([]plan.Command{
installCommands := []plan.Command{
plan.NewPathCommand(LOCAL_BIN_PATH),
plan.NewPathCommand(VENV_PATH + "/bin"),
plan.NewExecCommand("pdm install --check --prod --no-editable"),
})
}
install.AddCommands(installCommands)
return []string{VENV_PATH}
}
@@ -229,11 +281,13 @@ func (p *PythonProvider) InstallPoetry(ctx *generate.GenerateContext, install *g
})
p.copyInstallFiles(ctx, install)
install.AddCommands([]plan.Command{
installCommands := []plan.Command{
plan.NewPathCommand(LOCAL_BIN_PATH),
plan.NewPathCommand(VENV_PATH + "/bin"),
plan.NewExecCommand("poetry install --no-interaction --no-ansi --only main --no-root"),
})
}
install.AddCommands(installCommands)
return []string{VENV_PATH}
}
@@ -268,6 +322,11 @@ func (p *PythonProvider) AddRuntimeDeps(ctx *generate.GenerateContext) {
}
}
if ctx.Env.IsConfigVariableTruthy(PLAYWRIGHT_INSTALL_VAR) {
ctx.Logger.LogInfo("Installing runtime apt packages for playwright: %v", pythonPlaywrightRuntimeDependencies)
ctx.Deploy.AddAptPackages(pythonPlaywrightRuntimeDependencies)
}
if p.usesPostgres(ctx) {
ctx.Deploy.AddAptPackages([]string{"libpq5"})
}
@@ -468,6 +527,7 @@ func (p *PythonProvider) addMetadata(ctx *generate.GenerateContext) {
ctx.Metadata.Set("pythonRuntime", p.getRuntime(ctx))
}
// TODO this is incredibly naive: we should parse the files we can distinguish between prod and dev
func (p *PythonProvider) usesDep(ctx *generate.GenerateContext, dep string) bool {
files, err := ctx.App.FindFiles("**/{requirements.txt,pyproject.toml,Pipfile}")
if err != nil {
+94
View File
@@ -3,6 +3,8 @@ package python
import (
"testing"
"github.com/railwayapp/railpack/core/generate"
"github.com/railwayapp/railpack/core/plan"
"github.com/stretchr/testify/require"
testingUtils "github.com/railwayapp/railpack/core/testing"
@@ -142,3 +144,95 @@ func TestUsesPostgres(t *testing.T) {
})
}
}
func TestUsesProductionPlaywrightDependency(t *testing.T) {
tests := []struct {
name string
path string
dependency string
want bool
}{
{
name: "PEP 621 production dependency",
path: "../../../examples/python-playwright",
dependency: "playwright",
want: true,
},
{
// python-playwright declares pytest in dependency-groups.dev.
name: "PEP 735 development dependency",
path: "../../../examples/python-playwright",
dependency: "pytest",
want: false,
},
{
name: "Poetry production dependency",
path: "../../../examples/python-poetry",
dependency: "flask",
want: true,
},
{
name: "Pipenv production dependency",
path: "../../../examples/python-pipfile",
dependency: "cowsay",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := testingUtils.CreateGenerateContext(t, tt.path)
provider := PythonProvider{}
require.Equal(t, tt.want, provider.usesProductionDep(ctx, tt.dependency))
})
}
}
func TestPlaywrightInstallationIsOptIn(t *testing.T) {
t.Run("does not install by default", func(t *testing.T) {
ctx := testingUtils.CreateGenerateContext(t, "../../../examples/python-playwright")
provider := PythonProvider{}
require.NoError(t, provider.Plan(ctx))
require.False(t, stepHasExecCommand(ctx, "install", "playwright install --only-shell"))
require.NotContains(t, ctx.Deploy.AptPackages, "libnss3")
})
t.Run("install when enabled", func(t *testing.T) {
ctx := testingUtils.CreateGenerateContext(t, "../../../examples/python-playwright")
ctx.Env.Variables["RAILPACK_PYTHON_PLAYWRIGHT_INSTALL"] = "1"
provider := PythonProvider{}
require.NoError(t, provider.Plan(ctx))
require.True(t, stepHasExecCommand(ctx, "install", "playwright install --only-shell"))
require.Contains(t, ctx.Deploy.AptPackages, "libnss3")
})
}
func TestDependencySpecMatches(t *testing.T) {
require.True(t, dependencySpecMatches("Playwright[chromium]>=1.49", "playwright"))
require.True(t, dependencySpecMatches("playwright @ https://example.com/playwright.whl", "playwright"))
require.False(t, dependencySpecMatches("pytest-playwright>=0.7", "playwright"))
}
func stepHasExecCommand(ctx *generate.GenerateContext, stepName string, command string) bool {
step := ctx.GetStepByName(stepName)
if step == nil {
return false
}
commandStep, ok := (*step).(*generate.CommandStepBuilder)
if !ok {
return false
}
for _, candidate := range commandStep.Commands {
execCommand, ok := candidate.(plan.ExecCommand)
if ok && execCommand.Cmd == command {
return true
}
}
return false
}
+1 -1
View File
@@ -56,7 +56,7 @@ func validateStartCommand(plan *plan.BuildPlan, logger *logger.Logger, options *
return true
}
msg := "No start command detected. Specify a start command: https://railpack.com/config/file"
msg := "No start command detected. Specify a start command: https://railpack.com/config/file#deploy"
if options.ProviderToUse != nil {
if providerHelp := options.ProviderToUse.StartCommandHelp(); providerHelp != "" {
msg += "\n\n" + providerHelp
+15 -4
View File
@@ -97,6 +97,19 @@ Railpack determines the start command in the following order:
| `RAILPACK_NODE_INSTALL_PATTERNS` | Custom patterns to install dependencies | `prisma` |
| `RAILPACK_ANGULAR_PROJECT` | Name of the Angular project to build | `my-app` |
| `RAILPACK_NX_APP` | Nx app to build and start (project name, package name, or path) | `web` or `@org/web` |
| `RAILPACK_NODE_PLAYWRIGHT_INSTALL` | Install Playwright browsers | `1` |
### Playwright
When Playwright is a production dependency, Railpack suggests setting
`RAILPACK_NODE_PLAYWRIGHT_INSTALL=1`. Browser installation is opt-in because
it increases image size and is not required by every application that includes
Playwright.
When enabled, Railpack runs Playwright through the detected package manager to
install its browser binaries and adds the required runtime system packages.
Ensure Playwright is included in your production dependencies so its CLI is
available during the build.
### Package Managers
@@ -270,7 +283,7 @@ commands used.
### System Dependencies
Railpack automatically installs system dependencies for certain packages:
Railpack automatically installs system dependencies for Puppeteer:
- **Puppeteer**: When detected in workspace dependencies, Railpack installs
all necessary system packages for running headless Chrome, including
@@ -278,7 +291,5 @@ Railpack automatically installs system dependencies for certain packages:
Puppeteer's bundled Chromium [does not support
ARM64](https://github.com/puppeteer/puppeteer/issues/7740); if you need
to run on ARM hardware, consider switching to
[Playwright](#system-dependencies) or implementing a custom workaround
[Playwright](#playwright) or implementing a custom workaround
(e.g. installing a system Chromium and pointing `executablePath` at it).
- **Playwright**: When detected in workspace dependencies, Railpack installs
the necessary system packages and the headless shell version of Chromium
+16 -6
View File
@@ -102,10 +102,22 @@ Railpack supports multiple Python package managers:
### Config Variables
| Variable | Description | Example |
| -------------------------- | --------------------------- | ------------ |
| `RAILPACK_PYTHON_VERSION` | Override the Python version | `3.11` |
| `RAILPACK_DJANGO_APP_NAME` | Django app name | `myapp.wsgi` |
| Variable | Description | Example |
| --- | --- | --- |
| `RAILPACK_PYTHON_VERSION` | Override the Python version | `3.11` |
| `RAILPACK_DJANGO_APP_NAME` | Django app name | `myapp.wsgi` |
| `RAILPACK_PYTHON_PLAYWRIGHT_INSTALL` | Install browser binaries | `1` |
### Playwright
When Playwright is a production dependency, Railpack suggests setting
`RAILPACK_PYTHON_PLAYWRIGHT_INSTALL=1`. Railpack does not install browser
binaries automatically because doing so can unexpectedly change existing
builds and increase image size.
When enabled, Railpack installs Playwright's browser binaries and the system
packages needed to run them. Ensure Playwright is included in your production
dependencies so its CLI is available during the build.
### System Dependencies
@@ -115,8 +127,6 @@ Railpack installs system dependencies for common Python packages:
- **pdf2image**: Installs `poppler-utils`
- **pydub**: Installs `ffmpeg`
- **pymovie**: Installs `ffmpeg`, `qt5-qmake`, and related Qt packages
- **Playwright**: When detected in dependencies, Railpack installs the necessary
system packages and the headless shell version of Chromium
## Framework Support
+20
View File
@@ -0,0 +1,20 @@
const { chromium } = require("playwright");
(async () => {
console.log("Starting Playwright");
const browser = await chromium.launch({
headless: true,
args: ["--no-sandbox"],
});
const version = browser.version();
console.log(`Chromium version: ${version}`);
console.log("Creating Page");
const page = await browser.newPage();
console.log("Navigating to example.com");
await page.goto("https://example.com", {
waitUntil: "networkidle",
});
await browser.close();
console.log("Hello from playwright");
})();
+3
View File
@@ -0,0 +1,3 @@
[tools]
node = "latest"
pnpm = "latest"
+15
View File
@@ -0,0 +1,15 @@
{
"name": "playwright-example",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"playwright": "^1.60"
}
}
+43
View File
@@ -0,0 +1,43 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
playwright:
specifier: ^1.60
version: 1.62.0
packages:
[email protected]:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
[email protected]:
resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==}
engines: {node: '>=20'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==}
engines: {node: '>=20'}
hasBin: true
snapshots:
[email protected]:
optional: true
[email protected]: {}
[email protected]:
dependencies:
playwright-core: 1.62.0
optionalDependencies:
fsevents: 2.3.2
+8
View File
@@ -0,0 +1,8 @@
[
{
"envs": {
"RAILPACK_NODE_PLAYWRIGHT_INSTALL": "1"
},
"expectedOutput": "Hello from playwright"
}
]
+15
View File
@@ -0,0 +1,15 @@
from playwright.sync_api import sync_playwright
print("Starting Playwright")
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
version = browser.version
print(f"Chromium version: {version}")
print("Creating Page")
page = browser.new_page()
print("Navigating to example.com")
page.goto("https://example.com", wait_until="networkidle")
assert page.title() == "Example Domain"
browser.close()
print("Hello from playwright")
+3
View File
@@ -0,0 +1,3 @@
[tools]
python = "latest"
uv = "latest"
+13
View File
@@ -0,0 +1,13 @@
[project]
name = "python-playwright"
version = "0.1.0"
description = "Python Playwright example"
requires-python = ">=3.11"
dependencies = [
"playwright>=1.49",
]
[dependency-groups]
dev = [
"pytest>=8.0",
]
+8
View File
@@ -0,0 +1,8 @@
[
{
"envs": {
"RAILPACK_PYTHON_PLAYWRIGHT_INSTALL": "1"
},
"expectedOutput": "Hello from playwright"
}
]
+173
View File
@@ -0,0 +1,173 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "greenlet"
version = "3.2.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" },
{ url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" },
{ url = "https://files.pythonhosted.org/packages/ae/8f/95d48d7e3d433e6dae5b1682e4292242a53f22df82e6d3dda81b1701a960/greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3", size = 644646, upload-time = "2025-08-07T13:45:26.523Z" },
{ url = "https://files.pythonhosted.org/packages/d5/5e/405965351aef8c76b8ef7ad370e5da58d57ef6068df197548b015464001a/greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633", size = 640519, upload-time = "2025-08-07T13:53:13.928Z" },
{ url = "https://files.pythonhosted.org/packages/25/5d/382753b52006ce0218297ec1b628e048c4e64b155379331f25a7316eb749/greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079", size = 639707, upload-time = "2025-08-07T13:18:27.146Z" },
{ url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" },
{ url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" },
{ url = "https://files.pythonhosted.org/packages/3f/cc/b07000438a29ac5cfb2194bfc128151d52f333cee74dd7dfe3fb733fc16c/greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", size = 1142073, upload-time = "2025-08-07T13:18:21.737Z" },
{ url = "https://files.pythonhosted.org/packages/67/24/28a5b2fa42d12b3d7e5614145f0bd89714c34c08be6aabe39c14dd52db34/greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c", size = 1548385, upload-time = "2025-11-04T12:42:11.067Z" },
{ url = "https://files.pythonhosted.org/packages/6a/05/03f2f0bdd0b0ff9a4f7b99333d57b53a7709c27723ec8123056b084e69cd/greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5", size = 1613329, upload-time = "2025-11-04T12:42:12.928Z" },
{ url = "https://files.pythonhosted.org/packages/d8/0f/30aef242fcab550b0b3520b8e3561156857c94288f0332a79928c31a52cf/greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", size = 299100, upload-time = "2025-08-07T13:44:12.287Z" },
{ url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" },
{ url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" },
{ url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" },
{ url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" },
{ url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" },
{ url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" },
{ url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" },
{ url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" },
{ url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" },
{ url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" },
{ url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" },
{ url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
{ url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
{ url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" },
{ url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" },
{ url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" },
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
{ url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" },
{ url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" },
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
{ url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" },
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
{ url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" },
{ url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" },
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "playwright"
version = "1.56.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet" },
{ name = "pyee" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/6b/31/a5362cee43f844509f1f10d8a27c9cc0e2f7bdce5353d304d93b2151c1b1/playwright-1.56.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:b33eb89c516cbc6723f2e3523bada4a4eb0984a9c411325c02d7016a5d625e9c", size = 40611424, upload-time = "2025-11-11T18:39:10.175Z" },
{ url = "https://files.pythonhosted.org/packages/ef/95/347eef596d8778fb53590dc326c344d427fa19ba3d42b646fce2a4572eb3/playwright-1.56.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b228b3395212b9472a4ee5f1afe40d376eef9568eb039fcb3e563de8f4f4657b", size = 39400228, upload-time = "2025-11-11T18:39:13.915Z" },
{ url = "https://files.pythonhosted.org/packages/b9/54/6ad97b08b2ca1dfcb4fbde4536c4f45c0d9d8b1857a2d20e7bbfdf43bf15/playwright-1.56.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:0ef7e6fd653267798a8a968ff7aa2dcac14398b7dd7440ef57524e01e0fbbd65", size = 40611424, upload-time = "2025-11-11T18:39:17.093Z" },
{ url = "https://files.pythonhosted.org/packages/e4/76/6d409e37e82cdd5dda3df1ab958130ae32b46e42458bd4fc93d7eb8749cb/playwright-1.56.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:404be089b49d94bc4c1fe0dfb07664bda5ffe87789034a03bffb884489bdfb5c", size = 46263122, upload-time = "2025-11-11T18:39:20.619Z" },
{ url = "https://files.pythonhosted.org/packages/4f/84/fb292cc5d45f3252e255ea39066cd1d2385c61c6c1596548dfbf59c88605/playwright-1.56.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64cda7cf4e51c0d35dab55190841bfcdfb5871685ec22cb722cd0ad2df183e34", size = 46110645, upload-time = "2025-11-11T18:39:24.005Z" },
{ url = "https://files.pythonhosted.org/packages/61/bd/8c02c3388ae14edc374ac9f22cbe4e14826c6a51b2d8eaf86e89fabee264/playwright-1.56.0-py3-none-win32.whl", hash = "sha256:d87b79bcb082092d916a332c27ec9732e0418c319755d235d93cc6be13bdd721", size = 35639837, upload-time = "2025-11-11T18:39:27.174Z" },
{ url = "https://files.pythonhosted.org/packages/64/27/f13b538fbc6b7a00152f4379054a49f6abc0bf55ac86f677ae54bc49fb82/playwright-1.56.0-py3-none-win_amd64.whl", hash = "sha256:3c7fc49bb9e673489bf2622855f9486d41c5101bbed964638552b864c4591f94", size = 35639843, upload-time = "2025-11-11T18:39:30.851Z" },
{ url = "https://files.pythonhosted.org/packages/f2/c7/3ee8b556107995846576b4fe42a08ed49b8677619421f2afacf6ee421138/playwright-1.56.0-py3-none-win_arm64.whl", hash = "sha256:2745490ae8dd58d27e5ea4d9aa28402e8e2991eb84fb4b2fd5fbde2106716f6f", size = 31248959, upload-time = "2025-11-11T18:39:33.998Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pyee"
version = "13.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "python-playwright"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "playwright" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [{ name = "playwright", specifier = ">=1.49" }]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8.0" }]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
+4
View File
@@ -70,6 +70,10 @@ run = [
"go clean -testcache",
"mise cache clear",
"rm -rf /tmp/railpack",
# delete all top-level node_modules in examples/, this can cause state issues which cause snapshots to generate incorrectly
"find examples -maxdepth 2 -type d -name node_modules -exec rm -rf {} +",
# same goes for python .venv folders
"find examples -maxdepth 2 -type d -name .venv -exec rm -rf {} +",
]
# if a example folder does not have a test.json file, it will be skipped