Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -2288,6 +2288,7 @@ private void removeNoteAngularObject(String noteId, AngularObject angularObject,
connectionManager.broadcast(noteId,
new Message(OP.ANGULAR_OBJECT_REMOVE)
.put("name", angularObject.getName())
.put("interpreterGroupId", interpreterGroupId)
.put("noteId", angularObject.getNoteId())
.put("paragraphId", angularObject.getParagraphId()));
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,58 @@ void unbindAngularObjectFromRemoteForParagraphs() throws Exception {
}
}

@Test
void interpreterRemovalPreservesGroupAndRoutesAllScopes() throws Exception {
final String noteId = "angular-removal-note";
final String otherNoteId = "angular-removal-other-note";
final String groupId = "angular-setting:session";
final Notebook mockedNotebook = mock(Notebook.class, RETURNS_DEEP_STUBS);
when(mockedNotebook.getInterpreterSettingManager().getSettingIds())
.thenReturn(Collections.singletonList("angular-setting"));
final NoteInfo noteInfo = mock(NoteInfo.class);
final NoteInfo otherNoteInfo = mock(NoteInfo.class);
when(noteInfo.getId()).thenReturn(noteId);
when(otherNoteInfo.getId()).thenReturn(otherNoteId);
when(mockedNotebook.getNotesInfo()).thenReturn(Arrays.asList(noteInfo, otherNoteInfo));
final NotebookSocket conn = mock(NotebookSocket.class);
final NotebookSocket otherConn = mock(NotebookSocket.class);
notebookServer.getConnectionManager().noteSocketMap.put(noteId, Collections.singleton(conn));
notebookServer.getConnectionManager().noteSocketMap.put(otherNoteId, Collections.singleton(otherConn));

try {
notebookServer.setNotebook(() -> mockedNotebook);
for (String paragraphId : Arrays.asList("paragraph", null)) {
AngularObject<String> object = AngularObjectBuilder.build("value", "old", noteId, paragraphId);

notebookServer.onRemoveAngularObject(groupId, object);

Message expected = new Message(OP.ANGULAR_OBJECT_REMOVE)
.put("name", "value")
.put("interpreterGroupId", groupId)
.put("noteId", noteId)
.put("paragraphId", paragraphId);
verify(conn).send(notebookServer.serializeMessage(expected));
}
verify(otherConn, never()).send(anyString());
reset(conn);

AngularObject<String> globalObject = AngularObjectBuilder.build("value", "old", null, null);
notebookServer.onRemoveAngularObject(groupId, globalObject);

Message expectedGlobal = new Message(OP.ANGULAR_OBJECT_REMOVE)
.put("name", "value")
.put("interpreterGroupId", groupId)
.put("noteId", null)
.put("paragraphId", null);
verify(conn).send(notebookServer.serializeMessage(expectedGlobal));
verify(otherConn).send(notebookServer.serializeMessage(expectedGlobal));
} finally {
notebookServer.setNotebook(() -> NotebookServerTest.notebook);
notebookServer.getConnectionManager().noteSocketMap.remove(noteId);
notebookServer.getConnectionManager().noteSocketMap.remove(otherNoteId);
}
}

