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..410ef48 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,154 @@ 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, defaultTimeout) { + const app = await playwright._electron.launch({ executablePath }); + await app.firstWindow(); + app.context().setDefaultTimeout(defaultTimeout); + 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`. +- **It keeps its own timeout.** Browser does not change the context's default + timeout. To give it the library timeout, as the contexts Browser creates have, + take `defaultTimeout` and call `context.setDefaultTimeout(defaultTimeout)`, as + the example does. +- **`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. +- **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. 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) + adopted = self.call_js_keyword( + "launchElectronApplication", + executablePath=executable_path, + defaultTimeout=int(self.timeout), + **options, + ) + self.register_adopted_context(adopted["contextId"], options, adopted.get("videoPath")) + return adopted +``` + +```javascript +async function launchElectronApplication(executablePath, tracing, recordVideo, recordHar, defaultTimeout, playwright, adoptContext) { + const contextOptions = { acceptDownloads: true }; + if (recordVideo) contextOptions.recordVideo = recordVideo; + if (recordHar) contextOptions.recordHar = recordHar; + const app = await playwright._electron.launch({ executablePath, ...contextOptions }); + const window = await app.firstWindow(); + app.context().setDefaultTimeout(defaultTimeout); + const adopted = await adoptContext(app.context(), { name: 'electron', tracing: tracing || undefined, contextOptions }); + return { ...adopted, videoPath: recordVideo ? await window.video()?.path() : null }; +} +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, 1280×720 by default, and relative paths are resolved + as for `New Context`. Name the function's parameters like its keys and + `**options` passes them on. Give `tracing` to `adoptContext` and the others to + Playwright when you create the context. +- **`register_adopted_context`** registers the context after the call, so that + the keyword calls become groups in the trace and the log embeds the video. + It returns the video path, or an empty string without a video. + +::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 +375,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)']