diff --git a/packages/kbn-scout/src/playwright/config/index.ts b/packages/kbn-scout/src/playwright/config/index.ts index cb1e371cb43e7..96a401150d70a 100644 --- a/packages/kbn-scout/src/playwright/config/index.ts +++ b/packages/kbn-scout/src/playwright/config/index.ts @@ -57,7 +57,10 @@ export function createPlaywrightConfig(options: ScoutPlaywrightOptions): Playwri projects: [ { name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1920, height: 1200 }, + }, }, // { diff --git a/x-pack/plugins/observability_solution/observability_onboarding/ui_tests/fixtures/index.ts b/x-pack/plugins/observability_solution/observability_onboarding/ui_tests/fixtures/index.ts new file mode 100644 index 0000000000000..a928c1d6fe34b --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_onboarding/ui_tests/fixtures/index.ts @@ -0,0 +1,50 @@ +/* + * 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 { + test as base, + PageObjects, + createLazyPageObject, + ScoutTestFixtures, + ScoutWorkerFixtures, + KibanaUrl, + KbnClient, +} from '@kbn/scout'; +import { OnboardingHomePage } from './page_objects'; +import { CustomLogsPage } from './page_objects/custom_logs'; + +export interface ExtendedScoutTestFixtures extends ScoutTestFixtures { + pageObjects: PageObjects & { + onboardingHomePage: OnboardingHomePage; + customLogsPage: CustomLogsPage; + }; +} + +export const test = base.extend({ + pageObjects: async ( + { + pageObjects, + page, + kbnUrl, + kbnClient, + }: { + pageObjects: ExtendedScoutTestFixtures['pageObjects']; + page: ExtendedScoutTestFixtures['page']; + kbnUrl: KibanaUrl; + kbnClient: KbnClient; + }, + use: (pageObjects: ExtendedScoutTestFixtures['pageObjects']) => Promise + ) => { + const extendedPageObjects = { + ...pageObjects, + onboardingHomePage: createLazyPageObject(OnboardingHomePage, page), + customLogsPage: createLazyPageObject(CustomLogsPage, page, kbnUrl, kbnClient), + }; + + await use(extendedPageObjects); + }, +}); diff --git a/x-pack/plugins/observability_solution/observability_onboarding/ui_tests/fixtures/page_objects/custom_logs.ts b/x-pack/plugins/observability_solution/observability_onboarding/ui_tests/fixtures/page_objects/custom_logs.ts new file mode 100644 index 0000000000000..9f8fe0d4f90d1 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_onboarding/ui_tests/fixtures/page_objects/custom_logs.ts @@ -0,0 +1,107 @@ +/* + * 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 { ScoutPage, KibanaUrl, KbnClient } from '@kbn/scout'; + +export class CustomLogsPage { + constructor( + private readonly page: ScoutPage, + private readonly kbnUrl: KibanaUrl, + private readonly kbnClient: KbnClient + ) {} + + async goto() { + this.page.goto(`${this.kbnUrl.app('observabilityOnboarding')}/customLogs`); + } + + async clickBackButton() { + await this.page.testSubj.click('observabilityOnboardingFlowBackToSelectionButton'); + } + + logFilePathList() { + return this.page.locator(`[data-test-subj^=obltOnboardingLogFilePath-]`); + } + + logFilePathInput(index: number) { + return this.page.testSubj.locator(`obltOnboardingLogFilePath-${index}`).getByRole('textbox'); + } + + continueButton() { + return this.page.testSubj.locator('obltOnboardingCustomLogsContinue'); + } + + addLogFilePathButton() { + return this.page.testSubj.locator('obltOnboardingCustomLogsAddFilePath'); + } + + logFilePathDeleteButton(index: number) { + return this.page.testSubj.locator(`obltOnboardingLogFilePathDelete-${index}`); + } + + integrationNameInput() { + return this.page.testSubj.locator('obltOnboardingCustomLogsIntegrationsName'); + } + + datasetNameInput() { + return this.page.testSubj.locator('obltOnboardingCustomLogsDatasetName'); + } + + serviceNameInput() { + return this.page.testSubj.locator('obltOnboardingCustomLogsServiceName'); + } + + async clickAdvancedSettingsButton() { + return this.page.testSubj + .locator('obltOnboardingCustomLogsAdvancedSettings') + .getByRole('button') + .first() + .click(); + } + + namespaceInput() { + return this.page.testSubj.locator('obltOnboardingCustomLogsNamespace'); + } + + customConfigInput() { + return this.page.testSubj.locator('obltOnboardingCustomLogsCustomConfig'); + } + + async installCustomIntegration(name: string) { + await this.kbnClient.request({ + method: 'POST', + path: `/api/fleet/epm/custom_integrations`, + body: { + force: true, + integrationName: name, + datasets: [ + { name: `${name}.access`, type: 'logs' }, + { name: `${name}.error`, type: 'metrics' }, + { name: `${name}.warning`, type: 'logs' }, + ], + }, + }); + } + + async deleteIntegration(name: string) { + const packageInfo = await this.kbnClient.request<{ item: { status: string } }>({ + method: 'GET', + path: `/api/fleet/epm/packages/${name}`, + ignoreErrors: [404], + }); + + if (packageInfo.data.item?.status === 'installed') { + await this.kbnClient.request({ + method: 'DELETE', + path: `/api/fleet/epm/packages/${name}`, + }); + } + } + + customIntegrationErrorCallout() { + return this.page.testSubj.locator('obltOnboardingCustomIntegrationErrorCallout'); + } +} diff --git a/x-pack/plugins/observability_solution/observability_onboarding/ui_tests/fixtures/page_objects/index.ts b/x-pack/plugins/observability_solution/observability_onboarding/ui_tests/fixtures/page_objects/index.ts new file mode 100644 index 0000000000000..c032371659593 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_onboarding/ui_tests/fixtures/page_objects/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export { OnboardingHomePage } from './onboarding_home'; diff --git a/x-pack/plugins/observability_solution/observability_onboarding/ui_tests/fixtures/page_objects/onboarding_home.ts b/x-pack/plugins/observability_solution/observability_onboarding/ui_tests/fixtures/page_objects/onboarding_home.ts new file mode 100644 index 0000000000000..bd35dc4fbe909 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_onboarding/ui_tests/fixtures/page_objects/onboarding_home.ts @@ -0,0 +1,16 @@ +/* + * 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 { ScoutPage } from '@kbn/scout'; + +export class OnboardingHomePage { + constructor(private readonly page: ScoutPage) {} + + async goto() { + this.page.gotoApp('observabilityOnboarding'); + } +} diff --git a/x-pack/plugins/observability_solution/observability_onboarding/ui_tests/playwright.config.ts b/x-pack/plugins/observability_solution/observability_onboarding/ui_tests/playwright.config.ts new file mode 100644 index 0000000000000..34b370396b67e --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_onboarding/ui_tests/playwright.config.ts @@ -0,0 +1,13 @@ +/* + * 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 { createPlaywrightConfig } from '@kbn/scout'; + +// eslint-disable-next-line import/no-default-export +export default createPlaywrightConfig({ + testDir: './tests', +}); diff --git a/x-pack/plugins/observability_solution/observability_onboarding/ui_tests/tests/custom_logs.spec.ts b/x-pack/plugins/observability_solution/observability_onboarding/ui_tests/tests/custom_logs.spec.ts new file mode 100644 index 0000000000000..97d502033b9fe --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_onboarding/ui_tests/tests/custom_logs.spec.ts @@ -0,0 +1,279 @@ +/* + * 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/scout'; +import { test } from '../fixtures'; + +test.describe('Onboarding app - Custom logs quickstart flow', { tag: ['@ess', '@svlOblt'] }, () => { + test.beforeEach(async ({ browserAuth, pageObjects: { customLogsPage } }) => { + await browserAuth.loginAsViewer(); + await customLogsPage.goto(); + }); + + test('When user clicks the back button user goes to the onboarding home page', async ({ + pageObjects: { customLogsPage }, + page, + }) => { + await customLogsPage.clickBackButton(); + expect(page.url()).toContain('/app/observabilityOnboarding'); + }); + + test.describe('Log file path inputs', () => { + test(`Users shouldn't be able to continue if logFilePaths is empty`, async ({ + pageObjects: { customLogsPage }, + }) => { + await expect(customLogsPage.logFilePathInput(0)).toHaveValue(''); + await expect(customLogsPage.continueButton()).toBeDisabled(); + }); + + test(`Users should be able to continue if logFilePaths is not empty`, async ({ + pageObjects: { customLogsPage }, + }) => { + await customLogsPage.logFilePathInput(0).fill('some/path'); + + await expect(customLogsPage.continueButton()).not.toBeDisabled(); + }); + + test('Users can add multiple logFilePaths', async ({ pageObjects: { customLogsPage } }) => { + await customLogsPage.addLogFilePathButton().click(); + await expect(customLogsPage.logFilePathInput(0)).toBeVisible(); + await expect(customLogsPage.logFilePathInput(1)).toBeVisible(); + }); + + test('Users can delete logFilePaths', async ({ pageObjects: { customLogsPage } }) => { + await customLogsPage.addLogFilePathButton().click(); + await expect(customLogsPage.logFilePathList()).toHaveCount(2); + + await customLogsPage.logFilePathDeleteButton(1).click(); + await expect(customLogsPage.logFilePathList()).toHaveCount(1); + }); + + test.describe('when users fill logFilePaths', () => { + test('datasetname and integration name are auto generated if it is the first path', async ({ + pageObjects: { customLogsPage }, + }) => { + await customLogsPage.logFilePathInput(0).fill('myLogs.log'); + + await expect(customLogsPage.integrationNameInput()).toHaveValue('mylogs'); + await expect(customLogsPage.datasetNameInput()).toHaveValue('mylogs'); + }); + + test('datasetname and integration name are not generated if it is not the first path', async ({ + pageObjects: { customLogsPage }, + }) => { + await customLogsPage.addLogFilePathButton().click(); + await customLogsPage.logFilePathInput(1).fill('myLogs.log'); + + await expect(customLogsPage.integrationNameInput()).toHaveValue(''); + await expect(customLogsPage.datasetNameInput()).toHaveValue(''); + }); + }); + }); + + test.describe('Service name input', () => { + test('should be optional allowing user to continue if it is empty', async ({ + pageObjects: { customLogsPage }, + }) => { + await customLogsPage.logFilePathInput(0).fill('myLogs.log'); + + await expect(customLogsPage.serviceNameInput()).toHaveValue(''); + await expect(customLogsPage.continueButton()).not.toBeDisabled(); + }); + }); + + test.describe('Advanced settings', () => { + test('Users should expand and collapse the section', async ({ + pageObjects: { customLogsPage }, + }) => { + await expect(customLogsPage.namespaceInput()).not.toBeInViewport(); + await expect(customLogsPage.customConfigInput()).not.toBeInViewport(); + + await customLogsPage.clickAdvancedSettingsButton(); + + await expect(customLogsPage.namespaceInput()).toBeInViewport(); + await expect(customLogsPage.customConfigInput()).toBeInViewport(); + + await customLogsPage.clickAdvancedSettingsButton(); + + await expect(customLogsPage.namespaceInput()).not.toBeInViewport(); + await expect(customLogsPage.customConfigInput()).not.toBeInViewport(); + }); + + test.describe('Namespace', () => { + test.beforeEach(async ({ pageObjects: { customLogsPage } }) => { + await customLogsPage.clickAdvancedSettingsButton(); + }); + + test('Users should see a default namespace', async ({ pageObjects: { customLogsPage } }) => { + await expect(customLogsPage.namespaceInput()).toHaveValue('default'); + }); + + test('Users should not be able to continue if they do not specify a namespace', async ({ + pageObjects: { customLogsPage }, + }) => { + await customLogsPage.namespaceInput().fill(''); + await expect(customLogsPage.continueButton()).toBeDisabled(); + }); + }); + + test.describe('Custom config', () => { + test.beforeEach(async ({ pageObjects: { customLogsPage } }) => { + await customLogsPage.clickAdvancedSettingsButton(); + }); + + test.afterEach(async ({ pageObjects: { customLogsPage } }) => { + await customLogsPage.clickAdvancedSettingsButton(); + }); + + test('should be optional allowing user to continue if it is empty', async ({ + pageObjects: { customLogsPage }, + }) => { + await customLogsPage.logFilePathInput(0).fill('myLogs.log'); + await expect(customLogsPage.customConfigInput()).toHaveValue(''); + await expect(customLogsPage.continueButton()).not.toBeDisabled(); + }); + }); + }); + + test.describe('Integration name', () => { + test.beforeEach(async ({ pageObjects: { customLogsPage } }) => { + await customLogsPage.logFilePathInput(0).fill('myLogs.log'); + }); + + test('Users should not be able to continue if they do not specify an integrationName', async ({ + pageObjects: { customLogsPage }, + }) => { + await customLogsPage.integrationNameInput().fill(''); + await expect(customLogsPage.continueButton()).toBeDisabled(); + }); + + test('value will contain _ instead of special chars', async ({ + pageObjects: { customLogsPage }, + }) => { + await customLogsPage.integrationNameInput().fill('hello$world'); + await expect(customLogsPage.integrationNameInput()).toHaveValue('hello_world'); + }); + + test('value will be invalid if it is not lowercase', async ({ + pageObjects: { customLogsPage }, + page, + }) => { + await customLogsPage.integrationNameInput().fill('H3llowOrld'); + await expect(page.getByText('An integration name should be lowercase.')).toBeVisible(); + }); + }); + + test.describe('datasetName', () => { + test.beforeEach(async ({ pageObjects: { customLogsPage } }) => { + await customLogsPage.logFilePathInput(0).fill('myLogs.log'); + }); + + test('Users should not be able to continue if they do not specify a datasetName', async ({ + pageObjects: { customLogsPage }, + }) => { + await customLogsPage.datasetNameInput().fill(''); + await expect(customLogsPage.continueButton()).toBeDisabled(); + }); + + test('value will contain _ instead of special chars', async ({ + pageObjects: { customLogsPage }, + }) => { + await customLogsPage.datasetNameInput().fill('hello$world'); + await expect(customLogsPage.datasetNameInput()).toHaveValue('hello_world'); + }); + + test('value will be invalid if it is not lowercase', async ({ + pageObjects: { customLogsPage }, + page, + }) => { + await customLogsPage.datasetNameInput().fill('H3llowOrld'); + await expect(page.getByText('A dataset name should be lowercase.')).toBeVisible(); + }); + }); + + test.describe('custom integration', () => { + const CUSTOM_INTEGRATION_NAME = 'mylogs'; + + test.beforeEach(async ({ pageObjects: { customLogsPage }, browserAuth }) => { + await browserAuth.loginAsAdmin(); + await customLogsPage.deleteIntegration(CUSTOM_INTEGRATION_NAME); + }); + + test.describe('when user is missing privileges', () => { + test('installation fails', async ({ browserAuth, pageObjects: { customLogsPage }, page }) => { + await browserAuth.loginAsViewer(); + await page.reload(); + + await customLogsPage.logFilePathInput(0).fill(`${CUSTOM_INTEGRATION_NAME}.log`); + await customLogsPage.continueButton().click(); + + await expect(customLogsPage.customIntegrationErrorCallout()).toBeVisible(); + }); + }); + + test.describe('when user has proper privileges', () => { + test('installation succeed and user is redirected to install elastic agent step', async ({ + browserAuth, + page, + pageObjects: { customLogsPage }, + }) => { + await browserAuth.loginAsAdmin(); + await page.reload(); + + await customLogsPage.logFilePathInput(0).fill(`${CUSTOM_INTEGRATION_NAME}.log`); + await customLogsPage.continueButton().click(); + + await page.waitForURL('**/app/observabilityOnboarding/customLogs/installElasticAgent'); + }); + }); + + test('installation fails if integration already exists', async ({ + browserAuth, + pageObjects: { customLogsPage }, + page, + }) => { + await browserAuth.loginAsAdmin(); + await page.reload(); + + await customLogsPage.installCustomIntegration(CUSTOM_INTEGRATION_NAME); + await customLogsPage.logFilePathInput(0).fill(`${CUSTOM_INTEGRATION_NAME}.log`); + await customLogsPage.continueButton().click(); + + await expect( + page.getByText( + 'Failed to create the integration as an installation with the name mylogs already exists.' + ) + ).toBeVisible(); + }); + + test.describe('when an error occurred on creation', () => { + test('user should see the error displayed', async ({ + pageObjects: { customLogsPage }, + browserAuth, + page, + kbnUrl, + }) => { + await browserAuth.loginAsAdmin(); + await page.reload(); + + await page.route(kbnUrl.get('/api/fleet/epm/custom_integrations'), (route) => { + route.fulfill({ + status: 500, + json: { + message: 'Internal error', + }, + }); + }); + + await customLogsPage.logFilePathInput(0).fill(`${CUSTOM_INTEGRATION_NAME}.log`); + await customLogsPage.continueButton().click(); + + await expect(customLogsPage.customIntegrationErrorCallout()).toBeVisible(); + }); + }); + }); +});