Skip to content

Let libraries trace and record adopted contexts the way New Context does - #5326

Merged
aaltat merged 5 commits into
MarketSquare:mainfrom
d-biehl:adopted-context-recording
Oct 16, 2026
Merged

aaltat merged 5 commits into
MarketSquare:mainfrom
d-biehl:adopted-context-recording

Conversation

@d-biehl

@d-biehl d-biehl commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #5318, as discussed there. Fixes #5152.

With #5318, Browser starts tracing an adopted context, but a library still has to work out the trace path, the video folder and size and the HAR path itself, and register the context for the keyword groups in the trace and for the video in the log. The Electron library did that through internals of PlaywrightState, such as _resolve_trace_file, _set_video_path and _embed_video. This PR gives libraries built on Browser two public methods for it, so that an adopted context follows Browser's settings in the same way as New Context.

  • Browser.adopted_context_options(tracing=None, recordVideo=None, recordHar=None, viewport=None) returns an AdoptedContextOptions, a typed dictionary with tracing, recordVideo, recordHar and tracesDir. They follow the rules of New Context: tracing=True and ROBOT_FRAMEWORK_BROWSER_TRACING give Browser's trace folder, a video gets Browser's video folder and the size of viewport or 1280x720 by default, an empty recordVideo records with these defaults, and relative paths are resolved as for New Context. None means off. tracesDir is a new folder for Playwright's temporary trace files under browser/traces/temp, as New Browser gives one to each browser it launches. The keys are named like the options, so a library can pass them on to its extension function as keyword arguments.
  • Browser.register_adopted_context(context_id, options, video_path=None) registers the adopted context afterwards: 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.

Both call PlaywrightState, where New Context and New Persistent Context resolve their options and register their contexts with the same helpers.

A library in Python then does:

options = self.adopted_context_options(tracing=tracing, recordVideo=record_video, recordHar=record_har)
adopted = self.call_js_keyword("launchElectronApplication", executablePath=path, defaultTimeout=int(self.timeout), **options)
self.register_adopted_context(adopted["contextId"], options, adopted.get("videoPath"))

