From 61179af1d3ed746c4af6254f412533e367bb44f9 Mon Sep 17 00:00:00 2001 From: Karen Grigoryan Date: Thu, 19 Feb 2026 15:36:38 +0100 Subject: [PATCH 1/3] [DevTools Console] Avoid editor freeze after pasting large JSON (#251173) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #251172 This PR fixes a DevTools Console editor freeze that occurs after pasting very large request payloads (notably JSON containing huge string fields) by removing expensive, full-buffer operations from keystroke-time paths and by preventing a super-linear ES|QL context scan from running on large inputs. https://github.com/user-attachments/assets/54b90fdb-f0b0-4de0-ae53-6fa4a12a98d9 Console language) **What changed** - The Console completion provider no longer calls `model.getValue()` to compute `textBeforeCursor`. - ES|QL context detection is only attempted when the cursor is within a `POST /_query` (or `POST _query/async`) request. - When it *is* a `_query` request, the provider reads only the relevant slice via `model.getValueInRange()` (from the request line to the cursor). - For all other requests (or when no request line is found within a bounded lookback), completion delegates to the existing actions provider. **Why** - `model.getValue()` materializes the entire document string, which becomes prohibitively expensive after users paste very large payloads; doing that on completion triggers (often on every keystroke) is enough to stall the UI. - ES|QL completion only makes sense for `_query` requests; scoping the work avoids paying the cost for non-ES|QL traffic. **Why this approach** - We preserve existing completion behavior by delegating to the actions provider, but avoid the pathological cost by restricting work to a small, semantically relevant window. (`checkForTripleQuotesAndEsqlQuery`) **What changed** - `checkForTripleQuotesAndEsqlQuery` was rewritten to scan the text once and avoid repeated substring+regex work inside the main loop. **Why** - The previous approach could become super-linear on adversarial inputs (many quotes / many potential toggles), which is exactly what large JSON payloads tend to contain. - In the worst case, this turns “type one character” into “do a huge amount of CPU work”, producing the freeze. **Why this approach** - A single forward scan preserves semantics while making the runtime scale predictably with input size. keystroke (`shared-ux` React Monaco editor) **What changed** - The `onDidChangeModelContent` handler computes the next value by applying `event.changes` to a shadow string (`lastKnownValueRef`) instead of calling `editor.getValue()`. - Controlled-mode syncing (`useLayoutEffect`) first compares the incoming `value` against the shadow value to avoid unnecessary full-buffer reads. **Why** - `editor.getValue()` materializes the full buffer; doing it on every keystroke creates a hard O(N) cost per change, which is visible (and can be catastrophic) for large models. - Applying incremental changes keeps the per-keystroke cost proportional to the change set, not the document size. **Why this approach** - Monaco already provides the exact edit deltas (`IModelContentChangedEvent.changes`). Applying those deltas is the cheapest way to keep React state in sync without forcing full-buffer reads. - **`language.test.ts`**: exercises the completion provider branching/delegation so we don’t regress behavior while tightening when/what we read from the model. - **`autocomplete_utils.test.ts`**: focused behavioral coverage for ES|QL context detection edge cases (the perf-worker regression test was removed to keep the suite deterministic and low-maintenance). - **`editor.test.tsx`**: verifies the incremental `onChange` value computation (including multi-change ordering) and controlled sync behavior. - `node scripts/jest.js src/platform/packages/shared/kbn-monaco/src/languages/console/language.test.ts src/platform/packages/shared/kbn-monaco/src/languages/console/utils/autocomplete_utils.test.ts --config src/platform/packages/shared/kbn-monaco/jest.config.js` - `node scripts/jest.js src/platform/packages/shared/shared-ux/code_editor/impl/react_monaco_editor/editor.test.tsx --config src/platform/packages/shared/shared-ux/code_editor/impl/jest.config.js` Co-authored-by: Cursor --- .../src/languages/console/language.test.ts | 413 ++++++++++++++++++ .../src/languages/console/language.ts | 91 +++- .../console/utils/autocomplete_utils.test.ts | 155 +++---- .../console/utils/autocomplete_utils.ts | 208 ++++++--- .../impl/react_monaco_editor/editor.test.tsx | 239 +++++++++- .../impl/react_monaco_editor/editor.tsx | 49 ++- .../apps/console/_misc_console_behavior.ts | 65 ++- .../apps/console/quote_heavy_input.ts | 344 +++++++++++++++ 8 files changed, 1374 insertions(+), 190 deletions(-) create mode 100644 src/platform/packages/shared/kbn-monaco/src/languages/console/language.test.ts create mode 100644 src/platform/test/functional/apps/console/quote_heavy_input.ts diff --git a/src/platform/packages/shared/kbn-monaco/src/languages/console/language.test.ts b/src/platform/packages/shared/kbn-monaco/src/languages/console/language.test.ts new file mode 100644 index 0000000000000..4fee12c7fe900 --- /dev/null +++ b/src/platform/packages/shared/kbn-monaco/src/languages/console/language.test.ts @@ -0,0 +1,413 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { MutableRefObject } from 'react'; +import type { ESQLCallbacks, suggest as suggestFn } from '@kbn/esql-validation-autocomplete'; +import type { wrapAsMonacoSuggestions as wrapFn } from '../esql/lib/converters/suggestions'; +import type { + checkForTripleQuotesAndEsqlQuery as checkFn, + unescapeInvalidChars as unescapeFn, +} from './utils'; +import type { setupConsoleErrorsProvider as setupErrorsProviderFn } from './console_errors_provider'; +import type { ConsoleParsedRequestsProvider as ParsedProviderCtor } from './console_parsed_requests_provider'; + +import { monaco } from '../../monaco_imports'; +import { ESQL_AUTOCOMPLETE_TRIGGER_CHARS } from '../esql'; + +const mockWorkerSetup = jest.fn(); + +const mockSuggest = jest.fn, Parameters>(); +const mockWrapAsMonacoSuggestions = jest.fn, Parameters>(); +const mockCheckForTripleQuotesAndEsqlQuery = jest.fn< + ReturnType, + Parameters +>(); +const mockUnescapeInvalidChars = jest.fn< + ReturnType, + Parameters +>(); +const mockSetupConsoleErrorsProvider = jest.fn< + ReturnType, + Parameters +>(); +const mockConsoleParsedRequestsProvider = jest.fn< + InstanceType, + ConstructorParameters +>(); + +jest.mock('@kbn/esql-validation-autocomplete', () => ({ + suggest: (...args: Parameters) => mockSuggest(...args), +})); + +jest.mock('../esql/lib/converters/suggestions', () => ({ + wrapAsMonacoSuggestions: (...args: Parameters) => + mockWrapAsMonacoSuggestions(...args), +})); + +jest.mock('./utils', () => ({ + checkForTripleQuotesAndEsqlQuery: ( + ...args: Parameters + ) => mockCheckForTripleQuotesAndEsqlQuery(...args), + unescapeInvalidChars: (...args: Parameters) => + mockUnescapeInvalidChars(...args), +})); + +jest.mock('./console_errors_provider', () => ({ + setupConsoleErrorsProvider: (...args: Parameters) => + mockSetupConsoleErrorsProvider(...args), +})); + +jest.mock('./console_parsed_requests_provider', () => { + function ConsoleParsedRequestsProvider( + ...args: ConstructorParameters + ) { + mockConsoleParsedRequestsProvider(...args); + } + return { ConsoleParsedRequestsProvider }; +}); + +jest.mock('./console_worker_proxy', () => { + function ConsoleWorkerProxyService(this: { setup: () => void }) { + this.setup = () => mockWorkerSetup(); + } + return { ConsoleWorkerProxyService }; +}); + +import { ConsoleLang, CONSOLE_TRIGGER_CHARS, getParsedRequestsProvider } from './language'; + +type ProvideCompletionItems = NonNullable< + monaco.languages.CompletionItemProvider['provideCompletionItems'] +>; + +const createToken = (): { token: monaco.CancellationToken; dispose: () => void } => { + const source = new monaco.CancellationTokenSource(); + return { token: source.token, dispose: () => source.dispose() }; +}; + +const createActionsProvider = (): { + actionsProvider: MutableRefObject<{ provideCompletionItems: ProvideCompletionItems } | null>; + provideCompletionItems: jest.MockedFunction; +} => { + const completionList: monaco.languages.CompletionList = { suggestions: [] }; + const provideCompletionItems = jest.fn< + ReturnType, + Parameters + >(() => completionList); + + const actionsProvider: MutableRefObject<{ + provideCompletionItems: ProvideCompletionItems; + } | null> = { + current: { provideCompletionItems }, + }; + + return { actionsProvider, provideCompletionItems }; +}; + +const createProvider = ( + esqlCallbacks: Pick | undefined, + actionsProvider: MutableRefObject<{ provideCompletionItems: ProvideCompletionItems } | null> +): monaco.languages.CompletionItemProvider => { + if (!ConsoleLang.getSuggestionProvider) { + throw new Error('expected ConsoleLang.getSuggestionProvider to be defined'); + } + return ConsoleLang.getSuggestionProvider(esqlCallbacks, actionsProvider); +}; + +const createModel = (lines: string[]): monaco.editor.ITextModel => { + return monaco.editor.createModel(lines.join('\n'), 'plaintext'); +}; + +describe('console language', () => { + const baseContext: monaco.languages.CompletionContext = { + triggerKind: monaco.languages.CompletionTriggerKind.Invoke, + }; + + const createEsqlCallbacks = (): Pick => ({ + getSources: async () => [], + getPolicies: async () => [], + }); + + const createdModels: monaco.editor.ITextModel[] = []; + afterEach(() => { + jest.restoreAllMocks(); + while (createdModels.length) { + createdModels.pop()?.dispose(); + } + }); + + beforeEach(() => { + // Global mocks stay, but each test starts from a clean slate: + // - clears call history + // - resets per-test stubbed implementations + jest.resetAllMocks(); + }); + + it('exposes triggerCharacters including Console + ES|QL triggers', () => { + const { actionsProvider } = createActionsProvider(); + const provider = createProvider(undefined, actionsProvider); + expect(provider.triggerCharacters).toEqual( + expect.arrayContaining([...CONSOLE_TRIGGER_CHARS, ...ESQL_AUTOCOMPLETE_TRIGGER_CHARS]) + ); + }); + + it('delegates to actions provider when no request line is found', async () => { + const { actionsProvider, provideCompletionItems } = createActionsProvider(); + const provider = createProvider(undefined, actionsProvider); + const { token, dispose } = createToken(); + + const model = createModel(['{ "not": "a request line" }']); + createdModels.push(model); + + const getValueSpy = jest.spyOn(model, 'getValue'); + const getValueInRangeSpy = jest.spyOn(model, 'getValueInRange'); + + await provider.provideCompletionItems!(model, new monaco.Position(1, 1), baseContext, token); + + expect(provideCompletionItems).toHaveBeenCalledTimes(1); + expect(getValueSpy).not.toHaveBeenCalled(); + expect(getValueInRangeSpy).not.toHaveBeenCalled(); + expect(mockCheckForTripleQuotesAndEsqlQuery).not.toHaveBeenCalled(); + expect(mockSuggest).not.toHaveBeenCalled(); + expect(mockWrapAsMonacoSuggestions).not.toHaveBeenCalled(); + + dispose(); + }); + + it('delegates to actions provider for non-_query request lines (e.g. GET _search)', async () => { + const { actionsProvider, provideCompletionItems } = createActionsProvider(); + const provider = createProvider(undefined, actionsProvider); + const { token, dispose } = createToken(); + + const model = createModel(['GET _search', '{ "query": { "match_all": {} } }']); + createdModels.push(model); + + const getValueInRangeSpy = jest.spyOn(model, 'getValueInRange'); + + await provider.provideCompletionItems!(model, new monaco.Position(2, 5), baseContext, token); + + expect(provideCompletionItems).toHaveBeenCalledTimes(1); + expect(getValueInRangeSpy).not.toHaveBeenCalled(); + expect(mockCheckForTripleQuotesAndEsqlQuery).not.toHaveBeenCalled(); + + dispose(); + }); + + it('does not treat GET _query as ES|QL request (POST-only) and delegates to actions provider', async () => { + const { actionsProvider, provideCompletionItems } = createActionsProvider(); + const provider = createProvider(undefined, actionsProvider); + const { token, dispose } = createToken(); + + const model = createModel(['GET _query', '{ "query": "FROM logs" }']); + createdModels.push(model); + + await provider.provideCompletionItems!(model, new monaco.Position(2, 7), baseContext, token); + + expect(provideCompletionItems).toHaveBeenCalledTimes(1); + expect(mockCheckForTripleQuotesAndEsqlQuery).not.toHaveBeenCalled(); + + dispose(); + }); + + it('limits request-line lookback to 2000 lines and delegates when request line is beyond lookback', async () => { + const { actionsProvider, provideCompletionItems } = createActionsProvider(); + const provider = createProvider(undefined, actionsProvider); + const { token, dispose } = createToken(); + + const lines = ['POST _query', ...Array.from({ length: 2000 }, () => '')]; + const model = createModel(lines); + createdModels.push(model); + + const getValueInRangeSpy = jest.spyOn(model, 'getValueInRange'); + + await provider.provideCompletionItems!(model, new monaco.Position(2001, 1), baseContext, token); + + expect(provideCompletionItems).toHaveBeenCalledTimes(1); + expect(getValueInRangeSpy).not.toHaveBeenCalled(); + expect(mockCheckForTripleQuotesAndEsqlQuery).not.toHaveBeenCalled(); + + dispose(); + }); + + it('runs _query detection via getValueInRange and delegates when not inside ES|QL', async () => { + const { actionsProvider, provideCompletionItems } = createActionsProvider(); + const provider = createProvider(undefined, actionsProvider); + const { token, dispose } = createToken(); + + mockCheckForTripleQuotesAndEsqlQuery.mockReturnValue({ + insideTripleQuotes: false, + insideEsqlQuery: false, + esqlQueryIndex: -1, + }); + + const model = createModel(['POST _query', '{ "query": "FROM logs" }']); + createdModels.push(model); + + const getValueInRangeSpy = jest.spyOn(model, 'getValueInRange'); + + await provider.provideCompletionItems!(model, new monaco.Position(2, 7), baseContext, token); + + expect(getValueInRangeSpy).toHaveBeenCalledTimes(1); + expect(mockCheckForTripleQuotesAndEsqlQuery).toHaveBeenCalledTimes(1); + expect(provideCompletionItems).toHaveBeenCalledTimes(1); + expect(mockSuggest).not.toHaveBeenCalled(); + expect(mockWrapAsMonacoSuggestions).not.toHaveBeenCalled(); + dispose(); + }); + + it('delegates when inside ES|QL but esqlCallbacks are undefined', async () => { + const { actionsProvider, provideCompletionItems } = createActionsProvider(); + const provider = createProvider(undefined, actionsProvider); + const { token, dispose } = createToken(); + + mockCheckForTripleQuotesAndEsqlQuery.mockReturnValue({ + insideTripleQuotes: false, + insideEsqlQuery: true, + esqlQueryIndex: 0, + }); + + const model = createModel(['POST _query', '{ "query": "FROM logs" }']); + createdModels.push(model); + + await provider.provideCompletionItems!(model, new monaco.Position(2, 7), baseContext, token); + + expect(provideCompletionItems).toHaveBeenCalledTimes(1); + expect(mockSuggest).not.toHaveBeenCalled(); + expect(mockWrapAsMonacoSuggestions).not.toHaveBeenCalled(); + dispose(); + }); + + it('returns empty suggestions when no actions provider is available and request line is not found', async () => { + const actionsProvider: MutableRefObject<{ + provideCompletionItems: ProvideCompletionItems; + } | null> = { + current: null, + }; + const provider = createProvider(undefined, actionsProvider); + const { token, dispose } = createToken(); + + const model = createModel(['{ "no": "request line" }']); + createdModels.push(model); + + const result = await provider.provideCompletionItems!( + model, + new monaco.Position(1, 1), + baseContext, + token + ); + + expect(result).toEqual({ suggestions: [] }); + dispose(); + }); + + it('returns empty suggestions when _query detection runs but actions provider is missing', async () => { + const actionsProvider: MutableRefObject<{ + provideCompletionItems: ProvideCompletionItems; + } | null> = { + current: null, + }; + const provider = createProvider(undefined, actionsProvider); + const { token, dispose } = createToken(); + + mockCheckForTripleQuotesAndEsqlQuery.mockReturnValue({ + insideTripleQuotes: false, + insideEsqlQuery: false, + esqlQueryIndex: -1, + }); + + const model = createModel(['POST _query', '{ "query": "FROM logs" }']); + createdModels.push(model); + + const result = await provider.provideCompletionItems!( + model, + new monaco.Position(2, 7), + baseContext, + token + ); + + expect(result).toEqual({ suggestions: [] }); + dispose(); + }); + + it('runs ES|QL suggestions and wraps them when inside ES|QL (single quoted)', async () => { + const { actionsProvider, provideCompletionItems } = createActionsProvider(); + const esqlCallbacks = createEsqlCallbacks(); + const provider = createProvider(esqlCallbacks, actionsProvider); + const { token, dispose } = createToken(); + + const wrapped: monaco.languages.CompletionList = { suggestions: [] }; + mockWrapAsMonacoSuggestions.mockReturnValue(wrapped); + + mockCheckForTripleQuotesAndEsqlQuery.mockReturnValue({ + insideTripleQuotes: false, + insideEsqlQuery: true, + esqlQueryIndex: 0, + }); + + mockUnescapeInvalidChars.mockReturnValue('UNESCAPED_QUERY'); + mockSuggest.mockResolvedValue([]); + + const model = createModel(['POST _query', '{ "query": "FROM logs" }']); + createdModels.push(model); + + const result = await provider.provideCompletionItems!( + model, + new monaco.Position(2, 7), + baseContext, + token + ); + + expect(mockSuggest).toHaveBeenCalledWith( + 'UNESCAPED_QUERY', + 'UNESCAPED_QUERY'.length, + esqlCallbacks + ); + expect(mockWrapAsMonacoSuggestions).toHaveBeenCalledTimes(1); + expect(provideCompletionItems).not.toHaveBeenCalled(); + expect(result).toBe(wrapped); + dispose(); + }); + + it('passes allowSnippets=false when inside triple quotes (ES|QL)', async () => { + const { actionsProvider } = createActionsProvider(); + const esqlCallbacks = createEsqlCallbacks(); + const provider = createProvider(esqlCallbacks, actionsProvider); + const { token, dispose } = createToken(); + + mockCheckForTripleQuotesAndEsqlQuery.mockReturnValue({ + insideTripleQuotes: true, + insideEsqlQuery: true, + esqlQueryIndex: 0, + }); + + mockUnescapeInvalidChars.mockReturnValue('UNESCAPED_QUERY'); + mockSuggest.mockResolvedValue([]); + mockWrapAsMonacoSuggestions.mockReturnValue({ suggestions: [] }); + + const model = createModel(['POST _query', '{ "query": """FROM logs""" }']); + createdModels.push(model); + + await provider.provideCompletionItems!(model, new monaco.Position(2, 7), baseContext, token); + + // The 4th arg is `!insideTripleQuotes` -> false when inside triple quotes. + expect(mockWrapAsMonacoSuggestions.mock.calls[0][3]).toBe(false); + dispose(); + }); + + it('wires onLanguage + parsed request provider', () => { + ConsoleLang.onLanguage?.(); + expect(mockWorkerSetup).toHaveBeenCalledTimes(1); + expect(mockSetupConsoleErrorsProvider).toHaveBeenCalledTimes(1); + + const model = createModel(['GET _search']); + createdModels.push(model); + getParsedRequestsProvider(model); + + expect(mockConsoleParsedRequestsProvider).toHaveBeenCalledWith(expect.anything(), model); + }); +}); diff --git a/src/platform/packages/shared/kbn-monaco/src/languages/console/language.ts b/src/platform/packages/shared/kbn-monaco/src/languages/console/language.ts index 7a12721e55228..aa80ce2a19fbb 100644 --- a/src/platform/packages/shared/kbn-monaco/src/languages/console/language.ts +++ b/src/platform/packages/shared/kbn-monaco/src/languages/console/language.ts @@ -32,6 +32,52 @@ import { foldingRangeProvider } from './folding_range_provider'; export const CONSOLE_TRIGGER_CHARS = ['/', '.', '_', ',', '?', '=', '&', '"']; +const requestMethodRe = /^\s*(GET|POST|PUT|DELETE|HEAD|PATCH)\b/i; +const esqlRequestLineRe = /^\s*post\s+\/?_query(?:\/async)?(?:\s|\?|$)/i; +/** + * Safeguards for request-line lookup. We scan backwards from the cursor until we find the nearest + * request method line (GET/POST/...), but we cap the amount of work to avoid a potentially large + * number of `getLineContent()` calls on very long documents. + * + * If these limits are hit, ES|QL context detection is skipped and we fall back to the + * actions provider (preserving completion behavior, just without ES|QL suggestions). + */ +const MAX_REQUEST_LINE_LOOKBACK_LINES = 2000; +const MAX_REQUEST_LINE_LOOKBACK_CHARS = 100_000; + +const findEsqlRequestLineNumber = ( + model: monaco.editor.ITextModel, + positionLineNumber: number +): number | undefined => { + for ( + let lineNumber = positionLineNumber, scannedLines = 0, scannedChars = 0; + lineNumber >= 1 && + scannedLines < MAX_REQUEST_LINE_LOOKBACK_LINES && + scannedChars < MAX_REQUEST_LINE_LOOKBACK_CHARS; + lineNumber--, scannedLines++ + ) { + const line = model.getLineContent(lineNumber); + scannedChars += line.length + 1; + if (requestMethodRe.test(line)) { + // Only treat this as an ES|QL request if the request line matches POST _query(/async)?... + return esqlRequestLineRe.test(line) ? lineNumber : undefined; + } + } +}; + +const getRequestTextBeforeCursor = ( + model: monaco.editor.ITextModel, + requestLineNumber: number, + position: monaco.Position +): string => { + return model.getValueInRange({ + startLineNumber: requestLineNumber, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column, + }); +}; + /** * @description This language definition is used for the console input panel */ @@ -46,8 +92,10 @@ export const ConsoleLang: LangModuleType = { }, languageThemeResolver: buildConsoleTheme, getSuggestionProvider: ( - esqlCallbacks: Pick, - actionsProvider: MutableRefObject + esqlCallbacks: Pick | undefined, + actionsProvider: MutableRefObject<{ + provideCompletionItems: monaco.languages.CompletionItemProvider['provideCompletionItems']; + } | null> ): monaco.languages.CompletionItemProvider => { return { // force suggestions when these characters are used @@ -55,15 +103,36 @@ export const ConsoleLang: LangModuleType = { provideCompletionItems: async ( model: monaco.editor.ITextModel, position: monaco.Position, - context: monaco.languages.CompletionContext + context: monaco.languages.CompletionContext, + token: monaco.CancellationToken ) => { - const fullText = model.getValue(); - const cursorOffset = model.getOffsetAt(position); - const textBeforeCursor = fullText.slice(0, cursorOffset); + // NOTE: Materializing the full editor content (e.g. via `model.getValue()`) can be very + // expensive for large inputs (like pasted JSON with huge string fields). We only do ES|QL + // context detection when the cursor is within a POST /_query request. + const delegateToActionsProvider = () => { + const actions = actionsProvider.current; + return ( + actions?.provideCompletionItems(model, position, context, token) ?? { + suggestions: [], + } + ); + }; + + const esqlRequestLineNumber = findEsqlRequestLineNumber(model, position.lineNumber); + if (!esqlRequestLineNumber) { + return delegateToActionsProvider(); + } + + const requestTextBeforeCursor = getRequestTextBeforeCursor( + model, + esqlRequestLineNumber, + position + ); const { insideTripleQuotes, insideEsqlQuery, esqlQueryIndex } = - checkForTripleQuotesAndEsqlQuery(textBeforeCursor); + checkForTripleQuotesAndEsqlQuery(requestTextBeforeCursor); + if (esqlCallbacks && insideEsqlQuery) { - const queryText = textBeforeCursor.slice(esqlQueryIndex, cursorOffset); + const queryText = requestTextBeforeCursor.slice(esqlQueryIndex); const unescapedQuery = unescapeInvalidChars(queryText); const esqlSuggestions = await suggest( unescapedQuery, @@ -71,12 +140,8 @@ export const ConsoleLang: LangModuleType = { esqlCallbacks ); return wrapAsMonacoSuggestions(esqlSuggestions, queryText, false, !insideTripleQuotes); - } else if (actionsProvider.current) { - return actionsProvider.current?.provideCompletionItems(model, position, context); } - return { - suggestions: [], - }; + return delegateToActionsProvider(); }, }; }, diff --git a/src/platform/packages/shared/kbn-monaco/src/languages/console/utils/autocomplete_utils.test.ts b/src/platform/packages/shared/kbn-monaco/src/languages/console/utils/autocomplete_utils.test.ts index 3dde1b534c8a7..7e4bd7a062c01 100644 --- a/src/platform/packages/shared/kbn-monaco/src/languages/console/utils/autocomplete_utils.test.ts +++ b/src/platform/packages/shared/kbn-monaco/src/languages/console/utils/autocomplete_utils.test.ts @@ -10,7 +10,7 @@ import { checkForTripleQuotesAndEsqlQuery, unescapeInvalidChars } from './autocomplete_utils'; describe('autocomplete_utils', () => { - describe('checkForTripleQuotesAndQueries', () => { + describe('checkForTripleQuotesAndEsqlQuery', () => { it('returns false for all flags for an empty string', () => { expect(checkForTripleQuotesAndEsqlQuery('')).toEqual({ insideTripleQuotes: false, @@ -19,8 +19,8 @@ describe('autocomplete_utils', () => { }); }); - it('returns false for all flags for a request without triple quotes or ESQL query', () => { - const request = `POST _search\n{\n "query": {\n "match": {\n "message": "hello world"\n }\n }\n}`; + it('does not detect ES|QL inside non-_query requests', () => { + const request = `GET index/_search\n{\n "query": "FROM logs | STATS `; expect(checkForTripleQuotesAndEsqlQuery(request)).toEqual({ insideTripleQuotes: false, insideEsqlQuery: false, @@ -28,8 +28,8 @@ describe('autocomplete_utils', () => { }); }); - it('returns true for insideTripleQuotes and false for ESQL flags when triple quotes are outside a query', () => { - const request = `POST _ingest/pipeline/_simulate\n{\n "pipeline": {\n "processors": [\n {\n "script": {\n "source":\n """\n for (field in params['fields']){\n if (!$(field, '').isEmpty()){\n`; + it('returns insideTripleQuotes=true but insideEsqlQuery=false when triple quotes are outside the query value', () => { + const request = `POST _query\n{\n "script": """FROM test `; expect(checkForTripleQuotesAndEsqlQuery(request)).toEqual({ insideTripleQuotes: true, insideEsqlQuery: false, @@ -37,120 +37,87 @@ describe('autocomplete_utils', () => { }); }); - it('returns true for insideTripleQuotes but false for ESQL flags inside a non-_query request query field', () => { - const request = `POST _search\n{\n "query": """FROM test `; + it('sets insideEsqlQuery for single quoted query after POST _query', () => { + const request = `POST _query\n{\n "query": "FROM test `; expect(checkForTripleQuotesAndEsqlQuery(request)).toEqual({ - insideTripleQuotes: true, - insideEsqlQuery: false, - esqlQueryIndex: -1, + insideTripleQuotes: false, + insideEsqlQuery: true, + esqlQueryIndex: request.indexOf('"FROM test ') + 1, }); }); - it('returns false for ESQL flags inside a single-quoted query for non-_query request', () => { - const request = `GET index/_search\n{\n "query": "SELECT * FROM logs `; - const result = checkForTripleQuotesAndEsqlQuery(request); - expect(result).toEqual({ - insideTripleQuotes: false, - insideEsqlQuery: false, - esqlQueryIndex: -1, + it('sets insideEsqlQuery for triple quoted query after POST _query (case-insensitive)', () => { + const request = `post _query\n{\n "query": """FROM test `; + expect(checkForTripleQuotesAndEsqlQuery(request)).toEqual({ + insideTripleQuotes: true, + insideEsqlQuery: true, + esqlQueryIndex: request.indexOf('"""') + 3, }); }); - it('returns false for all flags if single quote is closed', () => { - const request = `POST _query\n{\n "query": "SELECT * FROM logs" }`; + it('handles escaped quotes correctly (not toggling inside state)', () => { + const request = `POST _query\n{\n "query": "FROM test | WHERE KQL(\\\"\\\"\\\")`; expect(checkForTripleQuotesAndEsqlQuery(request)).toEqual({ insideTripleQuotes: false, - insideEsqlQuery: false, - esqlQueryIndex: -1, + insideEsqlQuery: true, + esqlQueryIndex: request.indexOf('"FROM test ') + 1, }); }); - it('returns false for all flags if triple quote is closed', () => { - const request = `POST _query\n{\n "query": """SELECT * FROM logs""" }`; + it('detects query with /_query endpoint', () => { + const request = `POST /_query\n{\n "query": "FROM logs | STATS `; expect(checkForTripleQuotesAndEsqlQuery(request)).toEqual({ insideTripleQuotes: false, - insideEsqlQuery: false, - esqlQueryIndex: -1, + insideEsqlQuery: true, + esqlQueryIndex: request.indexOf('"FROM logs ') + 1, }); }); - }); - - it('sets insideEsqlQuery for single quoted query after POST _query', () => { - const request = `POST _query\n{\n "query": "FROM test `; - expect(checkForTripleQuotesAndEsqlQuery(request)).toEqual({ - insideTripleQuotes: false, - insideEsqlQuery: true, - esqlQueryIndex: request.indexOf('"FROM test ') + 1, - }); - }); - it('sets insideEsqlQuery for triple quoted query after POST _query (case-insensitive)', () => { - const request = `post _query\n{\n "query": """FROM test `; // lowercase POST should also match - const result = checkForTripleQuotesAndEsqlQuery(request); - expect(result).toEqual({ - insideTripleQuotes: true, - insideEsqlQuery: true, - esqlQueryIndex: request.indexOf('"""') + 3, - }); - }); - - it('detects single quoted query after POST _query?pretty suffix', () => { - const request = `POST _query?pretty\n{\n "query": "FROM logs | STATS `; - const result = checkForTripleQuotesAndEsqlQuery(request); - expect(result).toEqual({ - insideTripleQuotes: false, - insideEsqlQuery: true, - esqlQueryIndex: request.indexOf('"FROM logs ') + 1, - }); - }); - - it('detects query with /_query endpoint', () => { - const request = `POST /_query\n{\n "query": "FROM logs | STATS `; - const result = checkForTripleQuotesAndEsqlQuery(request); - expect(result).toEqual({ - insideTripleQuotes: false, - insideEsqlQuery: true, - esqlQueryIndex: request.indexOf('"FROM logs ') + 1, + it('detects query with /_query/async endpoint', () => { + const request = `POST /_query/async\n{\n "query": "FROM logs | STATS `; + expect(checkForTripleQuotesAndEsqlQuery(request)).toEqual({ + insideTripleQuotes: false, + insideEsqlQuery: true, + esqlQueryIndex: request.indexOf('"FROM logs ') + 1, + }); }); - }); - it('detects triple quoted query after POST _query?foo=bar with extra spaces', () => { - const request = `POST _query?foo=bar\n{\n "query": """FROM metrics `; - const result = checkForTripleQuotesAndEsqlQuery(request); - expect(result).toEqual({ - insideTripleQuotes: true, - insideEsqlQuery: true, - esqlQueryIndex: request.indexOf('"""') + 3, + it('does not treat longer words as request methods (e.g. GETS, POSTER)', () => { + const methods = ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'PATCH'] as const; + for (const method of methods) { + const request = `${method}A _query\n{\n "query": "FROM logs | STATS `; + expect(checkForTripleQuotesAndEsqlQuery(request)).toEqual({ + insideTripleQuotes: false, + insideEsqlQuery: false, + esqlQueryIndex: -1, + }); + } }); - }); - it('does not set ESQL flags for subsequent non-_query request in same buffer', () => { - const request = `POST _query\n{\n "query": "FROM a | STATS "\n}\nGET other_index/_search\n{\n "query": "match_all" }`; - const result = checkForTripleQuotesAndEsqlQuery(request); - expect(result).toEqual({ - insideTripleQuotes: false, - insideEsqlQuery: false, - esqlQueryIndex: -1, // single quotes closed in second request + it('does not treat near-miss keys as the "query" value', () => { + const request = `POST _query\n{\n "queryx": "FROM logs | STATS `; + expect(checkForTripleQuotesAndEsqlQuery(request)).toEqual({ + insideTripleQuotes: false, + insideEsqlQuery: false, + esqlQueryIndex: -1, + }); }); - }); - it('only flags current active _query section in mixed multi-request buffer', () => { - const partial = `POST _query\n{\n "query": "FROM a | STATS "\n}\nPOST _query\n{\n "query": """FROM b | WHERE foo = `; // cursor inside triple quotes of second request - const result = checkForTripleQuotesAndEsqlQuery(partial); - expect(result).toEqual({ - insideTripleQuotes: true, - insideEsqlQuery: true, - esqlQueryIndex: partial.lastIndexOf('"""') + 3, + it('only flags the current active _query section in a mixed multi-request buffer', () => { + const partial = `POST _query\n{\n "query": "FROM a | STATS "\n}\nPOST _query\n{\n "query": """FROM b | WHERE foo = `; + expect(checkForTripleQuotesAndEsqlQuery(partial)).toEqual({ + insideTripleQuotes: true, + insideEsqlQuery: true, + esqlQueryIndex: partial.lastIndexOf('"""') + 3, + }); }); - }); - it('handles request method at end of buffer without trailing newline (regression test)', () => { - const buffer = 'POST _query'; - const result = checkForTripleQuotesAndEsqlQuery(buffer); - expect(result).toEqual({ - insideTripleQuotes: false, - insideEsqlQuery: false, - esqlQueryIndex: -1, + it('handles request method at end of buffer without trailing newline', () => { + expect(checkForTripleQuotesAndEsqlQuery('POST _query')).toEqual({ + insideTripleQuotes: false, + insideEsqlQuery: false, + esqlQueryIndex: -1, + }); }); }); diff --git a/src/platform/packages/shared/kbn-monaco/src/languages/console/utils/autocomplete_utils.ts b/src/platform/packages/shared/kbn-monaco/src/languages/console/utils/autocomplete_utils.ts index bd8bba48c9b25..27c87950f5af2 100644 --- a/src/platform/packages/shared/kbn-monaco/src/languages/console/utils/autocomplete_utils.ts +++ b/src/platform/packages/shared/kbn-monaco/src/languages/console/utils/autocomplete_utils.ts @@ -8,76 +8,168 @@ */ /** - * This function takes a Console text up to the current position and determines whether - * the current position is inside triple quotes, triple-quote or single-quote query, - * and the start index of the current query. - * @param text The text up to the current position + * Determines whether the cursor position (given by providing the buffer up to the cursor) is + * currently within an ES|QL `"query"` string for a `POST /_query` request, including triple-quoted + * strings (`""" ... """`). + * + * @param text The Console buffer up to the cursor position. */ +const TRIPLE_QUOTES = '"""'; +const QUERY_KEY = '"query"'; +const ESQL_QUERY_REQUEST_LINE_RE = /^post\s+\/?_query(?:\/async)?(?:\s|\?|$)/i; +const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'PATCH'] as const; + +const ASCII = { + A_UPPER: 65, + Z_UPPER: 90, + A_LOWER: 97, + Z_LOWER: 122, +} as const; + +const isWhitespace = (ch: string | undefined) => + ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'; + +const skipWhitespaceBackward = (text: string, fromIndex: number): number => { + for (let index = fromIndex; index >= 0; index--) { + if (!isWhitespace(text[index])) { + return index; + } + } + return -1; +}; + +const isAsciiLetter = (ch: string | undefined): boolean => { + if (!ch) return false; + const code = ch.charCodeAt(0); + return ( + (code >= ASCII.A_UPPER && code <= ASCII.Z_UPPER) || + (code >= ASCII.A_LOWER && code <= ASCII.Z_LOWER) + ); +}; + +const isStartOfLine = (text: string, index: number): boolean => { + if (index === 0) { + return true; + } + return text[index - 1] === '\n'; +}; + +const isQueryValueStartAtQuote = (text: string, quoteIndex: number): boolean => { + const colonIndex = skipWhitespaceBackward(text, quoteIndex - 1); + if (colonIndex < 0 || text[colonIndex] !== ':') { + return false; + } + + const keyEndIndex = skipWhitespaceBackward(text, colonIndex - 1); + const keyStartIndex = keyEndIndex - (QUERY_KEY.length - 1); + if (keyStartIndex < 0) { + return false; + } + + return text.startsWith(QUERY_KEY, keyStartIndex); +}; + +const matchesWordAt = (text: string, startIndex: number, word: string): boolean => { + for (let offset = 0; offset < word.length; offset++) { + const ch = text[startIndex + offset]; + if (!ch || ch.toUpperCase() !== word[offset]) { + return false; + } + } + return !isAsciiLetter(text[startIndex + word.length]); +}; + +const isRequestMethodAt = (text: string, startIndex: number): boolean => { + if (!isAsciiLetter(text[startIndex])) return false; + for (const method of HTTP_METHODS) { + if (matchesWordAt(text, startIndex, method)) { + return true; + } + } + return false; +}; + +const isEsqlQueryRequestLine = (line: string): boolean => ESQL_QUERY_REQUEST_LINE_RE.test(line); + +const getQueryValueStartIndex = (text: string, quoteIndex: number, quoteLen: 1 | 3): number => { + return isQueryValueStartAtQuote(text, quoteIndex) ? quoteIndex + quoteLen : -1; +}; + +const scanRequestLineFrom = ( + text: string, + lineStartIndex: number +): { nextIndex: number; isEsqlQueryRequest: boolean } | undefined => { + let scanIndex = lineStartIndex; + while (scanIndex < text.length && (text[scanIndex] === ' ' || text[scanIndex] === '\t')) { + scanIndex++; + } + + if (scanIndex >= text.length || !isRequestMethodAt(text, scanIndex)) { + return; + } + + const newlineIndex = text.indexOf('\n', scanIndex); + const lineEnd = newlineIndex === -1 ? text.length : newlineIndex; + const line = text.slice(scanIndex, lineEnd); + const isEsqlQueryRequest = isEsqlQueryRequestLine(line); + + const nextIndex = newlineIndex === -1 ? text.length : newlineIndex + 1; + return { nextIndex, isEsqlQueryRequest }; +}; + export const checkForTripleQuotesAndEsqlQuery = ( text: string -): { - insideTripleQuotes: boolean; - insideEsqlQuery: boolean; - esqlQueryIndex: number; -} => { - let insideSingleQuotes = false; - let insideTripleQuotes = false; - - let insideSingleQuotesQuery = false; - let insideTripleQuotesQuery = false; - - let insideEsqlQueryRequest = false; - - let currentQueryStartIndex = -1; - let i = 0; - - while (i < text.length) { - const textBefore = text.slice(0, i); - const textFromIndex = text.slice(i); - if (text.startsWith('"""', i)) { - insideTripleQuotes = !insideTripleQuotes; - if (insideTripleQuotes) { - insideTripleQuotesQuery = /.*"query"\s*:\s*$/.test(textBefore); - if (insideTripleQuotesQuery) { - currentQueryStartIndex = i + 3; - } - } else { - insideTripleQuotesQuery = false; - currentQueryStartIndex = -1; +): { insideTripleQuotes: boolean; insideEsqlQuery: boolean; esqlQueryIndex: number } => { + let inDoubleQuoteString = false; + let inTripleQuoteString = false; + let inQueryValueString = false; + + let inEsqlQueryRequest = false; + let esqlQueryStartIndex = -1; + + for (let index = 0; index < text.length; ) { + if (!inDoubleQuoteString && !inTripleQuoteString && isStartOfLine(text, index)) { + const requestLineScan = scanRequestLineFrom(text, index); + if (requestLineScan) { + inEsqlQueryRequest = requestLineScan.isEsqlQueryRequest; + index = requestLineScan.nextIndex; + continue; } - i += 3; // Skip the triple quotes - } else if (text.at(i) === '"' && text.at(i - 1) !== '\\') { - insideSingleQuotes = !insideSingleQuotes; - if (insideSingleQuotes) { - insideSingleQuotesQuery = /.*"query"\s*:\s*$/.test(textBefore); - if (insideSingleQuotesQuery) { - currentQueryStartIndex = i + 1; - } + } + + if (!inDoubleQuoteString && text.startsWith(TRIPLE_QUOTES, index)) { + inTripleQuoteString = !inTripleQuoteString; + if (inTripleQuoteString) { + esqlQueryStartIndex = getQueryValueStartIndex(text, index, 3); + inQueryValueString = esqlQueryStartIndex !== -1; } else { - insideSingleQuotesQuery = false; - currentQueryStartIndex = -1; + inQueryValueString = false; + esqlQueryStartIndex = -1; } - i++; - } else if (/^(GET|POST|PUT|DELETE|HEAD|PATCH)/i.test(textFromIndex)) { - // If this is the start of a new request, check if it is a _query API request - insideEsqlQueryRequest = /^(P|p)(O|o)(S|s)(T|t)\s+\/?_query(\n|\s|\?)/.test(textFromIndex); - // Move the index past the current line that contains request method and endpoint. - const newlineIndex = text.indexOf('\n', i); - if (newlineIndex === -1) { - // No newline after the request line; advance to end to avoid infinite loop. - i = text.length; + index += 3; + continue; + } + + if (!inTripleQuoteString && text[index] === '"' && text[index - 1] !== '\\') { + inDoubleQuoteString = !inDoubleQuoteString; + if (inDoubleQuoteString) { + esqlQueryStartIndex = getQueryValueStartIndex(text, index, 1); + inQueryValueString = esqlQueryStartIndex !== -1; } else { - i = newlineIndex + 1; // Position at start of next line + inQueryValueString = false; + esqlQueryStartIndex = -1; } - } else { - i++; + index++; + continue; } + + index++; } return { - insideTripleQuotes, - insideEsqlQuery: insideEsqlQueryRequest && (insideSingleQuotesQuery || insideTripleQuotesQuery), - esqlQueryIndex: insideEsqlQueryRequest ? currentQueryStartIndex : -1, + insideTripleQuotes: inTripleQuoteString, + insideEsqlQuery: inEsqlQueryRequest && inQueryValueString, + esqlQueryIndex: inEsqlQueryRequest ? esqlQueryStartIndex : -1, }; }; diff --git a/src/platform/packages/shared/shared-ux/code_editor/impl/react_monaco_editor/editor.test.tsx b/src/platform/packages/shared/shared-ux/code_editor/impl/react_monaco_editor/editor.test.tsx index dc285ded4e1be..3b0cae4e57ae0 100644 --- a/src/platform/packages/shared/shared-ux/code_editor/impl/react_monaco_editor/editor.test.tsx +++ b/src/platform/packages/shared/shared-ux/code_editor/impl/react_monaco_editor/editor.test.tsx @@ -9,8 +9,12 @@ import React from 'react'; import type { ComponentProps } from 'react'; -import { CODE_EDITOR_DEFAULT_THEME_ID, CODE_EDITOR_TRANSPARENT_THEME_ID } from '@kbn/monaco'; -import { render } from '@testing-library/react'; +import { + CODE_EDITOR_DEFAULT_THEME_ID, + CODE_EDITOR_TRANSPARENT_THEME_ID, + monaco, +} from '@kbn/monaco'; +import { render, waitFor } from '@testing-library/react'; import { MonacoEditor } from './editor'; import * as supportedLanguages from './languages/supported'; @@ -21,7 +25,87 @@ const defaultProps: Partial> = { editorWillUnmount: jest.fn(), }; +const createEvent = ( + changes: monaco.editor.IModelContentChange[] +): monaco.editor.IModelContentChangedEvent => ({ + changes, + eol: '\n', + versionId: 1, + isUndoing: false, + isRedoing: false, + isFlush: false, + isEolChange: false, +}); + +const createRange = (): monaco.IRange => ({ + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 1, +}); + +const createDisposable = (): monaco.IDisposable => ({ dispose: jest.fn() }); + +const setupMonacoEditorHarness = (params: { + onDidChangeModelContent?: (cb: (e: monaco.editor.IModelContentChangedEvent) => void) => void; + onPushUndoStop: jest.Mock; + onCreateModel?: (model: monaco.editor.ITextModel) => void; +}) => { + const disposable = createDisposable(); + + const createSpy = jest.spyOn(monaco.editor, 'create').mockImplementation((container, options) => { + if (!options?.model) { + throw new Error('expected create() to be called with a model'); + } + + const model = options.model; + params.onCreateModel?.(model); + + const editor = { + onDidChangeModelContent: (cb: (e: monaco.editor.IModelContentChangedEvent) => void) => { + params.onDidChangeModelContent?.(cb); + return disposable; + }, + getModel: () => model, + pushUndoStop: params.onPushUndoStop, + updateOptions: jest.fn(), + layout: jest.fn(), + dispose: jest.fn(), + getDomNode: () => null, + } as unknown as monaco.editor.IStandaloneCodeEditor; + + return editor; + }); + + const markersSpy = jest + .spyOn(monaco.editor, 'onDidChangeMarkers') + .mockImplementation(() => disposable); + const getModelMarkersSpy = jest.spyOn(monaco.editor, 'getModelMarkers').mockReturnValue([]); + + const cleanup = () => { + createSpy.mockRestore(); + markersSpy.mockRestore(); + getModelMarkersSpy.mockRestore(); + }; + + return { cleanup }; +}; + describe('react monaco editor', () => { + let cleanupMonaco: (() => void) | undefined; + + beforeEach(() => { + const { cleanup } = setupMonacoEditorHarness({ + onPushUndoStop: jest.fn(), + }); + cleanupMonaco = cleanup; + }); + + afterEach(() => { + cleanupMonaco?.(); + cleanupMonaco = undefined; + }); + beforeAll(() => { jest.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation( (contextId, options) => @@ -50,11 +134,152 @@ describe('react monaco editor', () => { render(); - expect(defineThemeSpy).toHaveBeenCalled(); - expect(defineThemeSpy).toHaveBeenCalledWith(CODE_EDITOR_DEFAULT_THEME_ID, expect.any(Object)); - expect(defineThemeSpy).toHaveBeenCalledWith( - CODE_EDITOR_TRANSPARENT_THEME_ID, - expect.any(Object) + return waitFor(() => { + expect(defineThemeSpy).toHaveBeenCalled(); + expect(defineThemeSpy).toHaveBeenCalledWith(CODE_EDITOR_DEFAULT_THEME_ID, expect.any(Object)); + expect(defineThemeSpy).toHaveBeenCalledWith( + CODE_EDITOR_TRANSPARENT_THEME_ID, + expect.any(Object) + ); + }); + }); + + it('uses defaultValue when value is undefined (uncontrolled mode)', async () => { + const originalCreateModel = monaco.editor.createModel.bind(monaco.editor); + let firstArg: unknown; + const createModelSpy = jest + .spyOn(monaco.editor, 'createModel') + .mockImplementation((...args) => { + firstArg = args[0]; + return originalCreateModel(...args); + }); + + render(); + + await waitFor(() => expect(firstArg).toBe('fallback')); + + createModelSpy.mockRestore(); + }); +}); + +describe('react monaco editor onChange performance', () => { + let lastOnDidChangeModelContentCb: + | ((e: monaco.editor.IModelContentChangedEvent) => void) + | undefined; + + beforeEach(() => { + lastOnDidChangeModelContentCb = undefined; + jest.clearAllMocks(); + }); + + it('computes the next value from event.changes (including multiple changes)', async () => { + const editorPushUndoStop = jest.fn(); + + let createdModel: monaco.editor.ITextModel | undefined; + const { cleanup } = setupMonacoEditorHarness({ + onDidChangeModelContent: (cb) => { + lastOnDidChangeModelContentCb = cb; + }, + onPushUndoStop: editorPushUndoStop, + onCreateModel: (model) => { + createdModel = model; + }, + }); + + const onChange = jest.fn(); + + render( + + ); + + await waitFor(() => expect(typeof lastOnDidChangeModelContentCb).toBe('function')); + + // Two changes, deliberately provided out of order (ascending offsets) to ensure + // the implementation sorts descending to avoid offset shifting. + const range = createRange(); + const event = createEvent([ + { range, rangeOffset: 2, rangeLength: 2, text: 'XXXX' }, + { range, rangeOffset: 7, rangeLength: 1, text: 'Y' }, + ]); + lastOnDidChangeModelContentCb!(event); + + expect(onChange).toHaveBeenCalledWith('abXXXXefgYij', event); + expect(createdModel).toBeDefined(); + + cleanup(); + }); + + it('does not pushEditOperations for controlled rerenders when value matches last known value', async () => { + const originalCreateModel = monaco.editor.createModel.bind(monaco.editor); + let pushEditOperationsSpy: jest.SpyInstance | undefined; + const createModelSpy = jest + .spyOn(monaco.editor, 'createModel') + .mockImplementation((...args) => { + const model = originalCreateModel(...args); + pushEditOperationsSpy = jest.spyOn(model, 'pushEditOperations'); + return model; + }); + + const editorPushUndoStop = jest.fn(); + const { cleanup } = setupMonacoEditorHarness({ + onDidChangeModelContent: (cb) => { + lastOnDidChangeModelContentCb = cb; + }, + onPushUndoStop: editorPushUndoStop, + }); + + const onChange = jest.fn(); + const { rerender } = render( + ); + + await waitFor(() => expect(typeof lastOnDidChangeModelContentCb).toBe('function')); + + const range = createRange(); + const event = createEvent([{ range, rangeOffset: 2, rangeLength: 2, text: 'XXXX' }]); + lastOnDidChangeModelContentCb!(event); + + expect(onChange).toHaveBeenCalledWith('abXXXXefghij', event); + + rerender(); + + expect(pushEditOperationsSpy).toBeDefined(); + expect(pushEditOperationsSpy!).not.toHaveBeenCalled(); + expect(editorPushUndoStop).not.toHaveBeenCalled(); + + createModelSpy.mockRestore(); + cleanup(); + }); + + it('pushes a full replace when controlled value changes externally', async () => { + const originalCreateModel = monaco.editor.createModel.bind(monaco.editor); + let pushEditOperationsSpy: jest.SpyInstance | undefined; + const createModelSpy = jest + .spyOn(monaco.editor, 'createModel') + .mockImplementation((...args) => { + const model = originalCreateModel(...args); + pushEditOperationsSpy = jest.spyOn(model, 'pushEditOperations'); + return model; + }); + + const editorPushUndoStop = jest.fn(); + const { cleanup } = setupMonacoEditorHarness({ + onPushUndoStop: editorPushUndoStop, + }); + + const onChange = jest.fn(); + const { rerender } = render(); + + await waitFor(() => + expect(document.querySelector('.react-monaco-editor-container')).toBeTruthy() + ); + rerender(); + + expect(pushEditOperationsSpy).toBeDefined(); + expect(pushEditOperationsSpy!).toHaveBeenCalledTimes(1); + expect(editorPushUndoStop).toHaveBeenCalledTimes(2); + + createModelSpy.mockRestore(); + cleanup(); }); }); diff --git a/src/platform/packages/shared/shared-ux/code_editor/impl/react_monaco_editor/editor.tsx b/src/platform/packages/shared/shared-ux/code_editor/impl/react_monaco_editor/editor.tsx index e60fce3175079..a031deb1bf015 100644 --- a/src/platform/packages/shared/shared-ux/code_editor/impl/react_monaco_editor/editor.tsx +++ b/src/platform/packages/shared/shared-ux/code_editor/impl/react_monaco_editor/editor.tsx @@ -124,6 +124,23 @@ export interface MonacoEditorProps { onChange?: ChangeHandler; } +const applyModelContentChanges = ( + prevValue: string, + changes: monacoEditor.editor.IModelContentChange[] +): string => { + // Monaco reports offsets and lengths relative to the *previous* value. When multiple changes are + // present (e.g. multi-cursor edits), apply them from the end of the string towards the start so + // earlier edits don't shift the offsets of later ones. + const sortedChanges = [...changes].sort((a, b) => b.rangeOffset - a.rangeOffset); + + return sortedChanges.reduce((acc, change) => { + const start = change.rangeOffset; + // `rangeLength` is the number of chars to replace from the previous value. + const end = change.rangeOffset + change.rangeLength; + return acc.slice(0, start) + change.text + acc.slice(end); + }, prevValue); +}; + // initialize supported languages initializeSupportedLanguages(); @@ -157,6 +174,18 @@ export function MonacoEditor({ const onChangeRef = useRef(onChange); onChangeRef.current = onChange; + /** + * For large models, `editor.getValue()` can be very expensive because it materializes the + * full buffer. Keep a shadow copy of the latest value so we can apply incremental edits + * using `IModelContentChangedEvent` without forcing a full `getValue()` on every keystroke. + */ + const lastKnownValueRef = useRef(value ?? defaultValue); + useEffect(() => { + if (typeof value === 'string') { + lastKnownValueRef.current = value; + } + }, [value]); + const style = useMemo( () => ({ width: fixedWidth, @@ -175,7 +204,15 @@ export function MonacoEditor({ _subscription.current = editor.current!.onDidChangeModelContent((event) => { if (!__preventTriggerChangeEvent.current) { - onChangeRef.current?.(editor.current!.getValue(), event); + const onChangeHandler = onChangeRef.current; + if (!onChangeHandler) { + return; + } + + // Apply incremental changes to the shadow value (avoid `editor.getValue()` in hot path). + const nextValue = applyModelContentChanges(lastKnownValueRef.current, event.changes); + lastKnownValueRef.current = nextValue; + onChangeHandler(nextValue, event); } }); }; @@ -200,7 +237,8 @@ export function MonacoEditor({ }, [euiTheme]); const initMonaco = () => { - const finalValue = value !== null ? value : defaultValue; + // Treat `null`/`undefined` as uncontrolled, per the prop contract. + const finalValue = value ?? defaultValue; if (containerElement.current) { // Before initializing monaco editor @@ -246,7 +284,9 @@ export function MonacoEditor({ // useLayoutEffect instead of useEffect to mitigate https://github.com/facebook/react/issues/31023 in React@18 Legacy Mode useLayoutEffect(() => { if (editor.current) { - if (value === editor.current.getValue()) { + // In controlled mode, `value` changes on every keystroke. Avoid calling `editor.getValue()` + // (which materializes the full model) by comparing against our shadow copy first. + if (typeof value !== 'string' || value === lastKnownValueRef.current) { return; } @@ -267,6 +307,9 @@ export function MonacoEditor({ ); editor.current.pushUndoStop(); __preventTriggerChangeEvent.current = false; + + // Keep shadow state in sync for programmatic updates where we suppress onDidChangeModelContent. + lastKnownValueRef.current = value; } }, [value]); diff --git a/src/platform/test/functional/apps/console/_misc_console_behavior.ts b/src/platform/test/functional/apps/console/_misc_console_behavior.ts index d9d17fe32bd8d..5b3907e25c083 100644 --- a/src/platform/test/functional/apps/console/_misc_console_behavior.ts +++ b/src/platform/test/functional/apps/console/_misc_console_behavior.ts @@ -13,6 +13,7 @@ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs'; import { resolve } from 'path'; import type { FtrProviderContext } from '../../ftr_provider_context'; import { LARGE_INPUT } from './large_input'; +import { QUOTE_HEAVY_INPUT } from './quote_heavy_input'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); @@ -285,21 +286,55 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); writeFileSync(filePath, LARGE_INPUT, 'utf8'); - // Set file to upload and wait for the editor to be updated - await PageObjects.console.setFileToUpload(filePath); - await PageObjects.console.acceptFileImport(); - await PageObjects.common.sleep(1000); - - // The autocomplete should still show up without causing stack overflow - await PageObjects.console.enterText(`GET _search\n`); - await PageObjects.console.enterText(`{\n\t"query": {`); - await PageObjects.console.pressEnter(); - await PageObjects.console.sleepForDebouncePeriod(); - await PageObjects.console.promptAutocomplete(); - expect(await PageObjects.console.isAutocompleteVisible()).to.be.eql(true); - - // Clean up input file - unlinkSync(filePath); + try { + // Set file to upload and wait for the editor to be updated + await PageObjects.console.setFileToUpload(filePath); + await PageObjects.console.acceptFileImport(); + await PageObjects.common.sleep(1000); + + // The autocomplete should still show up without causing stack overflow + await PageObjects.console.enterText(`GET _search\n`); + await PageObjects.console.enterText(`{\n\t"query": {`); + await PageObjects.console.pressEnter(); + await PageObjects.console.sleepForDebouncePeriod(); + await PageObjects.console.promptAutocomplete(); + expect(await PageObjects.console.isAutocompleteVisible()).to.be.eql(true); + } finally { + unlinkSync(filePath); + } + }); + + it('should remain responsive with quote-heavy JSON payloads', async () => { + // This test targets the specific freeze scenario fixed in the ES|QL context detection. + // Before the fix, pasting JSON with many escaped quotes (e.g., serialized error messages) + // caused super-linear runtime in checkForTripleQuotesAndEsqlQuery, freezing the editor. + await PageObjects.console.clearEditorText(); + + const filePath = resolve( + REPO_ROOT, + `target/functional-tests/downloads/console_import_quote_heavy_input` + ); + writeFileSync(filePath, QUOTE_HEAVY_INPUT, 'utf8'); + + try { + // Set file to upload and wait for the editor to be updated + await PageObjects.console.setFileToUpload(filePath); + await PageObjects.console.acceptFileImport(); + await PageObjects.common.sleep(1000); + + // Ensure we start a new request after the imported payload (it may not end with a newline). + await PageObjects.console.pressEnter(); + + // Reuse the same request shape as the existing "large content" test to guarantee suggestions exist. + await PageObjects.console.enterText(`GET _search\n`); + await PageObjects.console.enterText(`{\n\t"query": {`); + await PageObjects.console.pressEnter(); + await PageObjects.console.sleepForDebouncePeriod(); + await PageObjects.console.promptAutocomplete(); + expect(await PageObjects.console.isAutocompleteVisible()).to.be.eql(true); + } finally { + unlinkSync(filePath); + } }); }); } diff --git a/src/platform/test/functional/apps/console/quote_heavy_input.ts b/src/platform/test/functional/apps/console/quote_heavy_input.ts new file mode 100644 index 0000000000000..cb55f0b2c8bd7 --- /dev/null +++ b/src/platform/test/functional/apps/console/quote_heavy_input.ts @@ -0,0 +1,344 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** + * A quote-heavy payload that triggers super-linear ES|QL context detection + * before the fix in checkForTripleQuotesAndEsqlQuery. Used to verify the + * editor remains responsive after pasting large JSON with many escaped quotes. + */ +export const QUOTE_HEAVY_INPUT = String.raw`POST _ingest/pipeline/_simulate +{ + "docs": [ + { + "_source": { + "@timestamp": "xQ9mP7vK2ZtR4nL1aB8cD6eF3gH0jS", + "eventData": { + "__original": "5\"wYxQ9mP7vK\"2Zt\"R4nL1aB8c\"D\"6eF3gH\"0\"jS5wYxQ9\"mP\"7vK2ZtR4n\"L1aB8cD\"6eF3gH0\"jS5wYxQ9m\"P7vK2ZtR4\"nL1aB8c\"D6eF3gH\"0jS5wYxQ9m\"P7vK2ZtR4\"nL1\"aB8cD6e\"F3g\"H0jS5wYx\"Q9m\"P7vK2Z\"tR4nL1\"aB8cD6e\"F3g\"H0jS5wYx\"Q9m\"P7vK2Zt\"R4nL1a\"B8cD6eF\"3gH0j\"S5wYxQ9\"m\"P7vK2ZtR4nL1aB8c\"D6\"eF3gH\"0jS5w\"YxQ9mP7v\"K2Z\"tR4\"n\"L1aB8\"c\"D6eF\"3gH\"0jS5wYxQ9\"mP7vK\"2ZtR4nL1a\"B8cD\"6eF3gH0jS5\"wYxQ9mP\"7vK2ZtR4nL\"1aB8cD6\"eF3g\"H0jS5\"wYxQ\"9\"mP7vK2ZtR4nL1aB8cD6eF3g\"H\"0jS5wYx\"Q9mP7v\"K2ZtR4n\"L1aB8c\"D6eF3gH\"0\"jS5wYxQ9mP7vK2Z\"t\"R4nL1aB8cD\"6eF3gH\"0jS5wYxQ9mP7v\"K2ZtR4n\"L1aB\"8\"cD6eF\"3\"gH0jS5wY\"x\"Q9mP7vK2ZtR4nL1aB8cD\"6\"eF3gH0jS5w\"Y\"xQ9mP7vK2ZtR4\"n\"L1aB8cD6e\"F3g\"H0jS5wYxQ9mP7vK2ZtR\"4nL1aB8cD\"6eF3gH0jS\"5w\"YxQ9mP7v\"K2ZtR\"4nL1aB8cD6eF3gH\"0jS\"5wY\"xQ9\"mP7vK\"2ZtR4\"nL1\"aB8c\"D6eF3\"gH0jS\"5wY\"xQ9m\"P7vK2\"ZtR4n\"L1a\"B8cD\"6eF3g\"H0jS5w\"YxQ\"9mP7\"vK2Zt\"R4nL1\"aB8\"cD6e\"F3gH0\"jS5wYxQ\"9mP\"7vK2Z\"tR4nL\"1aB8cD\"6eF\"3gH0j\"S5wYx\"Q9mP7\"vK2\"ZtR4n\"L1aB8\"cD6eF\"3gH\"0jS5wY\"xQ9mP\"7vK2Z\"tR4\"nL1aB8\"cD6eF\"3gH0j\"S5wYxQ9mP7vK2Zt\"R4n\"L1a\"B8c\"D6eF3\"gH0jS\"5wY\"xQ9m\"P7vK2\"ZtR4n\"L1a\"B8cD\"6eF3g\"H0jS5\"wYx\"Q9mP\"7vK2Z\"tR4nL\"1aB\"8cD6\"eF3gH\"0jS5w\"YxQ\"9mP7\"vK2Zt\"R4nL1\"aB8\"cD6eF\"3gH0j\"S5wYxQ9\"mP7\"vK2Zt\"R4nL1\"aB8cD\"6eF\"3gH0j\"S5wYx\"Q9mP7\"vK2\"ZtR4nL\"1aB8c\"D6eF3\"gH0\"jS5wYx\"Q9mP7\"vK2ZtR\"4nL1aB8cD\"6\"eF3gH0jS5wYxQ\"9\"mP7vK2ZtR\"4n\"L1aB8cD6\"eF3gH\"0jS5wYxQ9mP7v\"K2Z\"tR4\"nL1aB\"8cD6e\"F3gH0\"jS5\"wYxQ9\"mP7vK\"2ZtR4\"nL1\"aB8cD\"6eF3g\"H0jS5\"wYx\"Q9mP7\"vK2Zt\"R4nL1\"aB8\"cD6eF\"3gH0j\"S5wYxQ\"9mP\"7vK2Z\"tR4nL\"1aB8cD6\"eF3\"gH0jS\"5wYxQ\"9mP7v\"K2Z\"tR4nL\"1aB8c\"D6eF3\"gH0\"jS5wY\"xQ9mP\"7vK2Z\"tR4\"nL1\"aB8cD\"6eF3gH\"0jS5wY\"xQ9mP7\"vK2ZtR4nL\"1aB8c\"D6eF3gH\"0jS\"5wYxQ9mP7v\"K2Z\"tR4nL1aB8cD6e\"F3gH0jS\"5wY\"xQ9mP7vK2\"ZtR\"4\"nL\\1a\\\"\\B8cD6\"e\"F3gH\"0jS\"5wYxQ9mP7\"vK2Zt\"R4nL1aB8c\"D6eF3\"gH0jS5wYxQ\"9mP7vK2\"ZtR4nL1aB8\"cD6eF3g\"H0jS\"5wYxQ\"9mP7\"v\"K2ZtR4nL1aB8cD6\"e\"F3gH0jS\"5wYxQ9m\"P7vK2Zt\"R4nL1aB\"8cD6eF3\"g\"H0jS5wYxQ9mP7vK\"2\"ZtR4nL1aB8\"cD6eF3\"gH0jS5wYxQ9mP\"7vK2ZtR\"4nL1\"a\"B8cD6e\"F\"3gH0jS5w\"Y\"xQ9mP7vK2ZtR4nL1aB8c\"D\"6eF3gH0jS5\"w\"YxQ9mP7vK2ZtR\"4\"nL1aB8cD6\"eF3\"gH0jS5wYxQ9mP7vK2Zt\"R4nL1aB8c\"D6eF3gH0j\"S5\"wYxQ9mP7\"vK2Zt\"R4nL1aB8cD6eF3g\"H0j\"S5w\"YxQ\"9mP7v\"K2ZtR\"4nL\"1aB8\"cD6eF\"3gH0j\"S5w\"YxQ9\"mP7vK\"2ZtR4\"nL1\"aB8c\"D6eF3\"gH0jS\"5wY\"xQ9m\"P7vK2\"ZtR4n\"L1a\"B8cD\"6eF3g\"H0jS5\"wYx\"Q9mP7\"vK2Zt\"R4nL1aB\"8cD\"6eF3g\"H0jS5\"wYxQ9\"mP7\"vK2Zt\"R4nL1\"aB8cD\"6eF\"3gH0jS\"5wYxQ\"9mP7v\"K2Z\"tR4nL1\"aB8cD\"6eF3g\"H0jS5wYxQ9mP7vK\"2Zt\"R4n\"L1a\"B8cD6\"eF3gH0\"jS5\"wYxQ\"9mP7v\"K2ZtR\"4nL\"1aB8\"cD6eF\"3gH0j\"S5w\"YxQ9\"mP7vK\"2ZtR4\"nL1\"aB8c\"D6eF3\"gH0jS\"5wY\"xQ9m\"P7vK2\"ZtR4n\"L1a\"B8cD6\"eF3gH\"0jS5wYx\"Q9m\"P7vK2\"ZtR4n\"L1aB8\"cD6\"eF3gH\"0jS5w\"YxQ9m\"P7v\"K2ZtR4\"nL1aB\"8cD6e\"F3g\"H0jS5w\"YxQ9m\"P7vK2Z\"tR4nL1aB8\"c\"D6eF3gH0jS5wY\"x\"Q9mP7vK2Z\"tR\"4nL1aB8c\"D6eF3\"gH0jS5wYxQ9mP\"7vK\"2Zt\"R4nL1\"aB8cD\"6eF3g\"H0j\"S5wYx\"Q9mP7\"vK2Zt\"R4n\"L1aB8\"cD6eF\"3gH0j\"S5w\"YxQ9m\"P7vK2\"ZtR4n\"L1a\"B8cD6\"eF3gH\"0jS5w\"YxQ\"9mP7v\"K2ZtR\"4nL1a\"B8c\"D6eF3\"gH0jS\"5wYxQ9m\"P7v\"K2ZtR\"4nL1a\"B8cD6\"eF3\"gH0jS\"5wYxQ\"9mP7v\"K2Z\"tR4\"nL1aB\"8cD6eF\"3gH0jS\"5wYxQ9\"mP7vK2ZtR\"4nL1a\"B8cD6eF\"3gH\"0jS5wYxQ9m\"P7v\"K2Z\"tR4nL1\"aB8cD6eF3gH0j\"S5wYxQ9\"mP7\"vK2ZtR4n\"L1a\"B\"8cD6eF\"3\"gH0j\"S5w\"YxQ9mP7vK\"2ZtR4\"nL1aB8cD6\"eF3gH0j\"S5wYxQ9mP7\"vK2ZtR4\"nL1aB8cD6e\"F3gH0jS\"5wYx\"Q9mP7\"vK2Z\"t\"R4nL1aB8cD6eF3gH0jS5wYx\"Q\"9mP7vK2\"ZtR4nL1a\"B8cD6eF\"3gH0jS5wYx\"Q9mP7vK\"2\"ZtR4nL1aB8cD6eF\"3\"gH0jS5wYxQ\"9mP7vK\"2ZtR4nL1aB8cD\"6eF3gH0\"jS5w\"Y\"xQ9mP\"7\"vK2ZtR4n\"L\"1aB8cD6eF3gH0jS5wYxQ\"9\"mP7vK2ZtR4\"n\"L1aB8cD6eF3gH\"0\"jS5wYxQ9m\"P7v\"K2ZtR4nL1aB8cD6eF3g\"H0jS5wYxQ\"9mP7vK2Zt\"R4\"nL1aB8cD\"6eF3g\"H0jS5wYxQ9mP7vK\"2Zt\"R4n\"L1a\"B8cD6\"eF3gH\"0jS\"5wYx\"Q9mP7\"vK2Zt\"R4n\"L1aB\"8cD6e\"F3gH0\"jS5\"wYxQ\"9mP7v\"K2ZtR\"4nL\"1aB8\"cD6eF\"3gH0j\"S5w\"YxQ9\"mP7vK\"2ZtR4\"nL1\"aB8cD\"6eF3g\"H0jS5wY\"xQ9\"mP7vK\"2ZtR4\"nL1aB\"8cD\"6eF3g\"H0jS5\"wYxQ9\"mP7\"vK2ZtR\"4nL1a\"B8cD6\"eF3\"gH0jS5\"wYxQ9\"mP7vK\"2ZtR4nL1aB8cD6e\"F3g\"H0j\"S5w\"YxQ9m\"P7vK2Z\"tR4\"nL1a\"B8cD6\"eF3gH\"0jS\"5wYx\"Q9mP7\"vK2Zt\"R4n\"L1aB\"8cD6e\"F3gH0\"jS5\"wYxQ\"9mP7v\"K2ZtR\"4nL\"1aB8\"cD6eF\"3gH0j\"S5w\"YxQ9m\"P7vK2\"ZtR4nL1\"aB8\"cD6eF\"3gH0j\"S5wYx\"Q9m\"P7vK2\"ZtR4n\"L1aB8\"cD6\"eF3gH0\"jS5wY\"xQ9mP\"7vK\"2ZtR4n\"L1aB8\"cD6eF3\"gH0jS5wYx\"Q\"9mP7vK2ZtR4nL\"1\"aB8cD6eF3\"gH\"0jS5wYxQ\"9mP7v\"K2ZtR4nL1aB8c\"D6e\"F3g\"H0jS5\"wYxQ9\"mP7vK\"2Zt\"R4nL1\"aB8cD\"6eF3g\"H0j\"S5wYx\"Q9mP7\"vK2Zt\"R4n\"L1aB8\"cD6eF\"3gH0j\"S5w\"YxQ9m\"P7vK2\"ZtR4n\"L1a\"B8cD6\"eF3gH\"0jS5wYx\"Q9m\"P7vK2\"ZtR4n\"L1aB8c\"D6e\"F3gH0\"jS5wY\"xQ9mP\"7vK\"2ZtR4\"nL1aB\"8cD6e\"F3g\"H0j\"S5wYx\"Q9mP7v\"K2ZtR4\"nL1aB8\"cD6eF3gH0\"jS5wYxQ\"9mP7vK2\"ZtR\"4nL1aB8cD6\"eF3\"gH0jS5wYxQ9mP\"7vK2ZtR\"4nL\"1aB8cD6eF\"3gH\"0\"jS5wYx\"Q\"9mP7\"vK2\"ZtR4nL1aB\"8cD6e\"F3gH0jS5w\"YxQ9m\"P7vK2ZtR4n\"L1aB8cD6\"eF3gH0jS5w\"YxQ9mP7\"vK2Z\"tR4nL\"1aB8\"c\"D6eF3gH0jS5wYxQ9mP7vK2ZtR\"4\"nL1aB8c\"D6eF3gH0\"jS5wYxQ\"9mP7vK2\"ZtR4nL1\"a\"B8cD6eF3gH0jS5w\"Y\"xQ9mP7vK2Z\"tR4nL1\"aB8cD6eF3gH0j\"S5wYxQ9m\"P7vK\"2\"ZtR4n\"L\"1aB8cD6e\"F\"3gH0jS5wYxQ9mP7vK2Zt\"R\"4nL1aB8cD6\"e\"F3gH0jS5wYxQ9\"m\"P7vK2ZtR4\"nL1\"aB8cD6eF3gH0jS5wYxQ\"9mP7vK2Zt\"R4nL1aB8c\"D6\"eF3gH0jS\"5wYxQ\"9mP7vK2ZtR4nL1a\"B8c\"D6e\"F3g\"H0jS5\"wYxQ9\"mP7\"vK2Z\"tR4nL\"1aB8c\"D6e\"F3gH\"0jS5w\"YxQ9m\"P7v\"K2Zt\"R4nL1\"aB8cD\"6eF\"3gH0\"jS5wY\"xQ9mP\"7vK\"2ZtR\"4nL1a\"B8cD6\"eF3\"gH0jS\"5wYxQ\"9mP7vK\"2Zt\"R4nL1\"aB8cD\"6eF3gH\"0jS\"5wYxQ\"9mP7v\"K2ZtR\"4nL\"1aB8cD\"6eF3g\"H0jS5\"wYx\"Q9mP7v\"K2ZtR\"4nL1a\"B8cD6eF3gH0jS5w\"YxQ\"9mP\"7vK\"2ZtR4\"nL1aB\"8cD\"6eF3\"gH0jS\"5wYxQ\"9mP\"7vK2\"ZtR4n\"L1aB8\"cD6\"eF3g\"H0jS5\"wYxQ9\"mP7\"vK2Z\"tR4nL\"1aB8c\"D6e\"F3gH\"0jS5w\"YxQ9m\"P7v\"K2ZtR\"4nL1a\"B8cD6eF\"3gH\"0jS5w\"YxQ9m\"P7vK2\"ZtR\"4nL1a\"B8cD6\"eF3gH\"0jS\"5wYxQ9\"mP7vK\"2ZtR4\"nL1\"aB8cD6\"eF3gH\"0jS5wY\"xQ9mP7vK2\"Z\"tR4nL1aB8cD6e\"F\"3gH0jS5wY\"xQ\"9mP7vK2Z\"tR4nL\"1aB8cD6eF3gH0\"jS5\"wYx\"Q9mP7\"vK2Zt\"R4nL1\"aB8\"cD6eF\"3gH0j\"S5wYx\"Q9m\"P7vK2\"ZtR4n\"L1aB8cD\"6eF\"3gH0j\"S5wYx\"Q9mP7v\"K2Z\"tR4nL\"1aB8c\"D6eF3\"gH0\"jS5wY\"xQ9mP\"7vK2Z\"tR4\"nL1aB\"8cD6e\"F3gH0\"jS5\"wYxQ9\"mP7vK\"2ZtR4\"nL1\"aB8cD\"6eF3g\"H0jS5\"wYx\"Q9m\"P7vK2\"ZtR4nL\"1aB8cD\"6eF3gH0\"jS5wYxQ9m\"P7vK2\"ZtR4nL1\"aB8\"cD6eF3gH0j\"S5w\"YxQ\"9mP7vK\"2ZtR4nL1aB8cD\"6eF3gH0\"jS5\"wYxQ9mP7v\"K2ZtR4n\"L1\"aB8cD6e\"F3gH\"0jS5wYxQ9\"m\"P7vK2ZtR4nL\"1\"aB8cD6eF3gH0j\"S5wYxQ9\"mP7vK2Zt\"R4\"nL1aB\"8cD\"6eF3g\"H0j\"S5wYxQ9\"mP7v\"K2ZtR4nL\"1aB8c\"D6eF3gH0jS\"5wY\"xQ9mP7vK2Z\"tR4n\"L1aB8cD6eF3gH0jS\"5wY\"xQ9\"mP7v\"K2ZtR\"4nL1a\"B8c\"D6eF\"3gH0j\"S5wYx\"Q9m\"P7vK\"2ZtR4\"nL1aB\"8cD\"6eF3\"gH0jS\"5wYxQ\"9mP\"7vK2\"ZtR4n\"L1aB8\"cD6\"eF3g\"H0jS5\"wYxQ9\"mP7\"vK2Z\"tR4nL\"1aB8c\"D6e\"F3gH\"0jS5w\"YxQ9m\"P7v\"K2Zt\"R4nL1\"aB8cD\"6eF3gH0\"jS5\"wYxQ9mP\"7\"vK2ZtR4nL1aB8cD6\"e\"F3gH0jS5wYxQ9mP7\"vK2\"ZtR\"4nL1\"aB8cD\"6eF3g\"H0j\"S5wY\"xQ9mP\"7vK2Z\"tR4\"nL1a\"B8cD6\"eF3gH\"0jS\"5wYx\"Q9mP7\"vK2Zt\"R4n\"L1aB\"8cD6e\"F3gH0\"jS5\"wYxQ\"9mP7v\"K2ZtR\"4nL\"1aB8\"cD6eF\"3gH0j\"S5w\"YxQ9\"mP7vK\"2ZtR4\"nL1\"aB8c\"D6eF3\"gH0jS5\"wYxQ9mP7vK2ZtR4\"nL1a\"B8cD6eF3gH\"0jS5wY\"xQ9mP7vK2\"Z\"tR4nL1\"a\"B8cD6eF3\"gH\"0jS5wYxQ9\"mP7vK2Z\"tR4nL1a\"B8cD6eF3gH\"0jS5wYxQ9\"mP7vK2\"ZtR4nL1\"aB8cD6eF\"3gH0jS5wY\"xQ9\"mP7vK2Z\"tR4\"nL1aB8cD\"6eF\"3gH0jS\"5wYxQ9m\"P7vK2Zt\"R4nL1\"aB8cD6eF\"3gH0j\"S5wYxQ9\"mP7vK2\"ZtR4nL1\"aB8cD6\"eF3gH0j\"S\"5wYxQ9mP7vK2ZtR4\"nL\"1aB8c\"D6eF3\"gH0jS5wY\"xQ9\"mP7\"v\"K2ZtR4\"n\"L1aB\"8cD\"6eF3gH0jS\"5wYxQ\"9mP7vK2Zt\"R4nL1a\"B8cD6eF3gH\"0jS5wYxQ\"9mP7vK2ZtR\"4nL1aB8c\"D6eF\"3gH0j\"S5wY\"x\"Q9mP7vK2Zt\"R\"4nL1aB8\"cD6eF3gH0j\"S5wYxQ9\"mP7vK2Zt\"R4nL1aB\"8\"cD6eF3gH0jS5wYx\"Q\"9mP7vK2ZtR\"4nL1aB\"8cD6eF3gH0jS5\"wYxQ9mP7\"vK2Z\"t\"R4nL1a\"B\"8cD6eF3g\"H\"0jS5wYxQ9mP7vK2ZtR4n\"L\"1aB8cD6eF3\"g\"H0jS5wYxQ9mP7\"v\"K2ZtR4nL1\"aB8\"cD6eF3gH0jS5wYxQ9mP\"7vK2ZtR4n\"L1aB8cD6e\"F3\"gH0jS5wY\"xQ9mP\"7vK2ZtR4nL1aB8c\"D6e\"F3g\"H0j\"S5wYx\"Q9mP7\"vK2\"ZtR4\"nL1aB\"8cD6e\"F3g\"H0jS\"5wYxQ\"9mP7v\"K2Z\"tR4n\"L1aB8\"cD6eF\"3gH\"0jS5\"wYxQ9\"mP7vK\"2Zt\"R4nL\"1aB8c\"D6eF3\"gH0\"jS5wY\"xQ9mP\"7vK2Z\"tR4\"nL1aB\"8cD6e\"F3gH0\"jS5\"wYxQ9\"mP7vK\"2ZtR4nL\"1aB\"8cD6eF\"3gH0j\"S5wYxQ\"9mP\"7vK2Zt\"R4nL1\"aB8cD\"6eF3gH0jS5wYxQ9\"mP7\"vK2\"ZtR\"4nL1a\"B8cD6\"eF3\"gH0j\"S5wYx\"Q9mP7\"vK2\"ZtR4\"nL1aB\"8cD6e\"F3g\"H0jS\"5wYxQ\"9mP7v\"K2Z\"tR4n\"L1aB8\"cD6eF\"3gH\"0jS5\"wYxQ9\"mP7vK\"2Zt\"R4nL1\"aB8cD\"6eF3g\"H0j\"S5wYx\"Q9mP7\"vK2Zt\"R4n\"L1aB8\"cD6eF\"3gH0jS5\"wYx\"Q9mP7v\"K2ZtR\"4nL1a\"B8c\"D6eF3g\"H0jS5\"wYxQ9m\"P7vK2ZtR4\"n\"L1aB8cD6eF3gH\"0\"jS5wYxQ9m\"P7\"vK2ZtR4n\"L1aB8\"cD6eF3gH0jS5w\"YxQ\"9mP\"7vK2Z\"tR4nL\"1aB8c\"D6e\"F3gH0\"jS5wY\"xQ9mP\"7vK\"2ZtR4\"nL1aB\"8cD6e\"F3g\"H0jS5\"wYxQ9\"mP7vK\"2Zt\"R4nL1\"aB8cD\"6eF3g\"H0j\"S5wYx\"Q9mP7\"vK2ZtR4\"nL1\"aB8cD\"6eF3g\"H0jS5w\"YxQ\"9mP7v\"K2ZtR\"4nL1aB\"8cD\"6eF3g\"H0jS5\"wYxQ9\"mP7\"vK2\"ZtR4n\"L1aB8c\"D6eF3g\"H0jS5wY\"xQ9mP7vK2\"ZtR4nL\"1aB8cD6\"eF3\"gH0jS5wYxQ\"9mP\"7vK\"2ZtR4n\"L1aB8cD6eF3gH\"0jS5wYxQ\"9mP\"7vK2ZtR4n\"L1aB8cD\"6e\"F3gH0jS\"5wYx\"Q9mP7vK2Z\"t\"R4nL1aB8cD6\"e\"F3gH0jS5wYxQ9\"mP7vK2Z\"tR4nL1aB\"8c\"D6eF3\"gH0\"jS5wY\"xQ9\"mP7vK2Z\"tR4n\"L1aB8cD6\"eF3gH\"0jS5wYxQ9m\"P7v\"K2ZtR4nL1a\"B8cD\"6eF3gH0jS5wYxQ9m\"P7v\"K2Z\"tR4n\"L1aB8\"cD6eF\"3gH\"0jS5\"wYxQ9\"mP7vK\"2Zt\"R4nL\"1aB8c\"D6eF3\"gH0\"jS5w\"YxQ9m\"P7vK2\"ZtR\"4nL1\"aB8cD\"6eF3g\"H0j\"S5wY\"xQ9mP\"7vK2Z\"tR4\"nL1a\"B8cD6\"eF3gH\"0jS\"5wYx\"Q9mP7\"vK2Zt\"R4n\"L1aB\"8cD6e\"F3gH0\"jS5wYxQ\"9mP\"7vK2ZtR\"4\"nL1aB8cD6eF3gH0j\"S\"5wYxQ9mP7vK2ZtR4\"nL1\"aB8\"cD6e\"F3gH0\"jS5wY\"xQ9\"mP7v\"K2ZtR\"4nL1a\"B8c\"D6eF\"3gH0j\"S5wYx\"Q9m\"P7vK\"2ZtR4\"nL1aB\"8cD\"6eF3\"gH0jS\"5wYxQ\"9mP\"7vK2\"ZtR4n\"L1aB8\"cD6\"eF3g\"H0jS5\"wYxQ9\"mP7\"vK2Z\"tR4nL\"1aB8c\"D6e\"F3gH\"0jS5w\"YxQ9mP\"7vK2ZtR4nL1aB8c\"D6eF\"3gH0jS5wYx\"Q9mP7v\"K2ZtR4nL1\"a\"B8cD6eF3gH0jS\"5wY\"xQ9mP7vK2\"Z\"tR4nL1aB8cD6e\"F3g\"H0jS5wYxQ\"9\"mP7vK2ZtR4nL1\"aB8\"cD6eF3gH0\"j\"S5wYxQ9mP7vK2Zt\"R4n\"L1aB8cD6e\"F\"3gH0jS5wYxQ9mP7\"vK2\"ZtR4nL1aB\"8\"cD6eF3gH\"0jS\"5wYxQ9\"mP7vK2ZtR\"4nL1aB8cD\"6eF\"3gH0jS\"5w\"YxQ9\"mP7vK2ZtR4n\"L1aB8cD6eF3g\"H0jS5wYxQ\"9mP7vK2ZtR4n\"L1aB8cD6e\"F3gH0jS5wY\"x\"Q9mP7vK2ZtR4nL1aB8\"c\"D6eF3g\"H0jS5wYxQ9\"mP7vK\"2ZtR4nL1aB8\"cD6eF3gH0jS5wYxQ9m\"P7vK2ZtR4\"nL1aB8cD6\"eF3gH0jS5wYx\"Q9mP7vK2ZtR\"4nL\"1aB8cD6eF\"3\"gH0jS5\"w\"YxQ9mP7\"vK2\"ZtR4nL1aB\"8cD\"6eF3gH0j\"S5w\"YxQ9mP7vK\"2Zt\"R4nL1aB8cD6eF3g\"H0j\"S5wYxQ9mP7v\"K2Z\"tR4nL1a\"B8c\"D6eF3gH0j\"S5w\"YxQ9mP7v\"K2Z\"tR4nL1aB8\"cD6eF\"3gH0jS5wY\"x\"Q9mP7vK\"2\"ZtR4nL1\"aB8\"cD6eF3gH0\"jS5\"wYxQ9mP7\"vK2\"ZtR4nL1aB\"8cD\"6eF3gH0jS5wYxQ9\"mP7\"vK2ZtR4nL1a\"B8c\"D6eF3gH\"0jS\"5wYxQ9mP7\"vK2\"ZtR4nL1a\"B8c\"D6eF3gH0j\"S5wYx\"Q9mP7vK2Z\"t\"R4nL\"1\"aB8cD6e\"F3gH0jS5\"wYxQ9mP7v\"K2ZtR4\"nL1aB8cD\"6eF\"3gH0jS5wY\"xQ9\"mP7vK2ZtR4nL1aB\"8cD\"6eF3gH0jS5w\"YxQ\"9mP7vK2\"ZtR4nL1a\"B8cD6eF3g\"H0jS5w\"YxQ9mP7v\"K2Z\"tR4nL1aB8\"cD6eF\"3gH0jS5wY\"x\"Q9mP7vK\"2\"ZtR4nL1\"aB8\"cD6eF3gH0\"jS5\"wYxQ9mP7\"vK2\"ZtR4nL1aB\"8cD\"6eF3gH0jS5wYxQ9\"mP7\"vK2ZtR4nL1a\"B8c\"D6eF3gH\"0jS\"5wYxQ9mP7\"vK2\"ZtR4nL1a\"B8c\"D6eF3gH0j\"S5wYx\"Q9mP7vK2Z\"t\"R4nL1\"a\"B8cD6eF\"3gH\"0jS5wYxQ9\"mP7\"vK2ZtR4n\"L1a\"B8cD6eF3g\"H0j\"S5wYxQ9mP7vK2Zt\"R4n\"L1aB8cD6eF3\"gH0\"jS5wYxQ\"9mP\"7vK2ZtR4n\"L1a\"B8cD6eF3\"gH0\"jS5wYxQ9m\"P7vK2\"ZtR4nL1aB\"8\"cD6eF\"3\"gH0jS5w\"YxQ9mP7v\"K2ZtR4nL1\"aB8cD6\"eF3gH0jS\"5wY\"xQ9mP7vK2\"ZtR\"4nL1aB8cD6eF3gH\"0jS\"5wYxQ9mP7vK\"2ZtR\"4nL1aB8\"cD6eF3gH0j\"S5wYxQ9mP\"7vK2ZtR\"4nL1aB8c\"D6e\"F3gH0jS5w\"YxQ9m\"P7vK2ZtR4\"n\"L1aB8c\"D\"6eF3gH0\"jS5wYxQ9\"mP7vK2ZtR\"4nL1aB\"8cD6eF3g\"H0j\"S5wYxQ9mP\"7vK\"2ZtR4nL1aB8cD6e\"F3g\"H0jS5wYxQ9m\"P7v\"K2ZtR4n\"L1aB8cD6\"eF3gH0jS5\"wYxQ9m\"P7vK2ZtR\"4nL\"1aB8cD6eF\"3gH0j\"S5wYxQ9mP\"7\"vK2ZtR\"4\"nL1aB8c\"D6e\"F3gH0jS5w\"YxQ\"9mP7vK2Z\"tR4\"nL1aB8cD6\"eF3\"gH0jS5wYxQ9mP7v\"K2Z\"tR4nL1aB8cD\"6eF\"3gH0jS5\"wYx\"Q9mP7vK2Z\"tR4\"nL1aB8cD\"6eF\"3gH0jS5wY\"xQ9mP\"7vK2ZtR4n\"L\"1aB8cD6\"e\"F3gH0jS\"5wY\"xQ9mP7vK2\"ZtR\"4nL1aB8c\"D6e\"F3gH0jS5w\"YxQ\"9mP7vK2ZtR4nL1a\"B8c\"D6eF3gH0jS5\"wYx\"Q9mP7vK\"2Zt\"R4nL1aB8c\"D6e\"F3gH0jS5\"wYx\"Q9mP7vK2Z\"tR4nL\"1aB8cD6eF\"3\"gH0jS5\"w\"YxQ9mP7\"vK2\"ZtR4nL1aB\"8cD\"6eF3gH0j\"S5w\"YxQ9mP7vK\"2Zt\"R4nL1aB8cD6eF3g\"H0j\"S5wYxQ9mP7v\"K2Z\"tR4nL1a\"B8c\"D6eF3gH0j\"S5w\"YxQ9mP7v\"K2Z\"tR4nL1aB8\"cD6eF\"3gH0jS5wY\"x\"Q9mP7vK\"2\"ZtR4nL1\"aB8\"cD6eF3gH0\"jS5\"wYxQ9mP7\"vK2\"ZtR4nL1aB\"8cD\"6eF3gH0jS5wYxQ9\"mP7\"vK2ZtR4nL1a\"B8c\"D6eF3gH\"0jS\"5wYxQ9mP7\"vK2\"ZtR4nL1a\"B8c\"D6eF3gH0j\"S5wYx\"Q9mP7vK2Z\"t\"R4nL\"1\"aB8cD6e\"F3g\"H0jS5wYxQ\"9mP\"7vK2ZtR4\"nL1\"aB8cD6eF3\"gH0\"jS5wYxQ9mP7vK2Z\"tR4\"nL1aB8cD6eF\"3gH\"0jS5wYx\"Q9m\"P7vK2ZtR4\"nL1\"aB8cD6eF\"3gH\"0jS5wYxQ9\"mP7vK\"2ZtR4nL1a\"B\"8cD6e\"F\"3gH0jS5\"wYx\"Q9mP7vK2Z\"tR4\"nL1aB8cD\"6eF\"3gH0jS5wY\"xQ9\"mP7vK2ZtR4nL1aB\"8cD\"6eF3gH0jS5w\"YxQ\"9mP7vK2\"ZtR\"4nL1aB8cD\"6eF\"3gH0jS5w\"YxQ\"9mP7vK2Zt\"R4nL1\"aB8cD6eF3\"g\"H0jS5w\"Y\"xQ9mP7v\"K2ZtR4nL\"1aB8cD6eF\"3gH0jS\"5wYxQ9mP\"7vK\"2ZtR4nL1a\"B8c\"D6eF3gH0jS5wYxQ\"9mP\"7vK2ZtR4nL1\"aB8\"cD6eF3g\"H0jS5wYx\"Q9mP7vK2Z\"tR4nL1\"aB8cD6eF\"3gH\"0jS5wYxQ9\"mP7\"vK2ZtR4nL\"1a\"B8cD6eF3gH0\"jS5w\"YxQ9mP7vK\"2\"ZtR4nL\"1\"aB8cD6e\"F3g\"H0jS5wYxQ\"9mP\"7vK2ZtR4\"nL1\"aB8cD6eF3\"gH0\"jS5wYxQ9mP7vK2Z\"tR4\"nL1aB8cD6eF\"3gH\"0jS5wYx\"Q9m\"P7vK2ZtR4\"nL1\"aB8cD6eF\"3gH\"0jS5wYxQ9\"mP7vK\"2ZtR4nL1a\"B\"8cD6eF\"3\"gH0jS5w\"YxQ\"9mP7vK2Zt\"R4n\"L1aB8cD6\"eF3\"gH0jS5wYx\"Q9m\"P7vK2ZtR4nL1aB8\"cD6\"eF3gH0jS5wY\"xQ9\"mP7vK2Z\"tR4\"nL1aB8cD6\"eF3\"gH0jS5wY\"xQ9\"mP7vK2ZtR\"4nL1a\"B8cD6eF3g\"H\"0jS5wY\"x\"Q9mP7vK\"2Zt\"R4nL1aB8c\"D6e\"F3gH0jS5\"wYx\"Q9mP7vK2Z\"tR4\"nL1aB8cD6eF3gH0\"jS5\"wYxQ9mP7vK2\"ZtR\"4nL1aB8\"cD6\"eF3gH0jS5\"wYx\"Q9mP7vK2\"ZtR\"4nL1aB8cD\"6eF3g\"H0jS5wYxQ\"9\"mP7vK2Z\"t\"R4nL1aB\"8cD\"6eF3gH0jS\"5wY\"xQ9mP7vK\"2Zt\"R4nL1aB8c\"D6e\"F3gH0jS5wYxQ9mP\"7vK\"2ZtR4nL1aB8\"cD6\"eF3gH0j\"S5w\"YxQ9mP7vK\"2Zt\"R4nL1aB8\"cD6\"eF3gH0jS5\"wYxQ9\"mP7vK2ZtR\"4\"nL1aB8cD6\"e\"F3gH0jS\"5wYxQ9mP\"7vK2ZtR4n\"L1aB8c\"D6eF3gH0\"jS5\"wYxQ9mP7v\"K2Z\"tR4nL1aB8cD6eF3\"gH0\"jS5wYxQ9mP7\"vK2\"ZtR4nL1\"aB8cD6eF\"3gH0jS5wY\"xQ9mP7\"vK2ZtR4n\"L1a\"B8cD6eF3g\"H0j\"S5wYxQ9mP\"7v\"K2ZtR4nL1aB8c\"D6eF\"3gH0jS5wY\"x\"Q9mP7\"v\"K2ZtR4n\"L1aB8cD6eF\"3gH0jS5wY\"xQ9mP7v\"K2ZtR4nL\"1aB\"8cD6eF3gH\"0jS\"5wYxQ9mP7vK2ZtR\"4nL\"1aB8cD6eF3g\"H0jS5\"wYxQ9mP\"7vK2ZtR4n\"L1aB8cD6e\"F3gH0jS\"5wYxQ9mP\"7vK\"2ZtR4nL1a\"B8cD6\"eF3gH0jS5wY\"xQ9mP\"7vK2ZtR4nL1aB8\"cD\"6eF3g\"H0jS5\"wYxQ9mP7vK2\"ZtR4n\"L1aB8cD6eF3gH0\"jS5w\"YxQ9mP7vK2\"Zt\"R4nL1\"aB8cD6e\"F3gH0j\"S5wYx\"Q9mP7vK2\"ZtR\"4nL1aB8cD6eF\"3gH0\"jS5wYxQ9\"mP\"7vK2ZtR4\"nL1aB\"8cD6eF3gH0jS\"5wY\"xQ9\"mP7v\"K2ZtR\"4nL1a\"B8c\"D6eF\"3gH0j\"S5wYx\"Q9m\"P7vK\"2ZtR4\"nL1aB8c\"D6e\"F3gH\"0jS5w\"YxQ9mP\"7vK\"2Zt\"R4nL1\"aB8cD\"6eF3gH0\"jS5w\"YxQ9mP7v\"K2Z\"tR4nL1aB\"8cD\"6eF3gH0jS\"5wYx" + }, + "ingest_lag_in_seconds": 1, + "kafka": { + "eventId": "Q9mP7vK2ZtR4nL1aB8cD6", + "moduleId": "eF3gH0jS5wY" + }, + "customerInfo": { + "customerId": "xQ9mP7vK2Zt", + "deviceId": "R4nL1aB8cD6eF3gH0jS5w", + "lineId": "YxQ9mP7vK2ZtR4", + "publicIp": "nL1aB8cD6eF3" + } + } + }, + { + "_index": "gH0jS5wYxQ9mP7vK2Zt", + "_source": { + "kafka": { + "eventId": "R4nL1aB8cD6e", + "moduleId": "F3gH0jS5w" + }, + "customerInfo": { + "customerId": "YxQ9mP7vK2Z", + "deviceId": "tR4nL1aB8cD6eF3gH0jS5", + "lineId": "wYxQ9mP7vK2ZtR", + "publicIp": "4nL1aB8cD6eF" + }, + "eventData": { + "__original": "3\"gH0jS5w\"Yx\"Q9m\"P\"7vK2ZtR4nL1aB8cD6eF3gH0jS5wYxQ9mP7vK2ZtR4nL1aB8cD6eF3gH0jS5\"w\"YxQ9mP7\"vK2\"ZtR4nL1aB8cD6e\"F3g\"H0jS5wYx\"Q9m\"P7vK2\"Z\"tR4nL1aB 8cD6eF 3g H0jS5wYx\"Q\"9mP7vK2ZtR4nL1aB8c\"D6eF3\"gH0jS5wY\"xQ9mP7\"vK2ZtR\"4nL1aB8\"cD6eF3gH0jS5wYxQ9mP7vK\"2ZtR\"4nL1aB8cD6eF3gH0j\"S5wYxQ9\"mP7vK2ZtR4n\"L1aB8cD\"6eF3gH\"0\"jS5wYxQ\"9\"mP7vK2ZtR4nL\"1aB8\"cD6eF3gH0\"j\"S5wYxQ9\"m\"P7vK2ZtR4nL1\"a\"B8cD6eF3g\"H\"0jS5wYxQ9mP7\"v\"K2ZtR4n\"L\"1aB8cD6eF3gH0\"jS5w\"YxQ9mP7vK2ZtR4nL1aB\"8cD6e\"F3gH0jS5wYx\"Q9mP7\"vK2ZtR4nL1\"aB8cD6" + } + } + }, + { + "_source": { + "eventData": { + "__original": "e\"F3gH0jS\"5w\"YxQ\"9\"mP7vK2ZtR4nL1aB8cD6eF3gH0jS5wYxQ9mP7vK2ZtR4nL1aB8cD6eF3gH0jS5wYx\"Q\"9mP7vK2\"ZtR\"4nL1aB8cD6eF3g\"H0j\"S5wYxQ9m\"P7v\"K2ZtR\"4\"nL1aB8cD 6eF3gH 0j S5wYxQ9mP7v K\"2\"ZtR4nL1aB8cD6eF3gH\"0jS5w\"YxQ9mP7v\"K2ZtR4\"nL1aB8\"cD6eF3g\"H0jS5wYxQ9mP7vK2ZtR4nL\"1aB8\"cD6eF3gH0jS5wYxQ9\"mP7vK2Z\"tR4nL1aB8cD\"6eF3gH0\"jS5wYx\"Q\"9mP7vK2\"Z\"tR4nL1aB8cD6\"eF3g\"H0jS5wYxQ\"9\"mP7vK2ZtR4nL\"1\"aB8cD6eF3gH0\"j\"S5wYxQ9mP\"7\"vK2ZtR4nL1aB\"8\"cD6eF3gH0jS5w\"Y\"xQ9mP7vK2ZtR4\"nL1\"aB8cD6eF3gH0jS5wYxQ\"9mP7\"vK2ZtR4nL1a\"B8cD\"6eF3gH0jS5wY\"xQ\"9mP7vK2Z\"t\"R4n\"L\"1aB8cD6eF3gH0jS\"5wYxQ9mP\"7vK2ZtR4nL1aB8c\"D6eF3gH\"0jS5wYx\"Q9\"mP7vK2Zt\"R\"4nL1a\"B8c\"D6eF3gH0jS\"5wYxQ9m" + }, + "customerInfo": { + "customerId": "P7vK2ZtR4nL", + "deviceId": "1aB8cD6eF3gH0jS5wYxQ9", + "lineId": "mP7vK2ZtR4nL1a", + "publicIp": "B8cD6eF3gH0j" + }, + "kafka": { + "eventId": "S5wYxQ9mP7vK", + "moduleId": "2ZtR4nL1a" + } + } + }, + { + "_source": { + "@timestamp": "B8cD6eF3gH0jS5wYxQ9mP7vK2ZtR4n", + "eventData": { + "__original": "L\"1aB8cD6eF3gH0jS5w\"YxQ9m\"P7vK2ZtR4nL1aB8cD6e\"F3gH0jS\"5wYxQ9mP7vK2ZtR4n\"L1aB8\"cD6eF3gH0jS5wYxQ9mP\"7vK2Zt\"R4nL1aB8\"cD6eF" + }, + "ingest_lag_in_seconds": 1, + "kafka": { + "eventId": "3gH0jS5w", + "moduleId": "YxQ9mP7vK" + }, + "@version": "2", + "customerInfo": { + "customerId": "ZtR4nL1aB8c", + "deviceId": "D6eF3gH0jS5wYxQ9mP7vK", + "lineId": "2ZtR4nL1aB8cD6", + "publicIp": "eF3gH0jS5wYx" + } + } + } + ], + "pipeline": { + "processors": [ + { + "rename": { + "field": "Q9mP7vK2ZtR4nL1aB8cD", + "target_field": "6eF3gH0jS5wYxQ" + } + }, + { + "lowercase": { + "field": "9mP7vK2ZtR4nL1", + "target_field": "aB8cD6eF3gH", + "ignore_missing": true + } + }, + { + "set": { + "field": "0jS5wYxQ9mP7", + "copy_from": "vK2ZtR4nL1aB8", + "ignore_empty_value": true + } + }, + { + "set": { + "field": "cD6eF3gH0", + "copy_from": "jS5wYxQ9mP7vK2ZtR4nL1", + "ignore_empty_value": true + } + }, + { + "script": { + "source": """ + aB8 cD6eF3gH0j S 5wYxQ9mP7vK2ZtR4 + nL1aB8cD6eF3gH0jS5wYxQ9mP7vK2ZtR4nL1aB8cD6eF3gH0jS5wYxQ9mP""", + "if": "7vK2ZtR4nL1aB8cD 6e F3gH 0j S5wYxQ9mP7vK2ZtR4nL 1a B8cD" + } + }, + { + "script": { + "description": "6eF3gH0 jS5wYxQ9 mP 7vK2ZtR4nL1 aB8cD6e F3gH 0jS", + "source": "5wYxQ9mP7vK2ZtR4nL1aB8cD6eF3gH0jS5wYxQ9mP7vK2ZtR4nL1aB8cD6eF3gH0j", + "if": "S5wYxQ9mP7vK2ZtR4nL1aB8cD6 eF 3gH0 jS 5wYxQ9mP7vK2ZtR4nL1aB8cD6eF3 gH0jS5wYxQ 9mP7vK2" + } + }, + { + "set": { + "field": "ZtR4nL1aB8cD6e", + "copy_from": "F3gH0jS5wYxQ9mP7vK2", + "ignore_empty_value": true + } + }, + { + "set": { + "field": "ZtR4nL1aB8cD6", + "copy_from": "eF3gH0jS5wYxQ9mP7vK2Zt", + "ignore_empty_value": true + } + }, + { + "rename": { + "field": "R4nL1aB8cD6eF3gH0jS5w", + "target_field": "YxQ9mP7vK2Zt", + "ignore_missing": true + } + }, + { + "script": { + "source": """ + R4nL1aB8cD6eF3gH0jS5wYxQ9mP7vK2ZtR4nL1aB8cD6eF3gH0jS5wYxQ9mP7vK2ZtR4nL1aB8cD6eF3gH0 jS5wYxQ9mP7v + """, + "if": "K2ZtR4nL1aB8cD6eF3gH0jS5wYxQ9mP7vK2Zt R4 nL1a" + } + }, + { + "rename": { + "field": "B8cD6eF3gH0jS5wYxQ9mP7vK2ZtR4nL1aB8cD6", + "target_field": "eF3gH0jS5wYxQ9mP7vK2ZtR4nL1aB8", + "ignore_missing": true + } + }, + { + "rename": { + "field": "cD6eF3gH0jS5wYxQ9mP7vK2ZtR4nL1aB8cD6eF", + "target_field": "3gH0jS5wYxQ9mP7vK2ZtR4nL1aB8", + "ignore_missing": true + } + }, + { + "rename": { + "field": "cD6eF3gH0jS5wYxQ9mP7vK2ZtR4nL1aB8cD6eF3", + "target_field": "gH0jS5wYxQ9mP7vK2ZtR4nL1aB8cD6", + "ignore_missing": true + } + }, + { + "rename": { + "field": "eF3gH0jS5wYxQ9mP7v", + "target_field": "K2ZtR4nL1aB8cD6eF3gH0jS5w", + "ignore_missing": true + } + }, + { + "rename": { + "field": "YxQ9mP7vK2ZtR4nL1aB8cD6eF3g", + "target_field": "H0jS5wYxQ9mP7vK2ZtR4nL1aB8cD", + "ignore_missing": true + } + }, + { + "rename": { + "field": "6eF3gH0jS5wYxQ9mP7vK2ZtR4nL1a", + "target_field": "B8cD6eF3gH0jS5wYxQ9mP7vK2ZtR4n", + "ignore_missing": true + } + }, + { + "rename": { + "field": "L1aB8cD6eF3gH0jS5wYxQ9mP7vK", + "target_field": "2ZtR4nL1aB8cD6eF3gH0jS5wYxQ9", + "ignore_missing": true + } + }, + { + "rename": { + "field": "mP7vK2ZtR4nL1aB8cD6eF3gH0jS5w", + "target_field": "YxQ9mP7vK2ZtR4nL1aB8cD6eF3gH0j", + "ignore_missing": true + } + }, + { + "script": { + "description": "S5wYxQ9mP 7vK2ZtR 4nL1aB8 cD6eF 3gH 0jS5wYx Q9mP7vK 2ZtR", + "lang": "4nL1aB8c", + "source": """ + D6 eF3gH0jS5wYxQ9mP7vK2ZtR4nL 1a B8cD6 e + F3g H0j S 5wYxQ9mP7vK2ZtR4nL1aB8cD6 + eF3gH0jS5w Y xQ9mP7vK2Z tR 4nL1 a B8c D 6eF3gH0jS5w + + YxQ9mP7vK2ZtR4n L 1 + aB8c D6eF3gH0jS5wYxQ9mP7vK + 2ZtR 4nL1aB8cD6eF3gH0jS5wY + xQ9mP 7vK2ZtR4nL1aB8cD6eF3g + H0 + + jS 5wY xQ9mP7vK2ZtR4n L1 a B8cD6 eF3g H 0j S + 5wYxQ9mP 7 vK2ZtR4n L1 aB8c D 6eF 3 gH0jS5wYx + Q9mP7vK2ZtR4 n L + 1aB8cD6e F3gH0jS5wYxQ9mP7vK2 Z tR4nL + 1a + B + + 8c D6 eF3gH0 jS5wYxQ + 9m P7vK2ZtR4nL1aB8cD6eF3gH0 jS 5wYxQ 9 + mP7 vK2 Z tR4nL1aB8cD6eF3gH0jS5wY + xQ9mP7vK2ZtR4nL1a B 8 + cD6eF3gH 0jS5wYxQ9m + P7vK2Zt R4nL1aB8c + D6eF3gH0j S5wYxQ9m P7vK2ZtR4nL1aB8 + cD6eF3g H + 0jS5wYxQ 9mP7vK2Zt R 4nL1aB8cD6eF3g + H0jS5w YxQ9mP7vK2ZtR4nL1a B 8cD6eF3gH0jS5w Y xQ9mP7vK2 + Z + tR + 4 + + nL 1a B8c D6eF3gH 0jS5wYxQ9m P7vK2Z + tR 4nL1aB8cD6eF3gH0jS5wYxQ9mP7vK 2Z tR4nL 1 + aB8 cD6eF 3g H0jS5wYxQ9mP7vK2ZtR4nL1aB8cD 6 + eF 3gH0jS5wYxQ9mP7 vK 2ZtR4nL1aB8c D + 6eF3gH0jS5w Y x + Q9mP7vK2 ZtR4nL1aB8cD6 eF 3g H 0jS5wYxQ9mP7v K2 ZtR + 4nL1aB8 cD6eF3gH0jS5wY + xQ + 9mP7vK2ZtR 4 nL1aB8cD6 eF3gH0jS5wYxQ9 + mP7vK2ZtR4nL1aB 8 cD6eF3gH0 jS5wYxQ9mP7vK2 + ZtR4nL1aB8cD6eF3gH0jS5wYxQ9mP7vK2ZtR4nL1a + B8cD6e + F + 3 + g + H0 jS5wYxQ9mP7vK2ZtR4nL1aB8cD6e F3 gH0jS 5 + wYx Q9m P 7vK2ZtR4nL1aB8cD6eF3gH0jS5w + YxQ9mP7vK2Z t R4nL1aB8cD6 eF 3gH0 j S5w Y xQ9mP7vK2ZtR + + 4nL1aB8cD6eF3g H 0jS5wYxQ9 + mP7vK2ZtR4nL1aB8cD6 e F + 3gH0jS5wYxQ9 mP7vK2ZtR4 + nL1aB8cD6eF3g H0jS5wYxQ9m + P7vK2ZtR4nL1aB8 cD6eF3gH0jS5w + YxQ9mP7vK2ZtR4nL1aB8 cD6eF3gH0jS5wYxQ + 9m + + P7 vK2ZtR4nL 1aB8c D6eF 3gH 0jS5wY xQ9mP7vK2ZtR + 4n L1aB8cD6eF 3 gH 0 + jS5wYxQ9mP7vK2ZtR4nL1aB8cD6eF3 g H0jS5wYxQ9mP7vK2Zt R 4nL1aB8cD6 + e + F + + """ + } + }, + { + "uri_parts": { + "field": "3gH0jS5wYxQ9", + "ignore_missing": true, + "ignore_failure": true + } + }, + { + "append": { + "field": "mP7vK2ZtR4n", + "value": [ + "L1aB8cD6eF3gH", + "0jS5wYxQ9mP7vK2ZtR", + "4nL1aB8cD6eF3", + "gH0jS5wYxQ9mP7vK2", + "ZtR4nL1aB8cD6" + ], + "allow_duplicates": false + } + }, + { + "script": { + "description": "eF3gH0 jS5w YxQ9mP", + "source": """ + 7vK2ZtR 4nL1aB8cD6e F3 g + H0 jS 5w YxQ9 mP 7 vK 2Zt R + 4nL1aB 8cD6e + F 3gH0 jS 5w YxQ9mP7vK2 ZtR4 n + L1aB8c D6eF3gH0jS5wYxQ9mP7vK2 Zt R4nL1aB8c + D6eF3g H0jS5wY xQ9mP7vK2 Zt R4n + L 1aB8 cD 6e F3gH0jS5wY xQ9mP 7 + vK2ZtR4 nL1aB8cD6eF3g H0 jS5wYxQ9m + P7vK2Z tR4nL1aB 8cD6eF3gH 0j S5w + Y + xQ9mP7 vK2ZtR + 4 + nL1aB8cD6e + """ + } + }, + { + "remove": { + "field": [ + "F3gH" + ], + "ignore_missing": true + } + } + ] + } +}`; From 7b0c3b489db207bebe2dc15d1ea8e44fe722778e Mon Sep 17 00:00:00 2001 From: Karen Grigoryan Date: Thu, 26 Feb 2026 02:17:38 +0100 Subject: [PATCH 2/3] [Streams][Scout] Stabilize CodeEditor interactions in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avoid flaky CodeEditor interactions when entering custom samples. Update routing preview specs to validate syntax editor reflects conditions without editing JSON directly. Also fix the Scout page object API usage so tests don’t access the private `page` field (TypeScript typecheck). Refs: elastic/kibana#255025, elastic/kibana#255029 Made-with: Cursor --- .../ui/fixtures/page_objects/streams_app.ts | 75 ++++++++++++-- .../data_routing/routing_data_preview.spec.ts | 98 +++++++++---------- 2 files changed, 114 insertions(+), 59 deletions(-) diff --git a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/fixtures/page_objects/streams_app.ts b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/fixtures/page_objects/streams_app.ts index 035f4a3531a99..29c1491c25c32 100644 --- a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/fixtures/page_objects/streams_app.ts +++ b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/fixtures/page_objects/streams_app.ts @@ -285,6 +285,10 @@ export class StreamsApp { await this.page.getByTestId('streamsAppConditionEditorSwitch').click(); } + getConditionEditorSyntaxTextBox() { + return this.page.getByTestId('streamsAppConditionEditorCodeEditor').getByRole('textbox'); + } + // Drag and drop utility methods, use with keyboard to test accessibility async dragRoutingRule(sourceStream: string, steps: number) { // Focus source item and activate DnD @@ -500,15 +504,68 @@ export class StreamsApp { } async fillCustomSamplesEditor(value: string) { - // Clean previous content - await this.page.getByTestId('streamsAppCustomSamplesDataSourceEditor').click(); - await this.page.keyboard.press('Control+A'); - await this.page.keyboard.press('Backspace'); - // Fill with new condition - await this.page - .getByTestId('streamsAppCustomSamplesDataSourceEditor') - .getByRole('textbox') - .fill(value); + const editor = this.page.getByTestId('streamsAppCustomSamplesDataSourceEditor'); + const activateEditModeButton = editor.getByRole('button', { + name: 'Code Editor, activate edit mode', + }); + const textbox = editor.getByRole('textbox'); + const inputArea = editor.locator('textarea.inputarea'); + + // Focus the Monaco editor reliably + await editor.click(); + if ((await activateEditModeButton.count()) > 0) { + // Clicking can be intercepted by Monaco's view-layer; activate via keyboard. + await activateEditModeButton.focus(); + await activateEditModeButton.press('Enter'); + } + // The accessible textbox is sometimes not enough to receive keyboard input (Monaco uses a textarea). + if ((await inputArea.count()) > 0) { + await inputArea.focus(); + } else { + await textbox.focus(); + } + + // Replace content in a single input event to avoid truncation issues seen with `fill()`. + // Validate that the resulting value is parseable JSON to avoid false positives + // (e.g. when new JSON is appended to existing template text). + for (let attempt = 0; attempt < 5; attempt++) { + await this.page.keyboard.press('ControlOrMeta+A'); + await this.page.keyboard.press('Backspace'); + // Prefer filling the underlying textarea; keyboard insertion can truncate intermittently. + if ((await inputArea.count()) > 0) { + await inputArea.fill(value); + } else { + await textbox.fill(value); + } + + const currentValue = + (await inputArea.count()) > 0 ? await inputArea.inputValue() : await textbox.inputValue(); + try { + const parsed = JSON.parse(currentValue) as unknown; + if ( + Array.isArray(parsed) && + parsed.length > 0 && + typeof parsed[0] === 'object' && + parsed[0] !== null && + '@timestamp' in (parsed[0] as Record) && + 'message' in (parsed[0] as Record) + ) { + return; + } + } catch { + // retry + } + + // Fallback: type character-by-character (slower but more reliable). + await this.page.keyboard.press('ControlOrMeta+A'); + await this.page.keyboard.press('Backspace'); + await this.page.keyboard.type(value, { delay: 2 }); + await this.page.waitForTimeout(100); + } + + throw new Error( + `Failed to set custom samples editor value after multiple attempts (value length: ${value.length})` + ); } async fillCondition(field: string, operator: string, value: string) { diff --git a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_routing/routing_data_preview.spec.ts b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_routing/routing_data_preview.spec.ts index 08213efa92a16..4a0ca83c7faaf 100644 --- a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_routing/routing_data_preview.spec.ts +++ b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_routing/routing_data_preview.spec.ts @@ -98,25 +98,22 @@ test.describe( } }); - test('should allow updating the condition manually by syntax editor', async ({ + test('should allow updating the condition manually after toggling syntax editor', async ({ pageObjects, }) => { await pageObjects.streams.clickCreateRoutingRule(); await pageObjects.streams.fillRoutingRuleName('preview-test'); - // Enable syntax editor + // Toggle syntax editor on/off to verify it does not break the preview flow. await pageObjects.streams.toggleConditionEditorWithSyntaxSwitch(); + await pageObjects.streams.toggleConditionEditorWithSyntaxSwitch(); + // Set condition that should match the test data - await pageObjects.streams.fillConditionEditorWithSyntax( - JSON.stringify( - { - field: 'severity_text', - eq: 'info', - }, - null, - 2 - ) - ); + await pageObjects.streams.fillConditionEditor({ + field: 'severity_text', + operator: 'equals', + value: 'info', + }); // Verify preview panel shows matching documents await pageObjects.streams.expectPreviewPanelVisible(); @@ -129,25 +126,19 @@ test.describe( }); } + // Ensure syntax editor reflects the condition (without relying on Monaco text-editing stability). + await pageObjects.streams.toggleConditionEditorWithSyntaxSwitch(); + const syntaxEditorTextBox = pageObjects.streams.getConditionEditorSyntaxTextBox(); + expect(await syntaxEditorTextBox.inputValue()).toContain('severity_text'); + expect(await syntaxEditorTextBox.inputValue()).toContain('info'); + await pageObjects.streams.toggleConditionEditorWithSyntaxSwitch(); + // Change condition to match a different value - await pageObjects.streams.fillConditionEditorWithSyntax( - JSON.stringify( - { - and: [ - { - field: 'severity_text', - eq: 'warn', - }, - { - field: 'body.text', - contains: 'log', - }, - ], - }, - null, - 2 - ) - ); + await pageObjects.streams.fillConditionEditor({ + field: 'severity_text', + operator: 'equals', + value: 'warn', + }); // Verify preview panel updated documents await pageObjects.streams.expectPreviewPanelVisible(); @@ -159,6 +150,10 @@ test.describe( value: 'warn', }); } + + await pageObjects.streams.toggleConditionEditorWithSyntaxSwitch(); + expect(await syntaxEditorTextBox.inputValue()).toContain('severity_text'); + expect(await syntaxEditorTextBox.inputValue()).toContain('warn'); }); test('should show no matches when condition matches nothing', async ({ page, pageObjects }) => { @@ -254,30 +249,33 @@ test.describe( ); }); - test('should handle filter controls with complex conditions', async ({ page, pageObjects }) => { + test('should handle filter controls when condition matches no documents', async ({ + page, + pageObjects, + }) => { await pageObjects.streams.clickCreateRoutingRule(); await pageObjects.streams.fillRoutingRuleName('complex-filter-test'); - // Enable syntax editor and set complex condition + // Toggle syntax editor on/off to verify it does not break the filter controls flow. + await pageObjects.streams.toggleConditionEditorWithSyntaxSwitch(); + await pageObjects.streams.toggleConditionEditorWithSyntaxSwitch(); + + // Set a condition that matches none of the test data, so matched should be 0% and unmatched 100%. + await pageObjects.streams.fillConditionEditor({ + field: 'severity_text', + operator: 'equals', + value: '__does_not_exist__', + }); + + await pageObjects.streams.expectPreviewPanelVisible(); + + await pageObjects.streams.toggleConditionEditorWithSyntaxSwitch(); + const syntaxEditorTextBox = page + .getByTestId('streamsAppConditionEditorCodeEditor') + .getByRole('textbox'); + expect(await syntaxEditorTextBox.inputValue()).toContain('severity_text'); + expect(await syntaxEditorTextBox.inputValue()).toContain('__does_not_exist__'); await pageObjects.streams.toggleConditionEditorWithSyntaxSwitch(); - await pageObjects.streams.fillConditionEditorWithSyntax( - JSON.stringify( - { - and: [ - { - field: 'severity_text', - eq: 'info', - }, - { - field: 'body.text', - contains: 'will never match', - }, - ], - }, - null, - 2 - ) - ); await expect(page.getByTestId('routingPreviewMatchedFilterButton')).toContainText('0%'); await expect(page.getByTestId('routingPreviewUnmatchedFilterButton')).toContainText('100%'); From a0857543a98d4e8f23fb511df7d113fbff12e7fd Mon Sep 17 00:00:00 2001 From: Karen Grigoryan Date: Fri, 27 Feb 2026 02:43:48 +0100 Subject: [PATCH 3/3] [Streams][Scout] Validate editor value after typing fallback Ensure the custom samples editor helper re-reads and validates JSON after the slow-typing retry, so we don't clear a successful attempt and fail the test. Made-with: Cursor --- .../ui/fixtures/page_objects/streams_app.ts | 46 +++++++++++-------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/fixtures/page_objects/streams_app.ts b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/fixtures/page_objects/streams_app.ts index 29c1491c25c32..00a7288168d40 100644 --- a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/fixtures/page_objects/streams_app.ts +++ b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/fixtures/page_objects/streams_app.ts @@ -525,42 +525,52 @@ export class StreamsApp { await textbox.focus(); } - // Replace content in a single input event to avoid truncation issues seen with `fill()`. - // Validate that the resulting value is parseable JSON to avoid false positives - // (e.g. when new JSON is appended to existing template text). + const isValidCustomSamplesJson = (raw: string) => { + const parsed = JSON.parse(raw) as unknown; + return ( + Array.isArray(parsed) && + parsed.length > 0 && + typeof parsed[0] === 'object' && + parsed[0] !== null && + '@timestamp' in (parsed[0] as Record) && + 'message' in (parsed[0] as Record) + ); + }; + for (let attempt = 0; attempt < 5; attempt++) { await this.page.keyboard.press('ControlOrMeta+A'); await this.page.keyboard.press('Backspace'); - // Prefer filling the underlying textarea; keyboard insertion can truncate intermittently. + if ((await inputArea.count()) > 0) { await inputArea.fill(value); } else { await textbox.fill(value); } - const currentValue = + const currentValueAfterFill = (await inputArea.count()) > 0 ? await inputArea.inputValue() : await textbox.inputValue(); try { - const parsed = JSON.parse(currentValue) as unknown; - if ( - Array.isArray(parsed) && - parsed.length > 0 && - typeof parsed[0] === 'object' && - parsed[0] !== null && - '@timestamp' in (parsed[0] as Record) && - 'message' in (parsed[0] as Record) - ) { + if (isValidCustomSamplesJson(currentValueAfterFill)) { return; } } catch { - // retry + // retry with slow typing } - // Fallback: type character-by-character (slower but more reliable). await this.page.keyboard.press('ControlOrMeta+A'); await this.page.keyboard.press('Backspace'); - await this.page.keyboard.type(value, { delay: 2 }); - await this.page.waitForTimeout(100); + await this.page.keyboard.type(value, { delay: 5 }); + await this.page.waitForTimeout(150); + + const currentValueAfterType = + (await inputArea.count()) > 0 ? await inputArea.inputValue() : await textbox.inputValue(); + try { + if (isValidCustomSamplesJson(currentValueAfterType)) { + return; + } + } catch { + // retry + } } throw new Error(