Files
openobserve/tests/ui-testing/pages/generalPages/createOrgPage.js
T

382 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const { expect } = require('@playwright/test');
export class CreateOrgPage {
constructor(page) {
this.page = page;
// ── IAM navigation ────────────────────────────────────────────────
this.iamMenuLink = page.locator('[data-test="menu-link-\\/iam-item"]');
this.iamOrganizationsTab = page.locator('[data-test="iam-organizations-tab"]');
this.iamPage = page.locator('[data-test="iam-page"]');
// ── Add / Edit drawer ─────────────────────────────────────────────
this.addOrgButton = page.locator('[data-test="Add Organization"]');
this.addUpdateDialog = page.locator('[data-test="add-update-organization-dialog"]');
// OInput wrapper carries `org-name`; the native input is `org-name-field`.
this.orgNameWrapper = page.locator('[data-test="org-name"]');
this.orgNameInput = page.locator('[data-test="org-name-field"]');
this.orgNameError = page.locator('[data-test="org-name-error"]');
// ODrawer footer buttons exposed via o-drawer-{primary|secondary}-btn,
// scoped under the dialog wrapper data-test for uniqueness.
this.saveOrgButton = page.locator(
'[data-test="add-update-organization-dialog"] [data-test="o-dialog-primary-btn"]'
);
this.cancelOrgButton = page.locator(
'[data-test="add-update-organization-dialog"] [data-test="o-dialog-secondary-btn"]'
);
// ── Search input ──────────────────────────────────────────────────
// OInput wrapper data-test="organizations-search-input"; native input
// resolves to data-test="organizations-search-input-field".
this.searchOrgInput = page.locator('[data-test="organizations-search-input-field"]');
// ── Table / rows ──────────────────────────────────────────────────
this.tableRows = page.locator('[data-test^="o2-table-row-"]');
this.firstTableRow = this.tableRows.first();
this.tableCells = page.locator('[data-test^="o2-table-cell-"]');
// Edit action exposed per row in ListOrganizations.vue (#cell-actions).
// The current UI does NOT expose a delete button — used only for
// UI-deletion-attempt diagnostics.
this.orgEditButtons = page.locator('[data-test="organization-name-edit"]');
// OToast message surface — the "Organization added successfully." success
// notification renders inside [data-test="o-toast-message"] (mirrors
// userPage.verifySuccessMessage).
this.toastMessage = page.locator('[data-test="o-toast-message"]');
}
/**
* Returns the row locator whose name-cell text matches the given org name.
* Uses the o2-table-cell-name column (per OTable convention) plus an
* ancestor-axis jump back to the row container — no element-tag XPath.
*/
getOrgRowByName(orgName) {
// Escape regex special chars in the org name for the XPath text comparison.
const safe = orgName.replace(/'/g, "\\'");
return this.page.locator(
`xpath=//*[@data-test="o2-table-cell-name" and normalize-space()='${safe}']/ancestor::*[starts-with(@data-test,'o2-table-row-')]`
);
}
getOrgIdentifierCell(orgName) {
const safe = orgName.replace(/'/g, "\\'");
return this.page.locator(
`xpath=//*[@data-test="o2-table-cell-name" and normalize-space()='${safe}']/ancestor::*[starts-with(@data-test,'o2-table-row-')]//*[@data-test="o2-table-cell-identifier"]`
);
}
async createOrg(newOrgName) {
const basicAuthCredentials = Buffer.from(`${process.env["ZO_ROOT_USER_EMAIL"]}:${process.env["ZO_ROOT_USER_PASSWORD"]}`).toString('base64');
const headers = {
"Authorization": `Basic ${basicAuthCredentials}`,
"Content-Type": "application/json",
};
let newOrgBody = {
"name": newOrgName,
}
const fetchResponse = await fetch(
`${process.env.INGESTION_URL}/api/organizations`,
{
method: "POST",
headers: headers,
body: JSON.stringify(newOrgBody),
}
);
try {
const response = await fetchResponse;
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("application/json")) {
const jsonData = await response.json();
console.log(response.status);
console.log(jsonData);
return jsonData.identifier;
// Process JSON data here
} else {
const textData = await response.text();
console.log("Response is not JSON:", textData);
return textData;
// Handle the non-JSON response here
}
} catch (error) {
console.error("Failed to parse JSON response:", error);
return "";
}
}
async navigateToOrg() {
await this.iamMenuLink.click();
await this.iamOrganizationsTab.click();
}
async clickAddOrg() {
// The parent ListOrganizations.vue toggles the drawer via the router query
// ?action=add. Its watcher only fires when the value CHANGES, so if the
// drawer was closed via Cancel (which leaves the query alone), a second
// click on "Add Organization" pushes the same query and the watcher does
// not re-open the drawer. Guard against that by waiting for the org-name
// input to be visible, and if it never appears, re-navigate to clear the
// route query and try once more.
await this.addOrgButton.click();
try {
await this.orgNameWrapper.waitFor({ state: 'visible', timeout: 5000 });
} catch {
await this.navigateToOrg();
await this.addOrgButton.waitFor({ state: 'visible' });
await this.addOrgButton.click();
await this.orgNameWrapper.waitFor({ state: 'visible', timeout: 10000 });
}
}
async fillOrgName(orgName) {
//check if element is visible before filling
await this.orgNameInput.waitFor({ state: 'visible' });
await this.orgNameInput.fill(orgName);
}
async clearOrgName() {
await this.orgNameInput.waitFor({ state: 'visible' });
await this.orgNameInput.fill('');
}
async isDrawerOpen() {
return await this.orgNameWrapper.isVisible({ timeout: 2000 }).catch(() => false);
}
async clickSaveOrg() {
await this.saveOrgButton.click();
}
/**
* Click Save and wait until either the drawer closes (successful create
* / update) or the org-name field still shows — meaning onSubmit() bailed
* because the value failed isValidOrgName. Use this when the spec needs
* deterministic settling after a Save click instead of a fixed timeout.
*/
async clickSaveOrgAndWaitForResult({ timeout = 10000 } = {}) {
await this.saveOrgButton.click();
await Promise.race([
this.orgNameWrapper.waitFor({ state: 'hidden', timeout }).catch(() => {}),
this.orgNameError.waitFor({ state: 'visible', timeout }).catch(() => {}),
]);
}
async checkSaveEnabled() {
// Check if the button is enabled
const isEnabled = await this.saveOrgButton.isEnabled();
if (!isEnabled) {
console.error('The "Add Organization" button is not enabled.');
return false; // Return false to indicate the button is not enabled
}
return true; // Return true if the button is enabled
}
async clickCancelButton() {
// Check if the button is visible and enabled before clicking
const isVisible = await this.cancelOrgButton.isVisible();
const isEnabled = await this.cancelOrgButton.isEnabled();
if (isVisible && isEnabled) {
await this.cancelOrgButton.click();
} else {
console.error('The "Cancel" button is not visible or enabled.');
throw new Error('Attempted to click "Cancel" button, but it is not visible or enabled.');
}
}
async searchOrg(orgName) {
await this.searchOrgInput.click();
await this.searchOrgInput.fill(orgName);
// Wait for the table to settle after the global-filter change: either
// a matching row is rendered, or the "No data available" empty state
// appears. Whichever resolves first is fine — we just don't want to
// race the OTable's client-side filter.
await Promise.race([
this.firstTableRow.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {}),
this.page.locator('[data-test="o2-empty-state"]').waitFor({ state: 'visible', timeout: 5000 }).catch(() => {}),
]);
}
async clearSearchOrg() {
await this.searchOrgInput.fill('');
await expect(this.firstTableRow).toBeVisible({ timeout: 5000 }).catch(() => {});
}
async verifyOrgNotExists() {
await expect(this.page.locator('[data-test="o2-empty-state"]')).toBeVisible();
}
async verifyOrgExists(orgName) {
await expect(this.getOrgRowByName(orgName)).toBeVisible({ timeout: 10000 });
}
/**
* Returns the locator of the validation error message rendered by OInput
* for the org-name field. Use this for assertions in the spec rather than
* scraping by text — the data-test is derived from the parent's data-test.
*/
getOrgNameError() {
return this.orgNameError;
}
/**
* Assert the positive "Organization added successfully." toast appears after
* a successful create. Polls all rendered toast messages until one carries
* the expected text — mirrors userPage.verifySuccessMessage so the org
* success path is asserted without racing the transient toast auto-dismiss.
*/
async verifyOrgAddedSuccessfully(expectedMessage = 'Organization added successfully.') {
await expect.poll(async () => {
const count = await this.toastMessage.count();
for (let i = 0; i < count; i++) {
const visible = await this.toastMessage.nth(i).isVisible().catch(() => false);
if (!visible) continue;
const text = (await this.toastMessage.nth(i).textContent().catch(() => '')) ?? '';
if (text.includes(expectedMessage)) return true;
}
return false;
}, { timeout: 15000, intervals: [250, 500, 1000] }).toBe(true);
}
async getAdminOrgs(orgId = '_meta') {
const basicAuthCredentials = Buffer.from(`${process.env["ZO_ROOT_USER_EMAIL"]}:${process.env["ZO_ROOT_USER_PASSWORD"]}`).toString('base64');
const headers = {
"Authorization": `Basic ${basicAuthCredentials}`,
"Content-Type": "application/json",
};
const fetchResponse = await fetch(
`${process.env.INGESTION_URL}/api/${orgId}/organizations?page_size=1000000`,
{
method: "GET",
headers: headers,
}
);
const status = fetchResponse.status;
const contentType = fetchResponse.headers.get("content-type");
let data = null;
if (contentType && contentType.includes("application/json")) {
data = await fetchResponse.json();
} else {
data = await fetchResponse.text();
}
return { status, data };
}
async deleteOrgViaAPI(orgIdentifier) {
console.log(`⚠️ WARNING: Organization deletion may not be supported in this environment`);
const basicAuthCredentials = Buffer.from(`${process.env["ZO_ROOT_USER_EMAIL"]}:${process.env["ZO_ROOT_USER_PASSWORD"]}`).toString('base64');
const headers = {
"Authorization": `Basic ${basicAuthCredentials}`,
"Content-Type": "application/json",
};
try {
const fetchResponse = await fetch(
`${process.env.INGESTION_URL}/api/organizations/${orgIdentifier}`,
{
method: "DELETE",
headers: headers,
}
);
console.log(`Delete org API response - Status: ${fetchResponse.status}`);
const responseText = await fetchResponse.text();
console.log(`Delete org API response - Body: ${responseText}`);
if (fetchResponse.ok) {
console.log(`✓ Organization ${orgIdentifier} deletion API call succeeded`);
return true;
} else if (fetchResponse.status === 403) {
console.log(`✗ Organization deletion forbidden (403) - deletion not allowed in this environment`);
return false;
} else if (fetchResponse.status === 404) {
console.log(`ℹ Organization ${orgIdentifier} not found (404) - may already be deleted`);
return true; // Consider 404 as success since org doesn't exist
} else {
console.log(`✗ Failed to delete organization ${orgIdentifier}: Status ${fetchResponse.status} - ${responseText}`);
return false;
}
} catch (error) {
console.error(`✗ Error during organization deletion API call:`, error);
return false;
}
}
async deleteOrgViaUI(orgName) {
console.log(`⚠️ WARNING: UI deletion may not be supported - delete buttons might not exist`);
try {
// Search for the organization first
await this.searchOrg(orgName);
// The current ListOrganizations.vue exposes only an edit action
// (data-test="organization-name-edit") — there is no delete button
// in the rendered row. So we report unsupported and bail out.
const editCount = await this.orgEditButtons.count();
console.log(`Found ${editCount} edit buttons in the organization table`);
console.log(`✗ Delete action is not exposed in the organization table UI`);
return false;
} catch (error) {
console.error(`✗ Failed to delete organization ${orgName} via UI:`, error);
return false;
}
}
async getOrgIdentifierFromTable(orgName) {
const maxRetries = 3;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(`Attempting to get identifier for ${orgName} (attempt ${attempt}/${maxRetries})`);
// Ensure we search for the organization first to make it visible
await this.searchOrg(orgName);
await this.page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
// Wait for table to be stable
const row = this.getOrgRowByName(orgName);
if (!(await row.isVisible({ timeout: 5000 }))) {
console.log(`Organization row not visible for ${orgName} on attempt ${attempt}`);
if (attempt < maxRetries) {
await row.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {});
continue;
}
return null;
}
const identifierCell = this.getOrgIdentifierCell(orgName);
const identifier = (await identifierCell.textContent({ timeout: 3000 }).catch(() => null))?.trim();
if (identifier && /^[a-zA-Z0-9]{8,64}$/.test(identifier)) {
console.log(`Found identifier via column cell: ${identifier}`);
return identifier;
}
console.log(`No suitable identifier found for ${orgName} on attempt ${attempt}`);
} catch (error) {
console.error(`Error getting identifier for ${orgName} on attempt ${attempt}:`, error.message);
}
}
console.warn(`Failed to get identifier for ${orgName} after ${maxRetries} attempts`);
return null;
}
}