diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 38fae700db..529a83b2fe 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -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" diff --git a/tests/ui-testing/playwright-tests/Logs/logs-autocomplete-suggestions.spec.js b/tests/ui-testing/playwright-tests/Logs/logs-autocomplete-suggestions.spec.js new file mode 100644 index 0000000000..21f1299781 --- /dev/null +++ b/tests/ui-testing/playwright-tests/Logs/logs-autocomplete-suggestions.spec.js @@ -0,0 +1,1135 @@ +/** + * Autocomplete Value Suggestions E2E Tests + * + * Tests the new feature that persists field values in IndexedDB and shows them + * as autocomplete suggestions when the user types `field =` in the query editor. + * + * FEATURE DOCUMENTATION: + * - Values are captured from: field expansion (sidebar) + search results + * - Values are stored in: IndexedDB (o2FieldValues database) + * - Values are scoped by: org | streamType | streamName | fieldName + * - Values persist across page refreshes + * + * TEST COVERAGE: + * 1. Value capture from field expansion + * 2. Value capture from search results + * 3. Autocomplete suggestions appear when typing `field =` + * 4. Correct quoting behavior (strings vs numbers vs booleans) + * 5. Persistence across page refresh + * 6. Stream isolation (values don't leak between streams) + * 7. Regression - existing autocomplete still works + */ + +const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js'); +const testLogger = require('../utils/test-logger.js'); +const PageManager = require('../../pages/page-manager.js'); +const logData = require("../../fixtures/log.json"); +const { ingestTestData } = require('../utils/data-ingestion.js'); +const MonacoEditorHelper = require('../utils/MonacoEditorHelper.js'); + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +/** + * Clear IndexedDB o2FieldValues database to ensure clean test state + */ +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 = () => { + // Database is blocked - close any open connections first + resolve(); // Continue anyway, test isolation is secondary + }; + }); + }); + testLogger.info('Cleared IndexedDB o2FieldValues database'); +} + +/** + * Get all records from 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 a specific field record from IndexedDB + */ +async function getFieldRecord(page, org, streamType, streamName, fieldName) { + const key = `${org}|${streamType}|${streamName}|${fieldName}`; + return await page.evaluate(async (searchKey) => { + 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(null); + return; + } + const tx = db.transaction('fieldValues', 'readonly'); + const store = tx.objectStore('fieldValues'); + const getReq = store.get(searchKey); + getReq.onsuccess = () => { + db.close(); + resolve(getReq.result || null); + }; + getReq.onerror = () => { + db.close(); + reject(getReq.error); + }; + }; + }); + }, key); +} + +/** + * Wait for IndexedDB to have at least one record for a given field + */ +async function waitForFieldInIndexedDB(page, org, streamType, streamName, fieldName, timeout = 10000) { + const startTime = Date.now(); + while (Date.now() - startTime < timeout) { + const record = await getFieldRecord(page, org, streamType, streamName, fieldName); + if (record && record.values && record.values.length > 0) { + return record; + } + await page.waitForTimeout(500); + } + return null; +} + +/** + * Get MonacoEditorHelper instance and query editor container + * Uses page object selector for the container + */ +function getMonacoHelper(page, pm) { + const monacoHelper = new MonacoEditorHelper(page); + const queryEditorSelector = pm.logsPage.queryEditor || '[data-test="logs-search-bar-query-editor"]'; + const container = page.locator(queryEditorSelector); + return { monacoHelper, container }; +} + +/** + * Click into Monaco editor and type text + */ +async function typeInQueryEditor(page, pm, text) { + const { monacoHelper, container } = getMonacoHelper(page, pm); + await monacoHelper.type(container, text); +} + +/** + * Clear Monaco editor content + */ +async function clearQueryEditor(page, pm) { + const { monacoHelper, container } = getMonacoHelper(page, pm); + await monacoHelper.clear(container); +} + +/** + * Set Monaco editor content + */ +async function setQueryEditorContent(page, pm, content) { + const { monacoHelper, container } = getMonacoHelper(page, pm); + await monacoHelper.setContent(container, content); +} + +/** + * Wait for Monaco suggestions widget to be visible + */ +async function waitForSuggestionsWidget(page, pm, timeout = 5000) { + const { monacoHelper } = getMonacoHelper(page, pm); + return await monacoHelper.waitForSuggestions(timeout); +} + +/** + * Get all suggestion labels from Monaco autocomplete widget + */ +async function getSuggestionLabels(page, pm) { + const { monacoHelper } = getMonacoHelper(page, pm); + return await monacoHelper.getSuggestionLabels(); +} + +/** + * Check if suggestions widget contains a specific value + */ +async function suggestionContainsValue(page, pm, value) { + const { monacoHelper } = getMonacoHelper(page, pm); + return await monacoHelper.suggestionsContain(value); +} + +/** + * Select a suggestion from Monaco autocomplete widget by text + */ +async function selectSuggestion(page, pm, text) { + const { monacoHelper } = getMonacoHelper(page, pm); + await monacoHelper.selectSuggestion(text); +} + +/** + * Get editor content + */ +async function getQueryEditorContent(page, pm) { + const { monacoHelper, container } = getMonacoHelper(page, pm); + return await monacoHelper.getContent(container); +} + +/** + * Trigger autocomplete suggestions + */ +async function triggerSuggestions(page, pm) { + const { monacoHelper } = getMonacoHelper(page, pm); + await monacoHelper.triggerSuggestions(); +} + +/** + * Run query and wait for results + * Uses a resilient approach - waits for response OR timeout, doesn't fail if response not captured + */ +async function runQueryAndWaitForResults(page, pm) { + const orgName = process.env["ORGNAME"] || 'default'; + + // Start waiting for response before clicking + const searchResponsePromise = page.waitForResponse( + response => response.url().includes(`/api/${orgName}/`) && response.url().includes('_search'), + { timeout: 30000 } + ).catch(() => null); // Don't fail if response not captured + + await pm.logsPage.clickRefreshButton(); + + // Wait for either response or a timeout + await Promise.race([ + searchResponsePromise, + page.waitForTimeout(5000) // Fallback: wait 5 seconds if response not captured + ]); + + // Wait for results to render and any background operations (like IndexedDB writes) + await page.waitForTimeout(2000); +} + +/** + * Find a field that has string values (not purely numeric or boolean) + * Iterates through available fields until finding one with string values + * Returns { fieldName, stringValue, record } or null if none found + */ +async function findFieldWithStringValues(page, pm, orgName, streamName, maxAttempts = 5) { + // Use page object selector for field expand buttons + const fieldButtonsSelector = pm.logsPage.allFieldExpandButtons || '[data-test*="log-search-expand-"][data-test$="-field-btn"]'; + const fieldButtons = page.locator(fieldButtonsSelector); + const buttonCount = await fieldButtons.count(); + + // Try known string fields first - these are more likely to have string values + const preferredStringFields = ['kubernetes_container_name', 'level', 'log', 'kubernetes_pod_name', 'kubernetes_namespace_name']; + + for (const preferredField of preferredStringFields) { + // Use page object selector for specific field button + const preferredButtonSelector = pm.logsPage.fieldExpandButton ? + pm.logsPage.fieldExpandButton(preferredField) : + `[data-test="log-search-expand-${preferredField}-field-btn"]`; + const preferredButton = page.locator(preferredButtonSelector); + if (await preferredButton.count() > 0) { + await preferredButton.click(); + await page.waitForTimeout(2000); + + const record = await waitForFieldInIndexedDB(page, orgName, 'logs', streamName, preferredField, 10000); + if (record && record.values && record.values.length > 0) { + const stringValue = record.values.find(v => + isNaN(Number(v)) && v !== 'true' && v !== 'false' + ); + if (stringValue) { + testLogger.info(`Found string field: ${preferredField} with value: ${stringValue}`); + return { fieldName: preferredField, stringValue, record }; + } + } + } + } + + // Fallback: iterate through all fields + for (let i = 0; i < Math.min(buttonCount, maxAttempts); i++) { + const button = fieldButtons.nth(i); + const dataTest = await button.getAttribute('data-test'); + const fieldName = dataTest.replace('log-search-expand-', '').replace('-field-btn', ''); + + // Skip if already expanded (part of preferred fields) + if (preferredStringFields.includes(fieldName)) continue; + + await button.click(); + await page.waitForTimeout(2000); + + const record = await waitForFieldInIndexedDB(page, orgName, 'logs', streamName, fieldName, 10000); + if (record && record.values && record.values.length > 0) { + const stringValue = record.values.find(v => + isNaN(Number(v)) && v !== 'true' && v !== 'false' + ); + if (stringValue) { + testLogger.info(`Found string field: ${fieldName} with value: ${stringValue}`); + return { fieldName, stringValue, record }; + } + } + } + + return null; +} + +// ============================================================================ +// TEST SUITE +// ============================================================================ + +test.describe("Autocomplete Value Suggestions", () => { + test.describe.configure({ mode: 'serial' }); // Run serially to avoid IndexedDB conflicts + + let pm; + const streamName = 'e2e_automate'; + const orgName = process.env["ORGNAME"] || 'default'; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + + // Navigate to base URL with authentication + await navigateToBase(page); + pm = new PageManager(page); + + // Wait for auth to complete + await page.waitForTimeout(1000); + + // Ingest test data + await ingestTestData(page); + await page.waitForTimeout(1000); + + // Navigate to logs page + await page.goto( + `${logData.logsUrl}?org_identifier=${orgName}` + ); + + testLogger.info('Test setup completed'); + }); + + // ========================================================================= + // TEST: Value Capture from Field Expansion + // ========================================================================= + + test("should capture field values to IndexedDB when expanding a field in sidebar", { + tag: ['@all', '@autosuggestions', '@logs', '@indexeddb'] + }, async ({ page }) => { + testLogger.info('Testing value capture from field expansion'); + + // Clear IndexedDB to ensure clean state + await clearIndexedDB(page); + + // Select stream and run query to get results + await pm.logsPage.selectStream(streamName); + await runQueryAndWaitForResults(page, pm); + + // Find a field to expand - look for any available field expand button + const fieldButtons = page.locator(pm.logsPage.allFieldExpandButtons); + const buttonCount = await fieldButtons.count(); + expect(buttonCount).toBeGreaterThan(0); + + // Get the first field name from the data-test attribute + const firstButton = fieldButtons.first(); + const dataTest = await firstButton.getAttribute('data-test'); + // Extract field name from "log-search-expand-{fieldName}-field-btn" + const fieldName = dataTest.replace('log-search-expand-', '').replace('-field-btn', ''); + testLogger.info(`Expanding field: ${fieldName}`); + + // Click to expand the field + await firstButton.click(); + + // Wait for field values to load in the UI + await page.waitForTimeout(2000); + + // Wait for IndexedDB record to be created (background write via requestIdleCallback) + const record = await waitForFieldInIndexedDB(page, orgName, 'logs', streamName, fieldName, 15000); + + // Verify record was created + expect(record).not.toBeNull(); + expect(record.values).toBeDefined(); + expect(record.values.length).toBeGreaterThan(0); + testLogger.info(`IndexedDB record created with ${record.values.length} values for field: ${fieldName}`); + + // Verify record metadata + expect(record.key).toBe(`${orgName}|logs|${streamName}|${fieldName}`); + expect(record.updatedAt).toBeDefined(); + expect(record.expiresAt).toBeDefined(); + + testLogger.info('Field expansion capture test PASSED'); + }); + + // ========================================================================= + // TEST: Autocomplete Suggestions Appear + // ========================================================================= + + test("should show value suggestions when typing field = in query editor", { + tag: ['@all', '@autosuggestions', '@logs', '@autocomplete'] + }, async ({ page }) => { + testLogger.info('Testing autocomplete suggestions appear'); + + // First, capture some values by expanding a field + await pm.logsPage.selectStream(streamName); + await runQueryAndWaitForResults(page, pm); + + // Find a field with values and expand it + const fieldButtons = page.locator(pm.logsPage.allFieldExpandButtons); + const firstButton = fieldButtons.first(); + const dataTest = await firstButton.getAttribute('data-test'); + const fieldName = dataTest.replace('log-search-expand-', '').replace('-field-btn', ''); + + // Expand the field to capture values + await firstButton.click(); + await page.waitForTimeout(2000); + + // Wait for values to be stored + const record = await waitForFieldInIndexedDB(page, orgName, 'logs', streamName, fieldName, 15000); + expect(record).not.toBeNull(); + testLogger.info(`Field ${fieldName} has ${record.values.length} stored values`); + + // Now type in the query editor to trigger suggestions + // First clear and set up a base query + await setQueryEditorContent(page, pm, `SELECT * FROM "${streamName}" WHERE ${fieldName} = `); + + // Wait a moment for Monaco to process + await page.waitForTimeout(1000); + + // Trigger autocomplete by pressing Ctrl+Space or just waiting + await page.keyboard.press('Control+Space'); + + // Wait for suggestions widget + try { + await waitForSuggestionsWidget(page, pm, 10000); + + // Get suggestion labels + const suggestions = await getSuggestionLabels(page, pm); + testLogger.info(`Found ${suggestions.length} suggestions: ${suggestions.slice(0, 5).join(', ')}`); + + // Should have at least one value suggestion + expect(suggestions.length).toBeGreaterThan(0); + + // Check if any of our stored values appear in suggestions + const storedValues = record.values; + const hasStoredValue = suggestions.some(s => + storedValues.some(v => s.includes(v)) + ); + + // Assert that stored values appear in suggestions + expect(hasStoredValue).toBe(true); + testLogger.info('Found stored field values in suggestions'); + + testLogger.info('Autocomplete suggestions test PASSED'); + } catch (error) { + testLogger.warn(`Suggestions widget not visible: ${error.message}`); + // Take screenshot for debugging + await page.screenshot({ path: 'autocomplete-suggestions-debug.png' }); + throw error; + } + }); + + // ========================================================================= + // TEST: Correct Quoting Behavior + // ========================================================================= + + test("should insert string values with single quotes", { + tag: ['@all', '@autosuggestions', '@logs', '@quoting'] + }, async ({ page }) => { + testLogger.info('Testing string value quoting'); + + // Capture values first + await pm.logsPage.selectStream(streamName); + await runQueryAndWaitForResults(page, pm); + + // Find a field with string values (not just numeric like 'code') + const result = await findFieldWithStringValues(page, pm, orgName, streamName); + expect(result, 'Expected to find at least one field with string values').not.toBeNull(); + + const { fieldName, stringValue, record } = result; + testLogger.info(`Testing quoting with field: ${fieldName}, value: ${stringValue}`); + + // Type query to trigger suggestions + await setQueryEditorContent(page, pm, `SELECT * FROM "${streamName}" WHERE ${fieldName} = `); + await page.waitForTimeout(500); + await page.keyboard.press('Control+Space'); + + // Wait for suggestions - no silent catch + await waitForSuggestionsWidget(page, pm, 10000); + + // Assert string value is in suggestions + const hasStringValue = await suggestionContainsValue(page, pm, stringValue); + expect(hasStringValue, `Expected string value ${stringValue} to be in suggestions`).toBe(true); + + await selectSuggestion(page, pm, stringValue); + await page.waitForTimeout(500); + + // Get the editor content + const editorContent = await getQueryEditorContent(page, pm); + testLogger.info(`Editor content: ${editorContent}`); + + // String values should be wrapped in single quotes + const expectedPattern = `'${stringValue}'`; + expect(editorContent.includes(expectedPattern), `Expected string to be quoted as ${expectedPattern}`).toBe(true); + testLogger.info(`String value correctly quoted: ${expectedPattern}`); + + testLogger.info('String quoting test completed'); + }); + + // ========================================================================= + // TEST: Persistence Across Page Refresh + // ========================================================================= + + test("should persist values across page refresh", { + tag: ['@all', '@autosuggestions', '@logs', '@persistence'] + }, async ({ page }) => { + testLogger.info('Testing persistence across page refresh'); + + // Clear IndexedDB first + await clearIndexedDB(page); + + // Capture values + await pm.logsPage.selectStream(streamName); + await runQueryAndWaitForResults(page, pm); + + // Expand a field to capture values + const fieldButtons = page.locator(pm.logsPage.allFieldExpandButtons); + const firstButton = fieldButtons.first(); + const dataTest = await firstButton.getAttribute('data-test'); + const fieldName = dataTest.replace('log-search-expand-', '').replace('-field-btn', ''); + + await firstButton.click(); + await page.waitForTimeout(2000); + + // Verify values are in IndexedDB + const recordBefore = await waitForFieldInIndexedDB(page, orgName, 'logs', streamName, fieldName, 15000); + expect(recordBefore).not.toBeNull(); + const valuesBefore = recordBefore.values; + testLogger.info(`Values before refresh: ${valuesBefore.length}`); + + // Hard refresh the page + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2000); + + // Verify values are still in IndexedDB + const recordAfter = await getFieldRecord(page, orgName, 'logs', streamName, fieldName); + expect(recordAfter).not.toBeNull(); + expect(recordAfter.values.length).toBe(valuesBefore.length); + testLogger.info(`Values after refresh: ${recordAfter.values.length}`); + + // Verify the values are the same + expect(recordAfter.values).toEqual(valuesBefore); + + testLogger.info('Persistence test PASSED'); + }); + + // ========================================================================= + // TEST: Stream Isolation + // ========================================================================= + + test("should isolate values between different streams", { + tag: ['@all', '@autosuggestions', '@logs', '@isolation'] + }, async ({ page }) => { + testLogger.info('Testing stream isolation'); + + // Clear IndexedDB first + await clearIndexedDB(page); + + // Capture values from first stream + await pm.logsPage.selectStream(streamName); + await runQueryAndWaitForResults(page, pm); + + // Expand a field + const fieldButtons = page.locator(pm.logsPage.allFieldExpandButtons); + const firstButton = fieldButtons.first(); + const dataTest = await firstButton.getAttribute('data-test'); + const fieldName = dataTest.replace('log-search-expand-', '').replace('-field-btn', ''); + + await firstButton.click(); + await page.waitForTimeout(2000); + + // Wait for values + const record1 = await waitForFieldInIndexedDB(page, orgName, 'logs', streamName, fieldName, 15000); + expect(record1).not.toBeNull(); + testLogger.info(`Stream ${streamName} - ${fieldName}: ${record1.values.length} values`); + + // Verify the record key format is correct (includes stream name for isolation) + expect(record1.key).toContain(`|${streamName}|`); + testLogger.info(`✅ Record key properly namespaced: ${record1.key}`); + + // The primary isolation test is verifying the key format includes the stream name + // This ensures records from different streams will have different keys + const expectedKeyPattern = `${orgName}|logs|${streamName}|${fieldName}`; + expect(record1.key).toBe(expectedKeyPattern); + testLogger.info(`✅ Key format verified: ${record1.key}`); + + testLogger.info('Stream isolation test PASSED'); + }); + + // ========================================================================= + // TEST: Regression - Existing Autocomplete Still Works + // ========================================================================= + + test("should still show keyword suggestions when no field values available", { + tag: ['@all', '@autosuggestions', '@logs', '@regression'] + }, async ({ page }) => { + testLogger.info('Testing regression - keyword suggestions'); + + // Select stream and run query + await pm.logsPage.selectStream(streamName); + await runQueryAndWaitForResults(page, pm); + + // Type a partial keyword to trigger keyword suggestions + await setQueryEditorContent(page, pm, `SELECT * FROM "${streamName}" WHERE `); + await page.waitForTimeout(500); + await page.keyboard.press('Control+Space'); + + // Wait for suggestions - this is a regression test, failures indicate a problem + await waitForSuggestionsWidget(page, pm, 10000); + + const suggestions = await getSuggestionLabels(page, pm); + testLogger.info(`Keyword suggestions: ${suggestions.slice(0, 10).join(', ')}`); + + // Should show SQL keywords or field names + expect(suggestions.length).toBeGreaterThan(0); + + testLogger.info('Keyword suggestions regression test PASSED'); + }); + + // ========================================================================= + // TEST: Excluded Fields Are Not Stored + // ========================================================================= + + test("should not store excluded fields like _timestamp and body", { + tag: ['@all', '@autosuggestions', '@logs', '@excluded'] + }, async ({ page }) => { + testLogger.info('Testing excluded fields are not stored'); + + // Clear IndexedDB first + await clearIndexedDB(page); + + // Capture values from search + await pm.logsPage.selectStream(streamName); + await runQueryAndWaitForResults(page, pm); + + // Wait for background capture + await page.waitForTimeout(3000); + + // Get all records + const records = await getIndexedDBRecords(page); + + // Check that excluded fields are not present + // These fields should NEVER be stored (they're explicitly excluded in the feature) + const excludedFields = ['_timestamp', 'body', 'log', 'message', 'msg', '_id']; + const storedExcludedFields = []; + + for (const excludedField of excludedFields) { + const hasExcluded = records.some(r => + r.key && r.key.endsWith(`|${excludedField}`) + ); + + if (hasExcluded) { + storedExcludedFields.push(excludedField); + testLogger.warn(`Excluded field ${excludedField} was incorrectly stored`); + } else { + testLogger.info(`✅ Excluded field ${excludedField} correctly not stored`); + } + } + + // Assert: none of the excluded fields should be stored + expect(storedExcludedFields.length, + `Excluded fields should not be stored: [${storedExcludedFields.join(', ')}]` + ).toBe(0); + + testLogger.info('Excluded fields test completed'); + }); + + // ========================================================================= + // TEST: Values Limit Per Field + // ========================================================================= + + test("should limit stored values per field to max 50", { + tag: ['@all', '@autosuggestions', '@logs', '@limits'] + }, async ({ page }) => { + testLogger.info('Testing values limit per field'); + + // Capture values from search + await pm.logsPage.selectStream(streamName); + await runQueryAndWaitForResults(page, pm); + + // Wait for background capture + await page.waitForTimeout(3000); + + // Get all records + const records = await getIndexedDBRecords(page); + + // Check that no field has more than 50 values + const maxValues = 50; + let allWithinLimit = true; + + for (const record of records) { + if (record.values && record.values.length > maxValues) { + testLogger.warn(`Field ${record.key} has ${record.values.length} values (exceeds ${maxValues})`); + allWithinLimit = false; + } + } + + if (allWithinLimit) { + testLogger.info(`All fields have <= ${maxValues} values`); + } + + // This is more of a soft assertion - the feature should limit values + expect(allWithinLimit).toBe(true); + + testLogger.info('Values limit test PASSED'); + }); +}); + +// ============================================================================ +// EDGE CASE TESTS +// ============================================================================ + +test.describe("Autocomplete Value Suggestions - Edge Cases", () => { + test.describe.configure({ mode: 'serial' }); + + let pm; + const streamName = 'e2e_automate'; + const orgName = process.env["ORGNAME"] || 'default'; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + await navigateToBase(page); + pm = new PageManager(page); + await page.waitForTimeout(1000); + await ingestTestData(page); + await page.waitForTimeout(1000); + await page.goto(`${logData.logsUrl}?org_identifier=${orgName}`); + }); + + test("should handle typing field IN ( operator", { + tag: ['@all', '@autosuggestions', '@logs', '@operators'] + }, async ({ page }) => { + testLogger.info('Testing IN operator suggestions'); + + // Capture some values first + await pm.logsPage.selectStream(streamName); + await runQueryAndWaitForResults(page, pm); + + const fieldButtons = page.locator(pm.logsPage.allFieldExpandButtons); + const firstButton = fieldButtons.first(); + const dataTest = await firstButton.getAttribute('data-test'); + const fieldName = dataTest.replace('log-search-expand-', '').replace('-field-btn', ''); + + await firstButton.click(); + await page.waitForTimeout(2000); + + // Type IN operator + await setQueryEditorContent(page, pm, `SELECT * FROM "${streamName}" WHERE ${fieldName} IN (`); + await page.waitForTimeout(500); + await page.keyboard.press('Control+Space'); + + // Wait for suggestions and assert - no silent catch + await waitForSuggestionsWidget(page, pm, 5000); + const suggestions = await getSuggestionLabels(page, pm); + testLogger.info(`IN operator suggestions: ${suggestions.slice(0, 5).join(', ')}`); + expect(suggestions.length).toBeGreaterThan(0); + + testLogger.info('IN operator test completed'); + }); + + test("should handle typing field LIKE operator", { + tag: ['@all', '@autosuggestions', '@logs', '@operators'] + }, async ({ page }) => { + testLogger.info('Testing LIKE operator suggestions'); + + await pm.logsPage.selectStream(streamName); + await runQueryAndWaitForResults(page, pm); + + const fieldButtons = page.locator(pm.logsPage.allFieldExpandButtons); + const firstButton = fieldButtons.first(); + const dataTest = await firstButton.getAttribute('data-test'); + const fieldName = dataTest.replace('log-search-expand-', '').replace('-field-btn', ''); + + await firstButton.click(); + await page.waitForTimeout(2000); + + // Type LIKE operator + await setQueryEditorContent(page, pm, `SELECT * FROM "${streamName}" WHERE ${fieldName} LIKE `); + await page.waitForTimeout(500); + await page.keyboard.press('Control+Space'); + + // Wait for suggestions and assert - no silent catch + await waitForSuggestionsWidget(page, pm, 5000); + const suggestions = await getSuggestionLabels(page, pm); + testLogger.info(`LIKE operator suggestions: ${suggestions.slice(0, 5).join(', ')}`); + expect(suggestions.length).toBeGreaterThan(0); + + testLogger.info('LIKE operator test completed'); + }); + + test("should handle partial value input with open quote", { + tag: ['@all', '@autosuggestions', '@logs', '@quoting'] + }, async ({ page }) => { + testLogger.info('Testing partial value with open quote'); + + await pm.logsPage.selectStream(streamName); + await runQueryAndWaitForResults(page, pm); + + const fieldButtons = page.locator(pm.logsPage.allFieldExpandButtons); + const firstButton = fieldButtons.first(); + const dataTest = await firstButton.getAttribute('data-test'); + const fieldName = dataTest.replace('log-search-expand-', '').replace('-field-btn', ''); + + await firstButton.click(); + await page.waitForTimeout(2000); + + // Type with opening quote and partial value + await setQueryEditorContent(page, pm, `SELECT * FROM "${streamName}" WHERE ${fieldName} = 'par`); + await page.waitForTimeout(500); + await page.keyboard.press('Control+Space'); + + // Suggestions should still appear even with partial input - no silent catch + await waitForSuggestionsWidget(page, pm, 5000); + const suggestions = await getSuggestionLabels(page, pm); + testLogger.info(`Open quote suggestions: ${suggestions.slice(0, 5).join(', ')}`); + expect(suggestions.length).toBeGreaterThan(0); + + testLogger.info('Partial value test completed'); + }); + + test("should handle NOT IN operator", { + tag: ['@all', '@autosuggestions', '@logs', '@operators'] + }, async ({ page }) => { + testLogger.info('Testing NOT IN operator suggestions'); + + await pm.logsPage.selectStream(streamName); + await runQueryAndWaitForResults(page, pm); + + const fieldButtons = page.locator(pm.logsPage.allFieldExpandButtons); + const firstButton = fieldButtons.first(); + const dataTest = await firstButton.getAttribute('data-test'); + const fieldName = dataTest.replace('log-search-expand-', '').replace('-field-btn', ''); + + await firstButton.click(); + await page.waitForTimeout(2000); + + // Type NOT IN operator + await setQueryEditorContent(page, pm, `SELECT * FROM "${streamName}" WHERE ${fieldName} NOT IN (`); + await page.waitForTimeout(500); + await page.keyboard.press('Control+Space'); + + // Wait for suggestions and assert - no silent catch + await waitForSuggestionsWidget(page, pm, 5000); + const suggestions = await getSuggestionLabels(page, pm); + testLogger.info(`NOT IN operator suggestions: ${suggestions.slice(0, 5).join(', ')}`); + expect(suggestions.length).toBeGreaterThan(0); + + testLogger.info('NOT IN operator test completed'); + }); + + test("should handle != operator", { + tag: ['@all', '@autosuggestions', '@logs', '@operators'] + }, async ({ page }) => { + testLogger.info('Testing != operator suggestions'); + + await pm.logsPage.selectStream(streamName); + await runQueryAndWaitForResults(page, pm); + + const fieldButtons = page.locator(pm.logsPage.allFieldExpandButtons); + const firstButton = fieldButtons.first(); + const dataTest = await firstButton.getAttribute('data-test'); + const fieldName = dataTest.replace('log-search-expand-', '').replace('-field-btn', ''); + + await firstButton.click(); + await page.waitForTimeout(2000); + + // Type != operator + await setQueryEditorContent(page, pm, `SELECT * FROM "${streamName}" WHERE ${fieldName} != `); + await page.waitForTimeout(500); + await page.keyboard.press('Control+Space'); + + // Wait for suggestions and assert - no silent catch + await waitForSuggestionsWidget(page, pm, 5000); + const suggestions = await getSuggestionLabels(page, pm); + testLogger.info(`!= operator suggestions: ${suggestions.slice(0, 5).join(', ')}`); + expect(suggestions.length).toBeGreaterThan(0); + + testLogger.info('!= operator test completed'); + }); + + test("should handle >= and <= operators", { + tag: ['@all', '@autosuggestions', '@logs', '@operators'] + }, async ({ page }) => { + testLogger.info('Testing >= and <= operator suggestions'); + + await pm.logsPage.selectStream(streamName); + await runQueryAndWaitForResults(page, pm); + + const fieldButtons = page.locator(pm.logsPage.allFieldExpandButtons); + const firstButton = fieldButtons.first(); + const dataTest = await firstButton.getAttribute('data-test'); + const fieldName = dataTest.replace('log-search-expand-', '').replace('-field-btn', ''); + + await firstButton.click(); + await page.waitForTimeout(2000); + + // Test >= operator - no silent catch + await setQueryEditorContent(page, pm, `SELECT * FROM "${streamName}" WHERE ${fieldName} >= `); + await page.waitForTimeout(500); + await page.keyboard.press('Control+Space'); + + await waitForSuggestionsWidget(page, pm, 5000); + const suggestionsGte = await getSuggestionLabels(page, pm); + testLogger.info(`>= operator suggestions: ${suggestionsGte.slice(0, 5).join(', ')}`); + expect(suggestionsGte.length).toBeGreaterThan(0); + + // Test <= operator - no silent catch + await setQueryEditorContent(page, pm, `SELECT * FROM "${streamName}" WHERE ${fieldName} <= `); + await page.waitForTimeout(500); + await page.keyboard.press('Control+Space'); + + await waitForSuggestionsWidget(page, pm, 5000); + const suggestionsLte = await getSuggestionLabels(page, pm); + testLogger.info(`<= operator suggestions: ${suggestionsLte.slice(0, 5).join(', ')}`); + expect(suggestionsLte.length).toBeGreaterThan(0); + + testLogger.info('>= and <= operators test completed'); + }); + + test("should handle str_match function", { + tag: ['@all', '@autosuggestions', '@logs', '@functions'] + }, async ({ page }) => { + testLogger.info('Testing str_match function suggestions'); + + await pm.logsPage.selectStream(streamName); + await runQueryAndWaitForResults(page, pm); + + const fieldButtons = page.locator(pm.logsPage.allFieldExpandButtons); + const firstButton = fieldButtons.first(); + const dataTest = await firstButton.getAttribute('data-test'); + const fieldName = dataTest.replace('log-search-expand-', '').replace('-field-btn', ''); + + await firstButton.click(); + await page.waitForTimeout(2000); + + // Type str_match function + await setQueryEditorContent(page, pm, `SELECT * FROM "${streamName}" WHERE str_match(${fieldName}, `); + await page.waitForTimeout(500); + await page.keyboard.press('Control+Space'); + + // Wait for suggestions and assert - no silent catch + await waitForSuggestionsWidget(page, pm, 5000); + const suggestions = await getSuggestionLabels(page, pm); + testLogger.info(`str_match suggestions: ${suggestions.slice(0, 5).join(', ')}`); + expect(suggestions.length).toBeGreaterThan(0); + + testLogger.info('str_match function test completed'); + }); +}); + +// ============================================================================ +// QUOTING BEHAVIOR TESTS +// ============================================================================ + +test.describe("Autocomplete Value Suggestions - Quoting Behavior", () => { + test.describe.configure({ mode: 'serial' }); + + let pm; + const streamName = 'e2e_automate'; + const orgName = process.env["ORGNAME"] || 'default'; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + await navigateToBase(page); + pm = new PageManager(page); + await page.waitForTimeout(1000); + await ingestTestData(page); + await page.waitForTimeout(1000); + await page.goto(`${logData.logsUrl}?org_identifier=${orgName}`); + }); + + test("should insert numeric values WITHOUT quotes", { + tag: ['@all', '@autosuggestions', '@logs', '@quoting'] + }, async ({ page }) => { + testLogger.info('Testing numeric value quoting (no quotes expected)'); + + // Capture values first + await pm.logsPage.selectStream(streamName); + await runQueryAndWaitForResults(page, pm); + + // Find a numeric field (like 'code' which has HTTP status codes) + const codeFieldBtn = page.locator(pm.logsPage.fieldExpandButton('code')); + const codeFieldExists = await codeFieldBtn.count() > 0; + expect(codeFieldExists, 'Expected "code" field to exist for numeric quoting test').toBe(true); + + await codeFieldBtn.click(); + await page.waitForTimeout(2000); + + // Wait for values in IndexedDB - assert values were captured + const record = await waitForFieldInIndexedDB(page, orgName, 'logs', streamName, 'code', 15000); + expect(record, 'Expected IndexedDB record for code field').not.toBeNull(); + expect(record.values.length, 'Expected captured values for code field').toBeGreaterThan(0); + + // Find a numeric value (like 200, 404, 500) + const numericValue = record.values.find(v => !isNaN(Number(v))); + expect(numericValue, 'Expected at least one numeric value in code field').toBeDefined(); + + testLogger.info(`Testing with numeric value: ${numericValue}`); + + // Type query to trigger suggestions + await setQueryEditorContent(page, pm, `SELECT * FROM "${streamName}" WHERE code = `); + await page.waitForTimeout(500); + await page.keyboard.press('Control+Space'); + + // Wait for suggestions - no silent catch + await waitForSuggestionsWidget(page, pm, 10000); + + // Check if our numeric value is in suggestions + const hasNumericValue = await suggestionContainsValue(page, pm, numericValue); + expect(hasNumericValue, `Expected numeric value ${numericValue} to be in suggestions`).toBe(true); + + await selectSuggestion(page, pm, numericValue); + await page.waitForTimeout(500); + + // Get the editor content + const editorContent = await getQueryEditorContent(page, pm); + testLogger.info(`Editor content: ${editorContent}`); + + // Numeric values should NOT be wrapped in quotes - assert this + const hasQuotes = editorContent.includes(`'${numericValue}'`); + expect(hasQuotes, `Numeric value ${numericValue} should NOT have quotes`).toBe(false); + testLogger.info(`✅ Numeric value inserted without quotes: ${numericValue}`); + + testLogger.info('Numeric quoting test completed'); + }); + + // NOTE: Boolean quoting test removed - test data (e2e_automate) doesn't have boolean fields + // The feature logic for boolean quoting is verified by code review - same path as numeric values + + test("should close quote when partial value is typed", { + tag: ['@all', '@autosuggestions', '@logs', '@quoting'] + }, async ({ page }) => { + testLogger.info('Testing quote closing behavior'); + + await pm.logsPage.selectStream(streamName); + await runQueryAndWaitForResults(page, pm); + + // Find a field with string values (not just numeric like 'code') + const result = await findFieldWithStringValues(page, pm, orgName, streamName); + expect(result, 'Expected to find at least one field with string values').not.toBeNull(); + + const { fieldName, stringValue } = result; + testLogger.info(`Testing quote closing with field: ${fieldName}, value: ${stringValue}`); + + // Type with opening quote already present + await setQueryEditorContent(page, pm, `SELECT * FROM "${streamName}" WHERE ${fieldName} = '`); + await page.waitForTimeout(500); + await page.keyboard.press('Control+Space'); + + // Wait for suggestions - no silent catch + await waitForSuggestionsWidget(page, pm, 10000); + + const hasStringValue = await suggestionContainsValue(page, pm, stringValue); + expect(hasStringValue, `Expected string value ${stringValue} to be in suggestions`).toBe(true); + + await selectSuggestion(page, pm, stringValue); + await page.waitForTimeout(500); + + const editorContent = await getQueryEditorContent(page, pm); + testLogger.info(`Editor content: ${editorContent}`); + + // When open quote exists, selecting should only add value + closing quote + // Result should be: field = 'value' (NOT field = ''value'') + const doubleQuotePattern = `''${stringValue}'`; + const correctPattern = `'${stringValue}'`; + + expect(editorContent.includes(correctPattern), `Expected correct quote pattern '${stringValue}'`).toBe(true); + expect(editorContent.includes(doubleQuotePattern), 'Should not have double quotes').toBe(false); + testLogger.info(`✅ Quote closing works correctly: ${correctPattern}`); + + testLogger.info('Quote closing test completed'); + }); +}); + +// ============================================================================ +// COLD START & TTL TESTS +// ============================================================================ + +test.describe("Autocomplete Value Suggestions - Cold Start & TTL", () => { + test.describe.configure({ mode: 'serial' }); + + let pm; + const streamName = 'e2e_automate'; + const orgName = process.env["ORGNAME"] || 'default'; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + await navigateToBase(page); + pm = new PageManager(page); + await page.waitForTimeout(1000); + await ingestTestData(page); + await page.waitForTimeout(1000); + await page.goto(`${logData.logsUrl}?org_identifier=${orgName}`); + }); + + test("should show keyword suggestions on cold start (no stored values)", { + tag: ['@all', '@autosuggestions', '@logs', '@coldstart'] + }, async ({ page }) => { + testLogger.info('Testing cold start behavior'); + + // Clear IndexedDB completely + await clearIndexedDB(page); + + // Navigate and select stream WITHOUT expanding any fields or running search + await pm.logsPage.selectStream(streamName); + + // Don't run search - just type in query editor + await setQueryEditorContent(page, pm, `SELECT * FROM "${streamName}" WHERE `); + await page.waitForTimeout(500); + await page.keyboard.press('Control+Space'); + + // Wait for suggestions widget - cold start should still show SQL keywords/field names + await waitForSuggestionsWidget(page, pm, 10000); + const suggestions = await getSuggestionLabels(page, pm); + + testLogger.info(`Cold start suggestions: ${suggestions.slice(0, 10).join(', ')}`); + + // Assert suggestions appeared - cold start should show field names and/or SQL keywords + expect(suggestions.length, 'Cold start should show keyword/field suggestions').toBeGreaterThan(0); + + // Verify these are keywords/fields, NOT value suggestions (since we cleared IndexedDB) + const hasKeywords = suggestions.some(s => + ['AND', 'OR', 'NOT', 'IN', 'LIKE', 'BETWEEN', 'IS'].some(kw => + s.toUpperCase().includes(kw) + ) + ); + + testLogger.info(`Cold start has SQL keywords: ${hasKeywords}`); + expect(hasKeywords, 'Cold start should show SQL keywords, not value suggestions').toBe(true); + testLogger.info('Cold start test completed with suggestions'); + + testLogger.info('Cold start test completed'); + }); +}); diff --git a/tests/ui-testing/playwright-tests/Traces/traces-autocomplete-suggestions.spec.js b/tests/ui-testing/playwright-tests/Traces/traces-autocomplete-suggestions.spec.js new file mode 100644 index 0000000000..c491789f3f --- /dev/null +++ b/tests/ui-testing/playwright-tests/Traces/traces-autocomplete-suggestions.spec.js @@ -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'); + }); +}); diff --git a/tests/ui-testing/playwright-tests/utils/MonacoEditorHelper.js b/tests/ui-testing/playwright-tests/utils/MonacoEditorHelper.js new file mode 100644 index 0000000000..e66e8005e7 --- /dev/null +++ b/tests/ui-testing/playwright-tests/utils/MonacoEditorHelper.js @@ -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} + */ + 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} + */ + 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} + */ + 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} + */ + 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} + */ + 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;