Files
openobserve/tests/ui-testing/pages/alertsPages/alertSaveQueryModePage.js
T

293 lines
12 KiB
JavaScript

// Copyright 2026 OpenObserve Inc.
import { expect } from '@playwright/test';
const testLogger = require('../../playwright-tests/utils/test-logger.js');
const { getOrgIdentifier } = require('../../playwright-tests/utils/cloud-auth.js');
/**
* AlertSaveQueryModePage — the "Choose Query Mode" dialog Save opens when more
* than one query mode is in play, plus the bits of the alert editor those tests
* drive (the mode toggle, the SQL editor, the stream/destination selects).
*
* Owns the alert-save-mode-* selectors so specs stay selector-free.
*/
export class AlertSaveQueryModePage {
constructor(page) {
this.page = page;
this.locators = {
// ── the dialog ──────────────────────────────────────────────────────
dialog: '[data-test="alert-save-mode-dialog"]',
lead: '[data-test="alert-save-mode-lead"]',
warning: '[data-test="alert-save-mode-warning"]',
note: '[data-test="alert-save-mode-note"]',
primaryBtn: '[data-test="alert-save-mode-dialog"] [data-test="o-dialog-primary-btn"]',
secondaryBtn: '[data-test="alert-save-mode-dialog"] [data-test="o-dialog-secondary-btn"]',
// ── the editor ──────────────────────────────────────────────────────
submit: '[data-test="add-alert-submit-btn"]',
nameTrigger: '[data-test="add-alert-name-input-trigger"]',
nameInput: '[data-test="add-alert-name-input-input"]',
streamTrigger: '[data-test="add-alert-stream-name-select-dropdown-trigger"]',
destinationTrigger: '[data-test="alert-destinations-select-trigger"]',
addCondition: '[data-test="alert-conditions-add-condition-btn"]',
conditionColumn: '[data-test="alert-conditions-select-column-trigger"]',
conditionValue: '[data-test="alert-conditions-value-input-field"]',
threshold: '[data-test="alert-trigger-threshold-input-field"]',
realtimeTab: '[data-test="add-alert-type-tab-true"]',
scheduledTab: '[data-test="add-alert-type-tab-false"]',
alertRulesTab: '[data-test="add-alert-tab-condition"]',
advancedTab: '[data-test="add-alert-tab-advanced"]',
streamTypeTrigger: '[data-test="add-alert-stream-type-select-dropdown-trigger"]',
// Compare-with-Past lives on the Advanced tab, not Alert Rules.
multiWindowAdd: '[data-test="multi-time-range-alerts-add-btn"]',
multiWindowDelete: '[data-test="multi-time-range-alerts-delete-btn"]',
modeToggle: '[data-test="step2-query-tabs"]',
// Two nodes carry this: the visible query editor and the hidden anomaly preview.
queryEditor: '[data-test="query-editor"]:visible',
detailCondition: '[data-test="alerts-alertconfigsummary-condition"]',
};
}
modeOption(mode) {
return `[data-test="alert-save-mode-option-${mode}"]`;
}
modeTab(mode) {
return `[data-test="query-mode-${mode}"]`;
}
// ── navigation ─────────────────────────────────────────────────────────
async openEditor(alertId, { folder = 'default' } = {}) {
await this.page.goto(`/web/alerts?org_identifier=${getOrgIdentifier()}&action=update&alert_id=${alertId}&folder=${folder}`);
await expect(this.page.locator(this.locators.submit)).toBeVisible({ timeout: 30000 });
// The form shell renders before the alert is fetched, and a Save fired in
// that window reads an unpopulated form. The stream select is empty until
// the alert lands, so it doubles as the "data is in" signal.
await expect(this.page.locator(this.locators.streamTrigger))
.not.toHaveAttribute('data-test-selected-value', '', { timeout: 30000 });
testLogger.info('Opened alert editor', { alertId });
}
async openNewAlert({ folder = 'default' } = {}) {
await this.page.goto(`/web/alerts?org_identifier=${getOrgIdentifier()}&action=add&folder=${folder}`);
await expect(this.page.locator(this.locators.submit)).toBeVisible({ timeout: 30000 });
}
async openDetailConfiguration(alertId, { folder = 'default' } = {}) {
await this.page.goto(`/web/alerts/detail/${alertId}?org_identifier=${getOrgIdentifier()}&folder=${folder}`);
await this.page.getByRole('tab', { name: 'Configuration' }).click();
}
// ── form input ─────────────────────────────────────────────────────────
/** Reka's listbox renders into a portal, so options are addressed by role. */
async #pickFromDropdown(trigger, optionName) {
await this.page.locator(trigger).click();
const choice = this.page.getByRole('option', { name: optionName, exact: true });
await choice.waitFor({ state: 'visible', timeout: 15000 });
await choice.click();
}
async selectStream(streamName) {
await this.#pickFromDropdown(this.locators.streamTrigger, streamName);
}
/** Multi-select: the popover stays open after a pick, so it needs dismissing. */
async selectDestination(destinationName) {
await this.#pickFromDropdown(this.locators.destinationTrigger, destinationName);
await this.page.keyboard.press('Escape');
}
async addBuilderCondition(column, value) {
await this.page.locator(this.locators.addCondition).click();
await this.#pickFromDropdown(this.locators.conditionColumn, column);
await this.page.locator(this.locators.conditionValue).fill(value);
}
/** The name field is an inline edit: open it, then type. Enter submits the form. */
async setName(name, { submit = false } = {}) {
await this.page.locator(this.locators.nameTrigger).click();
await this.page.locator(this.locators.nameInput).fill(name);
if (submit) await this.page.locator(this.locators.nameInput).press('Enter');
}
async setThreshold(value) {
await this.page.locator(this.locators.threshold).fill(value);
}
async selectQueryMode(mode) {
await this.page.locator(this.modeTab(mode)).click();
}
async selectRealtime() {
await this.page.locator(this.locators.realtimeTab).click();
}
async selectScheduled() {
await this.page.locator(this.locators.scheduledTab).click();
}
async selectStreamType(streamType) {
await this.#pickFromDropdown(this.locators.streamTypeTrigger, streamType);
}
async openAdvancedTab() {
await this.page.locator(this.locators.advancedTab).click();
await expect(this.page.locator(this.locators.multiWindowAdd)).toBeVisible({ timeout: 20000 });
}
/** Compare-with-Past reference window. Requires the Advanced tab to be open. */
async addCompareWithPastWindow() {
const before = await this.page.locator(this.locators.multiWindowDelete).count();
await this.page.locator(this.locators.multiWindowAdd).click();
await expect(this.page.locator(this.locators.multiWindowDelete)).toHaveCount(before + 1, {
timeout: 20000,
});
}
async expectCompareWithPastWindowCount(count) {
await expect(this.page.locator(this.locators.multiWindowDelete)).toHaveCount(count, {
timeout: 20000,
});
}
async clickSave() {
await this.page.locator(this.locators.submit).click();
}
/**
* Monaco auto-closes quotes, so typing a query key by key turns `FROM "x"`
* into `FROM "x""` and the parser gives up — which silently kills the stream
* sync some tests assert on. Filling the backing textarea sets the model in
* one go and skips the auto-close entirely. A fill that lands before Monaco
* has wired up its model is dropped without error, so the write is retried
* until the new FROM table is on screen.
*/
async setSql(sql) {
const editor = this.page.locator(this.locators.queryEditor).first();
await editor.waitFor({ state: 'visible', timeout: 20000 });
const input = editor.locator('textarea.inputarea');
await input.waitFor({ state: 'attached', timeout: 20000 });
const table = (sql.match(/FROM\s+"([^"]+)"/i) || [])[1];
await expect(async () => {
await editor.locator('.view-lines').click();
await input.fill(sql);
await expect(editor).toContainText(table, { timeout: 5000 });
}).toPass({ timeout: 40000 });
// Click away afterwards: the stream-name sync runs off the committed value,
// which is why #15091/#15092 both say "click outside the editor and wait".
await this.page.locator(this.locators.alertRulesTab).click();
}
// ── dialog actions ─────────────────────────────────────────────────────
async pickSaveMode(mode) {
await this.page.locator(this.modeOption(mode)).click();
await expect(this.page.locator(this.modeOption(mode))).toHaveAttribute('data-state', 'on');
}
/**
* The dialog closes before the request is sent — `saveWithPickedMode` clears
* the open flag, then awaits a tick and submits — so a dialog-hidden wait
* returns while the PUT is still in flight and a caller reading the alert
* back sees the pre-save copy. The editor closing is the signal that the save
* actually round-tripped.
*/
async confirmSaveMode() {
await this.page.locator(this.locators.primaryBtn).click();
await expect(this.page.locator(this.locators.dialog)).toBeHidden({ timeout: 30000 });
await expect(this.page.locator(this.locators.submit)).toBeHidden({ timeout: 30000 });
}
async cancelSaveMode() {
await this.page.locator(this.locators.secondaryBtn).click();
await expect(this.page.locator(this.locators.dialog)).toBeHidden();
}
// ── assertions ─────────────────────────────────────────────────────────
async expectDialogOpen() {
await expect(this.page.locator(this.locators.dialog)).toBeVisible({ timeout: 15000 });
}
async expectDialogClosed() {
await expect(this.page.locator(this.locators.dialog)).toBeHidden();
}
async expectLead(text) {
await expect(this.page.locator(this.locators.lead)).toHaveText(text);
}
async expectWarning(text) {
await expect(this.page.locator(this.locators.warning)).toHaveText(text);
}
async expectNoWarning() {
await expect(this.page.locator(this.locators.warning)).toHaveCount(0);
}
async expectNote(text) {
await expect(this.page.locator(this.locators.note)).toHaveText(text);
}
async expectPrimaryButtonLabel(text) {
await expect(this.page.locator(this.locators.primaryBtn)).toHaveText(text);
}
async expectSaveModeSelected(mode) {
await expect(this.page.locator(this.modeOption(mode))).toHaveAttribute('data-state', 'on');
}
async expectQueryModeSelected(mode) {
await expect(this.page.locator(this.modeTab(mode))).toHaveAttribute('data-state', 'on');
}
async expectModeToggleHidden() {
await expect(this.page.locator(this.locators.modeToggle)).toBeHidden();
}
async expectOnEditor() {
await expect(this.page.locator(this.locators.submit)).toBeVisible();
}
/**
* The dialog is the only thing between Save and the request, so "no dialog"
* is asserted by the save completing: the editor closes back to the list.
*/
async expectSavedWithoutDialog() {
await this.expectDialogClosed();
await expect(this.page.locator(this.locators.submit)).toBeHidden({ timeout: 30000 });
}
async expectStreamName(streamName) {
await expect(this.page.locator(this.locators.streamTrigger)).toHaveText(streamName, { timeout: 20000 });
}
async expectThreshold(value) {
// The field repaints from the fetched alert, so allow for the round trip.
await expect(this.page.locator(this.locators.threshold)).toHaveValue(value, { timeout: 20000 });
}
async expectSqlContains(text) {
await expect(this.page.locator(this.locators.queryEditor).first())
.toContainText(text, { timeout: 20000 });
}
async expectValidationToast() {
await expect(
this.page.getByText('Please fix the highlighted fields before saving.').first(),
).toBeVisible({ timeout: 15000 });
}
async expectDetailConditionEmpty() {
const condition = this.page.locator(this.locators.detailCondition);
await expect(condition).toContainText('—');
await expect(condition).not.toContainText('SELECT');
}
async expectDetailConditionShowsSql() {
await expect(this.page.locator(this.locators.detailCondition)).toContainText('SELECT');
}
}