blob: fc9dce1f6facdc147db8a5da8d3f96114e0ad2ce [file]
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { expect, test } from '@playwright/test';
import { PublishedParagraphPage } from 'e2e/models/published-paragraph-page';
import { PublishedParagraphTestUtil } from '../../../models/published-paragraph-page.util';
import {
addPageAnnotationBeforeEach,
waitForNotebookLinks,
waitForZeppelinReady,
PAGES,
createTestNotebook
} from '../../../utils';
test.describe('Published Paragraph', () => {
// JUSTIFIED: page objects and notebook ids are stored in describe scope; fullyParallel can overwrite them.
test.describe.configure({ mode: 'default' });
addPageAnnotationBeforeEach(PAGES.WORKSPACE.PUBLISHED_PARAGRAPH);
let publishedParagraphPage: PublishedParagraphPage;
let testUtil: PublishedParagraphTestUtil;
let testNotebook: { noteId: string; paragraphId: string };
test.beforeEach(async ({ page }) => {
publishedParagraphPage = new PublishedParagraphPage(page);
await page.goto('/#/');
await waitForZeppelinReady(page);
await waitForNotebookLinks(page);
if ((await publishedParagraphPage.cancelButton.count()) > 0) {
await publishedParagraphPage.cancelButton.click();
await publishedParagraphPage.cancelButton.waitFor({ state: 'detached', timeout: 5000 });
}
testUtil = new PublishedParagraphTestUtil(page);
testNotebook = await createTestNotebook(page);
});
test.describe('Error Handling', () => {
test('should show error modal when notebook does not exist', async ({ page }) => {
const nonExistentIds = testUtil.generateNonExistentIds();
await publishedParagraphPage.navigateToPublishedParagraph(nonExistentIds.noteId, nonExistentIds.paragraphId);
// JUSTIFIED: last() handles stacked modals where the most recent error modal appears on top
const modal = page.locator('.ant-modal', { hasText: /not found/i }).last();
await expect(modal).toBeVisible({ timeout: 10000 });
await expect(modal).toContainText(/not found/i);
});
test('should show error modal when paragraph does not exist in valid notebook', async ({ page }) => {
const validNoteId = testNotebook.noteId;
const nonExistentParagraphId = testUtil.generateNonExistentIds().paragraphId;
await testUtil.navigateToPublishedParagraph(validNoteId, nonExistentParagraphId);
const errorModal = page.locator('.ant-modal', { hasText: /Paragraph Not Found|not found|Error/i });
await expect(errorModal).toBeVisible({ timeout: 10000 });
await expect(errorModal).toContainText(nonExistentParagraphId);
await testUtil.clickErrorModalOk();
await expect(page).toHaveURL(/\/#\/$/, { timeout: 10000 });
});
test('should redirect to home page after error modal dismissal', async ({ page }) => {
const nonExistentIds = testUtil.generateNonExistentIds();
await publishedParagraphPage.navigateToPublishedParagraph(nonExistentIds.noteId, nonExistentIds.paragraphId);
// Modal must appear — we navigated to non-existent IDs
const modal = page.locator('.ant-modal').filter({ hasText: /not found/i });
await expect(modal).toBeVisible({ timeout: 10000 });
await publishedParagraphPage.okButton.click();
await expect(page).toHaveURL(/\/#\/$/, { timeout: 10000 });
});
});
test.describe('Navigation and URL Patterns', () => {
test('should enter published paragraph by clicking link', async ({ page }) => {
const { noteId, paragraphId } = testNotebook;
await publishedParagraphPage.navigateToNotebook(noteId);
// JUSTIFIED: createTestNotebook creates a single paragraph; first() is deterministic
const paragraphElement = page.locator('zeppelin-notebook-paragraph').first();
await expect(paragraphElement).toBeVisible({ timeout: 10000 });
const settingsButton = paragraphElement.locator('a[nz-dropdown]');
await settingsButton.click();
const linkParagraphButton = page.locator('li.list-item', { hasText: 'Link this paragraph' });
await expect(linkParagraphButton).toBeVisible();
const [newPage] = await Promise.all([page.waitForEvent('popup'), linkParagraphButton.click()]);
await newPage.waitForLoadState();
await expect(newPage).toHaveURL(new RegExp(`/notebook/${noteId}/paragraph/${paragraphId}`), { timeout: 10000 });
// Published mode hides editing controls
await expect(newPage.locator('zeppelin-notebook-paragraph-code-editor')).toBeHidden();
await expect(newPage.locator('zeppelin-notebook-paragraph-control')).toBeHidden();
});
test('should load published paragraph component by direct URL navigation', async ({ page }) => {
await page.goto(`/#/notebook/${testNotebook.noteId}/paragraph/${testNotebook.paragraphId}`);
// Wait for the confirmation modal — it signals NOTE was received and the component is fully rendered.
// networkidle fires before the NOTE WebSocket response, so the modal is the reliable ready signal.
const confirmModal = page.locator('.ant-modal-confirm');
await expect(confirmModal).toBeVisible({ timeout: 15000 });
await publishedParagraphPage.cancelButton.click();
await expect(confirmModal).toBeHidden({ timeout: 5000 });
await expect(page).toHaveURL(
new RegExp(`/notebook/${testNotebook.noteId}/paragraph/${testNotebook.paragraphId}`)
);
// JUSTIFIED: paragraph has no results yet so the component renders 0×0 — toBeAttached confirms
// the route is active without requiring visible content.
await expect(page.locator('zeppelin-publish-paragraph')).toBeAttached({ timeout: 10000 });
});
test('should load published paragraph and keep component attached after modal confirmation', async ({ page }) => {
const { noteId, paragraphId } = testNotebook;
await page.goto(`/#/notebook/${noteId}/paragraph/${paragraphId}`);
// Confirmation modal signals NOTE was received and component is fully rendered.
const modal = page.locator('.ant-modal');
await expect(modal).toBeVisible({ timeout: 20000 });
await publishedParagraphPage.runButton.click();
await expect(modal).not.toBeVisible({ timeout: 10000 });
const publishedContainer = page.locator('zeppelin-publish-paragraph');
// JUSTIFIED: paragraph has no results yet so the component renders 0×0 — toBeAttached confirms
// the route is still active (not navigated away) without requiring visible content.
await expect(publishedContainer).toBeAttached({ timeout: 10000 });
});
test('should render React micro-frontend instead of Angular result component', async ({ page }) => {
await test.step('Given I navigate to React mode URL', async () => {
await page.goto(`/#/notebook/${testNotebook.noteId}/paragraph/${testNotebook.paragraphId}?react=true`);
await waitForZeppelinReady(page);
});
await test.step('Then the Angular renderer should not be used', async () => {
await expect(publishedParagraphPage.angularRenderer).toHaveCount(0, { timeout: 10000 });
});
await test.step('And React widget should be mounted in the container', async () => {
await expect(publishedParagraphPage.reactWidgetOrEmptyState).toBeAttached({ timeout: 15000 });
});
});
test('when the remote fails to load, the published paragraph falls back to Angular', async ({ page }) => {
const { noteId, paragraphId } = testNotebook;
// Dead remote: every remoteEntry.js request fails, so the mount directive's onError fires.
await page.route('**/remoteEntry.js', route => route.abort());
await test.step('When the remote entry is requested and fails', async () => {
// Angular is the default renderer, so the assertions below pass even if React was never enabled.
// Awaiting the request is what proves this is a real fallback.
const remoteRequested = page.waitForRequest('**/remoteEntry.js');
await page.goto(`/#/notebook/${noteId}/paragraph/${paragraphId}?react=true`);
await remoteRequested;
await waitForZeppelinReady(page);
});
await test.step('Then the Angular renderer takes over and React never mounts', async () => {
await expect(publishedParagraphPage.angularRenderer).toHaveCount(1, { timeout: 15000 });
await expect(publishedParagraphPage.reactWidget).toHaveCount(0);
});
});
});
test.describe('Published Mode Functionality', () => {
test('should hide editing controls in published mode', async ({ page }) => {
const { noteId, paragraphId } = testNotebook;
await page.goto(`/#/notebook/${noteId}/paragraph/${paragraphId}`);
// Wait for modal then dismiss — component visibility is unreliable while modal is animating open.
const confirmModal = page.locator('.ant-modal-confirm');
await expect(confirmModal).toBeVisible({ timeout: 15000 });
await publishedParagraphPage.cancelButton.click();
await expect(confirmModal).toBeHidden({ timeout: 5000 });
// JUSTIFIED: paragraph has no results yet so the component renders 0×0 — toBeAttached confirms
// the route is active without requiring visible content.
await expect(page.locator('zeppelin-publish-paragraph')).toBeAttached({ timeout: 10000 });
await expect(page.locator('zeppelin-notebook-paragraph-code-editor')).toBeHidden();
await expect(page.locator('zeppelin-notebook-paragraph-control')).toBeHidden();
});
});
test.describe('Confirmation Modal and Execution', () => {
for (const reactMode of [false, true]) {
test(`should show confirmation modal with code preview and allow running${reactMode ? ' (React mode)' : ''}`, async ({
page
}) => {
const { noteId, paragraphId } = testNotebook;
await publishedParagraphPage.navigateToNotebook(noteId);
// JUSTIFIED: createTestNotebook creates a single paragraph; first() is deterministic
const paragraphElement = page.locator('zeppelin-notebook-paragraph').first();
await expect(paragraphElement.locator('zeppelin-notebook-paragraph-result')).toBeHidden();
const urlSuffix = reactMode ? '?react=true' : '';
await page.goto(`/#/notebook/${noteId}/paragraph/${paragraphId}${urlSuffix}`);
await waitForZeppelinReady(page);
if (!reactMode) {
await expect(page).toHaveURL(new RegExp(`/paragraph/${paragraphId}`));
}
const modal = publishedParagraphPage.confirmationModal;
await expect(modal).toBeVisible({ timeout: 30000 });
await expect(publishedParagraphPage.modalTitle).toHaveText('Run Paragraph?');
const modalContent = modal.locator('.ant-modal-confirm-content');
await expect(modalContent).toContainText('This paragraph contains the following code:');
await expect(modalContent).toContainText('Would you like to execute this code?');
if (!reactMode) {
// Code preview element only checked in Angular mode
// JUSTIFIED: compound fallback selector; first() picks any element that confirms code preview is rendered
const codePreview = modalContent.locator('pre, code, .code-preview, [class*="code"]').first();
await expect(codePreview).toBeVisible();
await expect(codePreview).not.toBeEmpty(); // code must have content, not just an empty container
await expect(publishedParagraphPage.runButton).toBeVisible();
await expect(publishedParagraphPage.cancelButton).toBeVisible();
}
await publishedParagraphPage.runButton.click();
await expect(modal).toBeHidden();
});
}
});
});