@Test
void testCreateNoteWithDefaultInterpreterId() throws IOException {
// create two sockets and open it
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* 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 { randomUUID } from 'node:crypto';
import { expect, test } from '@playwright/test';
import { NotebookKeyboardPage } from '../../../models/notebook-keyboard-page';
import {
addPageAnnotationBeforeEach,
performLoginIfRequired,
PAGES,
setParagraphText,
waitForZeppelinReady
} from '../../../utils';

test.describe('Notebook-scoped Angular objects', () => {
addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_PARAGRAPH);
addPageAnnotationBeforeEach(PAGES.WORKSPACE.SHARE_RESULT);

test('displays retained values, synchronizes both directions, and removes the binding', async ({ page }) => {
await page.goto('/#/');
await waitForZeppelinReady(page);
await performLoginIfRequired(page);
const created = await page.request.post('/api/notebook', {
data: {
notePath: `E2E_TEST_FOLDER/AngularObject_${randomUUID()}`,
defaultInterpreterGroup: 'python',
addingEmptyParagraph: false,
paragraphs: [{ text: '%python' }, { text: '%python' }]
}
});
expect(created.ok()).toBeTruthy();
const noteId = (await created.json()).body as string;

try {
const note = await page.request.get(`/api/notebook/${noteId}`);
expect(note.ok()).toBeTruthy();
const [model, output] = (await note.json()).body.paragraphs;
const notebook = new NotebookKeyboardPage(page);
const value = page.getByLabel('Notebook model', { exact: true });
const runPython = async (code: string, paragraphId = model.id) => {
await setParagraphText(page, noteId, paragraphId, `%python\n${code}`);
const response = await page.request.post(`/api/notebook/run/${noteId}/${paragraphId}`);
expect(response.ok()).toBeTruthy();
const result = (await response.json()).body;
expect(result.code, JSON.stringify(result)).toBe('SUCCESS');
};
const template = `<output aria-label="Notebook model">{{ notebookModel }}</output>
<button type="button" (click)="z.set('notebookModel', 'from-ui', '${output.id}')">Update model</button>`;
await page.goto(`/#/notebook/${noteId}`);
await expect(notebook.getParagraphByIndex(1)).toBeVisible();

// Explicit note ID: the Python wrapper's default bind is not notebook-scoped.
await runPython(`z.angularBind('notebookModel', 'initial', '${noteId}')`);
await expect(value).toHaveCount(0);
await runPython(`print(${JSON.stringify(`%angular\n${template}`)})`, output.id);
await expect(value).toHaveText('initial');

await runPython(`z.angularBind('notebookModel', 'from-server', '${noteId}')`);
await expect(value).toHaveText('from-server');

await setParagraphText(page, noteId, model.id, `%python\nprint('SERVER-MODEL=' + z.angular('notebookModel'))`);
await page.getByRole('button', { name: 'Update model', exact: true }).click();
await expect(value).toHaveText('from-ui');
// Send the read through the same WebSocket as the edit to preserve message order.
await notebook.focusParagraphHost(0);
await notebook.pressRunParagraph();
await expect(notebook.getParagraphByIndex(0).getByText('SERVER-MODEL=from-ui', { exact: true })).toBeVisible();

// The Python wrapper calls a private overload; use the public Java context API.
await runPython(`z.z.angularUnbind('notebookModel')`);
await expect(value).toBeEmpty();
} finally {
const deleted = await page.request.delete(`/api/notebook/${noteId}`);
expect(deleted.ok()).toBeTruthy();
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@
* limitations under the License.
*/

import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest';
import { afterEach, assertType, describe, expect, expectTypeOf, it, vi } from 'vitest';

import { OP } from './interfaces/message-operator.interface';
import {
AngularObjectRemove,
AngularObjectUpdate,
AngularObjectUpdated
} from './interfaces/message-paragraph.interface';
import { Message } from './message';

afterEach(() => vi.restoreAllMocks());
Expand All @@ -22,6 +27,45 @@ it('accepts arbitrary values through the public Angular object helpers', () => {
expectTypeOf<Parameters<Message['angularObjectClientBind']>[2]>().toEqualTypeOf<unknown>();
});

it('accepts notebook and global wire scopes without requiring Gson-omitted fields', () => {
const updates: AngularObjectUpdate[] = [
{
noteId: 'route-note',
interpreterGroupId: 'group',
angularObject: { name: 'value', object: false, noteId: 'route-note' }
},
{
noteId: 'route-note',
paragraphId: null,
interpreterGroupId: 'group',
angularObject: { name: 'value', object: false, noteId: null, paragraphId: null }
},
{
noteId: 'route-note',
interpreterGroupId: 'group',
angularObject: { name: 'value', object: false }
}
];
const removals: AngularObjectRemove[] = [
{ noteId: 'route-note', name: 'value', interpreterGroupId: 'group' },
{ noteId: 'route-note', paragraphId: null, name: 'value', interpreterGroupId: 'group' },
{ name: 'value', interpreterGroupId: 'group' },
{ noteId: null, name: 'value', interpreterGroupId: 'group' },
{ ...updates[0], angularObject: updates[0].angularObject },
{ ...updates[1], angularObject: updates[1].angularObject },
{ ...updates[2], angularObject: updates[2].angularObject }
];
const outgoing: AngularObjectUpdated[] = [
{ noteId: 'route-note', name: 'value', value: false, interpreterGroupId: 'group' },
{ noteId: 'route-note', paragraphId: null, name: 'value', value: false, interpreterGroupId: 'group' }
];

assertType<AngularObjectUpdate[]>(updates);
assertType<AngularObjectRemove[]>(removals);
assertType<AngularObjectUpdated[]>(outgoing);
expectTypeOf<Parameters<Message['angularObjectUpdate']>[1]>().toEqualTypeOf<string | null | undefined>();
});

describe('Angular object send values', () => {
it.each([false, 42, null, ['a', false], { count: 0, enabled: false }])(
'preserves %j in update and bind payloads',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,21 +234,21 @@ export interface MoveParagraph {

export interface AngularObjectUpdated {
noteId: string;
paragraphId: string;
paragraphId?: string | null;
name: string;
value: unknown;
interpreterGroupId: string;
}

interface AngularObjectRemoveContext {
noteId: string;
paragraphId: string;
noteId?: string | null;
paragraphId?: string | null;
angularObject?: {
name: string;
object: unknown;
// Omitted for global (noteId) and note (paragraphId) scoped objects.
noteId?: string;
paragraphId?: string;
noteId?: string | null;
paragraphId?: string | null;
};
interpreterGroupId?: string;
}
Expand All @@ -258,13 +258,13 @@ export type AngularObjectRemove = AngularObjectRemoveContext &

export interface AngularObjectUpdate {
noteId: string;
paragraphId: string;
paragraphId?: string | null;
interpreterGroupId: string;
angularObject: {
name: string;
object: unknown;
noteId: string;
paragraphId: string;
noteId?: string | null;
paragraphId?: string | null;
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,6 @@ it('separates received wire fields from backward-compatible import input', () =>
expectTypeOf<ParagraphItem>().toHaveProperty('progress').toEqualTypeOf<number>();
expectTypeOf<NonNullable<Note['note']>>().toHaveProperty('version').toEqualTypeOf<string | undefined>();
expectTypeOf<EditorSettingReceived['editor']>().toHaveProperty('completionKey').toEqualTypeOf<string | undefined>();
expectTypeOf<AngularObjectRemove>().toHaveProperty('angularObject').toEqualTypeOf<
| {
name: string;
object: unknown;
noteId?: string;
paragraphId?: string;
}
| undefined
>();
expectTypeOf<AngularObjectRemove>().toHaveProperty('interpreterGroupId').toEqualTypeOf<string | undefined>();
const legacyImportParagraph = {
text: '%md legacy import',
Expand Down
2 changes: 1 addition & 1 deletion zeppelin-web-angular/projects/zeppelin-sdk/src/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ export class Message {

angularObjectUpdate(
noteId: string,
paragraphId: string,
paragraphId: string | null | undefined,
name: string,
value: unknown,
interpreterGroupId: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,32 @@

import { Observable } from 'rxjs';

export interface AngularObjectBinding {
noteId: string | null;
paragraphId: string | null;
interpreterGroupId?: string;
}

export interface AngularContext {
noteId: string;
paragraphId: string;
key: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any;
value: unknown;
emit: boolean;
set: boolean;
binding?: AngularObjectBinding;
}

export interface AngularContextManager {
setContextValue(key: string, value: unknown, paragraphId: string, emit?: boolean): void;
unsetContextValue(key: string, paragraphId: string, emit?: boolean): unknown;
registerParagraphBindings(noteId: string, paragraphId: string): void;
setContextValue(
key: string,
value: unknown,
paragraphId: string,
emit?: boolean,
binding?: AngularObjectBinding
): void;
unsetContextValue(key: string, paragraphId: string, emit?: boolean, binding?: AngularObjectBinding): void;
contextChanged(): Observable<AngularContext>;
runParagraphAction(): Observable<string>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/

import { ChangeDetectorRef } from '@angular/core';
import { AngularObjectRemove, DatasetType, Message, OP, ParagraphItem, ParagraphState } from '@zeppelin/sdk';
import { DatasetType, Message, OP, ParagraphItem, ParagraphState } from '@zeppelin/sdk';
import { EMPTY } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';

Expand All @@ -22,7 +22,6 @@ class TestParagraph extends ParagraphBase {
changeColWidth = vi.fn();
updateParagraphResult = vi.fn();
protected currentNoteId = 'note';
unsetContextValue = vi.fn();

hydrate(snapshot: ParagraphItem) {
this.setParagraphSnapshot(snapshot);
Expand All @@ -32,7 +31,7 @@ class TestParagraph extends ParagraphBase {
super(
{ receive: () => EMPTY, receiveEnvelope: () => EMPTY } as unknown as Message,
{ isParagraphRunning: item => item.status === 'RUNNING', isEntireNoteRunning: () => false },
{ unsetContextValue: (...args) => this.unsetContextValue(...args) } as AngularContextManager,
{ registerParagraphBindings: vi.fn() } as unknown as AngularContextManager,
{ markForCheck: vi.fn() } as unknown as ChangeDetectorRef
);
this.paragraph = paragraph;
Expand Down Expand Up @@ -74,25 +73,6 @@ const beginOutput = (component: TestParagraph) =>
const appendOutput = (component: TestParagraph) =>
component.onParagraphAppendOutput({ noteId: 'note', paragraphId: 'A', index: 0, data: 'second\n' });

describe('ParagraphBase Angular object removal', () => {
const payloads: AngularObjectRemove[] = [
{ noteId: 'note', paragraphId: 'A', name: 'bound' },
{ noteId: 'note', paragraphId: 'A', angularObject: { name: 'bound', object: 42 }, interpreterGroupId: 'group' }
];

it.each(payloads)('removes the binding for %j', data => {
const component = new TestParagraph(paragraph('A'));
component.angularObjectRemove(data);
expect(component.unsetContextValue).toHaveBeenCalledWith('bound', 'A', false);
});

it('does not remove a binding belonging to another paragraph', () => {
const component = new TestParagraph(paragraph('A'));
component.angularObjectRemove({ noteId: 'note', paragraphId: 'other', name: 'bound' });
expect(component.unsetContextValue).not.toHaveBeenCalled();
});
});

describe('ParagraphBase streaming state isolation', () => {
it.each(['append', 'update'])('ignores %s from another note with the same paragraph ID', kind => {
const component = new TestParagraph(paragraph('A'));
Expand Down Expand Up @@ -307,6 +287,7 @@ const createSaveTestParagraph = (text: string, dirtyText?: string): SaveTestPara
{ receive: () => EMPTY, receiveEnvelope: () => EMPTY } as unknown as Message,
{ isParagraphRunning: () => false, isEntireNoteRunning: () => false },
{
registerParagraphBindings: vi.fn(),
setContextValue: vi.fn(),
unsetContextValue: vi.fn(),
contextChanged: () => EMPTY,
Expand Down
Loading
Loading