Files

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;