Related changes:

  • Relative recordHar paths (New Context recordHar with a relative path writes the HAR into the wrapper directory, also on the Robot Framework path #5152): New Context and New Persistent Context passed the path of recordHar to the Node process as given, so a relative path put the HAR file into the wrapper folder of the installed package. A relative path is now relative to the output directory, as a .zip path of tracing is, and adopted_context_options uses the same rule. Unlike recordVideo and tracing, recordHar has no default location yet. Browser could give it one, for example browser/har/<unique name>.har when the path is left out, and adopted contexts would get it through adopted_context_options too. I left that out to keep this PR small, but I can add it here or in a separate PR if you like the idea.
  • An empty recordVideo: New Context recordVideo={} got no dir, so Playwright recorded into its temporary artifacts folder and deleted the video when the browser closed, while the log still linked to it. It now records into Browser's video folder.
  • The caller's recordVideo stays as given: before Python 3.14, New Context changed dir in the caller's dictionary to an absolute PosixPath. It now works on a copy on every Python version.

The JavaScript extensions page describes the two methods, updated in MarketSquare/robotframework-browser.org#21.

Tests:

  • atest/test/05_JS_Tests/adopted_context_recording.robot: a small library built on Browser, atest/library/AdoptingLibrary.py, adopts a persistent context through adopt.js with the options of adopted_context_options and registers it. The trace has the keyword groups, and the video is written to Browser's video folder.
  • utest/test_adopted_context.py: the options follow New Context, including the trace folders, the global tracing setting as environment variable and as Robot Framework variable, the video folder and size, the viewport, tracesDir and the HAR paths. Registering opens the trace groups, except in the tracing group mode Playwright, and embeds the video. New Context and New Persistent Context send relative HAR paths resolved.
  • atest/test/01_Browser_Management/record_har.robot: a relative HAR path ends up in the output directory.
  • atest/test/01_Browser_Management/video.robot: the caller's recordVideo stays as given on every Python version.

There are no Node or protocol changes. I verified it locally with inv lint-python, inv lint-robot, inv lint-node, inv utest and the acceptance suites Adopted Context Recording, Adopt Context, Record Har, Tracing, Tracing Groups, Video, Persistent State, Playwright State, Geolocation and Browser New Context Test, with Chromium only. The Electron library uses the two methods and passes tracesDir to _electron.launch, and its tests pass against this branch.

AI / tooling disclosure
This contribution was prepared with assistance from Claude Code (Anthropic). I reviewed the result manually, understand the changes, verified them locally, and confirm that I have the right to submit this contribution under the project license.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Adopted HAR options can forward string booleans to Playwright without conversion.

1 open finding
What changed in this PR

Adds public helpers for libraries adopting Browser contexts while aligning tracing, video, and HAR behavior with New Context.

Changes:

  • Adds adopted-context option generation and registration.
  • Resolves relative HAR paths against the output directory.
  • Adds unit and acceptance coverage.
File Description
Browser/​browser.py Adds adopted-context APIs and shortens extension help.
Browser/​keywords/​playwright_state.py Resolves relative HAR paths.
Browser/​utils/​data_types.py Defines adopted-context option types.
Browser/​utils/​__init__.py Exports the new type.
utest/​test_adopted_context.py Tests options and registration.
atest/​test/​01_Browser_Management/​record_har.robot Verifies relative HAR paths.

🧠 Review effort: Balanced

Comment thread Browser/browser.py Outdated

@aaltat aaltat left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the follow-up, Daniel. Some comments inline and two more here.

#5152: the change to relative recordHar paths fixes #5152. Could you add Fixes #5152 to the PR description, so that the issue is closed and the change shows up in the release notes?

Missing tests:

  • Most important: an acceptance test for the whole flow. adopt_context.robot does not use adopted_context_options or register_adopted_context, so right now only the Electron library tests the path from the extension to registration. For example, a small library in atest/library/ that subclasses Browser, calls adopted_context_options, adopts a context through adopt.js with tracing and video, and calls register_adopted_context. Then the test checks that the trace has the keyword groups and that the video is written.
  • Unit tests for:
    • a relative trace folder (not a .zip file)
    • an absolute video dir
    • the Robot Framework variable ${ROBOT_FRAMEWORK_BROWSER_TRACING} (only the environment variable is tested now)
    • TracingGroupMode.Playwright, which should not add trace groups
    • a relative HAR path with New Persistent Context

Comment thread utest/test_adopted_context.py Outdated


class NoPlaywright:
"""Stands in for the Node process, which these tests do not need."""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this docstring. We avoid docstrings and comments in tests; the class name already says what it is.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed. The fake now also stands in for the gRPC stub, so it is called FakePlaywright.

Comment thread Browser/browser.py Outdated
)
if not options["recordVideo"]:
return ""
state.context_cache.add(context_id, options["recordVideo"]["size"])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This repeats the end of new_context and new_persistent_context in playwright_state.py (add the context to the trace, context_cache.add, _embed_video). Could you move it into one helper on PlaywrightState that all three use? Then the next change to how a context is registered only has to happen in one place.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. _register_context on PlaywrightState adds the trace groups, the context cache entry and the video, and New Context, New Persistent Context and register_adopted_context use it. New Persistent Context calls it right after the context is created, where it added the trace groups before, so that the navigation to url stays inside the keyword's group in the trace.

Comment thread Browser/browser.py Outdated
"recordHar": None,
}
if recordVideo is not None:
params = {"recordVideo": {"dir": None, **recordVideo}}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New Context takes the video size from the viewport when recordVideo has no size, and only then falls back to 1280x720 (_get_video_size). Here only recordVideo is used, so an adopted context with a custom viewport gets 1280x720. Could you make it follow the same rule as New Context?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. adopted_context_options takes an optional viewport, and a video without a size gets its size, as in New Context, or 1280x720.

Comment thread Browser/browser.py Outdated
"recordVideo": None,
"recordHar": None,
}
if recordVideo is not None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New Context runs its arguments through convert_typed_dict, so the keys of recordVideo and recordHar are matched case-insensitively (Dir, Size, ...). adopted_context_options skips that, so {"Dir": ...} works with New Context but not here. Please convert them the same way.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked into this before changing it, and New Context does not match these keys case-insensitively today:

  • Robot Framework rejects New Context recordVideo={'Dir': 'videos'} before the keyword runs, because it checks the keys of the RecordVideo TypedDict: Argument 'recordVideo' got value '{'Dir': 'videos'}' that cannot be converted to RecordVideo or None. (tried with RF 7.5).
  • From Python, convert_typed_dict converts recordVideo only on Python 3.14. Before 3.14, RecordVideo | None is a types.UnionType, which has no __origin__, so the check for Union does not match and the dictionary is passed on as it is. That is also why video.robot needed ${PYTHON_314}.
  • On 3.14 the conversion keeps only the keys that RecordVideo declares. Playwright's recordVideo also has showActions, the overlay of the performed actions, and the Electron library passes it through adopted_context_options. With the conversion it would be lost, but only on Python 3.14.

