[ftr] implement FtrService classes and migrate common services (#99546)

Co-authored-by: spalger <spalger@users.noreply.github.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Spencer 2021-05-25 10:25:09 -06:00 committed by GitHub
parent d8c2594789
commit 111e15a054
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 291 additions and 252 deletions

View file

@ -139,11 +139,14 @@ export default function (/* { providerAPI } */) {
}
-----------
**Services**:::
Services are named singleton values produced by a Service Provider. Tests and other services can retrieve service instances by asking for them by name. All functionality except the mocha API is exposed via services.
**Service**:::
A Service is a named singleton created using a subclass of `FtrService`. Tests and other services can retrieve service instances by asking for them by name. All functionality except the mocha API is exposed via services. When you write your own functional tests check for existing services that help with the interactions you're looking to execute, and add new services for interactions which aren't already encoded in a service.
**Service Providers**:::
For legacy purposes, and for when creating a subclass of `FtrService` is inconvenient, you can also create services using a "Service Provider". These are functions which which create service instances and return them. These instances are cached and provided to tests. Currently these providers may also return a Promise for the service instance, allowing the service to do some setup work before tests run. We expect to fully deprecate and remove support for async service providers in the near future and instead require that services use the `lifecycle` service to run setup before tests. Providers which return instances of classes other than `FtrService` will likely remain supported for as long as possible.
**Page objects**:::
Page objects are a special type of service that encapsulate behaviors common to a particular page or plugin. When you write your own plugin, youll likely want to add a page object (or several) that describes the common interactions your tests need to execute.
Page objects are functionally equivalent to services, except they are loaded with a slightly different mechanism and generally defined separate from services. When you write your own functional tests you might want to write some of your services as Page objects, but it is not required.
**Test Files**:::
The `FunctionalTestRunner`'s primary purpose is to execute test files. These files export a Test Provider that is called with a Provider API but is not expected to return a value. Instead Test Providers define a suite using https://mochajs.org/#bdd[mocha's BDD interface].

View file

