[Intercept] Setup intervals for intercept in Kibana offerings (#221743)

## Summary

- Adds configuration for the product intercept in `oblt`, `es` and
`security` serverless offerings, alongsides stateful offering too. The
configuration provided sets the intercept to display every 90days, this
is configurable through the config `xpack.product_intercept.interval`.
The intercept can also be turned off through the config
`xpack.product_intercept.enabled`
- Also tweaks prompter timer implementation to accommodate inherent
[issue with long timer
delays](https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout#maximum_delay_value)
in the browser
- Adjusts the signature of the `registerIntercept` method, such that a
deferred value to be evaluated when the intercept is to be displayed is
passed. This unlocks the ability to have consumers provide dynamically
imported modules that provide the config for the intercept, see
0e07892217
for an example.

### How to test

- Add the following config to your `kibana.dev.yml` file;
	```yml
	xpack.product_intercept.enabled: true
# we set the interval to 30s so the wait long period to display the
intercept is bearable
	xpack.product_intercept.interval: '30s'
	```
- Start kibana in stateful, and serverless mode, in either scenario you
should be presented the product intercept, with the intercept
specifically stating the current product the user is interacting with.
See below for an example of observability solution;
	


https://github.com/user-attachments/assets/6ca6baf2-58d3-4002-ac94-ec6e9a0902ae


<!--
### Checklist

Check the PR satisfies following conditions. 

Reviewers should verify this PR satisfies this list as well.

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This was checked for breaking HTTP API changes, and any breaking
changes have been approved by the breaking-change committee. The
`release_note:breaking` label should be applied in these situations.
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

### Identify risks

Does this PR introduce any risks? For example, consider risks like hard
to test bugs, performance regression, potential of data loss.

Describe the risk, its severity, and mitigation for each identified
risk. Invite stakeholders and evaluate how to proceed before merging.

- [ ] [See some risk
examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx)
- [ ] ...


-->

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Eyo O. Eyo 2025-06-23 20:45:31 +02:00 committed by GitHub
parent 15ee621d10
commit fd4e551340
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 743 additions and 356 deletions

1
.github/CODEOWNERS vendored
View file

@ -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

View file

@ -23,4 +23,7 @@ xpack.wciExternalServer.enabled: true
xpack.spaces.maxSpaces: 1
## Content Connectors in stack management
xpack.contentConnectors.enabled: false
xpack.contentConnectors.enabled: false
## Disable Kibana Product Intercept
xpack.product_intercept.enabled: false

View file

@ -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'

View file

@ -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',
],
},
};

View file

@ -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',
],
},

View file

@ -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?)',

View file

@ -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;

View file

@ -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);

View file

@ -6,3 +6,4 @@
*/
export { InterceptPrompter } from './prompter';
export type { Intercept } from './prompter';

View file

@ -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<InterceptPrompter['start']>['registerIntercept']
>[0]['steps'] = [
const interceptSteps: Intercept['steps'] = [
{
id: 'start' as const,
title: 'Hello',
@ -84,14 +82,13 @@ describe('ProductInterceptPrompter', () => {
},
];
const intercept: Parameters<ReturnType<InterceptPrompter['start']>['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();
});
});
});
});

View file

@ -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<CoreSetup, 'analytics' | 'notifications'>;
type ProductInterceptPrompterStartDeps = Omit<
InterceptServiceStartDeps,
@ -24,6 +27,12 @@ export class InterceptPrompter {
private userInterceptRunPersistenceService = new UserInterceptRunPersistenceService();
private interceptDialogService = new InterceptDialogService();
private queueIntercept?: ReturnType<InterceptDialogService['start']>['add'];
// observer for page visibility changes, shared across all intercepts
private pageHidden$?: Rx.Observable<boolean>;
// 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<Omit<Intercept, 'id'>>;
}
) {
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,
},
});
}
}
})
);

View file

@ -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(

View file

@ -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;

View file

@ -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<SavedObject<InterceptInteractionUserRecordAttributes> | null> {
@ -131,7 +131,7 @@ export class InterceptUserInteractionService {
}
}
public async recordUserInteractionForTrigger(
private async recordUserInteractionForTrigger(
userId: string,
triggerId: string,
data: InterceptInteractionUserRecordAttributes['metadata']

View file

@ -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';

View file

@ -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;

View file

@ -9,7 +9,7 @@
"id": "productIntercept",
"browser": true,
"server": true,
"requiredPlugins": ["intercepts"],
"requiredPlugins": ["intercepts", "cloud"],
"requiredBundles": [],
"configPath": ["xpack", "product_intercept"]
}

View file

@ -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);
}

View file

@ -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<PromptTelemetry['start']>;
}
/**
* @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<Intercept, 'id'> => {
return {
steps: [
{
id: 'start',
title: i18n.translate('productIntercept.prompter.step.start.title', {
defaultMessage: 'Help us improve {productOffering}',
values: {
productOffering,
},
}),
content: () => (
<EuiText size="s" key="productInterceptPrompterStartContent">
<FormattedMessage
id="productIntercept.prompter.step.start.content"
defaultMessage="We are always looking for ways to improve {productOffering}. Please take a moment to share your feedback with us."
values={{ productOffering }}
/>
</EuiText>
),
},
{
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 (
<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 {productOffering}?',
values: {
productOffering,
},
}),
content: ({ onValue }) => {
return (
<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 (
<EuiText size="s" key="productInterceptPrompterCompletionContent">
<FormattedMessage
id="productIntercept.prompter.step.completion.content"
defaultMessage="If you'd like to participate in future research to help improve {productOffering}, <link>click here</link>."
values={{
productOffering,
link: (chunks) => (
<EuiLink external target="_blank" href={surveyUrl.toString()}>
{chunks}
</EuiLink>
),
}}
/>
</EuiText>
);
},
},
],
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,
});
},
};
};

View file

@ -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, <link>click here</link>.",
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();
}
}

View file

@ -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<unknown>) {
this.logger = initContext.logger.get();
this.config = initContext.config.get<ServerConfigSchema>();
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 {};

View file

@ -18,7 +18,8 @@
"@kbn/config-schema",
"@kbn/i18n",
"@kbn/intercepts-plugin",
"@kbn/i18n-react"
"@kbn/i18n-react",
"@kbn/cloud-plugin"
],
"exclude": ["target/**/*"]
}

View file

@ -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'));
});
}

View file

@ -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);
});
});
});
}

View file

@ -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);
});
});
});
}

View file

@ -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'));
});
}

View file

@ -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'));
});
}

View file

@ -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'));
});
}

View file

@ -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'));
});
}

View file

@ -168,5 +168,6 @@
"@kbn/test-suites-xpack-platform",
"@kbn/core-chrome-browser",
"@kbn/event-log-plugin",
"@kbn/intercepts-plugin"
]
}

View file

@ -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',
],
},