From c168c180ec6d79d1b4b00f87afb1af65c55829d4 Mon Sep 17 00:00:00 2001 From: travagliad <215686151+travagliad@users.noreply.github.com> Date: Thu, 18 Dec 2025 21:53:30 +0000 Subject: [PATCH 01/19] This is a POC commit for experimenting with hooks to switch to iframe in tests. --- pr.codecept.js | 3 ++- tests/helper/hooks.js | 30 ++++++++++++++++++++++++++++++ tests/pages/dashboardPage.js | 2 +- 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 tests/helper/hooks.js diff --git a/pr.codecept.js b/pr.codecept.js index 0f6a27add..c96e42fc5 100644 --- a/pr.codecept.js +++ b/pr.codecept.js @@ -16,6 +16,7 @@ exports.config = { url: pmmUrl.replace(/\/(?!.*\/)$/gm, ''), restart: true, show: false, + trace: true, browser: 'chromium', windowSize: '1920x1080', timeout: 20000, @@ -137,7 +138,7 @@ exports.config = { }, }, }, - bootstrap: false, + bootstrap: require('./tests/helper/hooks.js'), teardown: null, hooks: [], gherkin: {}, diff --git a/tests/helper/hooks.js b/tests/helper/hooks.js new file mode 100644 index 000000000..0349163ca --- /dev/null +++ b/tests/helper/hooks.js @@ -0,0 +1,30 @@ +const { event, container } = require('codeceptjs'); + +// Guard to avoid registering listeners multiple times per worker/process (causes multiple waits in same tests) +if (!global.__pmmGrafanaIframeHookRegistered) { + global.__pmmGrafanaIframeHookRegistered = true; + + module.exports = function () { + // Switch to iframe only after amOnPage completes + event.dispatcher.on(event.step.after, async (step) => { + if (!step || step.name !== 'amOnPage') return; + + const helper = container.helpers('Playwright'); + + // Reset to main context to avoid nested frame chains + await helper.switchTo(); + await helper.waitForVisible('#grafana-iframe', 30); + await helper.switchTo('#grafana-iframe'); + console.log('Switched to grafana iframe'); + }); + + // Switch back to main context after each scenario + event.dispatcher.on(event.test.after, async () => { + const helper = container.helpers('Playwright'); + + await helper.switchTo(); + }); + }; +} else { + module.exports = function () {}; +} diff --git a/tests/pages/dashboardPage.js b/tests/pages/dashboardPage.js index 63350f2f2..0a1de7024 100644 --- a/tests/pages/dashboardPage.js +++ b/tests/pages/dashboardPage.js @@ -1147,7 +1147,7 @@ module.exports = { clickablePanel: (name) => locate('$header-container').withText(name).find('a'), dashboardTitle: (name) => locate('span').withText(name), metricPanelNa: (name) => `//section[@aria-label="${name}"]//span[text()="N/A"]`, - loadingElement: locate('//div[@aria-label="Panel loading bar"]'), + loadingElement: '[aria-label="Panel loading bar"]', multiSelect: (filterName) => locate(`//label[contains(text(), "${filterName}")]/following-sibling::div//div[contains(@class,"grafana-select-multi-value-container")]`), }, From 1b7d29d83f7c35ef2f948e4f4acc148a01d724c2 Mon Sep 17 00:00:00 2001 From: travagliad <215686151+travagliad@users.noreply.github.com> Date: Thu, 18 Dec 2025 21:53:30 +0000 Subject: [PATCH 02/19] This is a POC commit for experimenting with hooks to switch to iframe in tests. --- pr.codecept.js | 3 ++- tests/helper/hooks.js | 30 ++++++++++++++++++++++++++++++ tests/pages/dashboardPage.js | 2 +- 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 tests/helper/hooks.js diff --git a/pr.codecept.js b/pr.codecept.js index 0f6a27add..c96e42fc5 100644 --- a/pr.codecept.js +++ b/pr.codecept.js @@ -16,6 +16,7 @@ exports.config = { url: pmmUrl.replace(/\/(?!.*\/)$/gm, ''), restart: true, show: false, + trace: true, browser: 'chromium', windowSize: '1920x1080', timeout: 20000, @@ -137,7 +138,7 @@ exports.config = { }, }, }, - bootstrap: false, + bootstrap: require('./tests/helper/hooks.js'), teardown: null, hooks: [], gherkin: {}, diff --git a/tests/helper/hooks.js b/tests/helper/hooks.js new file mode 100644 index 000000000..0349163ca --- /dev/null +++ b/tests/helper/hooks.js @@ -0,0 +1,30 @@ +const { event, container } = require('codeceptjs'); + +// Guard to avoid registering listeners multiple times per worker/process (causes multiple waits in same tests) +if (!global.__pmmGrafanaIframeHookRegistered) { + global.__pmmGrafanaIframeHookRegistered = true; + + module.exports = function () { + // Switch to iframe only after amOnPage completes + event.dispatcher.on(event.step.after, async (step) => { + if (!step || step.name !== 'amOnPage') return; + + const helper = container.helpers('Playwright'); + + // Reset to main context to avoid nested frame chains + await helper.switchTo(); + await helper.waitForVisible('#grafana-iframe', 30); + await helper.switchTo('#grafana-iframe'); + console.log('Switched to grafana iframe'); + }); + + // Switch back to main context after each scenario + event.dispatcher.on(event.test.after, async () => { + const helper = container.helpers('Playwright'); + + await helper.switchTo(); + }); + }; +} else { + module.exports = function () {}; +} diff --git a/tests/pages/dashboardPage.js b/tests/pages/dashboardPage.js index 63350f2f2..0a1de7024 100644 --- a/tests/pages/dashboardPage.js +++ b/tests/pages/dashboardPage.js @@ -1147,7 +1147,7 @@ module.exports = { clickablePanel: (name) => locate('$header-container').withText(name).find('a'), dashboardTitle: (name) => locate('span').withText(name), metricPanelNa: (name) => `//section[@aria-label="${name}"]//span[text()="N/A"]`, - loadingElement: locate('//div[@aria-label="Panel loading bar"]'), + loadingElement: '[aria-label="Panel loading bar"]', multiSelect: (filterName) => locate(`//label[contains(text(), "${filterName}")]/following-sibling::div//div[contains(@class,"grafana-select-multi-value-container")]`), }, From c742248e325d244bb98fd80eb3a8a25a620f7855 Mon Sep 17 00:00:00 2001 From: travagliad <215686151+travagliad@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:16:37 +0000 Subject: [PATCH 03/19] fix some methods in dashboard using old page --- pr.codecept.js | 3 +- tests/helper/hooks.js | 73 ++++++++++++++++++++++++++---------- tests/pages/dashboardPage.js | 30 +++++++-------- 3 files changed, 68 insertions(+), 38 deletions(-) diff --git a/pr.codecept.js b/pr.codecept.js index c96e42fc5..1b3ca3766 100644 --- a/pr.codecept.js +++ b/pr.codecept.js @@ -1,5 +1,6 @@ const { Agent } = require('https'); const { pageObjects, getChunks } = require('./codeceptConfigHelper'); +const bootstrapHook = require('./tests/helper/hooks.js'); require('dotenv').config(); @@ -138,7 +139,7 @@ exports.config = { }, }, }, - bootstrap: require('./tests/helper/hooks.js'), + bootstrap: bootstrapHook, teardown: null, hooks: [], gherkin: {}, diff --git a/tests/helper/hooks.js b/tests/helper/hooks.js index 0349163ca..d2102371f 100644 --- a/tests/helper/hooks.js +++ b/tests/helper/hooks.js @@ -1,30 +1,63 @@ const { event, container } = require('codeceptjs'); -// Guard to avoid registering listeners multiple times per worker/process (causes multiple waits in same tests) -if (!global.__pmmGrafanaIframeHookRegistered) { - global.__pmmGrafanaIframeHookRegistered = true; +let hooksRegistered = false; - module.exports = function () { - // Switch to iframe only after amOnPage completes - event.dispatcher.on(event.step.after, async (step) => { - if (!step || step.name !== 'amOnPage') return; +module.exports = function pmmGrafanaIframeHook() { + // Prevent double-registration per worker + if (hooksRegistered) return; - const helper = container.helpers('Playwright'); + hooksRegistered = true; - // Reset to main context to avoid nested frame chains + const helper = container.helpers('Playwright'); + const grafanaIframe = '#grafana-iframe'; + + /** + * Switches execution context to the Grafana iframe. + * Ensures the page is loaded and the iframe is visible before switching. + */ + const switchToGrafana = async () => { + try { + // Reset to main frame first await helper.switchTo(); - await helper.waitForVisible('#grafana-iframe', 30); - await helper.switchTo('#grafana-iframe'); - console.log('Switched to grafana iframe'); - }); + if (helper.page) { + await helper.page.waitForLoadState('domcontentloaded'); + } - // Switch back to main context after each scenario - event.dispatcher.on(event.test.after, async () => { - const helper = container.helpers('Playwright'); + await helper.waitForVisible(grafanaIframe, 60); + await helper.switchTo(grafanaIframe); + } catch (e) { + // eslint-disable-next-line no-console + console.error(`[Hooks] Error switching to Grafana iframe: ${e.message}`); + } + }; + /** + * Resets execution context to the main frame. + */ + const resetContext = async () => { + try { await helper.switchTo(); - }); + } catch (e) { + // Ignore errors if browser is already closed or context is invalid + } + }; + + // Reset context at the start and end of each test to avoid frame nesting issues + event.dispatcher.on(event.test.started, resetContext); + event.dispatcher.on(event.test.after, resetContext); + + // Patch _afterStep to automatically switch to Grafana iframe after navigation + // eslint-disable-next-line no-underscore-dangle + const originalAfterStep = helper._afterStep; + + // eslint-disable-next-line no-underscore-dangle + helper._afterStep = async function pmmAfterStep(step) { + if (originalAfterStep) { + await originalAfterStep.call(this, step); + } + + if (step.name === 'amOnPage') { + await switchToGrafana(); + } }; -} else { - module.exports = function () {}; -} +}; diff --git a/tests/pages/dashboardPage.js b/tests/pages/dashboardPage.js index 0a1de7024..627e41f1b 100644 --- a/tests/pages/dashboardPage.js +++ b/tests/pages/dashboardPage.js @@ -1341,28 +1341,24 @@ module.exports = { }, async expandEachDashboardRow() { - await I.usePlaywrightTo('expanding collapsed rows', async ({ page }) => { - const getCollapsedRowsLocators = async () => await page.locator(this.fields.collapsedDashboardRow).all(); - let collapsedRowsLocators = await getCollapsedRowsLocators(); + let collapsedRows = await I.grabNumberOfVisibleElements(this.fields.collapsedDashboardRow); + let maxTries = 20; - while (collapsedRowsLocators.length > 0) { - await page.keyboard.press('End'); - await collapsedRowsLocators[0].scrollIntoViewIfNeeded(); - await collapsedRowsLocators[0].click(); - collapsedRowsLocators.shift(); - - collapsedRowsLocators = await getCollapsedRowsLocators(); - } - }); + while (collapsedRows > 0 && maxTries > 0) { + I.pressKey('End'); + I.click(locate(this.fields.collapsedDashboardRow).first()); + I.wait(1); + collapsedRows = await I.grabNumberOfVisibleElements(this.fields.collapsedDashboardRow); + // eslint-disable-next-line no-plusplus + maxTries--; + } }, async expandDashboardRow(rowName) { - await I.usePlaywrightTo('Expand collapsed row', async ({ page }) => { - const rowLocator = await page.locator(this.fields.collapsedDashboardRowByName(rowName)); + const rowLocator = this.fields.collapsedDashboardRowByName(rowName); - await rowLocator.scrollIntoViewIfNeeded(); - await rowLocator.click(); - }); + I.scrollTo(rowLocator); + I.click(rowLocator); }, waitForDashboardOpened() { From afbb9c8eac3a1932b3faa3931e00a6fe1fd53d6e Mon Sep 17 00:00:00 2001 From: travagliad <215686151+travagliad@users.noreply.github.com> Date: Sat, 20 Dec 2025 00:45:21 +0000 Subject: [PATCH 04/19] fix more methods --- tests/helper/hooks.js | 75 ++++++++++++++++++++++++++++++++++++ tests/leftNavigation_test.js | 1 + tests/pages/homePage.js | 5 ++- 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/tests/helper/hooks.js b/tests/helper/hooks.js index d2102371f..e240a7faf 100644 --- a/tests/helper/hooks.js +++ b/tests/helper/hooks.js @@ -60,4 +60,79 @@ module.exports = function pmmGrafanaIframeHook() { await switchToGrafana(); } }; + + /** + * Patches grabTextFrom to explicitly use the helper context if available. + * This fixes a specific issue in CodeceptJS Playwright helper where grabTextFrom + * uses this.page.textContent() directly, ignoring the active frame context. + */ + const originalGrabTextFrom = helper.grabTextFrom; + + helper.grabTextFrom = async function pmmGrabTextFrom(locator) { + if (helper.context) { + // Use the context (iframe) directly to find the element + const element = helper.context.locator(locator).first(); + + return element.textContent(); + } + + return originalGrabTextFrom.call(this, locator); + }; + + /** + * Patches waitForText to avoid using waitForFunction on FrameLocator + * when specific context locator is provided. + */ + const originalWaitForText = helper.waitForText; + helper.waitForText = async function pmmWaitForText(text, sec = null, context = null) { + if (helper.context && context) { + const waitTimeout = sec ? sec * 1000 : helper.options.waitForTimeout; + // Use native Playwright text filtering which works with FrameLocator + const element = helper.context.locator(context).filter({ hasText: text }).first(); + await element.waitFor({ state: 'visible', timeout: waitTimeout }); + return; + } + return originalWaitForText.call(this, text, sec, context); + }; + + /** + * Patches waitForDetached to correctly handle XPath locators in FrameLocator context. + * Standard implementation uses waitForFunction which doesn't work with FrameLocator. + */ + const originalWaitForDetached = helper.waitForDetached; + helper.waitForDetached = async function pmmWaitForDetached(locator, sec = null) { + if (helper.context) { + const waitTimeout = sec ? sec * 1000 : helper.options.waitForTimeout; + // Convert CodeceptJS locator to Playwright locator if needed + // but simpler to just let Playwright handle the string directly + // as it supports XPath auto-detection + const locatorString = typeof locator === 'object' ? (locator.xpath || locator.css || locator.toString()) : locator; + + try { + await helper.context.locator(locatorString).first().waitFor({ state: 'detached', timeout: waitTimeout }); + return; + } catch (e) { + // Fallback to original if simple locator fails (though it likely won't) + } + } + return originalWaitForDetached.call(this, locator, sec); + }; + + /** + * Patches usePlaywrightTo to pass the active context (iframe) as 'page' + * if it exists. This allows usePlaywrightTo to work transparently inside iframes. + */ + const originalUsePlaywrightTo = helper.usePlaywrightTo; + helper.usePlaywrightTo = async function pmmUsePlaywrightTo(description, fn) { + if (originalUsePlaywrightTo) { + return originalUsePlaywrightTo.call(this, description, async (args) => { + if (helper.context) { + // Override page with the current context (iframe) + // eslint-disable-next-line no-param-reassign + args.page = helper.context; + } + return fn(args); + }); + } + }; }; diff --git a/tests/leftNavigation_test.js b/tests/leftNavigation_test.js index 29e21ab60..d501c1b69 100644 --- a/tests/leftNavigation_test.js +++ b/tests/leftNavigation_test.js @@ -59,6 +59,7 @@ Scenario( async ({ I, homePage, serverApi }) => { await homePage.open(); + I.switchTo(); I.waitForVisible(homePage.buttons.pmmHelp); I.click(homePage.buttons.pmmHelp); diff --git a/tests/pages/homePage.js b/tests/pages/homePage.js index d887ea3d3..2a7034b7c 100644 --- a/tests/pages/homePage.js +++ b/tests/pages/homePage.js @@ -34,6 +34,7 @@ module.exports = { failedChecksPanelInfo: '[aria-label="Advisors check panel"] i', newsPanelTitleSelector: dashboardPage.graphsLocator('Percona News'), pmmCustomMenu: locate('[data-toggle="dropdown"]').withText('PMM'), + metricTitle: '$header-container', servicesButton: locate('span').withText('Services'), newsPanelContentSelector: locate('.panel-content').inside('[aria-label="Percona News panel"]'), @@ -83,7 +84,7 @@ module.exports = { }, }, buttons: { - pmmHelp: locate('button').withAttr({ 'aria-label': 'Help' }), + pmmHelp: '$navitem-help-list-item', pmmLogs: locate('//a[@href="/logs.zip"]'), }, upgradeMilestones: [ @@ -102,7 +103,7 @@ module.exports = { async open() { I.amOnPage(this.url); - I.waitForElement(this.fields.dashboardHeaderLocator, 60); + I.waitForElement(this.fields.metricTitle, 60); }, async openLeftMenu() { From 052d1ad118b65f4795e6c2fe511c820da3bc1f5b Mon Sep 17 00:00:00 2001 From: travagliad <215686151+travagliad@users.noreply.github.com> Date: Sat, 20 Dec 2025 05:09:49 +0000 Subject: [PATCH 05/19] fix home tests --- pr.codecept.js | 1 + tests/dashboards/verifyHomeDashboards_test.js | 4 - tests/helper/hooks.js | 77 ++++++++++++++++--- 3 files changed, 69 insertions(+), 13 deletions(-) diff --git a/pr.codecept.js b/pr.codecept.js index 1b3ca3766..272bc5850 100644 --- a/pr.codecept.js +++ b/pr.codecept.js @@ -18,6 +18,7 @@ exports.config = { restart: true, show: false, trace: true, + keepTraceForPassedTests: false, browser: 'chromium', windowSize: '1920x1080', timeout: 20000, diff --git a/tests/dashboards/verifyHomeDashboards_test.js b/tests/dashboards/verifyHomeDashboards_test.js index 8caa066d8..0f8955e8d 100644 --- a/tests/dashboards/verifyHomeDashboards_test.js +++ b/tests/dashboards/verifyHomeDashboards_test.js @@ -52,10 +52,6 @@ Data(panels).Scenario( // Wait for tab to open I.wait(2); I.switchToNextTab(); - // need to skip PMM tour modal window due to new tab opening - await dashboardPage.clickUpgradeModal(); - await dashboardPage.clickSkipPmmTour(); - I.waitForElement(dashboardPage.fields.dashboardTitle(dashboardName), 60); I.seeInCurrentUrl(expectedDashboard.clearUrl); diff --git a/tests/helper/hooks.js b/tests/helper/hooks.js index e240a7faf..2459feecf 100644 --- a/tests/helper/hooks.js +++ b/tests/helper/hooks.js @@ -56,11 +56,46 @@ module.exports = function pmmGrafanaIframeHook() { await originalAfterStep.call(this, step); } - if (step.name === 'amOnPage') { + if (['amOnPage', 'switchToNextTab', 'switchToPreviousTab'].includes(step.name)) { await switchToGrafana(); } }; + /** + * Helper function to extract valid Playwright selector from CodeceptJS locator + */ + const getSelector = (locator) => { + let selector = locator; + + if (typeof locator === 'object') { + if (locator.xpath) { + selector = `xpath=${locator.xpath}`; + } else if (locator.css) { + selector = locator.css; + } else if (locator.value && locator.type) { + // Handle cases where it's a Locator instance but properties are internal + if (locator.type === 'xpath') selector = `xpath=${locator.value}`; + else if (locator.type === 'css') selector = locator.value; + else selector = locator.value; + } else { + selector = locator.toString(); + } + } + + if (typeof selector === 'string') { + // Handle stringified CodeceptJS locators (e.g., "{xpath: //...}") + if (selector.startsWith('{xpath:') && selector.endsWith('}')) { + return `xpath=${selector.substring(7, selector.length - 1).trim()}`; + } + + if (selector.startsWith('{css:') && selector.endsWith('}')) { + return selector.substring(5, selector.length - 1).trim(); + } + } + + return selector; + }; + /** * Patches grabTextFrom to explicitly use the helper context if available. * This fixes a specific issue in CodeceptJS Playwright helper where grabTextFrom @@ -70,8 +105,9 @@ module.exports = function pmmGrafanaIframeHook() { helper.grabTextFrom = async function pmmGrabTextFrom(locator) { if (helper.context) { + const selector = getSelector(locator); // Use the context (iframe) directly to find the element - const element = helper.context.locator(locator).first(); + const element = helper.context.locator(selector).first(); return element.textContent(); } @@ -79,19 +115,40 @@ module.exports = function pmmGrafanaIframeHook() { return originalGrabTextFrom.call(this, locator); }; + /** + * Patches grabTextFromAll to explicitly use the helper context if available. + */ + const originalGrabTextFromAll = helper.grabTextFromAll; + + helper.grabTextFromAll = async function pmmGrabTextFromAll(locator) { + if (helper.context) { + const selector = getSelector(locator); + const elements = helper.context.locator(selector); + + return elements.allTextContents(); + } + + return originalGrabTextFromAll.call(this, locator); + }; + /** * Patches waitForText to avoid using waitForFunction on FrameLocator * when specific context locator is provided. */ const originalWaitForText = helper.waitForText; + helper.waitForText = async function pmmWaitForText(text, sec = null, context = null) { if (helper.context && context) { const waitTimeout = sec ? sec * 1000 : helper.options.waitForTimeout; + const selector = getSelector(context); // Use native Playwright text filtering which works with FrameLocator - const element = helper.context.locator(context).filter({ hasText: text }).first(); + const element = helper.context.locator(selector).filter({ hasText: text }).first(); + await element.waitFor({ state: 'visible', timeout: waitTimeout }); + return; } + return originalWaitForText.call(this, text, sec, context); }; @@ -100,21 +157,21 @@ module.exports = function pmmGrafanaIframeHook() { * Standard implementation uses waitForFunction which doesn't work with FrameLocator. */ const originalWaitForDetached = helper.waitForDetached; + helper.waitForDetached = async function pmmWaitForDetached(locator, sec = null) { if (helper.context) { const waitTimeout = sec ? sec * 1000 : helper.options.waitForTimeout; - // Convert CodeceptJS locator to Playwright locator if needed - // but simpler to just let Playwright handle the string directly - // as it supports XPath auto-detection - const locatorString = typeof locator === 'object' ? (locator.xpath || locator.css || locator.toString()) : locator; - + const selector = getSelector(locator); + try { - await helper.context.locator(locatorString).first().waitFor({ state: 'detached', timeout: waitTimeout }); + await helper.context.locator(selector).first().waitFor({ state: 'detached', timeout: waitTimeout }); + return; } catch (e) { // Fallback to original if simple locator fails (though it likely won't) } } + return originalWaitForDetached.call(this, locator, sec); }; @@ -123,6 +180,7 @@ module.exports = function pmmGrafanaIframeHook() { * if it exists. This allows usePlaywrightTo to work transparently inside iframes. */ const originalUsePlaywrightTo = helper.usePlaywrightTo; + helper.usePlaywrightTo = async function pmmUsePlaywrightTo(description, fn) { if (originalUsePlaywrightTo) { return originalUsePlaywrightTo.call(this, description, async (args) => { @@ -131,6 +189,7 @@ module.exports = function pmmGrafanaIframeHook() { // eslint-disable-next-line no-param-reassign args.page = helper.context; } + return fn(args); }); } From e336eee6f1be4e2aa91a268ea4dcd03bc1192991 Mon Sep 17 00:00:00 2001 From: travagliad <215686151+travagliad@users.noreply.github.com> Date: Sat, 20 Dec 2025 05:44:11 +0000 Subject: [PATCH 06/19] fix framelocator in before hooks --- tests/helper/hooks.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/helper/hooks.js b/tests/helper/hooks.js index 2459feecf..551d853a9 100644 --- a/tests/helper/hooks.js +++ b/tests/helper/hooks.js @@ -25,6 +25,10 @@ module.exports = function pmmGrafanaIframeHook() { await helper.waitForVisible(grafanaIframe, 60); await helper.switchTo(grafanaIframe); + + if (helper.page) { + helper.context = helper.page.frameLocator(grafanaIframe); + } } catch (e) { // eslint-disable-next-line no-console console.error(`[Hooks] Error switching to Grafana iframe: ${e.message}`); @@ -37,13 +41,14 @@ module.exports = function pmmGrafanaIframeHook() { const resetContext = async () => { try { await helper.switchTo(); + helper.context = null; } catch (e) { // Ignore errors if browser is already closed or context is invalid } }; // Reset context at the start and end of each test to avoid frame nesting issues - event.dispatcher.on(event.test.started, resetContext); + event.dispatcher.on(event.test.before, resetContext); event.dispatcher.on(event.test.after, resetContext); // Patch _afterStep to automatically switch to Grafana iframe after navigation From 43895a4be186bd14d973efc9636278b902156958 Mon Sep 17 00:00:00 2001 From: travagliad <215686151+travagliad@users.noreply.github.com> Date: Sat, 20 Dec 2025 06:05:29 +0000 Subject: [PATCH 07/19] fixes more methods that can't handle frames --- tests/helper/hooks.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/helper/hooks.js b/tests/helper/hooks.js index 551d853a9..1e465dc7d 100644 --- a/tests/helper/hooks.js +++ b/tests/helper/hooks.js @@ -61,7 +61,7 @@ module.exports = function pmmGrafanaIframeHook() { await originalAfterStep.call(this, step); } - if (['amOnPage', 'switchToNextTab', 'switchToPreviousTab'].includes(step.name)) { + if (['amOnPage', 'switchToNextTab', 'switchToPreviousTab', 'refreshPage'].includes(step.name)) { await switchToGrafana(); } }; @@ -96,6 +96,11 @@ module.exports = function pmmGrafanaIframeHook() { if (selector.startsWith('{css:') && selector.endsWith('}')) { return selector.substring(5, selector.length - 1).trim(); } + + // Handle Custom Locator plugin strategy ($) + if (selector.startsWith('$')) { + return `[data-testid="${selector.substring(1)}"]`; + } } return selector; @@ -180,6 +185,7 @@ module.exports = function pmmGrafanaIframeHook() { return originalWaitForDetached.call(this, locator, sec); }; + /** * Patches usePlaywrightTo to pass the active context (iframe) as 'page' * if it exists. This allows usePlaywrightTo to work transparently inside iframes. From a75718c412ae9dacc5b5e0e5229b23e2f9d4fa44 Mon Sep 17 00:00:00 2001 From: travagliad <215686151+travagliad@users.noreply.github.com> Date: Sat, 20 Dec 2025 07:11:43 +0000 Subject: [PATCH 08/19] implement pressKey override --- tests/helper/hooks.js | 45 +++++++++++++++++++ .../queryAnalytics/queryAnalyticsFilters.js | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/tests/helper/hooks.js b/tests/helper/hooks.js index 1e465dc7d..88b332fe9 100644 --- a/tests/helper/hooks.js +++ b/tests/helper/hooks.js @@ -66,6 +66,51 @@ module.exports = function pmmGrafanaIframeHook() { } }; + /** + * Patches pressKey to always use the main page's keyboard. + * This is necessary because helper.context is set to a FrameLocator for iframes, + * which does not support keyboard methods, causing pressKey to fail. + */ + const originalPressKey = helper.pressKey; + + helper.pressKey = async function pmmPressKey(key) { + const getPage = () => { + if (helper.page && helper.page.keyboard) return helper.page; + if (helper.browserContext && helper.browserContext.pages().length > 0) { + return helper.browserContext.pages()[0]; + } + return helper.page; + }; + + const page = getPage(); + + if (helper.context && page && page.keyboard) { + const modifiers = ['Control', 'Command', 'Alt', 'Shift', 'Meta']; + + if (Array.isArray(key) && key.length === 2 && modifiers.includes(key[0])) { + await page.keyboard.down(key[0]); + await page.keyboard.press(key[1]); + await page.keyboard.up(key[0]); + + return; + } + + if (Array.isArray(key)) { + for (const k of key) { + await helper.pressKey(k); + } + + return; + } + + await page.keyboard.press(key); + + return; + } + + return originalPressKey.call(this, key); + }; + /** * Helper function to extract valid Playwright selector from CodeceptJS locator */ diff --git a/tests/pages/components/queryAnalytics/queryAnalyticsFilters.js b/tests/pages/components/queryAnalytics/queryAnalyticsFilters.js index ec34a3470..039cd4faf 100644 --- a/tests/pages/components/queryAnalytics/queryAnalyticsFilters.js +++ b/tests/pages/components/queryAnalytics/queryAnalyticsFilters.js @@ -50,7 +50,7 @@ class QueryAnalyticsFilters { await locator.waitFor({ state: 'attached' }); await locator.type(filterName); - await page.waitForTimeout(200); + await new Promise((resolve) => setTimeout(resolve, 200)); }); } From 1b95d349579adba000b9354483ea5e710259394c Mon Sep 17 00:00:00 2001 From: travagliad <215686151+travagliad@users.noreply.github.com> Date: Sat, 20 Dec 2025 09:05:19 +0000 Subject: [PATCH 09/19] fix QAN tests --- tests/helper/hooks.js | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/helper/hooks.js b/tests/helper/hooks.js index 88b332fe9..0056111a0 100644 --- a/tests/helper/hooks.js +++ b/tests/helper/hooks.js @@ -76,9 +76,11 @@ module.exports = function pmmGrafanaIframeHook() { helper.pressKey = async function pmmPressKey(key) { const getPage = () => { if (helper.page && helper.page.keyboard) return helper.page; + if (helper.browserContext && helper.browserContext.pages().length > 0) { return helper.browserContext.pages()[0]; } + return helper.page; }; @@ -230,6 +232,32 @@ module.exports = function pmmGrafanaIframeHook() { return originalWaitForDetached.call(this, locator, sec); }; + /** + * Patches waitForValue to correctly handle FrameLocator context. + * Standard implementation uses waitForFunction which doesn't work with FrameLocator. + */ + const originalWaitForValue = helper.waitForValue; + + helper.waitForValue = async function pmmWaitForValue(field, value, sec = null) { + if (helper.context) { + const waitTimeout = sec ? sec * 1000 : helper.options.waitForTimeout; + const selector = getSelector(field); + const locator = helper.context.locator(selector).first(); + const startTime = Date.now(); + + while (Date.now() - startTime < waitTimeout) { + const val = await locator.inputValue().catch(() => ''); + + if (val.includes(value)) return; + + await new Promise((r) => setTimeout(r, 100)); + } + + throw new Error(`Wait for value "${value}" failed for field ${field}`); + } + + return originalWaitForValue.call(this, field, value, sec); + }; /** * Patches usePlaywrightTo to pass the active context (iframe) as 'page' @@ -244,6 +272,12 @@ module.exports = function pmmGrafanaIframeHook() { // Override page with the current context (iframe) // eslint-disable-next-line no-param-reassign args.page = helper.context; + + // Polyfill evaluate if missing (FrameLocator) + if (!args.page.evaluate) { + // eslint-disable-next-line no-param-reassign + args.page.evaluate = async (func, arg) => args.page.locator('body').evaluate(func, arg); + } } return fn(args); From f7e26a667b8abae369d97949ee1b503e236a8b00 Mon Sep 17 00:00:00 2001 From: travagliad <215686151+travagliad@users.noreply.github.com> Date: Sat, 20 Dec 2025 09:27:14 +0000 Subject: [PATCH 10/19] most qan tests are fixed with these, timerange_test remain to be fixed --- tests/helper/hooks.js | 62 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/tests/helper/hooks.js b/tests/helper/hooks.js index 0056111a0..7c9f6f5f4 100644 --- a/tests/helper/hooks.js +++ b/tests/helper/hooks.js @@ -233,16 +233,23 @@ module.exports = function pmmGrafanaIframeHook() { }; /** - * Patches waitForValue to correctly handle FrameLocator context. - * Standard implementation uses waitForFunction which doesn't work with FrameLocator. - */ + + * Patches waitForValue to correctly handle FrameLocator context. + + * Standard implementation uses waitForFunction which doesn't work with FrameLocator. + + */ + const originalWaitForValue = helper.waitForValue; helper.waitForValue = async function pmmWaitForValue(field, value, sec = null) { - if (helper.context) { + if (this.context) { const waitTimeout = sec ? sec * 1000 : helper.options.waitForTimeout; + const selector = getSelector(field); - const locator = helper.context.locator(selector).first(); + + const locator = this.context.locator(selector).first(); + const startTime = Date.now(); while (Date.now() - startTime < waitTimeout) { @@ -260,9 +267,34 @@ module.exports = function pmmGrafanaIframeHook() { }; /** - * Patches usePlaywrightTo to pass the active context (iframe) as 'page' - * if it exists. This allows usePlaywrightTo to work transparently inside iframes. - */ + + * Patches moveCursorTo to correctly handle FrameLocator context. + + * Standard implementation may use page.mouse which doesn't work with FrameLocator. + + */ + + const originalMoveCursorTo = helper.moveCursorTo; + + helper.moveCursorTo = async function pmmMoveCursorTo(locator, offsetX = 0, offsetY = 0) { + if (this.context) { + const element = this.context.locator(getSelector(locator)).first(); + + await element.evaluate((el) => el.scrollIntoView({ block: 'center', inline: 'center' })).catch(() => {}); + await element.hover({ position: { x: offsetX, y: offsetY }, force: true }); + + return; + } + + return originalMoveCursorTo.call(this, locator, offsetX, offsetY); + }; /** + + * Patches usePlaywrightTo to pass the active context (iframe) as 'page' + + * if it exists. This allows usePlaywrightTo to work transparently inside iframes. + + */ + const originalUsePlaywrightTo = helper.usePlaywrightTo; helper.usePlaywrightTo = async function pmmUsePlaywrightTo(description, fn) { @@ -270,13 +302,23 @@ module.exports = function pmmGrafanaIframeHook() { return originalUsePlaywrightTo.call(this, description, async (args) => { if (helper.context) { // Override page with the current context (iframe) + // eslint-disable-next-line no-param-reassign + args.page = helper.context; // Polyfill evaluate if missing (FrameLocator) + if (!args.page.evaluate) { - // eslint-disable-next-line no-param-reassign - args.page.evaluate = async (func, arg) => args.page.locator('body').evaluate(func, arg); + Object.defineProperty(args.page, 'evaluate', { + + value: async (func, arg) => args.page.locator('body').evaluate(func, arg), + + writable: true, + + configurable: true, + + }); } } From 69722aa6d2f8e90e9b083fa982e21ff2ec73fdb7 Mon Sep 17 00:00:00 2001 From: travagliad <215686151+travagliad@users.noreply.github.com> Date: Sat, 20 Dec 2025 18:52:34 +0000 Subject: [PATCH 11/19] remove tracing errors --- tests/QAN/timerange_test.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/QAN/timerange_test.js b/tests/QAN/timerange_test.js index 820073f3c..498d27580 100644 --- a/tests/QAN/timerange_test.js +++ b/tests/QAN/timerange_test.js @@ -4,7 +4,11 @@ const assert = require('assert'); Feature('QAN timerange').retry(1); Before(async ({ I, queryAnalyticsPage, codeceptjsConfig }) => { - I.restartBrowser({ permissions: ['clipboard-read', 'clipboard-write'], origin: codeceptjsConfig.config.helpers.Playwright.url }); + await I.usePlaywrightTo('Grant Permissions', async ({ browserContext }) => { + await browserContext.grantPermissions(['clipboard-read', 'clipboard-write'], { + origin: codeceptjsConfig.config.helpers.Playwright.url, + }); + }); await I.usePlaywrightTo('Mock BE Responses', async ({ page }) => { await page.route('**/v1/users/me', (route) => route.fulfill({ status: 200, From 7af0158aef2165f7989dac1c5e95ea10544e1434 Mon Sep 17 00:00:00 2001 From: travagliad <215686151+travagliad@users.noreply.github.com> Date: Sat, 20 Dec 2025 22:37:35 +0000 Subject: [PATCH 12/19] fix qan timetables --- tests/helper/hooks.js | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/helper/hooks.js b/tests/helper/hooks.js index 7c9f6f5f4..37c46e7c0 100644 --- a/tests/helper/hooks.js +++ b/tests/helper/hooks.js @@ -40,6 +40,15 @@ module.exports = function pmmGrafanaIframeHook() { */ const resetContext = async () => { try { + if (helper.browserContext) { + const pages = helper.browserContext.pages(); + + if (pages.length > 0) { + // eslint-disable-next-line + helper.page = pages[0]; + } + } + await helper.switchTo(); helper.context = null; } catch (e) { @@ -51,6 +60,34 @@ module.exports = function pmmGrafanaIframeHook() { event.dispatcher.on(event.test.before, resetContext); event.dispatcher.on(event.test.after, resetContext); + /** + * Patches navigation methods to always reset context to the main frame first. + * This prevents "is not a function" errors when the helper is focused on an iframe. + */ + const originalAmOnPage = helper.amOnPage; + + helper.amOnPage = async function pmmAmOnPage(...args) { + await resetContext(); + + return originalAmOnPage.call(this, ...args); + }; + + const originalRefreshPage = helper.refreshPage; + + helper.refreshPage = async function pmmRefreshPage(...args) { + await resetContext(); + + return originalRefreshPage.call(this, ...args); + }; + + const originalOpenNewTab = helper.openNewTab; + + helper.openNewTab = async function pmmOpenNewTab(...args) { + await resetContext(); + + return originalOpenNewTab.call(this, ...args); + }; + // Patch _afterStep to automatically switch to Grafana iframe after navigation // eslint-disable-next-line no-underscore-dangle const originalAfterStep = helper._afterStep; From 4b0e904d1797f6acccc8a4443d619b1971bf97b4 Mon Sep 17 00:00:00 2001 From: travagliad <215686151+travagliad@users.noreply.github.com> Date: Sun, 21 Dec 2025 03:30:17 +0000 Subject: [PATCH 13/19] fix qan filters --- tests/helper/grafana_helper.js | 7 ++++--- tests/leftNavigation_test.js | 13 +++++++++++++ .../queryAnalytics/queryAnalyticsFilters.js | 8 +------- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/tests/helper/grafana_helper.js b/tests/helper/grafana_helper.js index c93479125..1884cd6be 100644 --- a/tests/helper/grafana_helper.js +++ b/tests/helper/grafana_helper.js @@ -295,7 +295,7 @@ class Grafana extends Helper { await Playwright.page.locator(dropdownLocator).first().waitFor({ state: 'attached', timeout: 5000 }); await Playwright.page.locator(dropdownLocator).first().click(); - await Playwright.page.waitForTimeout(500); + await Playwright.wait(0.5); const optionLocator = Playwright.page.locator('div[role="option"] span'); @@ -310,10 +310,11 @@ class Grafana extends Helper { async isElementDisplayed(locator, timeoutInSeconds = 60) { const { Playwright } = this.helpers; - const elementLocator = Playwright.page.locator(locate(locator).toXPath()); + const context = Playwright.context || Playwright.page; + const elementLocator = context.locator(locate(locator).toXPath()); for (let i = 0; i < timeoutInSeconds; i++) { - await Playwright.page.waitForTimeout(1000); + await Playwright.wait(1); if (await elementLocator.first().isVisible()) { return true; diff --git a/tests/leftNavigation_test.js b/tests/leftNavigation_test.js index d501c1b69..dabdaefe3 100644 --- a/tests/leftNavigation_test.js +++ b/tests/leftNavigation_test.js @@ -25,6 +25,17 @@ Feature('Left Navigation menu tests').retry(1); Before(async ({ I }) => { await I.Authorize(); + await I.usePlaywrightTo('Mock Updates for Help Menu', async ({ page }) => { + await page.route('**/v1/server/updates?force=**', (route) => route.fulfill({ + status: 200, + body: JSON.stringify({ + last_check: new Date().toISOString(), + installed: { timestamp: new Date().toISOString() }, + latest: { timestamp: new Date().toISOString() }, + update_available: false, + }), + })); + }); }); /** Data(sidebar).Scenario( @@ -61,8 +72,10 @@ Scenario( I.switchTo(); I.waitForVisible(homePage.buttons.pmmHelp); + I.wait(2); I.click(homePage.buttons.pmmHelp); + I.waitForVisible(homePage.buttons.pmmLogs, 5); const path = await I.downloadFile(homePage.buttons.pmmLogs); await I.seeEntriesInZip(path, ['pmm-agent.yaml', 'pmm-managed.log', 'pmm-agent.log']); diff --git a/tests/pages/components/queryAnalytics/queryAnalyticsFilters.js b/tests/pages/components/queryAnalytics/queryAnalyticsFilters.js index 039cd4faf..6c5109e4b 100644 --- a/tests/pages/components/queryAnalytics/queryAnalyticsFilters.js +++ b/tests/pages/components/queryAnalytics/queryAnalyticsFilters.js @@ -111,15 +111,9 @@ class QueryAnalyticsFilters { selectContainFilter(filterName) { I.waitForVisible(this.fields.groupHeaders, 30); - I.click(this.fields.groupHeaders); I.fillField(this.fields.filterBy, filterName); I.waitForVisible(this.fields.filterByName(filterName)); - I.usePlaywrightTo('Select QAN Filter', async ({ page }) => { - const locator = await page.locator(this.fields.filterByName(filterName).value); - - await locator.first().waitFor({ state: 'attached' }); - await locator.first().click(); - }); + I.click(this.fields.filterByName(filterName)); queryAnalyticsPage.waitForLoaded(); I.click(this.fields.filterBy); adminPage.customClearField(this.fields.filterBy); From 9391632f6728cf13aa1b49b207896e4fa543ef95 Mon Sep 17 00:00:00 2001 From: travagliad <215686151+travagliad@users.noreply.github.com> Date: Sun, 21 Dec 2025 12:09:52 +0000 Subject: [PATCH 14/19] fix mysql test --- tests/dashboards/verifyMysqlDashboards_test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/dashboards/verifyMysqlDashboards_test.js b/tests/dashboards/verifyMysqlDashboards_test.js index 261b4e0ce..ac2eb0d1d 100644 --- a/tests/dashboards/verifyMysqlDashboards_test.js +++ b/tests/dashboards/verifyMysqlDashboards_test.js @@ -153,7 +153,7 @@ Scenario.skip( Scenario( 'PMM-T324 - Verify MySQL - MySQL User Details dashboard @nightly @dashboards', async ({ I, dashboardPage }) => { - const serviceName = serviceList.find((service) => service.name.includes('ps_pmm')); + const serviceName = serviceList.find((service) => service.name.includes('ps_pmm')).name; const url = I.buildUrlWithParams(dashboardPage.mysqlUserDetailsDashboard.clearUrl, { service_name: serviceName, from: 'now-5m' }); I.amOnPage(url); From 7433199583e0ca06abaa0ee0834012eb329eb4a3 Mon Sep 17 00:00:00 2001 From: travagliad <215686151+travagliad@users.noreply.github.com> Date: Sun, 21 Dec 2025 12:12:57 +0000 Subject: [PATCH 15/19] fix pt-summary test --- tests/configuration/permissions_test.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/configuration/permissions_test.js b/tests/configuration/permissions_test.js index 3219cafa7..763b4a6e2 100644 --- a/tests/configuration/permissions_test.js +++ b/tests/configuration/permissions_test.js @@ -172,7 +172,7 @@ Data(editorRole).Scenario( }, ); -Data(ptSummaryRoleCheck).Scenario( +Data(ptSummaryRoleCheck).only.Scenario( 'PMM-T334 + PMM-T420 + PMM-T1726 - Verify Home dashboard and the pt-summary with viewer or editor role ' + '@nightly @gssapi-nightly @grafana-pr', async ({ @@ -183,7 +183,6 @@ Data(ptSummaryRoleCheck).Scenario( await I.Authorize(username, password); I.amOnPage(homePage.url); I.waitForVisible(homePage.fields.checksPanelSelector, 30); - I.waitForVisible(homePage.fields.pmmCustomMenu, 30); I.waitForVisible(dashboardPage.graphsLocator('Monitored Nodes'), 30); I.waitForVisible(dashboardPage.graphsLocator('Monitored DB Services'), 30); From 263bb7e72de4d645953985aabed67f1f7d3238ca Mon Sep 17 00:00:00 2001 From: travagliad <215686151+travagliad@users.noreply.github.com> Date: Sun, 21 Dec 2025 12:27:36 +0000 Subject: [PATCH 16/19] fix details test --- tests/QAN/details_test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/QAN/details_test.js b/tests/QAN/details_test.js index da96bc065..4b168b812 100644 --- a/tests/QAN/details_test.js +++ b/tests/QAN/details_test.js @@ -53,7 +53,7 @@ if (isJenkinsGssapiJob) { } else { databaseEnvironments = [ { serviceName: 'ps_', queryTypes: ['SELECT s.first_name', 'INSERT INTO classes', 'DELETE FROM students', 'CREATE TABLE classes'], cluster: 'ps-single-dev-cluster' }, - { serviceName: 'pdpgsql_', queryTypes: ['SELECT s.first_name', 'INSERT INTO classes', 'DELETE FROM', 'CREATE TABLE classes '], cluster: '' }, + { serviceName: 'pdpgsql_pmm_17', queryTypes: ['SELECT s.first_name', 'INSERT INTO classes', 'DELETE FROM', 'CREATE TABLE classes '], cluster: '' }, { serviceName: 'rs101', queryTypes: ['db.students', 'db.runCommand', 'db.test'], cluster: 'replicaset' }, ]; } From 90f9af4a2ead6a76189c54c9c9af4f5f8f3a0711 Mon Sep 17 00:00:00 2001 From: travagliad <215686151+travagliad@users.noreply.github.com> Date: Sun, 21 Dec 2025 17:26:33 +0000 Subject: [PATCH 17/19] fix permission test --- tests/configuration/permissions_test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/configuration/permissions_test.js b/tests/configuration/permissions_test.js index 763b4a6e2..5d86a788b 100644 --- a/tests/configuration/permissions_test.js +++ b/tests/configuration/permissions_test.js @@ -172,7 +172,7 @@ Data(editorRole).Scenario( }, ); -Data(ptSummaryRoleCheck).only.Scenario( +Data(ptSummaryRoleCheck).Scenario( 'PMM-T334 + PMM-T420 + PMM-T1726 - Verify Home dashboard and the pt-summary with viewer or editor role ' + '@nightly @gssapi-nightly @grafana-pr', async ({ From 5ea874464c95c8ff7c41e1bbf02922373d9f6766 Mon Sep 17 00:00:00 2001 From: travagliad <215686151+travagliad@users.noreply.github.com> Date: Tue, 23 Dec 2025 13:42:38 +0000 Subject: [PATCH 18/19] hooks reorganized --- tests/helper/hooks.js | 461 +++++++++++++-------------------------- tests/pages/loginPage.js | 2 + 2 files changed, 148 insertions(+), 315 deletions(-) diff --git a/tests/helper/hooks.js b/tests/helper/hooks.js index 37c46e7c0..e90a70c70 100644 --- a/tests/helper/hooks.js +++ b/tests/helper/hooks.js @@ -1,125 +1,118 @@ +/* eslint-disable func-names */ +/* eslint-disable no-underscore-dangle */ const { event, container } = require('codeceptjs'); -let hooksRegistered = false; - -module.exports = function pmmGrafanaIframeHook() { - // Prevent double-registration per worker - if (hooksRegistered) return; - - hooksRegistered = true; - - const helper = container.helpers('Playwright'); - const grafanaIframe = '#grafana-iframe'; - - /** - * Switches execution context to the Grafana iframe. - * Ensures the page is loaded and the iframe is visible before switching. - */ - const switchToGrafana = async () => { - try { - // Reset to main frame first - await helper.switchTo(); - if (helper.page) { - await helper.page.waitForLoadState('domcontentloaded'); - } - - await helper.waitForVisible(grafanaIframe, 60); - await helper.switchTo(grafanaIframe); - - if (helper.page) { - helper.context = helper.page.frameLocator(grafanaIframe); +function getSelector(locator) { + let selector = locator; + + if (typeof locator === 'object') { + if (locator.xpath) { + selector = `xpath=${locator.xpath}`; + } else if (locator.css) { + selector = locator.css; + } else if (locator.value && locator.type) { + if (locator.type === 'xpath') { + selector = `xpath=${locator.value}`; + } else if (locator.type === 'css') { + selector = locator.value; + } else { + selector = locator.value; } - } catch (e) { - // eslint-disable-next-line no-console - console.error(`[Hooks] Error switching to Grafana iframe: ${e.message}`); + } else { + selector = locator.toString(); } - }; + } - /** - * Resets execution context to the main frame. - */ - const resetContext = async () => { - try { - if (helper.browserContext) { - const pages = helper.browserContext.pages(); + if (typeof selector === 'string') { + if (selector.startsWith('{xpath:') && selector.endsWith('}')) { + return `xpath=${selector.substring(7, selector.length - 1).trim()}`; + } - if (pages.length > 0) { - // eslint-disable-next-line - helper.page = pages[0]; - } - } + if (selector.startsWith('{css:') && selector.endsWith('}')) { + return selector.substring(5, selector.length - 1).trim(); + } - await helper.switchTo(); - helper.context = null; - } catch (e) { - // Ignore errors if browser is already closed or context is invalid + if (selector.startsWith('$')) { + return `[data-testid="${selector.substring(1)}"]`; } - }; + } - // Reset context at the start and end of each test to avoid frame nesting issues - event.dispatcher.on(event.test.before, resetContext); - event.dispatcher.on(event.test.after, resetContext); + return selector; +} - /** - * Patches navigation methods to always reset context to the main frame first. - * This prevents "is not a function" errors when the helper is focused on an iframe. - */ - const originalAmOnPage = helper.amOnPage; +async function switchToGrafana(helper) { + const grafanaIframe = '#grafana-iframe'; - helper.amOnPage = async function pmmAmOnPage(...args) { - await resetContext(); + await helper.switchTo(); + if (helper.page) await helper.page.waitForLoadState('domcontentloaded'); - return originalAmOnPage.call(this, ...args); - }; + await helper.waitForVisible(grafanaIframe, 60); + await helper.switchTo(grafanaIframe); - const originalRefreshPage = helper.refreshPage; + if (helper.page) helper.context = helper.page.frameLocator(grafanaIframe); +} - helper.refreshPage = async function pmmRefreshPage(...args) { - await resetContext(); +async function resetContext(helper) { + if (helper.browserContext) { + const pages = helper.browserContext.pages(); - return originalRefreshPage.call(this, ...args); - }; + if (pages.length > 0) [helper.page] = pages; + } - const originalOpenNewTab = helper.openNewTab; + await helper.switchTo(); + helper.context = null; +} - helper.openNewTab = async function pmmOpenNewTab(...args) { - await resetContext(); +function applyOverride(helper, methodName, wrapperFunction) { + const originalMethod = helper[methodName]; - return originalOpenNewTab.call(this, ...args); + helper[methodName] = async function pmmMethodWrapper(...args) { + return wrapperFunction.apply(this, [originalMethod, ...args]); }; +} - // Patch _afterStep to automatically switch to Grafana iframe after navigation - // eslint-disable-next-line no-underscore-dangle - const originalAfterStep = helper._afterStep; +function applyContextOverride(helper, methodName, contextAction) { + applyOverride(helper, methodName, async function (original, ...args) { + if (helper.context) return contextAction.apply(this, args); - // eslint-disable-next-line no-underscore-dangle - helper._afterStep = async function pmmAfterStep(step) { - if (originalAfterStep) { - await originalAfterStep.call(this, step); - } + return original.apply(this, args); + }); +} - if (['amOnPage', 'switchToNextTab', 'switchToPreviousTab', 'refreshPage'].includes(step.name)) { - await switchToGrafana(); - } - }; +module.exports = function pmmGrafanaIframeHook() { + const helper = container.helpers('Playwright'); + const navigationMethods = ['amOnPage', 'refreshPage', 'openNewTab', 'switchToNextTab', 'switchToPreviousTab']; + const noIframeMethods = ['openNewTab']; + const noIframeUrls = ['login', 'help', 'updates']; - /** - * Patches pressKey to always use the main page's keyboard. - * This is necessary because helper.context is set to a FrameLocator for iframes, - * which does not support keyboard methods, causing pressKey to fail. - */ - const originalPressKey = helper.pressKey; + navigationMethods.forEach((methodName) => { + applyOverride(helper, methodName, async function (original, ...args) { + await resetContext(helper); + await original.apply(this, args); - helper.pressKey = async function pmmPressKey(key) { - const getPage = () => { + if (methodName === 'amOnPage' && noIframeUrls.some((url) => args[0].includes(url))) return; + + if (noIframeMethods.includes(methodName)) return; + + await switchToGrafana(helper); + }); + }); + applyOverride(helper, 'pressKey', async function (original, key) { + function getPage() { if (helper.page && helper.page.keyboard) return helper.page; - if (helper.browserContext && helper.browserContext.pages().length > 0) { - return helper.browserContext.pages()[0]; + if (helper.browserContext) { + const pages = helper.browserContext.pages(); + + if (pages.length > 0) { + const [firstPage] = pages; + + return firstPage; + } } return helper.page; - }; + } const page = getPage(); @@ -130,237 +123,75 @@ module.exports = function pmmGrafanaIframeHook() { await page.keyboard.down(key[0]); await page.keyboard.press(key[1]); await page.keyboard.up(key[0]); - - return; - } - - if (Array.isArray(key)) { - for (const k of key) { - await helper.pressKey(k); + } else if (Array.isArray(key)) { + for (const keyItem of key) { + await helper.pressKey(keyItem); } - - return; - } - - await page.keyboard.press(key); - - return; - } - - return originalPressKey.call(this, key); - }; - - /** - * Helper function to extract valid Playwright selector from CodeceptJS locator - */ - const getSelector = (locator) => { - let selector = locator; - - if (typeof locator === 'object') { - if (locator.xpath) { - selector = `xpath=${locator.xpath}`; - } else if (locator.css) { - selector = locator.css; - } else if (locator.value && locator.type) { - // Handle cases where it's a Locator instance but properties are internal - if (locator.type === 'xpath') selector = `xpath=${locator.value}`; - else if (locator.type === 'css') selector = locator.value; - else selector = locator.value; } else { - selector = locator.toString(); - } - } - - if (typeof selector === 'string') { - // Handle stringified CodeceptJS locators (e.g., "{xpath: //...}") - if (selector.startsWith('{xpath:') && selector.endsWith('}')) { - return `xpath=${selector.substring(7, selector.length - 1).trim()}`; - } - - if (selector.startsWith('{css:') && selector.endsWith('}')) { - return selector.substring(5, selector.length - 1).trim(); - } - - // Handle Custom Locator plugin strategy ($) - if (selector.startsWith('$')) { - return `[data-testid="${selector.substring(1)}"]`; + await page.keyboard.press(key); } - } - - return selector; - }; - - /** - * Patches grabTextFrom to explicitly use the helper context if available. - * This fixes a specific issue in CodeceptJS Playwright helper where grabTextFrom - * uses this.page.textContent() directly, ignoring the active frame context. - */ - const originalGrabTextFrom = helper.grabTextFrom; - - helper.grabTextFrom = async function pmmGrabTextFrom(locator) { - if (helper.context) { - const selector = getSelector(locator); - // Use the context (iframe) directly to find the element - const element = helper.context.locator(selector).first(); - - return element.textContent(); - } - - return originalGrabTextFrom.call(this, locator); - }; - - /** - * Patches grabTextFromAll to explicitly use the helper context if available. - */ - const originalGrabTextFromAll = helper.grabTextFromAll; - - helper.grabTextFromAll = async function pmmGrabTextFromAll(locator) { - if (helper.context) { - const selector = getSelector(locator); - const elements = helper.context.locator(selector); - - return elements.allTextContents(); - } - - return originalGrabTextFromAll.call(this, locator); - }; - - /** - * Patches waitForText to avoid using waitForFunction on FrameLocator - * when specific context locator is provided. - */ - const originalWaitForText = helper.waitForText; - - helper.waitForText = async function pmmWaitForText(text, sec = null, context = null) { - if (helper.context && context) { - const waitTimeout = sec ? sec * 1000 : helper.options.waitForTimeout; - const selector = getSelector(context); - // Use native Playwright text filtering which works with FrameLocator - const element = helper.context.locator(selector).filter({ hasText: text }).first(); - - await element.waitFor({ state: 'visible', timeout: waitTimeout }); return; } - return originalWaitForText.call(this, text, sec, context); - }; - - /** - * Patches waitForDetached to correctly handle XPath locators in FrameLocator context. - * Standard implementation uses waitForFunction which doesn't work with FrameLocator. - */ - const originalWaitForDetached = helper.waitForDetached; - - helper.waitForDetached = async function pmmWaitForDetached(locator, sec = null) { - if (helper.context) { - const waitTimeout = sec ? sec * 1000 : helper.options.waitForTimeout; - const selector = getSelector(locator); - - try { - await helper.context.locator(selector).first().waitFor({ state: 'detached', timeout: waitTimeout }); - - return; - } catch (e) { - // Fallback to original if simple locator fails (though it likely won't) - } + await original.call(this, key); + }); + applyContextOverride(helper, 'grabTextFrom', async (locator) => helper.context.locator(getSelector(locator)).first().textContent()); + applyContextOverride(helper, 'grabTextFromAll', async (locator) => helper.context.locator(getSelector(locator)).allTextContents()); + applyContextOverride(helper, 'waitForText', async (text, seconds = null, context = null) => { + await helper.context.locator(getSelector(context)).filter({ hasText: text }).first().waitFor({ + state: 'visible', + timeout: seconds ? seconds * 1000 : helper.options.waitForTimeout, + }); + }); + applyContextOverride(helper, 'waitForDetached', async (locator, seconds = null) => { + await helper.context + .locator(getSelector(locator)) + .first() + .waitFor({ state: 'detached', timeout: seconds ? seconds * 1000 : helper.options.waitForTimeout }); + }); + applyContextOverride(helper, 'waitForValue', async (field, value, seconds = null) => { + const waitTimeout = seconds ? seconds * 1000 : helper.options.waitForTimeout; + const locator = helper.context.locator(getSelector(field)).first(); + const startTime = Date.now(); + + while (Date.now() < startTime + waitTimeout) { + const inputValue = await locator.inputValue().catch(() => ''); + + if (inputValue.includes(value)) return; + + await new Promise((resolve) => { setTimeout(resolve, 100); }); } - - return originalWaitForDetached.call(this, locator, sec); - }; - - /** - - * Patches waitForValue to correctly handle FrameLocator context. - - * Standard implementation uses waitForFunction which doesn't work with FrameLocator. - - */ - - const originalWaitForValue = helper.waitForValue; - - helper.waitForValue = async function pmmWaitForValue(field, value, sec = null) { - if (this.context) { - const waitTimeout = sec ? sec * 1000 : helper.options.waitForTimeout; - - const selector = getSelector(field); - - const locator = this.context.locator(selector).first(); - - const startTime = Date.now(); - - while (Date.now() - startTime < waitTimeout) { - const val = await locator.inputValue().catch(() => ''); - - if (val.includes(value)) return; - - await new Promise((r) => setTimeout(r, 100)); + throw new Error(`Wait for value "${value}" failed for field ${field}`); + }); + applyContextOverride(helper, 'moveCursorTo', async (locator, offsetX = 0, offsetY = 0) => { + const element = helper.context.locator(getSelector(locator)).first(); + + await element.evaluate((elementInstance) => { + elementInstance.scrollIntoView({ block: 'center', inline: 'center' }); + }); + await element.hover({ position: { x: offsetX, y: offsetY }, force: true }); + }); + applyOverride(helper, 'usePlaywrightTo', async function (original, description, callback) { + return original.call(this, description, async (args) => { + if (helper.context) { + args.page = helper.context; + + if (!args.page.evaluate) { + Object.defineProperty(args.page, 'evaluate', { + async value(functionToExecute, argument) { + return args.page.locator('body').evaluate(functionToExecute, argument); + }, + writable: true, + configurable: true, + }); + } } - throw new Error(`Wait for value "${value}" failed for field ${field}`); - } - - return originalWaitForValue.call(this, field, value, sec); - }; - - /** - - * Patches moveCursorTo to correctly handle FrameLocator context. - - * Standard implementation may use page.mouse which doesn't work with FrameLocator. - - */ - - const originalMoveCursorTo = helper.moveCursorTo; - - helper.moveCursorTo = async function pmmMoveCursorTo(locator, offsetX = 0, offsetY = 0) { - if (this.context) { - const element = this.context.locator(getSelector(locator)).first(); - - await element.evaluate((el) => el.scrollIntoView({ block: 'center', inline: 'center' })).catch(() => {}); - await element.hover({ position: { x: offsetX, y: offsetY }, force: true }); - - return; - } - - return originalMoveCursorTo.call(this, locator, offsetX, offsetY); - }; /** - - * Patches usePlaywrightTo to pass the active context (iframe) as 'page' + return callback(args); + }); + }); - * if it exists. This allows usePlaywrightTo to work transparently inside iframes. - - */ - - const originalUsePlaywrightTo = helper.usePlaywrightTo; - - helper.usePlaywrightTo = async function pmmUsePlaywrightTo(description, fn) { - if (originalUsePlaywrightTo) { - return originalUsePlaywrightTo.call(this, description, async (args) => { - if (helper.context) { - // Override page with the current context (iframe) - - // eslint-disable-next-line no-param-reassign - - args.page = helper.context; - - // Polyfill evaluate if missing (FrameLocator) - - if (!args.page.evaluate) { - Object.defineProperty(args.page, 'evaluate', { - - value: async (func, arg) => args.page.locator('body').evaluate(func, arg), - - writable: true, - - configurable: true, - - }); - } - } - - return fn(args); - }); - } - }; + event.dispatcher.on(event.test.before, resetContext.bind(null, helper)); + event.dispatcher.on(event.test.after, resetContext.bind(null, helper)); }; diff --git a/tests/pages/loginPage.js b/tests/pages/loginPage.js index 09a120936..f23835a8e 100644 --- a/tests/pages/loginPage.js +++ b/tests/pages/loginPage.js @@ -36,6 +36,8 @@ module.exports = { I.click(this.fields.skipButton); } + I.amOnPage(homePage.url); + I.waitInUrl(homePage.landingUrl); }, From beff337032b00a4e98b931f18620c04cf5eb6055 Mon Sep 17 00:00:00 2001 From: travagliad <215686151+travagliad@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:37:49 +0000 Subject: [PATCH 19/19] iframe waitForText null --- tests/QAN/filters_test.js | 4 +--- tests/helper/hooks.js | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/QAN/filters_test.js b/tests/QAN/filters_test.js index 3701e3b57..32963a3ac 100644 --- a/tests/QAN/filters_test.js +++ b/tests/QAN/filters_test.js @@ -198,9 +198,7 @@ Scenario( queryAnalyticsPage.filters.selectContainFilter(serviceName); queryAnalyticsPage.data.waitForNewItemsCount(count); queryAnalyticsPage.filters.selectFilterInGroup(db2, section); - await within(queryAnalyticsPage.data.root, () => { - I.waitForText('No queries available for this combination of filters', 30); - }); + I.waitForText('No queries available for this combination of filters', 30); }, ).retry(2); diff --git a/tests/helper/hooks.js b/tests/helper/hooks.js index e90a70c70..b2407fc1b 100644 --- a/tests/helper/hooks.js +++ b/tests/helper/hooks.js @@ -5,6 +5,8 @@ const { event, container } = require('codeceptjs'); function getSelector(locator) { let selector = locator; + if (!locator) return null; + if (typeof locator === 'object') { if (locator.xpath) { selector = `xpath=${locator.xpath}`; @@ -139,7 +141,7 @@ module.exports = function pmmGrafanaIframeHook() { applyContextOverride(helper, 'grabTextFrom', async (locator) => helper.context.locator(getSelector(locator)).first().textContent()); applyContextOverride(helper, 'grabTextFromAll', async (locator) => helper.context.locator(getSelector(locator)).allTextContents()); applyContextOverride(helper, 'waitForText', async (text, seconds = null, context = null) => { - await helper.context.locator(getSelector(context)).filter({ hasText: text }).first().waitFor({ + await helper.context.locator(getSelector(context) || 'body').filter({ hasText: text }).first().waitFor({ state: 'visible', timeout: seconds ? seconds * 1000 : helper.options.waitForTimeout, });