BROWSER

Extending Browser

JavaScript extensions

Add keywords that run on the Node side with full Playwright access — and attach a debugger to them.

A JavaScript module adds keywords that run on the Node side, with the Playwright page object in hand. It is the lighter of the two extension points: no Python, no class, just exported functions.

Browser loads the module with require(). CommonJS is the safe default and what the library's own tests use, but on the Node versions Browser supports require() also loads ESM, so export/import works. The one thing it cannot load is an ESM module using top-level await, which fails with ERR_REQUIRE_ASYNC_MODULE.

A module

exports.__esModule = true;exports.getLinks = getLinks;exports.myMouseWheel = mouseWheel;getLinks.rfdoc = `Returns every link on the page as a name-to-href mapping.Implemented in JavaScript.`;async function getLinks(logger, page) {  logger('Collecting links');  return await page.locator('a').evaluateAll(elements => {    const object = {};    elements.filter(e => e.innerText).forEach(e => (object[e.innerText] = e.href));    return object;  });}mouseWheel.rfdoc = 'Scrolls the page by mouse input.';async function mouseWheel(x, y, logger, page) {  logger(`Mouse wheel at ${x}, ${y}`);  await page.mouse.wheel(Number(x), Number(y));  return await page.evaluate('document.scrollingElement.scrollTop');}

Three conventions carry the whole API:

  • Exported names become keywords — the export key, not the function's own name. exports.getLinks = getLinks gives Get Links; the exports.myMouseWheel = mouseWheel below gives My Mouse Wheel.
  • Seven argument names are filled in for you, by name — see below.
  • fn.rfdoc becomes the keyword documentation, so your keyword shows up in Libdoc and in editor tooltips like any other.

The seven names Browser fills in

Name a parameter one of these and the library passes the value in. Everything else in your signature becomes an ordinary keyword argument.

NameYou get
pageThe active Page
contextThe active BrowserContext
browserThe active Browser
loggerA function that writes to the Robot Framework log
playwrightThe playwright module itself
adoptContextA function that hands a context you created to Browser — see Handing over a context you created
defaultTimeoutThe library timeout in milliseconds, as Set Browser Timeout sets it

They are matched by name, not by position. Browser reads your parameter names and fills in the ones it recognises, so mouseWheel(x, y, logger, page) and mouseWheel(logger, x, page, y) behave identically — and the Robot side sees the same keyword either way, taking x and y. Put them wherever reads best.

These names are reserved: a keyword cannot take an argument called page from Robot Framework, because that name is spoken for. self is not usable either.

New in Browser 20.4.0 Only page, context and browser need an open browser. A keyword that takes one of them fails with No Browser is open but needed for this operation. when none is open. A keyword that takes none of them runs without one, which is what lets a module create the first browser itself.

One further name is special without being filled in. A parameter called args makes the keyword variadic — it receives Robot Framework's *args, so it carries values to your function rather than from the library. It cannot have a default, and anything you declare after it becomes a named-only argument on the Robot side, so it reads best last.

Whatever you return is JSON-serialised on the way back, so it must be serialisable — undefined arrives in Robot Framework as ${None}.

jsextension takes a single path, a comma-separated list, or a real list of paths. Load it at import:

*** Settings ***Library    Browser    jsextension=${CURDIR}/module.js*** Test Cases ***Read The Links    New Page    https://playwright.dev    ${links} =    Get Links    Log Many    &{links}

Custom selector strategies

A module can also register a selector engine, which then works anywhere a selector is accepted:

async function registerMySelector(playwright) {  await playwright.selectors.register('myselector', () => ({    query(root, selector) {      return root.querySelector(`a[data-title="${selector}"]`);    },    queryAll(root, selector) {      return Array.from(root.querySelectorAll(`a[data-title="${selector}"]`));    },  }));}exports.__esModule = true;exports.registerMySelector = registerMySelector;
*** Test Cases ***Use A Custom Engine    New Browser           chromium    Register My Selector    New Page              ${URL}    Click                 myselector=Some Title

Handing over a context you created

