diff --git a/Browser/browser.py b/Browser/browser.py index 07d2f41d8..fa4d4c01e 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,32 @@ 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``, ``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. 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, + ``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 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. @@ -802,8 +833,11 @@ 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")') + 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": @@ -852,16 +886,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/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 new file mode 100644 index 000000000..0df77b40a --- /dev/null +++ b/atest/test/05_JS_Tests/adopt.js @@ -0,0 +1,51 @@ +let adoptedContextClosed = false; +let onCloseCalled = false; + +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, { + onClose: async () => { + onCloseCalled = true; + }, + }); +} + +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 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; +} + +async function adoptedContextOnCloseWasCalled() { + return onCloseCalled; +} + +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 new file mode 100644 index 000000000..9993d2256 --- /dev/null +++ b/atest/test/05_JS_Tests/adopt_context.robot @@ -0,0 +1,34 @@ +*** Settings *** +Library Browser jsextension=${CURDIR}/adopt.js +Library OperatingSystem +Resource ../variables.resource + +Test 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 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 + Should Be True ${closed} + ${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 + ${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 324f9333f..005185230 100644 --- a/node/playwright-wrapper/__tests__/playwright-state.test.ts +++ b/node/playwright-wrapper/__tests__/playwright-state.test.ts @@ -16,6 +16,8 @@ import { closeAllBrowsers, closeBrowser, closeBrowserServer, + closeContext, + extensionKeywordCall, locatorCache, newPage, PlaywrightState, @@ -25,7 +27,7 @@ import { function makeBrowserState(id: string): BrowserState { const state = new BrowserState({ browser: null, - browserType: 'chromium', + name: 'chromium', headless: true, }); state.id = id; @@ -254,7 +256,7 @@ describe('BrowserState', () => { } as unknown as Browser; const browser = new BrowserState({ browser: browserMock, - browserType: 'chromium', + name: 'chromium', headless: true, }); browser.pushContext({ @@ -480,3 +482,225 @@ 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)) { + 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', () => { + 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); + 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('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('leaves the default timeout of the context alone', async () => { + const state = new PlaywrightState(); + const context = makeMockContext([makeMockPage()]); + + await state.adoptContext(context); + + expect(context.setDefaultTimeout).not.toHaveBeenCalled(); + }); + + 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); + }); + + 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()]), { + onClose: 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', () => { + 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( + 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], + ['adoptContext', 'RESERVED'], + ], + }), + }, + 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); + 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 78c4b253b..022ea280d 100644 --- a/node/playwright-wrapper/playwright-state.ts +++ b/node/playwright-wrapper/playwright-state.ts @@ -175,7 +175,14 @@ export async function extensionKeywordCall( apiArguments.set('context', state.getActiveContext()); apiArguments.set('logger', (msg: string) => call.write(jsonResponse('', msg))); apiArguments.set('playwright', playwright); - const functionArguments = argNames.map((argName) => apiArguments.get(argName) || namedArguments[argName]); + apiArguments.set('adoptContext', (context: BrowserContext, options?: AdoptContextOptions) => + state.adoptContext(context, options), + ); + // 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')]; @@ -196,6 +203,28 @@ 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; + tracing?: string; + contextOptions?: Record; +} + async function _newBrowser( browserType: 'chromium' | 'firefox' | 'webkit', headless: boolean, @@ -301,6 +330,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; @@ -398,7 +446,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 { @@ -490,7 +538,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) { @@ -515,6 +563,36 @@ 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. 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 = {}): Promise { + const browserState = new BrowserState({ + browser: null, + name: options.name ?? 'adopted', + headless: options.headless ?? false, + }); + browserState.onClose = options.onClose; + const indexedContext = await _indexContextWithPages( + context, + undefined, + options.tracing ?? '', + options.contextOptions, + ); + 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 +708,12 @@ export type IndexedPage = { type Uuid = string; +type AdoptedContext = { + browserId: Uuid; + contextId: Uuid; + pageId: Uuid | undefined; +}; + type FailedPage = { context: IndexedContext; page: IndexedPage; @@ -640,32 +724,38 @@ 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; 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 { @@ -784,11 +874,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'); } @@ -974,12 +1069,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 { 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]] + }