@ -12,6 +12,7 @@ import { loadTracer } from '../load_tracer';
import { createAsyncInstance, isAsyncInstance } from './async_instance';
import { Providers } from './read_provider_spec';
import { createVerboseInstance } from './verbose_instance';
import { GenericFtrService } from '../../public_types';
export class ProviderCollection {
private readonly instances = new Map();
@ -58,12 +59,19 @@ export class ProviderCollection {
}
public invokeProviderFn(provider: (args: any) => any) {
return provider({
const ctx = {
getService: this.getService,
hasService: this.hasService,
getPageObject: this.getPageObject,
getPageObjects: this.getPageObjects,
});
};
if (provider.prototype instanceof GenericFtrService) {
const Constructor = (provider as any) as new (ctx: any) => any;
return new Constructor(ctx);
}
return provider(ctx);
}
private findProvider(type: string, name: string) {

View file

@ -13,7 +13,7 @@ import { Test, Suite } from './fake_mocha_types';
export { Lifecycle, Config, FailureMetadata };
interface AsyncInstance<T> {
export interface AsyncInstance<T> {
/**
* Services that are initialized async are not ready before the tests execute, so you might need
* to call `init()` and await the promise it returns before interacting with the service
@ -39,7 +39,11 @@ export type ProvidedType<T extends (...args: any[]) => any> = MaybeAsyncInstance
* promise types into the async instances that other providers will receive.
*/
type ProvidedTypeMap<T extends {}> = {
[K in keyof T]: T[K] extends (...args: any[]) => any ? ProvidedType<T[K]> : unknown;
[K in keyof T]: T[K] extends new (...args: any[]) => infer X
? X
: T[K] extends (...args: any[]) => any
? ProvidedType<T[K]>
: unknown;
};
export interface GenericFtrProviderContext<
@ -84,6 +88,10 @@ export interface GenericFtrProviderContext<
loadTestFile(path: string): void;
}
export class GenericFtrService<ProviderContext extends GenericFtrProviderContext<any, any>> {
constructor(protected readonly ctx: ProviderContext) {}
}
export interface FtrConfigProviderContext {
log: ToolingLog;
readConfigFile(path: string): Promise<Config>;

View file

@ -6,8 +6,9 @@
* Side Public License, v 1.
*/
import { GenericFtrProviderContext } from '@kbn/test';
import { GenericFtrProviderContext, GenericFtrService } from '@kbn/test';
import { services } from './services';
export type FtrProviderContext = GenericFtrProviderContext<typeof services, {}>;
export class FtrService extends GenericFtrService<FtrProviderContext> {}

View file

@ -10,39 +10,37 @@ import { get } from 'lodash';
import fetch from 'node-fetch';
import { getUrl } from '@kbn/test';
import { FtrProviderContext } from '../ftr_provider_context';
import { FtrService } from '../ftr_provider_context';
export function DeploymentProvider({ getService }: FtrProviderContext) {
const config = getService('config');
export class DeploymentService extends FtrService {
private readonly config = this.ctx.getService('config');
return {
/**
* Returns Kibana host URL
*/
getHostPort() {
return getUrl.baseUrl(config.get('servers.kibana'));
},
/**
* Returns Kibana host URL
*/
getHostPort() {
return getUrl.baseUrl(this.config.get('servers.kibana'));
}
/**
* Returns ES host URL
*/
getEsHostPort() {
return getUrl.baseUrl(config.get('servers.elasticsearch'));
},
/**
* Returns ES host URL
*/
getEsHostPort() {
return getUrl.baseUrl(this.config.get('servers.elasticsearch'));
}
async isCloud(): Promise<boolean> {
const baseUrl = this.getHostPort();
const username = config.get('servers.kibana.username');
const password = config.get('servers.kibana.password');
const response = await fetch(baseUrl + '/api/stats?extended', {
method: 'get',
headers: {
'Content-Type': 'application/json',
Authorization: 'Basic ' + Buffer.from(username + ':' + password).toString('base64'),
},
});
const data = await response.json();
return get(data, 'usage.cloud.is_cloud_enabled', false);
},
};
async isCloud(): Promise<boolean> {
const baseUrl = this.getHostPort();
const username = this.config.get('servers.kibana.username');
const password = this.config.get('servers.kibana.password');
const response = await fetch(baseUrl + '/api/stats?extended', {
method: 'get',
headers: {
'Content-Type': 'application/json',
Authorization: 'Basic ' + Buffer.from(username + ':' + password).toString('base64'),
},
});
const data = await response.json();
return get(data, 'usage.cloud.is_cloud_enabled', false);
}
}

View file

@ -6,26 +6,26 @@
* Side Public License, v 1.
*/
import { DeploymentProvider } from './deployment';
import { DeploymentService } from './deployment';
import { LegacyEsProvider } from './legacy_es';
import { ElasticsearchProvider } from './elasticsearch';
import { EsArchiverProvider } from './es_archiver';
import { KibanaServerProvider } from './kibana_server';
import { RetryProvider } from './retry';
import { RandomnessProvider } from './randomness';
import { RetryService } from './retry';
import { RandomnessService } from './randomness';
import { SecurityServiceProvider } from './security';
import { EsDeleteAllIndicesProvider } from './es_delete_all_indices';
import { SavedObjectInfoProvider } from './saved_object_info';
import { SavedObjectInfoService } from './saved_object_info';
export const services = {
deployment: DeploymentProvider,
deployment: DeploymentService,
legacyEs: LegacyEsProvider,
es: ElasticsearchProvider,
esArchiver: EsArchiverProvider,
kibanaServer: KibanaServerProvider,
retry: RetryProvider,
randomness: RandomnessProvider,
retry: RetryService,
randomness: RandomnessService,
security: SecurityServiceProvider,
esDeleteAllIndices: EsDeleteAllIndicesProvider,
savedObjectInfo: SavedObjectInfoProvider,
savedObjectInfo: SavedObjectInfoService,
};

View file

@ -11,7 +11,7 @@ import { KbnClient } from '@kbn/test';
import { FtrProviderContext } from '../../ftr_provider_context';
export function KibanaServerProvider({ getService }: FtrProviderContext) {
export function KibanaServerProvider({ getService }: FtrProviderContext): KbnClient {
const log = getService('log');
const config = getService('config');
const lifecycle = getService('lifecycle');

View file

@ -7,8 +7,20 @@
*/
import Chance from 'chance';
import { ToolingLog } from '@kbn/dev-utils';
import { FtrProviderContext } from '../ftr_provider_context';
import { FtrService } from '../ftr_provider_context';
let __CACHED_SEED__: number | undefined;
function getSeed(log: ToolingLog) {
if (__CACHED_SEED__ !== undefined) {
return __CACHED_SEED__;
}
__CACHED_SEED__ = Date.now();
log.debug('randomness seed: %j', __CACHED_SEED__);
return __CACHED_SEED__;
}
interface CharOptions {
pool?: string;
@ -27,52 +39,45 @@ interface NumberOptions {
max?: number;
}
export function RandomnessProvider({ getService }: FtrProviderContext) {
const log = getService('log');
export class RandomnessService extends FtrService {
private readonly chance = new Chance(getSeed(this.ctx.getService('log')));
const seed = Date.now();
log.debug('randomness seed: %j', seed);
/**
* Generate a random natural number
*
* range: 0 to 9007199254740991
*
*/
naturalNumber(options?: NumberOptions) {
return this.chance.natural(options);
}
const chance = new Chance(seed);
/**
* Generate a random integer
*/
integer(options?: NumberOptions) {
return this.chance.integer(options);
}
return new (class RandomnessService {
/**
* Generate a random natural number
*
* range: 0 to 9007199254740991
*
*/
naturalNumber(options?: NumberOptions) {
return chance.natural(options);
}
/**
* Generate a random number, defaults to at least 4 and no more than 8 syllables
*/
word(options: { syllables?: number } = {}) {
const { syllables = this.naturalNumber({ min: 4, max: 8 }) } = options;
/**
* Generate a random integer
*/
integer(options?: NumberOptions) {
return chance.integer(options);
}
return this.chance.word({
syllables,
});
}
/**
* Generate a random number, defaults to at least 4 and no more than 8 syllables
*/
word(options: { syllables?: number } = {}) {
const { syllables = this.naturalNumber({ min: 4, max: 8 }) } = options;
return chance.word({
syllables,
});
}
/**
* Generate a random string, defaults to at least 8 and no more than 15 alpha-numeric characters
*/
string(options: StringOptions = {}) {
return chance.string({
length: this.naturalNumber({ min: 8, max: 15 }),
...(options.pool === 'undefined' ? { alpha: true, numeric: true, symbols: false } : {}),
...options,
});
}
})();
/**
* Generate a random string, defaults to at least 8 and no more than 15 alpha-numeric characters
*/
string(options: StringOptions = {}) {
return this.chance.string({
length: this.naturalNumber({ min: 8, max: 15 }),
...(options.pool === 'undefined' ? { alpha: true, numeric: true, symbols: false } : {}),
...options,
});
}
}

View file

@ -6,4 +6,4 @@
* Side Public License, v 1.
*/
export { RetryProvider } from './retry';
export { RetryService } from './retry';

View file

@ -6,64 +6,62 @@
* Side Public License, v 1.
*/
import { FtrProviderContext } from '../../ftr_provider_context';
import { FtrService } from '../../ftr_provider_context';
import { retryForSuccess } from './retry_for_success';
import { retryForTruthy } from './retry_for_truthy';
export function RetryProvider({ getService }: FtrProviderContext) {
const config = getService('config');
const log = getService('log');
export class RetryService extends FtrService {
private readonly config = this.ctx.getService('config');
private readonly log = this.ctx.getService('log');
return new (class Retry {
public async tryForTime<T>(
timeout: number,
block: () => Promise<T>,
onFailureBlock?: () => Promise<T>
) {
return await retryForSuccess(log, {
timeout,
methodName: 'retry.tryForTime',
block,
onFailureBlock,
});
}
public async tryForTime<T>(
timeout: number,
block: () => Promise<T>,
onFailureBlock?: () => Promise<T>
) {
return await retryForSuccess(this.log, {
timeout,
methodName: 'retry.tryForTime',
block,
onFailureBlock,
});
}
public async try<T>(block: () => Promise<T>, onFailureBlock?: () => Promise<T>) {
return await retryForSuccess(log, {
timeout: config.get('timeouts.try'),
methodName: 'retry.try',
block,
onFailureBlock,
});
}
public async try<T>(block: () => Promise<T>, onFailureBlock?: () => Promise<T>) {
return await retryForSuccess(this.log, {
timeout: this.config.get('timeouts.try'),
methodName: 'retry.try',
block,
onFailureBlock,
});
}
public async waitForWithTimeout(
description: string,
timeout: number,
block: () => Promise<boolean>,
onFailureBlock?: () => Promise<any>
) {
await retryForTruthy(log, {
timeout,
methodName: 'retry.waitForWithTimeout',
description,
block,
onFailureBlock,
});
}
public async waitForWithTimeout(
description: string,
timeout: number,
block: () => Promise<boolean>,
onFailureBlock?: () => Promise<any>
) {
await retryForTruthy(this.log, {
timeout,
methodName: 'retry.waitForWithTimeout',
description,
block,
onFailureBlock,
});
}
public async waitFor(
description: string,
block: () => Promise<boolean>,
onFailureBlock?: () => Promise<any>
) {
await retryForTruthy(log, {
timeout: config.get('timeouts.waitFor'),
methodName: 'retry.waitFor',
description,
block,
onFailureBlock,
});
}
})();
public async waitFor(
description: string,
block: () => Promise<boolean>,
onFailureBlock?: () => Promise<any>
) {
await retryForTruthy(this.log, {
timeout: this.config.get('timeouts.waitFor'),
methodName: 'retry.waitFor',
description,
block,
onFailureBlock,
});
}
}

View file

@ -6,48 +6,44 @@
* Side Public License, v 1.
*/
import { Client } from '@elastic/elasticsearch';
import url from 'url';
import { Either, fromNullable, chain, getOrElse } from 'fp-ts/Either';
import { flow } from 'fp-ts/function';
import { FtrProviderContext } from '../ftr_provider_context';
import { inspect } from 'util';
const pluck = (key: string) => (obj: any): Either<Error, string> =>
fromNullable(new Error(`Missing ${key}`))(obj[key]);
import { TermsAggregate } from '@elastic/elasticsearch/api/types';
const types = (node: string) => async (index: string = '.kibana') => {
let res: unknown;
try {
const { body } = await new Client({ node }).search({
index,
body: {
aggs: {
savedobjs: {
terms: {
field: 'type',
import { FtrService } from '../ftr_provider_context';
export class SavedObjectInfoService extends FtrService {
private readonly es = this.ctx.getService('es');
public async getTypes(index = '.kibana') {
try {
const { body } = await this.es.search({
index,
size: 0,
body: {
aggs: {
savedobjs: {
terms: {
field: 'type',
},
},
},
},
},
});
});
res = flow(
pluck('aggregations'),
chain(pluck('savedobjs')),
chain(pluck('buckets')),
getOrElse((err) => `${err.message}`)
)(body);
} catch (err) {
throw new Error(`Error while searching for saved object types: ${err}`);
const agg = body.aggregations?.savedobjs as
| TermsAggregate<{ key: string; doc_count: number }>
| undefined;
if (!agg?.buckets) {
throw new Error(
`expected es to return buckets of saved object types: ${inspect(body, { depth: 100 })}`
);
}
return agg.buckets;
} catch (error) {
throw new Error(`Error while searching for saved object types: ${error}`);
}
}
return res;
};
export const SavedObjectInfoProvider: any = ({ getService }: FtrProviderContext) => {
const config = getService('config');
return {
types: types(url.format(config.get('servers.elasticsearch'))),
};
};
}

View file

@ -10,23 +10,27 @@ import { Role } from './role';
import { User } from './user';
import { RoleMappings } from './role_mappings';
import { FtrProviderContext } from '../../ftr_provider_context';
import { createTestUserService, TestUserSupertestProvider } from './test_user';
import { createTestUserService, TestUserSupertestProvider, TestUser } from './test_user';
export async function SecurityServiceProvider(context: FtrProviderContext) {
const { getService } = context;
const log = getService('log');
const kibanaServer = getService('kibanaServer');
export class SecurityService {
constructor(
public readonly roleMappings: RoleMappings,
public readonly testUser: TestUser,
public readonly role: Role,
public readonly user: User,
public readonly testUserSupertest: ReturnType<typeof TestUserSupertestProvider>
) {}
}
export async function SecurityServiceProvider(ctx: FtrProviderContext) {
const log = ctx.getService('log');
const kibanaServer = ctx.getService('kibanaServer');
const role = new Role(log, kibanaServer);
const user = new User(log, kibanaServer);
const testUser = await createTestUserService(role, user, context);
const testUserSupertest = TestUserSupertestProvider(context);
const testUser = await createTestUserService(ctx, role, user);
const testUserSupertest = TestUserSupertestProvider(ctx);
const roleMappings = new RoleMappings(log, kibanaServer);
return new (class SecurityService {
roleMappings = new RoleMappings(log, kibanaServer);
testUser = testUser;
role = role;
user = user;
testUserSupertest = testUserSupertest;
})();
return new SecurityService(roleMappings, testUser, role, user, testUserSupertest);
}

View file

@ -11,41 +11,84 @@ import supertestAsPromised from 'supertest-as-promised';
import { Role } from './role';
import { User } from './user';
import { FtrProviderContext } from '../../ftr_provider_context';
import { FtrService, FtrProviderContext } from '../../ftr_provider_context';
import { Browser } from '../../../functional/services/common';
import { TestSubjects } from '../../../functional/services/common';
const TEST_USER_NAME = 'test_user';
const TEST_USER_PASSWORD = 'changeme';
export async function createTestUserService(
role: Role,
user: User,
{ getService, hasService }: FtrProviderContext
) {
const log = getService('log');
const config = getService('config');
// @ts-ignore browser service is not normally available in common.
const browser: Browser | void = hasService('browser') && getService('browser');
const testSubjects: TestSubjects | undefined =
export class TestUser extends FtrService {
private readonly config = this.ctx.getService('config');
private readonly log = this.ctx.getService('log');
private readonly browser: Browser | void =
// browser service is not normally available in common.
this.ctx.hasService('browser') ? (this.ctx.getService('browser' as any) as Browser) : undefined;
private readonly testSubjects: TestSubjects | undefined =
// testSubject service is not normally available in common.
hasService('testSubjects') ? (getService('testSubjects' as any) as TestSubjects) : undefined;
const kibanaServer = getService('kibanaServer');
this.ctx.hasService('testSubjects')
? (this.ctx.getService('testSubjects' as any) as TestSubjects)
: undefined;
constructor(
ctx: FtrProviderContext,
private readonly enabled: boolean,
private readonly user: User
) {
super(ctx);
}
async restoreDefaults(shouldRefreshBrowser: boolean = true) {
if (this.enabled) {
await this.setRoles(this.config.get('security.defaultRoles'), shouldRefreshBrowser);
}
}
async setRoles(roles: string[], shouldRefreshBrowser: boolean = true) {
if (this.enabled) {
this.log.debug(`set roles = ${roles}`);
await this.user.create(TEST_USER_NAME, {
password: TEST_USER_PASSWORD,
roles,
full_name: 'test user',
});
if (this.browser && this.testSubjects && shouldRefreshBrowser) {
if (await this.testSubjects.exists('kibanaChrome', { allowHidden: true })) {
await this.browser.refresh();
// accept alert if it pops up
const alert = await this.browser.getAlert();
await alert?.accept();
await this.testSubjects.find('kibanaChrome', this.config.get('timeouts.find') * 10);
}
}
}
}
}
export async function createTestUserService(ctx: FtrProviderContext, role: Role, user: User) {
const log = ctx.getService('log');
const config = ctx.getService('config');
const kibanaServer = ctx.getService('kibanaServer');
const enabledPlugins = config.get('security.disableTestUser')
? []
: await kibanaServer.plugins.getEnabledIds();
const isEnabled = () => {
return enabledPlugins.includes('security') && !config.get('security.disableTestUser');
};
if (isEnabled()) {
const enabled = enabledPlugins.includes('security') && !config.get('security.disableTestUser');
if (enabled) {
log.debug('===============creating roles and users===============');
// create the defined roles (need to map array to create roles)
for (const [name, definition] of Object.entries(config.get('security.roles'))) {
// create the defined roles (need to map array to create roles)
await role.create(name, definition);
}
// delete the test_user if present (will it error if the user doesn't exist?)
try {
// delete the test_user if present (will it error if the user doesn't exist?)
await user.delete(TEST_USER_NAME);
} catch (exception) {
log.debug('no test user to delete');
@ -60,34 +103,7 @@ export async function createTestUserService(
});
}
return new (class TestUser {
async restoreDefaults(shouldRefreshBrowser: boolean = true) {
if (isEnabled()) {
await this.setRoles(config.get('security.defaultRoles'), shouldRefreshBrowser);
}
}
async setRoles(roles: string[], shouldRefreshBrowser: boolean = true) {
if (isEnabled()) {
log.debug(`set roles = ${roles}`);
await user.create(TEST_USER_NAME, {
password: TEST_USER_PASSWORD,
roles,
full_name: 'test user',
});
if (browser && testSubjects && shouldRefreshBrowser) {
if (await testSubjects.exists('kibanaChrome', { allowHidden: true })) {
await browser.refresh();
// accept alert if it pops up
const alert = await browser.getAlert();
await alert?.accept();
await testSubjects.find('kibanaChrome', config.get('timeouts.find') * 10);
}
}
}
}
})();
return new TestUser(ctx, enabled, user);
}
export function TestUserSupertestProvider({ getService }: FtrProviderContext) {

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { FtrProviderContext } from '../../ftr_provider_context.d';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, loadTestFile }: FtrProviderContext) {
const browser = getService('browser');

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { FtrProviderContext } from '../../../ftr_provider_context.d';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { UI_SETTINGS } from '../../../../../src/plugins/data/common';
export default function ({ getPageObjects, getService, loadTestFile }: FtrProviderContext) {

View file

@ -6,9 +6,10 @@
* Side Public License, v 1.
*/
import { GenericFtrProviderContext } from '@kbn/test';
import { GenericFtrProviderContext, GenericFtrService } from '@kbn/test';
import { pageObjects } from './page_objects';
import { services } from './services';
export type FtrProviderContext = GenericFtrProviderContext<typeof services, typeof pageObjects>;
export class FtrService extends GenericFtrService<FtrProviderContext> {}

View file

@ -7,7 +7,7 @@
*/
import moment from 'moment';
import { FtrProviderContext } from '../ftr_provider_context.d';
import { FtrProviderContext } from '../ftr_provider_context';
import { WebElementWrapper } from '../services/lib/web_element_wrapper';
export type CommonlyUsed =

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { FtrProviderContext } from '../ftr_provider_context.d';
import { FtrProviderContext } from '../ftr_provider_context';
import { WebElementWrapper } from '../services/lib/web_element_wrapper';
export function VisualBuilderPageProvider({ getService, getPageObjects }: FtrProviderContext) {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { FtrProviderContext } from '../../ftr_provider_context.d';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, loadTestFile }: FtrProviderContext) {
const browser = getService('browser');

View file

@ -5,9 +5,10 @@
* 2.0.
*/
import { GenericFtrProviderContext } from '@kbn/test';
import { GenericFtrProviderContext, GenericFtrService } from '@kbn/test';
import { pageObjects } from './page_objects';
import { services } from './services';
export type FtrProviderContext = GenericFtrProviderContext<typeof services, typeof pageObjects>;
export class FtrService extends GenericFtrService<FtrProviderContext> {}