mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-06 08:09:24 +00:00
740 lines
35 KiB
JavaScript
740 lines
35 KiB
JavaScript
import { expect } from '@playwright/test';
|
|
|
|
const SUB_TABS = ['overview', 'funnels', 'paths', 'retention', 'events'];
|
|
|
|
/** The analytics URL codec: base64url of the JSON definition with '.' padding (b64EncodeUnicode). */
|
|
export function encodeDef(value) {
|
|
return Buffer.from(JSON.stringify(value), 'utf8')
|
|
.toString('base64')
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_')
|
|
.replace(/=/g, '.');
|
|
}
|
|
|
|
/** Inverse of encodeDef, for asserting what a URL parameter carries. */
|
|
export function decodeDef(param) {
|
|
const b64 = String(param).replace(/-/g, '+').replace(/_/g, '/').replace(/\./g, '=');
|
|
return JSON.parse(Buffer.from(b64, 'base64').toString('utf8'));
|
|
}
|
|
|
|
/** A funnel URL parameter over `steps`, counting sessions within one session unless `extra` overrides it. */
|
|
export function funnelParam(steps, extra = {}) {
|
|
return encodeDef({ s: steps, u: 'sessions', w: 'session', ...extra });
|
|
}
|
|
|
|
/** The SQL a search request carries, decoded when the app sent it base64-encoded. */
|
|
export function sqlOf(request) {
|
|
try {
|
|
const body = JSON.parse(request.postData() || '{}');
|
|
const sql = body.query?.sql || '';
|
|
return body.encoding === 'base64' ? Buffer.from(sql, 'base64').toString('utf8') : sql;
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
export class RumProductAnalyticsPage {
|
|
constructor(page) {
|
|
this.page = page;
|
|
this.base = (process.env.ZO_BASE_URL || 'http://localhost:5080').replace(/\/$/, '');
|
|
this.org = process.env.ORGNAME || 'default';
|
|
|
|
this.root = page.locator('[data-test="rum-analytics-page"]');
|
|
this.rumTabs = page.locator('[data-test^="rum-tab-"][role="tab"]');
|
|
// Experience's tile links to /rum, so this is the Experience tile whenever the group forms.
|
|
this.rumMenuLink = page.locator('[data-test="nav-group-experience"] [data-test="menu-link-/rum-item"]');
|
|
this.menuFlyout = page.locator('[data-test="nav-group-flyout-experience"]');
|
|
this.menuFlyoutItems = this.menuFlyout.locator('[data-test^="nav-group-item-"]');
|
|
this.menuLink = this.menuFlyout.locator('[data-test="nav-group-item-productAnalytics"]');
|
|
this.appSelect = page.locator('[data-test="rum-analytics-app-select-trigger"]');
|
|
this.appLabel = page.locator('[data-test="rum-analytics-app-label"]');
|
|
this.envSelect = page.locator('[data-test="rum-analytics-env-select"]');
|
|
this.refreshBtn = page.locator('[data-test="rum-analytics-refresh-btn"]');
|
|
this.shareBtn = page.locator('[data-test="rum-analytics-share-btn"]');
|
|
this.newEventBtn = page.locator('[data-test="rum-analytics-named-events-new-btn"]');
|
|
|
|
this.overview = page.locator('[data-test="rum-analytics-overview"]');
|
|
this.overviewEmpty = page.locator('[data-test="rum-analytics-overview-empty"]');
|
|
this.widenRangeBtn = page.locator('[data-test="rum-analytics-overview-widen-range-btn"]');
|
|
this.pagesTable = page.locator('[data-test="rum-analytics-overview-pages-table"]');
|
|
this.clicksTable = page.locator('[data-test="rum-analytics-overview-clicks-table"]');
|
|
this.clicksNotCaptured = page.locator('[data-test="rum-analytics-overview-clicks-not-captured"]');
|
|
this.clicksSetupLink = page.locator('[data-test="rum-analytics-overview-clicks-setup-link"]');
|
|
this.featuresTable = page.locator('[data-test="rum-analytics-overview-features-table"]');
|
|
this.trends = page.locator('[data-test="rum-analytics-trends"]');
|
|
this.trendsAddDashboardBtn = page.locator('[data-test="rum-analytics-trends-add-dashboard-btn"]');
|
|
|
|
this.funnels = page.locator('[data-test="rum-analytics-funnels"]');
|
|
this.funnelCold = page.locator('[data-test="rum-analytics-funnel-cold"]');
|
|
this.funnelOrderLabel = page.locator('[data-test="rum-analytics-funnel-order-label"]');
|
|
this.funnelAddDashboardBtn = page.locator('[data-test="rum-analytics-funnel-add-dashboard-btn"]');
|
|
this.breakdownSelect = page.locator('[data-test="rum-analytics-funnel-breakdown-select"]');
|
|
this.breakdown = page.locator('[data-test="rum-analytics-funnel-breakdown"]');
|
|
|
|
this.dropoffDrawer = page.locator('[data-test="rum-analytics-dropoff-drawer"]');
|
|
|
|
this.paths = page.locator('[data-test="rum-analytics-paths"]');
|
|
this.pathsFlow = page.locator('[data-test="rum-analytics-paths-flow"]');
|
|
this.pathsTopTable = page.locator('[data-test="rum-analytics-paths-top-table"]');
|
|
this.pathsSessionFilter = page.locator('[data-test="rum-analytics-paths-session-filter"]');
|
|
this.branchDrawer = page.locator('[data-test="rum-analytics-paths-branch-drawer"]');
|
|
|
|
this.retention = page.locator('[data-test="rum-analytics-retention"]');
|
|
this.retentionGrid = page.locator('[data-test="rum-analytics-retention-grid"]');
|
|
this.retentionUnlock = page.locator('[data-test="rum-analytics-retention-unlock"]');
|
|
this.retentionCellDrawer = page.locator('[data-test="rum-analytics-retention-cell-drawer"]');
|
|
|
|
this.namedEventsList = page.locator('[data-test="rum-analytics-events-list"]');
|
|
this.eventEditor = page.locator('[data-test="rum-analytics-event-editor"]');
|
|
this.eventSaveBtn = page.locator('[data-test="rum-analytics-event-editor-save-btn"]');
|
|
this.addToDashboardDialog = page.locator('[data-test="add-to-dashboard-dialog"]');
|
|
this.addToDashboardNotice = page.locator('[data-test="add-to-dashboard-notice"]');
|
|
|
|
this.sessionViewerContext = page.locator('[data-test="session-viewer-analytics-context"]');
|
|
this.sessionViewerEventsOnly = page.locator('[data-test="session-viewer-events-only"]');
|
|
this.sessionViewerStepMark = page.locator('[data-test="session-viewer-funnel-step-mark"]');
|
|
this.sessionViewerNoReplay = page.locator('[data-test="session-viewer-no-replay"]');
|
|
|
|
this.body = page.locator('body');
|
|
this.datePicker = page.locator('[data-test="rum-analytics-date-picker"]');
|
|
this.noData = page.locator('[data-test="rum-analytics-no-data"]');
|
|
this.emptyWebCard = page.locator('[data-test="rum-empty-web-card"]');
|
|
this.emptySessionCard = page.locator('[data-test="rum-empty-session-card"]');
|
|
this.invalidLink = page.locator('[data-test="rum-analytics-invalid-link"]');
|
|
this.invalidLinkDismiss = page.locator('[data-test="rum-analytics-invalid-link-dismiss"]');
|
|
this.deletedEventLink = page.locator('[data-test="rum-analytics-deleted-event-link"]');
|
|
this.syntheticExcluded = page.locator('[data-test="rum-analytics-synthetic-excluded"]');
|
|
this.kpiSessions = page.locator('[data-test="rum-analytics-kpi-sessions"]');
|
|
|
|
this.trendsClearBtn = page.locator('[data-test="rum-analytics-trends-clear-btn"]');
|
|
|
|
this.funnelAlertBtn = page.locator('[data-test="rum-analytics-funnel-alert-btn"]');
|
|
this.funnelLeftOut = page.locator('[data-test="rum-analytics-funnel-left-out"]');
|
|
this.funnelWindowFixed = page.locator('[data-test="rum-analytics-funnel-window-fixed"]');
|
|
this.funnelWindowTrigger = page.locator('[data-test="rum-analytics-funnel-window-select-trigger"]');
|
|
this.funnelSuggestionsError = page.locator('[data-test="rum-analytics-funnel-suggestions-error"]');
|
|
this.funnelSuggestionsRetryBtn = page.locator('[data-test="rum-analytics-funnel-suggestions-retry-btn"]');
|
|
this.funnelEntryStart0 = page.locator('[data-test="rum-analytics-funnel-entry-start-0"]');
|
|
this.funnelEntriesFailed = page.locator('[data-test="rum-analytics-funnel-entries-failed"]');
|
|
this.funnelSavedName = page.locator('[data-test="rum-analytics-funnel-saved-name"]');
|
|
this.funnelSaveBtn = page.locator('[data-test="rum-analytics-funnel-save-btn"]');
|
|
this.funnelSaveBtnHolder = page.locator('span:has(> [data-test="rum-analytics-funnel-save-btn"])');
|
|
this.saveFunnelDialog = page.locator('[data-test="rum-analytics-save-funnel-dialog"]');
|
|
this.saveFunnelNameField = this.saveFunnelDialog.locator('[data-test="rum-analytics-save-funnel-name-field"]');
|
|
this.saveFunnelPrimaryBtn = this.saveFunnelDialog.locator('[data-test="o-dialog-primary-btn"]');
|
|
this.saveFunnelCancelBtn = this.saveFunnelDialog.locator('[data-test="o-dialog-secondary-btn"]');
|
|
this.alertDialog = page.locator('[data-test="create-alert-from-source-dialog"]');
|
|
this.alertQueryPreview = page.locator('[data-test="create-alert-query-preview"]');
|
|
|
|
this.dropoffNextRow0 = page.locator('[data-test="rum-analytics-dropoff-next-row-0"]');
|
|
this.dropoffErrorDropped = page.locator('[data-test="rum-analytics-dropoff-error-dropped"]');
|
|
this.dropoffErrorConverted = page.locator('[data-test="rum-analytics-dropoff-error-converted"]');
|
|
this.dropoffSessionsCap = page.locator('[data-test="rum-analytics-dropoff-sessions-cap"]');
|
|
|
|
this.pathsEmpty = page.locator('[data-test="rum-analytics-paths-empty"]');
|
|
this.pathsAnchorTrigger = page.locator('[data-test="rum-analytics-paths-anchor-select-trigger"]');
|
|
this.pathsAnchorOptions = page.locator('[data-test="rum-analytics-paths-anchor-select-option"]');
|
|
this.branchSessionsCap = page.locator('[data-test="rum-analytics-paths-branch-sessions-cap"]');
|
|
this.branchSessionRow0Open = page.locator('[data-test="rum-analytics-paths-branch-sessions-row-0-open"]');
|
|
|
|
this.retentionGranularityTrigger = page.locator('[data-test="rum-analytics-retention-granularity-trigger"]');
|
|
this.retentionModeAfter = page.locator('[data-test="rum-analytics-retention-mode-after"]');
|
|
this.retentionUser0 = page.locator('[data-test="rum-analytics-retention-user-0"]');
|
|
this.retentionUser0SessionsBtn = page.locator('[data-test="rum-analytics-retention-user-0-sessions-btn"]');
|
|
|
|
this.eventNameField = page.locator('[data-test="rum-analytics-named-events-name-field"]');
|
|
this.addRuleBtn = page.locator('[data-test="rum-analytics-named-events-add-rule-btn"]');
|
|
this.eventPreview = page.locator('[data-test="rum-analytics-named-events-preview"]');
|
|
this.eventCancelBtn = page.locator('[data-test="rum-analytics-event-editor-cancel-btn"]');
|
|
this.eventDiscardDialog = page.locator('[data-test="rum-analytics-event-editor-discard-dialog"]');
|
|
this.eventDiscardConfirmBtn = this.eventDiscardDialog.locator('[data-test="o-dialog-primary-btn"]');
|
|
this.eventRules = page.locator('[data-test^="rum-analytics-named-events-rule-"][data-test$="-kind"]');
|
|
|
|
this.sessionsTable = page.locator('[data-test="rum-sessions-table"]');
|
|
this.sessionsNoData = this.sessionsTable.locator('[data-test="no-data-message"]');
|
|
this.sessionsRows = this.sessionsTable.locator('tbody tr');
|
|
// Rendered per row only when that session has a replay to play (#15190). The icon
|
|
// carries no data-test, so this keys off its own semantic class in AppSessions.vue.
|
|
this.sessionsPlayIcons = this.sessionsTable.locator('.session-play-icon');
|
|
this.toasts = page.locator('[data-test="o-toast-message"]');
|
|
}
|
|
|
|
/** Pins _rumdata's reported doc_time_min to the seed start: stream stats pick up back-dated rows only on their next run. */
|
|
async pinRumDataStart(dataStartUs) {
|
|
const patch = (node) => {
|
|
if (Array.isArray(node)) return node.forEach(patch);
|
|
if (!node || typeof node !== 'object') return;
|
|
if (node.name === '_rumdata' && node.stats && Number(node.stats.doc_time_min) > dataStartUs) {
|
|
node.stats.doc_time_min = dataStartUs;
|
|
}
|
|
Object.values(node).forEach(patch);
|
|
};
|
|
await this.page.route(/\/api\/[^/]+\/streams(\/_rumdata\/schema)?(\?|$)/, async (route) => {
|
|
try {
|
|
const res = await route.fetch();
|
|
const json = await res.json();
|
|
patch(json);
|
|
await route.fulfill({ response: res, json });
|
|
} catch {
|
|
// The page closed mid-request, or the body was not JSON: nothing to patch.
|
|
await route.continue().catch(() => {});
|
|
}
|
|
});
|
|
}
|
|
|
|
/** `org` opens another org than ORGNAME, e.g. a state org created by the test. */
|
|
async goto(subTab = null, params = {}, { org = null } = {}) {
|
|
const path = subTab ? `/web/product-analytics/${subTab}` : '/web/product-analytics';
|
|
const url = new URL(`${this.base}${path}`);
|
|
url.searchParams.set('org_identifier', org || this.org);
|
|
for (const [k, v] of Object.entries(params)) {
|
|
for (const item of Array.isArray(v) ? v : [v]) url.searchParams.append(k, String(item));
|
|
}
|
|
await this.page.goto(url.toString());
|
|
await expect(this.root).toBeVisible({ timeout: 30000 });
|
|
}
|
|
|
|
async gotoRum(path = '') {
|
|
await this.page.goto(`${this.base}/web/rum${path}?org_identifier=${this.org}`);
|
|
}
|
|
|
|
/** Opens the RUM Sessions list of `org` over an absolute range (µs). */
|
|
async gotoRumSessions({ org = null, from, to }) {
|
|
const url = new URL(`${this.base}/web/rum/sessions`);
|
|
url.searchParams.set('org_identifier', org || this.org);
|
|
url.searchParams.set('from', String(from));
|
|
url.searchParams.set('to', String(to));
|
|
await this.page.goto(url.toString());
|
|
}
|
|
|
|
async openExperienceMenu() {
|
|
// A pointer still resting on the tile after clicking it fires no new mouseenter, so step off first.
|
|
await this.page.mouse.move(0, 0);
|
|
await this.rumMenuLink.hover();
|
|
await expect(this.menuFlyout).toBeVisible();
|
|
}
|
|
|
|
/** Opens Product Analytics from the Experience flyout, as a user arriving from another module would. */
|
|
async openFromMenu() {
|
|
await this.openExperienceMenu();
|
|
await this.menuLink.click();
|
|
}
|
|
|
|
subTab(name) {
|
|
return this.page.locator(`[data-test="rum-analytics-subtab-${name}"]`);
|
|
}
|
|
|
|
async openSubTab(name) {
|
|
await this.subTab(name).click();
|
|
await expect(this.page).toHaveURL(new RegExp(`/product-analytics/${name}`));
|
|
}
|
|
|
|
async expectSubTabActive(name) {
|
|
await expect(this.subTab(name)).toHaveAttribute('data-state', 'active');
|
|
}
|
|
|
|
subTabNames() {
|
|
return SUB_TABS;
|
|
}
|
|
|
|
query() {
|
|
return new URL(this.page.url()).searchParams;
|
|
}
|
|
|
|
|
|
async pickOption(dataTest, { label = null, value = null, search = null } = {}) {
|
|
await this.page.locator(`[data-test="${dataTest}-trigger"]`).click();
|
|
if (search !== null) {
|
|
await this.page.locator(`[data-test="${dataTest}-search"]`).fill(search);
|
|
}
|
|
const option = label !== null
|
|
? this.page.locator(`[data-test="${dataTest}-option"][data-test-label="${label}"]`)
|
|
: this.page.locator(`[data-test="${dataTest}-option"][data-test-value="${value}"]`);
|
|
await expect(option.first()).toBeVisible({ timeout: 20000 });
|
|
await option.first().click();
|
|
}
|
|
|
|
async closePopovers() {
|
|
await this.page.keyboard.press('Escape');
|
|
}
|
|
|
|
|
|
rankedRow(table, index, cell) {
|
|
return this.page.locator(`[data-test="rum-analytics-overview-${table}-table-row-${index}-${cell}"]`);
|
|
}
|
|
|
|
async rankedKeys(table) {
|
|
const keys = this.page.locator(`[data-test^="rum-analytics-overview-${table}-table-row-"][data-test$="-key"]`);
|
|
await expect(keys.first()).toBeVisible({ timeout: 30000 });
|
|
return (await keys.allInnerTexts()).map((s) => s.trim());
|
|
}
|
|
|
|
async rowIndexOf(table, key) {
|
|
const keys = await this.rankedKeys(table);
|
|
const i = keys.findIndex((k) => k === key);
|
|
expect(i, `${table} lists ${key} (got ${keys.join(', ')})`).toBeGreaterThanOrEqual(0);
|
|
return i;
|
|
}
|
|
|
|
/** Clicks a row action; the actions sit behind hover, so the row is hovered first. */
|
|
async rowAction(table, index, action) {
|
|
const key = this.rankedRow(table, index, 'key');
|
|
await key.hover();
|
|
const btn = this.rankedRow(table, index, action);
|
|
await btn.click();
|
|
}
|
|
|
|
async numberIn(locator) {
|
|
const text = (await locator.innerText()).replace(/[~,\s]/g, '');
|
|
return Number(text);
|
|
}
|
|
|
|
|
|
stepCount(i) {
|
|
return this.page.locator(`[data-test="rum-analytics-funnel-step-${i}-count"]`);
|
|
}
|
|
|
|
step(i) {
|
|
return this.page.locator(`[data-test="rum-analytics-funnel-step-${i}"]`);
|
|
}
|
|
|
|
async funnelCounts(n) {
|
|
const out = [];
|
|
for (let i = 0; i < n; i++) {
|
|
await expect(this.stepCount(i)).toBeVisible({ timeout: 30000 });
|
|
out.push(await this.numberIn(this.stepCount(i)));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Polls until the funnel shows exactly these counts, since each edit recomputes after a debounce. */
|
|
async expectFunnelCounts(expected, timeout = 30000) {
|
|
await expect
|
|
.poll(async () => {
|
|
if ((await this.page.locator('[data-test^="rum-analytics-funnel-step-"][data-test$="-count"]').count()) !== expected.length) {
|
|
return null;
|
|
}
|
|
return this.funnelCounts(expected.length);
|
|
}, { timeout, intervals: [500, 1000, 2000] })
|
|
.toEqual(expected);
|
|
}
|
|
|
|
suggestion(text) {
|
|
return this.page.locator('[data-test^="rum-analytics-funnel-suggestion-"]').filter({ hasText: text });
|
|
}
|
|
|
|
dropoffBtn(i) {
|
|
return this.page.locator(`[data-test="rum-analytics-funnel-dropoff-${i}"]`);
|
|
}
|
|
|
|
topPathSessionsBtn(i) {
|
|
return this.page.locator(`[data-test="rum-analytics-paths-top-row-${i}-sessions-btn"]`);
|
|
}
|
|
|
|
async openDropoff(i) {
|
|
await expect(this.dropoffBtn(i)).toHaveAttribute('aria-haspopup', 'dialog');
|
|
await this.dropoffBtn(i).click();
|
|
await expect(this.dropoffDrawer).toBeVisible();
|
|
await this.expectFullHeight(this.dropoffDrawer);
|
|
}
|
|
|
|
/** Side panels start at the top of the window, not under the sub-tab strip. */
|
|
async expectFullHeight(drawer) {
|
|
const viewport = this.page.viewportSize();
|
|
await expect
|
|
.poll(async () => {
|
|
const box = await drawer.boundingBox();
|
|
return box ? Math.round(box.y) <= 1 && box.height >= viewport.height - 2 : false;
|
|
}, { timeout: 5000 })
|
|
.toBe(true);
|
|
}
|
|
|
|
|
|
retentionCell(cohort, k) {
|
|
return this.page.locator(`[data-test="rum-analytics-retention-cell-${cohort}-${k}"]`);
|
|
}
|
|
|
|
async retentionPct(cohort, k) {
|
|
const text = (await this.retentionCell(cohort, k).innerText()).trim();
|
|
return Number(text.replace(/[~%\s]/g, ''));
|
|
}
|
|
|
|
|
|
/** Opens the Named events sub-tab and waits for its list. */
|
|
async openNamedEvents() {
|
|
await this.openSubTab('events');
|
|
await expect(this.namedEventsList).toBeVisible({ timeout: 20000 });
|
|
}
|
|
|
|
/** The name cells of the named events list; each carries `rum-analytics-named-events-row-<i>`. */
|
|
namedEventNames() {
|
|
return this.namedEventsList.locator('[data-test="o2-table-cell-name"] [data-test^="rum-analytics-named-events-row-"]');
|
|
}
|
|
|
|
/** The row id (`rum-analytics-named-events-row-<i>`) of the listed event with this name. */
|
|
async namedEventRowId(name) {
|
|
const cell = this.namedEventNames().filter({ hasText: name }).first();
|
|
await expect(cell).toBeVisible({ timeout: 20000 });
|
|
return cell.getAttribute('data-test');
|
|
}
|
|
|
|
/** Fills the open editor's name and first page rule. */
|
|
async fillPageEvent(name, page) {
|
|
await expect(this.eventEditor).toBeVisible({ timeout: 20000 });
|
|
await this.page.locator('[data-test="rum-analytics-named-events-name-field"]').fill(name);
|
|
await this.page.locator('[data-test="rum-analytics-named-events-rule-0-value-input"]').fill(page);
|
|
await this.page.keyboard.press('Tab');
|
|
}
|
|
|
|
/** Creates a page event from the list's New event and lands back on the list. */
|
|
async createPageEvent(name, page) {
|
|
await this.openNamedEvents();
|
|
await this.newEventBtn.click();
|
|
await this.fillPageEvent(name, page);
|
|
await this.eventSaveBtn.click();
|
|
await expect(this.namedEventsList).toBeVisible({ timeout: 20000 });
|
|
await expect(this.namedEventNames().filter({ hasText: name }).first()).toBeVisible({ timeout: 20000 });
|
|
}
|
|
|
|
/** Deletes a listed event through its row action; the list must be open. */
|
|
async deleteNamedEvent(name) {
|
|
const id = await this.namedEventRowId(name);
|
|
await this.page.locator(`[data-test="${id}-delete-btn"]`).click();
|
|
await this.page.locator('[data-test="o-dialog-primary-btn"]').click();
|
|
await expect(this.namedEventNames().filter({ hasText: name })).toHaveCount(0, { timeout: 20000 });
|
|
}
|
|
|
|
|
|
/** Saves the funnel on screen: Save on an unsaved funnel, or a menu action that opens the name dialog. */
|
|
async saveFunnel(name, action = null) {
|
|
if (action) {
|
|
await this.page.locator('[data-test="rum-analytics-funnel-saved-menu-btn"]').click();
|
|
await this.page.locator(`[data-test="rum-analytics-funnel-saved-menu-${action}"]`).click();
|
|
} else {
|
|
await this.page.locator('[data-test="rum-analytics-funnel-save-btn"]').click();
|
|
}
|
|
const dialog = this.page.locator('[data-test="rum-analytics-save-funnel-dialog"]');
|
|
if (name !== null) await dialog.locator('[data-test="rum-analytics-save-funnel-name-field"]').fill(name);
|
|
await dialog.locator('[data-test="o-dialog-primary-btn"]').click();
|
|
await expect(dialog).toBeHidden({ timeout: 20000 });
|
|
}
|
|
|
|
/** Shows the saved-funnels list page: Back from the builder, or the Funnels sub-tab from anywhere else. */
|
|
async openSavedFunnels() {
|
|
const list = this.page.locator('[data-test="rum-analytics-saved-funnels-page"]');
|
|
if (!(await list.isVisible())) {
|
|
const back = this.page.locator('[data-test="rum-analytics-funnel-back-btn"]');
|
|
if (await back.isVisible()) await back.click();
|
|
else await this.subTab('funnels').click();
|
|
}
|
|
await expect(this.page).toHaveURL(/\/product-analytics\/funnels(\?|$)/);
|
|
await expect(list).toBeVisible({ timeout: 20000 });
|
|
return list;
|
|
}
|
|
|
|
/** Opens a saved funnel from the list page in the builder. */
|
|
async openSavedFunnel(name) {
|
|
await this.openSavedFunnels();
|
|
await (await this.savedFunnelRow(name)).click();
|
|
await expect(this.page).toHaveURL(/\/product-analytics\/funnels\/build/);
|
|
await expect(this.page.locator('[data-test="rum-analytics-funnel-saved-name"]')).toHaveText(name);
|
|
}
|
|
|
|
/** Opens an empty, unsaved funnel in the builder from the list page's New funnel. */
|
|
async newFunnel() {
|
|
await this.openSavedFunnels();
|
|
await this.page.locator('[data-test="rum-analytics-saved-funnels-new-btn"]').click();
|
|
await expect(this.page).toHaveURL(/\/product-analytics\/funnels\/build/);
|
|
await expect(this.funnelCold).toBeVisible({ timeout: 30000 });
|
|
}
|
|
|
|
/** The name cell of a saved-funnels list row, matched exactly; the list page must be open. */
|
|
async savedFunnelRow(name) {
|
|
const exact = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
|
|
const named = this.page
|
|
.locator('[data-test="rum-analytics-saved-funnels-page"] [data-test^="rum-analytics-saved-funnel-row-"]')
|
|
.filter({ hasText: exact })
|
|
.first();
|
|
await expect(named).toBeVisible({ timeout: 20000 });
|
|
return named;
|
|
}
|
|
|
|
async savedFunnelRowAction(name, action) {
|
|
await this.openSavedFunnels();
|
|
const id = await (await this.savedFunnelRow(name)).getAttribute('data-test');
|
|
await this.page.locator(`[data-test="${id}-${action}"]`).click();
|
|
}
|
|
|
|
/** Waits for the next named-event or funnel write and returns its JSON row. */
|
|
waitForAnalyticsWrite(kind, method = 'POST') {
|
|
const path = kind === 'event' ? 'named_events' : 'funnels';
|
|
return this.page
|
|
.waitForResponse((r) => r.request().method() === method && new RegExp(`/api/[^/]+/rum/analytics/${path}(/[0-9A-Za-z]{27})?\\?`).test(r.url()))
|
|
.then(async (r) => ({ status: r.status(), body: await r.json().catch(() => null) }));
|
|
}
|
|
|
|
/** The AnalyticsPanelState error box of a panel, e.g. `rum-analytics-paths` or `rum-analytics-overview-pages`. */
|
|
panelError(prefix) {
|
|
return this.page.locator(`[data-test="${prefix}-error"]`);
|
|
}
|
|
|
|
async expectNoPanelError(...prefixes) {
|
|
for (const prefix of prefixes) await expect(this.panelError(prefix)).toHaveCount(0);
|
|
}
|
|
|
|
async showPagesView(view) {
|
|
const option = this.page.locator(`[data-test="rum-analytics-overview-pages-view-${view}"]`);
|
|
await option.click();
|
|
await expect(option).toHaveAttribute('data-state', 'on');
|
|
}
|
|
|
|
async expectTopRankedRow(table, key, sessions) {
|
|
await expect(this.rankedRow(table, 0, 'key')).toHaveText(key, { timeout: 30000 });
|
|
await expect(this.rankedRow(table, 0, 'sessions')).toHaveText(String(sessions), { timeout: 30000 });
|
|
}
|
|
|
|
async expectRankedSessions(table, key, sessions) {
|
|
const i = await this.rowIndexOf(table, key);
|
|
await expect(this.rankedRow(table, i, 'sessions')).toHaveText(String(sessions), { timeout: 30000 });
|
|
}
|
|
|
|
trendSeries(i) {
|
|
return this.page.locator(`[data-test="rum-analytics-trends-series-${i}"]`);
|
|
}
|
|
|
|
countBy(unit) {
|
|
return this.page.locator(`[data-test="rum-analytics-funnel-count-by-${unit}"]`);
|
|
}
|
|
|
|
async selectCountBy(unit) {
|
|
await this.countBy(unit).click();
|
|
await expect(this.countBy(unit)).toHaveAttribute('data-state', 'on');
|
|
}
|
|
|
|
/** The funnel definition the URL carries now. */
|
|
funnelFromUrl() {
|
|
const param = this.query().get('funnel');
|
|
return param ? decodeDef(param) : null;
|
|
}
|
|
|
|
async pickConversionWindow(value) {
|
|
await this.pickOption('rum-analytics-funnel-window-select', { value });
|
|
await expect(this.funnelWindowTrigger).toHaveAttribute('data-test-selected-value', value);
|
|
}
|
|
|
|
dropoffSessionReplayTag(i) {
|
|
return this.page.locator(`[data-test="rum-analytics-dropoff-sessions-row-${i}-replay"]`);
|
|
}
|
|
|
|
branchSessionReplayTag(i) {
|
|
return this.page.locator(`[data-test="rum-analytics-paths-branch-sessions-row-${i}-replay"]`);
|
|
}
|
|
|
|
pathsInclude(name) {
|
|
return this.page.locator(`[data-test="rum-analytics-paths-include-${name}"]`);
|
|
}
|
|
|
|
pathsDirection(name) {
|
|
return this.page.locator(`[data-test="rum-analytics-paths-direction-${name}"]`);
|
|
}
|
|
|
|
/** Clicks a Paths include option and waits until it is the active one and in the URL. */
|
|
async setPathsInclude(name) {
|
|
await this.pathsInclude(name).click();
|
|
await expect(this.pathsInclude(name)).toHaveAttribute('data-state', 'on');
|
|
await expect.poll(() => this.query().get('inc')).toBe(name === 'all' ? null : name);
|
|
}
|
|
|
|
pathsTopRow(i) {
|
|
return this.page.locator(`[data-test="rum-analytics-paths-top-row-${i}"]`);
|
|
}
|
|
|
|
/** The labels the Paths "From" picker offers. */
|
|
async pathsAnchorOptionLabels() {
|
|
// The picker lists the selected anchor at once and only appends the scope's keys once its first search answers.
|
|
const isAnchorSearch = (r) => /\/_search(\?|$)/.test(r.url())
|
|
&& /SELECT kind, k, COUNT\(DISTINCT sid\) AS sessions FROM e\b/.test(sqlOf(r.request()));
|
|
await Promise.all([
|
|
this.page.waitForResponse(isAnchorSearch, { timeout: 30000 }),
|
|
this.pathsAnchorTrigger.click(),
|
|
]);
|
|
await expect(this.pathsAnchorOptions.first()).toBeVisible({ timeout: 30000 });
|
|
await expect(this.pathsAnchorTrigger.getByRole('status')).toHaveCount(0, { timeout: 30000 });
|
|
const labels = await this.pathsAnchorOptions.evaluateAll((els) => els.map((e) => e.getAttribute('data-test-label')));
|
|
await this.closePopovers();
|
|
return labels;
|
|
}
|
|
|
|
/** The Top paths table text, or '' while it is not rendered. */
|
|
async pathsTopText() {
|
|
return this.pathsTopTable.innerText({ timeout: 1000 }).catch(() => '');
|
|
}
|
|
|
|
async expectFlowRendered() {
|
|
await expect(this.pathsFlow.locator('canvas').first()).toBeVisible({ timeout: 30000 });
|
|
await expect(this.pathsTopTable).toBeVisible({ timeout: 30000 });
|
|
}
|
|
|
|
retentionGranularityOption(value) {
|
|
return this.page.locator(`[data-test="rum-analytics-retention-granularity-option"][data-test-value="${value}"]`);
|
|
}
|
|
|
|
async openRetentionGranularity() {
|
|
await this.retentionGranularityTrigger.click();
|
|
await expect(this.retentionGranularityOption('week')).toBeVisible({ timeout: 20000 });
|
|
}
|
|
|
|
ruleRow(i) {
|
|
return this.page.locator(`[data-test="rum-analytics-named-events-rule-${i}"]`);
|
|
}
|
|
|
|
ruleValueInput(i) {
|
|
return this.page.locator(`[data-test="rum-analytics-named-events-rule-${i}-value-input"]`);
|
|
}
|
|
|
|
/** The operator labels a page rule offers. */
|
|
async ruleOpOptionLabels(i) {
|
|
await this.page.locator(`[data-test="rum-analytics-named-events-rule-${i}-op-trigger"]`).click();
|
|
const options = this.page.locator(`[data-test="rum-analytics-named-events-rule-${i}-op-option"]`);
|
|
await expect(options.first()).toBeVisible({ timeout: 20000 });
|
|
const labels = await options.evaluateAll((els) => els.map((e) => e.getAttribute('data-test-label')));
|
|
await this.closePopovers();
|
|
return labels;
|
|
}
|
|
|
|
async setRuleOp(i, value) {
|
|
await this.pickOption(`rum-analytics-named-events-rule-${i}-op`, { value });
|
|
await expect(this.page.locator(`[data-test="rum-analytics-named-events-rule-${i}-op-trigger"]`))
|
|
.toHaveAttribute('data-test-selected-value', value);
|
|
}
|
|
|
|
async fillRuleValue(i, value) {
|
|
await this.ruleValueInput(i).fill(value);
|
|
await this.page.keyboard.press('Tab');
|
|
}
|
|
|
|
/** Opens a new, empty named event from the list's New event button. */
|
|
async openNewEventEditor() {
|
|
await this.openNamedEvents();
|
|
await expect(this.newEventBtn).toBeEnabled({ timeout: 20000 });
|
|
await this.newEventBtn.click();
|
|
await expect(this.eventEditor).toBeVisible({ timeout: 20000 });
|
|
await expect(this.eventNameField).toBeVisible({ timeout: 20000 });
|
|
}
|
|
|
|
/** Clicks Save and expects the editor to stay open showing `message`. */
|
|
async expectEventSaveRejected(message) {
|
|
await this.eventSaveBtn.click();
|
|
await expect(this.eventEditor).toContainText(message, { timeout: 20000 });
|
|
await expect(this.eventEditor).toBeVisible();
|
|
}
|
|
|
|
async openSaveFunnelDialog() {
|
|
await this.funnelSaveBtn.click();
|
|
await expect(this.saveFunnelDialog).toBeVisible({ timeout: 20000 });
|
|
}
|
|
|
|
/** Submits the name dialog with `name` and expects it to stay open showing `message`. */
|
|
async expectSaveFunnelRejected(name, message) {
|
|
await this.saveFunnelNameField.fill(name);
|
|
await this.saveFunnelPrimaryBtn.click();
|
|
await expect(this.saveFunnelDialog).toContainText(message, { timeout: 20000 });
|
|
await expect(this.saveFunnelDialog).toBeVisible();
|
|
}
|
|
|
|
async closeSaveFunnelDialog() {
|
|
await this.saveFunnelCancelBtn.click();
|
|
await expect(this.saveFunnelDialog).toBeHidden({ timeout: 20000 });
|
|
}
|
|
|
|
/** The tooltip the Save button's holder shows on hover. */
|
|
async saveBtnTooltip() {
|
|
await this.funnelSaveBtnHolder.hover();
|
|
const tip = this.page.getByRole('tooltip');
|
|
await expect(tip).toBeVisible({ timeout: 10000 });
|
|
return tip;
|
|
}
|
|
|
|
/** Flags, from the first paint on, any moment the KPI strip shows `text`; read it back with kpiTextSeen(). */
|
|
async watchKpiText(text) {
|
|
await this.page.addInitScript((needle) => {
|
|
// Init scripts run on every navigation, so the flag keeps one observer per document.
|
|
if (window.__kpiWatchInstalled) return;
|
|
window.__kpiWatchInstalled = true;
|
|
window.__kpiTextSeen = false;
|
|
new MutationObserver(() => {
|
|
const strip = document.querySelector('[data-test="rum-analytics-kpi-strip"]');
|
|
if (strip && strip.textContent.includes(needle)) window.__kpiTextSeen = true;
|
|
}).observe(document, { childList: true, subtree: true, characterData: true });
|
|
}, text);
|
|
}
|
|
|
|
async kpiTextSeen() {
|
|
return this.page.evaluate(() => window.__kpiTextSeen === true);
|
|
}
|
|
|
|
/** Text of the whole page; regression checks assert a raw server error never reaches it. */
|
|
async expectPageNotToContain(text) {
|
|
await expect(this.body).not.toContainText(text);
|
|
}
|
|
|
|
/** Resolves after `quietMs` with no request in flight; networkidle is not reset by in-app navigation. */
|
|
async waitForNetworkQuiet(quietMs = 1000, timeoutMs = 30000) {
|
|
let inflight = 0;
|
|
let last = Date.now();
|
|
const up = () => {
|
|
inflight++;
|
|
last = Date.now();
|
|
};
|
|
const down = () => {
|
|
inflight = Math.max(0, inflight - 1);
|
|
last = Date.now();
|
|
};
|
|
this.page.on('request', up);
|
|
this.page.on('requestfinished', down);
|
|
this.page.on('requestfailed', down);
|
|
try {
|
|
await expect
|
|
.poll(() => inflight === 0 && Date.now() - last >= quietMs, { timeout: timeoutMs, intervals: [100] })
|
|
.toBe(true);
|
|
} finally {
|
|
this.page.off('request', up);
|
|
this.page.off('requestfinished', down);
|
|
this.page.off('requestfailed', down);
|
|
}
|
|
}
|
|
|
|
/** WCAG contrast of an element's text against its nearest opaque background. */
|
|
async contrastOf(locator) {
|
|
return locator.evaluate((el) => {
|
|
const parse = (c) => {
|
|
const m = c.match(/rgba?\(([^)]+)\)/);
|
|
if (!m) return null;
|
|
const [r, g, b, a = '1'] = m[1].split(/[ ,/]+/).filter(Boolean);
|
|
return { r: +r, g: +g, b: +b, a: +a };
|
|
};
|
|
const lum = ({ r, g, b }) => {
|
|
const f = (v) => {
|
|
const s = v / 255;
|
|
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
};
|
|
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
|
};
|
|
const fg = parse(getComputedStyle(el).color);
|
|
let node = el;
|
|
let bg = null;
|
|
while (node) {
|
|
const c = parse(getComputedStyle(node).backgroundColor);
|
|
if (c && c.a > 0.5) {
|
|
bg = c;
|
|
break;
|
|
}
|
|
node = node.parentElement;
|
|
}
|
|
bg = bg || { r: 255, g: 255, b: 255, a: 1 };
|
|
const [hi, lo] = [lum(fg), lum(bg)].sort((a, b) => b - a);
|
|
return (hi + 0.05) / (lo + 0.05);
|
|
});
|
|
}
|
|
}
|