My suggestion: add showActions to RecordVideo, as a small ShowActions TypedDict with duration, position, fontSize and cursor, as Playwright documents it for the context options. Then New Context and New Persistent Context get the overlay too, the conversion keeps it, and AdoptedContextOptions can use RecordVideo and RecordHar (see the other thread). Shall I add it to this PR, or in a separate one?

Comment thread Browser/browser.py
return None
return json.loads(last_json)

def adopted_context_options(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When Browser launches a browser, it sets tracesDir to a unique folder under ${OUTPUT_DIR}/browser/traces/temp (_set_browser_options), and Browser removes these temporary trace files. The options here do not include tracesDir, so when an extension launches its own browser or Electron app with tracing, the temporary trace files end up outside that folder. Could adopted_context_options return tracesDir too, so that the extension can pass it to the launch?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The options include tracesDir, a new folder under browser/traces/temp, as New Browser uses for the browsers it launches, and the docstring says to pass it to the launch. The Electron library passes it to _electron.launch, and the new acceptance test passes it to launchPersistentContext.

Comment thread Browser/browser.py Outdated
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.
After the extension returns, call `register_adopted_context` with these

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In libdoc, single backticks link to a keyword, but register_adopted_context (here) and adopted_context_options (line 951) are not keywords, so the links point nowhere. Please use double backticks for both.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, both are in double backticks now.

"""

tracing: str | None
recordVideo: dict[str, Any] | None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why dict[str, Any] here, when RecordVideo and RecordHar already exist and are used for the arguments of adopted_context_options? Let's discuss this one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because of showActions: the Electron library passes Playwright's recordVideo.showActions through adopted_context_options, and RecordVideo does not declare it, so typing the returned dictionary as RecordVideo would hide a key that is there. If RecordVideo gets showActions, as I suggested in the thread about the case-insensitive keys, AdoptedContextOptions can use RecordVideo | None and RecordHar | None, and I will change it then. Until we agree on that, I left dict[str, Any].

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with your suggestion. I will merge this PR and send time on fixing few other minor things in the same go.

@aaltat

aaltat commented Oct 15, 2026

Copy link
Copy Markdown
Member

Daniel, one more, and it is the bigger one. adopted_context_options and register_adopted_context live on Browser, but all they do is call private methods of PlaywrightState: _resolve_trace_file, _set_video_path, _set_video_size_to_int, _set_har_path and _embed_video. To fit those helpers, the video options are wrapped as {"recordVideo": {"dir": None, **recordVideo}} and then taken apart again. In this repo state flows from Browser to the components (ADR 0009), not the other way. So I would like the logic to move to PlaywrightState, and Browser to keep only the public entry points for libraries.

Here is a sketch of what I mean. It is not tested; names and details are up to you.

1. The helpers take the option dict instead of the whole params, and return a new dict. They replace _set_video_path, _set_video_size_to_int, _get_video_size, _get_record_video_size and _set_har_path:

# Browser/keywords/playwright_state.py

def _record_video(self, record_video: dict | None, viewport: dict | None) -> dict | None:
    if not record_video:
        return None
    video_dir = record_video.get("dir")
    if video_dir is None:
        resolved_dir = self.video_output
    elif Path(video_dir).is_absolute():
        resolved_dir = Path(video_dir)
    else:
        resolved_dir = self.video_output / video_dir
    return {
        **record_video,
        "dir": str(resolved_dir.resolve()),
        "size": self._video_size(record_video, viewport),
    }

def _video_size(self, record_video: dict | None, viewport: dict | None) -> dict:
    size = (record_video or {}).get("size") or {}
    if size.get("width") and size.get("height"):
        return {"width": int(size["width"]), "height": int(size["height"])}
    return viewport or {"width": 1280, "height": 720}

def _record_har(self, record_har: dict | None) -> dict | None:
    if not record_har:
        return None
    if not record_har.get("path"):
        return {**record_har}
    return {**record_har, "path": str(Path(self.outputdir, record_har["path"]).resolve())}

2. One registration helper, shared by New Context, New Persistent Context and the adopted path. This is the shared helper from the inline comment:

def _register_context(
    self, context_id: str, trace_file: str, video_size: dict, video: dict | None = None
) -> str:
    self.add_context_and_keyword_call_stack_to_trace(trace_file, context_id)
    self.context_cache.add(context_id, video_size)
    return self._embed_video(video) if video else ""

3. New Context and New Persistent Context use the same helpers:

def _set_context_options(self, params, httpCredentials, storageState):
    params = convert_typed_dict(self.new_context.__annotations__, params)
    params["recordVideo"] = self._record_video(params.get("recordVideo"), params.get("viewport"))
    params["recordHar"] = self._record_har(params.get("recordHar"))
    ...  # reducedMotion, storageState, httpCredentials as today

# end of new_context (_new_context no longer adds the trace groups itself):
size = self._video_size(params.get("recordVideo"), params.get("viewport"))
self._register_context(response.id, trace_file, size)
return response.id

# end of new_persistent_context:
size = self._video_size(params.get("recordVideo"), params.get("viewport"))
video_path = self._register_context(
    response.id, trace_file, size, json.loads(response.video)
)

params goes to Node as JSON, so recordVideo and recordHar must not be sent as null when they are off. Drop them, or set them only when they have a value.

4. The adopted path on PlaywrightState, built from the same parts:

def adopted_context_options(
    self, tracing=None, recordVideo=None, recordHar=None, viewport=None
) -> AdoptedContextOptions:
    params = convert_typed_dict(  # case-insensitive keys, as in New Context
        self.new_context.__annotations__,
        {k: v for k, v in {"recordVideo": recordVideo, "recordHar": recordHar}.items() if v},
    )
    return {
        "tracing": self._resolve_trace_file(tracing) or None,
        "recordVideo": self._record_video(params.get("recordVideo"), viewport),
        "recordHar": self._record_har(params.get("recordHar")),
        "tracesDir": str(self.traces_temp / str(uuid4())),  # as in _set_browser_options
    }

def register_adopted_context(
    self, context_id: str, options: AdoptedContextOptions, video_path: str | None = None
) -> str:
    record_video = options["recordVideo"]
    video = {"video_path": video_path, "contextUuid": context_id} if record_video else None
    size = record_video["size"] if record_video else self._video_size(None, None)
    return self._register_context(context_id, options["tracing"] or "", size, video)

This also covers the inline comments about the viewport, the case-insensitive keys, tracesDir and the shared registration.

5. Browser keeps only the public entry points, because libraries subclass Browser and look for the API there:

# Browser/browser.py
def adopted_context_options(
    self,
    tracing: bool | Path | None = None,
    recordVideo: RecordVideo | None = None,
    recordHar: RecordHar | None = None,
    viewport: ViewportDimensions | None = None,
) -> AdoptedContextOptions:
    """Docstring for library authors, as now."""
    return self._playwright_state.adopted_context_options(
        tracing, recordVideo, recordHar, viewport
    )

def register_adopted_context(
    self, context_id: str, options: AdoptedContextOptions, video_path: str | None = None
) -> str:
    """Docstring for library authors, as now."""
    return self._playwright_state.register_adopted_context(context_id, options, video_path)

About the new viewport argument: New Context uses the viewport as the video size when recordVideo has no size. Browser does not know the viewport of a context it did not create, so the library has to pass it to adopted_context_options. It would be too late to read it from the contextOptions passed to adoptContext, because they are known only after the extension has launched the context with its video options. Please add viewport as an optional argument, and document it in the docstring.

What do you think?

@aaltat

aaltat commented Oct 15, 2026

Copy link
Copy Markdown
Member

Generally I agree on your idea, wrote few comments to fix.

New Context and New Persistent Context passed a relative recordHar path to
the Node process as given, so the HAR file ended up in Browser's own wrapper
folder. A relative path is now relative to the output directory, as a .zip
path of tracing is.
A library that hands over its own context with adoptContext had to reach into
Browser's internals to trace and record it the way New Context does.

- adopted_context_options returns tracing, recordVideo and recordHar by the
  rules of New Context, including ROBOT_FRAMEWORK_BROWSER_TRACING and Browser's
  trace and video folders.
- register_adopted_context registers the adopted context afterwards, so that
  the keyword calls become trace groups and the log embeds the video.
adopted_context_options returns a typed dictionary with tracing, recordVideo
and recordHar, None when off, so that a library can pass it on to the
extension function as keyword arguments. Its parameters take Browser's
RecordVideo and RecordHar, as New Context does.
adopted_context_options and register_adopted_context call PlaywrightState
now, where New Context and New Persistent Context resolve their recordVideo
and recordHar options and register their contexts with the same helpers.
Browser keeps only the two public methods.

- adopted_context_options takes the viewport of the adopted context, which
  gives the video its size when recordVideo has none, as in New Context. It
  also returns tracesDir, a new folder for Playwright's temporary trace
  files, as New Browser gives one to each browser it launches.
- Only None means off, so an empty recordVideo records with the defaults.
  New Context recordVideo={} got no dir before, and Playwright recorded into
  its temporary artifacts folder, which it deletes when the browser closes.
- New Context no longer changes the caller's recordVideo. Before Python
  3.14, dir became an absolute PosixPath in it; video.robot checked that per
  Python version and no longer needs ${PYTHON_314}.
- New Persistent Context registers the context right after creating it,
  where it added the trace groups before, so that the navigation to url
  stays inside the keyword's trace group.
AdoptingLibrary subclasses Browser, adopts a persistent context through
adopt.js with the options of adopted_context_options and registers it with
register_adopted_context. The test checks that the trace has the groups of
the keywords and that the video is written to Browser's video folder.
@d-biehl
d-biehl force-pushed the adopted-context-recording branch from b100dee to 0c42907 Compare October 15, 2026 21:55
@d-biehl

d-biehl commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review, Tatu. I followed your sketch: the logic is in PlaywrightState now, New Context and New Persistent Context resolve their options and register their contexts with the same helpers, and Browser keeps only adopted_context_options and register_adopted_context, which call it. Where I deviated from the sketch:

  • Only None means off. With if not record_video, an empty recordVideo would mean no video, but the Electron library documents record_video={} as recording with the defaults. This also fixes New Context recordVideo={}: it got no dir, so Playwright recorded into its temporary artifacts folder and deleted the video when the browser closed, while the log still linked to it.
  • New Context no longer changes the caller's recordVideo. Before Python 3.14, dir became an absolute PosixPath in the caller's dictionary. video.robot checked that per Python version; it now checks that the dictionary stays as given, and ${PYTHON_314} is gone.
  • New Persistent Context registers the context where it added the trace groups before, right after creating it, so that the navigation to url stays inside the keyword's group in the trace.
  • No case-insensitive conversion yet, see the thread: I'd like your opinion on showActions first.

Tests:

  • atest/test/05_JS_Tests/adopted_context_recording.robot: atest/library/AdoptingLibrary.py subclasses Browser, adopts a persistent context through adopt.js with the options of adopted_context_options and registers it. The test checks that the trace has the groups of the keyword that adopted the context and of Get Text, and that the video is written to Browser's video folder.
  • utest/test_adopted_context.py: the cases you listed, plus recordVideo={}, the viewport and tracesDir. The fake now records the gRPC calls, so the tests check the trace groups that are opened and the options that New Context and New Persistent Context send to Node.

I added Fixes #5152 to the description and rebased the branch on main. Your trim of the jsextension help in 9540783 replaced my commit for it, so I dropped mine.

@d-biehl

d-biehl commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

Some flaky tests, eh? :-)

The four red jobs fail in tests this PR doesn't touch: Wait For Elements State From Attached To Hidden Not Strict (Ubuntu, shard 3, in both jobs), Get Console Log Test and test_python_usage.py::test_open_page_get_text (both on Windows). Everything this PR changes is green. Could you re-run the failed jobs? I can't from my fork. If shard 3 stays red, I'll take a closer look: the new suite shifts the shards, so Wait For Http now runs next to that test.

@aaltat

aaltat commented Oct 16, 2026

Copy link
Copy Markdown
Member

Yes, we have flaky test and have been working to improve the situation, but clearly the work is not yet completed. I think your analysis is correct and all failures are old stuff and I did start rerun. Let’s see what the results says.

@aaltat

aaltat commented Oct 16, 2026

Copy link
Copy Markdown
Member

I wrote a ticket about the flaky test which fixes did not work yet #5343

@aaltat

aaltat commented Oct 16, 2026

Copy link
Copy Markdown
Member

Rerunning did help, I will look into this later, can’t promise today but at least in the train tomorrow.

@aaltat
aaltat merged commit c58bbd1 into MarketSquare:main Oct 16, 2026
84 of 90 checks passed
d-biehl added a commit to robotcodedev/robotframework-electron-vscode that referenced this pull request Oct 18, 2026
Browser merged the methods for adopted contexts in
MarketSquare/robotframework-browser#5326, so the fork branch is no
longer needed. Until a Browser release contains them, CI and the local
clone use Browser's main, which also has the maintainers' follow-ups.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

New Context recordHar with a relative path writes the HAR into the wrapper directory, also on the Robot Framework path

3 participants