test: add E2E tests for autocomplete value suggestions feature (#11027)

Add Playwright E2E tests for the new autocomplete value suggestions
feature that persists field values in IndexedDB and shows them when
typing `field =`.

Tests cover:
- IndexedDB capture from field expansion (Values API)
- IndexedDB capture from search results (marked as expected-to-fail due
to bug)
- Autocomplete suggestions for operators: =, !=, <>, >=, <=, IN, NOT IN,
LIKE
- Numeric value quoting (no quotes for numbers)
- TTL expiry filtering
- Stream/org isolation
- Traces page autocomplete

Note: One test marked as expected-to-fail due to BLOCKER BUG where
captureFromSearchHits() doesn't write to IndexedDB (schemaFields
filtering issue)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Neha P <undefined@users.noreply.github.com>
This commit is contained in:
Neha P
2026-03-27 12:02:07 +05:30
committed by GitHub
co-authored by Claude claude[bot] Neha P
parent f8b2e5e9fa
commit 258eecd447
4 changed files with 1711 additions and 0 deletions
+2
View File
@@ -157,6 +157,7 @@ jobs:
"logsqueries.cte.spec.js",
"logsqueries.matchall.spec.js",
"searchpartition.spec.js",
"logs-autocomplete-suggestions.spec.js",
]
- testfolder: "Alerts"
browser: "chrome"
@@ -262,6 +263,7 @@ jobs:
"traceDetails.spec.js",
"traceAdvancedFiltering.spec.js",
"tracesAnalyzeDimensions.spec.js",
"traces-autocomplete-suggestions.spec.js",
]
- testfolder: "RegressionSet"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,399 @@
/**
* Traces Autocomplete Value Suggestions E2E Tests
*
* Tests the autosuggestions feature on the Traces page.
* The feature captures field values when expanding fields in the sidebar
* and shows them as autocomplete suggestions when typing `field =`.
*
* Based on: docs/impact-reports/autosuggestions-feature-impact.md
*/
const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js');
const testLogger = require('../utils/test-logger.js');
const PageManager = require('../../pages/page-manager.js');
const MonacoEditorHelper = require('../utils/MonacoEditorHelper.js');
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
async function clearIndexedDB(page) {
await page.evaluate(async () => {
return new Promise((resolve, reject) => {
const req = indexedDB.deleteDatabase('o2FieldValues');
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
req.onblocked = () => resolve(); // Blocked is not an error, just a delay
});
});
testLogger.info('Cleared IndexedDB o2FieldValues database');
}
async function getIndexedDBRecords(page) {
return await page.evaluate(async () => {
return new Promise((resolve, reject) => {
const req = indexedDB.open('o2FieldValues', 1);
req.onerror = () => reject(req.error);
req.onsuccess = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('fieldValues')) {
db.close();
resolve([]);
return;
}
const tx = db.transaction('fieldValues', 'readonly');
const store = tx.objectStore('fieldValues');
const getAllReq = store.getAll();
getAllReq.onsuccess = () => {
db.close();
resolve(getAllReq.result || []);
};
getAllReq.onerror = () => {
db.close();
reject(getAllReq.error);
};
};
});
});
}
/**
* Get MonacoEditorHelper instance for traces
*/
function getMonacoHelper(page) {
return new MonacoEditorHelper(page);
}
async function waitForSuggestionsWidget(page, timeout = 5000) {
const monacoHelper = getMonacoHelper(page);
return await monacoHelper.waitForSuggestions(timeout);
}
async function getSuggestionLabels(page) {
const monacoHelper = getMonacoHelper(page);
return await monacoHelper.getSuggestionLabels();
}
/**
* Try to expand a field using multiple strategies
* Follows the pattern from traceQueryEditor.spec.js
* Waits for field values API response after expanding
*/
async function tryExpandField(page, pm) {
const orgName = process.env["ORGNAME"] || 'default';
// Strategy 1: Try common trace field names via tracesPage method
// These match the patterns used in traceQueryEditor.spec.js
const traceFieldNames = [
'status_code', // Common in trace data
'service_name', // Common trace attribute
'http.status_code', // OpenTelemetry semantic convention
'http.method', // OpenTelemetry semantic convention
'service.name', // OpenTelemetry semantic convention
'span.kind', // OpenTelemetry attribute
'environment', // Custom attribute
'operation_name' // Jaeger-style attribute
];
for (const fieldName of traceFieldNames) {
// Start listening for values API response before clicking
const valuesResponsePromise = page.waitForResponse(
response => response.url().includes(`/api/${orgName}/`) && response.url().includes('_values'),
{ timeout: 10000 }
).catch(() => null);
const expanded = await pm.tracesPage.expandTraceField(fieldName);
if (expanded) {
testLogger.info(`Expanded trace field: ${fieldName}`);
// Wait for values API response
const response = await valuesResponsePromise;
if (response) {
testLogger.info(`Values API responded for field: ${fieldName}`);
// Give IndexedDB time to write
await page.waitForTimeout(2000);
} else {
testLogger.info(`No values API response captured for field: ${fieldName}`);
await page.waitForTimeout(2000);
}
return { fieldName, success: true };
}
}
// Strategy 2: Click on field expansion headers in the traces field list
// Traces uses q-expansion-item with class 'field-expansion-item'
// The clickable header has class 'field-expansion-header'
// Using page object selector for the fields table
const fieldsTableSelector = pm.tracesPage.fieldsTable || '[data-test="log-search-index-list-fields-table"]';
const fieldsTable = page.locator(fieldsTableSelector);
if (await fieldsTable.isVisible({ timeout: 5000 }).catch(() => false)) {
// Look for field expansion items directly on the page (they're inside the table)
// Note: .field-expansion-item is a Quasar component class, not a data-test selector
const fieldHeaders = page.locator('.field-expansion-item .field-expansion-header');
const headerCount = await fieldHeaders.count();
testLogger.info(`Found ${headerCount} field expansion headers in traces field list`);
if (headerCount > 0) {
// Try first few fields (skip internal fields like _timestamp)
for (let i = 0; i < Math.min(headerCount, 10); i++) {
const header = fieldHeaders.nth(i);
const headerText = await header.textContent().catch(() => '');
// Skip system fields
if (headerText.includes('_timestamp') || headerText.includes('_id') || headerText.includes('duration')) {
continue;
}
// Extract field name from the header
const fieldName = headerText.trim().split(/\s+/)[0] || `field_${i}`;
testLogger.info(`Attempting to expand field: ${fieldName}`);
// Start listening for values API before clicking
const valuesResponsePromise = page.waitForResponse(
response => response.url().includes(`/api/${orgName}/`) && response.url().includes('_values'),
{ timeout: 15000 }
).catch(() => null);
// Click on the header to expand it
await header.click();
testLogger.info(`Clicked on field header: ${fieldName}`);
const response = await valuesResponsePromise;
if (response) {
testLogger.info(`Values API responded for field: ${fieldName}`);
await page.waitForTimeout(2000);
return { fieldName, success: true };
} else {
testLogger.info(`No values API response for field: ${fieldName}, trying next field`);
// Click again to collapse and try another field
await header.click().catch(() => {});
await page.waitForTimeout(500);
}
}
}
} else {
testLogger.info('Fields table not visible, trying fallback selectors');
// Fallback: try finding expansion items anywhere on the page
const anyExpansionItems = page.locator('.field-expansion-item');
const count = await anyExpansionItems.count();
testLogger.info(`Fallback: found ${count} field-expansion-item elements on page`);
}
// Strategy 3: Fallback - try clicking directly on field names
testLogger.info('Strategies 1-2 failed, no field could be expanded with values API response');
return { fieldName: null, success: false };
}
// ============================================================================
// TRACES AUTOCOMPLETE TESTS
// ============================================================================
test.describe("Traces Autocomplete Value Suggestions", () => {
test.describe.configure({ mode: 'serial' });
let pm;
test.beforeEach(async ({ page }, testInfo) => {
testLogger.testStart(testInfo.title, testInfo.file);
await navigateToBase(page);
pm = new PageManager(page);
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
// Navigate to traces page
await pm.tracesPage.navigateToTraces();
// Select the default stream if visible
if (await pm.tracesPage.isStreamSelectVisible()) {
await pm.tracesPage.selectTraceStream('default');
}
testLogger.info('Traces test setup completed');
});
test("should capture field values to IndexedDB when expanding a field in traces sidebar", {
tag: ['@all', '@autosuggestions', '@traces', '@indexeddb']
}, async ({ page }) => {
testLogger.info('Testing traces field expansion capture');
// Clear IndexedDB
await clearIndexedDB(page);
// Use setupTraceSearch - the standard pattern from working traces tests
await pm.tracesPage.setupTraceSearch('default');
// Verify we have trace results
const hasResults = await pm.tracesPage.hasTraceResults();
if (!hasResults) {
// Check for no results message which is also a valid state
const hasNoResults = await pm.tracesPage.isNoResultsVisible();
if (hasNoResults) {
testLogger.info('No trace data available - skipping field expansion test');
test.skip(true, 'No trace data available in test environment');
return;
}
testLogger.info('Search did not complete - test precondition not met');
test.skip(true, 'Trace search did not complete - precondition not met');
return;
}
testLogger.info('Trace results found, attempting field expansion');
// Ensure field list is visible
const fieldListVisible = await pm.tracesPage.isIndexListVisible();
if (!fieldListVisible) {
testLogger.info('Field list not visible, toggling...');
await pm.tracesPage.toggleFieldList();
await page.waitForTimeout(1000);
}
// Try to expand a field
const expandResult = await tryExpandField(page, pm);
if (!expandResult.success) {
// This is acceptable - some environments may not have expandable fields
testLogger.info('No expandable fields found - this may be expected in some environments');
test.skip(true, 'No expandable fields found in test environment');
return;
}
testLogger.info(`Successfully expanded field: ${expandResult.fieldName}`);
await page.waitForTimeout(2000);
// Check IndexedDB for traces records
const records = await getIndexedDBRecords(page);
const traceRecords = records.filter(r => r.key && r.key.includes('|traces|'));
testLogger.info(`Found ${traceRecords.length} traces records in IndexedDB`);
// Assert we captured trace records - no silent passing
expect(traceRecords.length, 'Expected traces records to be captured in IndexedDB after field expansion').toBeGreaterThan(0);
if (traceRecords.length > 0) {
testLogger.info('Sample traces records:');
traceRecords.slice(0, 3).forEach(r => {
testLogger.info(` - ${r.key}: ${r.values?.length || 0} values`);
});
}
testLogger.info('Traces field expansion capture test completed');
});
test("should show value suggestions in traces query editor", {
tag: ['@all', '@autosuggestions', '@traces', '@autocomplete']
}, async ({ page }) => {
testLogger.info('Testing traces autocomplete suggestions');
// Use setupTraceSearch - the standard pattern
await pm.tracesPage.setupTraceSearch('default');
// Verify we have trace results
const hasResults = await pm.tracesPage.hasTraceResults();
if (!hasResults) {
const hasNoResults = await pm.tracesPage.isNoResultsVisible();
if (hasNoResults) {
testLogger.info('No trace data available - skipping autocomplete test');
test.skip(true, 'No trace data available in test environment');
}
testLogger.info('Search did not complete - test precondition not met');
test.skip(true, 'Search did not complete - test precondition not met');
}
// Try to expand a field to capture values
const expandResult = await tryExpandField(page, pm);
let fieldToQuery = 'service.name'; // default fallback
if (expandResult.success && expandResult.fieldName !== 'unknown') {
fieldToQuery = expandResult.fieldName;
await page.waitForTimeout(2000);
testLogger.info(`Expanded field for value capture: ${fieldToQuery}`);
}
// Enter query in the query editor using tracesPage method
await pm.tracesPage.enterTraceQuery(`${fieldToQuery} = `);
await page.waitForTimeout(500);
await page.keyboard.press('Control+Space');
// Wait for suggestions widget and assert suggestions appear
await waitForSuggestionsWidget(page, 10000);
const suggestions = await getSuggestionLabels(page);
testLogger.info(`Traces suggestions: ${suggestions.slice(0, 5).join(', ')}`);
// Assert suggestions appeared - no silent passing
expect(suggestions.length, 'Expected autocomplete suggestions to appear').toBeGreaterThan(0);
testLogger.info('✅ Autocomplete suggestions appeared');
testLogger.info('Traces autocomplete test completed');
});
test("should isolate traces values from logs values", {
tag: ['@all', '@autosuggestions', '@traces', '@isolation']
}, async ({ page }) => {
testLogger.info('Testing traces vs logs isolation');
// First, capture some traces values by expanding a field
await pm.tracesPage.setupTraceSearch('default');
// Verify we have trace results
const hasResults = await pm.tracesPage.hasTraceResults();
if (hasResults) {
// Try to expand a field to capture values
const expandResult = await tryExpandField(page, pm);
if (expandResult.success) {
testLogger.info(`Expanded field for isolation test: ${expandResult.fieldName}`);
await page.waitForTimeout(2000);
}
}
// Get current IndexedDB state
const records = await getIndexedDBRecords(page);
// Separate logs and traces records
const logsRecords = records.filter(r => r.key && r.key.includes('|logs|'));
const tracesRecords = records.filter(r => r.key && r.key.includes('|traces|'));
testLogger.info(`Logs records: ${logsRecords.length}`);
testLogger.info(`Traces records: ${tracesRecords.length}`);
// Verify keys are properly namespaced
let isolationValid = true;
for (const record of tracesRecords) {
const keyParts = record.key.split('|');
if (keyParts.length >= 4) {
const streamType = keyParts[1];
if (streamType !== 'traces') {
isolationValid = false;
testLogger.warn(`Traces record has wrong namespace: ${record.key}`);
} else {
testLogger.info(`✅ Traces record properly namespaced: ${record.key}`);
}
}
}
for (const record of logsRecords) {
const keyParts = record.key.split('|');
if (keyParts.length >= 4) {
const streamType = keyParts[1];
if (streamType !== 'logs') {
isolationValid = false;
testLogger.warn(`Logs record has wrong namespace: ${record.key}`);
}
}
}
// Assert isolation is valid - require at least some records to verify
if (tracesRecords.length > 0 || logsRecords.length > 0) {
expect(isolationValid, 'Stream type isolation should be maintained').toBe(true);
testLogger.info('✅ Stream type isolation verified with records');
} else {
// No records means we can't verify isolation - skip the test
testLogger.warn('No records to verify isolation - skipping test');
test.skip(true, 'Isolation test requires records - run field expansion tests first');
}
testLogger.info('Isolation test completed');
});
});
@@ -0,0 +1,175 @@
/**
* MonacoEditorHelper - Utility class for Monaco editor interactions
*
* Encapsulates Monaco-specific selectors and interactions to:
* 1. Keep spec files clean (POM compliance)
* 2. Centralize Monaco selector changes if Monaco version updates
* 3. Provide consistent interaction patterns across tests
*
* Note: Monaco editor is a third-party component. These selectors
* may need updates when Monaco version changes.
*/
class MonacoEditorHelper {
/**
* @param {import('@playwright/test').Page} page - Playwright page object
*/
constructor(page) {
this.page = page;
// Monaco editor selectors (third-party component)
this.selectors = {
editor: '.monaco-editor',
viewLines: '.monaco-editor .view-lines',
inputArea: '.monaco-editor textarea.inputarea',
suggestWidget: '.monaco-editor .suggest-widget',
suggestionRows: '.monaco-editor .suggest-widget .monaco-list-row',
suggestionLabel: '.label-name, .monaco-icon-label-container',
};
}
/**
* Get the Monaco editor within a container
* @param {import('@playwright/test').Locator} container - Container locator with data-test attribute
* @returns {import('@playwright/test').Locator}
*/
getEditor(container) {
return container.locator(this.selectors.viewLines).first();
}
/**
* Get the input area (textarea) within a container
* @param {import('@playwright/test').Locator} container - Container locator
* @returns {import('@playwright/test').Locator}
*/
getInputArea(container) {
return container.locator(this.selectors.inputArea);
}
/**
* Click into the Monaco editor to focus it
* @param {import('@playwright/test').Locator} container - Container locator
*/
async focus(container) {
const inputArea = this.getInputArea(container);
if (await inputArea.count() > 0) {
await inputArea.focus();
} else {
const editor = this.getEditor(container);
await editor.click({ force: true });
}
}
/**
* Clear all content from the Monaco editor
* @param {import('@playwright/test').Locator} container - Container locator
*/
async clear(container) {
await this.focus(container);
await this.page.keyboard.press('Control+a');
await this.page.keyboard.press('Backspace');
}
/**
* Type text into the Monaco editor
* @param {import('@playwright/test').Locator} container - Container locator
* @param {string} text - Text to type
*/
async type(container, text) {
await this.focus(container);
await this.page.keyboard.type(text);
}
/**
* Set content in the Monaco editor (clears first, then types)
* @param {import('@playwright/test').Locator} container - Container locator
* @param {string} content - Content to set
*/
async setContent(container, content) {
await this.clear(container);
await this.page.keyboard.type(content);
}
/**
* Get the text content of the Monaco editor
* @param {import('@playwright/test').Locator} container - Container locator
* @returns {Promise<string>}
*/
async getContent(container) {
const viewLines = container.locator(this.selectors.viewLines);
return await viewLines.textContent();
}
/**
* Trigger autocomplete suggestions (Ctrl+Space)
*/
async triggerSuggestions() {
await this.page.keyboard.press('Control+Space');
}
/**
* Wait for the suggestions widget to be visible
* @param {number} timeout - Timeout in milliseconds (default 5000)
* @returns {Promise<import('@playwright/test').Locator>}
*/
async waitForSuggestions(timeout = 5000) {
const suggestWidget = this.page.locator(this.selectors.suggestWidget);
await suggestWidget.waitFor({ state: 'visible', timeout });
return suggestWidget;
}
/**
* Check if suggestions widget is visible
* @returns {Promise<boolean>}
*/
async isSuggestionsVisible() {
const suggestWidget = this.page.locator(this.selectors.suggestWidget);
return await suggestWidget.isVisible();
}
/**
* Get all suggestion labels from the autocomplete widget
* @param {number} timeout - Timeout to wait for suggestions (default 5000)
* @returns {Promise<string[]>}
*/
async getSuggestionLabels(timeout = 5000) {
const suggestionRows = this.page.locator(this.selectors.suggestionRows);
await suggestionRows.first().waitFor({ state: 'visible', timeout });
const labels = await suggestionRows
.locator(this.selectors.suggestionLabel)
.allTextContents();
return labels.map(l => l.trim()).filter(l => l.length > 0);
}
/**
* Check if suggestions contain a specific value
* @param {string} value - Value to search for
* @returns {Promise<boolean>}
*/
async suggestionsContain(value) {
const labels = await this.getSuggestionLabels();
return labels.some(label => label.includes(value));
}
/**
* Select a suggestion by text
* @param {string} text - Text of the suggestion to select
*/
async selectSuggestion(text) {
const suggestionRow = this.page.locator(
`${this.selectors.suggestionRows}:has-text("${text}")`
).first();
await suggestionRow.click();
}
/**
* Dismiss the suggestions widget by pressing Escape
*/
async dismissSuggestions() {
await this.page.keyboard.press('Escape');
}
}
module.exports = MonacoEditorHelper;