openobserve/tests/ui-testing/playwright-tests/Logs/logs-autocomplete-suggestio...

1248 lines
55 KiB
JavaScript

/**
* 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, waitForStreamData } = 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.
* Uses expect.poll keyed on the actual record contents — deterministic, no setTimeout.
*/
async function waitForFieldInIndexedDB(page, org, streamType, streamName, fieldName, timeout = 10000) {
let record = null;
try {
await expect.poll(async () => {
record = await getFieldRecord(page, org, streamType, streamName, fieldName);
return !!(record && record.values && record.values.length > 0);
}, { timeout, intervals: [200, 500, 1000] }).toBe(true);
} catch (_) {
return null;
}
return record;
}
/**
* Expand a field in the sidebar and deterministically capture its values.
*
* The sidebar expand fires a /_values_stream fetch; the page then schedules an
* IndexedDB write from that response. The legacy pattern here was
* `click → poll IndexedDB(10s)`, but the 10s window started at CLICK time and so
* raced a slow-runner fetch: under CI load /_values_stream could land AFTER the
* poll had already given up, leaving the record null even though the data
* existed (3848 rows are ingested at shard start). This anchors the IndexedDB
* wait AFTER the values response instead of racing it, which removes the flake.
*
* Matches any /_values_stream response (the click triggers exactly one, for this
* field) rather than the exact `fields=` encoding, so it is robust to how the
* field name is serialised. The OSS test env serves values over HTTP/SSE, so the
* response reliably surfaces to waitForResponse; the wait is still best-effort
* (`.catch`) so any non-HTTP transport falls straight through to the IDB poll.
*
* @returns the IndexedDB record ({ values, ... }) or null if none was captured.
*/
async function expandFieldAndCaptureValues(page, button, orgName, streamName, fieldName) {
const waitValues = () => page.waitForResponse(
(r) => r.url().includes('_values_stream'),
{ timeout: 20000 }
).catch(() => null);
let valuesResponse = waitValues();
await button.click();
let responded = await valuesResponse;
// If the click collapsed an already-expanded panel (no values fetch fired —
// e.g. a prior retry round left this field open), re-click to expand and
// re-arm the wait. Guarded on `responded` so a field that DID fetch is never
// toggled back shut.
if (!responded) {
valuesResponse = waitValues();
await button.click().catch(() => {});
responded = await valuesResponse;
}
// The IDB write happens within a beat of the response landing, so an 8s
// window post-response is ample (vs. the old 10s racing from click time).
return await waitForFieldInIndexedDB(page, orgName, 'logs', streamName, fieldName, 8000);
}
/**
* Get MonacoEditorHelper instance and query editor container
* Uses page object getter for the container (POM-strict)
*/
function getMonacoHelper(page, pm) {
const monacoHelper = new MonacoEditorHelper(page);
const container = pm.logsPage.getQueryEditorContainer();
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 directly from Monaco model (not view DOM) so reads are
* deterministic even if executeEdits hasn't yet re-rendered the view-lines.
*/
async function getQueryEditorContent(page, pm) {
return await page.evaluate((hostSelector) => {
const host = document.querySelector(hostSelector);
const editors = window.monaco?.editor?.getEditors?.() || [];
const ed = host ? editors.find((e) => host.contains(e.getDomNode())) : editors[0];
return ed?.getValue?.() ?? '';
}, '[data-test="logs-search-bar-query-editor"]');
}
/**
* Trigger autocomplete suggestions
*/
async function triggerSuggestions(page, pm) {
const { monacoHelper } = getMonacoHelper(page, pm);
await monacoHelper.triggerSuggestions();
}
/**
* Trigger Ctrl+Space and poll the suggestions widget until it shows the expected
* stored value — re-triggering on each poll if the popup closed. Avoids racing
* the CodeQueryEditor's 500ms `update:query` debounce that populates
* autoCompleteData. If expectedValueOrNull is null, just polls until suggestions
* appear non-empty.
*
* IMPORTANT: After this function returns, the suggestion popup is open AND the
* first row (sorted by sortText, value suggestions use '\x01' prefix so they're
* top) is the value. Caller should use selectSuggestion(text) to click a specific
* row OR press 'Enter' for the highlighted one.
*/
async function triggerSuggestionsWithRetry(page, pm, expectedValueOrNull, timeout = 12000) {
const { monacoHelper } = getMonacoHelper(page, pm);
let lastSuggestions = [];
await expect.poll(async () => {
// Press Escape to close any prior popup, then re-trigger
await page.keyboard.press('Escape').catch(() => {});
await page.keyboard.press('Control+Space');
try {
await monacoHelper.waitForSuggestions(1500);
lastSuggestions = await monacoHelper.getSuggestionLabels();
} catch (_) {
lastSuggestions = [];
}
if (expectedValueOrNull == null) return lastSuggestions.length > 0;
return lastSuggestions.some((s) => s.includes(String(expectedValueOrNull)));
}, { timeout, intervals: [400, 800, 1500] }).toBe(true);
return lastSuggestions;
}
/**
* Select a specific suggestion by value via Monaco's suggestController model.
* Reads the completion items, finds the one matching the expected value, and
* inserts its `insertText` directly via editor.executeEdits — bypasses both
* `:has-text(...)` click flakiness AND the Enter-key race when the suggestion
* popup transiently loses focus. Targets the query editor specifically (host
* keyed off the page object selector).
*/
async function acceptSuggestionByValue(page, expectedValue) {
const expected = String(expectedValue);
const result = await page.evaluate(({ expected, hostSelector }) => {
const host = document.querySelector(hostSelector);
const editors = window.monaco?.editor?.getEditors?.() || [];
const ed = host
? editors.find((e) => host.contains(e.getDomNode()))
: editors[0];
if (!ed) return { ok: false, reason: 'no-editor' };
const ctrl = ed.getContribution?.('editor.contrib.suggestController');
const list = ctrl?.model?._completionModel?.items ?? ctrl?.model?.items ?? [];
const labels = list.map((it) => {
const label = it?.completion?.label ?? it?.label;
return typeof label === 'string' ? label : label?.label ?? '';
});
const idx = labels.findIndex((l) => l.includes(expected));
if (idx < 0) return { ok: false, reason: 'not-found', labels: labels.slice(0, 10) };
const item = list[idx];
const insertText = item?.completion?.insertText ?? item?.insertText ?? '';
if (!insertText) return { ok: false, reason: 'no-insert-text', label: labels[idx] };
// Cancel the suggestion popup first so Monaco doesn't re-render the edit
// back to the suggestion's preview / ghost state.
try { ctrl?.cancelSuggestWidget?.(); } catch (_) {}
// Execute edit at current cursor — replaces selection if any.
ed.focus();
const position = ed.getPosition();
const range = {
startLineNumber: position.lineNumber,
startColumn: position.column,
endLineNumber: position.lineNumber,
endColumn: position.column,
};
ed.executeEdits('autocomplete-test', [{ range, text: String(insertText), forceMoveMarkers: true }]);
return { ok: true, insertText, value: ed.getValue() };
}, { expected, hostSelector: '[data-test="logs-search-bar-query-editor"]' });
if (!result.ok) {
throw new Error(`acceptSuggestionByValue failed: ${result.reason}. Labels: ${(result.labels || []).join(', ')}`);
}
}
/**
* Run query and wait for results.
* Uses the PO's deterministic run-query method (waits on button state) +
* waits for an actual /_search response to ensure IndexedDB background writes
* have data to capture, then waits for the suggestion store's stream context
* to be populated. No setTimeout / waitForTimeout buffers.
*/
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
// PO handles the click + ready-state wait deterministically
await pm.logsPage.runQueryAndWaitForResults().catch(() => pm.logsPage.clickRefreshButton());
// Wait for the search response (or skip if it never resolves — PO already settled the button)
await searchResponsePromise;
// Ensure the field list has hydrated with at least one field expand button —
// this is the deterministic signal that the search results are rendered AND
// useSuggestions has streamName populated for autocomplete value lookups.
await pm.logsPage.getAllFieldExpandButtons().first().waitFor({ state: 'visible', timeout: 15000 }).catch(() => {});
}
/**
* Pick the first genuine string value (not numeric / boolean) from a captured
* IndexedDB record. Returns { fieldName, stringValue, record } or null.
*/
function pickStringValue(fieldName, record) {
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;
}
/**
* Find a field that has string values (not purely numeric or boolean).
* Iterates through available fields until finding one with string values,
* capturing each via expandFieldAndCaptureValues (response-anchored, no
* click-time race). If a full pass captures nothing, re-runs the query to
* refresh the field-values context and re-scans — bounded by `maxRounds` so a
* genuinely value-less stream still fails in a reasonable time rather than
* hanging. Returns { fieldName, stringValue, record } or null if none found.
*/
async function findFieldWithStringValues(page, pm, orgName, streamName, maxAttempts = 6, maxRounds = 2) {
// 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'];
const scanOnce = async () => {
const fieldButtons = pm.logsPage.getAllFieldExpandButtons();
const buttonCount = await fieldButtons.count();
for (const preferredField of preferredStringFields) {
const preferredButton = pm.logsPage.getFieldExpandButton(preferredField);
if (await preferredButton.count() > 0) {
const record = await expandFieldAndCaptureValues(page, preferredButton, orgName, streamName, preferredField);
const hit = pickStringValue(preferredField, record);
if (hit) return hit;
}
}
// 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;
const record = await expandFieldAndCaptureValues(page, button, orgName, streamName, fieldName);
const hit = pickStringValue(fieldName, record);
if (hit) return hit;
}
return null;
};
for (let round = 0; round < maxRounds; round++) {
const result = await scanOnce();
if (result) return result;
// No field yielded values this pass — the /_values backend may have
// transiently returned empty (index still catching up under load).
// Re-run the query to refresh the field-values context, then re-scan.
if (round < maxRounds - 1) {
testLogger.warn(`findFieldWithStringValues: no values captured on round ${round + 1}/${maxRounds}, refreshing and retrying`);
await runQueryAndWaitForResults(page, pm).catch(() => {});
}
}
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-driven navigation to settle deterministically
await page.waitForLoadState('domcontentloaded');
// Ingest test data
await ingestTestData(page);
// Readiness gate: freshly-ingested data is not queryable immediately
// (WAL -> index lag). The value-capture flow reads the same index that
// /_search does, so gate on the stream being searchable before driving
// the UI — otherwise field expansion can fetch an empty value set and the
// IndexedDB capture never populates. Best-effort so an already-warm
// shared stream doesn't block on a slow poll.
await waitForStreamData(page, streamName, 1).catch(() => {});
// Navigate to logs page
await page.goto(
`${logData.logsUrl}?org_identifier=${orgName}`
);
await page.waitForLoadState('domcontentloaded');
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);
// Iterate through fields until one with captured values is found.
// Some fields (e.g. ftsKey fields) crash the expand handler silently, and
// others may have empty value sets on the pentest backend — using
// findFieldWithStringValues skips both deterministically.
const fieldButtons = pm.logsPage.getAllFieldExpandButtons();
const buttonCount = await fieldButtons.count();
expect(buttonCount).toBeGreaterThan(0);
const result = await findFieldWithStringValues(page, pm, orgName, streamName);
expect(result, 'Expected to capture at least one field with values').not.toBeNull();
const { fieldName, record } = result;
testLogger.info(`Expanded field: ${fieldName}`);
// 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 (skip ftsKey fields that crash
// the expand handler silently when fired with a null event)
const fieldResult = await findFieldWithStringValues(page, pm, orgName, streamName);
expect(fieldResult, 'Expected to find a field with captured values').not.toBeNull();
const { fieldName, record } = fieldResult;
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 for Monaco model + autocomplete context to settle before triggering
await pm.logsPage.waitForQueryEditorValue(`${fieldName} = `, 5000);
// Trigger autocomplete and retry until stored value appears (debounce-safe).
// Picks any stored value from the captured record to wait on.
const expectedSampleValue = record.values[0];
const suggestions = await triggerSuggestionsWithRetry(page, pm, expectedSampleValue, 12000);
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');
});
// =========================================================================
// 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 pm.logsPage.waitForQueryEditorValue(`${fieldName} = `, 5000);
// Retry-trigger autocomplete until the stored value appears
await triggerSuggestionsWithRetry(page, pm, stringValue, 12000);
// 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);
// Use keyboard navigation + Enter for deterministic suggestion selection.
// Monaco's virtualised suggest list can swallow raw `.click()` events when
// multiple rows share text — keyboard accept is the canonical insertion path.
await acceptSuggestionByValue(page, stringValue);
// 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 with values (skip ftsKey fields that crash the handler)
const fieldResult = await findFieldWithStringValues(page, pm, orgName, streamName);
expect(fieldResult, 'Expected to find a field with captured values').not.toBeNull();
const { fieldName, record: recordBefore } = fieldResult;
const valuesBefore = recordBefore.values;
testLogger.info(`Values before refresh: ${valuesBefore.length}`);
// Hard refresh the page
await page.reload({ waitUntil: 'domcontentloaded' });
// Verify values are still in IndexedDB.
// After reload the page may auto-search and add new values — the
// persistence guarantee is that ALL values captured before the refresh
// are still present (subset check, not exact equality).
const recordAfter = await getFieldRecord(page, orgName, 'logs', streamName, fieldName);
expect(recordAfter).not.toBeNull();
expect(recordAfter.values.length).toBeGreaterThanOrEqual(valuesBefore.length);
testLogger.info(`Values after refresh: ${recordAfter.values.length}`);
// Verify all pre-refresh values are still present (per-value subset
// check gives better failure diagnostics than arrayContaining).
for (const v of valuesBefore) {
expect(recordAfter.values).toContain(v);
}
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 with values (skip ftsKey fields that crash the handler)
const fieldResult = await findFieldWithStringValues(page, pm, orgName, streamName);
expect(fieldResult, 'Expected to find a field with captured values').not.toBeNull();
const { fieldName, record: record1 } = fieldResult;
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 pm.logsPage.waitForQueryEditorValue('WHERE', 5000);
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
// 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
// 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.waitForLoadState('domcontentloaded');
await ingestTestData(page);
// Gate on the stream being searchable before driving the UI so field
// expansion never fetches an empty value set (WAL -> index lag).
await waitForStreamData(page, streamName, 1).catch(() => {});
await page.goto(`${logData.logsUrl}?org_identifier=${orgName}`);
await page.waitForLoadState('domcontentloaded');
});
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);
// Find a field with captured values (skips ftsKey fields that crash the expand handler)
const fieldResult = await findFieldWithStringValues(page, pm, orgName, streamName);
expect(fieldResult, 'Expected to find a field with captured values').not.toBeNull();
const { fieldName } = fieldResult;
// Type IN operator
await setQueryEditorContent(page, pm, `SELECT * FROM "${streamName}" WHERE ${fieldName} IN (`);
await pm.logsPage.waitForQueryEditorValue('WHERE', 5000);
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);
// Find a field with captured values (skips ftsKey fields that crash the expand handler)
const fieldResult = await findFieldWithStringValues(page, pm, orgName, streamName);
expect(fieldResult, 'Expected to find a field with captured values').not.toBeNull();
const { fieldName } = fieldResult;
// Type LIKE operator
await setQueryEditorContent(page, pm, `SELECT * FROM "${streamName}" WHERE ${fieldName} LIKE `);
await pm.logsPage.waitForQueryEditorValue('WHERE', 5000);
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);
// Find a field that has a real string value captured in IndexedDB, and
// use a prefix of that value as the partial input. This keeps the test
// realistic — in value context the dropdown only shows stored values, so
// the partial must actually match one of them (hard-coded prefixes like
// 'par' only worked previously because function suggestions incorrectly
// leaked through in value context and embedded the typed word in their
// labels).
const fieldWithValues = await findFieldWithStringValues(page, pm, orgName, streamName);
expect(fieldWithValues).not.toBeNull();
const { fieldName, stringValue } = fieldWithValues;
const partial = stringValue.substring(0, Math.min(2, stringValue.length));
await setQueryEditorContent(
page,
pm,
`SELECT * FROM "${streamName}" WHERE ${fieldName} = '${partial}`,
);
await pm.logsPage.waitForQueryEditorValue('WHERE', 5000);
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);
// Find a field with captured values (skips ftsKey fields that crash the expand handler)
const fieldResult = await findFieldWithStringValues(page, pm, orgName, streamName);
expect(fieldResult, 'Expected to find a field with captured values').not.toBeNull();
const { fieldName } = fieldResult;
// Type NOT IN operator
await setQueryEditorContent(page, pm, `SELECT * FROM "${streamName}" WHERE ${fieldName} NOT IN (`);
await pm.logsPage.waitForQueryEditorValue('WHERE', 5000);
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);
// Find a field with captured values (skips ftsKey fields that crash the expand handler)
const fieldResult = await findFieldWithStringValues(page, pm, orgName, streamName);
expect(fieldResult, 'Expected to find a field with captured values').not.toBeNull();
const { fieldName } = fieldResult;
// Type != operator
await setQueryEditorContent(page, pm, `SELECT * FROM "${streamName}" WHERE ${fieldName} != `);
await pm.logsPage.waitForQueryEditorValue('WHERE', 5000);
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);
// Find a field with captured values (skips ftsKey fields that crash the expand handler)
const fieldResult = await findFieldWithStringValues(page, pm, orgName, streamName);
expect(fieldResult, 'Expected to find a field with captured values').not.toBeNull();
const { fieldName } = fieldResult;
// Test >= operator - no silent catch
await setQueryEditorContent(page, pm, `SELECT * FROM "${streamName}" WHERE ${fieldName} >= `);
await pm.logsPage.waitForQueryEditorValue('WHERE', 5000);
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 pm.logsPage.waitForQueryEditorValue('WHERE', 5000);
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);
// Find a field with captured values (skips ftsKey fields that crash the expand handler)
const fieldResult = await findFieldWithStringValues(page, pm, orgName, streamName);
expect(fieldResult, 'Expected to find a field with captured values').not.toBeNull();
const { fieldName } = fieldResult;
// Type str_match function
await setQueryEditorContent(page, pm, `SELECT * FROM "${streamName}" WHERE str_match(${fieldName}, `);
await pm.logsPage.waitForQueryEditorValue('WHERE', 5000);
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.waitForLoadState('domcontentloaded');
await ingestTestData(page);
// Gate on the stream being searchable before driving the UI so field
// expansion never fetches an empty value set (WAL -> index lag).
await waitForStreamData(page, streamName, 1).catch(() => {});
await page.goto(`${logData.logsUrl}?org_identifier=${orgName}`);
await page.waitForLoadState('domcontentloaded');
});
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);
// Search for the field in the field list search box first
await pm.logsPage.searchFieldByName('code');
// Find the field expand button for 'code'
const codeFieldBtn = pm.logsPage.getFieldExpandButton('code');
await codeFieldBtn.waitFor({ state: 'visible', timeout: 5000 });
// Response-anchored capture (not a click-time IDB race): waits for the
// /_values_stream fetch before confirming the IndexedDB write, so a slow
// runner can't make this null even though the data exists.
const record = await expandFieldAndCaptureValues(page, codeFieldBtn, orgName, streamName, 'code');
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 pm.logsPage.waitForQueryEditorValue('code = ', 5000);
// Retry-trigger autocomplete until the numeric value shows up
// (handles the 500ms editor debounce that gates updateAutoComplete).
await triggerSuggestionsWithRetry(page, pm, numericValue, 12000);
// 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);
// Keyboard accept — see acceptSuggestionByValue() rationale.
await acceptSuggestionByValue(page, numericValue);
// 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 pm.logsPage.waitForQueryEditorValue(`${fieldName} = '`, 5000);
// Retry-trigger autocomplete until the stored value appears
await triggerSuggestionsWithRetry(page, pm, stringValue, 12000);
const hasStringValue = await suggestionContainsValue(page, pm, stringValue);
expect(hasStringValue, `Expected string value ${stringValue} to be in suggestions`).toBe(true);
// Keyboard accept — see acceptSuggestionByValue() rationale above.
await acceptSuggestionByValue(page, stringValue);
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.waitForLoadState('domcontentloaded');
await ingestTestData(page);
// Gate on the stream being searchable before driving the UI so field
// expansion never fetches an empty value set (WAL -> index lag).
await waitForStreamData(page, streamName, 1).catch(() => {});
await page.goto(`${logData.logsUrl}?org_identifier=${orgName}`);
await page.waitForLoadState('domcontentloaded');
});
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 pm.logsPage.waitForQueryEditorValue('WHERE', 5000);
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');
});
});