diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 787828c89e98..32039274b174 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2829,6 +2829,7 @@ src/platform/testfunctional/page_objects/solution_navigation.ts @elastic/appex-s /x-pack/test_serverless/functional/page_objects/svl_common_navigation.ts @elastic/appex-sharedux /x-pack/test_serverless/functional/page_objects/svl_sec_landing_page.ts @elastic/appex-sharedux /x-pack/test_serverless/functional/test_suites/security/ftr/navigation.ts @elastic/appex-sharedux +/x-pack/test/api_integration/deployment_agnostic/apis/intercepts/*.ts @elastic/appex-sharedux # OpenAPI spec files oas_docs/.spectral.yaml @elastic/platform-docs diff --git a/config/serverless.chat.yml b/config/serverless.chat.yml index 23b429b97d93..fc8fa396e976 100644 --- a/config/serverless.chat.yml +++ b/config/serverless.chat.yml @@ -23,4 +23,7 @@ xpack.wciExternalServer.enabled: true xpack.spaces.maxSpaces: 1 ## Content Connectors in stack management -xpack.contentConnectors.enabled: false \ No newline at end of file +xpack.contentConnectors.enabled: false + +## Disable Kibana Product Intercept +xpack.product_intercept.enabled: false diff --git a/config/serverless.yml b/config/serverless.yml index 5ef349b9f25c..ccddd0077f0b 100644 --- a/config/serverless.yml +++ b/config/serverless.yml @@ -266,3 +266,7 @@ xpack.dataUsage.enableExperimental: ['dataUsageDisabled'] ## Content Connectors in stack management xpack.contentConnectors.enabled: true + +## Enable Kibana Product Intercept +xpack.product_intercept.enabled: true +xpack.product_intercept.interval: '90d' diff --git a/src/platform/test/common/config.js b/src/platform/test/common/config.js index 74c4b8e41db0..d47745d6c4c7 100644 --- a/src/platform/test/common/config.js +++ b/src/platform/test/common/config.js @@ -84,8 +84,6 @@ export default function () { pattern: '[%date][%level][%logger] %message %meta', }, })}`, - // disable product intercept for all ftr tests by default - '--xpack.intercepts.enabled=false', ], }, }; diff --git a/src/platform/test/functional/config.base.js b/src/platform/test/functional/config.base.js index 6d1610a79bb7..44ea067ccc5f 100644 --- a/src/platform/test/functional/config.base.js +++ b/src/platform/test/functional/config.base.js @@ -42,9 +42,6 @@ export default async function ({ readConfigFile }) { // disable fleet task that writes to metrics.fleet_server.* data streams, impacting functional tests `--xpack.task_manager.unsafe.exclude_task_types=${JSON.stringify(['Fleet-Metrics-Task'])}`, - - // disable product intercept - '--xpack.intercepts.enabled=false', ], }, diff --git a/src/platform/test/plugin_functional/test_suites/core_plugins/rendering.ts b/src/platform/test/plugin_functional/test_suites/core_plugins/rendering.ts index f2ec000f8958..0a642d3323c3 100644 --- a/src/platform/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/src/platform/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -305,6 +305,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'xpack.index_management.editableIndexSettings (all?|limited?|never)', 'xpack.index_management.enableMappingsSourceFieldSection (boolean?|never)', 'xpack.index_management.dev.enableSemanticText (boolean?)', + 'xpack.intercepts.enabled (boolean?)', 'xpack.license_management.ui.enabled (boolean?)', 'xpack.maps.preserveDrawingBuffer (boolean?)', 'xpack.maps.showMapsInspectorAdapter (boolean?)', diff --git a/x-pack/platform/plugins/private/intercepts/common/constants.ts b/x-pack/platform/plugins/private/intercepts/common/constants.ts index 1e7d6e2ecd2e..bba88b6afca9 100644 --- a/x-pack/platform/plugins/private/intercepts/common/constants.ts +++ b/x-pack/platform/plugins/private/intercepts/common/constants.ts @@ -5,7 +5,7 @@ * 2.0. */ -export const TRIGGER_INFO_API_ROUTE = '/internal/product_intercept/trigger_info' as const; +export const TRIGGER_INFO_API_ROUTE = '/internal/api/intercepts/trigger_info' as const; export const TRIGGER_USER_INTERACTION_METADATA_API_ROUTE = - '/internal/api/intercept/user_interaction/{triggerId}' as const; + '/internal/api/intercepts/user_interaction/{triggerId}' as const; diff --git a/x-pack/platform/plugins/private/intercepts/public/index.ts b/x-pack/platform/plugins/private/intercepts/public/index.ts index 2f5a8f2ee261..023f6b039a1d 100644 --- a/x-pack/platform/plugins/private/intercepts/public/index.ts +++ b/x-pack/platform/plugins/private/intercepts/public/index.ts @@ -8,6 +8,7 @@ import type { PluginInitializerContext } from '@kbn/core/public'; import { InterceptPublicPlugin } from './plugin'; export type { InterceptsSetup, InterceptsStart } from './plugin'; +export type { Intercept } from './prompter'; export function plugin(initializerContext: PluginInitializerContext) { return new InterceptPublicPlugin(initializerContext); diff --git a/x-pack/platform/plugins/private/intercepts/public/prompter/index.ts b/x-pack/platform/plugins/private/intercepts/public/prompter/index.ts index 54584ebc9e4f..fbc63ebb8157 100644 --- a/x-pack/platform/plugins/private/intercepts/public/prompter/index.ts +++ b/x-pack/platform/plugins/private/intercepts/public/prompter/index.ts @@ -6,3 +6,4 @@ */ export { InterceptPrompter } from './prompter'; +export type { Intercept } from './prompter'; diff --git a/x-pack/platform/plugins/private/intercepts/public/prompter/prompter.test.ts b/x-pack/platform/plugins/private/intercepts/public/prompter/prompter.test.ts index 75d49044a794..f91a958985be 100644 --- a/x-pack/platform/plugins/private/intercepts/public/prompter/prompter.test.ts +++ b/x-pack/platform/plugins/private/intercepts/public/prompter/prompter.test.ts @@ -11,7 +11,7 @@ import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks'; import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks'; import { renderingServiceMock } from '@kbn/core-rendering-browser-mocks'; import { InterceptDialogService } from './service'; -import { InterceptPrompter } from './prompter'; +import { InterceptPrompter, Intercept } from './prompter'; import { TRIGGER_INFO_API_ROUTE } from '../../common/constants'; import type { TriggerInfo } from '../../common/types'; @@ -64,9 +64,7 @@ describe('ProductInterceptPrompter', () => { const mockQueueInterceptFn = jest.fn(); - const interceptSteps: Parameters< - ReturnType['registerIntercept'] - >[0]['steps'] = [ + const interceptSteps: Intercept['steps'] = [ { id: 'start' as const, title: 'Hello', @@ -84,14 +82,13 @@ describe('ProductInterceptPrompter', () => { }, ]; - const intercept: Parameters['registerIntercept']>[0] = - { - id: 'test-intercept', - steps: interceptSteps, - onFinish: jest.fn(), - onDismiss: jest.fn(), - onProgress: jest.fn(), - }; + const intercept: Intercept = { + id: 'test-intercept', + steps: interceptSteps, + onFinish: jest.fn(), + onDismiss: jest.fn(), + onProgress: jest.fn(), + }; beforeEach(() => { jest.useFakeTimers(); @@ -121,7 +118,10 @@ describe('ProductInterceptPrompter', () => { triggerIntervalInMs: 1000, }); - const intercept$ = registerIntercept(intercept); + const intercept$ = registerIntercept({ + id: intercept.id, + config: () => Promise.resolve(intercept), + }); expect(intercept$).toBeInstanceOf(Rx.Observable); }); @@ -132,7 +132,10 @@ describe('ProductInterceptPrompter', () => { triggerIntervalInMs: 1000, }); - const intercept$ = registerIntercept(intercept); + const intercept$ = registerIntercept({ + id: intercept.id, + config: () => Promise.resolve(intercept), + }); expect(intercept$).toBeInstanceOf(Rx.Observable); @@ -152,233 +155,305 @@ describe('ProductInterceptPrompter', () => { subscription.unsubscribe(); }); - it('adds an intercept if the user has not already encountered the next scheduled run', async () => { - const triggerInfo: TriggerInfo = { - registeredAt: new Date( - '26 March 2025 19:08 GMT+0100 (Central European Standard Time)' - ).toISOString(), - triggerIntervalInMs: 30000, - recurrent: true, - }; + describe('within safeTimer Interval bounds', () => { + const triggerIntervalInMs = 30000; // 30 seconds - const triggerRuns = 30; - const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3; + it('adds an intercept if the user has not already encountered the next scheduled run', async () => { + const triggerInfo: TriggerInfo = { + registeredAt: new Date( + '26 March 2025 19:08 GMT+0100 (Central European Standard Time)' + ).toISOString(), + triggerIntervalInMs, + recurrent: true, + }; - // return the configured trigger info - jest.spyOn(http, 'post').mockResolvedValue(triggerInfo); - jest.spyOn(http, 'get').mockResolvedValue({ lastInteractedInterceptId: triggerRuns }); + const triggerRuns = 30; + const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3; - // set system time to time in the future, where there would have been 30 runs of the received trigger, - // with just about 1/3 of the time before the next trigger - jest.setSystemTime( - new Date( - Date.parse(triggerInfo.registeredAt) + - triggerInfo.triggerIntervalInMs * triggerRuns + - triggerInfo.triggerIntervalInMs - - timeInMsTillNextRun - ) - ); + // return the configured trigger info + jest.spyOn(http, 'post').mockResolvedValue(triggerInfo); + jest.spyOn(http, 'get').mockResolvedValue({ lastInteractedInterceptId: triggerRuns }); - const subscriptionHandler = jest.fn(); + // set system time to time in the future, where there would have been 30 runs of the received trigger, + // with just about 1/3 of the time before the next trigger + jest.setSystemTime( + new Date( + Date.parse(triggerInfo.registeredAt) + + triggerInfo.triggerIntervalInMs * triggerRuns + + triggerInfo.triggerIntervalInMs - + timeInMsTillNextRun + ) + ); - const intercept$ = registerIntercept(intercept); + const subscriptionHandler = jest.fn(); - const subscription = intercept$.subscribe(subscriptionHandler); - - await jest.runOnlyPendingTimersAsync(); - - expect(http.post).toHaveBeenCalledWith(TRIGGER_INFO_API_ROUTE, { - body: JSON.stringify({ triggerId: intercept.id }), - }); - - jest.advanceTimersByTime(timeInMsTillNextRun); - - expect(mockQueueInterceptFn).toHaveBeenCalledWith( - expect.objectContaining({ + const intercept$ = registerIntercept({ id: intercept.id, - runId: triggerRuns + 1, - }) - ); + config: () => Promise.resolve(intercept), + }); - subscription.unsubscribe(); - }); + const subscription = intercept$.subscribe(subscriptionHandler); - it('does not add an intercept if the user has already encountered the currently scheduled run', async () => { - const triggerInfo: TriggerInfo = { - registeredAt: new Date( - '26 March 2025 19:08 GMT+0100 (Central European Standard Time)' - ).toISOString(), - triggerIntervalInMs: 30000, - recurrent: true, - }; + await jest.runOnlyPendingTimersAsync(); - const triggerRuns = 30; - const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3; + expect(http.post).toHaveBeenCalledWith(TRIGGER_INFO_API_ROUTE, { + body: JSON.stringify({ triggerId: intercept.id }), + }); - // return the configured trigger info - jest.spyOn(http, 'post').mockResolvedValue(triggerInfo); - jest.spyOn(http, 'get').mockResolvedValue({ lastInteractedInterceptId: triggerRuns + 1 }); + expect(mockQueueInterceptFn).toHaveBeenCalledWith( + expect.objectContaining({ + id: intercept.id, + runId: triggerRuns + 1, + }) + ); - // set system time to time in the future, where there would have been 30 runs of the received trigger, - // with just about 1/3 of the time before the next trigger - jest.setSystemTime( - new Date( - Date.parse(triggerInfo.registeredAt) + - triggerInfo.triggerIntervalInMs * triggerRuns + - triggerInfo.triggerIntervalInMs - - timeInMsTillNextRun - ) - ); - - const subscriptionHandler = jest.fn(); - - const intercept$ = registerIntercept(intercept); - - const subscription = intercept$.subscribe(subscriptionHandler); - - await jest.runOnlyPendingTimersAsync(); - - expect(http.post).toHaveBeenCalledWith(TRIGGER_INFO_API_ROUTE, { - body: JSON.stringify({ triggerId: intercept.id }), + subscription.unsubscribe(); }); - jest.advanceTimersByTime(timeInMsTillNextRun); + it('does not add an intercept if the user has already encountered the currently scheduled run', async () => { + const triggerInfo: TriggerInfo = { + registeredAt: new Date( + '26 March 2025 19:08 GMT+0100 (Central European Standard Time)' + ).toISOString(), + triggerIntervalInMs, + recurrent: true, + }; - expect(mockQueueInterceptFn).not.toHaveBeenCalled(); + const triggerRuns = 30; + const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3; - subscription.unsubscribe(); - }); + // return the configured trigger info + jest.spyOn(http, 'post').mockResolvedValue(triggerInfo); + jest.spyOn(http, 'get').mockResolvedValue({ lastInteractedInterceptId: triggerRuns + 1 }); - it('does not add an intercept if the trigger is expected to be shown only once and the user already encountered that single run of the intercept', async () => { - const triggerInfo = { - registeredAt: new Date( - '26 March 2025 19:08 GMT+0100 (Central European Standard Time)' - ).toISOString(), - triggerIntervalInMs: 30000, - recurrent: false, - }; + // set system time to time in the future, where there would have been 30 runs of the received trigger, + // with just about 1/3 of the time before the next trigger + jest.setSystemTime( + new Date( + Date.parse(triggerInfo.registeredAt) + + triggerInfo.triggerIntervalInMs * triggerRuns + + triggerInfo.triggerIntervalInMs - + timeInMsTillNextRun + ) + ); - const triggerRuns = 30; - const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3; + const subscriptionHandler = jest.fn(); - // return the configured trigger info - jest.spyOn(http, 'post').mockResolvedValue(triggerInfo); - // configure a user that encountered the intercept on the 30th run - jest.spyOn(http, 'get').mockResolvedValue({ lastInteractedInterceptId: triggerRuns }); + const intercept$ = registerIntercept({ + id: intercept.id, + config: () => Promise.resolve(intercept), + }); - // set system time to time in the future, where there would have been 30 runs of the received trigger, - // with just about 1/3 of the time before the next trigger - jest.setSystemTime( - new Date( - Date.parse(triggerInfo.registeredAt) + - triggerInfo.triggerIntervalInMs * triggerRuns + - triggerInfo.triggerIntervalInMs - - timeInMsTillNextRun - ) - ); + const subscription = intercept$.subscribe(subscriptionHandler); - const subscriptionHandler = jest.fn(); + await jest.runOnlyPendingTimersAsync(); - const intercept$ = registerIntercept(intercept); + expect(http.post).toHaveBeenCalledWith(TRIGGER_INFO_API_ROUTE, { + body: JSON.stringify({ triggerId: intercept.id }), + }); - const subscription = intercept$.subscribe(subscriptionHandler); + jest.advanceTimersByTime(timeInMsTillNextRun); - await jest.runOnlyPendingTimersAsync(); + expect(mockQueueInterceptFn).not.toHaveBeenCalled(); - expect(http.post).toHaveBeenCalledWith(TRIGGER_INFO_API_ROUTE, { - body: JSON.stringify({ triggerId: intercept.id }), + subscription.unsubscribe(); }); - jest.advanceTimersByTime(timeInMsTillNextRun); + it('does not add an intercept if the trigger is expected to be shown only once and the user already encountered that single run of the intercept', async () => { + const triggerInfo = { + registeredAt: new Date( + '26 March 2025 19:08 GMT+0100 (Central European Standard Time)' + ).toISOString(), + triggerIntervalInMs, + recurrent: false, + }; - // we should not queue the intercept, - // because the user already encountered it especially that it's a one off - expect(mockQueueInterceptFn).not.toHaveBeenCalled(); + const triggerRuns = 30; + const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3; - subscription.unsubscribe(); - }); + // return the configured trigger info + jest.spyOn(http, 'post').mockResolvedValue(triggerInfo); + // configure a user that encountered the intercept on the 30th run + jest.spyOn(http, 'get').mockResolvedValue({ lastInteractedInterceptId: triggerRuns }); - it('queue another intercept automatically after the configured trigger interval when the time for displaying the intercept for the initial run has elapsed', async () => { - const triggerInfo = { - registeredAt: new Date( - '26 March 2025 19:08 GMT+0100 (Central European Standard Time)' - ).toISOString(), - triggerIntervalInMs: 30000, - recurrent: true, - }; + // set system time to time in the future, where there would have been 30 runs of the received trigger, + // with just about 1/3 of the time before the next trigger + jest.setSystemTime( + new Date( + Date.parse(triggerInfo.registeredAt) + + triggerInfo.triggerIntervalInMs * triggerRuns + + triggerInfo.triggerIntervalInMs - + timeInMsTillNextRun + ) + ); - const triggerRuns = 30; - const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3; + const subscriptionHandler = jest.fn(); - // return the configured trigger info - jest.spyOn(http, 'post').mockResolvedValue(triggerInfo); - jest.spyOn(http, 'get').mockResolvedValue({ lastInteractedInterceptId: triggerRuns }); + const intercept$ = registerIntercept({ + id: intercept.id, + config: () => Promise.resolve(intercept), + }); - // set system time to time in the future, where there would have been 30 runs of the received trigger, - // with just about 1/3 of the time before the next trigger - jest.setSystemTime( - new Date( - Date.parse(triggerInfo.registeredAt) + - triggerInfo.triggerIntervalInMs * triggerRuns + - triggerInfo.triggerIntervalInMs - - timeInMsTillNextRun - ) - ); + const subscription = intercept$.subscribe(subscriptionHandler); - const _intercept = { - ...intercept, - id: 'test-repeat-intercept', - }; + await jest.runOnlyPendingTimersAsync(); - const subscriptionHandler = jest.fn(({ lastInteractedInterceptId }) => { - // simulate persistence of the user interaction with the intercept - jest - .spyOn(http, 'get') - .mockResolvedValue({ lastInteractedInterceptId: lastInteractedInterceptId + 1 }); + expect(http.post).toHaveBeenCalledWith(TRIGGER_INFO_API_ROUTE, { + body: JSON.stringify({ triggerId: intercept.id }), + }); + + jest.advanceTimersByTime(timeInMsTillNextRun); + + // we should not queue the intercept, + // because the user already encountered it especially that it's a one off + expect(mockQueueInterceptFn).not.toHaveBeenCalled(); + + subscription.unsubscribe(); }); - const intercept$ = registerIntercept(_intercept); + it('queues another intercept automatically after the configured trigger interval when the time for displaying the intercept for the initial run has elapsed', async () => { + const triggerInfo = { + registeredAt: new Date( + '26 March 2025 19:08 GMT+0100 (Central European Standard Time)' + ).toISOString(), + triggerIntervalInMs, + recurrent: true, + }; - const subscription = intercept$.subscribe(subscriptionHandler); + const triggerRuns = 30; + const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3; - await jest.runOnlyPendingTimersAsync(); + // return the configured trigger info + jest.spyOn(http, 'post').mockResolvedValue(triggerInfo); + jest.spyOn(http, 'get').mockResolvedValue({ lastInteractedInterceptId: triggerRuns }); - expect(http.post).toHaveBeenCalledWith(TRIGGER_INFO_API_ROUTE, { - body: JSON.stringify({ triggerId: _intercept.id }), - }); + // set system time to time in the future, where there would have been 30 runs of the received trigger, + // with just about 1/3 of the time before the next trigger + jest.setSystemTime( + new Date( + Date.parse(triggerInfo.registeredAt) + + triggerInfo.triggerIntervalInMs * triggerRuns + + triggerInfo.triggerIntervalInMs - + timeInMsTillNextRun + ) + ); - jest.advanceTimersByTime(timeInMsTillNextRun); + const _intercept = { + ...intercept, + id: 'test-repeat-intercept', + }; - expect(mockQueueInterceptFn).toHaveBeenCalledWith( - expect.objectContaining({ + const subscriptionHandler = jest.fn(({ lastInteractedInterceptId }) => { + // simulate persistence of the user interaction with the intercept + jest + .spyOn(http, 'get') + .mockResolvedValue({ lastInteractedInterceptId: lastInteractedInterceptId + 1 }); + }); + + const intercept$ = registerIntercept({ id: _intercept.id, - runId: triggerRuns + 1, - }) - ); + config: () => Promise.resolve(_intercept), + }); - expect(subscriptionHandler).toHaveBeenCalledWith( - expect.objectContaining({ - lastInteractedInterceptId: triggerRuns, - }) - ); + const subscription = intercept$.subscribe(subscriptionHandler); - // advance to next run and wait for all promises to resolve - await jest.advanceTimersByTimeAsync(triggerInfo.triggerIntervalInMs); + await jest.runOnlyPendingTimersAsync(); - expect(mockQueueInterceptFn).toHaveBeenLastCalledWith( - expect.objectContaining({ - id: _intercept.id, - runId: triggerRuns + 2, - }) - ); + expect(http.post).toHaveBeenCalledWith(TRIGGER_INFO_API_ROUTE, { + body: JSON.stringify({ triggerId: _intercept.id }), + }); - expect(subscriptionHandler).toHaveBeenCalledWith( - expect.objectContaining({ - lastInteractedInterceptId: triggerRuns + 1, - }) - ); + jest.advanceTimersByTime(timeInMsTillNextRun); - subscription.unsubscribe(); + expect(mockQueueInterceptFn).toHaveBeenCalledWith( + expect.objectContaining({ + id: _intercept.id, + runId: triggerRuns + 1, + }) + ); + + expect(subscriptionHandler).toHaveBeenCalledWith( + expect.objectContaining({ + lastInteractedInterceptId: triggerRuns, + }) + ); + + // advance to next run and wait for all promises to resolve + await jest.advanceTimersByTimeAsync(triggerInfo.triggerIntervalInMs); + + expect(mockQueueInterceptFn).toHaveBeenLastCalledWith( + expect.objectContaining({ + id: _intercept.id, + runId: triggerRuns + 2, + }) + ); + + expect(subscriptionHandler).toHaveBeenCalledWith( + expect.objectContaining({ + lastInteractedInterceptId: triggerRuns + 1, + }) + ); + + subscription.unsubscribe(); + }); + }); + + describe('outside safeTimer Interval bounds', () => { + const triggerIntervalInMs = 30 * 24 * 60 * 60 * 1000; // 30 days + + it('handles a trigger interval that exceeds the safe timer bounds gracefully', async () => { + const triggerInfo: TriggerInfo = { + registeredAt: new Date( + '28 May 2025 19:08 GMT+0100 (Central European Standard Time)' + ).toISOString(), + triggerIntervalInMs, + recurrent: true, + }; + + const triggerRuns = 30; + const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3; + + // have call to http handler return predefined values + jest.spyOn(http, 'post').mockResolvedValue(triggerInfo); + jest.spyOn(http, 'get').mockResolvedValue({ lastInteractedInterceptId: triggerRuns }); + + // set system time to time in the future, where there would have been 30 runs of the received trigger, + // with just about 1/3 of the time before the next trigger + jest.setSystemTime( + new Date( + Date.parse(triggerInfo.registeredAt) + + triggerInfo.triggerIntervalInMs * triggerRuns + + triggerInfo.triggerIntervalInMs - + timeInMsTillNextRun + ) + ); + + const subscriptionHandler = jest.fn(); + + const intercept$ = registerIntercept({ + id: intercept.id, + config: () => Promise.resolve(intercept), + }); + const subscription = intercept$.subscribe(subscriptionHandler); + + // cause call to trigger info api route to happen + await jest.runOnlyPendingTimersAsync(); + expect(http.post).toHaveBeenCalledWith(TRIGGER_INFO_API_ROUTE, { + body: JSON.stringify({ triggerId: intercept.id }), + }); + + // advance time to point in time when next run should happen + await jest.advanceTimersByTimeAsync(timeInMsTillNextRun); + + expect(mockQueueInterceptFn).toHaveBeenCalledWith( + expect.objectContaining({ + id: intercept.id, + runId: triggerRuns + 1, + }) + ); + + subscription.unsubscribe(); + }); }); }); }); diff --git a/x-pack/platform/plugins/private/intercepts/public/prompter/prompter.ts b/x-pack/platform/plugins/private/intercepts/public/prompter/prompter.ts index b931c35ce535..d811d2296d15 100644 --- a/x-pack/platform/plugins/private/intercepts/public/prompter/prompter.ts +++ b/x-pack/platform/plugins/private/intercepts/public/prompter/prompter.ts @@ -7,12 +7,15 @@ import * as Rx from 'rxjs'; import type { CoreStart, CoreSetup } from '@kbn/core/public'; +import { apm } from '@elastic/apm-rum'; import { InterceptDialogService, InterceptServiceStartDeps } from './service'; import { UserInterceptRunPersistenceService } from './service/user_intercept_run_persistence_service'; import { Intercept } from './service'; import { TRIGGER_INFO_API_ROUTE } from '../../common/constants'; import { TriggerInfo } from '../../common/types'; +export type { Intercept } from './service'; + type ProductInterceptPrompterSetupDeps = Pick; type ProductInterceptPrompterStartDeps = Omit< InterceptServiceStartDeps, @@ -24,6 +27,12 @@ export class InterceptPrompter { private userInterceptRunPersistenceService = new UserInterceptRunPersistenceService(); private interceptDialogService = new InterceptDialogService(); private queueIntercept?: ReturnType['add']; + // observer for page visibility changes, shared across all intercepts + private pageHidden$?: Rx.Observable; + // Defines safe timer bound at 24 days, javascript browser timers are not reliable for longer intervals + // see https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout#maximum_delay_value, + // rxjs can do longer intervals, but we want to avoid the risk of running into issues with browser timers. + private readonly MAX_TIMER_INTERVAL = 0x7b98a000; // 24 days in milliseconds setup({ analytics, notifications }: ProductInterceptPrompterSetupDeps) { this.interceptDialogService.setup({ analytics, notifications }); @@ -41,6 +50,11 @@ export class InterceptPrompter { staticAssetsHelper: http.staticAssets, })); + this.pageHidden$ = Rx.fromEvent(document, 'visibilitychange').pipe( + Rx.map(() => document.hidden), + Rx.startWith(document.hidden) + ); + return { /** * Configures the intercept journey that will be shown to the user, and returns an observable @@ -56,7 +70,10 @@ export class InterceptPrompter { getUserTriggerData$: ReturnType< UserInterceptRunPersistenceService['start'] >['getUserTriggerData$'], - intercept: Intercept + intercept: { + id: Intercept['id']; + config: () => Promise>; + } ) { let nextRunId: number; @@ -70,36 +87,76 @@ export class InterceptPrompter { .pipe(Rx.filter((response) => !!response)) .pipe( Rx.mergeMap((response) => { - const now = Date.now(); + // anchor for all calculations, this is the time when the trigger was registered + const timePoint = Date.now(); + let diff = 0; // Calculate the number of runs since the trigger was registered const runs = Math.floor( - (diff = now - Date.parse(response.registeredAt)) / response.triggerIntervalInMs + (diff = timePoint - Date.parse(response.registeredAt)) / response.triggerIntervalInMs ); nextRunId = runs + 1; - // Calculate the time until the next run - const nextRun = nextRunId * response.triggerIntervalInMs - diff; + return this.pageHidden$!.pipe( + Rx.switchMap((isHidden) => { + if (isHidden) return Rx.EMPTY; - return Rx.timer(nextRun, response.triggerIntervalInMs).pipe( - Rx.switchMap(() => getUserTriggerData$(intercept.id)), - Rx.takeWhile((triggerData) => { - // Stop the timer if lastInteractedInterceptId is defined and matches nextRunId - if (!response.recurrent && triggerData.lastInteractedInterceptId) { - return false; - } - return true; + return Rx.timer( + Math.min(nextRunId * response.triggerIntervalInMs - diff, this.MAX_TIMER_INTERVAL), + Math.min(response.triggerIntervalInMs, this.MAX_TIMER_INTERVAL) + ).pipe( + Rx.switchMap((timerIterationCount) => { + if (response.triggerIntervalInMs < this.MAX_TIMER_INTERVAL) { + return getUserTriggerData$(intercept.id); + } else { + const timeElapsedSinceRegistration = + diff + this.MAX_TIMER_INTERVAL * timerIterationCount; + + const timeTillTriggerEvent = + nextRunId * response.triggerIntervalInMs - timeElapsedSinceRegistration; + + if (timeTillTriggerEvent <= this.MAX_TIMER_INTERVAL) { + // trigger event would happen sometime within this current slice + // set up a single use timer that will emit the trigger event + return Rx.timer(timeTillTriggerEvent).pipe( + Rx.switchMap(() => { + return getUserTriggerData$(intercept.id); + }) + ); + } else { + // current timer slice requires no action + return Rx.EMPTY; + } + } + }), + Rx.takeWhile((triggerData) => { + // Stop the timer if lastInteractedInterceptId is defined and matches nextRunId + if (!response.recurrent && triggerData.lastInteractedInterceptId) { + return false; + } + return true; + }) + ); }) ); }) ) .pipe( - Rx.tap((triggerData) => { + Rx.tap(async (triggerData) => { if (nextRunId !== triggerData.lastInteractedInterceptId) { - this.queueIntercept?.({ ...intercept, runId: nextRunId }); - nextRunId++; + try { + const interceptConfig = await intercept.config(); + this.queueIntercept?.({ id: intercept.id, runId: nextRunId, ...interceptConfig }); + nextRunId++; + } catch (err) { + apm.captureError(err, { + labels: { + interceptId: intercept.id, + }, + }); + } } }) ); diff --git a/x-pack/platform/plugins/private/intercepts/server/services/intercept_trigger.test.ts b/x-pack/platform/plugins/private/intercepts/server/services/intercept_trigger.test.ts index 7b2d4f127ea0..25865be5d1be 100644 --- a/x-pack/platform/plugins/private/intercepts/server/services/intercept_trigger.test.ts +++ b/x-pack/platform/plugins/private/intercepts/server/services/intercept_trigger.test.ts @@ -8,7 +8,7 @@ import { coreMock } from '@kbn/core/server/mocks'; import { InterceptTriggerService } from './intercept_trigger'; import { interceptTriggerRecordSavedObject } from '../saved_objects'; -import { ISavedObjectsRepository } from '@kbn/core/server'; +import type { ISavedObjectsRepository } from '@kbn/core/server'; describe('InterceptTriggerService', () => { describe('#setup', () => { @@ -18,7 +18,7 @@ describe('InterceptTriggerService', () => { const coreSetupMock = coreMock.createSetup(); interceptTrigger.setup(coreSetupMock, {} as any, { - kibanaVersion: '8.0.0', + kibanaVersion: '9.1.0', }); expect(coreSetupMock.savedObjects.registerType).toHaveBeenCalledWith( diff --git a/x-pack/platform/plugins/private/intercepts/server/services/intercept_trigger.ts b/x-pack/platform/plugins/private/intercepts/server/services/intercept_trigger.ts index 524147b3135e..ac28d1a52096 100644 --- a/x-pack/platform/plugins/private/intercepts/server/services/intercept_trigger.ts +++ b/x-pack/platform/plugins/private/intercepts/server/services/intercept_trigger.ts @@ -6,6 +6,7 @@ */ import { type CoreSetup, type CoreStart, type Logger } from '@kbn/core/server'; +import { SavedObjectsErrorHelpers } from '@kbn/core/server'; import type { ISavedObjectsRepository } from '@kbn/core-saved-objects-api-server'; import { interceptTriggerRecordSavedObject, type InterceptTriggerRecord } from '../saved_objects'; @@ -54,7 +55,13 @@ export class InterceptTriggerService { triggerId ); } catch (err) { - this.logger?.error(err); + if (SavedObjectsErrorHelpers.isNotFoundError(err)) { + // If the task is not found, it means it's not registered yet, so we return null + return null; + } else { + this.logger?.error(`Error fetching registered task: ${err.message}`); + return null; + } } return result?.attributes ?? null; diff --git a/x-pack/platform/plugins/private/intercepts/server/services/intercept_user_interaction.ts b/x-pack/platform/plugins/private/intercepts/server/services/intercept_user_interaction.ts index 7100c9abf856..0ba2c9e96bd1 100644 --- a/x-pack/platform/plugins/private/intercepts/server/services/intercept_user_interaction.ts +++ b/x-pack/platform/plugins/private/intercepts/server/services/intercept_user_interaction.ts @@ -107,7 +107,7 @@ export class InterceptUserInteractionService { // returns an id scoped to the current user private getSavedObjectId = (triggerId: string, userId: string) => `${triggerId}:${userId}`; - public async getUserInteractionSavedObject( + private async getUserInteractionSavedObject( userId: string, triggerId: string ): Promise | null> { @@ -131,7 +131,7 @@ export class InterceptUserInteractionService { } } - public async recordUserInteractionForTrigger( + private async recordUserInteractionForTrigger( userId: string, triggerId: string, data: InterceptInteractionUserRecordAttributes['metadata'] diff --git a/x-pack/platform/plugins/private/product_intercept/common/config.ts b/x-pack/platform/plugins/private/product_intercept/common/config.ts index dff1800bd5f9..95e80a075c4f 100644 --- a/x-pack/platform/plugins/private/product_intercept/common/config.ts +++ b/x-pack/platform/plugins/private/product_intercept/common/config.ts @@ -18,10 +18,10 @@ export const configSchema = schema.object({ * It's worth noting that if the intercept plugin is disabled this setting will have no effect. */ enabled: schema.boolean({ - defaultValue: false, + defaultValue: true, }), interval: schema.string({ - defaultValue: '30m', + defaultValue: '90d', validate(value) { if (!/^[0-9]+(d|h|m|s)$/.test(value)) { return 'must be a supported duration string'; diff --git a/x-pack/platform/plugins/private/product_intercept/common/constants.ts b/x-pack/platform/plugins/private/product_intercept/common/constants.ts index 4ec24c6c9079..08ef75c6f8c8 100644 --- a/x-pack/platform/plugins/private/product_intercept/common/constants.ts +++ b/x-pack/platform/plugins/private/product_intercept/common/constants.ts @@ -5,4 +5,10 @@ * 2.0. */ +/** + * The ID of the product intercept trigger definition. + * This is used to register the trigger definition with the intercepts plugin. + */ export const TRIGGER_DEF_ID = 'productInterceptTrigger' as const; + +export const UPGRADE_TRIGGER_DEF_PREFIX_ID = 'productUpgradeInterceptTrigger' as const; diff --git a/x-pack/platform/plugins/private/product_intercept/kibana.jsonc b/x-pack/platform/plugins/private/product_intercept/kibana.jsonc index a20095263c13..a05a95d63665 100644 --- a/x-pack/platform/plugins/private/product_intercept/kibana.jsonc +++ b/x-pack/platform/plugins/private/product_intercept/kibana.jsonc @@ -9,7 +9,7 @@ "id": "productIntercept", "browser": true, "server": true, - "requiredPlugins": ["intercepts"], + "requiredPlugins": ["intercepts", "cloud"], "requiredBundles": [], "configPath": ["xpack", "product_intercept"] } diff --git a/x-pack/platform/plugins/private/product_intercept/public/index.ts b/x-pack/platform/plugins/private/product_intercept/public/index.ts index 61c33807e97a..682bdc09166b 100644 --- a/x-pack/platform/plugins/private/product_intercept/public/index.ts +++ b/x-pack/platform/plugins/private/product_intercept/public/index.ts @@ -5,11 +5,12 @@ * 2.0. */ +import type { PluginInitializerContext } from '@kbn/core/public'; import { ProductInterceptPublicPlugin } from './plugin'; /** * @internal */ -export function plugin() { - return new ProductInterceptPublicPlugin(); +export function plugin(ctx: PluginInitializerContext) { + return new ProductInterceptPublicPlugin(ctx); } diff --git a/x-pack/platform/plugins/private/product_intercept/public/intercept_registration_config.tsx b/x-pack/platform/plugins/private/product_intercept/public/intercept_registration_config.tsx new file mode 100644 index 000000000000..2770e9607b60 --- /dev/null +++ b/x-pack/platform/plugins/private/product_intercept/public/intercept_registration_config.tsx @@ -0,0 +1,154 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiLink, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { Intercept } from '@kbn/intercepts-plugin/public'; +import type { PromptTelemetry } from './telemetry'; +import { NPSScoreInput } from './components'; + +interface ProductInterceptRegistrationHandlerParams { + productOffering: string; + surveyUrl: URL; + eventReporter: ReturnType; +} + +/** + * @description Returns the registration configuration for the product intercept. + * This configuration defines the steps and content of the intercept + * that prompts users for feedback on their experience with the product. + */ +export const productInterceptRegistrationConfig = ({ + eventReporter, + surveyUrl, + productOffering, +}: ProductInterceptRegistrationHandlerParams): Omit => { + return { + steps: [ + { + id: 'start', + title: i18n.translate('productIntercept.prompter.step.start.title', { + defaultMessage: 'Help us improve {productOffering}', + values: { + productOffering, + }, + }), + content: () => ( + + + + ), + }, + { + id: 'satisfaction', + title: i18n.translate('productIntercept.prompter.step.satisfaction.title', { + defaultMessage: 'Overall, how satisfied or dissatisfied are you with {productOffering}?', + values: { + productOffering, + }, + }), + content: ({ onValue }) => { + return ( + + ); + }, + }, + { + id: 'ease', + title: i18n.translate('productIntercept.prompter.step.ease.title', { + defaultMessage: 'Overall, how difficult or easy is it to use {productOffering}?', + values: { + productOffering, + }, + }), + content: ({ onValue }) => { + return ( + + ); + }, + }, + { + id: 'completion', + title: i18n.translate('productIntercept.prompter.step.completion.title', { + defaultMessage: 'Thanks for the feedback!', + }), + content: () => { + return ( + + ( + + {chunks} + + ), + }} + /> + + ); + }, + }, + ], + onProgress: ({ stepId, stepResponse, runId }) => { + eventReporter.reportInterceptInteractionProgress({ + interceptRunId: runId, + metricId: stepId, + value: Number(stepResponse), + }); + }, + onFinish: ({ response: feedbackResponse, runId }) => { + eventReporter.reportInterceptInteraction({ + interactionType: 'completion', + interceptRunId: runId, + }); + }, + onDismiss: ({ runId }) => { + // still update user profile run count, a dismissal is still an interaction + eventReporter.reportInterceptInteraction({ + interactionType: 'dismissal', + interceptRunId: runId, + }); + }, + }; +}; diff --git a/x-pack/platform/plugins/private/product_intercept/public/plugin.ts b/x-pack/platform/plugins/private/product_intercept/public/plugin.ts index 5aa69596237b..8dcafc8c79e6 100644 --- a/x-pack/platform/plugins/private/product_intercept/public/plugin.ts +++ b/x-pack/platform/plugins/private/product_intercept/public/plugin.ts @@ -6,156 +6,76 @@ */ import { Subscription } from 'rxjs'; -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { EuiLink, EuiText } from '@elastic/eui'; -import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; +import capitalize from 'lodash/capitalize'; +import { type CoreSetup, type CoreStart, Plugin } from '@kbn/core/public'; +import type { PluginInitializerContext } from '@kbn/core/public'; import { InterceptsStart } from '@kbn/intercepts-plugin/public'; -import { FormattedMessage } from '@kbn/i18n-react'; +import { type CloudStart } from '@kbn/cloud-plugin/public'; -import { NPSScoreInput } from './components'; import { PromptTelemetry } from './telemetry'; -import { TRIGGER_DEF_ID } from '../common/constants'; +import { TRIGGER_DEF_ID, UPGRADE_TRIGGER_DEF_PREFIX_ID } from '../common/constants'; interface ProductInterceptPluginStartDeps { intercepts: InterceptsStart; + cloud: CloudStart; } export class ProductInterceptPublicPlugin implements Plugin { private readonly telemetry = new PromptTelemetry(); private interceptSubscription?: Subscription; + private upgradeInterceptSubscription?: Subscription; + private readonly buildVersion: string; + + constructor(ctx: PluginInitializerContext) { + this.buildVersion = ctx.env.packageInfo.version; + } setup(core: CoreSetup) { return this.telemetry.setup({ analytics: core.analytics }); } - start(core: CoreStart, { intercepts }: ProductInterceptPluginStartDeps) { + start(core: CoreStart, { intercepts, cloud }: ProductInterceptPluginStartDeps) { const eventReporter = this.telemetry.start({ analytics: core.analytics, }); - this.interceptSubscription = intercepts - .registerIntercept?.({ - id: TRIGGER_DEF_ID, - steps: [ - { - id: 'start', - title: i18n.translate('productIntercept.prompter.step.start.title', { - defaultMessage: 'Help us improve Kibana', - }), - content: () => - React.createElement( - EuiText, - { key: 'productInterceptPrompterStartContent', size: 's' }, - i18n.translate('productIntercept.prompter.step.start.content', { - defaultMessage: - 'We are always looking for ways to improve Kibana. Please take a moment to share your feedback with us.', - }) + const productOffering = `Elastic ${capitalize(cloud.serverless.projectType || '')}`.trim(); + + void (async () => { + const currentUser = await core.security.authc.getCurrentUser(); + + const surveyUrl = new URL('https://ela.st/kibana-product-survey'); + + surveyUrl.searchParams.set('uid', String(currentUser.profile_uid || null)); + surveyUrl.searchParams.set('pid', String(cloud.serverless.projectId || null)); + surveyUrl.searchParams.set('solution', String(cloud.serverless.projectType || null)); + + [this.upgradeInterceptSubscription, this.interceptSubscription] = [ + TRIGGER_DEF_ID, + `${UPGRADE_TRIGGER_DEF_PREFIX_ID}:${this.buildVersion}`, + ].map((triggerId) => + intercepts + .registerIntercept?.({ + id: triggerId, + config: () => + import('./intercept_registration_config').then( + ({ productInterceptRegistrationConfig: registrationConfig }) => + registrationConfig({ + productOffering, + surveyUrl, + eventReporter, + }) ), - }, - { - id: 'satisfaction', - title: i18n.translate('productIntercept.prompter.step.satisfaction.title', { - defaultMessage: 'Overall, how satisfied or dissatisfied are you with Kibana?', - }), - content: ({ onValue }) => { - return React.createElement(NPSScoreInput, { - lowerBoundHelpText: i18n.translate( - 'productIntercept.prompter.step.satisfaction.lowerBoundDescriptionText', - { - defaultMessage: 'Very dissatisfied', - } - ), - upperBoundHelpText: i18n.translate( - 'productIntercept.prompter.step.satisfaction.upperBoundDescriptionText', - { - defaultMessage: 'Very satisfied', - } - ), - onChange: onValue, - }); - }, - }, - { - id: 'ease', - title: i18n.translate('productIntercept.prompter.step.ease.title', { - defaultMessage: 'Overall, how difficult or easy is it to use Kibana?', - }), - content: ({ onValue }) => { - return React.createElement(NPSScoreInput, { - lowerBoundHelpText: i18n.translate( - 'productIntercept.prompter.step.ease.lowerBoundDescriptionText', - { - defaultMessage: 'Very difficult', - } - ), - upperBoundHelpText: i18n.translate( - 'productIntercept.prompter.step.ease.upperBoundDescriptionText', - { - defaultMessage: 'Very easy', - } - ), - onChange: onValue, - }); - }, - }, - { - id: 'completion', - title: i18n.translate('productIntercept.prompter.step.completion.title', { - defaultMessage: 'Thanks for the feedback!', - }), - content: () => { - return React.createElement( - EuiText, - { size: 's' }, - React.createElement(FormattedMessage, { - id: 'productIntercept.prompter.step.completion.content', - defaultMessage: - "If you'd like to participate in future research to help improve kibana, click here.", - values: { - link: (chunks) => - React.createElement( - EuiLink, - { - external: true, - href: 'https://www.elastic.co/feedback', - target: '_blank', - }, - chunks - ), - }, - }) - ); - }, - }, - ], - onProgress: ({ stepId, stepResponse, runId }) => { - eventReporter.reportInterceptInteractionProgress({ - interceptRunId: runId, - metricId: stepId, - value: Number(stepResponse), - }); - }, - onFinish: ({ response: feedbackResponse, runId }) => { - eventReporter.reportInterceptInteraction({ - interactionType: 'completion', - interceptRunId: runId, - }); - }, - onDismiss: ({ runId }) => { - // still update user profile run count, a dismissal is still an interaction - eventReporter.reportInterceptInteraction({ - interactionType: 'dismissal', - interceptRunId: runId, - }); - }, - }) - .subscribe(); + }) + .subscribe() + ); + })(); return {}; } stop() { this.interceptSubscription?.unsubscribe(); + this.upgradeInterceptSubscription?.unsubscribe(); } } diff --git a/x-pack/platform/plugins/private/product_intercept/server/plugin.ts b/x-pack/platform/plugins/private/product_intercept/server/plugin.ts index 7e33c8a89c86..fb558cc99769 100644 --- a/x-pack/platform/plugins/private/product_intercept/server/plugin.ts +++ b/x-pack/platform/plugins/private/product_intercept/server/plugin.ts @@ -13,7 +13,7 @@ import { type Logger, } from '@kbn/core/server'; import type { InterceptSetup, InterceptStart } from '@kbn/intercepts-plugin/server'; -import { TRIGGER_DEF_ID } from '../common/constants'; +import { TRIGGER_DEF_ID, UPGRADE_TRIGGER_DEF_PREFIX_ID } from '../common/constants'; import { ServerConfigSchema } from '../common/config'; interface ProductInterceptServerPluginSetup { @@ -32,10 +32,13 @@ export class ProductInterceptServerPlugin { private readonly logger: Logger; private readonly config: ServerConfigSchema; + private readonly buildVersion: string; + private readonly upgradeInterval: string = '14d'; constructor(initContext: PluginInitializerContext) { this.logger = initContext.logger.get(); this.config = initContext.config.get(); + this.buildVersion = initContext.env.packageInfo.version; } setup(core: CoreSetup, {}: ProductInterceptServerPluginSetup) { @@ -45,9 +48,17 @@ export class ProductInterceptServerPlugin start(core: CoreStart, { intercepts }: ProductInterceptServerPluginStart) { if (this.config.enabled) { void intercepts.registerTriggerDefinition?.(TRIGGER_DEF_ID, () => { - this.logger.debug('Registering kibana product trigger definition'); + this.logger.debug('Registering global product intercept trigger definition'); return { triggerAfter: this.config.interval }; }); + + void intercepts.registerTriggerDefinition?.( + `${UPGRADE_TRIGGER_DEF_PREFIX_ID}:${this.buildVersion}`, + () => { + this.logger.debug('Registering global product upgrade intercept trigger definition'); + return { triggerAfter: this.upgradeInterval, isRecurrent: false }; + } + ); } return {}; diff --git a/x-pack/platform/plugins/private/product_intercept/tsconfig.json b/x-pack/platform/plugins/private/product_intercept/tsconfig.json index 61a03247e383..294f34e9ab08 100644 --- a/x-pack/platform/plugins/private/product_intercept/tsconfig.json +++ b/x-pack/platform/plugins/private/product_intercept/tsconfig.json @@ -18,7 +18,8 @@ "@kbn/config-schema", "@kbn/i18n", "@kbn/intercepts-plugin", - "@kbn/i18n-react" + "@kbn/i18n-react", + "@kbn/cloud-plugin" ], "exclude": ["target/**/*"] } diff --git a/x-pack/test/api_integration/deployment_agnostic/apis/intercepts/index.ts b/x-pack/test/api_integration/deployment_agnostic/apis/intercepts/index.ts new file mode 100644 index 000000000000..66ff0c757956 --- /dev/null +++ b/x-pack/test/api_integration/deployment_agnostic/apis/intercepts/index.ts @@ -0,0 +1,15 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { DeploymentAgnosticFtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext) { + describe('Intercepts API', function () { + loadTestFile(require.resolve('./interaction_apis')); + loadTestFile(require.resolve('./trigger_apis')); + }); +} diff --git a/x-pack/test/api_integration/deployment_agnostic/apis/intercepts/interaction_apis.ts b/x-pack/test/api_integration/deployment_agnostic/apis/intercepts/interaction_apis.ts new file mode 100644 index 000000000000..4058edbfe940 --- /dev/null +++ b/x-pack/test/api_integration/deployment_agnostic/apis/intercepts/interaction_apis.ts @@ -0,0 +1,53 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { TRIGGER_USER_INTERACTION_METADATA_API_ROUTE } from '@kbn/intercepts-plugin/common/constants'; +import { DeploymentAgnosticFtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { + const roleScopedSupertest = getService('roleScopedSupertest'); + + describe('Intercept User Interaction APIs', () => { + describe(`GET ${TRIGGER_USER_INTERACTION_METADATA_API_ROUTE}`, () => { + it('should return 200 with empty object when no interaction exists', async () => { + const supertest = await roleScopedSupertest.getSupertestWithRoleScope('viewer', { + useCookieHeader: true, // denotes authenticated user + withInternalHeaders: true, + }); + + const response = await supertest + .get(TRIGGER_USER_INTERACTION_METADATA_API_ROUTE.replace('{triggerId}', 'test-trigger')) + .expect(200); + + expect(response.body).to.eql({}); + }); + }); + + describe(`POST ${TRIGGER_USER_INTERACTION_METADATA_API_ROUTE}`, () => { + it('should successfully record intercept interaction and return the last saved value', async () => { + const supertest = await roleScopedSupertest.getSupertestWithRoleScope('viewer', { + useCookieHeader: true, // denotes authenticated user + withInternalHeaders: true, + }); + + const interactionRecord = { lastInteractedInterceptId: 1 }; + + await supertest + .post(TRIGGER_USER_INTERACTION_METADATA_API_ROUTE.replace('{triggerId}', 'test-trigger')) + .send(interactionRecord) + .expect(201); + + const response = await supertest + .get(TRIGGER_USER_INTERACTION_METADATA_API_ROUTE.replace('{triggerId}', 'test-trigger')) + .expect(200); + + expect(response.body).to.eql(interactionRecord); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/deployment_agnostic/apis/intercepts/trigger_apis.ts b/x-pack/test/api_integration/deployment_agnostic/apis/intercepts/trigger_apis.ts new file mode 100644 index 000000000000..b2f8c13c466c --- /dev/null +++ b/x-pack/test/api_integration/deployment_agnostic/apis/intercepts/trigger_apis.ts @@ -0,0 +1,78 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { interceptTriggerRecordSavedObject } from '@kbn/intercepts-plugin/server/saved_objects'; +import { TRIGGER_INFO_API_ROUTE } from '@kbn/intercepts-plugin/common/constants'; +import { DeploymentAgnosticFtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { + const roleScopedSupertest = getService('roleScopedSupertest'); + const kibanaServer = getService('kibanaServer'); + + describe('Intercept Trigger APIs', () => { + before(async () => { + await kibanaServer.savedObjects.clean({ + types: [interceptTriggerRecordSavedObject.name], + }); + }); + + after(async () => { + await kibanaServer.savedObjects.clean({ + types: [interceptTriggerRecordSavedObject.name], + }); + }); + + describe(`POST ${TRIGGER_INFO_API_ROUTE}`, () => { + it('should return 204 when no trigger info exists', async () => { + const supertest = await roleScopedSupertest.getSupertestWithRoleScope('viewer', { + useCookieHeader: true, // favorite only works with Cookie header + withInternalHeaders: true, + }); + + const response = await supertest + .post(TRIGGER_INFO_API_ROUTE) + .send({ triggerId: 'non-existent-trigger' }) + .expect(204); + + expect(response.body).to.be.empty(); + }); + + it('should return 200 with trigger info when it exists', async () => { + const triggerId = 'test-trigger'; + const now = new Date().toISOString(); + const interval = '1h'; + + const supertest = await roleScopedSupertest.getSupertestWithRoleScope('viewer', { + useCookieHeader: true, // denotes authenticated user + withInternalHeaders: true, + }); + + await kibanaServer.savedObjects.create({ + id: triggerId, + type: interceptTriggerRecordSavedObject.name, + overwrite: true, + attributes: { + firstRegisteredAt: now, + triggerAfter: interval, + recurrent: true, + installedOn: '9.1.0', + }, + }); + + const response = await supertest + .post(TRIGGER_INFO_API_ROUTE) + .send({ triggerId }) + .expect(200); + + expect(response.body).to.have.property('registeredAt', now); + expect(response.body).to.have.property('triggerIntervalInMs', 3600000); // 1h in ms + expect(response.body).to.have.property('recurrent', true); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/deployment_agnostic/configs/serverless/oblt.index.ts b/x-pack/test/api_integration/deployment_agnostic/configs/serverless/oblt.index.ts index b41dae4075e5..c06b7f133118 100644 --- a/x-pack/test/api_integration/deployment_agnostic/configs/serverless/oblt.index.ts +++ b/x-pack/test/api_integration/deployment_agnostic/configs/serverless/oblt.index.ts @@ -23,5 +23,6 @@ export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext) loadTestFile(require.resolve('../../apis/observability/slo')); loadTestFile(require.resolve('../../apis/observability/onboarding')); loadTestFile(require.resolve('../../apis/observability/incident_management')); + loadTestFile(require.resolve('../../apis/intercepts')); }); } diff --git a/x-pack/test/api_integration/deployment_agnostic/configs/serverless/search.index.ts b/x-pack/test/api_integration/deployment_agnostic/configs/serverless/search.index.ts index 97db4bf32d47..60f44c89ff5e 100644 --- a/x-pack/test/api_integration/deployment_agnostic/configs/serverless/search.index.ts +++ b/x-pack/test/api_integration/deployment_agnostic/configs/serverless/search.index.ts @@ -15,5 +15,6 @@ export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext) loadTestFile(require.resolve('../../apis/core')); loadTestFile(require.resolve('../../apis/management')); loadTestFile(require.resolve('../../apis/saved_objects_management')); + loadTestFile(require.resolve('../../apis/intercepts')); }); } diff --git a/x-pack/test/api_integration/deployment_agnostic/configs/serverless/security.index.ts b/x-pack/test/api_integration/deployment_agnostic/configs/serverless/security.index.ts index 9e750ccf898f..8f043bb3d306 100644 --- a/x-pack/test/api_integration/deployment_agnostic/configs/serverless/security.index.ts +++ b/x-pack/test/api_integration/deployment_agnostic/configs/serverless/security.index.ts @@ -16,5 +16,6 @@ export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext) loadTestFile(require.resolve('../../apis/management')); loadTestFile(require.resolve('../../apis/painless_lab')); loadTestFile(require.resolve('../../apis/saved_objects_management')); + loadTestFile(require.resolve('../../apis/intercepts')); }); } diff --git a/x-pack/test/api_integration/deployment_agnostic/configs/stateful/platform.index.ts b/x-pack/test/api_integration/deployment_agnostic/configs/stateful/platform.index.ts index ddaf3dd9c5f6..57df1ec9a107 100644 --- a/x-pack/test/api_integration/deployment_agnostic/configs/stateful/platform.index.ts +++ b/x-pack/test/api_integration/deployment_agnostic/configs/stateful/platform.index.ts @@ -15,5 +15,6 @@ export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext) loadTestFile(require.resolve('../../apis/management')); loadTestFile(require.resolve('../../apis/painless_lab')); loadTestFile(require.resolve('../../apis/saved_objects_management')); + loadTestFile(require.resolve('../../apis/intercepts')); }); } diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index aeeade6d29bc..679e0838cb70 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -168,5 +168,6 @@ "@kbn/test-suites-xpack-platform", "@kbn/core-chrome-browser", "@kbn/event-log-plugin", + "@kbn/intercepts-plugin" ] } diff --git a/x-pack/test_serverless/shared/config.base.ts b/x-pack/test_serverless/shared/config.base.ts index 1c0ca0696cfc..02dff6d120e3 100644 --- a/x-pack/test_serverless/shared/config.base.ts +++ b/x-pack/test_serverless/shared/config.base.ts @@ -181,8 +181,6 @@ export default async () => { `--xpack.cloud.deployments_url=/deployments`, `--xpack.cloud.organization_url=/account/`, `--xpack.cloud.users_and_roles_url=/account/members/`, - // disable product intercept for all ftr tests by default - '--xpack.intercepts.enabled=false', ], },