From a49e7eae94803608b16cb0252432886cb638489d Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Tue, 20 Oct 2026 23:44:00 +0300 Subject: [PATCH 1/3] feat: register adopted contexts from what Node reports Node writes a report on the response stream of the extension call as soon as adoptContext resolves: the context id, its trace file, the options the extension passed as contextOptions and the video of its active page. call_js_keyword registers each report the way New Context registers its own contexts: keyword trace groups, cached video size, embedded video. Every extension call goes through call_js_keyword, so a plain jsextension keyword now gets this too, where before only a library built on Browser could. The video size comes from the reported recordVideo, then the viewport, then 1280x720, so a context adopted without its options still embeds its video. register_adopted_context is removed. It was never released; a library built on Browser no longer passes the options twice nor returns the video path. ADR 0010 records the decision, and Browser/CONTEXT.md names the adopted context. Co-Authored-By: Claude Opus 5.5 --- Browser/CONTEXT.md | 8 ++ Browser/browser.py | 33 ++--- Browser/keywords/playwright_state.py | 33 ++--- Browser/utils/data_types.py | 14 ++ atest/library/AdoptingLibrary.py | 7 +- atest/test/05_JS_Tests/adopt_context.robot | 10 +- ...d-contexts-registered-from-nodes-report.md | 37 +++++ .../__tests__/playwright-state.test.ts | 68 ++++++++- node/playwright-wrapper/playwright-state.ts | 28 ++-- node/playwright-wrapper/response-util.ts | 10 +- protobuf/playwright.proto | 1 + utest/test_adopted_context.py | 130 +++++++++++++++--- utest/test_js_extension_call.py | 2 +- 13 files changed, 298 insertions(+), 83 deletions(-) create mode 100644 docs/adr/0010-adopted-contexts-registered-from-nodes-report.md diff --git a/Browser/CONTEXT.md b/Browser/CONTEXT.md index 299aa4e4c..e156d1646 100644 --- a/Browser/CONTEXT.md +++ b/Browser/CONTEXT.md @@ -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`, @@ -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 diff --git a/Browser/browser.py b/Browser/browser.py index 7204ea691..12b7ba560 100755 --- a/Browser/browser.py +++ b/Browser/browser.py @@ -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 @@ -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: @@ -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: diff --git a/Browser/keywords/playwright_state.py b/Browser/keywords/playwright_state.py index 11f26d1b0..bcaccefd2 100755 --- a/Browser/keywords/playwright_state.py +++ b/Browser/keywords/playwright_state.py @@ -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 @@ -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): diff --git a/Browser/utils/data_types.py b/Browser/utils/data_types.py index ac643e6a9..9b3700a1f 100644 --- a/Browser/utils/data_types.py +++ b/Browser/utils/data_types.py @@ -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. diff --git a/atest/library/AdoptingLibrary.py b/atest/library/AdoptingLibrary.py index cfd6c9422..1bf6c7555 100644 --- a/atest/library/AdoptingLibrary.py +++ b/atest/library/AdoptingLibrary.py @@ -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 ) @@ -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: diff --git a/atest/test/05_JS_Tests/adopt_context.robot b/atest/test/05_JS_Tests/adopt_context.robot index 35a1a30da..7ec0ecca2 100644 --- a/atest/test/05_JS_Tests/adopt_context.robot +++ b/atest/test/05_JS_Tests/adopt_context.robot @@ -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 @@ -24,11 +27,14 @@ An Adopted Context Gets The Library Timeout 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* diff --git a/docs/adr/0010-adopted-contexts-registered-from-nodes-report.md b/docs/adr/0010-adopted-contexts-registered-from-nodes-report.md new file mode 100644 index 000000000..2630eb57f --- /dev/null +++ b/docs/adr/0010-adopted-contexts-registered-from-nodes-report.md @@ -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`. diff --git a/node/playwright-wrapper/__tests__/playwright-state.test.ts b/node/playwright-wrapper/__tests__/playwright-state.test.ts index 05dcf5e84..7ea8583db 100644 --- a/node/playwright-wrapper/__tests__/playwright-state.test.ts +++ b/node/playwright-wrapper/__tests__/playwright-state.test.ts @@ -502,7 +502,7 @@ describe('adoptContext', () => { const state = new PlaywrightState(); state.browserStack.push(makeBrowserState('browser=existing')); - const adopted = await state.adoptContext(makeMockContext([makeMockPage()])); + const { adopted } = await state.adoptContext(makeMockContext([makeMockPage()])); expect(state.browserStack).toHaveLength(2); expect(state.activeBrowser?.id).toBe(adopted.browserId); @@ -568,7 +568,7 @@ describe('adoptContext', () => { const first = makeMockPage(); const second = makeMockPage(); - const adopted = await state.adoptContext(makeMockContext([first, second])); + const { adopted } = await state.adoptContext(makeMockContext([first, second])); expect(state.activeBrowser?.context?.pageStack.map((page) => page.p)).toEqual([second, first]); expect(state.activeBrowser?.page?.p).toBe(first); @@ -580,7 +580,7 @@ describe('adoptContext', () => { const first = { on: jest.fn(), video: () => ({ path: async () => '/videos/first.webm' }) } as any; const second = { on: jest.fn(), video: () => ({ path: async () => '/videos/second.webm' }) } as any; - const adopted = await state.adoptContext(makeMockContext([first, second])); + const { adopted } = await state.adoptContext(makeMockContext([first, second])); expect(adopted.videoPath).toBe('/videos/first.webm'); }); @@ -589,7 +589,7 @@ describe('adoptContext', () => { const state = new PlaywrightState(); const page = { on: jest.fn(), video: () => null } as any; - const adopted = await state.adoptContext(makeMockContext([page])); + const { adopted } = await state.adoptContext(makeMockContext([page])); expect(adopted.videoPath).toBeNull(); }); @@ -597,7 +597,7 @@ describe('adoptContext', () => { it('returns no video path when the context has no pages', async () => { const state = new PlaywrightState(); - const adopted = await state.adoptContext(makeMockContext([])); + const { adopted } = await state.adoptContext(makeMockContext([])); expect(adopted.videoPath).toBeNull(); }); @@ -605,8 +605,8 @@ describe('adoptContext', () => { it('keeps an existing browser state with a null browser separate', async () => { const state = new PlaywrightState(); - const one = await state.adoptContext(makeMockContext([makeMockPage()])); - const two = await state.adoptContext(makeMockContext([makeMockPage()])); + const { adopted: one } = await state.adoptContext(makeMockContext([makeMockPage()])); + const { adopted: two } = await state.adoptContext(makeMockContext([makeMockPage()])); expect(state.browserStack).toHaveLength(2); expect(state.browserStack.map((browser) => browser.id)).toEqual([one.browserId, two.browserId]); @@ -710,6 +710,13 @@ describe('addBrowser', () => { }); }); +function reportsWrittenTo(call: { write: jest.Mock }): { id: string }[] { + return call.write.mock.calls + .map(([response]) => (response as { adoptedContext: string }).adoptedContext) + .filter(Boolean) + .map((report) => JSON.parse(report)); +} + describe('extensionKeywordCall', () => { beforeEach(() => { jest.clearAllMocks(); @@ -766,6 +773,53 @@ describe('extensionKeywordCall', () => { expect(context.setDefaultTimeout).toHaveBeenCalledWith(5000); }); + it('reports an adopted context on the stream with what Browser needs to register it', async () => { + const state = new PlaywrightState(); + const page = { on: jest.fn(), video: () => ({ path: async () => '/videos/app.webm' }) } as any; + const context = makeMockContext([page]); + const contextOptions = { recordVideo: { dir: '/videos' }, viewport: { width: 800, height: 600 } }; + async function openOwnContext(adoptContext: (c: unknown, options?: unknown) => Promise) { + return adoptContext(context, { tracing: '/traces/app.zip', contextOptions }); + } + state.extensions.push({ openOwnContext } as any); + const call = { write: jest.fn() } as any; + + await extensionKeywordCall( + { name: 'openOwnContext', arguments: JSON.stringify({ arguments: [['adoptContext', 'RESERVED']] }) }, + call, + state, + ); + + expect(reportsWrittenTo(call)).toEqual([ + { + id: state.activeBrowser?.context?.id, + traceFile: '/traces/app.zip', + contextOptions, + video: { video_path: '/videos/app.webm', contextUuid: state.activeBrowser?.context?.id }, + }, + ]); + }); + + it('reports an adopted context even if the extension function fails after adopting it', async () => { + expect.assertions(2); + const state = new PlaywrightState(); + async function openOwnContext(adoptContext: (c: unknown) => Promise) { + await adoptContext(makeMockContext([makeMockPage()])); + throw new Error('window did not open'); + } + state.extensions.push({ openOwnContext } as any); + const call = { write: jest.fn() } as any; + + await expect( + extensionKeywordCall( + { name: 'openOwnContext', arguments: JSON.stringify({ arguments: [['adoptContext', 'RESERVED']] }) }, + call, + state, + ), + ).rejects.toThrow('window did not open'); + expect(reportsWrittenTo(call).map((report) => report.id)).toEqual([state.activeBrowser?.context?.id]); + }); + it('passes null as the browser of an adopted context', async () => { const state = new PlaywrightState(); const context = makeMockContext([makeMockPage()]); diff --git a/node/playwright-wrapper/playwright-state.ts b/node/playwright-wrapper/playwright-state.ts index e2d7aae57..0d12ae0ff 100644 --- a/node/playwright-wrapper/playwright-state.ts +++ b/node/playwright-wrapper/playwright-state.ts @@ -74,6 +74,7 @@ import { HighlightDisposableCache } from './highlight-cache'; import { trackRequests, withLoadReport } from './load-report'; import { exists } from './playwright-invoke'; import { + adoptedContextResponse, emptyWithLog, getConsoleLogResponse, getErrorMessagesResponse, @@ -173,9 +174,15 @@ export async function extensionKeywordCall( apiArguments.set('context', state.getActiveContext()); apiArguments.set('logger', (msg: string) => call.write(jsonResponse('', msg))); apiArguments.set('playwright', playwright); - apiArguments.set('adoptContext', (context: BrowserContext, options?: AdoptContextOptions) => - state.adoptContext(context, options, namedArguments.defaultTimeout as number | undefined), - ); + apiArguments.set('adoptContext', async (context: BrowserContext, options?: AdoptContextOptions) => { + const { adopted, report } = await state.adoptContext( + context, + options, + namedArguments.defaultTimeout as number | undefined, + ); + call.write(adoptedContextResponse(report, `Adopted ${adopted.contextId}`)); + return adopted; + }); // Injected values can be null, for example the browser of an adopted context. const functionArguments = argNames.map((argName) => apiArguments.has(argName) ? apiArguments.get(argName) : namedArguments[argName], @@ -556,7 +563,7 @@ export class PlaywrightState { context: BrowserContext, options: AdoptContextOptions = {}, defaultTimeout?: number, - ): Promise { + ): Promise<{ adopted: AdoptedContext; report: string }> { const browserState = new BrowserState({ browser: null, name: options.name ?? 'adopted', @@ -572,11 +579,16 @@ export class PlaywrightState { browserState.pushContext(indexedContext); this.browserStack.push(browserState); const page = browserState.page; + const videoPath = (await page?.p.video()?.path()) ?? null; + const report = JSON.stringify({ + id: indexedContext.id, + traceFile: indexedContext.traceFile, + contextOptions: indexedContext.options ?? {}, + video: { video_path: videoPath, contextUuid: indexedContext.id }, + }); return { - browserId: browserState.id, - contextId: indexedContext.id, - pageId: page?.id, - videoPath: (await page?.p.video()?.path()) ?? null, + adopted: { browserId: browserState.id, contextId: indexedContext.id, pageId: page?.id, videoPath }, + report, }; } diff --git a/node/playwright-wrapper/response-util.ts b/node/playwright-wrapper/response-util.ts index 91328bac4..9293ea24a 100644 --- a/node/playwright-wrapper/response-util.ts +++ b/node/playwright-wrapper/response-util.ts @@ -56,14 +56,14 @@ export function getConsoleLogResponse(page: IndexedPage, fullLog: boolean, messa const consoleMessages = page.consoleMessages; const responseMessages = fullLog ? consoleMessages : consoleMessages.slice(page.consoleIndex); page.consoleIndex = consoleMessages.length; - return { log: message, json: JSON.stringify(responseMessages), bodyPart: '' }; + return { log: message, json: JSON.stringify(responseMessages), bodyPart: '', adoptedContext: '' }; } export function getErrorMessagesResponse(page: IndexedPage, fullLog: boolean, message: string): Response_Json { const pageErrors = page.pageErrors; const responseErrors = fullLog ? pageErrors : pageErrors.slice(page.errorIndex); page.errorIndex = pageErrors.length; - return { log: message, json: JSON.stringify(responseErrors), bodyPart: '' }; + return { log: message, json: JSON.stringify(responseErrors), bodyPart: '', adoptedContext: '' }; } function definedString(body: string): string { @@ -79,7 +79,11 @@ export function listStringResponse(body: string[], logMessage: string): Response } export function jsonResponse(body: string, logMessage: string, bodyPart: string = ''): Response_Json { - return { json: definedString(body), log: logMessage, bodyPart }; + return { json: definedString(body), log: logMessage, bodyPart, adoptedContext: '' }; +} + +export function adoptedContextResponse(adoptedContext: string, logMessage: string): Response_Json { + return { json: '', log: logMessage, bodyPart: '', adoptedContext }; } export function intResponse(body: number, logMessage: string): Response_Int { diff --git a/protobuf/playwright.proto b/protobuf/playwright.proto index 270c10eb7..a41633210 100644 --- a/protobuf/playwright.proto +++ b/protobuf/playwright.proto @@ -467,6 +467,7 @@ message Response { string log = 1; string json = 2; string bodyPart = 3; + string adoptedContext = 4; } message JavascriptExecutionResult { diff --git a/utest/test_adopted_context.py b/utest/test_adopted_context.py index e9d313460..a36835dd4 100644 --- a/utest/test_adopted_context.py +++ b/utest/test_adopted_context.py @@ -165,21 +165,42 @@ def test_video_without_size_has_the_size_of_the_viewport(browser: Browser): assert options["recordVideo"]["size"] == {"width": 1024, "height": 768} -def test_registered_context_gets_the_keyword_calls_as_trace_groups(browser: Browser): +def adopt(browser: Browser, *reports: dict): + browser._playwright.stub.CallExtensionKeyword.return_value = [ + SimpleNamespace(log="", bodyPart="", adoptedContext=json.dumps(report)) + for report in reports + ] + browser.call_js_keyword("openApplication") + + +def report( + context_id: str = "context=1", + trace_file: str = "", + context_options: dict | None = None, + video_path: str | None = None, +) -> dict: + return { + "id": context_id, + "traceFile": trace_file, + "contextOptions": context_options or {}, + "video": {"video_path": video_path, "contextUuid": context_id}, + } + + +def test_adopted_context_gets_the_keyword_calls_as_trace_groups(browser: Browser): browser.keyword_call_stack.append(keyword_call("Open Application")) - options = browser.adopted_context_options(tracing=True) - browser.register_adopted_context("context=1", options) + adopt(browser, report(trace_file="/traces/trace_context=1.zip")) group = browser._playwright.stub.OpenTraceGroup.call_args.args[0] assert (group.name, group.contextId) == ("Open Application", "context=1") assert browser.tracing_contexts == ["context=1"] -def test_registered_context_without_tracing_gets_no_trace_groups(browser: Browser): +def test_adopted_context_without_tracing_gets_no_trace_groups(browser: Browser): browser.keyword_call_stack.append(keyword_call("Open Application")) - browser.register_adopted_context("context=1", browser.adopted_context_options()) + adopt(browser, report()) assert not browser._playwright.stub.OpenTraceGroup.called assert browser.tracing_contexts == [] @@ -188,46 +209,79 @@ def test_registered_context_without_tracing_gets_no_trace_groups(browser: Browse def test_playwright_tracing_group_mode_adds_no_trace_groups(browser: Browser): browser.tracing_group_mode = TracingGroupMode.Playwright browser.keyword_call_stack.append(keyword_call("Open Application")) - options = browser.adopted_context_options(tracing=True) - browser.register_adopted_context("context=1", options) + adopt(browser, report(trace_file="/traces/trace_context=1.zip")) assert not browser._playwright.stub.OpenTraceGroup.called assert browser.tracing_contexts == [] -def test_registered_context_outside_a_keyword_is_traced_without_trace_groups( +def test_adopted_context_outside_a_keyword_is_traced_without_trace_groups( browser: Browser, ): browser.tracing_group_mode = TracingGroupMode.Browser - options = browser.adopted_context_options(tracing=True) - browser.register_adopted_context("context=1", options) + adopt(browser, report(trace_file="/traces/trace_context=1.zip")) assert not browser._playwright.stub.OpenTraceGroup.called assert browser.tracing_contexts == ["context=1"] -def test_registered_context_embeds_its_video(browser: Browser, caplog): +def test_every_context_adopted_in_one_call_is_registered(browser: Browser): + adopt( + browser, + report("context=1", trace_file="/traces/one.zip"), + report("context=2", trace_file="/traces/two.zip"), + ) + + assert browser.tracing_contexts == ["context=1", "context=2"] + + +def test_context_adopted_before_the_extension_function_fails_is_registered( + browser: Browser, +): + def stream(): + yield SimpleNamespace( + log="", + bodyPart="", + adoptedContext=json.dumps(report(trace_file="/traces/one.zip")), + ) + raise RuntimeError("window did not open") + + browser._playwright.stub.CallExtensionKeyword.return_value = stream() + + with pytest.raises(RuntimeError, match="window did not open"): + browser.call_js_keyword("openApplication") + assert browser.tracing_contexts == ["context=1"] + + +def test_adopted_context_embeds_its_video(browser: Browser, caplog): caplog.set_level("INFO") options = browser.adopted_context_options(recordVideo={}, viewport=VIEWPORT) video = str(Path(options["recordVideo"]["dir"], "page.webm")) - video_path = browser.register_adopted_context("context=1", options, video) + adopt( + browser, + report( + context_options={"recordVideo": options["recordVideo"]}, video_path=video + ), + ) - assert video_path == video assert ( '' ) in caplog.text -def test_video_path_without_record_video_is_ignored(browser: Browser): - options = browser.adopted_context_options() +def test_video_of_a_context_adopted_without_its_options_has_the_fallback_size( + browser: Browser, caplog +): + caplog.set_level("INFO") + video = str(Path(browser.video_output, "page.webm")) - video_path = browser.register_adopted_context("context=1", options, "page.webm") + adopt(browser, report(video_path=video)) - assert video_path == "" + assert '