// Copyright 2026 OpenObserve Inc.
const { expect } = require('@playwright/test');
class ActionScriptsFormValidationPage {
/**
* @param {import('@playwright/test').Page} page
*/
constructor(page) {
this.page = page;
// ── List page ─────────────────────────────────────────────────────────────
this.listPage = '[data-test="action-scripts-list-page"]';
this.addButton = '[data-test="action-list-add-btn"]';
this.table = '[data-test="action-scripts-table"]';
// ── Clone dialog ──────────────────────────────────────────────────────────
// ODialog wrapper
this.cloneDialog = '[data-test="action-scripts-form-dialog"]';
// OInput data-test="to-be-clone-action-name" → -field / -error
this.cloneNameField = '[data-test="to-be-clone-action-name-field"]';
this.cloneNameError = '[data-test="to-be-clone-action-name-error"]';
// OSelect data-test="to-be-clone-stream-type" → -popover / -error
this.cloneStreamTypePopover = '[data-test="to-be-clone-stream-type-popover"]';
this.cloneStreamTypeError = '[data-test="to-be-clone-stream-type-error"]';
// OSelect data-test="to-be-clone-stream-name" → -popover / -error
this.cloneStreamNamePopover = '[data-test="to-be-clone-stream-name-popover"]';
// ODialog built-in primary / secondary buttons scoped to dialog
this.cloneSaveBtn = '[data-test="action-scripts-form-dialog"] [data-test="o-dialog-primary-btn"]';
this.cloneCancelBtn = '[data-test="action-scripts-form-dialog"] [data-test="o-dialog-secondary-btn"]';
// ── EditScript (add/update) page ──────────────────────────────────────────
this.editScriptSection = '[data-test="add-action-script-section"]';
this.backBtn = '[data-test="add-action-script-back-btn"]';
this.titleEl = '[data-test="add-action-script-title"]';
// OInput data-test="add-action-script-name-input" wraps OInput:
// OInput generates -field (native ) and -error (error message)
this.nameInputWrapper = '[data-test="add-action-script-name-input-wrapper"]';
this.nameField = '[data-test="add-action-script-name-input-field"]';
this.nameError = '[data-test="add-action-script-name-input-error"]';
// OSelect data-test="add-action-script-type-select" → -popover / -error / -trigger
this.typeSelectPopover = '[data-test="add-action-script-type-select-popover"]';
this.typeSelectError = '[data-test="add-action-script-type-select-error"]';
this.typeSelectTrigger = '[data-test="add-action-script-type-select-trigger"]';
// OSelect listbox option — keyed off the option's data-test-label
this.typeSelectOptionByLabel = (label) =>
this.page.locator(`[data-test="add-action-script-type-select-option"][data-test-label="${label}"]`);
// Stepper steps
this.step1 = '[data-test="add-action-script-step-1"]';
this.step3 = '[data-test="add-action-script-step-3"]';
// OFile data-test="add-action-script-file-input" → -error
this.fileInput = '[data-test="add-action-script-file-input"]';
this.step1ContinueBtn = '[data-test="add-action-script-step1-continue-btn"]';
// Service account select
this.serviceAccountSelect = '[data-test="add-action-script-service-account-select"]';
this.serviceAccountError = '[data-test="add-action-script-service-account-select-error"]';
// Footer action buttons
this.saveBtn = '[data-test="add-action-script-save-btn"]';
this.cancelBtn = '[data-test="add-action-script-cancel-btn"]';
// Per-row action buttons (dynamic — built with row.name)
this.rowEditBtnSelector = (name) => `[data-test="alert-list-${name}-update-alert"]`;
this.rowDeleteBtnSelector = (name) => `[data-test="alert-list-${name}-delete-alert"]`;
// Toast
this.toastSuccess = '[data-test-variant="success"]';
this.toastError = '[data-test-variant="error"]';
}
// ── Locator getters ───────────────────────────────────────────────────────
getTableLocator() {
return this.page.locator(this.table);
}
getCloneDialogLocator() {
return this.page.locator(this.cloneDialog);
}
getListPageLocator() {
return this.page.locator(this.listPage);
}
// ── Navigation ────────────────────────────────────────────────────────────
/**
* Navigates to the Action Scripts list page and reports whether the feature
* is available in the running build.
*
* Action Scripts is an ENTERPRISE-only feature — its `/web/actions` route is
* registered exclusively by `useEnterpriseRoutes`. On an OSS binary (e.g. the
* OSS Playwright CI job) the route does not exist, so navigation redirects
* away and the list page never mounts. We detect that here and return `false`
* so the caller can skip enterprise-only assertions cleanly, mirroring the
* availability-gate pattern used by the anomaly-detection suite.
*
* @returns {Promise} true when the list page rendered (enterprise build)
*/
async navigateToActionScripts(orgIdentifier) {
const org = orgIdentifier || process.env.ORGID || 'default';
await this.page.goto(
`${process.env.ZO_BASE_URL}/web/actions?org_identifier=${org}`
);
// Cold CI runners load the SPA bundle + run the enterprise route guard async
// check before this view mounts — wait for the DOM to settle then give the
// list page a deterministic window to render.
await this.page.waitForLoadState('domcontentloaded');
try {
await this.page.locator(this.listPage).waitFor({ state: 'visible', timeout: 15000 });
return true;
} catch {
return false;
}
}
// ── List page helpers ─────────────────────────────────────────────────────
async clickAddButton() {
await this.page.locator(this.addButton).waitFor({ state: 'visible' });
await this.page.locator(this.addButton).click();
await this.page.locator(this.editScriptSection).waitFor({ state: 'visible', timeout: 10000 });
}
async clickEditRow(name) {
await this.page.locator(this.rowEditBtnSelector(name)).waitFor({ state: 'visible' });
await this.page.locator(this.rowEditBtnSelector(name)).click();
await this.page.locator(this.editScriptSection).waitFor({ state: 'visible', timeout: 10000 });
}
// ── EditScript form actions ────────────────────────────────────────────────
async fillName(value) {
await this.page.locator(this.nameField).waitFor({ state: 'visible' });
await this.page.locator(this.nameField).fill(value);
}
async clearName() {
await this.page.locator(this.nameField).waitFor({ state: 'visible' });
await this.page.locator(this.nameField).fill('');
}
async expectNameError(message) {
await this.page.locator(this.nameError).waitFor({ state: 'visible' });
if (message) {
await expect(this.page.locator(this.nameError)).toContainText(message);
} else {
await expect(this.page.locator(this.nameError)).toBeVisible();
}
}
async expectTypeError(message) {
await this.page.locator(this.typeSelectError).waitFor({ state: 'visible' });
await expect(this.page.locator(this.typeSelectError)).toBeVisible();
if (message) {
await expect(this.page.locator(this.typeSelectError)).toContainText(message);
}
}
async expectTypeDefaulted(label) {
// The type OSelect is pre-filled (defaults to "Scheduled") and is not
// clearable, so a new action script always has a valid type selected.
await this.page.locator(this.typeSelectTrigger).waitFor({ state: 'visible' });
await expect(this.page.locator(this.typeSelectTrigger)).toHaveAttribute(
'data-test-selected-label',
label
);
}
async expectTypeErrorHidden() {
await expect(this.page.locator(this.typeSelectError)).toHaveCount(0);
}
async selectType(label) {
// Open the OSelect by clicking its trigger, wait for the popover to render,
// then pick the option keyed off its data-test-label.
await this.page.locator(this.typeSelectTrigger).waitFor({ state: 'visible' });
await this.page.locator(this.typeSelectTrigger).click();
await this.page.locator(this.typeSelectPopover).waitFor({ state: 'visible' });
await this.typeSelectOptionByLabel(label).click();
}
async clickSave() {
await this.page.locator(this.saveBtn).waitFor({ state: 'visible' });
await this.page.locator(this.saveBtn).click();
}
async clickCancel() {
await this.page.locator(this.cancelBtn).waitFor({ state: 'visible' });
await this.page.locator(this.cancelBtn).click();
}
async expectSaveButtonVisible() {
await expect(this.page.locator(this.saveBtn)).toBeVisible();
}
// ── Clone dialog helpers ───────────────────────────────────────────────────
async openCloneDialog(rowName) {
// Clone is opened via a row-level action — not directly via the add button.
// The clone dialog data-test is "action-scripts-form-dialog" and is triggered
// by some row-level UI. In the absence of a dedicated clone button selector,
// we rely on the caller to trigger the dialog before calling this method.
await this.page.locator(this.cloneDialog).waitFor({ state: 'visible', timeout: 10000 });
}
async fillCloneName(value) {
await this.page.locator(this.cloneNameField).waitFor({ state: 'visible' });
await this.page.locator(this.cloneNameField).fill(value);
}
async clearCloneName() {
await this.page.locator(this.cloneNameField).fill('');
}
async expectCloneNameError(message) {
await this.page.locator(this.cloneNameError).waitFor({ state: 'visible' });
await expect(this.page.locator(this.cloneNameError)).toBeVisible();
if (message) {
await expect(this.page.locator(this.cloneNameError)).toContainText(message);
}
}
async clickCloneSave() {
await this.page.locator(this.cloneSaveBtn).waitFor({ state: 'visible' });
await this.page.locator(this.cloneSaveBtn).click();
}
async clickCloneCancel() {
await this.page.locator(this.cloneCancelBtn).waitFor({ state: 'visible' });
await this.page.locator(this.cloneCancelBtn).click();
await this.page.locator(this.cloneDialog).waitFor({ state: 'hidden', timeout: 5000 });
}
async expectCloneDialogClosed() {
await expect(this.page.locator(this.cloneDialog)).not.toBeVisible();
}
async expectCloneDialogOpen() {
await expect(this.page.locator(this.cloneDialog)).toBeVisible();
}
// ── Assertion helpers ──────────────────────────────────────────────────────
async expectOnListPage() {
await this.page.locator(this.listPage).waitFor({ state: 'visible', timeout: 10000 });
await expect(this.page.locator(this.listPage)).toBeVisible();
}
async expectOnEditPage() {
await this.page.locator(this.editScriptSection).waitFor({ state: 'visible', timeout: 10000 });
await expect(this.page.locator(this.editScriptSection)).toBeVisible();
}
}
module.exports = { ActionScriptsFormValidationPage };