New in Browser 20.7.0 A module can create a browser context itself and hand it to Browser with adoptContext. That is how a library supports a platform that Browser does not launch, such as an Electron application, without Browser knowing about it:

async function launchElectronApplication(executablePath, playwright, adoptContext) {  const app = await playwright._electron.launch({ executablePath });  await app.firstWindow();  return adoptContext(app.context(), { name: 'electron' });}exports.__esModule = true;exports.launchElectronApplication = launchElectronApplication;
*** Settings ***Library    Browser    jsextension=${CURDIR}/electron.js*** Test Cases ***Read The Title Of An Electron Application    Launch Electron Application    /path/to/app    Get Title    ==    My App    Close Browser

The examples on this page are kept short. A real module closes the application when anything after the launch fails, for example with try/catch around firstWindow and adoptContext, because Browser cannot close a process it has not taken over.

adoptContext(context, options) adds the context as a new browser and makes it the active one, the same way Browser keeps a persistent context:

  • Its pages become Browser's pages. The pages the context already has are indexed and the first one becomes the active page, so Click, Get Text and the other keywords work on it right away. Pages it opens later can be selected with Switch Page.
  • activePage picks another page. Pass a page of the context to make it the active page instead of the first one, for example the window of an Electron application when Playwright saw one of its views first. A page of another context makes adoptContext fail without adopting anything.
  • It gets the library timeout, as the contexts Browser creates have, so the function does not call setDefaultTimeout itself.
  • options describe the browser. All of them are optional. name is what Get Browser Catalog reports as its type, adopted by default, and headless says whether it runs headless, false by default.
  • tracing records a trace. Pass the absolute path of a .zip trace file or of a folder, which gets trace_<context id>.zip, and Browser starts tracing the context and saves the trace before it closes the context, as with tracing of New Context. A relative path would be relative to Browser's Node process, not to the output directory. A library written in Python gets the path from Browser, see From a library written in Python.
  • contextOptions say how the context was created. Pass the options you created it with, in the form of Playwright's browser context options. Browser keywords that depend on them then work on it: Download needs acceptDownloads, which an Electron application has by default, so pass { acceptDownloads: true } for one.
  • Browser registers it like a context of New Context. As soon as adoptContext resolves, the keyword calls become groups in the trace, if it is traced, and the log embeds the video of the active page, if it records one, in the size of recordVideo in contextOptions, else of their viewport, else 1280×720.
  • It returns the ids of the new browser and context, as an object with browserId and contextId, plus pageId if the context has a page, and videoPath, the video of the active page or null. Return it from your function and the keyword returns it to Robot Framework.
  • Closing the browser closes the context. Close Browser and automatic closing treat it like any other browser.
  • onClose releases the rest. The async option onClose runs once after the context is closed, even when closing it failed. Pass it for what the context does not own. An Electron application quits with its context and needs none; a connection to an Android device does not close by itself: adoptContext(context, { onClose: () => device.close() }).

From a library written in Python

A library built on Browser in Python calls the module with call_js_keyword and lets Browser decide where traces, videos and HAR files go, by the same rules as for New Context. Type the keyword's arguments with Browser's types, as the example does, so that Robot Framework converts False or a dictionary as it does for New Context:

