Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,26 @@ jobs:
- name: Run headless E2E test with Maven
# Classic UI e2e runs only on the anonymous leg, like the legacy Protractor suite
run: xvfb-run --auto-servernum --server-args="-screen 0 1024x768x24" ./mvnw verify -pl zeppelin-web-angular -Pweb-e2e -Dweb.e2e.classic.disabled=${{ matrix.mode != 'anonymous' }} ${MAVEN_ARGS}
- name: Run revision isolation E2E test with Git storage
env:
CI: 'true'
ZEPPELIN_NOTEBOOK_STORAGE: org.apache.zeppelin.notebook.repo.GitNotebookRepo
ZEPPELIN_E2E_REQUIRE_REVISION: 'true'
PLAYWRIGHT_HTML_OUTPUT_DIR: playwright-report-revision
run: |
revision_notebook_dir="$(mktemp -d "${RUNNER_TEMP}/zeppelin-revision-notebooks.XXXXXX")"
zeppelin_daemon="${GITHUB_WORKSPACE}/bin/zeppelin-daemon.sh"
cleanup() {
"$zeppelin_daemon" stop || true
rm -rf -- "$revision_notebook_dir"
}
trap cleanup EXIT
export ZEPPELIN_NOTEBOOK_DIR="$revision_notebook_dir"
# This step owns notebook cleanup instead of Playwright's global directory reset.
unset ZEPPELIN_E2E_TEST_NOTEBOOK_DIR
"$zeppelin_daemon" start
cd zeppelin-web-angular
xvfb-run --auto-servernum --server-args="-screen 0 1024x768x24" ./node/npm run e2e -- tests/notebook/revision/revision-isolation.spec.ts --output=test-results-revision --reporter=github,html
- name: Upload Playwright Report
uses: actions/upload-artifact@v6
if: always()
Expand All @@ -123,6 +143,7 @@ jobs:
path: |
zeppelin-web-angular/playwright-report/
zeppelin-web-angular/playwright-report-classic/
zeppelin-web-angular/playwright-report-revision/
retention-days: 3
- name: Print Zeppelin logs
if: always()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
* 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, Locator, Page, test } from '@playwright/test';

import {
addPageAnnotationBeforeEach,
createTestNotebookWithName,
PAGES,
performLoginIfRequired,
waitForNotebookLinks,
waitForZeppelinReady
} from '../../../utils';

const prepareWorkspace = async (page: Page): Promise<void> => {
await page.goto('/#/');
await waitForZeppelinReady(page);
await performLoginIfRequired(page);
await waitForNotebookLinks(page);
};

const openNotebook = async (page: Page, noteId: string): Promise<void> => {
await page.goto(`/#/notebook/${noteId}`);
await waitForZeppelinReady(page);
};

/** Saves a revision. The commit control is an icon-only button that opens a popover. */
const commitRevision = async (page: Page, message: string): Promise<void> => {
await page.locator('button:has(i[nzType="to-top"])').click();
const commitInput = page.getByPlaceholder('commit message');
await expect(commitInput).toBeVisible({ timeout: 15000 });
await commitInput.fill(message);
await page.getByRole('button', { name: 'commit', exact: true }).click();
await expect(commitInput).toBeHidden({ timeout: 15000 });
};

/** The look and feel dropdown is labelled with the note's current value. */
const lookAndFeelButton = (page: Page): Locator => page.locator('button[nz-dropdown]:has(i[nzType="down"])').last();

const setLookAndFeel = async (page: Page, value: string): Promise<void> => {
await lookAndFeelButton(page).click();
await page.locator('li[nz-menu-item]').filter({ hasText: value }).last().click();
};

test.describe('Revision isolation', () => {
addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK);

// All viewers share one principal (same storageState), matching the collaborative-mode spec.
// Look and feel is what drives NOTE_UPDATE, and so the NOTE_UPDATED broadcast this covers;
// renaming goes through a different op that resends the whole note.
test('keeps a live NOTE_UPDATED from mutating an open revision snapshot', async ({ page, browser }) => {
await prepareWorkspace(page);

const capabilities = await page.request.get('/api/notebook/capabilities');
expect(capabilities.ok()).toBe(true);
const { body } = await capabilities.json();
expect(typeof body.isRevisionSupported).toBe('boolean');
expect(
body.isRevisionSupported || process.env.ZEPPELIN_E2E_REQUIRE_REVISION !== 'true',
'The revision CI run requires versioned notebook storage'
).toBe(true);
test.skip(!body.isRevisionSupported, 'The configured notebook storage does not support revisions');

const { noteId } = await createTestNotebookWithName(page, { namePrefix: 'RevisionIsolation' });
await openNotebook(page, noteId);

// The snapshot has to capture the original look and feel, before the live change below.
await expect(lookAndFeelButton(page)).toContainText('default', { timeout: 15000 });
await commitRevision(page, 'snapshot before look and feel change');

// The dropdown is labelled with the current revision, which is "Head" until one is chosen.
await page.getByRole('button', { name: 'Head', exact: true }).click();
const revisionItem = page.locator('li[nz-menu-item]').filter({ hasText: 'snapshot before look' });
await expect(revisionItem).toBeVisible({ timeout: 15000 });
await revisionItem.click();

// Same socket, now showing the historical snapshot.
await expect(page).toHaveURL(/\/revision\//, { timeout: 15000 });
await expect(lookAndFeelButton(page)).toContainText('default', { timeout: 15000 });

// The assertion below has to run after the revision view has actually received the event,
// otherwise it would pass simply by checking too early. Message.receive logs every op.
let sawNoteUpdated = false;
page.on('console', message => {
if (message.text().includes('Receive: NOTE_UPDATED')) {
sawNoteUpdated = true;
}
});

const liveContext = await browser.newContext({ storageState: await page.context().storageState() });
try {
const livePage = await liveContext.newPage();
const followerPage = await liveContext.newPage();
let followerMessagesReceived = 0;
followerPage.on('console', message => {
if (message.text().includes('Receive: NOTE_UPDATED')) {
followerMessagesReceived++;
}
});

await prepareWorkspace(livePage);
await openNotebook(livePage, noteId);
await expect(lookAndFeelButton(livePage)).toContainText('default', { timeout: 15000 });

await openNotebook(followerPage, noteId);
await expect(lookAndFeelButton(followerPage)).toContainText('default', { timeout: 15000 });

// Change the live note from an independent browser context; this broadcasts NOTE_UPDATED.
await setLookAndFeel(livePage, 'simple');

// This viewer performs no local edit, so its change must come from the broadcast.
await expect(lookAndFeelButton(followerPage)).toContainText('simple', { timeout: 15000 });
await expect.poll(() => sawNoteUpdated, { timeout: 15000 }).toBe(true);

// The revision view got the event and must still show the snapshot as it was saved.
await expect(lookAndFeelButton(page)).toContainText('default');
// Count received broadcasts; the UI assertion above verifies their visible effect.
expect(followerMessagesReceived).toBe(1);
} finally {
await liveContext.close();
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,9 @@ export class NotebookComponent extends MessageListenersManager implements OnInit

@MessageListener(OP.NOTE_UPDATED)
noteUpdated(data: MessageReceiveDataTypeMap[OP.NOTE_UPDATED]) {
if (!this.note) {
// NOTE_UPDATED carries the live note, so applying it while a revision is open would
// overwrite the historical snapshot with current values.
if (!this.note || this.revisionView) {
return;
}
if (data.name !== this.note.name) {
Expand Down
Loading