mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-03 16:10:21 +00:00
341 lines
13 KiB
JavaScript
341 lines
13 KiB
JavaScript
// Copyright 2026 OpenObserve Inc.
|
|
|
|
const { expect } = require('@playwright/test');
|
|
|
|
/**
|
|
* Page object for the Functions Import & Export feature.
|
|
*
|
|
* Covers the import screen (/web/pipeline/functions/import) — the JSON Monaco
|
|
* editor, the run-import action, and the inline fix-up controls that render for
|
|
* rejected items — and the list page's single/bulk export actions.
|
|
*
|
|
* List-side navigation/assertion helpers (search, expect-in-list, row lookup)
|
|
* live on `functionsPage`; this object only owns the import/export surface so
|
|
* the two concerns stay separate.
|
|
*/
|
|
class FunctionsImportExportPage {
|
|
constructor(page) {
|
|
this.page = page;
|
|
|
|
// ==================== Locators ====================
|
|
|
|
// Import screen
|
|
this.importJsonButton = this.page.locator('[data-test="function-import-json-btn"]');
|
|
this.importSqlEditor = this.page.locator('[data-test="function-import-sql-editor"]');
|
|
this.importCancelButton = this.page.locator('[data-test="function-import-cancel-btn"]');
|
|
this.importResultsTitle = this.page.locator('[data-test="function-import-results-title"]');
|
|
|
|
// BaseImport source tabs (test-prefix="function" => AppTabs values below)
|
|
this.fileUploadTab = this.page.locator('[data-test="tab-import_json_file"]');
|
|
this.urlImportTab = this.page.locator('[data-test="tab-import_json_url"]');
|
|
// OFile/OInput forward the consumer data-test onto the real control as `-field`.
|
|
this.importFileInput = this.page.locator('[data-test="function-import-json-file-input-field"]');
|
|
this.importUrlInput = this.page.locator('[data-test="function-import-url-input-field"]');
|
|
|
|
// List page
|
|
this.listImportButton = this.page.locator('[data-test="function-list-import-function-btn"]');
|
|
this.rowMoreActionsSelector = '[data-test="function-list-row-more-actions"]';
|
|
this.rowExportMenuItem = this.page.locator('[data-test="function-list-export-function-btn-menu"]');
|
|
|
|
// Export (list page)
|
|
this.bulkExportButton = this.page.locator('[data-test="function-list-export-functions-btn"]');
|
|
this.rowExportButtonSelector = '[data-test="function-list-export-function-btn"]';
|
|
|
|
// Toast
|
|
this.toastMessage = this.page.locator('[data-test="o-toast-message"]');
|
|
}
|
|
|
|
// ==================== Navigation ====================
|
|
|
|
/**
|
|
* Navigate directly to the import route and wait for the JSON editor to mount.
|
|
*/
|
|
async navigateToImport(org) {
|
|
await this.page.goto(`${process.env.ZO_BASE_URL}/web/pipeline/functions/import?org_identifier=${org}`);
|
|
await this.page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {});
|
|
await this.importSqlEditor.waitFor({ state: 'visible', timeout: 30000 });
|
|
}
|
|
|
|
// ==================== Import JSON input ====================
|
|
|
|
/**
|
|
* Write JSON into the import Monaco editor via window.monaco. Headless-safe —
|
|
* keyboard.type never reaches Monaco's hidden textarea.
|
|
*/
|
|
async setImportJsonViaMonaco(json) {
|
|
await this._setMonacoValue(this.importSqlEditor, json);
|
|
}
|
|
|
|
/** Click the header "Import" button. */
|
|
async runImport() {
|
|
await this.importJsonButton.click();
|
|
}
|
|
|
|
/**
|
|
* Click Import and wait for the screen to take itself back to the list.
|
|
*
|
|
* The screen writes each item in turn and only redirects once every one of
|
|
* them has been written, so this is the signal that the import is finished.
|
|
* Navigating away under our own steam instead would cancel whichever POSTs
|
|
* were still in flight — with a multi-item file that silently drops the
|
|
* trailing items.
|
|
*/
|
|
async runImportAndWaitForList() {
|
|
await this.importJsonButton.click();
|
|
await this.page.waitForURL((url) => !url.pathname.includes('/functions/import'), {
|
|
timeout: 90000,
|
|
});
|
|
}
|
|
|
|
// ==================== Import fix-up controls ====================
|
|
|
|
/** Type a replacement name into an item's rename control. */
|
|
async renameImportItem(itemIndex, name) {
|
|
const field = this.page.locator(`[data-test="function-import-name-input-${itemIndex}-field"]`);
|
|
await field.waitFor({ state: 'visible', timeout: 15000 });
|
|
await field.fill(name);
|
|
}
|
|
|
|
/** Write a new body into an item's body editor via window.monaco. */
|
|
async setImportBody(itemIndex, body) {
|
|
const wrapper = this.page.locator(`[data-test="function-import-body-input-${itemIndex}"]`);
|
|
await this._setMonacoValue(wrapper, body);
|
|
}
|
|
|
|
/** Tick an item's "replace existing function" checkbox. */
|
|
async overrideImportItem(itemIndex) {
|
|
const checkbox = this.page
|
|
.locator(`[data-test="function-import-override-checkbox-${itemIndex}"]`)
|
|
.getByRole('checkbox');
|
|
await checkbox.waitFor({ state: 'visible', timeout: 15000 });
|
|
await checkbox.click();
|
|
}
|
|
|
|
// ==================== Export ====================
|
|
|
|
/** Click the per-row export button for a function (by name). */
|
|
async clickSingleExport(name) {
|
|
const row = this._rowByName(name);
|
|
await row.waitFor({ state: 'visible', timeout: 30000 });
|
|
await row.locator(this.rowExportButtonSelector).click();
|
|
}
|
|
|
|
/** Select a function's row checkbox (for bulk actions). */
|
|
async selectFunctionRow(name) {
|
|
const row = this._rowByName(name);
|
|
await row.waitFor({ state: 'visible', timeout: 30000 });
|
|
await row.locator('[data-test="o2-table-select-cell"]').getByRole('checkbox').click();
|
|
}
|
|
|
|
/** Click the footer bulk-export button (only rendered when rows are selected). */
|
|
async clickBulkExport() {
|
|
await this.bulkExportButton.click();
|
|
}
|
|
|
|
// ==================== Import source tabs ====================
|
|
|
|
/** Switch to the "File Upload" source tab and wait for the editor to remount. */
|
|
async switchToFileTab() {
|
|
await this.fileUploadTab.click();
|
|
await this.importSqlEditor.waitFor({ state: 'visible', timeout: 15000 });
|
|
}
|
|
|
|
/** Switch to the "URL Import" source tab and wait for the editor to remount. */
|
|
async switchToUrlTab() {
|
|
await this.urlImportTab.click();
|
|
await this.importUrlInput.waitFor({ state: 'visible', timeout: 15000 });
|
|
}
|
|
|
|
/**
|
|
* Import from a real .json file through the file chooser. Exercises the
|
|
* FileReader path that pasting into Monaco skips.
|
|
*/
|
|
async uploadImportFile(filePath) {
|
|
await this.importFileInput.setInputFiles(filePath);
|
|
// BaseImport reads the file and writes it into the editor; wait for that.
|
|
await expect
|
|
.poll(async () => (await this._readMonacoValue(this.importSqlEditor))?.length ?? 0, {
|
|
timeout: 15000,
|
|
intervals: [200, 500, 1000],
|
|
})
|
|
.toBeGreaterThan(0);
|
|
}
|
|
|
|
/**
|
|
* Fill the URL field on the URL Import tab. BaseImport watches `url` and
|
|
* axios-GETs it, so the editor fills itself once the response lands.
|
|
*/
|
|
async fillImportUrl(url) {
|
|
await this.importUrlInput.fill(url);
|
|
await this.importUrlInput.blur();
|
|
await expect
|
|
.poll(async () => (await this._readMonacoValue(this.importSqlEditor))?.length ?? 0, {
|
|
timeout: 20000,
|
|
intervals: [200, 500, 1000],
|
|
})
|
|
.toBeGreaterThan(0);
|
|
}
|
|
|
|
// ==================== List-page affordances ====================
|
|
|
|
/** Open a row's overflow ("more actions") menu — the narrow-viewport home of Export. */
|
|
async openRowMoreActions(name) {
|
|
const row = this._rowByName(name);
|
|
await row.waitFor({ state: 'visible', timeout: 30000 });
|
|
await row.locator(this.rowMoreActionsSelector).click();
|
|
}
|
|
|
|
/** Click Export from inside a row's overflow menu. */
|
|
async clickExportFromRowMenu(name) {
|
|
await this.openRowMoreActions(name);
|
|
await this.rowExportMenuItem.click();
|
|
}
|
|
|
|
// ==================== Assertions ====================
|
|
|
|
/** Assert a specific per-item validation/rejection error line is visible. */
|
|
async expectImportError(itemIndex, errorIndex) {
|
|
await expect(
|
|
this.page.locator(`[data-test="function-import-error-${itemIndex}-${errorIndex}"]`),
|
|
).toBeVisible({ timeout: 15000 });
|
|
}
|
|
|
|
/** Assert an item's "replace existing function" checkbox is visible. */
|
|
async expectOverrideCheckboxVisible(itemIndex) {
|
|
await expect(
|
|
this.page.locator(`[data-test="function-import-override-checkbox-${itemIndex}"]`),
|
|
).toBeVisible({ timeout: 15000 });
|
|
}
|
|
|
|
/** Assert a toast surfaced with the given (stable, i18n) message text. */
|
|
async expectToast(text) {
|
|
await expect(this.toastMessage.first()).toContainText(text, { timeout: 15000 });
|
|
}
|
|
|
|
/** Assert some toast surfaced (used where the message text is browser-dependent). */
|
|
async expectToastVisible() {
|
|
await expect(this.toastMessage.first()).toBeVisible({ timeout: 15000 });
|
|
}
|
|
|
|
/** Assert the import wrote nothing (no per-item result line rendered). */
|
|
async expectNoImportResult() {
|
|
await expect(this.page.locator('[data-test="function-import-result-0"]')).toHaveCount(0);
|
|
}
|
|
|
|
/** Assert the footer bulk-export button is visible (selection active). */
|
|
async expectBulkExportVisible() {
|
|
await expect(this.bulkExportButton).toBeVisible({ timeout: 15000 });
|
|
}
|
|
|
|
/** Assert the affected-pipelines warning names a pipeline. */
|
|
async expectOverrideDependents(itemIndex, pipelineName) {
|
|
await expect(
|
|
this.page.locator(`[data-test="function-import-override-dependents-${itemIndex}"]`),
|
|
).toContainText(pipelineName, { timeout: 15000 });
|
|
}
|
|
|
|
/** Assert the affected-pipelines warning is not rendered. */
|
|
async expectOverrideDependentsHidden(itemIndex) {
|
|
await expect(
|
|
this.page.locator(`[data-test="function-import-override-dependents-${itemIndex}"]`),
|
|
).toHaveCount(0);
|
|
}
|
|
|
|
/** Assert a specific per-item result line contains the given text. */
|
|
async expectImportResult(itemIndex, text) {
|
|
await expect(
|
|
this.page.locator(`[data-test="function-import-result-${itemIndex}"]`),
|
|
).toContainText(text, { timeout: 15000 });
|
|
}
|
|
|
|
/** The Import button is disabled for the run, so its re-enabling is the only "finished" signal. */
|
|
async waitForImportSettled() {
|
|
await expect(this.importJsonButton).toBeEnabled({ timeout: 60000 });
|
|
}
|
|
|
|
/** Assert an item was NOT offered as an inline-fixable error. */
|
|
async expectNoImportError(itemIndex, errorIndex) {
|
|
await expect(
|
|
this.page.locator(`[data-test="function-import-error-${itemIndex}-${errorIndex}"]`),
|
|
).toHaveCount(0);
|
|
}
|
|
|
|
/** Assert neither the language control nor the body editor is offered for an item. */
|
|
async expectNoInlineFixControls(itemIndex) {
|
|
await expect(
|
|
this.page.locator(`[data-test="function-import-trans-type-input-${itemIndex}"]`),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
this.page.locator(`[data-test="function-import-body-input-${itemIndex}"]`),
|
|
).toHaveCount(0);
|
|
}
|
|
|
|
/** Assert the import screen is still mounted (no redirect back to the list). */
|
|
async expectStillOnImportScreen() {
|
|
await expect(this.importJsonButton).toBeVisible({ timeout: 10000 });
|
|
expect(this.page.url()).toContain('/functions/import');
|
|
}
|
|
|
|
/** Assert the list page's header Import button is visible. */
|
|
async expectListImportButtonVisible() {
|
|
await expect(this.listImportButton).toBeVisible({ timeout: 15000 });
|
|
}
|
|
|
|
/** Read the JSON currently held by the import editor. */
|
|
async getImportEditorValue() {
|
|
return await this._readMonacoValue(this.importSqlEditor);
|
|
}
|
|
|
|
// ==================== Internal helpers ====================
|
|
|
|
/** Locate the OTable row (`<tr>`) that contains a function's unique name cell. */
|
|
_rowByName(name) {
|
|
return this.page
|
|
.locator('tr')
|
|
.filter({ has: this.page.locator(`[data-test="function-list-name-cell-${name}"]`) });
|
|
}
|
|
|
|
/** Read a Monaco editor's current value through window.monaco. */
|
|
async _readMonacoValue(wrapper) {
|
|
await wrapper.waitFor({ state: 'visible', timeout: 15000 });
|
|
return await wrapper.evaluate((el) => {
|
|
const editors = window.monaco?.editor?.getEditors?.() ?? [];
|
|
for (const ed of editors) {
|
|
const node = ed.getDomNode?.();
|
|
if (node && el.contains(node)) return ed.getValue();
|
|
}
|
|
return null;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Drive a Monaco editor's value via window.monaco and wait for it to stick.
|
|
* The wrapper locator must enclose the editor's DOM node (QueryEditor root).
|
|
*/
|
|
async _setMonacoValue(wrapper, value) {
|
|
await wrapper.waitFor({ state: 'visible', timeout: 15000 });
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
await wrapper.evaluate((el, text) => {
|
|
const editors = window.monaco?.editor?.getEditors?.() ?? [];
|
|
let target = null;
|
|
for (const ed of editors) {
|
|
const node = ed.getDomNode?.();
|
|
if (node && el.contains(node)) target = ed;
|
|
}
|
|
if (!target) return null;
|
|
target.setValue(text);
|
|
target.focus();
|
|
return target.getValue();
|
|
}, value),
|
|
{ timeout: 15000, intervals: [200, 500, 1000] },
|
|
)
|
|
.toBe(value);
|
|
// Let Monaco's debounce flush the value into the v-model before acting.
|
|
await this.page.waitForTimeout(1000);
|
|
}
|
|
}
|
|
|
|
module.exports = FunctionsImportExportPage;
|