diff --git a/content/docs/concepts/browser-context-page.md b/content/docs/concepts/browser-context-page.md index fd2b839..d0def25 100644 --- a/content/docs/concepts/browser-context-page.md +++ b/content/docs/concepts/browser-context-page.md @@ -297,8 +297,10 @@ and `Switch Browser`, `Switch Context` and `Switch Page` move that pointer. By default, the **contexts and pages** a test opened are closed when the test ends. Browsers are not: no auto-closing level closes a browser per test or per suite, so a browser lives until execution ends or you call `Close Browser`. The -one exception is a browser opened by `New Persistent Context` — it is closed -together with its context. +exceptions are a browser opened by `New Persistent Context` and one that a +JavaScript extension +[handed over with `adoptContext`](/docs/extending/javascript-extensions#handing-over-a-context-you-created) +— each is closed together with its context. That setting is `auto_closing_level`, and it has four values: diff --git a/content/docs/extending/javascript-extensions.md b/content/docs/extending/javascript-extensions.md index cf62531..7d1e774 100644 --- a/content/docs/extending/javascript-extensions.md +++ b/content/docs/extending/javascript-extensions.md @@ -46,13 +46,13 @@ 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`. -- **Five argument names are filled in for you**, by name — see below. +- **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 five names Browser fills in +### The seven names Browser fills in -Name a parameter one of these and the library passes the object in. Everything +Name a parameter one of these and the library passes the value in. Everything else in your signature becomes an ordinary keyword argument. | Name | You get | @@ -62,6 +62,8 @@ else in your signature becomes an ordinary keyword argument. | `browser` | The active [Browser](https://playwright.dev/docs/api/class-browser) | | `logger` | A function that writes to the Robot Framework log | | `playwright` | The [`playwright` module](https://playwright.dev/docs/api/class-playwright) itself | +| `adoptContext` | A function that hands a context you created to Browser — see [Handing over a context you created](#handing-over-a-context-you-created) | +| `defaultTimeout` | The 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)` @@ -72,6 +74,12 @@ 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{version="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](#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 @@ -119,7 +127,7 @@ exports.registerMySelector = registerMySelector; *** Test Cases *** Use A Custom Engine New Browser chromium - Register My Selector # a browser must already be open + Register My Selector New Page ${URL} Click myselector=Some Title ``` @@ -128,17 +136,166 @@ Use A Custom Engine --- kind: warning --- -Two things this example depends on. - -**A browser must already be open.** Every JavaScript-extension keyword resolves -the active browser before it runs, even one whose only argument is `playwright`, -so calling this first fails with `Browser has been closed.` - **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{version="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_.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](#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. + +::doc-note +--- +kind: 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 @@ -230,6 +387,7 @@ rows: - ['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](#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](/docs/extending/python-libraries)'] - ['Your own keywords and your own failure handling, with Browser underneath', '[Browser as a base](/docs/extending/browser-as-a-base)'] diff --git a/content/docs/extending/python-plugins.md b/content/docs/extending/python-plugins.md index 525e05d..f572486 100644 --- a/content/docs/extending/python-plugins.md +++ b/content/docs/extending/python-plugins.md @@ -163,9 +163,10 @@ class PythonPlugin(LibraryComponent): ::doc-note{kind="warning"} Every argument to `call_js_keyword` must be **named** and JSON serialisable — -passing one positionally raises `TypeError`. The names `page`, `context`, -`browser`, `logger` and `playwright` are reserved: the Node side injects those -itself, so you cannot pass your own. +passing one positionally raises `TypeError`. The Node side fills in `page`, +`context`, `browser`, `logger`, `playwright` and `adoptContext` itself, so a +value you pass for them is ignored. `defaultTimeout` is the library timeout in +milliseconds unless you pass your own. :: This combination is usually the one you want: the keyword signature, type