Module: PlaywrightRuntime

Defined in:
app/services/playwright_runtime.rb

Overview

Resolves how to obtain a Playwright browser for the current environment, and
yields a connected browser to callers via PlaywrightRuntime.with_browser.

Two modes, selected by the PLAYWRIGHT_SERVER_URL env var:

  1. REMOTE (production / staging) — PLAYWRIGHT_SERVER_URL is set, e.g.
    ws://heatwave-playwright:3000/ws. The Kamal app image ships NO Node /
    Playwright / browsers (see Dockerfile + .dockerignore); browsers run in a
    separate playwright Kamal accessory (mcr.microsoft.com/playwright) and the
    app connects over the kamal network via connect_to_browser_server. See the
    playwright accessory in config/deploy.yml / config/deploy.staging.yml.

  2. LOCAL (development) — PLAYWRIGHT_SERVER_URL is unset; launch a browser with
    the local driver resolved by PlaywrightRuntime.cli_path. Lookup order:
    a. node_modules/.bin/playwright (where yarn install provides it)
    b. ~/.playwright-driver/ (standalone driver, bundles its own Node)
    c. npx playwright (fallback)

The standalone driver is installed with:
wget https://playwright.azureedge.net/builds/driver/playwright--linux.zip
unzip -d ~/.playwright-driver/

VERSION LOCKSTEP: in remote mode the accessory image tag MUST equal the gem's
Playwright::COMPATIBLE_PLAYWRIGHT_VERSION (the wire protocol is version-matched).
Bumping playwright-ruby-client means bumping the accessory image tag in the same PR.

Constant Summary collapse

STANDALONE_DRIVER_DIR =

Standalone driver dir (local mode).

File.join(Dir.home, '.playwright-driver').freeze
SESSION_TIMEOUT_SECONDS =

Hard ceiling on ANY browser session. Not an SLA — every caller's own
per-call timeouts are 30-60s and a real session finishes in seconds. This is
the backstop for the failure mode those per-call timeouts cannot see: when
playwright-ruby-client's websocket reader thread dies, nobody reads the
socket, so every pending protocol call — including the server's own timeout
REPLY — blocks forever. Gem 1.61.0 did exactly that on the Menard portal and
wedged the EDI inventory flow for three weeks
(doc/troubleshooting/MENARD_PLAYWRIGHT_OUTAGE_2026_07.md).

Sized above the worst case of the slowest caller (Menard: ~8 sequential
waits of 30-60s each) so it can only ever fire on a hang, never on a slow
but progressing session. Callers wanting a tighter DOMAIN deadline pass
timeout: or wrap their own — e.g. Edi::Menard::InventoryMessageSender's
300s, which exists to produce an operator-facing ECL note.

600

Class Method Summary collapse

Class Method Details

.cli_pathString

Resolves the local Playwright CLI executable for Playwright.create (local mode).

Returns:

  • (String)

    the CLI command (node_modules binary, standalone driver, or npx playwright)

Raises:

  • (RuntimeError)

    when no local driver is found and PLAYWRIGHT_SERVER_URL is unset



111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'app/services/playwright_runtime.rb', line 111

def cli_path
  local = Rails.root.join('node_modules/.bin/playwright')
  return local.to_s if File.exist?(local)

  standalone_node = File.join(STANDALONE_DRIVER_DIR, 'node')
  standalone_cli  = File.join(STANDALONE_DRIVER_DIR, 'package', 'cli.js')
  return "#{standalone_node} #{standalone_cli}" if File.exist?(standalone_node) && File.exist?(standalone_cli)

  npx = find_npx
  return "#{npx} playwright" if npx

  raise "Playwright runtime not found. Set PLAYWRIGHT_SERVER_URL to use the remote " \
        "Playwright accessory, install the standalone driver in #{STANDALONE_DRIVER_DIR}, " \
        "or ensure node_modules/.bin/playwright exists (yarn install)."
end

.find_npxString?

Locates the npx executable, if any, for the local-driver fallback.

Returns:

  • (String, nil)

    absolute path to npx, or nil when not on PATH



130
131
132
133
# File 'app/services/playwright_runtime.rb', line 130

def find_npx
  path = `which npx 2>/dev/null`.strip
  path.presence
end

.remote?Boolean

True when a remote Playwright server is configured (production / staging).

Returns:

  • (Boolean)


61
62
63
# File 'app/services/playwright_runtime.rb', line 61

def remote?
  server_url.present?
end

.server_urlString?

ws:// endpoint of the remote Playwright server accessory, or nil in local mode.

Returns:

  • (String, nil)


55
56
57
# File 'app/services/playwright_runtime.rb', line 55

def server_url
  ENV['PLAYWRIGHT_SERVER_URL'].presence
end

.with_browser(launch_options: {}, browser_type: 'chromium', timeout: SESSION_TIMEOUT_SECONDS) {|browser| ... } ⇒ Object

Yields a connected Playwright::Browser, picking remote vs local per server_url,
and cleans it up afterwards. The block's return value is passed through.

launch_options apply ONLY to a local launch — a remote browser is pre-launched
by playwright run-server on the accessory, so the client cannot set its CLI args
(e.g. --no-sandbox / --disable-dev-shm-usage). The accessory image handles the
sandbox; size /dev/shm via the accessory's options: { shm-size } instead.

Every session is capped at SESSION_TIMEOUT_SECONDS; see that constant for
why a Thread#raise-based Timeout is the only guard that works here. Unwinding
is safe: the gem's teardown closes the websocket with its own 2s bound
(WebSocketTransport#stop), so the ensure can't hang after the raise.

Parameters:

  • launch_options (Hash) (defaults to: {})

    passed to chromium.launch in local mode only

  • browser_type (String) (defaults to: 'chromium')

    'chromium' | 'firefox' | 'webkit'

  • timeout (Integer) (defaults to: SESSION_TIMEOUT_SECONDS)

    seconds; the session ceiling

Yield Parameters:

  • browser (Playwright::Browser)

Returns:

  • (Object)

    the block's return value, passed through

Raises:

  • (Timeout::Error)

    when the session outlives +timeout+



84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'app/services/playwright_runtime.rb', line 84

def with_browser(launch_options: {}, browser_type: 'chromium', timeout: SESSION_TIMEOUT_SECONDS, &)
  # Timeout.timeout treats nil and 0 as "run without a timeout", so a caller
  # passing either would silently restore the indefinite hang this guard
  # exists to stop — the failure would look exactly like having no guard.
  raise ArgumentError, "timeout must be a positive number, got #{timeout.inspect}" \
    unless timeout.is_a?(Numeric) && timeout.positive?

  Timeout.timeout(timeout, nil, "Playwright session exceeded #{timeout}s") do
    if (ws = server_url)
      Playwright.connect_to_browser_server(ws, browser_type: browser_type, &)
    else
      Playwright.create(playwright_cli_executable_path: cli_path) do |playwright|
        browser = playwright.public_send(browser_type).launch(**launch_options)
        begin
          yield(browser)
        ensure
          browser&.close
        end
      end
    end
  end
end