# JavaScript extensions

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

Source: [https://robotframework-browser.org/docs/extending/javascript-extensions](https://robotframework-browser.org/docs/extending/javascript-extensions)

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

```javascript
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.

<table>
<thead>
  <tr>
    <th>
      Name
    </th>
    
    <th>
      You get
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        page
      </code>
    </td>
    
    <td>
      The active <a href="https://playwright.dev/docs/api/class-page" rel="nofollow">
        Page
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        context
      </code>
    </td>
    
    <td>
      The active <a href="https://playwright.dev/docs/api/class-browsercontext" rel="nofollow">
        BrowserContext
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        browser
      </code>
    </td>
    
    <td>
      The active <a href="https://playwright.dev/docs/api/class-browser" rel="nofollow">
        Browser
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        logger
      </code>
    </td>
    
    <td>
      A function that writes to the Robot Framework log
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        playwright
      </code>
    </td>
    
    <td>
      The <a href="https://playwright.dev/docs/api/class-playwright" rel="nofollow">
        <code>
          playwright
        </code>
        
         module
      </a>
      
       itself
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        adoptContext
      </code>
    </td>
    
    <td>
      A function that hands a context you created to Browser — see <a href="https://robotframework-browser.org/docs/extending/javascript-extensions#handing-over-a-context-you-created">
        Handing over a context you created
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        defaultTimeout
      </code>
    </td>
    
    <td>
      The library timeout in milliseconds, as <code>
        Set Browser Timeout
      </code>
      
       sets it
    </td>
  </tr>
</tbody>
</table>

**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.

*(since 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](https://robotframework-browser.org/docs/extending/javascript-extensions#handing-over-a-context-you-created).

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:

```robot
*** 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:

```javascript
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;
```

```robot
*** Test Cases ***
Use A Custom Engine
    New Browser           chromium
    Register My Selector
    New Page              ${URL}
    Click                 myselector=Some Title
```

> **Watch out:**
> 
> **Register once per run.** `selectors.register` rejects a name that is already
> registered, and because the registration is awaited that rejection fails the
> keyword — unawaited, it takes the whole Node process down instead.

## Handing over a context you created

*(since 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:

```javascript
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;
```

```robot
*** 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](https://robotframework-browser.org/docs/extending/javascript-extensions#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`:

```python
from pathlib import Path

from Browser import Browser
from Browser.utils import RecordHar, RecordVideo
from robotlibcore import keyword

class 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,
        )
```

```javascript
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.

> **Aside:**
> 
> Browser does not support Electron or Android itself, and `_electron` and
> `_android` are experimental in Playwright. The module, and keeping it working
> with the platform, are yours. A module that does more than launch belongs in a
> library of its own, built on Browser.

## 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.

| Option | Behaviour |
| --- | --- |
| `--inspect` | Debugging on, process starts immediately. Attach whenever. |
| `--inspect-brk` | Process pauses until a debugger attaches. Needed to debug startup. |

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

```text
--inspect=127.0.0.1:9999
```

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

```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"
}
```

> **Watch out:**
> 
> With `--inspect-brk` you have to attach quickly: the Playwright process start
> has a timeout of about **15 seconds**. Miss it and the run fails before you are
> attached. On macOS a failed start is retried once, but the first process is
> killed and a second one started, so you still only get those 15 seconds to
> attach to any one process.

### 2. Attach

```json
{
  "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 want | Use |
| --- | --- |
| Playwright APIs Browser does not expose | JavaScript module |
| Assertion arguments like the built-in getters | Python plugin with AssertionEngine |
| To replace an existing keyword | Python plugin |
| A custom selector engine | JavaScript module |
| A platform Browser does not launch, such as Electron | JavaScript module that [hands over its context](https://robotframework-browser.org/docs/extending/javascript-extensions#handing-over-a-context-you-created), usually in a library of its own |
| Both Python and page-side logic in one keyword | Python plugin calling a JS module |
| Business logic in Python — `IF`, `TRY`, parsing — with Browser unchanged | [Your own Python library](https://robotframework-browser.org/docs/extending/python-libraries) |
| Your own keywords and your own failure handling, with Browser underneath | [Browser as a base](https://robotframework-browser.org/docs/extending/browser-as-a-base) |

## Before you write one

Somebody may have already written it.
[robotframework-browser-extensions](https://github.com/MarketSquare/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.
