-
Notifications
You must be signed in to change notification settings - Fork 142
Add Electron application support #4695
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 10 commits
5f34129
cb77a41
ee71d9e
0105496
0115059
b812a5f
421940b
b1fe363
2293043
ad851dc
e721267
fbbb273
4ac2bfa
a0f0898
d3b9058
1ecfe43
f906d04
992104a
c6e340b
33d7e08
5eb6bb2
56a575d
3a8e876
d0a0f95
ec23470
707db03
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,257 @@ | ||
| # Copyright 2020- Robot Framework Foundation | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| import json | ||
| from copy import copy | ||
| from datetime import timedelta | ||
| from pathlib import Path | ||
|
|
||
| from ..base import LibraryComponent | ||
| from ..generated.playwright_pb2 import Request | ||
| from ..utils import ( | ||
| ColorScheme, | ||
| GeoLocation, | ||
| HttpCredentials, | ||
| NewPageDetails, | ||
| RecordHar, | ||
| RecordVideo, | ||
| ViewportDimensions, | ||
| keyword, | ||
| logger, | ||
| ) | ||
|
|
||
|
|
||
| class Electron(LibraryComponent): | ||
| """Keywords for launching and controlling Electron applications.""" | ||
|
|
||
| @keyword(tags=("Setter", "BrowserControl")) | ||
| def new_electron_application( | ||
| self, | ||
| executable_path: Path, | ||
| args: list[str] | None = None, | ||
| env: dict[str, str] | None = None, | ||
| timeout: timedelta = timedelta(seconds=30), | ||
| *, | ||
| acceptDownloads: bool = True, | ||
| bypassCSP: bool = False, | ||
| colorScheme: ColorScheme | None = None, | ||
| cwd: str | None = None, | ||
| extraHTTPHeaders: dict[str, str] | None = None, | ||
| geolocation: GeoLocation | None = None, | ||
| hasTouch: bool | None = None, | ||
| httpCredentials: HttpCredentials | None = None, | ||
| ignoreHTTPSErrors: bool = False, | ||
| isMobile: bool | None = None, | ||
| javaScriptEnabled: bool = True, | ||
| locale: str | None = None, | ||
| offline: bool = False, | ||
| recordHar: RecordHar | None = None, | ||
| recordVideo: RecordVideo | None = None, | ||
| slowMo: timedelta = timedelta(seconds=0), | ||
| timezoneId: str | None = None, | ||
| tracesDir: str | None = None, | ||
| viewport: ViewportDimensions | None = ViewportDimensions( | ||
| width=1280, height=720 | ||
| ), | ||
| ) -> tuple[str, str, NewPageDetails]: | ||
| """Launches an Electron application and sets its first window as the active page. | ||
|
|
||
| Uses Playwright's ``_electron.launch()`` API to start the application, then | ||
| attaches the first window as the active ``Page``. All standard Browser library | ||
| page keywords (``Click``, ``Get Text``, ``Wait For Elements State``, …) work | ||
| against the Electron window without any extra setup. | ||
|
|
||
| Returns a ``(browser_id, context_id, page_details)`` tuple — the same shape as | ||
| `New Persistent Context` — so ``Switch Page`` and friends work if multiple | ||
| windows are open. | ||
|
|
||
| *Note on headless mode* | ||
|
|
||
| Playwright does not expose a ``headless`` option for Electron — the application | ||
| always opens a native GUI window. On Linux CI machines, run the process inside a | ||
| virtual display: | ||
|
|
||
| | # Before running tests, start a virtual display: | ||
| | Xvfb :99 -screen 0 1280x720x24 & | ||
| | export DISPLAY=:99 | ||
|
|
||
| | =Argument= | =Description= | | ||
| | ``executable_path`` | Path to the Electron binary or packaged application executable. When using the bare ``electron`` npm package pass the path to ``main.js`` via ``args``. | | ||
| | ``args`` | Additional command-line arguments forwarded to the Electron process. Use this to pass the entry-point script when running the bare Electron binary, e.g. ``[node/my-app/main.js]``. | | ||
| | ``env`` | Environment variables for the launched process. Merged on top of the current process environment so ``PATH`` and other system variables are still inherited. | | ||
| | ``timeout`` | Maximum time to wait for the first window to appear. Defaults to ``30 seconds``. Pass ``0`` to disable. | | ||
| | ``acceptDownloads`` | Whether to automatically download all attachments. Defaults to ``True``. | | ||
| | ``bypassCSP`` | Toggles bypassing page's Content-Security-Policy. Defaults to ``False``. | | ||
| | ``colorScheme`` | Emulates ``prefers-color-scheme`` media feature: ``dark``, ``light``, ``no-preference``, or ``null`` to disable emulation. | | ||
| | ``cwd`` | Working directory for the launched Electron process. Defaults to the current working directory. | | ||
| | ``extraHTTPHeaders`` | Additional HTTP headers sent with every renderer request. | | ||
| | ``geolocation`` | Geolocation to emulate. Dictionary with ``latitude``, ``longitude``, and optional ``accuracy`` keys. | | ||
| | ``hasTouch`` | Whether the viewport should support touch events. | | ||
| | ``httpCredentials`` | Credentials for HTTP Basic Authentication. Dictionary with ``username`` and ``password`` keys. | | ||
| | ``ignoreHTTPSErrors`` | Whether to ignore HTTPS errors during navigation. Defaults to ``False``. | | ||
| | ``isMobile`` | Whether to emulate a mobile device (meta-viewport tag, touch events, …). | | ||
| | ``javaScriptEnabled`` | Whether to enable JavaScript in the renderer. Defaults to ``True``. | | ||
| | ``locale`` | Renderer locale, e.g. ``en-GB``. Affects ``navigator.language`` and date/number formatting. | | ||
| | ``offline`` | Emulates network being offline. Defaults to ``False``. | | ||
| | ``recordHar`` | Enable HAR recording. Dictionary with ``path`` (required) and optional ``omitContent``. | | ||
| | ``recordVideo`` | Enable video recording. Dictionary with ``dir`` (required) and optional ``size``. | | ||
| | ``slowMo`` | Slows down all Playwright operations by the given duration. Useful for visual debugging. Defaults to no delay. | | ||
| | ``timezoneId`` | Overrides the system timezone for the renderer, e.g. ``Europe/Berlin``. | | ||
| | ``tracesDir`` | Directory where Playwright trace files are written. | | ||
| | ``viewport`` | Initial viewport dimensions. Defaults to ``{'width': 1280, 'height': 720}``. Pass ``None`` to use the native window size. | | ||
|
|
||
| Example — bare Electron binary with a source checkout: | ||
| | ${ELECTRON}= Set Variable node_modules/.bin/electron | ||
| | @{ARGS}= Create List node/electron-test-app/main.js | ||
| | ${browser} ${context} ${page}= `New Electron Application` | ||
| | ... executable_path=${ELECTRON} args=@{ARGS} | ||
| | `Get Title` == My App | ||
|
|
||
| Example — packaged application executable: | ||
| | ${browser} ${context} ${page}= `New Electron Application` | ||
| | ... executable_path=/usr/share/myapp/myapp | ||
| | `Get Title` == My App | ||
|
|
||
| Example — French locale, larger viewport, video recording: | ||
| | &{VIDEO}= Create Dictionary dir=videos | ||
| | ${browser} ${context} ${page}= `New Electron Application` | ||
| | ... executable_path=/usr/share/myapp/myapp | ||
| | ... locale=fr-FR | ||
| | ... viewport={'width': 1920, 'height': 1080} | ||
| | ... recordVideo=${VIDEO} | ||
|
|
||
| [https://forum.robotframework.org/t//4309|Comment >>] | ||
| """ | ||
| timeout_ms = int(timeout.total_seconds() * 1000) | ||
| slow_mo_ms = int(slowMo.total_seconds() * 1000) | ||
|
|
||
| options: dict = { | ||
| "executablePath": str(executable_path), | ||
| "acceptDownloads": acceptDownloads, | ||
| } | ||
|
|
||
| if args: | ||
| options["args"] = args | ||
| if env: | ||
| options["env"] = env | ||
| if bypassCSP: | ||
| options["bypassCSP"] = bypassCSP | ||
| if colorScheme is not None: | ||
| options["colorScheme"] = colorScheme.name.replace("_", "-") | ||
| if cwd is not None: | ||
| options["cwd"] = cwd | ||
| if extraHTTPHeaders is not None: | ||
| options["extraHTTPHeaders"] = extraHTTPHeaders | ||
| if geolocation is not None: | ||
| options["geolocation"] = dict(geolocation) | ||
| if hasTouch is not None: | ||
| options["hasTouch"] = hasTouch | ||
| if httpCredentials is not None: | ||
| options["httpCredentials"] = dict(httpCredentials) | ||
| if ignoreHTTPSErrors: | ||
| options["ignoreHTTPSErrors"] = ignoreHTTPSErrors | ||
| if isMobile is not None: | ||
| options["isMobile"] = isMobile | ||
| if not javaScriptEnabled: | ||
| options["javaScriptEnabled"] = javaScriptEnabled | ||
| if locale is not None: | ||
| options["locale"] = locale | ||
| if offline: | ||
| options["offline"] = offline | ||
| if recordHar is not None: | ||
| options["recordHar"] = dict(recordHar) | ||
| if recordVideo is not None: | ||
| options["recordVideo"] = dict(recordVideo) | ||
| if slow_mo_ms > 0: | ||
| options["slowMo"] = slow_mo_ms | ||
| if timeout_ms is not None: | ||
| options["timeout"] = timeout_ms | ||
| if timezoneId is not None: | ||
| options["timezoneId"] = timezoneId | ||
| if tracesDir is not None: | ||
| options["tracesDir"] = tracesDir | ||
| if viewport is not None: | ||
| options["viewport"] = copy(viewport) | ||
|
|
||
| with self.playwright.grpc_channel() as stub: | ||
| response = stub.LaunchElectron( | ||
| Request().ElectronLaunch( | ||
| rawOptions=json.dumps(options, default=str), | ||
| defaultTimeout=timeout_ms, | ||
| ) | ||
| ) | ||
| logger.info(response.log) | ||
|
|
||
| video_path = None | ||
| if recordVideo is not None: | ||
| try: | ||
| video_path = response.video | ||
| except Exception: | ||
| pass | ||
|
|
||
| return ( | ||
| response.browserId, | ||
| response.id, | ||
| NewPageDetails(page_id=response.pageId, video_path=video_path), | ||
| ) | ||
|
|
||
| @keyword(tags=("Setter", "BrowserControl")) | ||
| def close_electron_application(self) -> None: | ||
| """Closes the running Electron application and cleans up library state. | ||
|
|
||
| Equivalent to `Close Browser` for Electron apps. Closes the | ||
| ``ElectronApplication`` handle and removes the associated browser, context, | ||
| and page from the Browser library state stack. | ||
|
|
||
| After this keyword there is no active browser; call `New Electron Application`, | ||
| `New Browser`, or `New Persistent Context` before issuing further page | ||
| interactions. | ||
|
|
||
| Calling this keyword when no Electron app is open is safe — it logs a message | ||
| and does nothing. | ||
|
|
||
| Example: | ||
| | `New Electron Application` executable_path=/path/to/app | ||
| | # ... test steps ... | ||
| | [Teardown] `Close Electron Application` | ||
|
|
||
| [https://forum.robotframework.org/t//4309|Comment >>] | ||
| """ | ||
| with self.playwright.grpc_channel() as stub: | ||
| response = stub.CloseElectron(Request().Empty()) | ||
| logger.info(response.log) | ||
|
|
||
| @keyword(tags=("Getter", "BrowserControl")) | ||
| def open_electron_dev_tools(self) -> None: | ||
| """Opens Chromium DevTools for every window of the running Electron application. | ||
|
|
||
| Calls ``BrowserWindow.getAllWindows()`` in the Electron **main process** via | ||
| ``ElectronApplication.evaluate()``. Node.js and Electron APIs are only | ||
| available in the main process — renderer contexts (where `Evaluate JavaScript` | ||
| runs) cannot access them. | ||
|
|
||
| Intended as a development-time debugging aid: use it to inspect the live DOM, | ||
| find element selectors, and debug JavaScript. | ||
|
|
||
| Example: | ||
| | `New Electron Application` executable_path=/path/to/app | ||
| | `Open Electron Dev Tools` | ||
| | Sleep 30s # manually inspect the DevTools panel | ||
| | `Close Electron Application` | ||
|
|
||
| [https://forum.robotframework.org/t//4309|Comment >>] | ||
| """ | ||
| with self.playwright.grpc_channel() as stub: | ||
| response = stub.OpenElectronDevTools(Request().Empty()) | ||
| logger.info(response.log) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We use Python invoke https://www.pyinvoke.org/ for this type of jobs. Although I think this code should not be needed in this form, if we can merge package.json with the one in the root folder. In any case installing development dependencies in the test is not accepted. It must be done in the existing task with the invoke. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import subprocess | ||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| from robot.api.exceptions import SkipExecution | ||
|
|
||
|
|
||
| def install_electron_test_app(app_dir: str) -> str: | ||
| """Install npm dependencies and return the platform-specific Electron binary path. | ||
|
|
||
| Raises SkipExecution when app_dir does not exist so the caller suite is | ||
| gracefully skipped (e.g. BrowserBatteries runs that remove node/). | ||
| Skips the npm ci step when node_modules is already present. | ||
| """ | ||
| app_path = Path(app_dir) | ||
| if not app_path.exists(): | ||
| raise SkipExecution( | ||
| f"Electron test app not present ({app_dir} missing) — skipping suite." | ||
| ) | ||
|
|
||
| node_modules = app_path / "node_modules" | ||
| if not node_modules.exists(): | ||
| npm = "npm.cmd" if sys.platform == "win32" else "npm" | ||
| result = subprocess.run( | ||
| [npm, "ci"], | ||
| cwd=str(app_path), | ||
| capture_output=True, | ||
| text=True, | ||
| check=False, | ||
| ) | ||
| if result.returncode != 0: | ||
| raise RuntimeError(f"npm ci failed in {app_dir}:\n{result.stderr}") | ||
|
|
||
| if sys.platform == "win32": | ||
| return str(app_path / "node_modules" / "electron" / "dist" / "electron.exe") | ||
| if sys.platform == "darwin": | ||
| return str( | ||
| app_path | ||
| / "node_modules" | ||
| / "electron" | ||
| / "dist" | ||
| / "Electron.app" | ||
| / "Contents" | ||
| / "MacOS" | ||
| / "Electron" | ||
| ) | ||
| return str(app_path / "node_modules" / "electron" / "dist" / "electron") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
recordVideois set, this code readsresponse.videoas if it were a plain path string. Elsewhere (e.g.New Persistent Context)response.videois JSON and is passed through_embed_video(...)to produce the finalvideo_path. As-is, Electron video recording will either be broken or inconsistent; align this with the existing persistent-context handling (parse JSON + embed, and update any required caches).