From 3d4c8e5cb7689979eecd7efb8272324804691d85 Mon Sep 17 00:00:00 2001 From: Daniel Biehl Date: Thu, 8 Oct 2026 12:58:17 +0200 Subject: [PATCH 1/9] feat: let JavaScript extensions adopt a context they created Extension functions can request adoptContext(context, onClose) to register a BrowserContext they created, for example with playwright._electron.launch or a persistent context, as a new active browser. onClose runs once after the context is closed. The page indexing is shared with newPersistentContext. --- Browser/browser.py | 39 ++++--- atest/test/05_JS_Tests/adopt.js | 26 +++++ atest/test/05_JS_Tests/adopt_context.robot | 16 +++ .../__tests__/playwright-state.test.ts | 100 ++++++++++++++++++ node/playwright-wrapper/playwright-state.ts | 76 ++++++++++--- 5 files changed, 230 insertions(+), 27 deletions(-) create mode 100644 atest/test/05_JS_Tests/adopt.js create mode 100644 atest/test/05_JS_Tests/adopt_context.robot diff --git a/Browser/browser.py b/Browser/browser.py index 07d2f41d8..e2870e03d 100755 --- a/Browser/browser.py +++ b/Browser/browser.py @@ -444,6 +444,15 @@ class Browser(DynamicCore): _context_cache = ContextCache() _suite_cleanup_done = False _output_dir = "." + # Parameter names the Node side fills in for JavaScript extension functions. + _js_injected_arguments = ( + "logger", + "playwright", + "page", + "context", + "browser", + "adoptContext", + ) def __init__( # noqa: PLR0915 self, @@ -501,10 +510,18 @@ def __init__( # noqa: PLR0915 - ``jsextension``: Path to JavaScript modules exposed as extra keywords. The modules must be in CommonJS format; exported functions become keywords and an ``fn.rfdoc`` string becomes a keyword's documentation. The - argument names ``page``, ``context``, ``browser``, ``logger`` and - ``playwright`` are filled in by the library rather than taken from the - keyword call. Can be a single path, a comma-separated list of paths or a - real list of strings. See + argument names ``page``, ``context``, ``browser``, ``logger``, + ``playwright`` and ``adoptContext`` are filled in by the library rather + than taken from the keyword call. ``adoptContext(context, onClose)`` + registers a BrowserContext that the function created itself, for + example with ``playwright.chromium.launchPersistentContext`` or + ``playwright._electron.launch``, as a new active browser and makes its + first page the active page, so the other keywords work on it. It + returns the new browser, context and page ids. Closing that browser + closes the context and then calls the optional async ``onClose`` once, + so the function can release what it owns, such as an app or a device + connection. Can be a single path, a + comma-separated list of paths or a real list of strings. See https://robotframework-browser.org/docs/extending/javascript-extensions - ``language``: Defines language which is used to translate keyword names and documentation. @@ -802,7 +819,7 @@ def _jskeyword_call( arg_set_texts = [] for item in argument_names_and_vals: arg_name = item[0] - if arg_name in ["logger", "playwright", "page", "context", "browser"]: + if arg_name in self._js_injected_arguments: arg_set_texts.append(f'("{arg_name}", "RESERVED")') else: arg_set_texts.append(f'("{arg_name}", {arg_name})') @@ -852,16 +869,12 @@ def {name}(self, {", ".join(argument_names_and_default_values_texts)}): raise DataError(f"{e.msg} in {name}") def call_js_keyword(self, keyword_name: str, **args) -> Any: - reserved = { - "logger": "RESERVED", - "playwright": "RESERVED", - "page": "RESERVED", - "context": "RESERVED", - "browser": "RESERVED", - } _args_browser_internal = { "arguments": [ - (arg_name, reserved.get(arg_name, value)) + ( + arg_name, + "RESERVED" if arg_name in self._js_injected_arguments else value, + ) for arg_name, value in args.items() ] } diff --git a/atest/test/05_JS_Tests/adopt.js b/atest/test/05_JS_Tests/adopt.js new file mode 100644 index 000000000..295494f79 --- /dev/null +++ b/atest/test/05_JS_Tests/adopt.js @@ -0,0 +1,26 @@ +let adoptedContextClosed = false; +let onCloseCalled = false; + +async function openAdoptedPersistentContext(userDataDir, url, headless, playwright, adoptContext) { + const context = await playwright.chromium.launchPersistentContext(userDataDir, { + headless: String(headless).toLowerCase() !== 'false', + }); + context.on('close', () => (adoptedContextClosed = true)); + await context.pages()[0].goto(url); + return adoptContext(context, async () => { + onCloseCalled = true; + }); +} + +async function adoptedContextIsClosed() { + return adoptedContextClosed; +} + +async function adoptedContextOnCloseWasCalled() { + return onCloseCalled; +} + +exports.__esModule = true; +exports.openAdoptedPersistentContext = openAdoptedPersistentContext; +exports.adoptedContextIsClosed = adoptedContextIsClosed; +exports.adoptedContextOnCloseWasCalled = adoptedContextOnCloseWasCalled; diff --git a/atest/test/05_JS_Tests/adopt_context.robot b/atest/test/05_JS_Tests/adopt_context.robot new file mode 100644 index 000000000..b23af3900 --- /dev/null +++ b/atest/test/05_JS_Tests/adopt_context.robot @@ -0,0 +1,16 @@ +*** Settings *** +Library Browser jsextension=${CURDIR}/adopt.js +Resource imports.resource + +Force Tags no-iframe + +*** Test Cases *** +Browser Keywords Work On A Context Adopted By A JavaScript Extension + ${adopted} = Open Adopted Persistent Context ${OUTPUT_DIR}/adopted-profile ${LOGIN_URL} ${HEADLESS} + Get Browser Ids ACTIVE contains ${adopted}[browserId] + Get Text h1 == Login Page + Close Browser + ${closed} = Adopted Context Is Closed + Should Be True ${closed} + ${on_close_called} = Adopted Context On Close Was Called + Should Be True ${on_close_called} diff --git a/node/playwright-wrapper/__tests__/playwright-state.test.ts b/node/playwright-wrapper/__tests__/playwright-state.test.ts index 324f9333f..a705df251 100644 --- a/node/playwright-wrapper/__tests__/playwright-state.test.ts +++ b/node/playwright-wrapper/__tests__/playwright-state.test.ts @@ -16,6 +16,7 @@ import { closeAllBrowsers, closeBrowser, closeBrowserServer, + extensionKeywordCall, locatorCache, newPage, PlaywrightState, @@ -480,3 +481,102 @@ describe('removeFailedPage', () => { expect(browser.page?.id).toBe('page=open'); }); }); + +function makeMockPage() { + return { on: jest.fn() } as any; +} + +function makeMockContext(pages: any[], close = jest.fn().mockResolvedValue(undefined)) { + return { pages: () => pages, on: jest.fn(), close } as any; +} + +describe('adoptContext', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('adds the context as a new active browser without a browser object', async () => { + const state = new PlaywrightState(); + state.browserStack.push(makeBrowserState('browser=existing')); + + const adopted = await state.adoptContext(makeMockContext([makeMockPage()])); + + expect(state.browserStack).toHaveLength(2); + expect(state.activeBrowser?.id).toBe(adopted.browserId); + expect(state.activeBrowser?.browser).toBeNull(); + expect(state.activeBrowser?.context?.id).toBe(adopted.contextId); + }); + + it('registers every existing page and makes the first one active', async () => { + const state = new PlaywrightState(); + const first = makeMockPage(); + const second = makeMockPage(); + + 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); + expect(adopted.pageId).toBe(state.activeBrowser?.page?.id); + }); + + 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()])); + + expect(state.browserStack).toHaveLength(2); + expect(state.browserStack.map((browser) => browser.id)).toEqual([one.browserId, two.browserId]); + }); + + it('runs onClose once after the adopted context is closed', async () => { + const state = new PlaywrightState(); + const context = makeMockContext([makeMockPage()]); + const onClose = jest.fn().mockResolvedValue(undefined); + await state.adoptContext(context, onClose); + const adopted = state.getActiveBrowser(); + + await closeBrowser(state); + await adopted.close(); + + expect(context.close).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('runs onClose even if closing the context fails', async () => { + expect.assertions(2); + const state = new PlaywrightState(); + const context = makeMockContext([makeMockPage()], jest.fn().mockRejectedValue(new Error('gone'))); + const onClose = jest.fn().mockResolvedValue(undefined); + await state.adoptContext(context, onClose); + + await expect(state.getActiveBrowser().close()).rejects.toThrow('gone'); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); + +describe('extensionKeywordCall', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('lets an extension function adopt a context it created', async () => { + const state = new PlaywrightState(); + const context = makeMockContext([makeMockPage()]); + async function openOwnContext(adoptContext: (c: unknown) => Promise) { + return adoptContext(context); + } + state.extensions.push({ openOwnContext } as any); + const call = { write: jest.fn() } as any; + + const responses = await extensionKeywordCall( + { name: 'openOwnContext', arguments: JSON.stringify({ arguments: [] }) }, + call, + state, + ); + + const adopted = JSON.parse(responses.map((response) => response.bodyPart).join('')); + expect(adopted.browserId).toBe(state.activeBrowser?.id); + expect(state.activeBrowser?.context?.c).toBe(context); + }); +}); diff --git a/node/playwright-wrapper/playwright-state.ts b/node/playwright-wrapper/playwright-state.ts index 78c4b253b..e08a2b00b 100644 --- a/node/playwright-wrapper/playwright-state.ts +++ b/node/playwright-wrapper/playwright-state.ts @@ -175,6 +175,9 @@ 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, onClose?: () => Promise) => + state.adoptContext(context, onClose), + ); const functionArguments = argNames.map((argName) => apiArguments.get(argName) || namedArguments[argName]); const result = await keyword(...functionArguments); if (result === undefined) { @@ -301,6 +304,25 @@ async function _createIndexedContext( return indexedContext; } +/* + * Indexes a context and every page it already has. The context's first page + * ends up last in the page stack, so it becomes the active page. + */ +async function _indexContextWithPages( + context: BrowserContext, + defaultTimeout: number | undefined, + traceFile: string, + options?: Record, +): Promise { + const indexedContext = await _createIndexedContext(context, defaultTimeout, traceFile, options); + for (const page of context.pages()) { + if (!indexedContext.pageStack.some((indexed) => indexed.p === page)) { + indexedContext.pageStack.unshift(await _newPage(indexedContext, page)); + } + } + return indexedContext; +} + function indexedPage(newPage: Page): IndexedPage { trackRequests(newPage); const timestamp = new Date().getTime() / 1000; @@ -515,6 +537,22 @@ export class PlaywrightState { return browserState; } + /* + * Adds a context that was created outside the library, for example by a + * JavaScript extension, as a new active browser without a browser object. + * Its first page becomes the active page. Closing the context closes the + * browser, as with a persistent context. onClose runs once after that, so + * the creator can release what it owns, such as an app or a device. + */ + public async adoptContext(context: BrowserContext, onClose?: () => Promise): Promise { + const browserState = new BrowserState({ browser: null, browserType: 'chromium', headless: false }); + browserState.onClose = onClose; + const indexedContext = await _indexContextWithPages(context, undefined, ''); + browserState.pushContext(indexedContext); + this.browserStack.push(browserState); + return { browserId: browserState.id, contextId: indexedContext.id, pageId: browserState.page?.id }; + } + public addBrowserServer = (browserServer: BrowserServer): void => { this.browserServer.push(browserServer); }; @@ -630,6 +668,12 @@ export type IndexedPage = { type Uuid = string; +type AdoptedContext = { + browserId: Uuid; + contextId: Uuid; + pageId: Uuid | undefined; +}; + type FailedPage = { context: IndexedContext; page: IndexedPage; @@ -652,20 +696,26 @@ export class BrowserState { name?: string; id: Uuid; headless: boolean; + onClose?: () => Promise; public async close(): Promise { - for (const context of this.contextStack) { - const traceFile = context.traceFile; - if (traceFile) { - await context.c.tracing.stop({ path: traceFile }); + try { + for (const context of this.contextStack) { + const traceFile = context.traceFile; + if (traceFile) { + await context.c.tracing.stop({ path: traceFile }); + } + await context.c.close(); } - await context.c.close(); - } - this._contextStack = []; - if (this.browser === null) { - return; + this._contextStack = []; + if (this.browser !== null) { + await this.browser.close(); + } + } finally { + const onClose = this.onClose; + this.onClose = undefined; + await onClose?.(); } - await this.browser.close(); } public async getOrCreateActiveContext(defaultTimeout: number | undefined): Promise { @@ -974,12 +1024,10 @@ export async function newPersistentContext( throw new Error('A new browser was created in error while trying to create a persistent context'); } - const indexedContext = await _createIndexedContext(context, timeout, traceFile, options); - const page = indexedContext.c.pages()[0]; - indexedContext.pageStack.unshift(await _newPage(indexedContext, page)); + const indexedContext = await _indexContextWithPages(context, timeout, traceFile, options); const baseResponse = await _finishContextResponse(indexedContext, browserState, options); const currentBrowser = openBrowsers.activeBrowser; - const currentPage = indexedContext.pageStack[0]; + const currentPage = indexedContext.pageStack[indexedContext.pageStack.length - 1]; const videoPath = await currentPage.p.video()?.path(); const video = { video_path: videoPath || null, contextUuid: indexedContext.id }; return { From e77ffd2a507dcc419bd215f3669abd70b39e0b0e Mon Sep 17 00:00:00 2001 From: Daniel Biehl Date: Sat, 10 Oct 2026 00:26:31 +0200 Subject: [PATCH 2/9] fix: wait for the browser of a closed persistent or adopted context closeContext closed the browser of a persistent or adopted context without awaiting it, so an async onClose could still run after the keyword returned, and a failing context.close() skipped onClose. It now awaits closing the browser in a finally block. --- .../__tests__/playwright-state.test.ts | 27 +++++++++++++++++++ node/playwright-wrapper/playwright-state.ts | 15 +++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/node/playwright-wrapper/__tests__/playwright-state.test.ts b/node/playwright-wrapper/__tests__/playwright-state.test.ts index a705df251..3a5088c03 100644 --- a/node/playwright-wrapper/__tests__/playwright-state.test.ts +++ b/node/playwright-wrapper/__tests__/playwright-state.test.ts @@ -16,6 +16,7 @@ import { closeAllBrowsers, closeBrowser, closeBrowserServer, + closeContext, extensionKeywordCall, locatorCache, newPage, @@ -553,6 +554,32 @@ describe('adoptContext', () => { await expect(state.getActiveBrowser().close()).rejects.toThrow('gone'); expect(onClose).toHaveBeenCalledTimes(1); }); + + it('closes the browser and waits for onClose when its context is closed', async () => { + const state = new PlaywrightState(); + let released = false; + await state.adoptContext(makeMockContext([makeMockPage()]), async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + released = true; + }); + + await closeContext({ value: false }, state); + + expect(released).toBe(true); + expect(state.browserStack).toHaveLength(0); + }); + + it('closes the browser and runs onClose even if closing its context fails', async () => { + expect.assertions(3); + const state = new PlaywrightState(); + const context = makeMockContext([makeMockPage()], jest.fn().mockRejectedValue(new Error('gone'))); + const onClose = jest.fn().mockResolvedValue(undefined); + await state.adoptContext(context, onClose); + + await expect(closeContext({ value: false }, state)).rejects.toThrow('gone'); + expect(onClose).toHaveBeenCalledTimes(1); + expect(state.browserStack).toHaveLength(0); + }); }); describe('extensionKeywordCall', () => { diff --git a/node/playwright-wrapper/playwright-state.ts b/node/playwright-wrapper/playwright-state.ts index e08a2b00b..64e14ba0f 100644 --- a/node/playwright-wrapper/playwright-state.ts +++ b/node/playwright-wrapper/playwright-state.ts @@ -834,11 +834,16 @@ export async function closeContext(request: Request_Bool, openBrowsers: Playwrig for (const page of activeBrowser.context?.pageStack || []) { await _saveCoverageReport(page); } - await openBrowsers.getActiveContext()?.close(); - activeBrowser.popContext(); - // Closing Persistent Context if Context is closed. - if (activeBrowser.contextStack.length === 0 && activeBrowser.browser === null) { - void closeBrowser(openBrowsers); + // A persistent or adopted context is the only context of a browser without a browser object. + // Closing it closes that browser too, which runs its onClose, even if closing the context fails. + const closesBrowser = activeBrowser.browser === null && activeBrowser.contextStack.length === 1; + try { + await openBrowsers.getActiveContext()?.close(); + activeBrowser.popContext(); + } finally { + if (closesBrowser) { + await closeBrowser(openBrowsers); + } } return emptyWithLog('Successfully closed Context'); } From 8a5c4ecbc1c4bc6b052d7ee30262ab76c28d13c7 Mon Sep 17 00:00:00 2001 From: Daniel Biehl Date: Sat, 10 Oct 2026 00:26:31 +0200 Subject: [PATCH 3/9] docs: adoptContext takes app.context() of an Electron app --- Browser/browser.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Browser/browser.py b/Browser/browser.py index e2870e03d..fcebecd31 100755 --- a/Browser/browser.py +++ b/Browser/browser.py @@ -514,12 +514,13 @@ def __init__( # noqa: PLR0915 ``playwright`` and ``adoptContext`` are filled in by the library rather than taken from the keyword call. ``adoptContext(context, onClose)`` registers a BrowserContext that the function created itself, for - example with ``playwright.chromium.launchPersistentContext`` or - ``playwright._electron.launch``, as a new active browser and makes its - first page the active page, so the other keywords work on it. It - returns the new browser, context and page ids. Closing that browser - closes the context and then calls the optional async ``onClose`` once, - so the function can release what it owns, such as an app or a device + example with ``playwright.chromium.launchPersistentContext`` or as + ``app.context()`` of an app from ``playwright._electron.launch``, as a + new active browser and makes its first page the active page, so the + other keywords work on it. It returns the new browser and context ids, + and the page id if the context has a page. Closing that browser closes + the context and then calls the optional async ``onClose`` once, so the + function can release what the context does not own, such as a device connection. Can be a single path, a comma-separated list of paths or a real list of strings. See https://robotframework-browser.org/docs/extending/javascript-extensions From 1d0ffbbeb17380f24bd2c5f3b920cbbb7002fc1d Mon Sep 17 00:00:00 2001 From: Daniel Biehl Date: Sat, 10 Oct 2026 12:24:46 +0200 Subject: [PATCH 4/9] feat: adoptContext takes options and sets the library timeout adoptContext(context, options) takes the optional name, headless and onClose. The name is the browser's type in Get Browser Catalog, 'adopted' by default, and headless is false by default. BrowserState gets its own input type, so launched and connected browsers keep their browser type as name. An adopted context gets the library timeout as its default timeout, like the contexts the library creates. call_js_keyword sends the timeout along with the arguments. --- Browser/browser.py | 19 ++++-- Browser/keywords/playwright_state.py | 5 +- atest/test/05_JS_Tests/adopt.js | 6 +- atest/test/05_JS_Tests/adopt_context.robot | 1 + .../__tests__/playwright-state.test.ts | 48 ++++++++++---- node/playwright-wrapper/playwright-state.ts | 64 ++++++++++++++----- 6 files changed, 104 insertions(+), 39 deletions(-) diff --git a/Browser/browser.py b/Browser/browser.py index fcebecd31..3bd60efe9 100755 --- a/Browser/browser.py +++ b/Browser/browser.py @@ -512,16 +512,20 @@ def __init__( # noqa: PLR0915 and an ``fn.rfdoc`` string becomes a keyword's documentation. The argument names ``page``, ``context``, ``browser``, ``logger``, ``playwright`` and ``adoptContext`` are filled in by the library rather - than taken from the keyword call. ``adoptContext(context, onClose)`` + than taken from the keyword call. ``adoptContext(context, options)`` registers a BrowserContext that the function created itself, for example with ``playwright.chromium.launchPersistentContext`` or as ``app.context()`` of an app from ``playwright._electron.launch``, as a new active browser and makes its first page the active page, so the - other keywords work on it. It returns the new browser and context ids, - and the page id if the context has a page. Closing that browser closes - the context and then calls the optional async ``onClose`` once, so the - function can release what the context does not own, such as a device - connection. Can be a single path, a + other keywords work on it. Like the contexts the library creates, it + gets the library timeout as its default timeout. It returns the new + browser and context ids, and the page id if the context has a page. + Closing that browser closes the context. The optional ``options`` are + ``name``, which `Get Browser Catalog` reports as the browser's type, + ``adopted`` by default; ``headless``, ``false`` by default; and + ``onClose``, an async function that runs once after the context is + closed, so the function can release what the context does not own, + such as a device connection. Can be a single path, a comma-separated list of paths or a real list of strings. See https://robotframework-browser.org/docs/extending/javascript-extensions - ``language``: Defines language which is used to translate keyword names and @@ -877,7 +881,8 @@ def call_js_keyword(self, keyword_name: str, **args) -> Any: "RESERVED" if arg_name in self._js_injected_arguments else value, ) for arg_name, value in args.items() - ] + ], + "defaultTimeout": int(self.timeout), } with self.playwright.grpc_channel() as stub: responses = stub.CallExtensionKeyword( diff --git a/Browser/keywords/playwright_state.py b/Browser/keywords/playwright_state.py index aa9ede195..960897f7a 100755 --- a/Browser/keywords/playwright_state.py +++ b/Browser/keywords/playwright_state.py @@ -1247,7 +1247,10 @@ def get_browser_catalog( The data is parsed into a python list containing data representing the open Objects. - On the root level the data contains a list of open browsers. + On the root level the data contains a list of open browsers. The ``type`` of a + browser is ``chromium``, ``firefox`` or ``webkit``. For a context that a JavaScript + extension adopted, it is the name the extension passed, ``adopted`` by default; + see ``jsextension`` in `Importing`. Data can be manipulated also with ``assertion_operator`` for example to find a specific id based on index or page title with ``then`` operator. diff --git a/atest/test/05_JS_Tests/adopt.js b/atest/test/05_JS_Tests/adopt.js index 295494f79..6807a5aaf 100644 --- a/atest/test/05_JS_Tests/adopt.js +++ b/atest/test/05_JS_Tests/adopt.js @@ -7,8 +7,10 @@ async function openAdoptedPersistentContext(userDataDir, url, headless, playwrig }); context.on('close', () => (adoptedContextClosed = true)); await context.pages()[0].goto(url); - return adoptContext(context, async () => { - onCloseCalled = true; + return adoptContext(context, { + onClose: async () => { + onCloseCalled = true; + }, }); } diff --git a/atest/test/05_JS_Tests/adopt_context.robot b/atest/test/05_JS_Tests/adopt_context.robot index b23af3900..dba45093d 100644 --- a/atest/test/05_JS_Tests/adopt_context.robot +++ b/atest/test/05_JS_Tests/adopt_context.robot @@ -8,6 +8,7 @@ Force Tags no-iframe Browser Keywords Work On A Context Adopted By A JavaScript Extension ${adopted} = Open Adopted Persistent Context ${OUTPUT_DIR}/adopted-profile ${LOGIN_URL} ${HEADLESS} Get Browser Ids ACTIVE contains ${adopted}[browserId] + Get Browser Catalog validate [b["type"] for b in value if b["id"] == "${adopted}[browserId]"] == ["adopted"] Get Text h1 == Login Page Close Browser ${closed} = Adopted Context Is Closed diff --git a/node/playwright-wrapper/__tests__/playwright-state.test.ts b/node/playwright-wrapper/__tests__/playwright-state.test.ts index 3a5088c03..e1ac755bb 100644 --- a/node/playwright-wrapper/__tests__/playwright-state.test.ts +++ b/node/playwright-wrapper/__tests__/playwright-state.test.ts @@ -27,7 +27,7 @@ import { function makeBrowserState(id: string): BrowserState { const state = new BrowserState({ browser: null, - browserType: 'chromium', + name: 'chromium', headless: true, }); state.id = id; @@ -256,7 +256,7 @@ describe('BrowserState', () => { } as unknown as Browser; const browser = new BrowserState({ browser: browserMock, - browserType: 'chromium', + name: 'chromium', headless: true, }); browser.pushContext({ @@ -488,7 +488,7 @@ function makeMockPage() { } function makeMockContext(pages: any[], close = jest.fn().mockResolvedValue(undefined)) { - return { pages: () => pages, on: jest.fn(), close } as any; + return { pages: () => pages, on: jest.fn(), close, setDefaultTimeout: jest.fn() } as any; } describe('adoptContext', () => { @@ -506,6 +506,26 @@ describe('adoptContext', () => { expect(state.activeBrowser?.id).toBe(adopted.browserId); expect(state.activeBrowser?.browser).toBeNull(); expect(state.activeBrowser?.context?.id).toBe(adopted.contextId); + expect(state.activeBrowser?.name).toBe('adopted'); + expect(state.activeBrowser?.headless).toBe(false); + }); + + it('takes the name and headless that the creator passes', async () => { + const state = new PlaywrightState(); + + await state.adoptContext(makeMockContext([makeMockPage()]), { name: 'electron', headless: true }); + + expect(state.activeBrowser?.name).toBe('electron'); + expect(state.activeBrowser?.headless).toBe(true); + }); + + it('sets the library timeout as the default timeout of the context', async () => { + const state = new PlaywrightState(); + const context = makeMockContext([makeMockPage()]); + + await state.adoptContext(context, {}, 5000); + + expect(context.setDefaultTimeout).toHaveBeenCalledWith(5000); }); it('registers every existing page and makes the first one active', async () => { @@ -534,7 +554,7 @@ describe('adoptContext', () => { const state = new PlaywrightState(); const context = makeMockContext([makeMockPage()]); const onClose = jest.fn().mockResolvedValue(undefined); - await state.adoptContext(context, onClose); + await state.adoptContext(context, { onClose }); const adopted = state.getActiveBrowser(); await closeBrowser(state); @@ -549,7 +569,7 @@ describe('adoptContext', () => { const state = new PlaywrightState(); const context = makeMockContext([makeMockPage()], jest.fn().mockRejectedValue(new Error('gone'))); const onClose = jest.fn().mockResolvedValue(undefined); - await state.adoptContext(context, onClose); + await state.adoptContext(context, { onClose }); await expect(state.getActiveBrowser().close()).rejects.toThrow('gone'); expect(onClose).toHaveBeenCalledTimes(1); @@ -558,9 +578,11 @@ describe('adoptContext', () => { it('closes the browser and waits for onClose when its context is closed', async () => { const state = new PlaywrightState(); let released = false; - await state.adoptContext(makeMockContext([makeMockPage()]), async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - released = true; + await state.adoptContext(makeMockContext([makeMockPage()]), { + onClose: async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + released = true; + }, }); await closeContext({ value: false }, state); @@ -574,7 +596,7 @@ describe('adoptContext', () => { const state = new PlaywrightState(); const context = makeMockContext([makeMockPage()], jest.fn().mockRejectedValue(new Error('gone'))); const onClose = jest.fn().mockResolvedValue(undefined); - await state.adoptContext(context, onClose); + await state.adoptContext(context, { onClose }); await expect(closeContext({ value: false }, state)).rejects.toThrow('gone'); expect(onClose).toHaveBeenCalledTimes(1); @@ -590,14 +612,14 @@ describe('extensionKeywordCall', () => { it('lets an extension function adopt a context it created', async () => { const state = new PlaywrightState(); const context = makeMockContext([makeMockPage()]); - async function openOwnContext(adoptContext: (c: unknown) => Promise) { - return adoptContext(context); + async function openOwnContext(adoptContext: (c: unknown, options?: unknown) => Promise) { + return adoptContext(context, { name: 'own' }); } state.extensions.push({ openOwnContext } as any); const call = { write: jest.fn() } as any; const responses = await extensionKeywordCall( - { name: 'openOwnContext', arguments: JSON.stringify({ arguments: [] }) }, + { name: 'openOwnContext', arguments: JSON.stringify({ arguments: [], defaultTimeout: 5000 }) }, call, state, ); @@ -605,5 +627,7 @@ describe('extensionKeywordCall', () => { const adopted = JSON.parse(responses.map((response) => response.bodyPart).join('')); expect(adopted.browserId).toBe(state.activeBrowser?.id); expect(state.activeBrowser?.context?.c).toBe(context); + expect(state.activeBrowser?.name).toBe('own'); + expect(context.setDefaultTimeout).toHaveBeenCalledWith(5000); }); }); diff --git a/node/playwright-wrapper/playwright-state.ts b/node/playwright-wrapper/playwright-state.ts index 64e14ba0f..75e699978 100644 --- a/node/playwright-wrapper/playwright-state.ts +++ b/node/playwright-wrapper/playwright-state.ts @@ -159,7 +159,7 @@ export async function extensionKeywordCall( state: PlaywrightState, ): Promise { const keywordName = request.name; - const args = JSON.parse(request.arguments) as { arguments: [string, unknown][] }; + const args = JSON.parse(request.arguments) as { arguments: [string, unknown][]; defaultTimeout?: number }; const extension = state.extensions.find((extension) => Object.keys(extension).includes(keywordName)); if (!extension) throw Error(`Could not find keyword ${keywordName}`); const keyword = extension[keywordName]; @@ -175,8 +175,8 @@ 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, onClose?: () => Promise) => - state.adoptContext(context, onClose), + apiArguments.set('adoptContext', (context: BrowserContext, options?: AdoptContextOptions) => + state.adoptContext(context, options, args.defaultTimeout), ); const functionArguments = argNames.map((argName) => apiArguments.get(argName) || namedArguments[argName]); const result = await keyword(...functionArguments); @@ -199,6 +199,26 @@ interface BrowserAndConfs { headless: boolean; } +/* + * What a BrowserState is made of. Launched and connected browsers take their + * name from the browser type; adopted contexts get the name their creator passes. + */ +interface BrowserStateConfig { + browser: Browser | null; + name: string; + headless: boolean; +} + +function _browserStateConfig({ browser, browserType, headless }: BrowserAndConfs): BrowserStateConfig { + return { browser, name: browserType, headless }; +} + +export interface AdoptContextOptions { + name?: string; + headless?: boolean; + onClose?: () => Promise; +} + async function _newBrowser( browserType: 'chromium' | 'firefox' | 'webkit', headless: boolean, @@ -420,7 +440,7 @@ export class PlaywrightState { if (currentBrowser === undefined) { logger.info('No active browser, creating a new one'); const browserAndConfs = await _newBrowser(browserType || 'chromium', true, timeout); - const newState = new BrowserState(browserAndConfs); + const newState = new BrowserState(_browserStateConfig(browserAndConfs)); this.browserStack.push(newState); return { browser: newState, newBrowser: true }; } else { @@ -512,7 +532,7 @@ export class PlaywrightState { if (browserState !== undefined) { this.browserStack = this.browserStack.filter((b) => b.browser !== adding_browser); } else { - browserState = new BrowserState(browserAndConfs); + browserState = new BrowserState(_browserStateConfig(browserAndConfs)); } const browserContexts = browserState.browser?.contexts(); if (browserContexts !== undefined) { @@ -540,14 +560,24 @@ export class PlaywrightState { /* * Adds a context that was created outside the library, for example by a * JavaScript extension, as a new active browser without a browser object. - * Its first page becomes the active page. Closing the context closes the - * browser, as with a persistent context. onClose runs once after that, so - * the creator can release what it owns, such as an app or a device. + * Its first page becomes the active page, and it gets the library's default + * timeout like a context the library creates. Closing the context closes the + * browser, as with a persistent context. options.onClose runs once after that, + * so the creator can release what the context does not own, such as a device. + * The browser is named 'adopted' and not headless unless the creator says otherwise. */ - public async adoptContext(context: BrowserContext, onClose?: () => Promise): Promise { - const browserState = new BrowserState({ browser: null, browserType: 'chromium', headless: false }); - browserState.onClose = onClose; - const indexedContext = await _indexContextWithPages(context, undefined, ''); + public async adoptContext( + context: BrowserContext, + options: AdoptContextOptions = {}, + defaultTimeout?: number, + ): Promise { + const browserState = new BrowserState({ + browser: null, + name: options.name ?? 'adopted', + headless: options.headless ?? false, + }); + browserState.onClose = options.onClose; + const indexedContext = await _indexContextWithPages(context, defaultTimeout, ''); browserState.pushContext(indexedContext); this.browserStack.push(browserState); return { browserId: browserState.id, contextId: indexedContext.id, pageId: browserState.page?.id }; @@ -684,16 +714,16 @@ type FailedPage = { * User opened items should get pushed and page opened unshifted * */ export class BrowserState { - constructor(browserAndConfs: BrowserAndConfs) { - this.name = browserAndConfs.browserType; - this.browser = browserAndConfs.browser; - this.headless = browserAndConfs.headless; + constructor(config: BrowserStateConfig) { + this.name = config.name; + this.browser = config.browser; + this.headless = config.headless; this._contextStack = []; this.id = `browser=${uuidv4()}`; } private _contextStack: IndexedContext[]; browser: Browser | null; - name?: string; + name: string; id: Uuid; headless: boolean; onClose?: () => Promise; From 86d460997b89cb9bbc98d9233cc9cacac8784f1f Mon Sep 17 00:00:00 2001 From: Daniel Biehl Date: Sat, 10 Oct 2026 12:24:46 +0200 Subject: [PATCH 5/9] test: import only the variables in the adopt context suite imports.resource imports Browser a second time with other arguments, which Robot Framework warns about. The suite also uses Test Tags instead of the deprecated Force Tags. --- atest/test/05_JS_Tests/adopt_context.robot | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/atest/test/05_JS_Tests/adopt_context.robot b/atest/test/05_JS_Tests/adopt_context.robot index dba45093d..dc5107c58 100644 --- a/atest/test/05_JS_Tests/adopt_context.robot +++ b/atest/test/05_JS_Tests/adopt_context.robot @@ -1,8 +1,8 @@ *** Settings *** -Library Browser jsextension=${CURDIR}/adopt.js -Resource imports.resource +Library Browser jsextension=${CURDIR}/adopt.js +Resource ../variables.resource -Force Tags no-iframe +Test Tags no-iframe *** Test Cases *** Browser Keywords Work On A Context Adopted By A JavaScript Extension From ec98f0e461ddfb452c6e5dde10ada79ff1d44162 Mon Sep 17 00:00:00 2001 From: Daniel Biehl Date: Sat, 10 Oct 2026 17:43:46 +0200 Subject: [PATCH 6/9] feat: adoptContext takes tracing and contextOptions With tracing, Browser starts tracing the adopted context and saves the trace before it closes the context, as for New Context with tracing. contextOptions are the options the context was created with; Browser keeps them as it does for New Context, so keywords that depend on them work. Download, for example, needs acceptDownloads and failed on every adopted context. --- Browser/browser.py | 11 ++++-- atest/test/05_JS_Tests/adopt.js | 11 ++++++ atest/test/05_JS_Tests/adopt_context.robot | 10 ++++++ .../__tests__/playwright-state.test.ts | 36 ++++++++++++++++++- node/playwright-wrapper/playwright-state.ts | 12 ++++++- 5 files changed, 76 insertions(+), 4 deletions(-) diff --git a/Browser/browser.py b/Browser/browser.py index 3bd60efe9..f3ef833a9 100755 --- a/Browser/browser.py +++ b/Browser/browser.py @@ -522,10 +522,17 @@ def __init__( # noqa: PLR0915 browser and context ids, and the page id if the context has a page. Closing that browser closes the context. The optional ``options`` are ``name``, which `Get Browser Catalog` reports as the browser's type, - ``adopted`` by default; ``headless``, ``false`` by default; and + ``adopted`` by default; ``headless``, ``false`` by default; ``onClose``, an async function that runs once after the context is closed, so the function can release what the context does not own, - such as a device connection. Can be a single path, a + such as a device connection; ``tracing``, the path of a trace file + or folder, as the library resolves it for ``tracing`` of `New Context`; + and ``contextOptions``, the options the context was created with, in + the form of Playwright's browser context options. With ``tracing``, + the library starts tracing the context and saves the trace before it + closes the context. With ``contextOptions``, keywords that depend on + them work on the context, such as `Download` with + ``acceptDownloads``. Can be a single path, a comma-separated list of paths or a real list of strings. See https://robotframework-browser.org/docs/extending/javascript-extensions - ``language``: Defines language which is used to translate keyword names and diff --git a/atest/test/05_JS_Tests/adopt.js b/atest/test/05_JS_Tests/adopt.js index 6807a5aaf..ae738470a 100644 --- a/atest/test/05_JS_Tests/adopt.js +++ b/atest/test/05_JS_Tests/adopt.js @@ -14,6 +14,16 @@ async function openAdoptedPersistentContext(userDataDir, url, headless, playwrig }); } +async function openAdoptedPersistentContextWithTracingAndDownloads(userDataDir, url, headless, tracing, playwright, adoptContext) { + const contextOptions = { acceptDownloads: true }; + const context = await playwright.chromium.launchPersistentContext(userDataDir, { + headless: String(headless).toLowerCase() !== 'false', + ...contextOptions, + }); + await context.pages()[0].goto(url); + return adoptContext(context, { tracing, contextOptions }); +} + async function adoptedContextIsClosed() { return adoptedContextClosed; } @@ -24,5 +34,6 @@ async function adoptedContextOnCloseWasCalled() { exports.__esModule = true; exports.openAdoptedPersistentContext = openAdoptedPersistentContext; +exports.openAdoptedPersistentContextWithTracingAndDownloads = openAdoptedPersistentContextWithTracingAndDownloads; exports.adoptedContextIsClosed = adoptedContextIsClosed; exports.adoptedContextOnCloseWasCalled = adoptedContextOnCloseWasCalled; diff --git a/atest/test/05_JS_Tests/adopt_context.robot b/atest/test/05_JS_Tests/adopt_context.robot index dc5107c58..fd136e98d 100644 --- a/atest/test/05_JS_Tests/adopt_context.robot +++ b/atest/test/05_JS_Tests/adopt_context.robot @@ -1,5 +1,6 @@ *** Settings *** Library Browser jsextension=${CURDIR}/adopt.js +Library OperatingSystem Resource ../variables.resource Test Tags no-iframe @@ -15,3 +16,12 @@ Browser Keywords Work On A Context Adopted By A JavaScript Extension Should Be True ${closed} ${on_close_called} = Adopted Context On Close Was Called Should Be True ${on_close_called} + +An Adopted Context Is Traced 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 diff --git a/node/playwright-wrapper/__tests__/playwright-state.test.ts b/node/playwright-wrapper/__tests__/playwright-state.test.ts index e1ac755bb..5456a1735 100644 --- a/node/playwright-wrapper/__tests__/playwright-state.test.ts +++ b/node/playwright-wrapper/__tests__/playwright-state.test.ts @@ -488,7 +488,8 @@ function makeMockPage() { } function makeMockContext(pages: any[], close = jest.fn().mockResolvedValue(undefined)) { - return { pages: () => pages, on: jest.fn(), close, setDefaultTimeout: jest.fn() } as any; + const tracing = { start: jest.fn().mockResolvedValue(undefined), stop: jest.fn().mockResolvedValue(undefined) }; + return { pages: () => pages, on: jest.fn(), close, setDefaultTimeout: jest.fn(), tracing } as any; } describe('adoptContext', () => { @@ -519,6 +520,39 @@ describe('adoptContext', () => { expect(state.activeBrowser?.headless).toBe(true); }); + it('traces the context and saves the trace before closing it', async () => { + const state = new PlaywrightState(); + const context = makeMockContext([makeMockPage()]); + await state.adoptContext(context, { tracing: '/traces/app.zip' }); + + await state.getActiveBrowser().close(); + + expect(context.tracing.start).toHaveBeenCalledWith({ screenshots: true, snapshots: true }); + expect(context.tracing.stop).toHaveBeenCalledWith({ path: '/traces/app.zip' }); + expect(context.tracing.stop.mock.invocationCallOrder[0]).toBeLessThan( + context.close.mock.invocationCallOrder[0], + ); + }); + + it('does not trace the context without the tracing option', async () => { + const state = new PlaywrightState(); + const context = makeMockContext([makeMockPage()]); + await state.adoptContext(context); + + await state.getActiveBrowser().close(); + + expect(context.tracing.start).not.toHaveBeenCalled(); + expect(context.tracing.stop).not.toHaveBeenCalled(); + }); + + it('keeps the options the context was created with', async () => { + const state = new PlaywrightState(); + + await state.adoptContext(makeMockContext([makeMockPage()]), { contextOptions: { acceptDownloads: true } }); + + expect(state.activeBrowser?.context?.options).toEqual({ acceptDownloads: true }); + }); + it('sets the library timeout as the default timeout of the 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 75e699978..27e0d20f0 100644 --- a/node/playwright-wrapper/playwright-state.ts +++ b/node/playwright-wrapper/playwright-state.ts @@ -217,6 +217,8 @@ export interface AdoptContextOptions { name?: string; headless?: boolean; onClose?: () => Promise; + tracing?: string; + contextOptions?: Record; } async function _newBrowser( @@ -565,6 +567,9 @@ export class PlaywrightState { * browser, as with a persistent context. options.onClose runs once after that, * so the creator can release what the context does not own, such as a device. * The browser is named 'adopted' and not headless unless the creator says otherwise. + * With options.tracing, the context is traced like one created with a trace file. + * options.contextOptions are the options the context was created with, kept like + * those of New Context, so that keywords depending on them, such as Download, work. */ public async adoptContext( context: BrowserContext, @@ -577,7 +582,12 @@ export class PlaywrightState { headless: options.headless ?? false, }); browserState.onClose = options.onClose; - const indexedContext = await _indexContextWithPages(context, defaultTimeout, ''); + const indexedContext = await _indexContextWithPages( + context, + defaultTimeout, + options.tracing ?? '', + options.contextOptions, + ); browserState.pushContext(indexedContext); this.browserStack.push(browserState); return { browserId: browserState.id, contextId: indexedContext.id, pageId: browserState.page?.id }; From 60329437b89d5b6858f8eaf1a07f51037846f033 Mon Sep 17 00:00:00 2001 From: Daniel Biehl Date: Sun, 11 Oct 2026 01:52:08 +0200 Subject: [PATCH 7/9] feat: let JavaScript extensions take the library timeout as defaultTimeout Extension functions can request defaultTimeout like the other injected names. Generated keywords fill it with the library timeout at the time of the call, so it is sent only to functions that ask for it. adoptContext no longer sets a timeout itself; a function gives its context the library timeout with context.setDefaultTimeout(defaultTimeout). call_js_keyword sends only the arguments it is given. --- Browser/browser.py | 16 ++-- atest/test/05_JS_Tests/adopt.js | 14 ++- atest/test/05_JS_Tests/adopt_context.robot | 7 ++ .../__tests__/playwright-state.test.ts | 22 +++-- node/playwright-wrapper/playwright-state.ts | 18 ++-- utest/test_js_extension_call.py | 85 +++++++++++++++++++ 6 files changed, 139 insertions(+), 23 deletions(-) create mode 100644 utest/test_js_extension_call.py diff --git a/Browser/browser.py b/Browser/browser.py index f3ef833a9..e17498e6b 100755 --- a/Browser/browser.py +++ b/Browser/browser.py @@ -511,14 +511,16 @@ def __init__( # noqa: PLR0915 modules must be in CommonJS format; exported functions become keywords and an ``fn.rfdoc`` string becomes a keyword's documentation. The argument names ``page``, ``context``, ``browser``, ``logger``, - ``playwright`` and ``adoptContext`` are filled in by the library rather - than taken from the keyword call. ``adoptContext(context, options)`` + ``playwright``, ``adoptContext`` and ``defaultTimeout`` are filled in by + the library rather than taken from the keyword call. ``defaultTimeout`` + is the library timeout in milliseconds. ``adoptContext(context, options)`` registers a BrowserContext that the function created itself, for example with ``playwright.chromium.launchPersistentContext`` or as ``app.context()`` of an app from ``playwright._electron.launch``, as a new active browser and makes its first page the active page, so the - other keywords work on it. Like the contexts the library creates, it - gets the library timeout as its default timeout. It returns the new + other keywords work on it. It keeps its own default timeout; + ``context.setDefaultTimeout(defaultTimeout)`` gives it the library + timeout, like the contexts the library creates. It returns the new browser and context ids, and the page id if the context has a page. Closing that browser closes the context. The optional ``options`` are ``name``, which `Get Browser Catalog` reports as the browser's type, @@ -833,6 +835,9 @@ def _jskeyword_call( arg_name = item[0] if arg_name in self._js_injected_arguments: arg_set_texts.append(f'("{arg_name}", "RESERVED")') + elif arg_name == "defaultTimeout": + # The library timeout at the time of the call. + arg_set_texts.append('("defaultTimeout", int(self.timeout))') else: arg_set_texts.append(f'("{arg_name}", {arg_name})') if arg_name == "args": @@ -888,8 +893,7 @@ def call_js_keyword(self, keyword_name: str, **args) -> Any: "RESERVED" if arg_name in self._js_injected_arguments else value, ) for arg_name, value in args.items() - ], - "defaultTimeout": int(self.timeout), + ] } with self.playwright.grpc_channel() as stub: responses = stub.CallExtensionKeyword( diff --git a/atest/test/05_JS_Tests/adopt.js b/atest/test/05_JS_Tests/adopt.js index ae738470a..0df77b40a 100644 --- a/atest/test/05_JS_Tests/adopt.js +++ b/atest/test/05_JS_Tests/adopt.js @@ -1,10 +1,11 @@ let adoptedContextClosed = false; let onCloseCalled = false; -async function openAdoptedPersistentContext(userDataDir, url, headless, playwright, adoptContext) { +async function openAdoptedPersistentContext(userDataDir, url, headless, playwright, adoptContext, defaultTimeout) { const context = await playwright.chromium.launchPersistentContext(userDataDir, { headless: String(headless).toLowerCase() !== 'false', }); + context.setDefaultTimeout(defaultTimeout); context.on('close', () => (adoptedContextClosed = true)); await context.pages()[0].goto(url); return adoptContext(context, { @@ -24,6 +25,16 @@ async function openAdoptedPersistentContextWithTracingAndDownloads(userDataDir, return adoptContext(context, { tracing, contextOptions }); } +async function millisecondsUntilAWaitFails(page) { + const start = Date.now(); + try { + await page.waitForSelector('#not-on-the-page'); + } catch { + return Date.now() - start; + } + return -1; +} + async function adoptedContextIsClosed() { return adoptedContextClosed; } @@ -35,5 +46,6 @@ async function adoptedContextOnCloseWasCalled() { exports.__esModule = true; exports.openAdoptedPersistentContext = openAdoptedPersistentContext; exports.openAdoptedPersistentContextWithTracingAndDownloads = openAdoptedPersistentContextWithTracingAndDownloads; +exports.millisecondsUntilAWaitFails = millisecondsUntilAWaitFails; exports.adoptedContextIsClosed = adoptedContextIsClosed; exports.adoptedContextOnCloseWasCalled = adoptedContextOnCloseWasCalled; diff --git a/atest/test/05_JS_Tests/adopt_context.robot b/atest/test/05_JS_Tests/adopt_context.robot index fd136e98d..9993d2256 100644 --- a/atest/test/05_JS_Tests/adopt_context.robot +++ b/atest/test/05_JS_Tests/adopt_context.robot @@ -17,6 +17,13 @@ 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} +An Extension Gives An Adopted Context The Library Timeout + Set Browser Timeout 1s scope=Test + Open Adopted Persistent Context ${OUTPUT_DIR}/adopted-profile-timeout ${LOGIN_URL} ${HEADLESS} + ${elapsed} = Milliseconds Until A Wait Fails + Should Be True ${elapsed} < 5000 + [Teardown] Close Browser + An Adopted Context Is Traced And Accepts Downloads Open Adopted Persistent Context With Tracing And Downloads ... ${OUTPUT_DIR}/adopted-profile-traced ${LOGIN_URL} ${HEADLESS} ${OUTPUT_DIR}/adopted-trace.zip diff --git a/node/playwright-wrapper/__tests__/playwright-state.test.ts b/node/playwright-wrapper/__tests__/playwright-state.test.ts index 5456a1735..56650ef83 100644 --- a/node/playwright-wrapper/__tests__/playwright-state.test.ts +++ b/node/playwright-wrapper/__tests__/playwright-state.test.ts @@ -553,13 +553,13 @@ describe('adoptContext', () => { expect(state.activeBrowser?.context?.options).toEqual({ acceptDownloads: true }); }); - it('sets the library timeout as the default timeout of the context', async () => { + it('leaves the default timeout of the context alone', async () => { const state = new PlaywrightState(); const context = makeMockContext([makeMockPage()]); - await state.adoptContext(context, {}, 5000); + await state.adoptContext(context); - expect(context.setDefaultTimeout).toHaveBeenCalledWith(5000); + expect(context.setDefaultTimeout).not.toHaveBeenCalled(); }); it('registers every existing page and makes the first one active', async () => { @@ -646,14 +646,26 @@ describe('extensionKeywordCall', () => { it('lets an extension function adopt a context it created', async () => { const state = new PlaywrightState(); const context = makeMockContext([makeMockPage()]); - async function openOwnContext(adoptContext: (c: unknown, options?: unknown) => Promise) { + async function openOwnContext( + defaultTimeout: number, + adoptContext: (c: unknown, options?: unknown) => Promise, + ) { + context.setDefaultTimeout(defaultTimeout); return adoptContext(context, { name: 'own' }); } state.extensions.push({ openOwnContext } as any); const call = { write: jest.fn() } as any; const responses = await extensionKeywordCall( - { name: 'openOwnContext', arguments: JSON.stringify({ arguments: [], defaultTimeout: 5000 }) }, + { + name: 'openOwnContext', + arguments: JSON.stringify({ + arguments: [ + ['defaultTimeout', 5000], + ['adoptContext', 'RESERVED'], + ], + }), + }, call, state, ); diff --git a/node/playwright-wrapper/playwright-state.ts b/node/playwright-wrapper/playwright-state.ts index 27e0d20f0..18ef28fce 100644 --- a/node/playwright-wrapper/playwright-state.ts +++ b/node/playwright-wrapper/playwright-state.ts @@ -159,7 +159,7 @@ export async function extensionKeywordCall( state: PlaywrightState, ): Promise { const keywordName = request.name; - const args = JSON.parse(request.arguments) as { arguments: [string, unknown][]; defaultTimeout?: number }; + const args = JSON.parse(request.arguments) as { arguments: [string, unknown][] }; const extension = state.extensions.find((extension) => Object.keys(extension).includes(keywordName)); if (!extension) throw Error(`Could not find keyword ${keywordName}`); const keyword = extension[keywordName]; @@ -176,7 +176,7 @@ export async function extensionKeywordCall( apiArguments.set('logger', (msg: string) => call.write(jsonResponse('', msg))); apiArguments.set('playwright', playwright); apiArguments.set('adoptContext', (context: BrowserContext, options?: AdoptContextOptions) => - state.adoptContext(context, options, args.defaultTimeout), + state.adoptContext(context, options), ); const functionArguments = argNames.map((argName) => apiArguments.get(argName) || namedArguments[argName]); const result = await keyword(...functionArguments); @@ -562,20 +562,16 @@ export class PlaywrightState { /* * Adds a context that was created outside the library, for example by a * JavaScript extension, as a new active browser without a browser object. - * Its first page becomes the active page, and it gets the library's default - * timeout like a context the library creates. Closing the context closes the - * browser, as with a persistent context. options.onClose runs once after that, + * Its first page becomes the active page. It keeps its default timeout; an + * extension gives it the library timeout with the injected defaultTimeout. + * Closing the context closes the browser, as with a persistent context. options.onClose runs once after that, * so the creator can release what the context does not own, such as a device. * The browser is named 'adopted' and not headless unless the creator says otherwise. * With options.tracing, the context is traced like one created with a trace file. * options.contextOptions are the options the context was created with, kept like * those of New Context, so that keywords depending on them, such as Download, work. */ - public async adoptContext( - context: BrowserContext, - options: AdoptContextOptions = {}, - defaultTimeout?: number, - ): Promise { + public async adoptContext(context: BrowserContext, options: AdoptContextOptions = {}): Promise { const browserState = new BrowserState({ browser: null, name: options.name ?? 'adopted', @@ -584,7 +580,7 @@ export class PlaywrightState { browserState.onClose = options.onClose; const indexedContext = await _indexContextWithPages( context, - defaultTimeout, + undefined, options.tracing ?? '', options.contextOptions, ); diff --git a/utest/test_js_extension_call.py b/utest/test_js_extension_call.py new file mode 100644 index 000000000..4f8398e06 --- /dev/null +++ b/utest/test_js_extension_call.py @@ -0,0 +1,85 @@ +import inspect +import json +from contextlib import contextmanager +from datetime import timedelta + +from Browser import Browser +from Browser.base import LibraryComponent + + +class RecordingPlaywright: + """Stands in for the Node process and records the extension calls.""" + + def __init__(self): + self.requests = [] + + @contextmanager + def grpc_channel(self): + yield self + + def CallExtensionKeyword(self, request): # noqa: N802 - the name of the gRPC method + self.requests.append(request) + return [] + + def payload(self) -> dict: + return json.loads(self.requests[-1].arguments) + + +def library_with_recorded_calls( + tmpdir, monkeypatch +) -> tuple[Browser, RecordingPlaywright]: + monkeypatch.setattr(Browser, "_output_dir", tmpdir) + library = Browser(timeout=timedelta(seconds=7)) + library._playwright = RecordingPlaywright() + return library, library._playwright + + +def generated_keyword(library: Browser, name: str, arguments: str): + component = LibraryComponent(library) + library._jskeyword_call(component, name, arguments, "") + return getattr(component, name) + + +def test_generated_keyword_passes_the_library_timeout_as_default_timeout( + tmpdir, monkeypatch +): + library, playwright = library_with_recorded_calls(tmpdir, monkeypatch) + keyword = generated_keyword(library, "openOwnContext", "url, defaultTimeout") + + keyword("https://a.b") + + assert list(inspect.signature(keyword).parameters) == ["url"] + assert playwright.payload() == { + "arguments": [["url", "https://a.b"], ["defaultTimeout", 7000]] + } + + +def test_generated_keyword_reads_the_library_timeout_at_each_call(tmpdir, monkeypatch): + library, playwright = library_with_recorded_calls(tmpdir, monkeypatch) + keyword = generated_keyword(library, "openOwnContext", "defaultTimeout") + library.scope_stack["timeout"].setter_function = None + + library.scope_stack["timeout"].set(3000.0) + keyword() + + assert playwright.payload() == {"arguments": [["defaultTimeout", 3000]]} + + +def test_other_generated_keywords_send_only_their_arguments(tmpdir, monkeypatch): + library, playwright = library_with_recorded_calls(tmpdir, monkeypatch) + + generated_keyword(library, "clickTwice", "selector, page")("#button") + + assert playwright.payload() == { + "arguments": [["selector", "#button"], ["page", "RESERVED"]] + } + + +def test_call_js_keyword_sends_the_arguments_as_given(tmpdir, monkeypatch): + library, playwright = library_with_recorded_calls(tmpdir, monkeypatch) + + library.call_js_keyword("openOwnContext", url="https://a.b", defaultTimeout=5000) + + assert playwright.payload() == { + "arguments": [["url", "https://a.b"], ["defaultTimeout", 5000]] + } From 43b35d4260f5fba5276dcaf52e5d7e005e5dc4e0 Mon Sep 17 00:00:00 2001 From: Daniel Biehl Date: Sun, 11 Oct 2026 01:52:19 +0200 Subject: [PATCH 8/9] fix: pass null and undefined values of injected extension arguments on Injected arguments were resolved with ||, so a null or undefined value fell back to the RESERVED marker. An extension asking for browser got that marker for a persistent context, whose browser is null, and now also for an adopted context. Arguments are now resolved by map membership. --- .../__tests__/playwright-state.test.ts | 27 +++++++++++++++++++ node/playwright-wrapper/playwright-state.ts | 6 ++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/node/playwright-wrapper/__tests__/playwright-state.test.ts b/node/playwright-wrapper/__tests__/playwright-state.test.ts index 56650ef83..005185230 100644 --- a/node/playwright-wrapper/__tests__/playwright-state.test.ts +++ b/node/playwright-wrapper/__tests__/playwright-state.test.ts @@ -676,4 +676,31 @@ describe('extensionKeywordCall', () => { expect(state.activeBrowser?.name).toBe('own'); expect(context.setDefaultTimeout).toHaveBeenCalledWith(5000); }); + + it('passes null as the browser of an adopted context', async () => { + const state = new PlaywrightState(); + const context = makeMockContext([makeMockPage()]); + let received: unknown = 'not called'; + async function openOwnContext(adoptContext: (c: unknown) => Promise) { + return adoptContext(context); + } + async function useBrowser(browser: unknown) { + received = browser; + } + state.extensions.push({ openOwnContext, useBrowser } as any); + const call = { write: jest.fn() } as any; + + await extensionKeywordCall( + { name: 'openOwnContext', arguments: JSON.stringify({ arguments: [['adoptContext', 'RESERVED']] }) }, + call, + state, + ); + await extensionKeywordCall( + { name: 'useBrowser', arguments: JSON.stringify({ arguments: [['browser', 'RESERVED']] }) }, + call, + state, + ); + + expect(received).toBeNull(); + }); }); diff --git a/node/playwright-wrapper/playwright-state.ts b/node/playwright-wrapper/playwright-state.ts index 18ef28fce..022ea280d 100644 --- a/node/playwright-wrapper/playwright-state.ts +++ b/node/playwright-wrapper/playwright-state.ts @@ -178,7 +178,11 @@ export async function extensionKeywordCall( apiArguments.set('adoptContext', (context: BrowserContext, options?: AdoptContextOptions) => state.adoptContext(context, options), ); - const functionArguments = argNames.map((argName) => apiArguments.get(argName) || namedArguments[argName]); + // Injected values can be null or undefined, for example the browser of a + // persistent or adopted context, so they must not fall back to the marker. + const functionArguments = argNames.map((argName) => + apiArguments.has(argName) ? apiArguments.get(argName) : namedArguments[argName], + ); const result = await keyword(...functionArguments); if (result === undefined) { return [jsonResponse('', 'ok')]; From a6acf22c33473e655e51f79079868dc47e9a0121 Mon Sep 17 00:00:00 2001 From: Daniel Biehl Date: Sun, 11 Oct 2026 02:06:54 +0200 Subject: [PATCH 9/9] docs: adoptContext takes an absolute trace path adoptContext passes options.tracing to Node as given, so a relative path would be relative to the Node process, not resolved like tracing of New Context. --- Browser/browser.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Browser/browser.py b/Browser/browser.py index e17498e6b..fa4d4c01e 100755 --- a/Browser/browser.py +++ b/Browser/browser.py @@ -527,15 +527,15 @@ def __init__( # noqa: PLR0915 ``adopted`` by default; ``headless``, ``false`` by default; ``onClose``, an async function that runs once after the context is closed, so the function can release what the context does not own, - such as a device connection; ``tracing``, the path of a trace file - or folder, as the library resolves it for ``tracing`` of `New Context`; - and ``contextOptions``, the options the context was created with, in - the form of Playwright's browser context options. With ``tracing``, - the library starts tracing the context and saves the trace before it - closes the context. With ``contextOptions``, keywords that depend on - them work on the context, such as `Download` with - ``acceptDownloads``. Can be a single path, a - comma-separated list of paths or a real list of strings. See + such as a device connection; ``tracing``, the absolute path of a + ``.zip`` trace file or of a folder, in which the trace is saved as + ``trace_.zip``; and ``contextOptions``, the options the + context was created with, in the form of Playwright's browser context + options. With ``tracing``, the library starts tracing the context and + saves the trace before it closes the context. With ``contextOptions``, + keywords that depend on them work on the context, such as `Download` + with ``acceptDownloads``. Can be a single path, a comma-separated list + of paths or a real list of strings. See https://robotframework-browser.org/docs/extending/javascript-extensions - ``language``: Defines language which is used to translate keyword names and documentation.