Redirect endpoint compression (#111471) (#112451)

* add ability to lz-encode redirect url params

* move redirect param utils to /common

* improve error message

* add getRedirectUrl() to locators

* wire in version into locator redirect method

* make redirect url default to compressed

* fix typescript errors

* pass throuh initialization context to share plugin

* fix test mocks

* improve locators mocks usage

* fix typescript types

* use getRedirectUrl in example plugin

* make redirect url options optional

* add locator tests

* deprecate geturl

* load redirect app UI on demand

Co-authored-by: Vadim Kibana <82822460+vadimkibana@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2021-09-16 15:15:08 -04:00 committed by GitHub
parent 95a54512a2
commit 4ca503295b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 297 additions and 123 deletions

View file

@ -19,7 +19,7 @@ import { EuiFieldText } from '@elastic/eui';
import { EuiPageHeader } from '@elastic/eui';
import { EuiLink } from '@elastic/eui';
import { AppMountParameters } from '../../../src/core/public';
import { formatSearchParams, SharePluginSetup } from '../../../src/plugins/share/public';
import { SharePluginSetup } from '../../../src/plugins/share/public';
import {
HelloLocatorV1Params,
HelloLocatorV2Params,
@ -164,14 +164,7 @@ const ActionsExplorer = ({ share }: Props) => {
<EuiLink
color={link.version !== '0.0.2' ? 'danger' : 'primary'}
data-test-subj="linkToHelloPage"
href={
'/app/r?' +
formatSearchParams({
id: 'HELLO_LOCATOR',
version: link.version,
params: link.params,
}).toString()
}
href={share.url.locators.get('HELLO_LOCATOR')?.getRedirectUrl(link.params)}
target="_blank"
>
through redirect app

View file

@ -7,6 +7,7 @@
*/
import { DiscoverSetup, DiscoverStart } from '.';
import { sharePluginMock } from '../../share/public/mocks';
export type Setup = jest.Mocked<DiscoverSetup>;
export type Start = jest.Mocked<DiscoverStart>;
@ -16,16 +17,7 @@ const createSetupContract = (): Setup => {
docViews: {
addDocView: jest.fn(),
},
locator: {
getLocation: jest.fn(),
getUrl: jest.fn(),
useUrl: jest.fn(),
navigate: jest.fn(),
extract: jest.fn(),
inject: jest.fn(),
telemetry: jest.fn(),
migrations: {},
},
locator: sharePluginMock.createLocator(),
};
return setupContract;
};
@ -36,16 +28,7 @@ const createStartContract = (): Start => {
urlGenerator: ({
createUrl: jest.fn(),
} as unknown) as DiscoverStart['urlGenerator'],
locator: {
getLocation: jest.fn(),
getUrl: jest.fn(),
useUrl: jest.fn(),
navigate: jest.fn(),
extract: jest.fn(),
inject: jest.fn(),
telemetry: jest.fn(),
migrations: {},
},
locator: sharePluginMock.createLocator(),
};
return startContract;
};

View file

@ -8,6 +8,7 @@
import { ManagementSetup, ManagementStart, DefinedSections } from '../types';
import { ManagementSection } from '../index';
import { sharePluginMock } from '../../../share/public/mocks';
export const createManagementSectionMock = () =>
(({
@ -31,18 +32,12 @@ const createSetupContract = (): ManagementSetup => ({
} as unknown) as DefinedSections,
},
locator: {
...sharePluginMock.createLocator(),
getLocation: jest.fn(async () => ({
app: 'MANAGEMENT',
path: '',
state: {},
})),
getUrl: jest.fn(),
useUrl: jest.fn(),
navigate: jest.fn(),
extract: jest.fn(),
inject: jest.fn(),
telemetry: jest.fn(),
migrations: {},
},
});

View file

@ -6,4 +6,4 @@
* Side Public License, v 1.
*/
export { LocatorDefinition, LocatorPublic, useLocatorUrl } from './url_service';
export { LocatorDefinition, LocatorPublic, useLocatorUrl, formatSearchParams } from './url_service';

View file

@ -9,4 +9,5 @@
export * from './types';
export * from './locator';
export * from './locator_client';
export * from './redirect';
export { useLocatorUrl } from './use_locator_url';

View file

@ -0,0 +1,115 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { LocatorDefinition } from './types';
import { Locator, LocatorDependencies } from './locator';
import { KibanaLocation } from 'src/plugins/share/public';
import { LocatorGetUrlParams } from '.';
import { decompressFromBase64 } from 'lz-string';
const setup = () => {
const baseUrl = 'http://localhost:5601';
const version = '1.2.3';
const deps: LocatorDependencies = {
baseUrl,
version,
navigate: jest.fn(),
getUrl: jest.fn(async (location: KibanaLocation, getUrlParams: LocatorGetUrlParams) => {
return (getUrlParams.absolute ? baseUrl : '') + '/app/' + location.app + location.path;
}),
};
const definition: LocatorDefinition<{ foo?: string; baz?: string }> = {
id: 'TEST_LOCATOR',
getLocation: jest.fn(async ({ foo = 'bar', baz = 'qux' }) => {
return {
app: 'test_app',
path: `/foo/${foo}?baz=${baz}`,
state: {},
};
}),
};
const locator = new Locator(definition, deps);
return { baseUrl, version, deps, definition, locator };
};
describe('Locator', () => {
test('can create a locator', () => {
const { locator } = setup();
expect(locator).toBeInstanceOf(Locator);
expect(locator.definition.id).toBe('TEST_LOCATOR');
});
describe('.getLocation()', () => {
test('returns location as defined in definition', async () => {
const { locator } = setup();
const location = await locator.getLocation({});
expect(location).toEqual({
app: 'test_app',
path: '/foo/bar?baz=qux',
state: {},
});
});
});
describe('.getUrl()', () => {
test('returns URL of the location defined in definition', async () => {
const { locator } = setup();
const url1 = await locator.getUrl({});
const url2 = await locator.getUrl({}, { absolute: true });
const url3 = await locator.getUrl({ foo: 'a', baz: 'b' });
const url4 = await locator.getUrl({ foo: 'a', baz: 'b' }, { absolute: true });
expect(url1).toBe('/app/test_app/foo/bar?baz=qux');
expect(url2).toBe('http://localhost:5601/app/test_app/foo/bar?baz=qux');
expect(url3).toBe('/app/test_app/foo/a?baz=b');
expect(url4).toBe('http://localhost:5601/app/test_app/foo/a?baz=b');
});
});
describe('.getRedirectUrl()', () => {
test('returns URL of the redirect endpoint', async () => {
const { locator } = setup();
const url = await locator.getRedirectUrl({ foo: 'a', baz: 'b' });
const params = new URLSearchParams(url.split('?')[1]);
expect(params.get('l')).toBe('TEST_LOCATOR');
expect(params.get('v')).toBe('1.2.3');
expect(JSON.parse(decompressFromBase64(params.get('lz')!)!)).toMatchObject({
foo: 'a',
baz: 'b',
});
});
});
describe('.navigate()', () => {
test('returns URL of the redirect endpoint', async () => {
const { locator, deps } = setup();
expect(deps.navigate).toHaveBeenCalledTimes(0);
await locator.navigate({ foo: 'a', baz: 'b' });
expect(deps.navigate).toHaveBeenCalledTimes(1);
expect(deps.navigate).toHaveBeenCalledWith(
await locator.getLocation({ foo: 'a', baz: 'b' }),
{ replace: false }
);
await locator.navigate({ foo: 'a2', baz: 'b2' }, { replace: true });
expect(deps.navigate).toHaveBeenCalledTimes(2);
expect(deps.navigate).toHaveBeenCalledWith(
await locator.getLocation({ foo: 'a2', baz: 'b2' }),
{ replace: true }
);
});
});
});

View file

@ -18,8 +18,19 @@ import type {
LocatorNavigationParams,
LocatorGetUrlParams,
} from './types';
import { formatSearchParams, FormatSearchParamsOptions, RedirectOptions } from './redirect';
export interface LocatorDependencies {
/**
* Public URL of the Kibana server.
*/
baseUrl?: string;
/**
* Current version of Kibana, e.g. `7.0.0`.
*/
version?: string;
/**
* Navigate without reloading the page to a KibanaLocation.
*/
@ -76,6 +87,22 @@ export class Locator<P extends SerializableRecord> implements LocatorPublic<P> {
return url;
}
public getRedirectUrl(params: P, options: FormatSearchParamsOptions = {}): string {
const { baseUrl = '', version = '0.0.0' } = this.deps;
const redirectOptions: RedirectOptions = {
id: this.definition.id,
version,
params,
};
const formatOptions: FormatSearchParamsOptions = {
...options,
lzCompress: options.lzCompress ?? true,
};
const search = formatSearchParams(redirectOptions, formatOptions).toString();
return baseUrl + '/app/r?' + search;
}
public async navigate(
params: P,
{ replace = false }: LocatorNavigationParams = {}

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import { decompressFromBase64 } from 'lz-string';
import { formatSearchParams } from './format_search_params';
import { parseSearchParams } from './parse_search_params';
@ -41,3 +42,23 @@ test('can format and then parse redirect options', () => {
expect(parsed).toEqual(options);
});
test('compresses params using lz-string when { lzCompress: true } provided', () => {
const options = {
id: 'LOCATOR_ID',
version: '7.21.3',
params: {
dashboardId: '123',
mode: 'edit',
},
};
const formatted = formatSearchParams(options, { lzCompress: true });
const search = new URLSearchParams(formatted);
const paramsJson = decompressFromBase64(search.get('lz')!)!;
expect(JSON.parse(paramsJson)).toEqual(options.params);
const parsed = parseSearchParams(formatted.toString());
expect(parsed).toEqual(options);
});

View file

@ -0,0 +1,35 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { compressToBase64 } from 'lz-string';
import { RedirectOptions } from './types';
export interface FormatSearchParamsOptions {
lzCompress?: boolean;
}
export function formatSearchParams(
opts: RedirectOptions,
{ lzCompress }: FormatSearchParamsOptions = {}
): URLSearchParams {
const searchParams = new URLSearchParams();
searchParams.set('l', opts.id);
searchParams.set('v', opts.version);
const json = JSON.stringify(opts.params);
if (lzCompress) {
const compressed = compressToBase64(json);
searchParams.set('lz', compressed);
} else {
searchParams.set('p', JSON.stringify(opts.params));
}
return searchParams;
}

View file

@ -6,14 +6,6 @@
* Side Public License, v 1.
*/
import { RedirectOptions } from '../redirect_manager';
export function formatSearchParams(opts: RedirectOptions): URLSearchParams {
const searchParams = new URLSearchParams();
searchParams.set('l', opts.id);
searchParams.set('v', opts.version);
searchParams.set('p', JSON.stringify(opts.params));
return searchParams;
}
export * from './types';
export * from './format_search_params';
export * from './parse_search_params';

View file

@ -8,7 +8,8 @@
import type { SerializableRecord } from '@kbn/utility-types';
import { i18n } from '@kbn/i18n';
import type { RedirectOptions } from '../redirect_manager';
import { decompressFromBase64 } from 'lz-string';
import type { RedirectOptions } from './types';
/**
* Parses redirect endpoint URL path search parameters. Expects them in the
@ -23,9 +24,11 @@ import type { RedirectOptions } from '../redirect_manager';
*/
export function parseSearchParams(urlSearch: string): RedirectOptions {
const search = new URLSearchParams(urlSearch);
const id = search.get('l');
const version = search.get('v');
const paramsJson = search.get('p');
const compressed = search.get('lz');
const paramsJson: string | null = compressed ? decompressFromBase64(compressed) : search.get('p');
if (!id) {
const message = i18n.translate(

View file

@ -0,0 +1,28 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { SerializableRecord } from '@kbn/utility-types';
/**
* @public
* Serializable locator parameters that can be used by the redirect service to navigate to a
* location in Kibana.
*
* When passed to the public `share` plugin `.navigate()` function, locator params will also
* be migrated.
*/
export interface RedirectOptions<P extends SerializableRecord = unknown & SerializableRecord> {
/** Locator ID. */
id: string;
/** Kibana version when locator params were generated. */
version: string;
/** Locator params. */
params: P;
}

View file

@ -9,6 +9,7 @@
import type { SerializableRecord } from '@kbn/utility-types';
import { DependencyList } from 'react';
import { PersistableState } from 'src/plugins/kibana_utils/common';
import type { FormatSearchParamsOptions } from './redirect';
/**
* URL locator registry.
@ -62,11 +63,24 @@ export interface LocatorPublic<P extends SerializableRecord> extends Persistable
/**
* Returns a URL as a string.
*
* @deprecated Use `getRedirectUrl` instead. `getRedirectUrl` will preserve
* the location state, whereas the `getUrl` just return the URL without
* the location state.
*
* @param params URL locator parameters.
* @param getUrlParams URL construction parameters.
*/
getUrl(params: P, getUrlParams?: LocatorGetUrlParams): Promise<string>;
/**
* Returns a URL to the redirect endpoint, which will redirect the user to
* the final destination.
*
* @param params URL locator parameters.
* @param options URL serialization options.
*/
getRedirectUrl(params: P, options?: FormatSearchParamsOptions): string;
/**
* Navigate using the `core.application.navigateToApp()` method to a Kibana
* location generated by this locator. This method is available only on the

View file

@ -6,10 +6,11 @@
* Side Public License, v 1.
*/
import type { PluginInitializerContext } from 'src/core/public';
export { CSV_QUOTE_VALUES_SETTING, CSV_SEPARATOR_SETTING } from '../common/constants';
export { LocatorDefinition, LocatorPublic, KibanaLocation } from '../common/url_service';
export { parseSearchParams, formatSearchParams } from './url_service';
export { UrlGeneratorStateMapping } from './url_generators/url_generator_definition';
@ -31,7 +32,7 @@ export {
UrlGeneratorsService,
} from './url_generators';
export { RedirectOptions } from './url_service';
export { RedirectOptions } from '../common/url_service';
export { useLocatorUrl } from '../common/url_service/locators/use_locator_url';
import { SharePlugin } from './plugin';
@ -40,4 +41,6 @@ export { KibanaURL } from './kibana_url';
export { downloadMultipleAs, downloadFileAs } from './lib/download_as';
export type { DownloadableContent } from './lib/download_as';
export const plugin = () => new SharePlugin();
export function plugin(ctx: PluginInitializerContext) {
return new SharePlugin(ctx);
}

View file

@ -49,6 +49,7 @@ const createLocator = <T extends SerializableRecord = SerializableRecord>(): jes
> => ({
getLocation: jest.fn(),
getUrl: jest.fn(),
getRedirectUrl: jest.fn(),
useUrl: jest.fn(),
navigate: jest.fn(),
extract: jest.fn(),

View file

@ -25,7 +25,10 @@ describe('SharePlugin', () => {
const plugins = {
securityOss: mockSecurityOssPlugin.createSetup(),
};
const setup = await new SharePlugin().setup(coreSetup, plugins);
const setup = await new SharePlugin(coreMock.createPluginInitializerContext()).setup(
coreSetup,
plugins
);
expect(registryMock.setup).toHaveBeenCalledWith();
expect(setup.register).toBeDefined();
});
@ -35,7 +38,7 @@ describe('SharePlugin', () => {
const plugins = {
securityOss: mockSecurityOssPlugin.createSetup(),
};
await new SharePlugin().setup(coreSetup, plugins);
await new SharePlugin(coreMock.createPluginInitializerContext()).setup(coreSetup, plugins);
expect(coreSetup.application.register).toHaveBeenCalledWith(
expect.objectContaining({
id: 'short_url_redirect',
@ -50,7 +53,7 @@ describe('SharePlugin', () => {
const pluginsSetup = {
securityOss: mockSecurityOssPlugin.createSetup(),
};
const service = new SharePlugin();
const service = new SharePlugin(coreMock.createPluginInitializerContext());
await service.setup(coreSetup, pluginsSetup);
const pluginsStart = {
securityOss: mockSecurityOssPlugin.createStart(),

View file

@ -8,7 +8,7 @@
import './index.scss';
import { CoreSetup, CoreStart, Plugin } from 'src/core/public';
import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public';
import { ShareMenuManager, ShareMenuManagerStart } from './services';
import type { SecurityOssPluginSetup, SecurityOssPluginStart } from '../../security_oss/public';
import { ShareMenuRegistry, ShareMenuRegistrySetup } from './services';
@ -19,7 +19,8 @@ import {
UrlGeneratorsStart,
} from './url_generators/url_generator_service';
import { UrlService } from '../common/url_service';
import { RedirectManager, RedirectOptions } from './url_service';
import { RedirectManager } from './url_service';
import type { RedirectOptions } from '../common/url_service/locators/redirect';
export interface ShareSetupDependencies {
securityOss?: SecurityOssPluginSetup;
@ -79,10 +80,17 @@ export class SharePlugin implements Plugin<SharePluginSetup, SharePluginStart> {
private redirectManager?: RedirectManager;
private url?: UrlService;
constructor(private readonly initializerContext: PluginInitializerContext) {}
public setup(core: CoreSetup, plugins: ShareSetupDependencies): SharePluginSetup {
core.application.register(createShortUrlRedirectApp(core, window.location));
const { application, http } = core;
const { basePath } = http;
application.register(createShortUrlRedirectApp(core, window.location));
this.url = new UrlService({
baseUrl: basePath.publicBaseUrl || basePath.serverBasePath,
version: this.initializerContext.env.packageInfo.version,
navigate: async ({ app, path, state }, { replace = false } = {}) => {
const [start] = await core.getStartServices();
await start.application.navigateToApp(app, {

View file

@ -7,5 +7,3 @@
*/
export * from './redirect_manager';
export { formatSearchParams } from './util/format_search_params';
export { parseSearchParams } from './util/parse_search_params';

View file

@ -9,30 +9,9 @@
import type { CoreSetup } from 'src/core/public';
import { i18n } from '@kbn/i18n';
import { BehaviorSubject } from 'rxjs';
import type { SerializableRecord } from '@kbn/utility-types';
import { migrateToLatest } from '../../../../kibana_utils/common';
import type { UrlService } from '../../../common/url_service';
import { render } from './render';
import { parseSearchParams } from './util/parse_search_params';
/**
* @public
* Serializable locator parameters that can be used by the redirect service to navigate to a location
* in Kibana.
*
* When passed to the public {@link SharePluginSetup['navigate']} function, locator params will also be
* migrated.
*/
export interface RedirectOptions<P extends SerializableRecord = unknown & SerializableRecord> {
/** Locator ID. */
id: string;
/** Kibana version when locator params where generated. */
version: string;
/** Locator params. */
params: P;
}
import { parseSearchParams, RedirectOptions } from '../../../common/url_service/locators/redirect';
export interface RedirectManagerDependencies {
url: UrlService;
@ -48,7 +27,8 @@ export class RedirectManager {
id: 'r',
title: 'Redirect endpoint',
chromeless: true,
mount: (params) => {
mount: async (params) => {
const { render } = await import('./render');
const unmount = render(params.element, { manager: this });
this.onMount(params.history.location.search);
return () => {

View file

@ -31,8 +31,10 @@ export class SharePlugin implements Plugin<SharePluginSetup, SharePluginStart> {
public setup(core: CoreSetup) {
this.url = new UrlService({
baseUrl: core.http.basePath.publicBaseUrl || core.http.basePath.serverBasePath,
version: this.initializerContext.env.packageInfo.version,
navigate: async () => {
throw new Error('Locator .navigate() currently is not supported on the server.');
throw new Error('Locator .navigate() is not supported on the server.');
},
getUrl: async () => {
throw new Error('Locator .getUrl() currently is not supported on the server.');

View file

@ -17,6 +17,7 @@ import {
import { ViewMode } from '../../../../../../src/plugins/embeddable/public';
import { Filter, RangeFilter } from '../../../../../../src/plugins/data/public';
import { DiscoverAppLocator } from '../../../../../../src/plugins/discover/public';
import { sharePluginMock } from '../../../../../../src/plugins/share/public/mocks';
const i18nTranslateSpy = (i18n.translate as unknown) as jest.SpyInstance;
@ -45,6 +46,7 @@ const setup = (
) => {
const core = coreMock.createStart();
const locator: DiscoverAppLocator = {
...sharePluginMock.createLocator(),
getLocation: jest.fn(() =>
Promise.resolve({
app: 'discover',
@ -52,13 +54,6 @@ const setup = (
state: {},
})
),
navigate: jest.fn(async () => {}),
getUrl: jest.fn(),
useUrl: jest.fn(),
extract: jest.fn(),
inject: jest.fn(),
telemetry: jest.fn(),
migrations: {},
};
const plugins: PluginDeps = {

View file

@ -15,6 +15,7 @@ import {
} from '../../../../../../src/plugins/visualizations/public';
import { ViewMode } from '../../../../../../src/plugins/embeddable/public';
import { DiscoverAppLocator } from '../../../../../../src/plugins/discover/public';
import { sharePluginMock } from '../../../../../../src/plugins/share/public/mocks';
const i18nTranslateSpy = (i18n.translate as unknown) as jest.SpyInstance;
@ -31,6 +32,7 @@ afterEach(() => {
const setup = ({ dashboardOnlyMode = false }: { dashboardOnlyMode?: boolean } = {}) => {
const core = coreMock.createStart();
const locator: DiscoverAppLocator = {
...sharePluginMock.createLocator(),
getLocation: jest.fn(() =>
Promise.resolve({
app: 'discover',
@ -38,13 +40,6 @@ const setup = ({ dashboardOnlyMode = false }: { dashboardOnlyMode?: boolean } =
state: {},
})
),
navigate: jest.fn(async () => {}),
getUrl: jest.fn(),
useUrl: jest.fn(),
extract: jest.fn(),
inject: jest.fn(),
telemetry: jest.fn(),
migrations: {},
};
const plugins: PluginDeps = {

View file

@ -7,12 +7,14 @@
import { ManagementAppLocatorDefinition } from 'src/plugins/management/common/locator';
import { IngestPipelinesLocatorDefinition, INGEST_PIPELINES_PAGES } from './locator';
import { sharePluginMock } from '../../../../src/plugins/share/public/mocks';
describe('Ingest pipeline locator', () => {
const setup = () => {
const managementDefinition = new ManagementAppLocatorDefinition();
const definition = new IngestPipelinesLocatorDefinition({
managementAppLocator: {
...sharePluginMock.createLocator(),
getLocation: (params) => managementDefinition.getLocation(params),
getUrl: async () => {
throw new Error('not implemented');
@ -21,10 +23,6 @@ describe('Ingest pipeline locator', () => {
throw new Error('not implemented');
},
useUrl: () => '',
telemetry: jest.fn(),
extract: jest.fn(),
inject: jest.fn(),
migrations: {},
},
});
return { definition };

View file

@ -6,33 +6,17 @@
*/
import { MlPluginSetup, MlPluginStart } from './plugin';
import { sharePluginMock } from '../../../../src/plugins/share/public/mocks';
const createSetupContract = (): jest.Mocked<MlPluginSetup> => {
return {
locator: {
getLocation: jest.fn(),
getUrl: jest.fn(),
useUrl: jest.fn(),
navigate: jest.fn(),
extract: jest.fn(),
inject: jest.fn(),
telemetry: jest.fn(),
migrations: {},
},
locator: sharePluginMock.createLocator(),
};
};
const createStartContract = (): jest.Mocked<MlPluginStart> => {
return {
locator: {
getLocation: jest.fn(),
getUrl: jest.fn(),
useUrl: jest.fn(),
navigate: jest.fn(),
extract: jest.fn(),
inject: jest.fn(),
telemetry: jest.fn(),
migrations: {},
},
locator: sharePluginMock.createLocator(),
};
};