diff --git a/.gitignore b/.gitignore index 289ed31..8a3d5ba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /build -/target \ No newline at end of file +/target +/coverage/ diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..16a64bf --- /dev/null +++ b/babel.config.js @@ -0,0 +1,25 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +// babelrc doesn't respect NODE_PATH anymore but using require does. +// Alternative to install them locally in node_modules +module.exports = function (api) { + // ensure env is test so that this config won't impact build or dev server + if (api.env('test')) { + return { + presets: [ + require('@babel/preset-env', { + useBuiltIns: false, + targets: { + node: 'current', + }, + }), + require('@babel/preset-react'), + require('@babel/preset-typescript'), + ], + }; + } + return {}; +}; diff --git a/common/config.ts b/common/config.ts index b6be3f7..b9ea475 100644 --- a/common/config.ts +++ b/common/config.ts @@ -7,6 +7,17 @@ import { schema, TypeOf } from '@osd/config-schema'; export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), + queryAssist: schema.object({ + supportedLanguages: schema.arrayOf( + schema.object({ + language: schema.string(), + agentConfig: schema.string(), + }), + { + defaultValue: [{ language: 'PPL', agentConfig: 'os_query_assist_ppl' }], + } + ), + }), }); export type ConfigSchema = TypeOf; diff --git a/common/constants.ts b/common/constants.ts index aa78e9a..67fdabc 100644 --- a/common/constants.ts +++ b/common/constants.ts @@ -17,6 +17,10 @@ export const API = { SEARCH: `${BASE_API}/search`, PPL_SEARCH: `${BASE_API}/search/${SEARCH_STRATEGY.PPL}`, SQL_SEARCH: `${BASE_API}/search/${SEARCH_STRATEGY.SQL}`, + QUERY_ASSIST: { + LANGUAGES: `${BASE_API}/assist/languages`, + GENERATE: `${BASE_API}/assist/generate`, + }, }; export const URI = { @@ -34,3 +38,5 @@ export const OPENSEARCH_API = { }; export const UI_SETTINGS = {}; + +export const ERROR_DETAILS = { GUARDRAILS_TRIGGERED: 'guardrails triggered' }; diff --git a/common/query_assist/index.ts b/common/query_assist/index.ts new file mode 100644 index 0000000..375227d --- /dev/null +++ b/common/query_assist/index.ts @@ -0,0 +1 @@ +export { QueryAssistParameters, QueryAssistResponse } from './types'; diff --git a/common/query_assist/types.ts b/common/query_assist/types.ts new file mode 100644 index 0000000..1193d99 --- /dev/null +++ b/common/query_assist/types.ts @@ -0,0 +1,14 @@ +import { TimeRange } from '../../../../src/plugins/data/common'; + +export interface QueryAssistResponse { + query: string; + timeRange?: TimeRange; +} + +export interface QueryAssistParameters { + question: string; + index: string; + language: string; + // for MDS + dataSourceId?: string; +} diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json index 710f289..b7b3e9c 100644 --- a/opensearch_dashboards.json +++ b/opensearch_dashboards.json @@ -6,5 +6,5 @@ "ui": true, "requiredPlugins": ["data"], "optionalPlugins": ["dataSource", "dataSourceManagement"], - "requiredBundles": ["opensearchDashboardsUtils"] + "requiredBundles": ["opensearchDashboardsUtils", "opensearchDashboardsReact"] } diff --git a/package.json b/package.json index c920fb9..6c23b9c 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "build": "yarn plugin-helpers build", "plugin-helpers": "node ../../scripts/plugin_helpers", + "test": "../../node_modules/.bin/jest --config ./test/jest.config.js", "osd": "node ../../scripts/osd" }, "dependencies": {}, diff --git a/public/assets/query_assist_mark.svg b/public/assets/query_assist_mark.svg new file mode 100644 index 0000000..b744e8c --- /dev/null +++ b/public/assets/query_assist_mark.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/public/index.ts b/public/index.ts index d2bb583..0dc86c5 100644 --- a/public/index.ts +++ b/public/index.ts @@ -3,12 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { PluginInitializerContext } from '../../../src/core/public'; import './index.scss'; - import { QueryEnhancementsPlugin } from './plugin'; -export function plugin() { - return new QueryEnhancementsPlugin(); +export function plugin(initializerContext: PluginInitializerContext) { + return new QueryEnhancementsPlugin(initializerContext); } export { QueryEnhancementsPluginSetup, QueryEnhancementsPluginStart } from './types'; diff --git a/public/plugin.tsx b/public/plugin.tsx index fa6ca0c..23ceda8 100644 --- a/public/plugin.tsx +++ b/public/plugin.tsx @@ -4,8 +4,10 @@ */ import moment from 'moment'; -import { CoreSetup, CoreStart, Plugin } from '../../../src/core/public'; +import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '../../../src/core/public'; import { IStorageWrapper, Storage } from '../../../src/plugins/opensearch_dashboards_utils/public'; +import { ConfigSchema } from '../common/config'; +import { createQueryAssistExtension } from './query_assist'; import { PPLSearchInterceptor, SQLSearchInterceptor } from './search'; import { setData, setStorage } from './services'; import { @@ -15,11 +17,15 @@ import { QueryEnhancementsPluginStartDependencies, } from './types'; +export type PublicConfig = Pick; + export class QueryEnhancementsPlugin implements Plugin { private readonly storage: IStorageWrapper; + private readonly config: PublicConfig; - constructor() { + constructor(initializerContext: PluginInitializerContext) { + this.config = initializerContext.config.get(); this.storage = new Storage(window.localStorage); } @@ -86,6 +92,12 @@ export class QueryEnhancementsPlugin }, }); + data.__enhance({ + ui: { + queryEditorExtension: createQueryAssistExtension(core.http, this.config), + }, + }); + return {}; } diff --git a/public/query_assist/components/__snapshots__/call_outs.test.tsx.snap b/public/query_assist/components/__snapshots__/call_outs.test.tsx.snap new file mode 100644 index 0000000..e7cccd4 --- /dev/null +++ b/public/query_assist/components/__snapshots__/call_outs.test.tsx.snap @@ -0,0 +1,163 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CallOuts spec should display empty_index call out 1`] = ` +
+
+
+ + + + Select a data source or index to ask a question. + + +
+
+
+`; + +exports[`CallOuts spec should display empty_query call out 1`] = ` +
+
+
+ + + + Enter a natural language question to automatically generate a query to view results. + + +
+
+
+`; + +exports[`CallOuts spec should display invalid_query call out 1`] = ` +
+
+
+ + + + I am unable to respond to this query. Try another question. + + +
+
+
+`; + +exports[`CallOuts spec should display query_generated call out 1`] = ` +
+
+
+ + + + test lang query generated. If there are any issues with the response, try adding more context to the question or a new question to submit. + + +
+ +
+
+`; diff --git a/public/query_assist/components/call_outs.test.tsx b/public/query_assist/components/call_outs.test.tsx new file mode 100644 index 0000000..ddace95 --- /dev/null +++ b/public/query_assist/components/call_outs.test.tsx @@ -0,0 +1,46 @@ +import { render } from '@testing-library/react'; +import React, { ComponentProps } from 'react'; +import { QueryAssistCallOut } from './call_outs'; + +type Props = ComponentProps; + +const renderCallOut = (overrideProps: Partial = {}) => { + const props: Props = Object.assign>( + { + type: 'empty_query', + language: 'test lang', + onDismiss: jest.fn(), + }, + overrideProps + ); + const component = render(); + return { component, props: props as jest.MockedObjectDeep }; +}; + +describe('CallOuts spec', () => { + it('should display nothing if type is invalid', () => { + // @ts-expect-error testing invalid type + const { component } = renderCallOut({ type: '' }); + expect(component.container).toBeEmptyDOMElement(); + }); + + it('should display empty_query call out', () => { + const { component } = renderCallOut({ type: 'empty_query' }); + expect(component.container).toMatchSnapshot(); + }); + + it('should display empty_index call out', () => { + const { component } = renderCallOut({ type: 'empty_index' }); + expect(component.container).toMatchSnapshot(); + }); + + it('should display invalid_query call out', () => { + const { component } = renderCallOut({ type: 'invalid_query' }); + expect(component.container).toMatchSnapshot(); + }); + + it('should display query_generated call out', () => { + const { component } = renderCallOut({ type: 'query_generated' }); + expect(component.container).toMatchSnapshot(); + }); +}); diff --git a/public/query_assist/components/call_outs.tsx b/public/query_assist/components/call_outs.tsx new file mode 100644 index 0000000..3074ec7 --- /dev/null +++ b/public/query_assist/components/call_outs.tsx @@ -0,0 +1,101 @@ +import { EuiCallOut, EuiCallOutProps } from '@elastic/eui'; +import { FormattedMessage } from '@osd/i18n/react'; +import React from 'react'; + +interface QueryAssistCallOutProps extends Required> { + language: string; + type: QueryAssistCallOutType; +} + +export type QueryAssistCallOutType = + | undefined + | 'invalid_query' + | 'prohibited_query' + | 'empty_query' + | 'empty_index' + | 'query_generated'; + +const EmptyIndexCallOut: React.FC = (props) => ( + + } + size="s" + color="warning" + iconType="iInCircle" + dismissible + onDismiss={props.onDismiss} + /> +); + +const ProhibitedQueryCallOut: React.FC = (props) => ( + + } + size="s" + color="danger" + iconType="alert" + dismissible + onDismiss={props.onDismiss} + /> +); + +const EmptyQueryCallOut: React.FC = (props) => ( + + } + size="s" + color="warning" + iconType="iInCircle" + dismissible + onDismiss={props.onDismiss} + /> +); + +const QueryGeneratedCallOut: React.FC = (props) => ( + + } + size="s" + color="success" + iconType="check" + dismissible + onDismiss={props.onDismiss} + /> +); + +export const QueryAssistCallOut: React.FC = (props) => { + switch (props.type) { + case 'empty_query': + return ; + case 'empty_index': + return ; + case 'invalid_query': + return ; + case 'query_generated': + return ; + default: + break; + } + return null; +}; diff --git a/public/query_assist/components/index.ts b/public/query_assist/components/index.ts new file mode 100644 index 0000000..d6276e4 --- /dev/null +++ b/public/query_assist/components/index.ts @@ -0,0 +1 @@ +export { QueryAssistBar } from './query_assist_bar'; diff --git a/public/query_assist/components/index_selector.tsx b/public/query_assist/components/index_selector.tsx new file mode 100644 index 0000000..68aa418 --- /dev/null +++ b/public/query_assist/components/index_selector.tsx @@ -0,0 +1,42 @@ +import { EuiComboBox, EuiComboBoxOptionOption, EuiText } from '@elastic/eui'; +import React from 'react'; +import { useIndexPatterns, useIndices } from '../hooks/use_indices'; + +interface IndexSelectorProps { + dataSourceId?: string; + selectedIndex?: string; + setSelectedIndex: React.Dispatch>; +} + +// TODO this is a temporary solution, there will be a dataset selector from discover +export const IndexSelector: React.FC = (props) => { + const { data: indices, loading: indicesLoading } = useIndices(props.dataSourceId); + const { data: indexPatterns, loading: indexPatternsLoading } = useIndexPatterns(); + const loading = indicesLoading || indexPatternsLoading; + const indicesAndIndexPatterns = + indexPatterns && indices + ? [...indexPatterns, ...indices].filter( + (v1, index, array) => array.findIndex((v2) => v1 === v2) === index + ) + : []; + const options: EuiComboBoxOptionOption[] = indicesAndIndexPatterns.map((index) => ({ + label: index, + })); + const selectedOptions = props.selectedIndex ? [{ label: props.selectedIndex }] : undefined; + + return ( + Index} + singleSelection={{ asPlainText: true }} + isLoading={loading} + options={options} + selectedOptions={selectedOptions} + onChange={(index) => { + props.setSelectedIndex(index[0].label); + }} + /> + ); +}; diff --git a/public/query_assist/components/query_assist_banner.tsx b/public/query_assist/components/query_assist_banner.tsx new file mode 100644 index 0000000..f042e8f --- /dev/null +++ b/public/query_assist/components/query_assist_banner.tsx @@ -0,0 +1,67 @@ +import { + EuiBadge, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLink, + EuiTextColor, +} from '@elastic/eui'; +import { FormattedMessage } from '@osd/i18n/react'; +import React, { useState } from 'react'; +import assistantMark from '../../assets/query_assist_mark.svg'; +import { getStorage } from '../../services'; + +const BANNER_STORAGE_KEY = 'queryAssist:banner:show'; + +interface QueryAssistBannerProps { + languages: string[]; +} + +export const QueryAssistBanner: React.FC = (props) => { + const storage = getStorage(); + const [showCallOut, _setShowCallOut] = useState(true); + const setShowCallOut: typeof _setShowCallOut = (show) => { + if (!show) { + storage.set(BANNER_STORAGE_KEY, false); + } + _setShowCallOut(show); + }; + + if (!showCallOut || storage.get(BANNER_STORAGE_KEY) === false) return null; + + return ( + + + + + + + + + + + + + + + + + + + } + dismissible + onDismiss={() => setShowCallOut(false)} + /> + ); +}; diff --git a/public/query_assist/components/query_assist_bar.tsx b/public/query_assist/components/query_assist_bar.tsx new file mode 100644 index 0000000..98c6caf --- /dev/null +++ b/public/query_assist/components/query_assist_bar.tsx @@ -0,0 +1,109 @@ +import { EuiFlexGroup, EuiFlexItem, EuiForm, EuiFormRow } from '@elastic/eui'; +import React, { SyntheticEvent, useEffect, useMemo, useRef, useState } from 'react'; +import { IDataPluginServices, PersistedLog } from '../../../../../src/plugins/data/public'; +import { QueryEditorExtensionDependencies } from '../../../../../src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extension'; +import { useOpenSearchDashboards } from '../../../../../src/plugins/opensearch_dashboards_react/public'; +import { QueryAssistParameters } from '../../../common/query_assist'; +import { getStorage } from '../../services'; +import { useGenerateQuery } from '../hooks'; +import { getMdsDataSourceId, getPersistedLog, ProhibitedQueryError } from '../utils'; +import { QueryAssistCallOut, QueryAssistCallOutType } from './call_outs'; +import { IndexSelector } from './index_selector'; +import { QueryAssistInput } from './query_assist_input'; +import { QueryAssistSubmitButton } from './submit_button'; + +interface QueryAssistInputProps { + dependencies: QueryEditorExtensionDependencies; +} + +export const QueryAssistBar: React.FC = (props) => { + const { services } = useOpenSearchDashboards(); + const inputRef = useRef(null); + const storage = getStorage(); + const persistedLog: PersistedLog = useMemo( + () => getPersistedLog(services.uiSettings, storage, 'query-assist'), + [services.uiSettings, storage] + ); + const { generateQuery, loading } = useGenerateQuery(); + const [callOutType, setCallOutType] = useState(); + const dismissCallout = () => setCallOutType(undefined); + const [selectedIndex, setSelectedIndex] = useState(''); + const dataSourceIdRef = useRef(); + const previousQuestionRef = useRef(); + + useEffect(() => { + // TODO need proper way to get dataSourceId when discover index pattern selector is removed + getMdsDataSourceId(services.data.indexPatterns, props.dependencies.indexPatterns?.at(0)).then( + (id) => (dataSourceIdRef.current = id) + ); + }, [props.dependencies.indexPatterns, services.data.indexPatterns]); + + const onSubmit = async (e: SyntheticEvent) => { + e.preventDefault(); + if (!inputRef.current?.value) { + setCallOutType('empty_query'); + return; + } + if (!selectedIndex) { + setCallOutType('empty_index'); + return; + } + dismissCallout(); + previousQuestionRef.current = inputRef.current.value; + persistedLog.add(inputRef.current.value); + const params: QueryAssistParameters = { + question: inputRef.current.value, + index: selectedIndex, + language: props.dependencies.language, + dataSourceId: dataSourceIdRef.current, + }; + const { response, error } = await generateQuery(params); + if (error) { + if (error instanceof ProhibitedQueryError) { + setCallOutType('invalid_query'); + } else { + services.notifications.toasts.addError(error, { title: 'Failed to generate results' }); + } + } else if (response) { + services.data.query.queryString.setQuery({ + query: response.query, + language: params.language, + }); + if (response.timeRange) services.data.query.timefilter.timefilter.setTime(response.timeRange); + setCallOutType('query_generated'); + } + }; + + return ( + + + + + + + + + + + + + + + + + ); +}; diff --git a/public/query_assist/components/query_assist_input.tsx b/public/query_assist/components/query_assist_input.tsx new file mode 100644 index 0000000..22f8382 --- /dev/null +++ b/public/query_assist/components/query_assist_input.tsx @@ -0,0 +1,108 @@ +import { EuiFieldText, EuiIcon, EuiOutsideClickDetector, EuiPortal } from '@elastic/eui'; +import React, { useMemo, useState } from 'react'; +import { PersistedLog, QuerySuggestionTypes } from '../../../../../src/plugins/data/public'; +import assistantMark from '../../assets/query_assist_mark.svg'; +import { getData } from '../../services'; + +interface QueryAssistInputProps { + inputRef: React.RefObject; + persistedLog: PersistedLog; + isDisabled: boolean; + initialValue?: string; + selectedIndex?: string; + previousQuestion?: string; +} + +export const QueryAssistInput: React.FC = (props) => { + const { + ui: { SuggestionsComponent }, + } = getData(); + const [isSuggestionsVisible, setIsSuggestionsVisible] = useState(false); + const [suggestionIndex, setSuggestionIndex] = useState(null); + const [value, setValue] = useState(props.initialValue ?? ''); + + const sampleDataSuggestions = useMemo(() => { + switch (props.selectedIndex) { + case 'opensearch_dashboards_sample_data_ecommerce': + return [ + 'How many unique customers placed orders this week?', + 'Count the number of orders grouped by manufacturer and category', + 'find customers with first names like Eddie', + ]; + + case 'opensearch_dashboards_sample_data_logs': + return [ + 'Are there any errors in my logs?', + 'How many requests were there grouped by response code last week?', + "What's the average request size by week?", + ]; + + case 'opensearch_dashboards_sample_data_flights': + return [ + 'how many flights were there this week grouped by destination country?', + 'what were the longest flight delays this week?', + 'what carriers have the furthest flights?', + ]; + + default: + return []; + } + }, [props.selectedIndex]); + + const suggestions = useMemo(() => { + if (!props.persistedLog) return []; + return props.persistedLog + .get() + .concat(sampleDataSuggestions) + .filter( + (suggestion, i, array) => array.indexOf(suggestion) === i && suggestion.includes(value) + ) + .map((suggestion) => ({ + type: QuerySuggestionTypes.RecentSearch, + text: suggestion, + start: 0, + end: value.length, + })); + }, [props.persistedLog, value, sampleDataSuggestions]); + + return ( + setIsSuggestionsVisible(false)}> +
+ setIsSuggestionsVisible(true)} + onChange={(e) => setValue(e.target.value)} + onKeyDown={() => setIsSuggestionsVisible(true)} + placeholder={ + props.previousQuestion || + (props.selectedIndex + ? `Ask a natural language question about ${props.selectedIndex} to generate a query` + : 'Select an index to ask a question') + } + prepend={} + fullWidth + /> + + { + if (!props.inputRef.current) return; + setValue(suggestion.text); + setIsSuggestionsVisible(false); + setSuggestionIndex(null); + props.inputRef.current.focus(); + }} + onMouseEnter={(i) => setSuggestionIndex(i)} + loadMore={() => {}} + queryBarRect={props.inputRef.current?.getBoundingClientRect()} + size="s" + /> + +
+
+ ); +}; diff --git a/public/query_assist/components/submit_button.test.tsx b/public/query_assist/components/submit_button.test.tsx new file mode 100644 index 0000000..a3f4726 --- /dev/null +++ b/public/query_assist/components/submit_button.test.tsx @@ -0,0 +1,29 @@ +import { fireEvent, render } from '@testing-library/react'; +import React, { ComponentProps } from 'react'; +import { QueryAssistSubmitButton } from './submit_button'; + +type SubmitButtonProps = ComponentProps; + +const renderSubmitButton = (overrideProps: Partial = {}) => { + const props: SubmitButtonProps = Object.assign>( + { + isDisabled: false, + }, + overrideProps + ); + const onSubmit = jest.fn((e) => e.preventDefault()); + const component = render( +
+ + + ); + return { component, onSubmit, props: props as jest.MockedObjectDeep }; +}; + +describe(' spec', () => { + it('should trigger submit form', () => { + const { component, onSubmit } = renderSubmitButton(); + fireEvent.click(component.getByTestId('query-assist-submit-button')); + expect(onSubmit).toBeCalled(); + }); +}); diff --git a/public/query_assist/components/submit_button.tsx b/public/query_assist/components/submit_button.tsx new file mode 100644 index 0000000..ddae082 --- /dev/null +++ b/public/query_assist/components/submit_button.tsx @@ -0,0 +1,20 @@ +import { EuiButtonIcon } from '@elastic/eui'; +import React from 'react'; + +interface SubmitButtonProps { + isDisabled: boolean; +} + +export const QueryAssistSubmitButton: React.FC = (props) => { + return ( + + ); +}; diff --git a/public/query_assist/hooks/index.ts b/public/query_assist/hooks/index.ts new file mode 100644 index 0000000..5922606 --- /dev/null +++ b/public/query_assist/hooks/index.ts @@ -0,0 +1 @@ +export * from './use_generate'; diff --git a/public/query_assist/hooks/use_generate.ts b/public/query_assist/hooks/use_generate.ts new file mode 100644 index 0000000..a5e0db6 --- /dev/null +++ b/public/query_assist/hooks/use_generate.ts @@ -0,0 +1,46 @@ +import { useEffect, useRef, useState } from 'react'; +import { IDataPluginServices } from '../../../../../src/plugins/data/public'; +import { useOpenSearchDashboards } from '../../../../../src/plugins/opensearch_dashboards_react/public'; +import { API } from '../../../common'; +import { QueryAssistParameters, QueryAssistResponse } from '../../../common/query_assist'; +import { formatError } from '../utils'; + +export const useGenerateQuery = () => { + const mounted = useRef(false); + const [loading, setLoading] = useState(false); + const abortControllerRef = useRef(); + const { services } = useOpenSearchDashboards(); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = undefined; + } + }; + }, []); + + const generateQuery = async ( + params: QueryAssistParameters + ): Promise<{ response?: QueryAssistResponse; error?: Error }> => { + abortControllerRef.current?.abort(); + abortControllerRef.current = new AbortController(); + setLoading(true); + try { + const response = await services.http.post(API.QUERY_ASSIST.GENERATE, { + body: JSON.stringify(params), + signal: abortControllerRef.current?.signal, + }); + if (mounted.current) return { response }; + } catch (error) { + if (mounted.current) return { error: formatError(error) }; + } finally { + if (mounted.current) setLoading(false); + } + return {}; + }; + + return { generateQuery, loading, abortControllerRef }; +}; diff --git a/public/query_assist/hooks/use_indices.ts b/public/query_assist/hooks/use_indices.ts new file mode 100644 index 0000000..7c6bb2c --- /dev/null +++ b/public/query_assist/hooks/use_indices.ts @@ -0,0 +1,97 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CatIndicesResponse } from '@opensearch-project/opensearch/api/types'; +import { Reducer, useEffect, useReducer, useState } from 'react'; +import { IDataPluginServices } from '../../../../../src/plugins/data/public'; +import { useOpenSearchDashboards } from '../../../../../src/plugins/opensearch_dashboards_react/public'; + +interface State { + data?: T; + loading: boolean; + error?: Error; +} + +type Action = + | { type: 'request' } + | { type: 'success'; payload: State['data'] } + | { type: 'failure'; error: NonNullable['error']> }; + +// TODO use instantiation expressions when typescript is upgraded to >= 4.7 +type GenericReducer = Reducer, Action>; +export const genericReducer: GenericReducer = (state, action) => { + switch (action.type) { + case 'request': + return { data: state.data, loading: true }; + case 'success': + return { loading: false, data: action.payload }; + case 'failure': + return { loading: false, error: action.error }; + default: + return state; + } +}; + +export const useIndices = (dataSourceId: string | undefined) => { + const reducer: GenericReducer = genericReducer; + const [state, dispatch] = useReducer(reducer, { loading: false }); + const [refresh, setRefresh] = useState({}); + const { services } = useOpenSearchDashboards(); + + useEffect(() => { + const abortController = new AbortController(); + dispatch({ type: 'request' }); + services.http + .post('/api/console/proxy', { + query: { path: '_cat/indices?format=json', method: 'GET', dataSourceId }, + signal: abortController.signal, + }) + .then((payload: CatIndicesResponse) => + dispatch({ + type: 'success', + payload: payload + .filter((meta) => meta.index && !meta.index.startsWith('.')) + .map((meta) => meta.index!), + }) + ) + .catch((error) => dispatch({ type: 'failure', error })); + + return () => abortController.abort(); + }, [refresh, services.http, dataSourceId]); + + return { ...state, refresh: () => setRefresh({}) }; +}; + +export const useIndexPatterns = () => { + const reducer: GenericReducer = genericReducer; + const [state, dispatch] = useReducer(reducer, { loading: false }); + const [refresh, setRefresh] = useState({}); + const { services } = useOpenSearchDashboards(); + + useEffect(() => { + let abort = false; + dispatch({ type: 'request' }); + + services.data.indexPatterns + .getTitles() + .then((payload) => { + if (!abort) + dispatch({ + type: 'success', + // temporary solution does not support index patterns from other data sources + payload: payload.filter((title) => !title.includes('::')), + }); + }) + .catch((error) => { + if (!abort) dispatch({ type: 'failure', error }); + }); + + return () => { + abort = true; + }; + }, [refresh, services.data.indexPatterns]); + + return { ...state, refresh: () => setRefresh({}) }; +}; diff --git a/public/query_assist/index.ts b/public/query_assist/index.ts new file mode 100644 index 0000000..0c059cf --- /dev/null +++ b/public/query_assist/index.ts @@ -0,0 +1 @@ +export { createQueryAssistExtension } from './utils'; diff --git a/public/query_assist/utils/create_extension.tsx b/public/query_assist/utils/create_extension.tsx new file mode 100644 index 0000000..80c9293 --- /dev/null +++ b/public/query_assist/utils/create_extension.tsx @@ -0,0 +1,105 @@ +import { HttpSetup } from 'opensearch-dashboards/public'; +import React, { useEffect, useState } from 'react'; +import { getMdsDataSourceId } from '.'; +import { QueryEditorExtensionConfig } from '../../../../../src/plugins/data/public/ui/query_editor'; +import { QueryEditorExtensionDependencies } from '../../../../../src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extension'; +import { API } from '../../../common'; +import { PublicConfig } from '../../plugin'; +import { getData } from '../../services'; +import { QueryAssistBar } from '../components'; +import { QueryAssistBanner } from '../components/query_assist_banner'; + +let availableLanguagesByDataSource: Map; + +/** + * @param dependencies - QueryEditorExtensionDependencies. + * @param http - HttpSetup. + * @returns list of query assist agents configured languages in the data source + * associated with the currently selected index pattern. + */ +const getAvailableLanguages = async ( + dependencies: QueryEditorExtensionDependencies, + http: HttpSetup +) => { + if (!availableLanguagesByDataSource) availableLanguagesByDataSource = new Map(); + + const dataSourceId = await getMdsDataSourceId( + getData().indexPatterns, + dependencies.indexPatterns?.at(0) + ); + const cached = availableLanguagesByDataSource.get(dataSourceId); + if (cached !== undefined) return cached; + + const languages = await http + .get<{ configuredLanguages: string[] }>(API.QUERY_ASSIST.LANGUAGES, { + query: { dataSourceId }, + }) + .then((response) => response.configuredLanguages) + .catch(() => []); + availableLanguagesByDataSource.set(dataSourceId, languages); + return languages; +}; + +export const createQueryAssistExtension = ( + http: HttpSetup, + config: PublicConfig +): QueryEditorExtensionConfig => { + return { + id: 'query-assist', + order: 1000, + isEnabled: async (dependencies) => { + // currently query assist tool relies on opensearch API to get index + // mappings, non-default data source types are not supported + if (dependencies.dataSource && dependencies.dataSource?.getType() !== 'default') return false; + + const languages = await getAvailableLanguages(dependencies, http); + return languages.length > 0; + }, + getComponent: (dependencies) => { + // only show the component if user is on a supported language. + return ( + + + + ); + }, + getBanner: (dependencies) => { + // advertise query assist if user is not on a supported language. + return ( + + conf.language)} + /> + + ); + }, + }; +}; + +interface QueryAssistWrapperProps { + dependencies: QueryEditorExtensionDependencies; + http: HttpSetup; + invert?: boolean; +} + +const QueryAssistWrapper: React.FC = (props) => { + const [visible, setVisible] = useState(false); + + useEffect(() => { + let mounted = true; + + (async () => { + const available = (await getAvailableLanguages(props.dependencies, props.http)).includes( + props.dependencies.language + ); + if (mounted) setVisible(props.invert ? !available : available); + })(); + + return () => { + mounted = false; + }; + }, [props]); + + if (!visible) return null; + return <>{props.children}; +}; diff --git a/public/query_assist/utils/errors.ts b/public/query_assist/utils/errors.ts new file mode 100644 index 0000000..4f1dbb9 --- /dev/null +++ b/public/query_assist/utils/errors.ts @@ -0,0 +1,25 @@ +import { ResponseError } from '@opensearch-project/opensearch/lib/errors'; +import { ERROR_DETAILS } from '../../../common'; + +export class ProhibitedQueryError extends Error { + constructor(message?: string) { + super(message); + } +} + +export const formatError = (error: ResponseError | Error): Error => { + if ('body' in error) { + if (error.body.statusCode === 429) + return { + ...error.body, + message: 'Request is throttled. Try again later or contact your administrator', + } as Error; + if ( + error.body.statusCode === 400 && + error.body.message.includes(ERROR_DETAILS.GUARDRAILS_TRIGGERED) + ) + return new ProhibitedQueryError(error.body.message); + return error.body as Error; + } + return error; +}; diff --git a/public/query_assist/utils/get_mds_id.ts b/public/query_assist/utils/get_mds_id.ts new file mode 100644 index 0000000..6dab73e --- /dev/null +++ b/public/query_assist/utils/get_mds_id.ts @@ -0,0 +1,11 @@ +import { IIndexPattern, IndexPatternsContract } from '../../../../../src/plugins/data/public'; + +export const getMdsDataSourceId = async ( + indexPatterns: IndexPatternsContract, + indexPattern: IIndexPattern | string | undefined +): Promise => { + if (!indexPattern || typeof indexPattern !== 'object' || !indexPattern.id) return undefined; + return indexPatterns + .get(indexPattern.id) + .then((indexPatternEntity) => indexPatternEntity.dataSourceRef?.id); +}; diff --git a/public/query_assist/utils/get_persisted_log.ts b/public/query_assist/utils/get_persisted_log.ts new file mode 100644 index 0000000..185acdc --- /dev/null +++ b/public/query_assist/utils/get_persisted_log.ts @@ -0,0 +1,19 @@ +import { IUiSettingsClient } from 'opensearch-dashboards/public'; +import { UI_SETTINGS } from '../../../../../src/plugins/data/common'; +import { PersistedLog } from '../../../../../src/plugins/data/public'; +import { IStorageWrapper } from '../../../../../src/plugins/opensearch_dashboards_utils/public'; + +export function getPersistedLog( + uiSettings: IUiSettingsClient, + storage: IStorageWrapper, + language: string +) { + return new PersistedLog( + `typeahead:${language}`, + { + maxLength: uiSettings.get(UI_SETTINGS.HISTORY_LIMIT), + filterDuplicates: true, + }, + storage + ); +} diff --git a/public/query_assist/utils/index.ts b/public/query_assist/utils/index.ts new file mode 100644 index 0000000..421893f --- /dev/null +++ b/public/query_assist/utils/index.ts @@ -0,0 +1,4 @@ +export * from './create_extension'; +export * from './errors'; +export * from './get_mds_id'; +export * from './get_persisted_log'; diff --git a/server/index.ts b/server/index.ts index 45aade2..b8437d8 100644 --- a/server/index.ts +++ b/server/index.ts @@ -8,7 +8,9 @@ import { QueryEnhancementsPlugin } from './plugin'; import { configSchema, ConfigSchema } from '../common/config'; export const config: PluginConfigDescriptor = { - exposeToBrowser: {}, + exposeToBrowser: { + queryAssist: true, + }, schema: configSchema, }; diff --git a/server/plugin.ts b/server/plugin.ts index a0ce775..e372dcf 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -4,6 +4,7 @@ */ import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; import { CoreSetup, CoreStart, @@ -13,6 +14,7 @@ import { SharedGlobalConfig, } from '../../../src/core/server'; import { SEARCH_STRATEGY } from '../common'; +import { ConfigSchema } from '../common/config'; import { defineRoutes } from './routes'; import { pplSearchStrategyProvider, sqlSearchStrategyProvider } from './search'; import { @@ -20,13 +22,13 @@ import { QueryEnhancementsPluginSetupDependencies, QueryEnhancementsPluginStart, } from './types'; -import { OpenSearchPPLPlugin, OpenSearchObservabilityPlugin } from './utils'; +import { OpenSearchObservabilityPlugin, OpenSearchPPLPlugin } from './utils'; export class QueryEnhancementsPlugin implements Plugin { private readonly logger: Logger; private readonly config$: Observable; - constructor(initializerContext: PluginInitializerContext) { + constructor(private initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); this.config$ = initializerContext.config.legacy.globalConfig$; } @@ -45,6 +47,15 @@ export class QueryEnhancementsPlugin data.search.registerSearchStrategy(SEARCH_STRATEGY.PPL, pplSearchStrategy); data.search.registerSearchStrategy(SEARCH_STRATEGY.SQL, sqlSearchStrategy); + core.http.registerRouteHandlerContext('query_assist', () => ({ + logger: this.logger, + configPromise: this.initializerContext.config + .create() + .pipe(first()) + .toPromise(), + dataSourceEnabled: !!dataSource, + })); + defineRoutes(this.logger, router, { ppl: pplSearchStrategy, sql: sqlSearchStrategy, diff --git a/server/routes/index.ts b/server/routes/index.ts index d9c0f65..467aa77 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -15,7 +15,8 @@ import { IOpenSearchDashboardsSearchRequest, } from '../../../../src/plugins/data/common'; import { ISearchStrategy } from '../../../../src/plugins/data/server'; -import { SEARCH_STRATEGY, API } from '../../common'; +import { API, SEARCH_STRATEGY } from '../../common'; +import { registerQueryAssistRoutes } from './query_assist'; function defineRoute( logger: Logger, @@ -69,4 +70,5 @@ export function defineRoutes( ) { defineRoute(logger, router, searchStrategies, SEARCH_STRATEGY.PPL); defineRoute(logger, router, searchStrategies, SEARCH_STRATEGY.SQL); + registerQueryAssistRoutes(router); } diff --git a/server/routes/query_assist/agents.test.ts b/server/routes/query_assist/agents.test.ts new file mode 100644 index 0000000..f3b4cfa --- /dev/null +++ b/server/routes/query_assist/agents.test.ts @@ -0,0 +1,102 @@ +import { ApiResponse } from '@opensearch-project/opensearch'; +import { ResponseError } from '@opensearch-project/opensearch/lib/errors'; +import { RequestHandlerContext } from 'src/core/server'; +import { CoreRouteHandlerContext } from '../../../../../src/core/server/core_route_handler_context'; +import { coreMock, httpServerMock } from '../../../../../src/core/server/mocks'; +import { loggerMock } from '../../../../../src/core/server/logging/logger.mock'; +import { getAgentIdByConfig, requestAgentByConfig } from './agents'; + +describe('Agents helper functions', () => { + const coreContext = new CoreRouteHandlerContext( + coreMock.createInternalStart(), + httpServerMock.createOpenSearchDashboardsRequest() + ); + const client = coreContext.opensearch.client.asCurrentUser; + const mockedTransport = client.transport.request as jest.Mock; + const context: RequestHandlerContext = { + core: coreContext, + dataSource: jest.fn(), + query_assist: { dataSourceEnabled: false, logger: loggerMock.create() }, + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('searches agent id by name', async () => { + mockedTransport.mockResolvedValueOnce({ + body: { + type: 'agent', + configuration: { agent_id: 'agentId' }, + }, + }); + const id = await getAgentIdByConfig(client, 'test_agent'); + expect(id).toEqual('agentId'); + expect(mockedTransport.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "method": "GET", + "path": "/_plugins/_ml/config/test_agent", + }, + ] + `); + }); + + it('handles not found errors', async () => { + mockedTransport.mockRejectedValueOnce( + new ResponseError(({ + body: { + error: { + root_cause: [ + { + type: 'status_exception', + reason: 'Failed to find config with the provided config id: test_agent', + }, + ], + type: 'status_exception', + reason: 'Failed to find config with the provided config id: test_agent', + }, + status: 404, + }, + statusCode: 404, + } as unknown) as ApiResponse) + ); + await expect( + getAgentIdByConfig(client, 'test agent') + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Get agent 'test agent' failed, reason: {\\"error\\":{\\"root_cause\\":[{\\"type\\":\\"status_exception\\",\\"reason\\":\\"Failed to find config with the provided config id: test_agent\\"}],\\"type\\":\\"status_exception\\",\\"reason\\":\\"Failed to find config with the provided config id: test_agent\\"},\\"status\\":404}"` + ); + }); + + it('handles search errors', async () => { + mockedTransport.mockRejectedValueOnce('request failed'); + await expect( + getAgentIdByConfig(client, 'test agent') + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Get agent 'test agent' failed, reason: request failed"` + ); + }); + + it('searches for agent id and sends request', async () => { + mockedTransport + .mockResolvedValueOnce({ + body: { + type: 'agent', + configuration: { agent_id: 'new-id' }, + }, + }) + .mockResolvedValueOnce({ + body: { inference_results: [{ output: [{ result: 'test response' }] }] }, + }); + const response = await requestAgentByConfig({ + context, + configName: 'new_agent', + body: { parameters: { param1: 'value1' } }, + }); + expect(mockedTransport).toBeCalledWith( + expect.objectContaining({ path: '/_plugins/_ml/agents/new-id/_execute' }), + expect.anything() + ); + expect(response.body.inference_results[0].output[0].result).toEqual('test response'); + }); +}); diff --git a/server/routes/query_assist/agents.ts b/server/routes/query_assist/agents.ts new file mode 100644 index 0000000..ce93c61 --- /dev/null +++ b/server/routes/query_assist/agents.ts @@ -0,0 +1,66 @@ +import { ApiResponse } from '@opensearch-project/opensearch'; +import { RequestBody, TransportRequestPromise } from '@opensearch-project/opensearch/lib/Transport'; +import { RequestHandlerContext } from 'src/core/server'; +import { URI } from '../../../common'; + +const AGENT_REQUEST_OPTIONS = { + /** + * It is time-consuming for LLM to generate final answer + * Give it a large timeout window + */ + requestTimeout: 5 * 60 * 1000, + /** + * Do not retry + */ + maxRetries: 0, +}; + +export type AgentResponse = ApiResponse<{ + inference_results: Array<{ + output: Array<{ name: string; result?: string }>; + }>; +}>; + +type OpenSearchClient = RequestHandlerContext['core']['opensearch']['client']['asCurrentUser']; + +export const getAgentIdByConfig = async ( + client: OpenSearchClient, + configName: string +): Promise => { + try { + const response = (await client.transport.request({ + method: 'GET', + path: `${URI.ML}/config/${configName}`, + })) as ApiResponse<{ type: string; configuration: { agent_id?: string } }>; + + if (!response || response.body.configuration.agent_id === undefined) { + throw new Error('cannot find any agent by configuration: ' + configName); + } + return response.body.configuration.agent_id; + } catch (error) { + const errorMessage = JSON.stringify(error.meta?.body) || error; + throw new Error(`Get agent '${configName}' failed, reason: ` + errorMessage); + } +}; + +export const requestAgentByConfig = async (options: { + context: RequestHandlerContext; + configName: string; + body: RequestBody; + dataSourceId?: string; +}): Promise => { + const { context, configName, body, dataSourceId } = options; + const client = + context.query_assist.dataSourceEnabled && dataSourceId + ? await context.dataSource.opensearch.getClient(dataSourceId) + : context.core.opensearch.client.asCurrentUser; + const agentId = await getAgentIdByConfig(client, configName); + return client.transport.request( + { + method: 'POST', + path: `${URI.ML}/agents/${agentId}/_execute`, + body, + }, + AGENT_REQUEST_OPTIONS + ) as TransportRequestPromise; +}; diff --git a/server/routes/query_assist/createResponse.ts b/server/routes/query_assist/createResponse.ts new file mode 100644 index 0000000..c72305b --- /dev/null +++ b/server/routes/query_assist/createResponse.ts @@ -0,0 +1,23 @@ +import { QueryAssistResponse } from '../../../common/query_assist'; +import { AgentResponse } from './agents'; +import { createPPLResponseBody } from './ppl/create_response'; + +export const createResponseBody = ( + language: string, + agentResponse: AgentResponse +): QueryAssistResponse => { + switch (language) { + case 'PPL': + return createPPLResponseBody(agentResponse); + + default: + if (!agentResponse.body.inference_results[0].output[0].result) + throw new Error('Generated query not found.'); + const result = JSON.parse( + agentResponse.body.inference_results[0].output[0].result! + ) as Record; + const query = Object.values(result).at(0); + if (typeof query !== 'string') throw new Error('Generated query not found.'); + return { query }; + } +}; diff --git a/server/routes/query_assist/index.ts b/server/routes/query_assist/index.ts new file mode 100644 index 0000000..5f6c2c9 --- /dev/null +++ b/server/routes/query_assist/index.ts @@ -0,0 +1 @@ +export { registerQueryAssistRoutes } from './routes'; diff --git a/server/routes/query_assist/ppl/create_response.ts b/server/routes/query_assist/ppl/create_response.ts new file mode 100644 index 0000000..5d541a0 --- /dev/null +++ b/server/routes/query_assist/ppl/create_response.ts @@ -0,0 +1,16 @@ +import { QueryAssistResponse } from '../../../../common/query_assist'; +import { AgentResponse } from '../agents'; + +export const createPPLResponseBody = (agentResponse: AgentResponse): QueryAssistResponse => { + if (!agentResponse.body.inference_results[0].output[0].result) + throw new Error('Generated query not found.'); + const result = JSON.parse(agentResponse.body.inference_results[0].output[0].result!) as { + ppl: string; + }; + const ppl = result.ppl + .replace(/[\r\n]/g, ' ') + .trim() + .replace(/ISNOTNULL/g, 'isnotnull') // https://github.com/opensearch-project/sql/issues/2431 + .replace(/\bSPAN\(/g, 'span('); // https://github.com/opensearch-project/dashboards-observability/issues/759 + return { query: ppl }; +}; diff --git a/server/routes/query_assist/routes.ts b/server/routes/query_assist/routes.ts new file mode 100644 index 0000000..51c07d7 --- /dev/null +++ b/server/routes/query_assist/routes.ts @@ -0,0 +1,88 @@ +import { schema } from '@osd/config-schema'; +import { IRouter } from 'opensearch-dashboards/server'; +import { isResponseError } from '../../../../../src/core/server/opensearch/client/errors'; +import { API, ERROR_DETAILS } from '../../../common'; +import { getAgentIdByConfig, requestAgentByConfig } from './agents'; +import { createResponseBody } from './createResponse'; + +export function registerQueryAssistRoutes(router: IRouter) { + router.get( + { + path: API.QUERY_ASSIST.LANGUAGES, + validate: { + query: schema.object({ + dataSourceId: schema.maybe(schema.string()), + }), + }, + }, + async (context, request, response) => { + const config = await context.query_assist.configPromise; + const client = + context.query_assist.dataSourceEnabled && request.query.dataSourceId + ? await context.dataSource.opensearch.getClient(request.query.dataSourceId) + : context.core.opensearch.client.asCurrentUser; + const configuredLanguages: string[] = []; + try { + await Promise.allSettled( + config.queryAssist.supportedLanguages.map((languageConfig) => + // if the call does not throw any error, then the agent is properly configured + getAgentIdByConfig(client, languageConfig.agentConfig).then(() => + configuredLanguages.push(languageConfig.language) + ) + ) + ); + return response.ok({ body: { configuredLanguages } }); + } catch (error) { + return response.ok({ body: { configuredLanguages, error: error.message } }); + } + } + ); + + router.post( + { + path: API.QUERY_ASSIST.GENERATE, + validate: { + body: schema.object({ + index: schema.string(), + question: schema.string(), + language: schema.string(), + dataSourceId: schema.maybe(schema.string()), + }), + }, + }, + async (context, request, response) => { + const config = await context.query_assist.configPromise; + const languageConfig = config.queryAssist.supportedLanguages.find( + (c) => c.language === request.body.language + ); + if (!languageConfig) return response.badRequest({ body: 'Unsupported language' }); + try { + const agentResponse = await requestAgentByConfig({ + context, + configName: languageConfig.agentConfig, + body: { + parameters: { + index: request.body.index, + question: request.body.question, + }, + }, + dataSourceId: request.body.dataSourceId, + }); + const responseBody = createResponseBody(languageConfig.language, agentResponse); + return response.ok({ body: responseBody }); + } catch (error) { + if (isResponseError(error)) { + if (error.statusCode === 400 && error.body.includes(ERROR_DETAILS.GUARDRAILS_TRIGGERED)) + return response.badRequest({ body: ERROR_DETAILS.GUARDRAILS_TRIGGERED }); + return response.badRequest({ + body: + typeof error.meta.body === 'string' + ? error.meta.body + : JSON.stringify(error.meta.body), + }); + } + return response.custom({ statusCode: error.statusCode || 500, body: error.message }); + } + } + ); +} diff --git a/server/types.ts b/server/types.ts index 7335075..ab85fe2 100644 --- a/server/types.ts +++ b/server/types.ts @@ -4,7 +4,9 @@ */ import { DataPluginSetup } from 'src/plugins/data/server/plugin'; +import { Logger } from '../../../src/core/server'; import { DataSourcePluginStart } from '../../../src/plugins/data_source/server'; +import { ConfigSchema } from '../common/config'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface QueryEnhancementsPluginSetup {} @@ -48,3 +50,13 @@ export interface FacetRequest { format?: string; }; } + +declare module '../../../src/core/server' { + interface RequestHandlerContext { + query_assist: { + logger: Logger; + configPromise: Promise; + dataSourceEnabled: boolean; + }; + } +} diff --git a/test/__mocks__/fileMock.js b/test/__mocks__/fileMock.js new file mode 100644 index 0000000..cac247b --- /dev/null +++ b/test/__mocks__/fileMock.js @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +module.exports = 'file-stub'; diff --git a/test/__mocks__/styleMock.js b/test/__mocks__/styleMock.js new file mode 100644 index 0000000..28de3c8 --- /dev/null +++ b/test/__mocks__/styleMock.js @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +module.exports = {}; diff --git a/test/jest.config.js b/test/jest.config.js new file mode 100644 index 0000000..f5a1419 --- /dev/null +++ b/test/jest.config.js @@ -0,0 +1,31 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +process.env.TZ = 'UTC'; + +module.exports = { + rootDir: '../', + setupFiles: ['/test/setupTests.ts'], + setupFilesAfterEnv: ['/test/setup.jest.ts'], + roots: [''], + testMatch: ['**/*.test.js', '**/*.test.jsx', '**/*.test.ts', '**/*.test.tsx'], + clearMocks: true, + modulePathIgnorePatterns: ['/offline-module-cache/'], + testPathIgnorePatterns: ['/build/', '/node_modules/', '/__utils__/'], + snapshotSerializers: ['enzyme-to-json/serializer'], + coveragePathIgnorePatterns: [ + '/build/', + '/node_modules/', + '/test/', + '/public/requests/', + '/__utils__/', + ], + moduleNameMapper: { + '\\.(css|less|sass|scss)$': '/test/__mocks__/styleMock.js', + '\\.(gif|ttf|eot|svg|png)$': '/test/__mocks__/fileMock.js', + '^!!raw-loader!.*': 'jest-raw-loader', + }, + testEnvironment: 'jsdom', +}; diff --git a/test/setup.jest.ts b/test/setup.jest.ts new file mode 100644 index 0000000..9d19503 --- /dev/null +++ b/test/setup.jest.ts @@ -0,0 +1,48 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { configure } from '@testing-library/react'; +import { TextDecoder, TextEncoder } from 'util'; +import '@testing-library/jest-dom'; + +configure({ testIdAttribute: 'data-test-subj' }); + +// https://github.com/inrupt/solid-client-authn-js/issues/1676#issuecomment-917016646 +global.TextEncoder = TextEncoder; +global.TextDecoder = TextDecoder as typeof global.TextDecoder; + +window.URL.createObjectURL = () => ''; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +HTMLCanvasElement.prototype.getContext = () => '' as any; +Element.prototype.scrollIntoView = jest.fn(); +window.IntersectionObserver = (class IntersectionObserver { + constructor() {} + + disconnect() { + return null; + } + + observe() { + return null; + } + + takeRecords() { + return null; + } + + unobserve() { + return null; + } +} as unknown) as typeof window.IntersectionObserver; + +jest.mock('@elastic/eui/lib/components/form/form_row/make_id', () => () => 'random-id'); + +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ + htmlIdGenerator: () => { + return () => 'random_html_id'; + }, +})); + +jest.setTimeout(30000); diff --git a/test/setupTests.ts b/test/setupTests.ts new file mode 100644 index 0000000..5a996f6 --- /dev/null +++ b/test/setupTests.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +require('babel-polyfill'); +require('core-js/stable');