from pathlib import Pathfrom Browser import Browserfrom Browser.utils import RecordHar, RecordVideofrom robotlibcore import keywordclass ElectronLibrary(Browser):    @keyword    def launch_electron_application(        self,        executable_path: str,        tracing: bool | Path | None = None,        record_video: RecordVideo | None = None,        record_har: RecordHar | None = None,    ):        options = self.adopted_context_options(tracing=tracing, recordVideo=record_video, recordHar=record_har)        return self.call_js_keyword(            "launchElectronApplication",            executablePath=executable_path,            **options,        )
async function launchElectronApplication(executablePath, tracing, recordVideo, recordHar, tracesDir, playwright, adoptContext) {  const contextOptions = { acceptDownloads: true };  if (recordVideo) contextOptions.recordVideo = recordVideo;  if (recordHar) contextOptions.recordHar = recordHar;  const app = await playwright._electron.launch({ executablePath, tracesDir, ...contextOptions });  await app.firstWindow();  return adoptContext(app.context(), { name: 'electron', tracing: tracing || undefined, contextOptions });}exports.__esModule = true;exports.launchElectronApplication = launchElectronApplication;
  • adopted_context_options returns an AdoptedContextOptions, a typed dictionary with tracing, recordVideo and recordHar as New Context would use them, None when off: tracing=True and ROBOT_FRAMEWORK_BROWSER_TRACING give Browser's trace folder, a video goes to Browser's video folder, and relative paths are resolved as for New Context. Its arguments are converted like those of New Context, so an unknown key fails. An empty recordVideo records with these defaults. Without a size, the video has the size of the viewport argument, the viewport you create the context with, or 1280×720. tracesDir is a new folder for Playwright's temporary trace files, as Browser gives one to each browser it launches. Name the function's parameters like the keys and **options passes them on. Give tracing to adoptContext, tracesDir to the launch, and the others to Playwright when you create the context.
  • Nothing to register afterwards. call_js_keyword registers the context while the call runs, as soon as adoptContext resolves, so the keyword calls become groups in the trace and the log embeds the video, as described above. The keyword returns what the function returned, here the ids and videoPath.
  • defaultTimeout is filled in by call_js_keyword too, with the library timeout, and adoptContext gives it to the context. Pass your own only when your library keeps a timeout of its own; it is the one filled-in name you can override.

Debugging

You can attach a Node debugger to the Playwright process — to your own extension or to the library's internals.

1. Start Node with debugging enabled

Set ROBOT_FRAMEWORK_BROWSER_NODE_DEBUG_OPTIONS; its value is passed to the Node process as CLI options.

OptionBehaviour
--inspectDebugging on, process starts immediately. Attach whenever.
--inspect-brkProcess pauses until a debugger attaches. Needed to debug startup.

Bind it to a specific port so it does not collide with anything else:

--inspect=127.0.0.1:9999

Set it for the debug session rather than globally. For RobotCode, in launch.json:

{  "name": "RobotCode: Default",  "type": "robotcode",  "request": "launch",  "purpose": "default",  "env": {    "ROBOT_FRAMEWORK_BROWSER_NODE_DEBUG_OPTIONS": "--inspect=127.0.0.1:9999"  },  "pythonConfiguration": "RobotCode: Python"}

2. Attach

{  "name": "Attach to Node",  "port": 9999,  "type": "node",  "request": "attach",  "skipFiles": ["<node_internals>/**"]}

3. Run

  1. Set breakpoints in your extension — or in site-packages/Browser/wrapper/index.js to debug the library itself.
  2. Set a breakpoint in the Robot code early, after Library Browser is loaded and before your JS runs. This buys you the time to attach.
  3. Start the Robot debug session and wait for that breakpoint.
  4. Switch to Run and Debug, choose Attach to Node, and start it.

Step 2 is the one people skip, and then wonder why the JS breakpoint never fires: without it the extension has already run by the time you attach.

Which extension point?

Three shapes, not two. A Python plugin and a JavaScript module both extend the Browser instance Robot Framework loaded, from the inside. The third is not an extension point at all: your own Robot Framework library, which either sits alongside Browser or owns the instance itself.

You wantUse
Playwright APIs Browser does not exposeJavaScript module
Assertion arguments like the built-in gettersPython plugin with AssertionEngine
To replace an existing keywordPython plugin
A custom selector engineJavaScript module
A platform Browser does not launch, such as ElectronJavaScript module that hands over its context, usually in a library of its own
Both Python and page-side logic in one keywordPython plugin calling a JS module
Business logic in Python — IF, TRY, parsing — with Browser unchangedYour own Python library
Your own keywords and your own failure handling, with Browser underneathBrowser as a base

Before you write one

Somebody may have already written it. robotframework-browser-extensions collects working community extensions — accessibility checks with axe-core, visual comparison, network throttling, request mocking, element highlighting, and native Playwright page methods.

Reading one is also the fastest way to see the shape of a finished extension rather than a snippet. And when yours works, it belongs there too: it is a monorepo that takes pull requests, so publishing is one PR rather than a release process.