Files
openobserve/tests/ui-testing/pages/workflowsPages/workflowsPage.js
T
71ac1071be fix: draft workflows (#13666)
Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com>
Co-authored-by: Yashodhan Joshi <yjdoc2@gmail.com>
Co-authored-by: Yashodhan Joshi <yashodhan@openobserve.ai>
2026-08-18 09:15:28 +00:00

429 lines
22 KiB
JavaScript

// Workflows (v1) page object — OpenObserve Enterprise "Workflows" feature.
// Feature: event(alert_fired) -> action(remote/pipeline destination), optional Condition/Function nodes.
// Selectors reconciled against merged main (web/src/components/workflows/*, 2026-07-21). See
// tests/ui-testing/MD_Files/features/workflows/ for the full test plan + findings (K9/K10 etc).
// IMPORTANT — O2 component data-test pattern: OInput/OSelect/OTable render the consumer's
// data-test on a NON-interactive wrapper (the <OInput data-test="x"> value lands on a <div>).
// The fillable <input>/<textarea> is exposed as `x-field`; OTable's container is `o2-table-root`.
// Always target the inner element for fill()/type(), never the wrapper.
// NOTE: the canvas node-interaction selectors (trigger add-out/delete, palette-*) are NOT yet
// present in merged markup — the nodes render on a vue-flow canvas. They feed only the parked
// test.fixme cases and must be discovered live on an ENT build before those tests are enabled.
//
// Known quirks baked in here:
// K9 — the editor Save button has a tooltip overlay that intercepts pointer events; we click it
// via evaluate() to bypass the interception.
// K10 — the workflows list GET is slow (~16-20s); readiness/list helpers use generous timeouts.
const { expect } = require('@playwright/test');
const testLogger = require('../../playwright-tests/utils/test-logger.js');
const { getOrgIdentifier } = require('../../playwright-tests/utils/cloud-auth.js');
const LIST_TIMEOUT_MS = 45000; // K10: list load is slow
const DRAWER_TIMEOUT_MS = 15000;
class WorkflowsPage {
constructor(page) {
this.page = page;
// Header / list
this.addBtn = '[data-test="workflow-list-add-btn"]';
// OInput puts the consumer data-test on a NON-interactive wrapper; the real <input> is
// exposed as `<name>-field`. For list readiness use the component-owned page container
// `workflows-list-page` (present + visible on the list route regardless of OTable version
// and before the slow list GET settles) rather than an OTable-internal container, which
// differs across builds (o2-table-root vs o2-table / forwarded workflow-list-table).
this.searchInput = '[data-test="workflow-list-search-input-field"]';
this.listTable = '[data-test="workflows-list-page"]';
// Editor
this.editorPage = '[data-test="workflow-editor-page"]';
// Name and description are inline-edited titles (OInlineEdit): a display
// trigger swaps to an input on click, then commits (Enter/blur) back to the
// read-only value span. -trigger opens, -input edits, -value reads.
this.nameTrigger = '[data-test="workflow-editor-name-trigger"]';
this.nameInput = '[data-test="workflow-editor-name-input"]';
this.nameValue = '[data-test="workflow-editor-name-value"]';
this.descTrigger = '[data-test="workflow-editor-description-trigger"]';
this.descInput = '[data-test="workflow-editor-description-input"]';
// Inline "create function" form inside a Function node drawer — the name is
// an inline-edited title (OFormInlineEdit), same as the Functions page.
this.nodeFunctionCreateToggle = '[data-test="create-function-toggle-btn"]';
this.nodeFunctionNameTrigger = '[data-test="add-function-name-input-trigger"]';
this.nodeFunctionNameInput = '[data-test="add-function-name-input-input"]';
this.saveBtn = '[data-test="workflow-editor-save"]';
this.publishBtn = '[data-test="workflow-editor-publish"]';
this.testBtn = '[data-test="workflow-editor-test"]';
this.cancelBtn = '[data-test="workflow-editor-cancel"]';
this.backBtn = '[data-test="workflow-editor-back"]';
// Docked palette (the reliable click-to-append add path). NOTE: the rail is
// COLLAPSED by default — open it via the canvas control stack
// ([data-test="workflow-palette-collapse-btn"]) before using these.
// Pattern: workflow-palette-<key>-<ioType>-btn (destination=output, condition/function=default).
this.paletteCondition = '[data-test="workflow-palette-condition-default-btn"]';
this.paletteFunction = '[data-test="workflow-palette-function-default-btn"]';
this.paletteDestination = '[data-test="workflow-palette-destination-output-btn"]';
// Trigger node (delete only visible on node hover). The hover-`+` add-out
// button no longer exists — clicking the node's SOURCE HANDLE opens the step
// picker instead.
this.triggerNode = '[data-test="workflow-node-workflow_trigger"]';
this.triggerDelete = '[data-test="workflow-node-workflow_trigger-delete-btn"]';
// Empty-canvas start node -> trigger picker. The editor no longer pre-places
// an Alert Trigger on create, so a workflow now BEGINS by choosing one here.
this.startNode = '[data-test="workflow-flow-start-node"]';
// Trigger-picker items are keyed by TRIGGER KIND (alert_fired, incident_event) —
// the picker offers one row per enabled kind (all of node_type workflow_trigger).
this.stepTriggerFor = (kind = 'alert_fired') => `[data-test="workflow-step-${kind}"]`;
// Step picker dialog (source-handle path); options keyed by node_type
this.stepCondition = '[data-test="workflow-step-condition"]';
this.stepFunction = '[data-test="workflow-step-function"]';
this.stepDestination = '[data-test="workflow-step-destination"]';
// Node config drawer (generic O2 drawer). An overlay (o-drawer-overlay) intercepts canvas
// clicks while a drawer is open, and the trigger drawer auto-opens on /add — close it first.
this.nodeDrawer = '[data-test="workflow-node-drawer"]';
this.drawerClose = '[data-test="o-drawer-close-btn"]';
this.drawerPrimary = '[data-test="o-drawer-primary-btn"]';
this.drawerSecondary = '[data-test="o-drawer-secondary-btn"]';
// Destination picker (inside node drawer). OSelect/OToggle put the consumer data-test on a
// wrapper div; the interactive element is the `-trigger`/`-btn` child — use those to click.
this.destPicker = '[data-test="destination-picker"]';
this.destPickerCreateToggle = '[data-test="destination-picker-create-toggle-btn"]';
this.destPickerSelectTrigger = '[data-test="destination-picker-select-trigger"]';
this.destPickerSelectPopover = '[data-test="destination-picker-select-popover"]';
// Inline "create destination" wizard: step 1 choose-type card + continue, step 2 connection.
this.destTypeCard = (t) => `[data-test="destination-type-card-${t}"]`;
this.destStep1Continue = '[data-test="step1-continue-btn"]';
this.destNameField = '[data-test="add-destination-name-input-field"]';
this.destUrlField = '[data-test="add-destination-url-input-field"]';
this.destSubmitBtn = '[data-test="add-destination-submit-btn"]';
// Post-save "link to alerts" dialog (ODialog): Skip = secondary button.
this.linkAlertsDialog = '[data-test="workflow-link-alerts-dialog"]';
this.dialogSecondary = '[data-test="o-dialog-secondary-btn"]';
}
// Workflows is an Enterprise-only feature. On OSS builds the API returns 404/403 and the menu is
// hidden, so specs must skip rather than fail. Probe the list endpoint to decide.
async isAvailable() {
const url = `${process.env.ZO_BASE_URL}/api/${getOrgIdentifier()}/workflows`;
try {
const resp = await this.page.request.get(url);
return resp.status() !== 404 && resp.status() !== 403;
} catch (_e) {
return false;
}
}
// ---------- navigation ----------
async goToList() {
const url = `${process.env.ZO_BASE_URL}/web/workflows?org_identifier=${getOrgIdentifier()}`;
await this.page.goto(url, { timeout: 60000 });
await this.waitForListReady();
}
/**
* Open the create editor AND choose the Alert Trigger, so callers start from a
* workflow that has its trigger — the state the editor used to seed itself.
* (`?trigger=` is gone; the canvas starts empty and asks via the start node.)
* Use `goToAddEmpty()` when a spec needs the untouched empty canvas.
*/
async goToAdd(kind = 'alert_fired') {
await this.goToAddEmpty();
await this.chooseTrigger(kind);
}
async goToAddEmpty() {
const url = `${process.env.ZO_BASE_URL}/web/workflows/add?org_identifier=${getOrgIdentifier()}`;
await this.page.goto(url, { timeout: 60000 });
await this.page.locator(this.nameTrigger).waitFor({ state: 'attached', timeout: DRAWER_TIMEOUT_MS });
}
/**
* Empty canvas -> start node -> trigger picker -> pick a kind (default
* alert_fired; pass 'incident_event' for the incident lifecycle). The trigger's
* panel is a read-only payload reference, so it is dismissed, not saved.
*/
async chooseTrigger(kind = 'alert_fired') {
await this.page.locator(this.startNode).click({ timeout: DRAWER_TIMEOUT_MS });
await this.page.locator(this.stepTriggerFor(kind)).click({ timeout: DRAWER_TIMEOUT_MS });
await this.page.locator(this.triggerNode).waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
await this.closeOpenDrawer();
}
async waitForListReady() {
// K10: list GET is slow; wait for either rows or the empty-state, not "Loading data".
await this.page.locator(this.listTable).first().waitFor({ state: 'visible', timeout: LIST_TIMEOUT_MS });
await this.page.locator('text=Loading data')
.waitFor({ state: 'detached', timeout: LIST_TIMEOUT_MS }).catch(() => {});
}
// ---------- editor helpers ----------
async setName(name) {
// Open the inline editor, fill it, and commit (Enter) so the name lands in
// the read-only value span. The name has no owning <form>, so Enter only
// commits — it does not submit/navigate.
await this.page.locator(this.nameTrigger).click();
const input = this.page.locator(this.nameInput);
await input.waitFor({ state: 'visible' });
await input.fill(name);
await input.press('Enter');
}
// ---------- assertions (keep raw locators out of specs) ----------
async expectNameValue(name) {
// After commit the name renders as the read-only value span; textContent is
// the full value even when CSS-truncated visually.
await expect(this.page.locator(this.nameValue)).toHaveText(name);
}
async expectEditorVisible() {
await expect(this.page.locator(this.editorPage)).toBeVisible();
}
async expectListVisible() {
await expect(this.page.locator(this.listTable).first()).toBeVisible();
}
async setDescription(desc) {
// Description is inline-edited too: open, fill, commit.
await this.page.locator(this.descTrigger).click();
const input = this.page.locator(this.descInput);
await input.waitFor({ state: 'visible' });
await input.fill(desc);
await input.press('Enter');
}
// K9: bypass the tooltip that intercepts pointer events on Save.
async clickSave() {
await this.page.evaluate((sel) => {
const b = document.querySelector(sel);
if (!b) throw new Error('save button not found: ' + sel);
b.click();
}, this.saveBtn);
}
async clickPublish() {
await this.page.evaluate((sel) => {
const b = document.querySelector(sel);
if (!b) throw new Error('publish button not found: ' + sel);
b.click();
}, this.publishBtn);
}
/**
* K9 canary probe — attempts a PLAIN Playwright click on Save (no JS bypass) and returns
* true if it was intercepted (the click never landed). This is NON-deterministic in headless
* CI: Save has no tooltip of its own (WorkflowEditor.vue), so the overlay that intercepts it
* is a transient/adjacent tooltip that often never appears on a programmatic hover.
* Callers MUST treat the result as informational only, not a hard assertion — see the CT-19
* K9 spec. A consistent `false` would be the signal that K9 is fixed and the clickSave()
* JS-bypass workaround can be dropped.
*/
async normalSaveClickIsIntercepted(timeoutMs = 4000) {
await this.page.locator(this.saveBtn).hover().catch(() => {});
try {
await this.page.locator(this.saveBtn).click({ timeout: timeoutMs });
return false;
} catch (_e) {
return true;
}
}
async clickTest() {
await this.page.evaluate((sel) => {
const b = document.querySelector(sel);
if (!b) throw new Error('test button not found: ' + sel);
b.click();
}, this.testBtn);
}
/** The trigger's config drawer auto-opens on /add; its overlay blocks canvas/palette clicks. */
async closeOpenDrawer() {
const close = this.page.locator(this.drawerClose);
if (await close.count()) {
await close.first().click({ timeout: DRAWER_TIMEOUT_MS }).catch(() => {});
await this.page.locator('[data-test="o-drawer-overlay"]')
.waitFor({ state: 'detached', timeout: DRAWER_TIMEOUT_MS }).catch(() => {});
}
}
/**
* Append a node from the docked palette (auto-wires after the end node) and wait for its
* config drawer. The palette is the reliable add path — the on-node hover-`+` is hidden.
*/
async ensureNodePaletteOpen() {
const rail = this.page.locator('[data-test="workflow-palette"]');
if (await rail.isVisible().catch(() => false)) return;
await this.page.locator('[data-test="workflow-palette-collapse-btn"]').click();
await rail.waitFor({ state: 'visible' });
}
async addNodeFromPalette(type /* 'condition' | 'function' | 'destination' */) {
await this.closeOpenDrawer();
await this.ensureNodePaletteOpen();
const paletteSel = { condition: this.paletteCondition, function: this.paletteFunction, destination: this.paletteDestination }[type];
await this.page.locator(paletteSel).click({ timeout: DRAWER_TIMEOUT_MS });
await this.page.locator(this.nodeDrawer).waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
}
/**
* In an open Destination node drawer, create a new remote destination inline and bind it to
* the node.
*
* The workflow destination node passes `forced-type="custom"` (WorkflowDestination.vue) to the
* shared DestinationPicker, so CreateDestinationForm opens DIRECTLY on the Connection step
* (name/url) with `step === 2` and hides the type-selection step — there is no
* `destination-type-card-*` / `step1-continue-btn` in this flow. We assert that expected UX
* (name field present right after toggling create); we do NOT tolerate a type-selection step
* here — if it reappears, the forced-type contract has changed and this should fail loudly.
*/
async createDestinationInline({ name, url }) {
await this.page.locator(this.destPickerCreateToggle).click({ timeout: DRAWER_TIMEOUT_MS });
const nameField = this.page.locator(this.destNameField);
await nameField.waitFor({ state: 'attached', timeout: DRAWER_TIMEOUT_MS });
await nameField.fill(name);
await this.page.locator(this.destUrlField).fill(url);
await this.page.locator(this.destSubmitBtn).click({ timeout: DRAWER_TIMEOUT_MS });
}
/**
* Open the Test drawer from the editor and run the saved graph against the (pre-filled) sample
* payload. Per-node results paint as ✓/✗ badges on the canvas nodes afterwards.
*/
async testRunFromEditor() {
await this.page.locator(this.testBtn).click({ timeout: DRAWER_TIMEOUT_MS });
await this.page.locator('[data-test="workflow-test-drawer"]').waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
await this.page.locator('[data-test="workflow-test-drawer"] [data-test="o-drawer-primary-btn"]')
.click({ timeout: DRAWER_TIMEOUT_MS });
}
/** Assert a node painted an error badge after a test run (node_type e.g. 'destination','function').
* Generous timeout — on slow CI runners the run + failing send can take a while to resolve. */
async expectNodeTestError(nodeType, timeout = 60000) {
await expect(this.page.locator(`[data-test="workflow-node-${nodeType}-test-error"]`))
.toBeVisible({ timeout });
}
/** Assert a node painted a success badge after a test run. */
async expectNodeTestOk(nodeType, timeout = 60000) {
await expect(this.page.locator(`[data-test="workflow-node-${nodeType}-test-ok"]`))
.toBeVisible({ timeout });
}
/** Commit the node config drawer (binds the node) and wait for it to close. */
async saveNodeDrawer() {
await this.page.locator(this.drawerPrimary).click({ timeout: DRAWER_TIMEOUT_MS });
await this.page.locator(this.nodeDrawer).waitFor({ state: 'detached', timeout: DRAWER_TIMEOUT_MS }).catch(() => {});
}
/** If a node drawer is still open, commit it; otherwise no-op (some inline saves auto-close). */
async bindNodeDrawerIfOpen() {
const drawer = this.page.locator(this.nodeDrawer);
if (await drawer.isVisible().catch(() => false)) {
await this.page.locator(this.drawerPrimary).click({ timeout: DRAWER_TIMEOUT_MS }).catch(() => {});
await drawer.waitFor({ state: 'detached', timeout: DRAWER_TIMEOUT_MS }).catch(() => {});
}
}
/**
* In an open Function node drawer, open the inline "create function" form, type code into the
* (QuickJS/JavaScript) editor, attempt to save, and return the resulting toast text. Used to
* assert that INVALID function code is rejected gracefully (a compile-error message, no crash).
*/
async attemptCreateFunction({ name, code }) {
await this.page.locator(this.nodeFunctionCreateToggle).click({ timeout: DRAWER_TIMEOUT_MS });
// Open the inline name editor, then fill the revealed input.
await this.page.locator(this.nodeFunctionNameTrigger).waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
await this.page.locator(this.nodeFunctionNameTrigger).click({ timeout: DRAWER_TIMEOUT_MS });
await this.page.locator(this.nodeFunctionNameInput).waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
await this.page.locator(this.nodeFunctionNameInput).fill(name);
// Function code editor (workflow functions run on QuickJS — JavaScript, not VRL).
const editor = this.page.locator('[data-test="logs-vrl-function-editor"]');
await editor.click();
await this.page.keyboard.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
await this.page.keyboard.type(code);
await this.page.locator('[data-test="add-function-save-btn"]').click({ timeout: DRAWER_TIMEOUT_MS });
const toast = this.page.locator('[role="alert"], .q-notification__message').first();
await toast.waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS }).catch(() => {});
return (await toast.textContent().catch(() => '') || '').trim();
}
/** After a successful create, the "Link to alerts" dialog appears — Skip it (secondary btn). */
async skipLinkAlerts() {
const dlg = this.page.locator(this.linkAlertsDialog);
if (await dlg.waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS }).then(() => true).catch(() => false)) {
await this.page.locator(this.dialogSecondary).first().click({ timeout: DRAWER_TIMEOUT_MS }).catch(() => {});
}
}
/**
* Full happy path: build a Trigger -> Destination workflow with a freshly-created destination
* and save it. Returns nothing; caller verifies via the list.
*/
async buildTriggerToDestinationAndSave({ name, destName, url }) {
await this.goToAdd();
await this.setName(name);
await this.addNodeFromPalette('destination');
await this.createDestinationInline({ name: destName, url });
await this.saveNodeDrawer();
await this.clickPublish();
await this.skipLinkAlerts();
}
/**
* Save the workflow. Returns the toast/validation text so callers can assert
* (e.g. trigger-only save -> "Add At Least One Step After The Trigger").
*/
async saveAndCaptureResult() {
await this.clickPublish();
const toast = this.page.locator('[role="alert"], .q-notification__message').first();
await toast.waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS }).catch(() => {});
return (await toast.textContent().catch(() => '') || '').trim();
}
// ---------- list operations ----------
async search(text) {
await this.page.locator(this.searchInput).fill(text);
}
// OTable renders rows as divs (not <tr>); each row exposes per-action data-tests keyed by the
// workflow name: workflow-list-<name>-{view,edit,pause-start-action,more-options,delete}.
// Match on that prefix so row lookup is independent of the table's DOM shape.
rowByName(name) {
return this.page.locator(`[data-test^="workflow-list-${name}-"]`);
}
async openEdit(name) {
await this.page.locator(`[data-test="workflow-list-${name}-edit"]`).first().click({ timeout: DRAWER_TIMEOUT_MS });
await this.page.locator(this.editorPage).waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
}
/** Delete via row action (in the more-options menu). Returns any error toast (delete-protected
* when linked to an alert). */
async deleteByName(name) {
await this.page.locator(`[data-test="workflow-list-${name}-more-options"]`).first().click({ timeout: DRAWER_TIMEOUT_MS }).catch(() => {});
await this.page.locator(`[data-test="workflow-list-${name}-delete"]`).first().click({ timeout: DRAWER_TIMEOUT_MS }).catch(() => {});
await this.page.locator('[data-test$="-confirm-button"], [data-test="dlg-primary"], button:has-text("OK"), button:has-text("Delete")')
.first().click({ timeout: DRAWER_TIMEOUT_MS }).catch(() => {});
const toast = this.page.locator('[role="alert"], .q-notification__message').first();
await toast.waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS }).catch(() => {});
return (await toast.textContent().catch(() => '') || '').trim();
}
async isPresent(name) {
await this.waitForListReady();
// The list is slow (K10) and virtualized — filter to the name, then wait for its row to
// appear rather than scanning the full (possibly not-yet-rendered) table.
await this.search(name);
return await this.rowByName(name).first()
.waitFor({ state: 'attached', timeout: LIST_TIMEOUT_MS })
.then(() => true).catch(() => false);
}
// ---------- enable / disable ----------
async toggleEnable(name) {
await this.page.locator(`[data-test="workflow-list-${name}-pause-start-action"]`).first().click({ timeout: DRAWER_TIMEOUT_MS });
}
}
module.exports = WorkflowsPage;