Skip to content
Open
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
20 changes: 20 additions & 0 deletions zeppelin-web-angular/e2e/models/notebook-assistant-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,31 @@ export class NotebookAssistantPage extends BasePage {
readonly toggleButton: Locator;
readonly panel: Locator;
readonly closeButton: Locator;
readonly question: Locator;
readonly sendButton: Locator;
readonly messages: Locator;

constructor(page: Page) {
super(page);
this.toggleButton = page.getByRole('button', { name: 'Toggle AI Assistant' });
this.panel = page.getByRole('complementary', { name: 'AI Assistant workspace' });
this.closeButton = page.getByRole('button', { name: 'Close AI Assistant' });
this.question = this.panel.getByRole('textbox', { name: 'Question' });
this.sendButton = this.panel.getByRole('button', { name: 'Send', exact: true });
this.messages = this.panel.getByRole('region', { name: 'Conversation messages' });
}

async selectConversation(title: string): Promise<void> {
await this.panel.getByRole('button', { name: 'Open conversations' }).click();
await this.panel
.getByRole('region', { name: 'Conversations', exact: true })
.getByRole('button')
.filter({ has: this.page.getByText(title, { exact: true }) })
.click();
}

async sendQuestion(content: string): Promise<void> {
await this.question.fill(content);
await this.sendButton.click();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/*
* 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 { NotebookAssistantPage } from '../../../models/notebook-assistant-page';
import { addPageAnnotationBeforeEach, createTestNotebook, PAGES, waitForZeppelinReady } from '../../../utils';

test.describe('Notebook AI Assistant fixture conversations', () => {
addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_ASSISTANT);

let assistant: NotebookAssistantPage;
let noteId: string | undefined;

test.beforeEach(async ({ page }) => {
noteId = undefined;
assistant = new NotebookAssistantPage(page);

await page.goto('/#/');
await waitForZeppelinReady(page);
({ noteId } = await createTestNotebook(page));

await page.goto(`/#/notebook/${noteId}?reactAssistant=true`);
await waitForZeppelinReady(page);
await assistant.toggleButton.click();
await expect(assistant.question).toBeEditable();
});

test.afterEach(async ({ page }) => {
if (noteId) {
const response = await page.request.delete(`/api/notebook/${noteId}`);
expect(response.ok(), 'Delete the disposable notebook').toBe(true);
}
});

test('streams a new answer without remounting on completion and restores its saved history', async () => {
const question = 'Explain the revenue results for this new conversation.';

await assistant.sendQuestion(question);

const answer = assistant.messages.getByRole('article', { name: 'Assistant answer', exact: true });
await expect(answer).toHaveAttribute('aria-busy', 'true');
await expect(answer).toContainText('I checked');
const original = await answer.elementHandle();
expect(original).not.toBeNull();

await expect(answer.getByRole('table')).toBeVisible();
await expect(answer).not.toHaveAttribute('aria-busy', 'true');
await expect(assistant.question).toBeEditable();
await expect(assistant.messages).not.toHaveAttribute('aria-busy', 'true');
expect(await original!.evaluate(element => element.isConnected)).toBe(true);
await expect(assistant.messages.getByRole('article', { name: 'Your question', exact: true })).toContainText(
question
);
await expect(answer).toHaveCount(1);

await assistant.closeButton.click();
await expect(assistant.panel).toBeHidden();
await assistant.toggleButton.click();
await assistant.selectConversation('New conversation');

await expect(assistant.messages).not.toHaveAttribute('aria-busy', 'true');
await expect(assistant.messages.getByRole('article', { name: 'Your question', exact: true })).toContainText(
question
);
await expect(answer).toHaveCount(1);
await expect(answer.getByRole('table')).toBeVisible();
});

test('keeps B draft and results separate when switching away from an active A run', async () => {
const aQuestion = 'Compare revenue for conversation A.';
const bQuestion = 'Summarize the regions for conversation B.';
const summary = 'This notebook compares monthly revenue across regions.';

await assistant.selectConversation('Notebook summary');
await expect(assistant.messages).toContainText(summary);
await assistant.question.fill(bQuestion);
await assistant.selectConversation('Revenue analysis');
await expect(assistant.question).toBeEditable();

await assistant.sendQuestion(aQuestion);

await expect(
assistant.messages.getByRole('article', { name: 'Assistant answer', exact: true }).last()
).toHaveAttribute('aria-busy', 'true');

await assistant.selectConversation('Notebook summary');
await expect(assistant.question).toHaveValue(bQuestion);
await expect(assistant.question).toBeEditable();
await expect(assistant.messages).toContainText(summary);
await expect(assistant.messages).not.toContainText(aQuestion);

await assistant.sendButton.click();
const bAnswer = assistant.messages.getByRole('article', { name: 'Assistant answer', exact: true }).last();

await expect(bAnswer.getByRole('table')).toBeVisible();
await expect(bAnswer).not.toHaveAttribute('aria-busy', 'true');
await expect(assistant.question).toBeEditable();
await expect(assistant.messages).not.toHaveAttribute('aria-busy', 'true');
await expect(assistant.messages.getByText(bQuestion, { exact: true })).toHaveCount(1);
await expect(assistant.messages).toContainText(summary);
await expect(assistant.messages).not.toContainText(aQuestion);

await assistant.question.fill('Keep this next B draft.');

await assistant.selectConversation('Revenue analysis');
await expect(assistant.messages.getByText(aQuestion, { exact: true })).toHaveCount(1);
await expect(assistant.messages.getByRole('table')).toHaveCount(1);
await expect(assistant.question).toBeEditable();
await expect(assistant.messages).not.toContainText(bQuestion);

await assistant.selectConversation('Notebook summary');

await expect(assistant.question).toHaveValue('Keep this next B draft.');
await expect(assistant.messages.getByText(bQuestion, { exact: true })).toHaveCount(1);
await expect(assistant.messages.getByRole('table')).toHaveCount(1);
await expect(assistant.messages).toContainText(summary);
});

test('scrolling to earlier history joins the split answer without replacing the later answer', async () => {
await assistant.selectConversation('Revenue analysis');
const laterAnswer = assistant.messages.getByRole('article', { name: 'Assistant answer', exact: true }).filter({
hasText: 'Compare Europe’s order volume with its average order value.'
});
await expect(laterAnswer).toBeVisible();
const original = await laterAnswer.elementHandle();
expect(original).not.toBeNull();

await assistant.messages.evaluate(element => {
element.scrollTop = 0;
});

await expect(assistant.messages.getByText('How did revenue change by region?', { exact: true })).toHaveCount(1);
await expect(assistant.messages).not.toHaveAttribute('aria-busy', 'true');
const joinedAnswer = assistant.messages.getByRole('article', { name: 'Assistant answer', exact: true }).filter({
hasText: 'I checked the regional revenue paragraph.'
});
await expect(joinedAnswer).toContainText('Revenue increased in all three regions, led by Europe.');
await expect(assistant.messages.getByRole('article', { name: 'Assistant answer', exact: true })).toHaveCount(2);
await expect(laterAnswer).toHaveCount(1);
expect(await original!.evaluate(element => element.isConnected)).toBe(true);
await expect(assistant.question).toBeEditable();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const answer = () => screen.getByRole('article', { name: 'Assistant answer' });
describe('AssistantAnswer', () => {
it('shows only the current state as the run moves from loading to done', () => {
const { rerender } = render(<AssistantAnswer view={{ status: 'loading' }} />);

expect(screen.getByRole('status').textContent).toBe('Thinking…');
expect(answer().getAttribute('aria-busy')).toBe('true');

Expand All @@ -32,6 +33,7 @@ describe('AssistantAnswer', () => {
}}
/>
);

expect(screen.getByRole('status').textContent).toBe('Writing answer…');
expect(answer().textContent).toContain('Revenue is grouped');

Expand All @@ -50,6 +52,7 @@ describe('AssistantAnswer', () => {
}}
/>
);

expect(screen.queryByRole('status')).toBeNull();
expect(screen.queryByRole('alert')).toBeNull();
expect(answer().hasAttribute('aria-busy')).toBe(false);
Expand All @@ -70,26 +73,30 @@ describe('AssistantAnswer', () => {
}}
/>
);

expect(screen.getByRole('status').textContent).toBe('Running read_results…');
expect(screen.getByText('Using 2 tools')).toBeTruthy();
const [first, second] = screen.getAllByRole('listitem');
expect(within(first).getByRole('img', { name: 'Done' })).toBeTruthy();
expect(within(second).getByRole('img', { name: 'Running' })).toBeTruthy();
});

it('opens the tool log while a tool runs and collapses it when the answer completes', () => {
it('keeps a user-collapsed tool log closed through answer updates and completion', () => {
const running: AssistantAnswerView = {
status: 'streaming',
content: '',
tools: [{ id: 't1', name: 'list_paragraphs', status: 'running' }]
};
const toolLog = () => screen.getByText(/tool$/).closest('details')!;
const toolLog = () => within(answer()).getByRole('group') as HTMLDetailsElement;

const { rerender } = render(<AssistantAnswer view={running} />);

expect(toolLog().open).toBe(true);

// A user who collapses the log keeps it collapsed while more of the answer arrives.
toolLog().open = false;
rerender(<AssistantAnswer view={{ ...running, content: 'Revenue' }} />);

expect(toolLog().open).toBe(false);

rerender(
Expand All @@ -104,11 +111,80 @@ describe('AssistantAnswer', () => {
}}
/>
);

expect(toolLog().open).toBe(false);
});

it('preserves an explicitly expanded finished tool log when streaming completes', () => {
const { rerender } = render(
<AssistantAnswer
view={{
status: 'streaming',
content: 'Revenue',
tools: [{ id: 't1', name: 'list_paragraphs', status: 'done' }]
}}
/>
);

const log = within(answer()).getByRole('group') as HTMLDetailsElement;
const summary = log.querySelector('summary')!;
log.open = true;
summary.focus();

rerender(
<AssistantAnswer
view={{
status: 'done',
message: {
id: 'm1',
role: 'assistant',
segments: [{ id: 's1', content: 'Revenue', toolCalls: [{ id: 't1', name: 'list_paragraphs' }] }]
}
}}
/>
);

expect(within(answer()).getByRole('group')).toHaveProperty('open', true);
expect(document.activeElement).toBe(summary);
});

it('keeps the native tool log and its focused summary when streaming fails', () => {
const tools = [
{ id: 't1', name: 'list_paragraphs', status: 'done' as const },
{ id: 't2', name: 'read_results', status: 'running' as const }
];
const { rerender } = render(<AssistantAnswer view={{ status: 'streaming', content: 'Revenue', tools }} />);
const log = within(answer()).getByRole('group') as HTMLDetailsElement;
const summary = log.querySelector('summary')!;
log.open = false;
summary.focus();

rerender(
<AssistantAnswer
view={{
status: 'error',
kind: 'connection',
message: 'Connection lost',
content: 'Revenue',
tools
}}
/>
);

expect(within(answer()).getByRole('group')).toBe(log);
expect(log.open).toBe(false);
expect(document.activeElement).toBe(summary);
expect(screen.queryByRole('status')).toBeNull();
expect(screen.getByRole('alert').textContent).toContain('Connection lost');
expect(answer().textContent).toContain('Revenue');
expect(screen.getByRole('img', { name: 'Done' })).toBeTruthy();
expect(screen.getByRole('img', { name: 'Status unknown' })).toBeTruthy();
expect(screen.queryByRole('img', { name: 'Running' })).toBeNull();
});

it('labels a tool whose completion event carried no name', () => {
render(<AssistantAnswer view={{ status: 'streaming', content: '', tools: [{ id: 't1', status: 'running' }] }} />);

expect(screen.getByRole('status').textContent).toBe('Running Tool…');
});

Expand Down Expand Up @@ -141,7 +217,9 @@ describe('AssistantAnswer', () => {
message: 'The assistant stopped responding before the reply finished. Retry the request.',
content: 'Revenue is grouped'
};

render(<AssistantAnswer view={view} />);

expect(screen.queryByRole('status')).toBeNull();
expect(answer().hasAttribute('aria-busy')).toBe(false);
expect(answer().textContent).toContain('Revenue is grouped');
Expand Down Expand Up @@ -175,6 +253,7 @@ describe('AssistantAnswer', () => {
renderContent={content => <strong>{content.replaceAll('*', '')}</strong>}
/>
);

expect(answer().querySelector('strong')?.textContent).toBe('bold');
});
});
Loading
Loading