Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Browser/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ keyword, over the **Robot Framework path** or the **Python path**, and a Python
on Browser can also call the function by name with `call_js_keyword`.
_Avoid_: JS keyword, extension function (that is the JavaScript side only)

**Adopted context**:
A browser context that a **JavaScript extension keyword** created itself, such as the context of
an Electron app, and handed over with `adoptContext`. From then on Browser owns its lifecycle and
registers it like a context of `New Context`.
_Avoid_: external context, foreign context

**Injected argument**:
An argument of a **JavaScript extension keyword**'s function that the library supplies by
name instead of taking it from the caller: `page`, `context`, `browser`, `logger`,
Expand All @@ -70,6 +76,8 @@ _Avoid_: reserved argument, API argument
Only `defaultTimeout` yields to a caller, and only to a library calling the function by name
with `call_js_keyword`. Called as a keyword, it always gets the library timeout, which a test
changes with `Set Browser Timeout`.
- An **adopted context** is registered by `call_js_keyword`, from what Node reports when
`adoptContext` resolves, however the **JavaScript extension keyword** was called.

## Example dialogue

Expand Down
33 changes: 12 additions & 21 deletions Browser/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,9 @@ def __init__( # noqa: PLR0915
``call_js_keyword`` may pass its own instead.
``adoptContext(context, options)`` makes a BrowserContext that the
function created itself, such as the context of an Electron app, the
active browser, so the other keywords work on it. Can be a single path,
active browser, so the other keywords work on it. The context gets the
library timeout, and is traced and recorded like a context of
`New Context`. Can be a single path,
a comma-separated list of paths or a real list of strings. The options
of ``adoptContext`` and examples are in
https://robotframework-browser.org/docs/extending/javascript-extensions
Expand Down Expand Up @@ -887,6 +889,10 @@ def call_js_keyword(self, keyword_name: str, /, **args) -> Any:
body_parts: list[str] = []
for response in responses:
logger.info(response.log)
if response.adoptedContext:
self._playwright_state._register_adopted_context(
json.loads(response.adoptedContext)
)
if response.bodyPart:
body_parts.append(response.bodyPart)
if not body_parts:
Expand Down Expand Up @@ -918,32 +924,17 @@ def adopted_context_options(
The keys can be passed to the extension function as keyword arguments:
``tracing`` for the ``tracing`` option of ``adoptContext``, ``recordVideo``
and ``recordHar`` for Playwright when the extension creates the context, and
``tracesDir`` when it launches a browser or an application. After the
extension returns, call ``register_adopted_context`` with these options.
``tracesDir`` when it launches a browser or an application. Pass the options
the context was created with as ``contextOptions`` of ``adoptContext``:
Browser then registers the context like `New Context` registers its own, so
the keyword calls become groups in the trace and the log embeds the video in
its size.
"""
options = self._playwright_state.adopted_context_options
return converting_proxy(options, get_type_hints(options))(
tracing, recordVideo, recordHar, viewport
)

def register_adopted_context(
self,
context_id: str,
options: AdoptedContextOptions,
video_path: str | None = None,
) -> str:
"""Registers a context that a JavaScript extension handed over with
``adoptContext`` the way `New Context` registers its own.

``options`` are those of ``adopted_context_options``. With ``tracing``, the
keyword calls become groups in the trace. With ``recordVideo``, the log
embeds the video at ``video_path``. Returns ``video_path``, or an empty
string if there is no video.
"""
return self._playwright_state.register_adopted_context(
context_id, options, video_path
)

@property
def outputdir(self) -> str:
if EXECUTION_CONTEXTS.current:
Expand Down
33 changes: 17 additions & 16 deletions Browser/keywords/playwright_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@
from robot.utils import get_link_path
from robot.utils.robottypes import is_truthy

from Browser.utils.data_types import BrowserInfo, ContextVideo, TracingGroupMode
from Browser.utils.data_types import (
AdoptedContextReport,
BrowserInfo,
ContextVideo,
TracingGroupMode,
)
from Browser.utils.misc import get_download_id

from ..assertion_engine import assertion_formatter_used, with_assertion_polling
Expand Down Expand Up @@ -1015,21 +1020,17 @@ def adopted_context_options(
"tracesDir": str(self.traces_temp / str(uuid4())),
}

def register_adopted_context(
self,
context_id: str,
options: AdoptedContextOptions,
video_path: str | None = None,
) -> str:
record_video = options["recordVideo"]
video: ContextVideo | None = (
{"video_path": video_path, "contextUuid": context_id}
if record_video is not None
else None
)
return self._register_context(
context_id, options["tracing"] or "", record_video, video
)
def _register_adopted_context(self, report: AdoptedContextReport) -> None:
options = report["contextOptions"]
video = report["video"]
record_video: RecordVideo | None = None
if options.get("recordVideo") is not None or video["video_path"]:
requested: RecordVideo = options.get("recordVideo") or RecordVideo()
record_video = {
**requested,
"size": self._video_size(requested, options.get("viewport")),
}
self._register_context(report["id"], report["traceFile"], record_video, video)

def _resolve_trace_file(self, tracing: bool | Path | None) -> str:
if isinstance(tracing, str):
Expand Down
14 changes: 14 additions & 0 deletions Browser/utils/data_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,20 @@ class ContextVideo(TypedDict):
contextUuid: str


class AdoptedContextReport(TypedDict):
"""A context that a JavaScript extension handed over with ``adoptContext``, as
the Node side reports it so that Browser registers it like its own contexts.

``traceFile`` is empty when the context is not traced. ``contextOptions`` are
the options the extension passed to ``adoptContext``.
"""

id: str
traceFile: str
contextOptions: dict[str, Any]
video: ContextVideo


class KeywordCallStackEntry(TypedDict):
"""Information about the keyword call stack.

Expand Down
7 changes: 2 additions & 5 deletions atest/library/AdoptingLibrary.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ def open_adopted_context_with_recording(
viewport: ViewportDimensions | None = None,
) -> str:
"""Opens a persistent context in the JavaScript extension with the options of
``adopted_context_options``, registers it with ``register_adopted_context``
and returns the path of its video."""
``adopted_context_options`` and returns the path of its video."""
options = self.adopted_context_options(
tracing=tracing, recordVideo=record_video, viewport=viewport
)
Expand All @@ -31,9 +30,7 @@ def open_adopted_context_with_recording(
viewport=viewport,
**options,
)
return self.register_adopted_context(
adopted["contextId"], options, adopted["videoPath"]
)
return adopted["videoPath"]

@keyword
def video_size_of_the_active_context(self) -> ViewportDimensions | None:
Expand Down
10 changes: 10 additions & 0 deletions atest/test/05_JS_Tests/adopt.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,20 @@ async function openAdoptedPersistentContextWithRecording(userDataDir, url, headl
return adoptContext(context, { tracing, contextOptions });
}

async function openAdoptedPersistentContextOnItsSecondPage(userDataDir, url, headless, playwright, adoptContext) {
const context = await playwright.chromium.launchPersistentContext(userDataDir, {
headless: String(headless).toLowerCase() !== 'false',
});
const second = await context.newPage();
await second.goto(url);
return adoptContext(context, { activePage: second });
}

exports.__esModule = true;
exports.openAdoptedPersistentContext = openAdoptedPersistentContext;
exports.openAdoptedPersistentContextWithTracingAndDownloads = openAdoptedPersistentContextWithTracingAndDownloads;
exports.messageOfAFailedWait = messageOfAFailedWait;
exports.adoptedContextIsClosed = adoptedContextIsClosed;
exports.adoptedContextOnCloseWasCalled = adoptedContextOnCloseWasCalled;
exports.openAdoptedPersistentContextWithRecording = openAdoptedPersistentContextWithRecording;
exports.openAdoptedPersistentContextOnItsSecondPage = openAdoptedPersistentContextOnItsSecondPage;
17 changes: 15 additions & 2 deletions atest/test/05_JS_Tests/adopt_context.robot
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
*** Settings ***
Library Browser jsextension=${CURDIR}/adopt.js
Library ArchiveLibrary
Library Collections
Library OperatingSystem
Library ../../library/tracing_groups.py
Resource ../variables.resource

Test Tags no-iframe
Expand All @@ -17,18 +20,28 @@ Browser Keywords Work On A Context Adopted By A JavaScript Extension
${on_close_called} = Adopted Context On Close Was Called
Should Be True ${on_close_called}

The Creator Of An Adopted Context Chooses Its Active Page
${adopted} = Open Adopted Persistent Context On Its Second Page
... ${OUTPUT_DIR}/adopted-profile-active-page ${LOGIN_URL} ${HEADLESS}
Get Url == ${LOGIN_URL}
Get Page Ids ACTIVE ACTIVE ACTIVE == ${adopted}[pageId]
[Teardown] Close Browser

An Adopted Context Gets The Library Timeout
Set Browser Timeout 1s scope=Test
Open Adopted Persistent Context ${OUTPUT_DIR}/adopted-profile-timeout ${LOGIN_URL} ${HEADLESS}
${message} = Message Of A Failed Wait
Should Contain ${message} Timeout 1000ms exceeded
[Teardown] Close Browser

An Adopted Context Is Traced And Accepts Downloads
An Adopted Context Is Traced With Keyword Groups And Accepts Downloads
Open Adopted Persistent Context With Tracing And Downloads
... ${OUTPUT_DIR}/adopted-profile-traced ${LOGIN_URL} ${HEADLESS} ${OUTPUT_DIR}/adopted-trace.zip
${download_url} = Get Property id=file_download href
${download} = Download ${download_url}
File Should Exist ${download}[saveAs]
Close Browser
File Should Exist ${OUTPUT_DIR}/adopted-trace.zip
Extract Zip File ${OUTPUT_DIR}/adopted-trace.zip ${OUTPUT_DIR}/adopted-trace
${groups} = Get Trace Group Titles ${OUTPUT_DIR}/adopted-trace/trace.trace
Should Contain Match ${groups} Browser.Open Adopted Persistent Context With Tracing And Downloads*
Should Contain Match ${groups} Browser.Download*
1 change: 1 addition & 0 deletions docker/Dockerfile.dev_pr
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ USER root
RUN apt-get update
RUN apt install -y software-properties-common
RUN add-apt-repository -y ppa:deadsnakes/ppa
RUN apt-get update
RUN apt install -y python3.14 python3.14-venv

# Clean up
Expand Down
37 changes: 37 additions & 0 deletions docs/adr/0010-adopted-contexts-registered-from-nodes-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Adopted contexts are registered by `call_js_keyword` from Node's report

A JavaScript extension hands Browser a context it created with `adoptContext`, for example the
context of an Electron app. Browser must then register it the way it registers the contexts of
`New Context`: open the keyword calls as trace groups, cache the video size, embed the video in
the log. Node writes a report on the response stream of the extension call as soon as
`adoptContext` resolves, with the context id, its trace file, the options the extension passed
as `contextOptions` and the video path of its active page. `call_js_keyword` registers each
report while it reads the stream. Every extension call goes through `call_js_keyword`, so a
plain `jsextension` keyword gets the same registration as a library built on Browser.

## Considered options

- **The caller registers**, with a public `register_adopted_context(context_id, options,
video_path)` after the extension call. This was the first design (#5326), removed before it
was released. The caller had to pass the same options twice, make the extension return the
video path, and remember the call: forgetting it failed silently, with no trace groups and no
video. A plain `jsextension` keyword had no way to make the call at all.
- **Register at the end of the call**, from the final message. Rejected: when the extension
function throws after adopting, Python would never hear of a context that Node keeps.
- **Python as the source of truth**, registering from the options of `adopted_context_options`.
Rejected: Python then believes what the options say, not what the extension did, for example
whether it passed `tracing` to `adoptContext`. Node knows the trace file and the video it
actually got.
- **Reuse the `json` field** of `Response.Json`, which extension calls leave empty. Rejected:
it hides what the field means. `adoptedContext` is its own field.

## Consequences

- **`adopted_context_options` stays** as the one Python step. It holds what only Python
knows: the trace path and `ROBOT_FRAMEWORK_BROWSER_TRACING`, the video folder and size, the
HAR path and `tracesDir`.
- **`contextOptions` is optional.** Without it the video is still embedded, in the size of the
viewport or 1280x720, and `Download` does not see `acceptDownloads`.
- **An extension function may adopt several contexts**; each one is reported and registered.
- **The report is registered on whatever path the call came from.** Trace groups exist only on
the Robot Framework path, as for `New Context`.
Loading
Loading