Skip to content

Commit d37dfa0

Browse files
Merge pull request #83 from ApryseSDK/e2e-testing-as229-new-tests
webviewer-ask-ai: e2e functional test implementation
2 parents 05524e0 + 3a8e4b6 commit d37dfa0

12 files changed

Lines changed: 494 additions & 248 deletions

File tree

webviewer-ask-ai/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ A license key is required to run WebViewer. You can obtain a trial key in our [g
1717

1818
## Initial setup
1919

20-
Before you begin, make sure the development environment includes [Node.js](https://nodejs.org/en/).
20+
Before you begin, make sure the development environment includes [Node.js](https://nodejs.org/en/) 20 or newer.
2121

2222
## Install
2323

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// This file provides mock implementations for the webviewer-ask-ai module,
2+
// allowing developers to test and develop features without relying on actual
3+
// API calls.
4+
5+
export const MOCK_RESPONSE = {
6+
DOCUMENT_QUESTION: 'In 2011, Rosneft undertook several social responsibility initiatives, including: 1. Support for education by providing RUB 141 million to higher education institutions and extending loans totaling RUB 3.6 million to 97 workers for educational courses. 2. Charity efforts focused on socio-economic projects, healthcare, education, culture, and sports, with a total spending of RUB 2.9 billion on charity. 3. Maintenance of social infrastructure, with spending of RUB 1.0 billion aimed at optimizing facilities for employees and communities [14][15].',
7+
SELECTED_TEXT_SUMMARY: 'The Tuapse license area covers 12,000 square km in the Black Sea and has geological similarities to the West-Kuban Trough, a historic oil production region in Russia [6]. The Tuapse Block has undergone comprehensive 2D seismic work, with the most promising areas also analyzed using 3D seismic technology [6]. Current data indicates the presence of 20 promising structures with an estimated 8.9 billion barrels of recoverable oil resources [6].',
8+
DOCUMENT_CONTEXTUAL_QUESTIONS: '• What strategic partnerships did Rosneft establish in 2011 for offshore exploration?\n• How did Rosneft\'s resource base change in 2011 compared to previous years?\n• What social responsibility initiatives did Rosneft undertake in 2011?'
9+
};
10+
11+
// Registers a Playwright route interceptor that mocks all /api/chat POST requests,
12+
// preventing real network calls to the AI backend during tests.
13+
export const registerApiChatMock = async (page) => {
14+
await page.route('/api/chat', async (route) => {
15+
const requestBody = JSON.parse(route.request().postData() || '{}');
16+
const { promptType } = requestBody;
17+
18+
let response;
19+
switch (promptType) {
20+
case 'DOCUMENT_CONTEXTUAL_QUESTIONS':
21+
response = MOCK_RESPONSE.DOCUMENT_CONTEXTUAL_QUESTIONS;
22+
break;
23+
case 'DOCUMENT_QUESTION':
24+
response = MOCK_RESPONSE.DOCUMENT_QUESTION;
25+
break;
26+
case 'SELECTED_TEXT_SUMMARY':
27+
response = MOCK_RESPONSE.SELECTED_TEXT_SUMMARY;
28+
break;
29+
default:
30+
response = '';
31+
}
32+
33+
await route.fulfill({
34+
status: 200,
35+
contentType: 'application/json',
36+
body: JSON.stringify({ response }),
37+
});
38+
});
39+
};
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// This file contains end-to-end tests for the WebViewer Ask AI sample application,
2+
// using the Playwright testing framework.
3+
4+
import { test, expect } from '@playwright/test';
5+
import { MOCK_RESPONSE, registerApiChatMock } from '../../__mocks__/webviewer-ask-ai.mock.js';
6+
7+
const question = 'What social responsibility initiatives did Rosneft undertake in 2011?';
8+
9+
// Register the API chat mock before each test
10+
// to ensure consistent and predictable responses
11+
// from the chatbot during testing.
12+
test.beforeEach(async ({ page }) => {
13+
await registerApiChatMock(page);
14+
});
15+
16+
// Validate the chatbot toggle button visibility.
17+
test('Chatbot toggle button visibility', async ({ page }) => {
18+
await page.goto('/client/index.html');
19+
20+
const toggle = page.locator('button[data-element="askWebSDKPanelToggle"]');
21+
await toggle.waitFor({ state: 'visible' });
22+
await expect(toggle).toBeVisible();
23+
});
24+
25+
// Validate the chatbot panel visibility.
26+
test('Chatbot panel visibility', async ({ page }) => {
27+
await page.goto('/client/index.html');
28+
29+
const panel = page.locator('div.ModularPanel[data-element="askWebSDKPanel"]');
30+
await panel.waitFor({ state: 'visible' });
31+
await expect(panel).toBeVisible();
32+
});
33+
34+
// Validate the summarizing selection button visibility.
35+
test('Summarizing selection button visibility', async ({ page }) => {
36+
await page.goto('/client/index.html');
37+
await simulateDocumentTextSelection(page, {
38+
pageNumber: 2,
39+
start: { x: 56.69320848, y: 32.40185332 },
40+
end: { x: 105.11567193, y: 40.06439572 },
41+
});
42+
43+
const popupButton = page.locator('button[data-element="askWebSDKButton"]');
44+
await popupButton.waitFor({ state: 'visible' });
45+
await expect(popupButton).toBeVisible();
46+
});
47+
48+
// Simulates user asking free question within the chatbot panel
49+
test('Ask free question', async ({ page }) => {
50+
await page.goto('/client/index.html');
51+
52+
// Locate the question input field, enter a question, and submit it by pressing 'Enter'
53+
const questionInput = page.locator('#askWebSDKQuestionInput');
54+
await questionInput.fill(question);
55+
await questionInput.press('Enter');
56+
57+
// Validate that the assistant's response contains the expected text from the mock response
58+
const assistantResponse = page.locator('.askWebSDKAssistantMessageClass').last();
59+
await expect(assistantResponse).toContainText(MOCK_RESPONSE.DOCUMENT_QUESTION);
60+
});
61+
62+
// Simulates user selecting text on the document and asking the chatbot to summarize it
63+
test('Summarize selected text', async ({ page }) => {
64+
await page.goto('/client/index.html');
65+
await simulateDocumentTextSelection(page, {
66+
pageNumber: 6,
67+
start: { x: 179.72459999999998, y: 536.4002 },
68+
end: { x: 141.165, y: 415.9352 },
69+
});
70+
71+
// Click the "Summarize selection" button in the TextPopup
72+
const popupButton = page.locator('button[data-element="askWebSDKButton"]');
73+
await popupButton.waitFor({ state: 'visible' });
74+
await popupButton.click();
75+
76+
// Validate that the assistant's response contains the expected text from the mock response
77+
const assistantResponse = page.locator('.askWebSDKAssistantMessageClass').last();
78+
await expect(assistantResponse).toContainText(MOCK_RESPONSE.SELECTED_TEXT_SUMMARY);
79+
});
80+
81+
// Simulates user hiding and showing the chatbot panel
82+
// via clicking the toggle button in the header.
83+
// Chatbot panel should maintain the visibility of the conversation.
84+
test('Hide/Show chatbot panel', async ({ page }) => {
85+
await page.goto('/client/index.html');
86+
87+
// Locate the question input field, enter a question, and submit it by pressing 'Enter'
88+
const questionInput = page.locator('#askWebSDKQuestionInput');
89+
await questionInput.fill(question);
90+
await questionInput.press('Enter');
91+
92+
// Locate the toggle button and chatbot panel
93+
const toggle = page.locator('button[data-element="askWebSDKPanelToggle"]');
94+
const panel = page.locator('div.ModularPanel[data-element="askWebSDKPanel"]');
95+
96+
// Hide the chatbot panel
97+
await toggle.click();
98+
await expect(panel).not.toBeVisible();
99+
100+
// Show the chatbot panel again
101+
await toggle.click();
102+
await expect(panel).toBeVisible();
103+
});
104+
105+
// Helper function to select document text and trigger the TextPopup.
106+
const simulateDocumentTextSelection = async (page, { pageNumber, start, end }) => {
107+
await page.locator('div.ModularPanel[data-element="askWebSDKPanel"]').waitFor({ state: 'visible' });
108+
109+
await page.evaluate(({ pageNumber, start, end }) => {
110+
const instance = globalThis.WebViewer.getInstance();
111+
const core = instance.Core;
112+
const UI = instance.UI;
113+
const documentViewer = core.documentViewer;
114+
const textSelectTool = documentViewer.getTool(core.Tools.ToolNames.TEXT_SELECT);
115+
116+
documentViewer.setCurrentPage(pageNumber);
117+
UI.setToolMode(core.Tools.ToolNames.TEXT_SELECT);
118+
textSelectTool.select({ pageNumber, ...start }, { pageNumber, ...end });
119+
}, { pageNumber, start, end });
120+
121+
await page.waitForFunction(() => {
122+
const instance = globalThis.WebViewer.getInstance();
123+
const selectedText = instance?.Core?.documentViewer?.getSelectedText?.();
124+
return typeof selectedText === 'string' && selectedText.trim().length > 0;
125+
});
126+
127+
await page.evaluate(() => {
128+
const instance = globalThis.WebViewer.getInstance();
129+
instance.UI.openElements(['textPopup']);
130+
});
131+
};

webviewer-ask-ai/client/chatbot/chatbot.js

Lines changed: 54 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,8 @@ class Chatbot {
2929
const message = messagesHistory[i];
3030
let messageTokenCount;
3131

32-
try {
33-
// Use simple character-based estimation for token counting
34-
messageTokenCount = Math.ceil(message.content.length / 4);
35-
} catch (error) {
36-
// Fallback to estimation
37-
messageTokenCount = Math.ceil(message.content.length / 4);
38-
}
32+
// Use simple character-based estimation for token counting
33+
messageTokenCount = Math.ceil(message.content.length / 4);
3934

4035
if (tokenCount + messageTokenCount <= maxTokens) {
4136
trimmedHistory.unshift(message);
@@ -48,57 +43,53 @@ class Chatbot {
4843
}
4944

5045
async sendMessage(promptLine, message) {
51-
try {
52-
// For document-level operations, use increased token limit to preserve messages history
53-
// Adjust token limits based on prompt type to balance document content and messages history
54-
let maxTokens = this.messagesHistoryOptions.maxTokens || 8000;
55-
56-
// For document questions, we need more room for messages history since we're sending full document
57-
if (promptLine.includes('DOCUMENT_'))
58-
maxTokens = Math.max(maxTokens, 8000); // Ensure minimum 8000 tokens for document questions
59-
const messagesHistoryToSend = this.messagesHistoryOptions.useEmpty ? [] : await this.trimHistoryForTokenLimit(this.messagesHistory, maxTokens);
60-
61-
const response = await fetch('/api/chat', {
62-
method: 'POST',
63-
headers: {
64-
'Content-Type': 'application/json',
65-
},
66-
body: JSON.stringify({
67-
message: message,
68-
promptType: promptLine,
69-
history: messagesHistoryToSend
70-
})
71-
});
72-
73-
if (!response.ok)
74-
throw new Error(`HTTP error! status: ${response.status}`);
75-
76-
const data = await response.json();
77-
78-
// Update messages history only if not explicitly disabled
79-
if (!this.messagesHistoryOptions.skipUpdate) {
80-
// For document queries, extract only the question part to avoid storing redundant document content
81-
let historyMessage = message;
82-
if (promptLine.includes('DOCUMENT_')) {
83-
// Extract question from document queries to avoid token waste
84-
const questionMatch = message.match(/(?:Human Question|Question): (.+?)\n\nDocument Content:/);
85-
if (questionMatch)
86-
historyMessage = questionMatch[1];
87-
else
88-
// Fallback: use first 200 chars if pattern not found, avoiding full document
89-
historyMessage = message.length > 200 ? message.substring(0, 200) + '... [document content excluded from history]' : message;
90-
}
46+
// For document-level operations, use increased token limit to preserve messages history
47+
// Adjust token limits based on prompt type to balance document content and messages history
48+
let maxTokens = this.messagesHistoryOptions.maxTokens || 8000;
49+
50+
// For document questions, we need more room for messages history since we're sending full document
51+
if (promptLine.includes('DOCUMENT_'))
52+
maxTokens = Math.max(maxTokens, 8000); // Ensure minimum 8000 tokens for document questions
53+
const messagesHistoryToSend = this.messagesHistoryOptions.useEmpty ? [] : await this.trimHistoryForTokenLimit(this.messagesHistory, maxTokens);
54+
55+
const response = await fetch('/api/chat', {
56+
method: 'POST',
57+
headers: {
58+
'Content-Type': 'application/json',
59+
},
60+
body: JSON.stringify({
61+
message: message,
62+
promptType: promptLine,
63+
history: messagesHistoryToSend
64+
})
65+
});
9166

92-
this.messagesHistory.push(
93-
{ role: 'human', content: `${promptLine}: ${historyMessage}` },
94-
{ role: 'assistant', content: data.response }
95-
);
67+
if (!response.ok)
68+
throw new Error(`HTTP error! status: ${response.status}`);
69+
70+
const data = await response.json();
71+
72+
// Update messages history only if not explicitly disabled
73+
if (!this.messagesHistoryOptions.skipUpdate) {
74+
// For document queries, extract only the question part to avoid storing redundant document content
75+
let historyMessage = message;
76+
if (promptLine.includes('DOCUMENT_')) {
77+
// Extract question from document queries to avoid token waste
78+
const questionMatch = message.match(/(?:Human Question|Question): (.+?)\n\nDocument Content:/);
79+
if (questionMatch)
80+
historyMessage = questionMatch[1];
81+
else
82+
// Fallback: use first 200 chars if pattern not found, avoiding full document
83+
historyMessage = message.length > 200 ? message.substring(0, 200) + '... [document content excluded from history]' : message;
9684
}
9785

98-
return data.response;
99-
} catch (error) {
100-
throw error;
86+
this.messagesHistory.push(
87+
{ role: 'human', content: `${promptLine}: ${historyMessage}` },
88+
{ role: 'assistant', content: data.response }
89+
);
10190
}
91+
92+
return data.response;
10293
}
10394

10495
// Prepare a message, considering contextual question or history question
@@ -151,7 +142,7 @@ class Chatbot {
151142

152143
askQuestionByPrompt(prompt, question = null) {
153144
// Start spinning on main div
154-
spinner.spin(askWebSDKMainDiv);
145+
spinner.spin(globalThis.askWebSDKMainDiv);
155146

156147
// Create a wrapper callback that stops the spinner after bubble is called
157148
const callbackWrapper = (...args) => {
@@ -169,7 +160,7 @@ class Chatbot {
169160
// DOCUMENT_QUESTION
170161
async summarizeTextByPrompt(prompt, text) {
171162
// Start spinning on main div
172-
spinner.spin(askWebSDKMainDiv);
163+
spinner.spin(globalThis.askWebSDKMainDiv);
173164

174165
// Combine into single container for all bubble responses
175166
let responseText = '';
@@ -204,9 +195,11 @@ class Chatbot {
204195
let updatedCount = 0;
205196
questionsLIs.forEach((configAndLiTags) => {
206197

207-
if (configAndLiTags[0] && configAndLiTags[0].promptType === 'DOCUMENT_CONTEXTUAL_QUESTION_EXACTLY') {
198+
if (configAndLiTags[0]?.promptType === 'DOCUMENT_CONTEXTUAL_QUESTION_EXACTLY') {
208199

209-
if (this.questionsContextuallySound[index] !== undefined) {
200+
if (this.questionsContextuallySound[index] === undefined) {
201+
console.warn(`Question ${index + 1} is undefined! Available questions:`, this.questionsContextuallySound);
202+
} else {
210203
let li = configAndLiTags[1];
211204
if (li)
212205
li.innerText = this.questionsContextuallySound[index];
@@ -218,8 +211,7 @@ class Chatbot {
218211
configItem.content = this.questionsContextuallySound[index];
219212

220213
updatedCount++;
221-
} else
222-
console.warn(`Question ${index + 1} is undefined! Available questions:`, this.questionsContextuallySound);
214+
}
223215

224216
index++;
225217
}
@@ -248,8 +240,8 @@ class Chatbot {
248240
let messageDiv = document.createElement('div');
249241
messageDiv.className = (role === 'assistant') ? 'askWebSDKAssistantMessageClass' : 'askWebSDKHumanMessageClass';
250242
messageDiv.innerHTML = content;
251-
askWebSDKChattingDiv.appendChild(messageDiv);
252-
askWebSDKChattingDiv.scrollTop = askWebSDKChattingDiv.scrollHeight;
243+
globalThis.askWebSDKChattingDiv.appendChild(messageDiv);
244+
globalThis.askWebSDKChattingDiv.scrollTop = globalThis.askWebSDKChattingDiv.scrollHeight;
253245
}
254246
};
255247

webviewer-ask-ai/client/globals.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ let clipboard = '';
88

99
// Chatbot panel div elements
1010
let askWebSDKMainDiv = null;
11+
globalThis.askWebSDKMainDiv = askWebSDKMainDiv;
1112
let askWebSDKChattingDiv = null;
13+
globalThis.askWebSDKChattingDiv = askWebSDKChattingDiv;
1214
let assistantContentDiv = null;
15+
globalThis.assistantContentDiv = assistantContentDiv;
1316

1417
// Chatbot panel conversation log
1518
// to keep track of assistant and human messages

0 commit comments

Comments
 (0)