/*! * Matomo - free/libre analytics platform * * Screenshot integration tests. * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later */ describe("BotTracking", function () { this.timeout(0); this.fixture = "Piwik\\Plugins\\BotTracking\\tests\\Fixtures\\BotTraffic"; var generalParams = 'idSite=1&period=day&date=2025-02-02', urlBase = 'module=CoreHome&action=index&' + generalParams; async function waitForRowEvolutionPopover() { await page.waitForFunction('$(".ui-dialog:visible .rowevolution").length > 0'); await page.waitForFunction( '$(".ui-dialog:visible .rowevolution table.metrics tr").length > 0' + ' && $(".ui-dialog:visible .rowevolution .jqplot-target").length > 0' ); await page.waitForTimeout(250); } async function assertHeaders(widgetId, expectedHeaders) { const headers = await page.$$eval(widgetId + ' thead th .thDIV', function (divs) { return divs.map(function (d) { return (d.textContent || '').trim(); }); }); expect(headers).to.deep.equal(expectedHeaders); } async function assertMetricDocumentation(widgetId, expectedCount) { const docs = await page.$$eval(widgetId + ' thead th .columnDocumentation', function (nodes) { return nodes.map(function (node) { return (node.textContent || '').trim(); }); }); expect(docs.length).to.equal(expectedCount); docs.forEach(function (text) { expect(text.length).to.be.above(0); }); } it('should render AI Assistants > AI Chatbots Overview page with evolution and sparkline', async function () { await page.goto("?" + urlBase + "#?" + generalParams + "&category=General_AIAssistants&subcategory=BotTracking_AIChatbotsOverview"); await page.waitForNetworkIdle(); // the metrics picker options are always present in the DOM (the dropdown is // only hidden until the toggle is clicked), so we can count them directly const availableMetrics = await page.$$('.metrics-picker__options input'); expect(availableMetrics.length).to.equal(8); await page.mouse.move(0, 0); const sparklines = await page.$$('.sparkline-metrics'); expect(sparklines.length).to.equal(8); var elem = await page.$('.pageWrap'); expect(await elem.screenshot()).to.matchImage('bot_overview'); }); it('should not have shown a "no recent tracking requests" message', async function () { const notifications = await page.$$('.bot-tracking-no-recent-requests-message'); expect(notifications.length).to.equal(0); }) it('should not show unique pages and documents metric for higher periods', async function () { await page.goto("?" + urlBase + "#?idSite=1&period=week&date=2025-02-02&category=General_AIAssistants&subcategory=BotTracking_AIChatbotsOverview"); await page.waitForNetworkIdle(); const availableMetrics = await page.$$('.metrics-picker__options input'); expect(availableMetrics.length).to.equal(6); const sparklines = await page.$$('.sparkline-metrics'); expect(sparklines.length).to.equal(6); }); it('should render AI Assistants > AI Chatbots Overview bot detail report', async function () { await page.goto("?" + urlBase + "#?" + generalParams + "&category=General_AIAssistants&subcategory=BotTracking_AIChatbotsOverview"); await page.waitForNetworkIdle(); const row = await page.jQuery('tr.subDataTable:first'); await row.click(); await page.mouse.move(-10, -10); await page.waitForNetworkIdle(); await page.waitForTimeout(250); // rendering var elem = await page.$('#widgetBotTrackinggetAIChatbotRequests'); expect(await elem.screenshot()).to.matchImage('bot_requests'); }); it('should switch to secondary dimension when clicked', async function () { await page.evaluate(() => $('.datatableRelatedReports li span:contains("Document Requests")').click()); await page.waitForNetworkIdle(); const row = await page.jQuery('tr.subDataTable:first'); await row.click(); await page.mouse.move(-10, -10); await page.waitForNetworkIdle(); await page.waitForTimeout(250); // rendering var elem = await page.$('#widgetBotTrackinggetAIChatbotRequests'); expect(await elem.screenshot()).to.matchImage('bot_requests_documents'); }); it('should render AI Assistants > AI Chatbots Content Requests page', async function () { await page.goto("?" + urlBase + "#?" + generalParams + "&category=General_AIAssistants&subcategory=BotTracking_AIChatbotsContentRequests"); await page.waitForNetworkIdle(); // Assert the help icon for this subcategory exists in the reporting menu (proves getHelp() // returns non-empty content — the icon only renders when subcategory.help is truthy). // The active subcategory li in the menu contains the help icon as a sibling of .item. const helpIconCount = await page.evaluate(function () { return jQuery( '.reportingMenu ul.navbar > li.menuTab.active > ul > li.active .item-help-icon' ).length; }); expect(helpIconCount).to.equal(1); // Click the help icon via jQuery to trigger the help notification for this subcategory. // (Direct puppeteer click fails because the icon is CSS-hidden until hover.) await page.evaluate(function () { jQuery( '.reportingMenu ul.navbar > li.menuTab.active > ul > li.active .item-help-icon' ).trigger('click'); }); // Wait until the help notification is visible in the notification container. await page.waitForSelector('#notificationContainer .help-notification', { visible: true }); // Assert the help notification body contains non-empty text (proving getHelp() returned content). const helpNotificationText = await page.evaluate(function () { var notif = document.querySelector('#notificationContainer .help-notification .notification-body'); return notif ? notif.textContent.trim() : ''; }); expect(helpNotificationText.length).to.be.above(0); // Assert exactly 5 report widgets are present on the page (the wide Pages report on top, // then the Documents / Broken pair side-by-side, then the Human-Favoured / AI-Favoured // pair side-by-side). const widgets = await page.$$('.matomo-widget'); expect(widgets.length).to.equal(5); // Dismiss the help notification so it does not obscure table headers, // then wait for the tables to be fully rendered. await page.evaluate(function () { jQuery('#notificationContainer .notification .close').trigger('click'); }); await page.waitForNetworkIdle(); // Wait explicitly for the Pages widget's table header to appear before querying. await page.waitForSelector('#widgetBotTrackinggetAIChatbotContentPages thead th .thDIV', { visible: true }); // Assert per-widget column counts and header text for Pages widget. // The DataTable renders column labels inside
within each . // Each widget should display exactly 4 default columns: label + 3 metrics. const pagesWidgetId = '#widgetBotTrackinggetAIChatbotContentPages'; const pagesHeaders = await page.$$eval(pagesWidgetId + ' thead th .thDIV', function (divs) { return divs.map(function (d) { return (d.textContent || '').trim(); }); }); expect(pagesHeaders.length).to.equal(4); expect(pagesHeaders[0]).to.equal('Page URL'); expect(pagesHeaders[1]).to.equal('Requests'); expect(pagesHeaders[2]).to.equal('Avg. Server Time'); expect(pagesHeaders[3]).to.equal('Avg. Response Size'); // Assert default sort indicator on the Requests column in the Pages widget. // The sorted th gets class "columnSorted"; sort direction is a child span with class "sortIcon desc". const pagesSortedThCount = await page.$$eval(pagesWidgetId + ' thead th.columnSorted', function (ths) { return ths.length; }); expect(pagesSortedThCount).to.equal(1); const pagesSortedThText = await page.$eval(pagesWidgetId + ' thead th.columnSorted .thDIV', function (div) { return (div.textContent || '').trim(); }); expect(pagesSortedThText).to.equal('Requests'); // Assert per-widget column counts and header text for Documents widget. const docsWidgetId = '#widgetBotTrackinggetAIChatbotContentDocuments'; const docsHeaders = await page.$$eval(docsWidgetId + ' thead th .thDIV', function (divs) { return divs.map(function (d) { return (d.textContent || '').trim(); }); }); expect(docsHeaders.length).to.equal(4); expect(docsHeaders[0]).to.equal('Document URL'); expect(docsHeaders[1]).to.equal('Requests'); expect(docsHeaders[2]).to.equal('Avg. Server Time'); expect(docsHeaders[3]).to.equal('Avg. Response Size'); // Assert default sort indicator on the Requests column in the Documents widget. const docsSortedThCount = await page.$$eval(docsWidgetId + ' thead th.columnSorted', function (ths) { return ths.length; }); expect(docsSortedThCount).to.equal(1); const docsSortedThText = await page.$eval(docsWidgetId + ' thead th.columnSorted .thDIV', function (div) { return (div.textContent || '').trim(); }); expect(docsSortedThText).to.equal('Requests'); // Assert per-widget column counts and header text for Broken Content widget. const brokenWidgetId = '#widgetBotTrackinggetAIChatbotBrokenContent'; const brokenHeaders = await page.$$eval(brokenWidgetId + ' thead th .thDIV', function (divs) { return divs.map(function (d) { return (d.textContent || '').trim(); }); }); expect(brokenHeaders.length).to.equal(4); expect(brokenHeaders[0]).to.equal('URL'); expect(brokenHeaders[1]).to.equal('Total Broken Requests'); expect(brokenHeaders[2]).to.equal('Page Not Found (404) Requests'); expect(brokenHeaders[3]).to.equal('5XX Requests'); // Assert default sort indicator on total_broken_requests in the Broken widget. const brokenSortedThCount = await page.$$eval(brokenWidgetId + ' thead th.columnSorted', function (ths) { return ths.length; }); expect(brokenSortedThCount).to.equal(1); const brokenSortedThText = await page.$eval(brokenWidgetId + ' thead th.columnSorted .thDIV', function (div) { return (div.textContent || '').trim(); }); expect(brokenSortedThText).to.equal('Total Broken Requests'); // Assert per-widget column counts and header text for Human-Favoured widget. const humanWidgetId = '#widgetBotTrackinggetAIChatbotHumanFavouredPages'; await page.waitForSelector(humanWidgetId + ' thead th .thDIV', { visible: true }); const humanHeaders = await page.$$eval(humanWidgetId + ' thead th .thDIV', function (divs) { return divs.map(function (d) { return (d.textContent || '').trim(); }); }); expect(humanHeaders.length).to.equal(4); expect(humanHeaders[0]).to.equal('Page URL'); expect(humanHeaders[1]).to.equal('Unique Human Pageviews'); expect(humanHeaders[2]).to.equal('AI Chatbot Requests'); expect(humanHeaders[3]).to.equal('Discrepancy Score'); // Default sort on Discrepancy Score for the Human-Favoured widget. const humanSortedThText = await page.$eval(humanWidgetId + ' thead th.columnSorted .thDIV', function (div) { return (div.textContent || '').trim(); }); expect(humanSortedThText).to.equal('Discrepancy Score'); // Assert per-widget column counts and header text for AI-Favoured widget. const aiWidgetId = '#widgetBotTrackinggetAIChatbotAIFavouredPages'; const aiHeaders = await page.$$eval(aiWidgetId + ' thead th .thDIV', function (divs) { return divs.map(function (d) { return (d.textContent || '').trim(); }); }); // The AI-Favoured report leads with its strong-side metric (AI Chatbot Requests), then human. expect(aiHeaders.length).to.equal(4); expect(aiHeaders[0]).to.equal('Page URL'); expect(aiHeaders[1]).to.equal('AI Chatbot Requests'); expect(aiHeaders[2]).to.equal('Unique Human Pageviews'); expect(aiHeaders[3]).to.equal('Discrepancy Score'); // Default sort on Discrepancy Score for the AI-Favoured widget. const aiSortedThText = await page.$eval(aiWidgetId + ' thead th.columnSorted .thDIV', function (div) { return (div.textContent || '').trim(); }); expect(aiSortedThText).to.equal('Discrepancy Score'); // The reporting page auto-pairs consecutive non-wide widgets into a 2-column row (see // CoreHome ReportingPage.store). The Documents + Broken reports must share one row, and the // Human-Favoured + AI-Favoured reports must share one row. const sameRow = function (firstSel, secondSel) { return page.evaluate(function (a, b) { const first = document.querySelector(a); const second = document.querySelector(b); if (!first || !second) { return 0; } const firstRow = first.closest('.row'); const secondRow = second.closest('.row'); return firstRow && firstRow === secondRow ? 1 : 0; }, firstSel, secondSel); }; expect(await sameRow(docsWidgetId, brokenWidgetId)).to.equal(1); expect(await sameRow(humanWidgetId, aiWidgetId)).to.equal(1); var elem = await page.$('.pageWrap'); expect(await elem.screenshot()).to.matchImage('bot_content_requests'); }); it('should render AI Assistants > AI Chatbots Real-time page', async function () { await page.goto("?" + urlBase + "#?" + generalParams + "&category=General_AIAssistants&subcategory=BotTracking_AIChatbotsRealtime"); await page.waitForNetworkIdle(); const helpIconCount = await page.evaluate(function () { return jQuery( '.reportingMenu ul.navbar > li.menuTab.active > ul > li.active .item-help-icon' ).length; }); expect(helpIconCount).to.equal(1); await page.evaluate(function () { jQuery( '.reportingMenu ul.navbar > li.menuTab.active > ul > li.active .item-help-icon' ).trigger('click'); }); await page.waitForSelector('#notificationContainer .help-notification', { visible: true }); const helpNotificationText = await page.evaluate(function () { var notif = document.querySelector('#notificationContainer .help-notification .notification-body'); return notif ? notif.textContent.trim() : ''; }); expect(helpNotificationText).to.contain('recent AI chatbot activity'); await page.evaluate(function () { jQuery('#notificationContainer .notification .close').trigger('click'); }); await page.waitForNetworkIdle(); const widgets = await page.$$('.matomo-widget'); expect(widgets.length).to.equal(4); const chatbots30WidgetId = '#widgetBotTrackinggetAIChatbotsRealTimelastMinutes30'; const chatbots8hWidgetId = '#widgetBotTrackinggetAIChatbotsRealTimelastMinutes480'; const pages30WidgetId = '#widgetBotTrackinggetTopPageUrlsRealTimelastMinutes30'; const pages8hWidgetId = '#widgetBotTrackinggetTopPageUrlsRealTimelastMinutes480'; await page.waitForSelector(chatbots30WidgetId + ' thead th .thDIV', { visible: true }); await assertHeaders(chatbots30WidgetId, [ 'AI Chatbot Name', 'Requests', 'Unique page URLs', 'Not found requests', 'Server error (5xx) requests', ]); await assertHeaders(chatbots8hWidgetId, [ 'AI Chatbot Name', 'Requests', 'Unique page URLs', 'Not found requests', 'Server error (5xx) requests', ]); await assertHeaders(pages30WidgetId, [ 'Page URL', 'Requests', ]); await assertHeaders(pages8hWidgetId, [ 'Page URL', 'Requests', ]); await assertMetricDocumentation(chatbots30WidgetId, 4); await assertMetricDocumentation(chatbots8hWidgetId, 4); await assertMetricDocumentation(pages30WidgetId, 1); await assertMetricDocumentation(pages8hWidgetId, 1); }); it('should offer Row Evolution on the Favoured Pages reports and render its chart', async function () { // Use a date inside the human/AI overlap window (Feb 3-5) so the evolution chart has a real // multi-day series. Row Evolution is only available now the reports are archived. await page.goto("?" + urlBase + "#?idSite=1&period=day&date=2025-02-03&category=General_AIAssistants&subcategory=BotTracking_AIChatbotsContentRequests"); await page.waitForNetworkIdle(); const humanWidgetId = '#widgetBotTrackinggetAIChatbotHumanFavouredPages'; const row = await page.waitForSelector(humanWidgetId + ' table.dataTable tbody tr:first-child'); await row.hover(); await page.waitForTimeout(100); // The Row Evolution row action must now be offered (disable_row_evolution was removed once the // data is archived, so the icon is no longer suppressed). const icon = await row.$('a.actionRowEvolution'); expect(icon).to.not.equal(null); await icon.hover(); await icon.click(); await page.mouse.move(-10, -10); await waitForRowEvolutionPopover(); expect(await page.screenshotSelector('.ui-dialog:visible')).to.matchImage('row_evolution_favoured_pages'); }); it('should show segment not supported footer message on AI Chatbots Content Requests page when segmented', async function () { const segment = encodeURIComponent('visitConverted==1'); await page.goto("?" + urlBase + "#?" + generalParams + "&category=General_AIAssistants&subcategory=BotTracking_AIChatbotsContentRequests&segment=" + segment); await page.waitForNetworkIdle(); const expectedMessage = 'Report does not support segmentation. The data displayed is your standard, unsegmented report data.'; const matchingFooterMessages = await page.$$eval('.datatableFooterMessage', (nodes, expected) => { return nodes .map((node) => (node.textContent || '').trim()) .filter((text) => text.includes(expected)) .length; }, expectedMessage); // All 5 content-request widgets must show the segment-not-supported footer message. expect(matchingFooterMessages).to.be.at.least(5); }); it('should show segment not supported footer message in AI bot reports when segmented', async function () { const segment = encodeURIComponent('visitConverted==1'); await page.goto("?" + urlBase + "#?" + generalParams + "&category=General_AIAssistants&subcategory=BotTracking_AIChatbotsOverview&segment=" + segment); await page.waitForNetworkIdle(); const expectedMessage = 'Report does not support segmentation. The data displayed is your standard, unsegmented report data.'; const matchingFooterMessages = await page.$$eval('.datatableFooterMessage', (nodes, expected) => { return nodes .map((node) => (node.textContent || '').trim()) .filter((text) => text.includes(expected)) .length; }, expectedMessage); expect(matchingFooterMessages).to.be.at.least(3); }); });