Playwright Goes Polyglot: Leveraging Python and Node for Next-Level Testing
Playwright is an extremely popular library for browser automation.
It support multiple languages. The officially supported ones are:
- JavaScript/TypeScript
- Python
- Java
- .NET
but there are community efforts to support other languages as well.
In most cases, projects that relies on Playwright are written in a single language. However, there are cases when you might want to use multiple languages in the same project. This often happens when your main project is written in a given language but you want to leverage libraries from another ecosystem.
For instance, Python has amazing image processing libraries like OpenCV that can be used to do visual testing, Java has a wide ecosystem of testing libraries like JUnit and TestNG, and Node.js has a rich ecosystem of libraries designed to augment Playwright.
In this post, we will take a look at the architecture of Playwright as a library and show how a polyglot approach can be used to leverage the best of all worlds.
Connecting multiple scripts to the same browser instance
There are two ways to connect multiple scripts to the same browser instance:
Playwright Server
The Node.js version of Playwright exposes the BrowserType#launchServer
method that starts a Playwright server.
import { firefox } from 'playwright';
const browserServer = await firefox.launchServer();
console.log(browserServer.wsEndpoint());
Will print
$ node server.js
ws://localhost:53576/cd3880c5e2a87544e506eea8d9cede4a
This is a websocket server URL. The Node.js process is now acting as a server that can be connected to by other processes.
So if in another Node.js script we do:
import { firefox } from 'playwright';
const browser = await firefox.connect('ws://localhost:53576/cd3880c5e2a87544e506eea8d9cede4a');
const page = await browser.newPage();
await page.goto('https://example.com');
console.log(await page.title());
await browser.close();
this will display
$ node client.js
Example Domain
The client script did not start a new browser instance, but connected to the server started by the server script. Also, the server script is still running until it is stopped using Ctrl+C.
But this client could have been written in any language that is supported by Playwright!
import asyncio
from playwright.async_api import async_playwright
async def main():
async with async_playwright() as p:
# Connect to an already-running Firefox instance:
browser = await p.firefox.connect(ws_endpoint="ws://localhost:53576/cd3880c5e2a87544e506eea8d9cede4a")
# Create a new page/tab:
page = await browser.new_page()
# Navigate to the site
await page.goto("https://example.com")
# Print the page title
print(await page.title())
# Close the browser
await browser.close()
asyncio.run(main())
Will give us
python client.py
Example Domain
This method works with every browser that Playwright supports!
Chrome DevTools Protocol
All browsers based on Chromium (Chrome, Edge, etc.) support the Chrome DevTools Protocol (CDP). It is a low-level protocol that allows to control the browser programmatically.
For a browser to be controlled via CDP, it must be started with the --remote-debugging-port
flag. This can be done
through Playwright or directly through the browser executable.
On a Mac with Chrome installed, the following command will start Chrome with CDP enabled:
% /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-remote-debug --headless=new
Let's dissect this command:
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome
- this is the path to the Chrome executable on a Mac--remote-debugging-port=9222
- this tells Chrome to start a CDP server on port 9222--user-data-dir=/tmp/chrome-remote-debug
- this tells Chrome to use a temporary directory for its user data, this is to make sure that a new instance of Chrome is started--headless=new
- this tells Chrome to start in headless mode
This command is equivalent to the following Node.js code:
import { chromium } from 'playwright';
const browser = await chromium.launch({
args: ['--remote-debugging-port=9222', '--headless=new']
});
Or in Python:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(args=['--remote-debugging-port=9222', '--headless=new'])
When the browser is started with any of these methods, we need to identify the full URL to use to connect to the browser through the CDP.
A simple curl
on the /json/version
endpoint of the browser will give us the URL:
% curl http://localhost:9222/json/version
{
"Browser": "Chrome/133.0.6943.143",
"Protocol-Version": "1.3",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/133.0.0.0 Safari/537.36",
"V8-Version": "13.3.415.23",
"WebKit-Version": "537.36 (@c0343fb2cd38e04fcb4190e8615244f80947f083)",
"webSocketDebuggerUrl": "ws://localhost:9222/devtools/browser/9c5ca5ef-3189-4149-9ec8-4bb895e0aa98"
}
The webSocketDebuggerUrl
is what we need to connect to the browser.
Therefore, the code
import { chromium} from "playwright";
const browser = await chromium.connectOverCDP('ws://localhost:9222/devtools/browser/9c5ca5ef-3189-4149-9ec8-4bb895e0aa98')
const page = await browser.newPage();
await page.goto('https://example.com');
console.log(await page.title());
await browser.close();
will display
$ node cdpClient.js
Example Domain
Comparison of the two methods
Method | Can be started from | Limitations |
---|---|---|
Playwright Server | Node.js | Client and Server must be compatible versions of Playwright |
Chrome DevTools Protocol | Any language or without Playwright | Only works on Chromium-based browsers |
Having multiple languages work together
Now that we know how to connect multiple scripts to the same browser instance, we need to decide how we will prevent these scripts from stepping on each other's toes.
In this example, we will start a python script from Node.js and use the output of the Python script in the Node.js script.
We could also have used a Playwright binding to expose a method to the page and call it from node using Page#evaluate
.
The Node.js script
Here is the full script, don't worry, we will go through it step by step just after:
// main.js
import { chromium } from 'playwright';
import { promisify } from 'node:util';
import child_process from 'node:child_process';
const exec = promisify(child_process.exec);
const browser = await chromium.launch({
args: ['--remote-debugging-port=9222'],
});
const page = await browser.newPage();
await page.goto('https://example.com');
const pageUrl = page.url();
const versionResp = await fetch('http://localhost:9222/json/version');
const asJson = await versionResp.json();
const endpoint = asJson.webSocketDebuggerUrl;
const {stdout, stderr} = await exec('.venv/bin/python script.py ' + endpoint + ' ' + pageUrl);
if (stderr.trim() !== '') {
throw new Error(stderr);
}
const diffScore = parseFloat(stdout.trim());
console.log(`The page is ${diffScore}% different from the reference image`);
await browser.close();
Let's go through this code:
import { chromium } from 'playwright';
import { promisify } from 'node:util';
import child_process from 'node:child_process';
const exec = promisify(child_process.exec);
This are our imports. We will use:
chromium
from Playwright to start a browserpromisify
from Node.js to convert theexec
function from a callback-based function to a promise-based functionchild_process
from Node.js to execute the Python script
const browser = await chromium.launch({
args: ['--remote-debugging-port=9222'],
});
As we have seen before, this starts a Chromium browser with CDP enabled.
const page = await browser.newPage();
await page.goto('https://example.com');
const pageUrl = page.url();
This is a simple code to create a new page and navigate to https://example.com
. We store the URL of the page in the pageUrl
variable.
const versionResp = await fetch('http://localhost:9222/json/version');
const asJson = await versionResp.json();
const endpoint = asJson.webSocketDebuggerUrl;
We call the browser to know the URL to connect to it through CDP.
const {stdout, stderr} = await exec('.venv/bin/python script.py ' + endpoint + ' ' + pageUrl);
if (stderr.trim() !== '') {
throw new Error(stderr);
}
This will invoke our Python script with the endpoint and the page URL as arguments. The Python script will return the diff score between the page and a reference image.
const diffScore = parseFloat(stdout.trim());
console.log(`The page is ${diffScore}% different from the reference image`);
await browser.close();
Finally, we parse the output of the Python script and print it.
The Python script
Now, what does our Python script look like? (Once again, scroll under this block to see explanations)
# script.py
import sys
import os
import asyncio
from pathlib import Path
from playwright.async_api import async_playwright
from PIL import Image, ImageChops
SCREENSHOTS_DIR = "screenshots"
os.makedirs(SCREENSHOTS_DIR, exist_ok=True)
async def compare_screenshots(baseline_path: Path, new_path: Path) -> float:
"""Compare two screenshots and return the difference percentage (0.0 to 100.0)."""
with Image.open(baseline_path) as img1, Image.open(new_path) as img2:
# If the sizes differ, 100% difference
if img1.size != img2.size:
return 100.0
diff = ImageChops.difference(img1, img2)
diff_gray = diff.convert("L")
diff_data = diff_gray.getdata()
num_diff_pixels = sum(1 for p in diff_data if p != 0)
total_pixels = img1.width * img1.height
diff_percentage = (num_diff_pixels / total_pixels) * 100.0
return diff_percentage
async def main():
if len(sys.argv) < 3:
print("Usage: python script.py ws://ENDPOINT pageUrl")
sys.exit(1)
ws_url = sys.argv[1] # e.g. "ws://localhost:9222/devtools/browser/..."
target_url = sys.argv[2] # e.g. "https://example.com"
async with async_playwright() as p:
# Connect over CDP to the Node-launched browser
browser = await p.chromium.connect_over_cdp(ws_url)
contexts = browser.contexts
# Find a page matching the target_url
found_page = None
for context in contexts:
for page in context.pages:
if page.url == target_url:
found_page = page
break
if found_page:
break
if not found_page:
print(f"No page found with URL {target_url}")
sys.exit(2)
# Prepare filenames
safe_filename = (
target_url.replace("://", "_")
.replace("/", "_")
.replace("?", "_")
.replace("=", "_")
.replace("&", "_")
.replace(":", "_")
)
baseline_path = Path(SCREENSHOTS_DIR) / f"{safe_filename}.png"
temp_path = Path(SCREENSHOTS_DIR) / f"{safe_filename}_temp.png"
# Take a screenshot of that page
await found_page.screenshot(path=temp_path)
# If no baseline exists, use this as the baseline
if not baseline_path.exists():
temp_path.rename(baseline_path)
print("0.0") # No difference on first creation
else:
# Compare with existing baseline
diff_percentage = await compare_screenshots(baseline_path, temp_path)
temp_path.unlink(missing_ok=True)
print(diff_percentage)
# Close the connection (exits the script)
await browser.close()
if __name__ == "__main__":
asyncio.run(main())
Let's see this script in details
import sys
import os
import asyncio
from pathlib import Path
from playwright.async_api import async_playwright
from PIL import Image, ImageChops
Here, we import the modules we need:
- sys to access command-line arguments
- os for filesystem operations
- asyncio to run asynchronous code
- Path from pathlib to handle filesystem paths more cleanly
- async_playwright from Playwright to connect to the already-launched browser
- PIL (Python Imaging Library) to compare screenshots
SCREENSHOTS_DIR = "screenshots"
os.makedirs(SCREENSHOTS_DIR, exist_ok=True)
We define a directory to store screenshots (screenshots) and ensure that it exists (create if not present).
async def compare_screenshots(baseline_path: Path, new_path: Path) -> float:
"""Compare two screenshots and return the difference percentage (0.0 to 100.0)."""
with Image.open(baseline_path) as img1, Image.open(new_path) as img2:
# If the sizes differ, 100% difference
if img1.size != img2.size:
return 100.0
diff = ImageChops.difference(img1, img2)
diff_gray = diff.convert("L")
diff_data = diff_gray.getdata()
num_diff_pixels = sum(1 for p in diff_data if p != 0)
total_pixels = img1.width * img1.height
diff_percentage = (num_diff_pixels / total_pixels) * 100.0
return diff_percentage
This is the helper method that compares two images (baseline vs. new) and returns a numerical score indicating how different they are (0.0 means identical, 100.0 means completely different). We won’t go into the details here—it simply does the actual pixel-by-pixel comparison under the hood.
async def main():
if len(sys.argv) < 3:
print("Usage: python script.py ws://ENDPOINT pageUrl")
sys.exit(1)
ws_url = sys.argv[1] # e.g., "ws://localhost:9222/devtools/browser/..."
target_url = sys.argv[2] # e.g., "https://example.com"
async with async_playwright() as p:
# 1. Connect to the existing browser via the CDP (websocket) endpoint
browser = await p.chromium.connect_over_cdp(ws_url)
# 2. Search through all contexts/pages for one with the matching URL
found_page = None
for context in browser.contexts:
for page in context.pages:
if page.url == target_url:
found_page = page
break
if found_page:
break
if not found_page:
print(f"No page found with URL {target_url}")
sys.exit(2)
# 3. Prepare the screenshot file paths
safe_filename = (
target_url.replace("://", "_")
.replace("/", "_")
.replace("?", "_")
.replace("=", "_")
.replace("&", "_")
.replace(":", "_")
)
baseline_path = Path(SCREENSHOTS_DIR) / f"{safe_filename}.png"
temp_path = Path(SCREENSHOTS_DIR) / f"{safe_filename}_temp.png"
# 4. Take a screenshot of that page
await found_page.screenshot(path=temp_path)
# 5. If there's no baseline image, use this one as the baseline
if not baseline_path.exists():
temp_path.rename(baseline_path)
print("0.0") # No difference on the first run
else:
# 6. Compare it against the existing baseline and print the difference
diff_percentage = await compare_screenshots(baseline_path, temp_path)
temp_path.unlink(missing_ok=True)
print(diff_percentage)
# 7. Close the browser connection before exiting
await browser.close()
if __name__ == "__main__":
asyncio.run(main())
- Argument Handling: We grab the WebSocket URL and the target page URL from sys.argv.
- Connect to the Browser: We use playwright.async_api.connect_over_cdp(ws_url) to attach to the browser that the Node.js script launched.
- Locate the Target Page: We loop through all available contexts and pages looking for the one whose .url matches our target_url. If no match is found, the script exits.
- Screenshot Filenames: We create a “safe” filename by replacing potentially problematic URL characters with underscores. This helps avoid issues with filenames on different OSes.
- Taking the Screenshot : We screenshot the found page, saving it as temp_path.
- Compare or Create a Baseline
- If there’s no existing baseline image, we simply rename the new screenshot to become the baseline and print 0.0 difference.
- If a baseline exists, we call compare_screenshots and print the resulting difference percentage.
- Close the Browser: Finally, we close the connection (to cleanly exit) and print the diff result, which is captured by the Node.js script.
Conclusion
In this post, we have seen how to connect multiple scripts to the same browser instance using Playwright Server or the Chrome DevTools Protocol.
We could have replaced Node.js and Python with Java and .NET. Or any combination of these languages.
There are several reasons why you might want to use multiple languages in the same project. Usually, the goal is to use libraries that are not available in the main language of the project.
I hope you enjoyed this article. Feel free to contact me if you have any questions (on BlueSky or X)!
If you are still there, please consider Heal.dev for your end-to-end testing!
Heal.dev automates, runs, and maintains end-to-end tests for you so you don’t ever have to think about testing. That means setting up tests is as easy as sending a slack message, and tests run with 0 flakes and 0 maintenance. You could reach 80% test coverage in a matter of weeks!
If you want to know more, book a meeting with me!