Files

1398 lines
69 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.
// The canvas node-interaction selectors (palette-*, workflow-node-*) ARE present in merged
// markup and sit on the live path of the build-and-configure specs — not parked fixmes.
//
// 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.
// K11 — under CI parallel load a node-drawer dropdown click can land without the list opening,
// and waiting longer never recovers it; openSelect() re-clicks a trigger that is still closed
// and reports every re-click (warn log + screenshot) so a real UI bug stays visible.
const { expect, test } = require('@playwright/test');
const testLogger = require('../../playwright-tests/utils/test-logger.js');
const { getOrgIdentifier } = require('../../playwright-tests/utils/cloud-auth.js');
const MonacoEditorHelper = require('../../playwright-tests/utils/MonacoEditorHelper.js');
const LIST_TIMEOUT_MS = 45000; // K10: list load is slow
const DRAWER_TIMEOUT_MS = 25000; // K11: bumped from 15s — drawer dropdowns under CI parallel load
// Availability is a property of the build, not of a test — probe it once per worker.
let workflowsEnabled;
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"]';
this.descValue = '[data-test="workflow-editor-description-value"]';
// Workflow single-screen function editor: the AddFunction body is ALWAYS
// visible below the associate-function select (FunctionPicker's `createButton`
// mode), so there is no "Create New" toggle. Save routes through the icon
// button next to the select, then a saved-function dialog collects a name and
// creates the function via the backend (which is where invalid JS surfaces).
this.nodeFunctionSaveBtn = '[data-test="wf-function-save-btn"]';
this.nodeFunctionNameInput = '[data-test="wf-saved-function-name-input-field"]';
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"]';
this.paletteBranch = '[data-test="workflow-palette-branch-default-btn"]';
// Branch node (N-way fan-out, #14027). Rows key on the case's STABLE handle, never
// its index: a surviving arm must keep the handle its edges are already wired to,
// so `case-N` comes off a high-water mark and is kept for the node's whole life.
this.stepBranch = '[data-test="workflow-step-branch"]';
this.branchBody = '[data-test="workflow-branch-body"]';
this.branchAddCase = '[data-test="workflow-branch-add-case"]';
this.branchElse = '[data-test="workflow-branch-else"]';
this.branchCaseRows = '[data-test^="workflow-branch-case-"]';
this.branchOrderBadges = '[data-test^="workflow-branch-order-"]';
this.branchCaseFor = (h) => `[data-test="workflow-branch-case-${h}"]`;
this.branchLabelFieldFor = (h) => `[data-test="workflow-branch-label-${h}-field"]`;
this.branchMoveUpFor = (h) => `[data-test="workflow-branch-move-up-${h}"]`;
this.branchMoveDownFor = (h) => `[data-test="workflow-branch-move-down-${h}"]`;
this.branchRemoveFor = (h) => `[data-test="workflow-branch-remove-case-${h}"]`;
// The else arm is a permanent row: it is what guarantees every record leaves by
// exactly one handle, so it renders WITHOUT a remove control.
this.branchElseRemove = '[data-test="workflow-branch-else-remove"]';
this.branchUnwiredBadge = '[data-test="workflow-node-branch-unwired-badge"]';
// Hover-only "+" on the canvas: one per OPEN (still unwired) source handle, in
// declared arm order, so nth(i) addresses arm i of what remains unwired.
this.flowAppendAdd = '[data-test="workflow-flow-append-add"]';
// Edge labels name the arm each connector leaves by — the only on-canvas evidence of
// which path a destination is actually wired to. They are HTML in vue-flow's label
// LAYER, not SVG <text>, and the layer also holds the (empty) hover-"+" wrappers, so
// callers must filter blanks rather than index children positionally. Note the class
// is plural: `.vue-flow__edge-label` matches nothing here.
this.edgeLabelLayer = '.vue-flow__edge-labels';
// 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 scaffold's trigger card -> trigger picker. The editor no
// longer pre-places an Alert Trigger on create; on a fresh /add the canvas
// renders a two-slot scaffold (workflow-flow-start-scaffold) and the trigger
// card is `workflow-flow-start-trigger`. `workflow-flow-start-node` is only
// the fallback when a step exists but the trigger was deleted.
this.startNode = '[data-test="workflow-flow-start-trigger"]';
// 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 is an ODialog (not the legacy O2 drawer) — buttons/close
// are `o-dialog-*`, and the panel commits config on close (there is no primary
// "Save" button in the footer; drawer close === save). An overlay
// (o-dialog-overlay) intercepts canvas clicks while the dialog is open.
this.nodeDrawer = '[data-test="workflow-node-drawer"]';
this.drawerClose = '[data-test="o-dialog-close-btn"]';
this.drawerPrimary = '[data-test="o-dialog-primary-btn"]';
this.drawerSecondary = '[data-test="o-dialog-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"]';
this.destPickerSelectSearch = '[data-test="destination-picker-select-search"]';
this.destPickerSelectOption = '[data-test="destination-picker-select-option"]';
// 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"]';
// Trigger config panel — a READ-ONLY payload reference, no config to save.
// Kinds with `commonMetaKeys` (incidents) render a SPLIT view: a variant
// OSelect plus common/event-specific blocks. Kinds without (alerts) render a
// single combined block. `no-extras` replaces the specific block when an
// event_type adds nothing (e.g. `created`, whose extras are {}).
// Canvas chrome + per-node selectors. Node-scoped ones are factories (same
// pattern as stepTriggerFor/destTypeCard) because they key on node_type / name.
this.dialogOverlay = '[data-test="o-dialog-overlay"]';
this.palette = '[data-test="workflow-palette"]';
this.paletteCollapseBtn = '[data-test="workflow-palette-collapse-btn"]';
this.nodeFor = (t) => `[data-test="workflow-node-${t}"]`;
this.nodeTestOkFor = (t) => `[data-test="workflow-node-${t}-test-ok"]`;
// A pass renders as the green ✓ on a draft but as the rehearsal flask on a
// published workflow (a test run must not read as production verification).
this.nodeTestPassedFor = (t) =>
`[data-test="workflow-node-${t}-test-ok"], [data-test="workflow-node-${t}-test-rehearsal"]`;
this.nodeTestErrorFor = (t) => `[data-test="workflow-node-${t}-test-error"]`;
this.nodeTestRehearsalFor = (t) => `[data-test="workflow-node-${t}-test-rehearsal"]`;
// Branch-arm append connectors (WorkflowCanvas appendPointsFor) — the hover-revealed
// `+` points, each carrying an SVG path whose `d` starts at the arm's own source handle.
this.appendAdd = '[data-test="workflow-flow-append-add"]';
// Direct-child svg only: FlowAddButton's `+` icon is another svg under the same
// wrapper (at `> button > svg`), and counting its path too yields 2 paths per arm.
this.appendAddPath = '[data-test="workflow-flow-append-add"] > svg path';
// Node hover actions (WorkflowNode): the disable/enable toggle and the Disabled badge.
this.nodeDisableToggle = '[data-test="workflows-node-disable-toggle"]';
this.nodeDisabledBadgeFor = (t) => `[data-test="workflow-node-${t}-disabled-badge"]`;
this.listRowPrefixFor = (n) => `[data-test^="workflow-list-${n}-"]`;
this.listRowActionFor = (n, a) => `[data-test="workflow-list-${n}-${a}"]`;
this.listDraftTag = '[data-test="workflow-list-draft-tag"]';
this.listTriggerTag = '[data-test="workflow-list-trigger-tag"]';
this.listRefresh = '[data-test="workflow-list-refresh"]';
// Draft vs published is the editor's whole mode switch: a draft offers
// Save-as-Draft + Publish, a published workflow offers Save + History instead.
this.editorDraftTag = '[data-test="workflow-editor-draft-tag"]';
this.saveDraftBtn = '[data-test="workflow-editor-save-draft"]';
this.historyBtn = '[data-test="workflow-editor-history"]';
this.unsavedDialog = '[data-test="workflows-editor-unsaved-dialog"]';
// Per-node hover actions. The rail only renders while the node is hovered, so
// every one of these needs a hover on the node first.
this.nodeActionsFor = (t) => `[data-test="workflow-node-${t}-actions"]`;
this.nodeDeleteBtnFor = (t) => `[data-test="workflow-node-${t}-delete-btn"]`;
this.nodeIncompleteBadgeFor = (t) => `[data-test="workflow-node-${t}-incomplete-badge"]`;
this.nodeDisabledBadgeFor = (t) => `[data-test="workflow-node-${t}-disabled-badge"]`;
this.nodeDisableToggle = '[data-test="workflows-node-disable-toggle"]';
this.nodeCommentIndicator = '[data-test="workflows-node-comment-indicator"]';
// Rename + comment live in the node DRAWER, not on the card.
// Rename is an OInlineEdit (trigger opens, -input edits, -value reads); the comment
// is an OTextarea whose writable element is -field. Both data-tests land on wrappers.
this.nodeRenameTrigger = '[data-test="workflows-node-rename-input-trigger"]';
this.nodeRenameField = '[data-test="workflows-node-rename-input-input"]';
this.nodeCommentField = '[data-test="workflows-node-comment-input-field"]';
this.canvasUndo = '[data-test="workflows-canvas-undo"]';
this.canvasTidy = '[data-test="workflows-canvas-tidy"]';
// Test drawer (reworked in #14027): the payload can come from the seeded sample
// or a previous run, and destinations are suppressed by DEFAULT so a rehearsal
// never posts to production — turning that off is what raises the dispatch warning.
this.testDrawer = '[data-test="workflow-test-drawer"]';
this.testInputSource = '[data-test="workflow-test-input-source"]';
this.testRunPicker = '[data-test="workflow-test-run-picker"]';
this.testRunFrom = '[data-test="workflow-test-run-from"]';
this.testRevertInput = '[data-test="workflow-test-revert-input"]';
this.testResetSample = '[data-test="workflow-test-reset-sample"]';
this.testSuppressDestinations = '[data-test="workflow-test-suppress-destinations"]';
this.testSuppressDestinationsBtn = '[data-test="workflow-test-suppress-destinations-btn"]';
this.testDispatchWarning = '[data-test="workflow-test-dispatch-warning"]';
this.ndvPrevStep = '[data-test="workflow-ndv-prev-step"]';
this.ndvNextStep = '[data-test="workflow-ndv-next-step"]';
this.runsPage = '[data-test="workflow-runs-page"]';
this.configSummaryBranch = '[data-test="workflow-config-summary-branch"]';
this.configSummaryBranchPaths = '[data-test^="workflow-config-summary-branch-path-"]';
// NDV output pane — on a successful destination send this holds the sink's
// response body, which is what makes delivery assertable.
this.ndvOutput = '[data-test="workflow-ndv-output"]';
// The Test panel is still a real ODrawer (WorkflowTestDialog.vue) — only the NODE
// config panel became an ODialog — so its primary button stays `o-drawer-*`.
this.testDrawerPrimary = '[data-test="workflow-test-drawer"] [data-test="o-drawer-primary-btn"]';
// Workflow function code editor (QuickJS/JavaScript), shared with the Functions page.
this.functionEditor = '[data-test="logs-vrl-function-editor"]';
// Warning toast — how a blocked Publish reports itself, since it never reaches the network.
this.warningToast = '[data-test^="o-toast-"][data-test-variant="warning"]';
this.anyToast = '[role="alert"], .q-notification__message';
this.confirmButton =
'[data-test="o-dialog-primary-btn"], [data-test$="-confirm-button"], [data-test="dlg-primary"], button:has-text("OK"), button:has-text("Delete")';
// Destination header rows are keyed by the header's CURRENT key, so these are
// functions rather than constants — a blank row answers to the empty-key form.
this.destHeaderKeyField = (key = '') => `[data-test="add-destination-header-${key}-key-input-field"]`;
this.destHeaderValueField = (key = '') => `[data-test="add-destination-header-${key}-value-input-field"]`;
this.triggerBody = '[data-test="workflow-trigger-body"]';
this.triggerVariantTrigger = '[data-test="workflow-trigger-sample-variant-trigger"]';
this.triggerVariantOption = '[data-test="workflow-trigger-sample-variant-option"]';
// Condition node body + the shared ConditionBuilder. OFormSelect/OFormInput
// bind {...$attrs} onto OSelect/OInput, so the consumer data-test lands on the
// wrapper and the interactive child is `-trigger` (select) / `-field` (input).
// Options carry data-test-value, so a rule is picked by VALUE, not by label.
this.conditionBody = '[data-test="workflow-condition-body"]';
this.conditionNote = '[data-test="workflow-condition-note"]';
this.conditionNoteDismiss = '[data-test="workflow-condition-note-dismiss"]';
this.conditionBuilder = '[data-test="condition-builder"]';
this.condColumnTrigger = '[data-test="alert-conditions-select-column-trigger"]';
this.condColumnSearch = '[data-test="alert-conditions-select-column-search"]';
this.condColumnOption = '[data-test="alert-conditions-select-column-option"]';
this.condOperatorTrigger = '[data-test="alert-conditions-operator-select-trigger"]';
this.condOperatorOption = '[data-test="alert-conditions-operator-select-option"]';
// v-if'd away entirely for unary operators (is_null/is_empty/...) — absence,
// not an empty string, is what a unary operator looks like in the DOM.
this.condValueField = '[data-test="alert-conditions-value-input-field"]';
this.condAddBtn = '[data-test="alert-conditions-add-condition-btn"]';
this.condDeleteBtn = '[data-test="alert-conditions-delete-condition-btn"]';
}
// Workflows is an Enterprise-only feature: on a build without it the API answers 404/403.
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;
}
}
/**
* Fail loudly, up front, when the feature is not enabled on the build under test.
*
* These specs run ONLY in the ENT playwright matrix (ci_matrix.ent.json `Workflows`
* shard), where playwright.yml pins O2_WORKFLOWS_ENABLED=true. A flipped default, a
* dropped env line or a broken route would otherwise surface as an opaque selector
* timeout deep inside a test — or, if anyone reintroduces a skip, as a silent green.
* Probed once per worker; every test then fails with the same actionable message.
*/
async assertEnabled() {
if (workflowsEnabled === undefined) workflowsEnabled = await this.isAvailable();
if (!workflowsEnabled) {
throw new Error(
`Workflows is NOT enabled on ${process.env.ZO_BASE_URL} (GET /api/${getOrgIdentifier()}/workflows returned 403/404). ` +
'These specs are enterprise-only and must never be skipped: set O2_WORKFLOWS_ENABLED=true on the build under test.'
);
}
}
// ---------- 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);
}
/** Close any open node dialog. Left in place as a defensive no-op — the new
* editor no longer auto-opens the trigger's config panel, but a stray dialog
* from a previous step would still block canvas/palette clicks via the
* o-dialog-overlay. */
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(this.dialogOverlay)
.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(this.palette);
if (await rail.isVisible().catch(() => false)) return;
await this.page.locator(this.paletteCollapseBtn).click();
await rail.waitFor({ state: 'visible' });
}
async addNodeFromPalette(type /* 'condition' | 'function' | 'destination' | 'branch' */) {
await this.closeOpenDrawer();
await this.ensureNodePaletteOpen();
const paletteSel = {
condition: this.paletteCondition,
function: this.paletteFunction,
destination: this.paletteDestination,
branch: this.paletteBranch,
}[type];
await this.page.locator(paletteSel).click({ timeout: DRAWER_TIMEOUT_MS });
// "Insert-immediately" — the palette adds the node in Set-up-later mode and
// does NOT auto-open the config panel. Click the freshly-added node to open
// its drawer. Multiple nodes of the same type can coexist; last() picks the
// newest for the caller's follow-up config.
const nodeSel = this.nodeFor(type);
const node = this.page.locator(nodeSel).last();
await node.waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
await node.click({ timeout: DRAWER_TIMEOUT_MS });
await this.page.locator(this.nodeDrawer).waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
}
/**
* Add a Branch from the palette and open its drawer. Same insert-immediately flow as
* addNodeFromPalette, but a Branch opens on one seeded row: a case-less Branch is
* rejected by the backend validator, so the panel never shows zero paths.
*/
async addBranchFromPalette() {
await this.closeOpenDrawer();
await this.ensureNodePaletteOpen();
await this.page.locator(this.paletteBranch).click({ timeout: DRAWER_TIMEOUT_MS });
const node = this.page.locator(this.nodeFor('branch')).last();
await node.waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
await node.click({ timeout: DRAWER_TIMEOUT_MS });
await this.page.locator(this.branchBody).waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
}
async openBranchDrawer() {
await this.closeOpenDrawer();
await this.page.locator(this.nodeFor('branch')).first().dblclick({ timeout: DRAWER_TIMEOUT_MS });
await this.page.locator(this.branchBody).waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
}
async addBranchCase() {
const before = await this.branchCaseCount();
await this.page.locator(this.branchAddCase).click({ timeout: DRAWER_TIMEOUT_MS });
await expect.poll(async () => this.branchCaseCount(), { timeout: DRAWER_TIMEOUT_MS }).toBe(before + 1);
}
async removeBranchCase(handle) {
const before = await this.branchCaseCount();
await this.page.locator(this.branchRemoveFor(handle)).click({ timeout: DRAWER_TIMEOUT_MS });
await expect.poll(async () => this.branchCaseCount(), { timeout: DRAWER_TIMEOUT_MS }).toBe(before - 1);
}
async moveBranchCase(handle, direction /* 'up' | 'down' */) {
const sel = direction === 'up' ? this.branchMoveUpFor(handle) : this.branchMoveDownFor(handle);
await this.page.locator(sel).click({ timeout: DRAWER_TIMEOUT_MS });
}
async branchCaseCount() {
return this.page.locator(this.branchCaseRows).count();
}
/**
* The ordinal badge on each case row, in rendered order. These are the user-visible
* statement of evaluation order, so they must renumber when rows move or are removed.
*/
async branchOrderValues() {
return this.page
.locator(this.branchOrderBadges)
.evaluateAll((els) => els.map((e) => (e.textContent || '').trim()));
}
/** Handles in RENDERED order — which is evaluation order, since first match wins. */
async branchCaseHandles() {
return this.page.locator(this.branchCaseRows).evaluateAll((els) =>
els.map((e) => (e.getAttribute('data-test') || '').replace('workflow-branch-case-', ''))
);
}
/**
* Path labels in rendered order. Scoped to the label field by data-test, NOT to `input`:
* every row also contains its ConditionBuilder's value input, which would interleave a
* blank entry after each label.
*/
async branchCaseLabels() {
return this.page
.locator(`${this.branchCaseRows} [data-test^="workflow-branch-label-"][data-test$="-field"]`)
.evaluateAll((els) => els.map((e) => e.value));
}
/**
* Configure one path. Every branch row renders its OWN ConditionBuilder, so the
* column/operator triggers must be scoped to the row; the option popover is a portal
* and stays global (only one is open at a time).
*/
async setBranchCase(handle, { label, column, operator = '=', value } = {}) {
const row = this.branchCaseFor(handle);
if (label !== undefined) {
await this.page.locator(this.branchLabelFieldFor(handle)).fill(label);
}
if (column !== undefined) {
await this.dismissConditionGuidelinesIfPresent();
await this.pickSelectOption(`${row} ${this.condColumnTrigger}`, this.condColumnOption, column);
await this.pickSelectOption(`${row} ${this.condOperatorTrigger}`, this.condOperatorOption, operator);
if (value !== undefined) {
await this.page.locator(`${row} ${this.condValueField}`).fill(value);
}
}
}
/**
* Wire the nth still-unwired arm to a destination. The per-arm "+" is hover-only, so
* the node must be hovered first; `nth` indexes the OPEN handles in declared order,
* which shifts as arms get wired — wire left to right and it stays predictable.
*/
async wireArmToDestination(nth, { destName, url, existing } = {}) {
await this.page.locator(this.nodeFor('branch')).first().hover({ timeout: DRAWER_TIMEOUT_MS });
const plus = this.page.locator(this.flowAppendAdd).nth(nth);
await plus.waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
await plus.click({ timeout: DRAWER_TIMEOUT_MS });
await this.page.locator(this.stepDestination).click({ timeout: DRAWER_TIMEOUT_MS });
const node = this.page.locator(this.nodeFor('destination')).last();
await node.waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
await node.dblclick({ timeout: DRAWER_TIMEOUT_MS });
await this.page.locator(this.nodeDrawer).waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
if (existing) await this.pickExistingDestination(existing);
else await this.createDestinationInline({ name: destName, url });
await this.saveNodeDrawer();
}
// ---------- draft lifecycle ----------
/** Save as Draft. Drafts skip the connectivity checks, so a half-built graph persists. */
async saveAsDraft() {
await this.page.locator(this.saveDraftBtn).first().evaluate((el) => el.click());
await this.page.locator(this.listTable).first().waitFor({ state: 'visible', timeout: LIST_TIMEOUT_MS });
}
async expectDraftTagInEditor() {
await expect(this.page.locator(this.editorDraftTag)).toBeVisible();
}
async expectNoDraftTagInEditor() {
await expect(this.page.locator(this.editorDraftTag)).toHaveCount(0);
}
/** A published workflow offers Save + History; a draft offers Save-as-Draft + Publish. */
async expectPublishedEditorControls() {
await expect(this.page.locator(this.saveBtn)).toBeVisible();
await expect(this.page.locator(this.saveDraftBtn)).toHaveCount(0);
}
async expectHistoryControl() {
await expect(this.page.locator(this.historyBtn)).toBeVisible();
}
/** Leave with unsaved edits and discard them; the guard dialog must appear first. */
async cancelAndDiscard() {
await this.page.locator(this.cancelBtn).click({ timeout: DRAWER_TIMEOUT_MS });
await this.page.locator(this.unsavedDialog).waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
await this.page.locator(this.drawerPrimary).click({ timeout: DRAWER_TIMEOUT_MS });
await this.page.locator(this.listTable).first().waitFor({ state: 'visible', timeout: LIST_TIMEOUT_MS });
}
/**
* Row tags are not name-scoped in the markup, so these are only meaningful after a
* search has narrowed the list to the workflow under test.
*/
async expectDraftTagOnRow(name) {
await this.rowByName(name).first().waitFor({ state: 'visible', timeout: LIST_TIMEOUT_MS });
await expect(this.page.locator(this.listDraftTag).first()).toBeVisible();
}
async expectTriggerTagOnRow() {
await expect(this.page.locator(this.listTriggerTag).first()).toBeVisible();
}
/** 'pause' when the workflow is running, 'resume' when it is paused. */
async rowActionState(name) {
return this.page
.locator(this.listRowActionFor(name, 'pause-start-action'))
.first()
.getAttribute('data-row-action');
}
async refreshList() {
await this.page.locator(this.listRefresh).click({ timeout: LIST_TIMEOUT_MS });
await this.waitForListReady();
}
/**
* The row's View control is a HOVER PREVIEW, not a route: the button carries an OTooltip
* rendering a WorkflowView summary and has no click handler. Hovering is the interaction.
*/
async hoverViewPreview(name) {
await this.rowByName(name).first().waitFor({ state: 'visible', timeout: LIST_TIMEOUT_MS });
await this.page.locator(this.listRowActionFor(name, 'view')).first().hover({ timeout: LIST_TIMEOUT_MS });
}
async expectViewPreview(name) {
await expect(this.page.getByRole('tooltip').first()).toContainText(name, { timeout: DRAWER_TIMEOUT_MS });
}
/** Read-only means the save paths are ABSENT, not disabled — assert on count. */
async expectReadOnlyEditor() {
await expect(this.page.locator(this.publishBtn)).toHaveCount(0);
await expect(this.page.locator(this.saveDraftBtn)).toHaveCount(0);
}
async expectDescriptionValue(text) {
await this.page.locator(this.editorPage).waitFor({ state: 'visible', timeout: LIST_TIMEOUT_MS });
await expect(this.page.locator(this.descValue).first()).toContainText(text, {
timeout: DRAWER_TIMEOUT_MS,
});
}
// ---------- node card actions ----------
/** The action rail is hover-only, so every node action starts with a hover. */
async hoverNode(type) {
await this.page.locator(this.nodeFor(type)).first().hover({ timeout: DRAWER_TIMEOUT_MS });
await this.page.locator(this.nodeActionsFor(type)).first().waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
}
async toggleNodeDisabled(type) {
await this.hoverNode(type);
// The rail is painted only while the pointer is on the node, and moving to the button
// re-runs that check — dispatch the click instead of chasing the hover.
await this.page.locator(this.nodeDisableToggle).first().dispatchEvent('click');
}
/** Both the hover-delete and the drawer's Delete funnel through one ConfirmDialog. */
async deleteNode(type) {
await this.hoverNode(type);
await this.page.locator(this.nodeDeleteBtnFor(type)).first().click({ timeout: DRAWER_TIMEOUT_MS });
await this.page.locator(this.drawerPrimary).first().click({ timeout: DRAWER_TIMEOUT_MS });
}
async expectNodeDisabledBadge(type) {
await expect(this.page.locator(this.nodeDisabledBadgeFor(type))).toBeVisible();
}
async expectNoIncompleteBadge(type) {
await expect(this.page.locator(this.nodeIncompleteBadgeFor(type))).toHaveCount(0);
}
async expectNoDisabledBadge(type) {
await expect(this.page.locator(this.nodeDisabledBadgeFor(type))).toHaveCount(0);
}
async closeTestDrawer() {
await this.page.locator(this.drawerClose).first().click({ timeout: DRAWER_TIMEOUT_MS });
await this.page.locator(this.testDrawer).waitFor({ state: 'detached', timeout: DRAWER_TIMEOUT_MS }).catch(() => {});
}
async expectNodeIncompleteBadge(type) {
await expect(this.page.locator(this.nodeIncompleteBadgeFor(type))).toBeVisible();
}
async nodeCount(type) {
return this.page.locator(this.nodeFor(type)).count();
}
/** Rename and comment are drawer fields, not card affordances. */
async renameOpenNode(name) {
await this.page.locator(this.nodeRenameTrigger).click({ timeout: DRAWER_TIMEOUT_MS });
const field = this.page.locator(this.nodeRenameField);
await field.waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
await field.fill(name);
await field.press('Enter');
}
async commentOpenNode(text) {
await this.page.locator(this.nodeCommentField).fill(text);
}
async expectCommentIndicator() {
await expect(this.page.locator(this.nodeCommentIndicator).first()).toBeVisible();
}
/** A renamed node keeps its type selector but renders the custom name on the card. */
async expectNodeShowsText(type, text) {
await expect(this.page.locator(this.nodeFor(type)).first()).toContainText(text, {
timeout: DRAWER_TIMEOUT_MS,
});
}
/** The NDV always offers step navigation, which is how a long graph is walked. */
async expectNdvStepNavigation() {
await expect(this.page.locator(this.ndvPrevStep).first()).toBeVisible();
await expect(this.page.locator(this.ndvNextStep).first()).toBeVisible();
}
async clickCanvasUndo() {
await this.page.locator(this.canvasUndo).click({ timeout: DRAWER_TIMEOUT_MS });
}
async clickCanvasTidy() {
await this.page.locator(this.canvasTidy).click({ timeout: DRAWER_TIMEOUT_MS });
}
// ---------- test drawer ----------
/** Open the Test drawer without running — the controls are the subject, not the run. */
async openTestDrawer() {
await this.closeOpenDrawer();
await this.page.locator(this.testBtn).first().evaluate((el) => el.click());
await this.page.locator(this.testDrawer).waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
}
async expectTestControl(selector) {
await expect(this.page.locator(selector).first()).toBeVisible();
}
/**
* Destinations are suppressed by default so a rehearsal cannot post to production.
* Turning that off is what surfaces the dispatch warning.
*/
async setSuppressDestinations(on) {
const toggle = this.page.locator(this.testSuppressDestinationsBtn).first();
const state = await toggle.getAttribute('aria-checked');
if (String(on) !== state) await toggle.click({ timeout: DRAWER_TIMEOUT_MS });
// A silently-dropped click would leave a delivery test running a suppressed rehearsal.
await expect(toggle).toHaveAttribute('aria-checked', String(on), { timeout: DRAWER_TIMEOUT_MS });
}
async expectSuppressDestinations(on) {
const toggle = this.page.locator(this.testSuppressDestinationsBtn).first();
await expect(toggle).toHaveAttribute('aria-checked', String(on), { timeout: DRAWER_TIMEOUT_MS });
}
async expectDispatchWarning(destName) {
const banner = this.page.locator(this.testDispatchWarning);
await expect(banner).toBeVisible();
if (destName) await expect(banner).toContainText(destName, { timeout: DRAWER_TIMEOUT_MS });
}
async expectNoDispatchWarning() {
await expect(this.page.locator(this.testDispatchWarning)).toHaveCount(0);
}
// ---------- branch summary ----------
async branchSummaryPathCount() {
return this.page.locator(this.configSummaryBranchPaths).count();
}
/** The else arm renders after every case row, whatever the path count. */
async expectElseArmLast() {
const rows = this.page.locator(`${this.branchCaseRows}, ${this.branchElse}`);
await expect(rows.last()).toHaveAttribute('data-test', 'workflow-branch-else');
}
async expectElseArmVisible() {
await expect(this.page.locator(this.branchElse)).toBeVisible();
}
/** Non-deletable rows omit the control entirely — assert absence, not a disabled state. */
async expectElseArmNotRemovable() {
await expect(this.page.locator(this.branchElseRemove)).toHaveCount(0);
}
async expectBranchCaseNotRemovable(handle) {
await expect(this.page.locator(this.branchRemoveFor(handle))).toHaveCount(0);
}
async expectBranchUnwiredBadge() {
await expect(this.page.locator(this.branchUnwiredBadge)).toBeVisible();
}
/** Choose an already-saved destination by name (the picker's list is searchable). */
async pickExistingDestination(name) {
await this.page.locator(this.destPickerSelectTrigger).click({ timeout: DRAWER_TIMEOUT_MS });
await this.page.locator(this.destPickerSelectSearch).fill(name);
const option = this.page.locator(this.destPickerSelectOption).first();
await option.waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
await option.click({ timeout: DRAWER_TIMEOUT_MS });
}
/**
* Arm names as rendered on the connectors — the assertion that catches a silent reroute.
* vue-flow paints the label layer a tick after the edge itself, so poll until `expected`
* non-blank labels exist rather than reading once.
*/
async edgeLabelTexts(expected = 1) {
const kids = this.page.locator(`${this.edgeLabelLayer} > *`);
const read = async () => (await kids.allTextContents()).map((t) => t.trim()).filter(Boolean);
await expect
.poll(async () => (await read()).length, { timeout: DRAWER_TIMEOUT_MS })
.toBeGreaterThanOrEqual(expected);
return read();
}
/**
* Add one header row to the inline create-destination form. The row's data-test is
* keyed by the header's CURRENT key, so the blank row is `...-header--key-input` and
* the value input only answers to its final name once the key has been typed.
* The backend forwards these on every send (batch_execution.rs applies
* `endpoint.headers` to the outbound request), which is what lets a workflow post
* into an authenticated endpoint.
*/
async setDestinationHeader(key, value) {
await this.page.locator(this.destHeaderKeyField()).fill(key, { timeout: DRAWER_TIMEOUT_MS });
await this.page.locator(this.destHeaderValueField(key)).fill(value, { timeout: DRAWER_TIMEOUT_MS });
}
/** Open an OSelect until `option` shows (K11); an open list with no such option still fails. */
async openSelect(triggerSel, option) {
const trigger = this.page.locator(triggerSel);
let clicks = 0;
await expect(async () => {
if ((await trigger.getAttribute('data-state')) !== 'open') {
if (clicks > 0) await this.reportSelectReclick(triggerSel, clicks);
clicks += 1;
await trigger.click({ timeout: 5000 });
}
await expect(option).toBeVisible({ timeout: 3000 });
}).toPass({ timeout: DRAWER_TIMEOUT_MS });
}
/** A re-click may be hiding a real UI bug, so leave a warning, screenshot and state in the report. */
async reportSelectReclick(triggerSel, clicksSoFar) {
const state = await this.page.evaluate((sel) => {
const t = document.querySelector(sel);
const active = document.activeElement;
return {
dataState: t?.getAttribute('data-state') ?? null,
ariaExpanded: t?.getAttribute('aria-expanded') ?? null,
openPopovers: document.querySelectorAll('[data-reka-popper-content-wrapper]').length,
activeElement: active?.getAttribute('data-test') || active?.tagName || null,
};
}, triggerSel).catch((e) => ({ evaluateError: e.message }));
const detail = { triggerSel, clicksSoFar, ...state };
testLogger.warn('K11 select re-click: the previous click left the dropdown closed', detail);
const info = test.info();
info.annotations.push({ type: 'k11-select-reclick', description: JSON.stringify(detail) });
await info.attach(`k11-select-reclick-${clicksSoFar}.json`, {
body: JSON.stringify(detail, null, 2),
contentType: 'application/json',
});
const shot = await this.page.screenshot().catch(() => null);
if (shot) await info.attach(`k11-select-reclick-${clicksSoFar}.png`, { body: shot, contentType: 'image/png' });
}
/** Pick an OSelect option by its VALUE (options stamp data-test-value). */
async pickSelectOption(triggerSel, optionSel, value) {
const option = this.page.locator(`${optionSel}[data-test-value="${value}"]`).first();
await this.openSelect(triggerSel, option);
await option.click({ timeout: DRAWER_TIMEOUT_MS });
}
/** Values of every option an OSelect currently offers. */
async selectOptionValues(triggerSel, optionSel) {
await this.openSelect(triggerSel, this.page.locator(optionSel).first());
const values = await this.page.locator(optionSel).evaluateAll((els) =>
els.map((e) => e.getAttribute('data-test-value'))
);
await this.page.keyboard.press('Escape');
return values.filter(Boolean);
}
// ---------- trigger config panel ----------
/** Open the trigger node's read-only payload reference. */
async openTriggerConfig() {
await this.page.locator(this.triggerNode).click({ timeout: DRAWER_TIMEOUT_MS });
await this.page.locator(this.nodeDrawer).waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
}
/** The event_type values the incident trigger offers as sample variants. */
async triggerVariantValues() {
return this.selectOptionValues(this.triggerVariantTrigger, this.triggerVariantOption);
}
async selectTriggerVariant(eventType) {
await this.pickSelectOption(this.triggerVariantTrigger, this.triggerVariantOption, eventType);
}
/**
* The trigger's payload as TEXT, independent of how it is laid out: main renders
* a common + event-specific pair, while an in-flight UX branch merges them into a
* single block. Reading every editor under the trigger body keeps assertions about
* WHAT the payload contains rather than which box it happens to sit in.
*
* Read through window.monaco rather than `.view-lines`: Monaco only renders the
* lines currently in view, so a scrolled payload would silently lose fields.
*/
async triggerPayloadText() {
await this.page.locator(this.triggerBody).waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
await this.page.waitForFunction(
() => Boolean(window.monaco?.editor?.getEditors?.()?.length),
null,
{ timeout: DRAWER_TIMEOUT_MS }
);
return this.page.evaluate(() => {
const body = document.querySelector('[data-test="workflow-trigger-body"]');
if (!body) return '';
return (window.monaco?.editor?.getEditors?.() || [])
.filter((ed) => body.contains(ed.getDomNode()))
.map((ed) => ed.getValue())
.join('\n');
});
}
// ---------- condition node ----------
/**
* The guidelines box is a FIRST-RUN hint persisted to localStorage. On a fresh
* CI profile it renders every time and can sit over the builder, so dismiss it
* before driving the rows rather than letting it flake the click.
*/
async dismissConditionGuidelinesIfPresent() {
const dismiss = this.page.locator(this.conditionNoteDismiss);
if (await dismiss.isVisible().catch(() => false)) {
await dismiss.click({ timeout: DRAWER_TIMEOUT_MS }).catch(() => {});
await this.page.locator(this.conditionNote)
.waitFor({ state: 'detached', timeout: DRAWER_TIMEOUT_MS }).catch(() => {});
}
}
/** Column values the condition builder offers — trigger-kind specific. */
async conditionColumnValues() {
return this.selectOptionValues(this.condColumnTrigger, this.condColumnOption);
}
/**
* Fill one condition rule. `value` is omitted for unary operators
* (is_null/is_not_null/is_empty/is_not_empty) — the input is not rendered at all.
*/
async setCondition({ column, operator, value }) {
await this.page.locator(this.conditionBuilder).waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
await this.dismissConditionGuidelinesIfPresent();
await this.pickSelectOption(this.condColumnTrigger, this.condColumnOption, column);
await this.pickSelectOption(this.condOperatorTrigger, this.condOperatorOption, operator);
if (value !== undefined) {
await this.page.locator(this.condValueField).fill(value);
}
}
/**
* Change only the operator (and value) of an existing rule. Re-picking the column
* each time is needless work and an extra flake surface when a test sweeps a set of
* operators against one column.
*/
async setConditionOperator(operator, value) {
await this.pickSelectOption(this.condOperatorTrigger, this.condOperatorOption, operator);
if (value !== undefined) {
await this.page.locator(this.condValueField).fill(value);
}
}
/** Unary operators remove the value input from the DOM (v-if), not just clear it. */
async expectConditionValueAbsent() {
await expect(this.page.locator(this.condValueField)).toHaveCount(0);
}
async expectConditionValueVisible() {
await expect(this.page.locator(this.condValueField)).toBeVisible();
}
async expectConditionBuilderVisible() {
await expect(this.page.locator(this.conditionBuilder)).toBeVisible();
}
async expectConditionGuidelinesAbsent() {
await expect(this.page.locator(this.conditionNote)).toHaveCount(0);
}
/**
* 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, headers }) {
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);
for (const [key, value] of Object.entries(headers || {})) {
await this.setDestinationHeader(key, value);
}
await this.page.locator(this.destSubmitBtn).click({ timeout: DRAWER_TIMEOUT_MS });
// onDestinationCreated only STAGES the name (pendingSelection); the select is
// bound after the refetch resolves. The trigger renders before that value lands,
// so waiting for it to be visible races — the drawer closes first, the node
// commits with an empty destination_id, and Publish rejects it with "1 step needs
// setup before publishing". Wait for the bound value itself (OSelect stamps the
// option's value, which DestinationPicker.toOption sets to the destination name).
await expect(this.page.locator(this.destPickerSelectTrigger))
.toHaveAttribute('data-test-selected-value', name, { 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({ liveSend = false } = {}) {
await this.openTestDrawer();
if (liveSend) {
// A silent no-op flip would run a suppressed rehearsal and green a delivery test,
// so the toggle landing is guarded inside setSuppressDestinations().
await this.setSuppressDestinations(false);
}
// The Test panel is still a real ODrawer (WorkflowTestDialog.vue) — only the NODE
// config panel became an ODialog. Its buttons stay `o-drawer-*`.
await this.page.locator(this.testDrawerPrimary).click({ timeout: DRAWER_TIMEOUT_MS });
}
/** Assert a node passed with the REHEARSAL flask (published workflow), never the green ✓. */
async expectNodeTestRehearsal(nodeType, timeout = 60000) {
await expect(this.page.locator(this.nodeTestRehearsalFor(nodeType)))
.toBeVisible({ timeout });
await expect(this.page.locator(this.nodeTestOkFor(nodeType))).toHaveCount(0);
}
/**
* The destination node's output for a SUPPRESSED run — the `{"suppressed": true, ...}`
* preview, not a delivery receipt. Same two-parse shape as destinationIngestReceipt():
* the node output is an array of JSON strings (one per send).
*/
async destinationSuppressionPreview() {
const raw = await this.nodeTestOutputText('destination');
const outer = JSON.parse(raw);
const first = Array.isArray(outer) ? outer[0] : outer;
return typeof first === 'string' ? JSON.parse(first) : first;
}
/** 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(this.nodeTestErrorFor(nodeType)))
.toBeVisible({ timeout });
}
/**
* A node's test-run OUTPUT, as text. On a successful destination send the backend
* sets the node's output to the sink's RESPONSE BODY (batch_execution.rs calls
* send_output with `body`), so pointing a destination at OpenObserve's own ingest
* endpoint makes this the ingest receipt — direct, synchronous proof of delivery
* with no polling and no follow-up search.
*/
async nodeTestOutputText(nodeType) {
await this.page.locator(this.nodeFor(nodeType)).last()
.click({ timeout: DRAWER_TIMEOUT_MS });
await this.page.locator(this.nodeDrawer).waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
const panel = this.page.locator(this.ndvOutput);
await panel.waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
await this.page.waitForFunction(
() => Boolean(window.monaco?.editor?.getEditors?.()?.length),
null,
{ timeout: DRAWER_TIMEOUT_MS }
);
return this.page.evaluate(() => {
const out = document.querySelector('[data-test="workflow-ndv-output"]');
if (!out) return '';
return (window.monaco?.editor?.getEditors?.() || [])
.filter((ed) => out.contains(ed.getDomNode()))
.map((ed) => ed.getValue())
.join('\n');
});
}
/**
* The destination's response parsed into an object. The node output is an ARRAY of
* response bodies (one per send), each itself a JSON string, so it needs two parses
* — matching a regex against the escaped text is brittle and reads badly.
* For a self-ingest sink this resolves to OpenObserve's receipt:
* { code: 200, status: [{ name, successful, failed }] }
*/
async destinationIngestReceipt() {
const raw = await this.nodeTestOutputText('destination');
const outer = JSON.parse(raw);
const first = Array.isArray(outer) ? outer[0] : outer;
return typeof first === 'string' ? JSON.parse(first) : first;
}
/** Assert a node painted a success badge after a test run. */
async expectNodeTestOk(nodeType, timeout = 60000) {
await expect(this.page.locator(this.nodeTestOkFor(nodeType)))
.toBeVisible({ timeout });
}
/** Assert a node passed its test run — green ✓ (draft) or rehearsal flask (published). */
async expectNodeTestPassed(nodeType, timeout = 60000) {
await expect(this.page.locator(this.nodeTestPassedFor(nodeType)).first())
.toBeVisible({ timeout });
}
/** Commit the node config: the new ODialog-based drawer has no "Save" button —
* closing the dialog IS the save (applyNodeConfig runs on close). Use the
* built-in close X (`o-dialog-close-btn`) and wait for the drawer to detach. */
async saveNodeDrawer() {
await this.page.locator(this.drawerClose).first().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, close it (commits config); otherwise no-op. */
async bindNodeDrawerIfOpen() {
const drawer = this.page.locator(this.nodeDrawer);
if (await drawer.isVisible().catch(() => false)) {
await this.page.locator(this.drawerClose).first().click({ timeout: DRAWER_TIMEOUT_MS }).catch(() => {});
await drawer.waitFor({ state: 'detached', timeout: DRAWER_TIMEOUT_MS }).catch(() => {});
}
}
/**
* With the Function node drawer open, type code into the JS editor and route it
* through the save flow (icon save -> saved-function dialog: name + primary).
* The backend's create endpoint is what rejects invalid JS — the returned
* message surfaces as a toast, which we return so callers can assert on it.
*/
async attemptCreateFunction({ name, code }) {
// The workflow single-screen has the AddFunction body always visible below the
// select; no create toggle. Clicking the editor WRAPPER does not focus Monaco —
// the keystrokes go nowhere and the untouched seed template is what gets saved,
// so an invalid-code test silently asserts nothing. Drive the real textarea.
const editor = this.page.locator(this.functionEditor);
const monaco = new MonacoEditorHelper(this.page);
await monaco.focus(editor);
await this.page.keyboard.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
await this.page.keyboard.type(code);
// Fail here, not on the toast, if the code never landed. Compared whitespace-free:
// Monaco re-renders indentation and auto-closes brackets.
const norm = (s) => (s || '').replace(/\s+/g, '');
await expect
.poll(async () => norm(await monaco.getContent(editor)), { timeout: DRAWER_TIMEOUT_MS })
.toContain(norm(code));
// Save reads AddFunction's MODEL (getCode -> formData.function), which QueryEditor
// only updates after its 500ms `update:query` debounce. Clicking sooner submits the
// previous value — the seed template — and the backend then rightly reports success.
await this.page.waitForTimeout(800);
// Route through the workflow save button (opens the Update|Create dialog).
await this.page.locator(this.nodeFunctionSaveBtn).click({ timeout: DRAWER_TIMEOUT_MS });
// With no function currently selected the dialog defaults to Create mode
// and exposes the name input. Fill it and submit via the ODialog primary.
const nameInput = this.page.locator(this.nodeFunctionNameInput);
await nameInput.waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
await nameInput.fill(name);
await this.page.locator(this.drawerPrimary).click({ timeout: DRAWER_TIMEOUT_MS });
const toast = this.page.locator(this.anyToast).first();
await toast.waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS }).catch(() => {});
return (await toast.textContent().catch(() => '') || '').trim();
}
/**
* The link-alerts prompt is offered ONLY for trigger kinds whose registry entry
* sets `linksAlerts` (alert_fired). Incident workflows must go straight back to
* the list instead — asserting both halves is what proves the flag is honoured.
*/
async expectLinkAlertsPrompt() {
await expect(this.page.locator(this.linkAlertsDialog)).toBeVisible({ timeout: DRAWER_TIMEOUT_MS });
}
async expectNoLinkAlertsPrompt() {
await expect(this.page.locator(this.linkAlertsDialog)).toHaveCount(0);
}
/** 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.
*/
/**
* Publish, and fail at the point of rejection. A blocked Publish never reaches the
* network — validate() bails with a warning toast — so a caller that only waits for
* the link-alerts dialog turns a real rejection into a silent timeout and surfaces
* it much later as a bare "expected true".
*/
async publishAndExpectAccepted() {
await this.clickPublish();
const warning = this.page.locator(this.warningToast).first();
const linkDialog = this.page.locator(this.linkAlertsDialog);
const editor = this.page.locator(this.editorPage);
await expect
.poll(
async () => {
if (await warning.isVisible().catch(() => false)) return 'rejected';
if (await linkDialog.isVisible().catch(() => false)) return 'accepted';
// Trigger kinds that don't associate with alerts get no link prompt — a
// successful publish just navigates back to the list.
if (!(await editor.isVisible().catch(() => false))) return 'accepted';
return 'pending';
},
{ timeout: DRAWER_TIMEOUT_MS, message: 'Publish produced neither a result nor a message' }
)
.not.toBe('pending');
if (await warning.isVisible().catch(() => false)) {
const msg = await warning.getAttribute('data-test-message');
throw new Error(`Publish was rejected: ${(msg || '').trim()}`);
}
}
/**
* Publish expecting the app to REFUSE, and return the warning text. The mirror of
* publishAndExpectAccepted(): a blocked Publish never reaches the network, so the
* only evidence is the toast.
*/
async publishAndCaptureRejection() {
await this.clickPublish();
const warning = this.page.locator(this.warningToast).first();
await warning.waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
return ((await warning.getAttribute('data-test-message')) || '').trim();
}
async buildTriggerToDestinationAndSave({ name, destName, url, headers, kind = 'alert_fired' }) {
await this.goToAdd(kind);
await this.setName(name);
await this.addNodeFromPalette('destination');
await this.createDestinationInline({ name: destName, url, headers });
await this.saveNodeDrawer();
await this.publishAndExpectAccepted();
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(this.anyToast).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(this.listRowPrefixFor(name));
}
async openEdit(name) {
// The list is slow (K10) and virtualized, so a row for an arbitrary name is not
// necessarily rendered — filter to it first, exactly as isPresent() does.
await this.waitForListReady();
await this.search(name);
await this.page.locator(this.listRowActionFor(name, 'edit')).first().click({ timeout: LIST_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). */
/** Like deleteByName but surfaces a miss instead of swallowing it — for assertions. */
async deleteByNameStrict(name) {
await this.page.locator(this.listRowActionFor(name, 'more-options')).first().click({ timeout: LIST_TIMEOUT_MS });
const item = this.page.locator(this.listRowActionFor(name, 'delete')).first();
await item.waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
await item.click({ timeout: DRAWER_TIMEOUT_MS });
await this.page.locator(this.drawerPrimary).first().click({ timeout: DRAWER_TIMEOUT_MS });
}
async deleteByName(name) {
await this.page.locator(this.listRowActionFor(name, 'more-options')).first().click({ timeout: DRAWER_TIMEOUT_MS }).catch(() => {});
await this.page.locator(this.listRowActionFor(name, 'delete')).first().click({ timeout: DRAWER_TIMEOUT_MS }).catch(() => {});
await this.page.locator(this.confirmButton).first().click({ timeout: DRAWER_TIMEOUT_MS }).catch(() => {});
const toast = this.page.locator(this.anyToast).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(this.listRowActionFor(name, 'pause-start-action')).first().click({ timeout: DRAWER_TIMEOUT_MS });
}
// ---------- list pause/resume control variant ----------
// The pause/resume row action is a single OButton whose `:variant` flips between
// ghost-destructive (enabled -> pause) and ghost-success (paused -> resume). Both the
// `data-row-action` state and the variant class land on the same <button> root.
async listActionRowState(name) {
const btn = this.page.locator(this.listRowActionFor(name, 'pause-start-action')).first();
await btn.waitFor({ state: 'visible', timeout: LIST_TIMEOUT_MS });
return (await btn.getAttribute('data-row-action')) || '';
}
async listActionClasses(name) {
const btn = this.page.locator(this.listRowActionFor(name, 'pause-start-action')).first();
await btn.waitFor({ state: 'visible', timeout: LIST_TIMEOUT_MS });
return ((await btn.getAttribute('class')) || '').split(/\s+/);
}
// ---------- branch-arm connector geometry ----------
/**
* Hover the Branch node to reveal its per-arm append `+` connectors, then read each
* connector's SVG path start-x. Each path's `d` is `M <cx> 0 ...`; the fix under test puts
* `cx` at the arm's OWN handle offset (small, per-arm), not the old shared convergence
* point (|cx| == ARM_GAP/2 == 44). Returns the parsed start-x values in DOM order.
*/
async branchAppendConnectorPaths() {
await this.page.locator(this.nodeFor('branch')).hover({ timeout: DRAWER_TIMEOUT_MS });
const paths = this.page.locator(this.appendAddPath);
await paths.first().waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
return paths.evaluateAll((els) =>
els.map((el) => {
const d = el.getAttribute('d') || '';
const m = /^M\s*(-?[\d.]+)\s+0\b/.exec(d);
return m ? parseFloat(m[1]) : null;
})
);
}
// ---------- node status badges ----------
/**
* Hover the node (revealing its hover actions) and click the disable/enable toggle, which
* flips the step's muted state. Scoped to the node so multiple nodes can't collide.
*/
async disableNode(nodeType) {
const node = this.page.locator(this.nodeFor(nodeType));
await node.hover({ timeout: DRAWER_TIMEOUT_MS });
const toggle = node.locator(this.nodeDisableToggle);
await toggle.waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
await toggle.click({ timeout: DRAWER_TIMEOUT_MS });
}
async expectNodeDisabledBadge(nodeType) {
await expect(this.page.locator(this.nodeDisabledBadgeFor(nodeType))).toBeVisible({
timeout: DRAWER_TIMEOUT_MS,
});
}
/**
* The Disabled badge lives inside the status-glyph strip that must now wrap (flex-wrap) and
* cap its width (max-w-*) so several badges stay inside the card. Read the badge's direct
* parent (that strip) and assert the two structural classes are present.
*/
async expectStatusStripWraps(nodeType) {
const badge = this.page.locator(this.nodeDisabledBadgeFor(nodeType));
await badge.waitFor({ state: 'visible', timeout: DRAWER_TIMEOUT_MS });
const classes = await badge.evaluate((el) => (el.parentElement ? el.parentElement.className : ''));
expect(classes).toContain('flex-wrap');
expect(classes).toContain('max-w-');
}
}
module.exports = WorkflowsPage;