diff --git a/.eslintrc.yml b/.eslintrc.yml index c83f113e..28c374fd 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -60,3 +60,6 @@ overrides: extends: "plugin:testing-library/react" - files: ["playwright/**/*.ts"] extends: "plugin:playwright/recommended" + rules: + playwright/no-conditional-in-test: off + playwright/no-conditional-expect: off diff --git a/playwright.config.ts b/playwright.config.ts index f3d8f6be..e0367064 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -29,7 +29,7 @@ export default defineConfig({ ? process.env.BASE_URL : 'http://127.0.0.1:9090', video: 'retain-on-failure', - trace: 'on-first-retry', + trace: 'on', ignoreHTTPSErrors: true, }, diff --git a/playwright/Customizations/Hostname.spec.ts b/playwright/Customizations/Hostname.spec.ts new file mode 100644 index 00000000..509635b3 --- /dev/null +++ b/playwright/Customizations/Hostname.spec.ts @@ -0,0 +1,84 @@ +import { expect } from '@playwright/test'; +import { v4 as uuidv4 } from 'uuid'; + +import { test } from '../fixtures/cleanup'; +import { isHosted } from '../helpers/helpers'; +import { login } from '../helpers/login'; +import { navigateToOptionalSteps, ibFrame } from '../helpers/navHelpers'; +import { + registerLater, + fillInDetails, + createBlueprint, + fillInImageOutputGuest, + deleteBlueprint, + exportBlueprint, + importBlueprint, +} from '../helpers/wizardHelpers'; + +test('Create a blueprint with Hostname customization', async ({ + page, + cleanup, +}) => { + const blueprintName = 'test-' + uuidv4(); + const hostname = 'testsystem'; + + // Delete the blueprint after the run fixture + await cleanup.add(() => deleteBlueprint(page, blueprintName)); + + // Login, navigate to IB and get the frame + await login(page); + const frame = await ibFrame(page); + + await test.step('Navigate to optional steps in Wizard', async () => { + await navigateToOptionalSteps(frame); + await registerLater(frame); + }); + + await test.step('Select and fill the Hostname step', async () => { + await frame.getByRole('button', { name: 'Hostname' }).click(); + await frame.getByRole('textbox', { name: 'hostname input' }).fill(hostname); + await frame.getByRole('button', { name: 'Review and finish' }).click(); + }); + + await test.step('Fill the BP details', async () => { + await fillInDetails(frame, blueprintName); + }); + + await test.step('Create BP', async () => { + await createBlueprint(frame, blueprintName); + }); + + await test.step('Edit BP', async () => { + await frame.getByRole('button', { name: 'Edit blueprint' }).click(); + await frame.getByLabel('Revisit Hostname step').click(); + await frame.getByRole('textbox', { name: 'hostname input' }).click(); + await frame + .getByRole('textbox', { name: 'hostname input' }) + .fill(hostname + 'edited'); + await frame.getByRole('button', { name: 'Review and finish' }).click(); + await frame + .getByRole('button', { name: 'Save changes to blueprint' }) + .click(); + }); + + // This is for hosted service only as these features are not available in cockpit plugin + await test.step('Export BP', async (step) => { + step.skip(!isHosted(), 'Exporting is not available in the plugin'); + await exportBlueprint(page, blueprintName); + }); + + await test.step('Import BP', async (step) => { + step.skip(!isHosted(), 'Importing is not available in the plugin'); + await importBlueprint(page, blueprintName); + }); + + await test.step('Review imported BP', async (step) => { + step.skip(!isHosted(), 'Importing is not available in the plugin'); + await fillInImageOutputGuest(page); + await page.getByRole('button', { name: 'Hostname' }).click(); + await expect( + page.getByRole('textbox', { name: 'hostname input' }) + ).toHaveValue(hostname + 'edited'); + await page.getByRole('button', { name: 'Cancel' }).click(); + }); +}); diff --git a/playwright/fixtures/cleanup.ts b/playwright/fixtures/cleanup.ts new file mode 100644 index 00000000..05c860f3 --- /dev/null +++ b/playwright/fixtures/cleanup.ts @@ -0,0 +1,45 @@ +import { test as oldTest } from '@playwright/test'; + +type WithCleanup = { + cleanup: Cleanup; +}; + +export interface Cleanup { + add: (cleanupFn: () => Promise) => symbol; + runAndAdd: (cleanupFn: () => Promise) => Promise; + remove: (key: symbol) => void; +} + +export const test = oldTest.extend({ + // eslint-disable-next-line no-empty-pattern + cleanup: async ({}, use) => { + const cleanupFns: Map Promise> = new Map(); + + // eslint-disable-next-line react-hooks/rules-of-hooks + await use({ + add: (cleanupFn) => { + const key = Symbol(); + cleanupFns.set(key, cleanupFn); + return key; + }, + runAndAdd: async (cleanupFn) => { + await cleanupFn(); + + const key = Symbol(); + cleanupFns.set(key, cleanupFn); + return key; + }, + remove: (key) => { + cleanupFns.delete(key); + }, + }); + + await test.step( + 'Post-test cleanup', + async () => { + await Promise.all(Array.from(cleanupFns).map(([, fn]) => fn())); + }, + { box: true } + ); + }, +}); diff --git a/playwright/helpers/helpers.ts b/playwright/helpers/helpers.ts new file mode 100644 index 00000000..3cd26521 --- /dev/null +++ b/playwright/helpers/helpers.ts @@ -0,0 +1,35 @@ +import { type Page, expect } from '@playwright/test'; + +export const togglePreview = async (page: Page) => { + const toggleSwitch = page.locator('#preview-toggle'); + + if (!(await toggleSwitch.isChecked())) { + await toggleSwitch.click(); + } + + const turnOnButton = page.getByRole('button', { name: 'Turn on' }); + if (await turnOnButton.isVisible()) { + await turnOnButton.click(); + } + + await expect(toggleSwitch).toBeChecked(); +}; + +export const isHosted = (): boolean => { + return process.env.BASE_URL?.includes('redhat.com') || false; +}; + +export const closePopupsIfExist = async (page: Page) => { + const locatorsToCheck = [ + page.locator('.pf-v5-c-alert.notification-item button'), // This closes all toast pop-ups + page.locator(`button[id^="pendo-close-guide-"]`), // This closes the pendo guide pop-up + page.locator(`button[id="truste-consent-button"]`), // This closes the trusted consent pop-up + page.getByLabel('close-notification'), // This closes a one off info notification (May be covered by the toast above, needs recheck.) + ]; + + for (const locator of locatorsToCheck) { + await page.addLocatorHandler(locator, async () => { + await locator.first().click(); // There can be multiple toast pop-ups + }); + } +}; diff --git a/playwright/lib/lib.ts b/playwright/helpers/login.ts similarity index 54% rename from playwright/lib/lib.ts rename to playwright/helpers/login.ts index c3072d16..7b460862 100644 --- a/playwright/lib/lib.ts +++ b/playwright/helpers/login.ts @@ -1,29 +1,11 @@ -import { type Page, type FrameLocator, expect } from '@playwright/test'; +import { type Page, expect } from '@playwright/test'; -export const ibFrame = (page: Page): FrameLocator | Page => { - if (isHosted()) { - return page; - } - return page - .locator('iframe[name="cockpit1\\:localhost\\/cockpit-image-builder"]') - .contentFrame(); -}; - -export const togglePreview = async (page: Page) => { - const toggleSwitch = page.locator('#preview-toggle'); - - if (!(await toggleSwitch.isChecked())) { - await toggleSwitch.click(); - } - - const turnOnButton = page.getByRole('button', { name: 'Turn on' }); - if (await turnOnButton.isVisible()) { - await turnOnButton.click(); - } - - await expect(toggleSwitch).toBeChecked(); -}; +import { closePopupsIfExist, isHosted, togglePreview } from './helpers'; +/** + * Logs in to either Cockpit or Console, will distinguish between them based on the environment + * @param page - the page object + */ export const login = async (page: Page) => { if (!process.env.PLAYWRIGHT_USER || !process.env.PLAYWRIGHT_PASSWORD) { throw new Error('user or password not set in environment'); @@ -38,10 +20,6 @@ export const login = async (page: Page) => { return loginCockpit(page, user, password); }; -export const isHosted = (): boolean => { - return process.env.BASE_URL?.includes('redhat.com') || false; -}; - const loginCockpit = async (page: Page, user: string, password: string) => { await page.goto('/cockpit-image-builder'); @@ -68,10 +46,13 @@ const loginCockpit = async (page: Page, user: string, password: string) => { } // expect to have administrative access - await page.getByRole('button', { name: 'Administrative access' }); + await expect( + page.getByRole('button', { name: 'Administrative access' }) + ).toBeVisible(); }; const loginConsole = async (page: Page, user: string, password: string) => { + await closePopupsIfExist(page); await page.goto('/insights/image-builder/landing'); await page .getByRole('textbox', { name: 'Red Hat login or email' }) @@ -79,22 +60,6 @@ const loginConsole = async (page: Page, user: string, password: string) => { await page.getByRole('button', { name: 'Next' }).click(); await page.getByRole('textbox', { name: 'Password' }).fill(password); await page.getByRole('button', { name: 'Log in' }).click(); - await closePopupsIfExist(page); await togglePreview(page); - await page.getByRole('heading', { name: 'All images' }); -}; - -const closePopupsIfExist = async (page: Page) => { - const locatorsToCheck = [ - page.locator('.pf-v5-c-alert.notification-item button'), // This closes all toast pop-ups - page.locator(`button[id^="pendo-close-guide-"]`), // This closes the pendo guide pop-up - page.locator(`button[id="truste-consent-button"]`), // This closes the trusted consent pop-up - page.getByLabel('close-notification'), // This closes a one off info notification (May be covered by the toast above, needs recheck.) - ]; - - for (const locator of locatorsToCheck) { - await page.addLocatorHandler(locator, async () => { - await locator.first().click(); // There can be multiple toast pop-ups - }); - } + await expect(page.getByRole('heading', { name: 'All images' })).toBeVisible(); }; diff --git a/playwright/helpers/navHelpers.ts b/playwright/helpers/navHelpers.ts new file mode 100644 index 00000000..2ef83ce4 --- /dev/null +++ b/playwright/helpers/navHelpers.ts @@ -0,0 +1,26 @@ +import type { FrameLocator, Page } from '@playwright/test'; + +import { isHosted } from './helpers'; + +/** + * Opens the wizard, fills out the "Image Output" step, and navigates to the optional steps + * @param page - the page object + */ +export const navigateToOptionalSteps = async (page: Page | FrameLocator) => { + await page.getByRole('button', { name: 'Create blueprint' }).click(); + await page.getByRole('checkbox', { name: 'Virtualization' }).click(); + await page.getByRole('button', { name: 'Next' }).click(); +}; + +/** + * Returns the FrameLocator object in case we are using cockpit plugin, else it returns the page object + * @param page - the page object + */ +export const ibFrame = (page: Page): FrameLocator | Page => { + if (isHosted()) { + return page; + } + return page + .locator('iframe[name="cockpit1\\:localhost\\/cockpit-image-builder"]') + .contentFrame(); +}; diff --git a/playwright/helpers/wizardHelpers.ts b/playwright/helpers/wizardHelpers.ts new file mode 100644 index 00000000..0dcbe5b3 --- /dev/null +++ b/playwright/helpers/wizardHelpers.ts @@ -0,0 +1,129 @@ +import { expect, FrameLocator, type Page, test } from '@playwright/test'; + +import { isHosted } from './helpers'; +import { ibFrame } from './navHelpers'; + +/** + * Clicks the create button, handles the modal, clicks the button again and selecets the BP in the list + * @param page - the page object + * @param blueprintName - the name of the created blueprint + */ +export const createBlueprint = async ( + page: Page | FrameLocator, + blueprintName: string +) => { + await page.getByRole('button', { name: 'Create blueprint' }).click(); + await page.getByRole('button', { name: 'Close' }).first().click(); + await page.getByRole('button', { name: 'Create blueprint' }).click(); + await page.getByRole('textbox', { name: 'Search input' }).fill(blueprintName); + await page.getByTestId('blueprint-card').getByText(blueprintName).click(); +}; + +/** + * Fill in the "Details" step in the wizard + * This method assumes that the "Details" step is ENABLED! + * After filling the step, it will click the "Next" button + * Description defaults to "Testing blueprint" + * @param page - the page object + * @param blueprintName - the name of the blueprint to create + */ +export const fillInDetails = async ( + page: Page | FrameLocator, + blueprintName: string +) => { + await page.getByRole('listitem').filter({ hasText: 'Details' }).click(); + await page + .getByRole('textbox', { name: 'Blueprint name' }) + .fill(blueprintName); + await page + .getByRole('textbox', { name: 'Blueprint description' }) + .fill('Testing blueprint'); + await page.getByRole('button', { name: 'Next' }).click(); +}; + +/** + * Select "Register later" option in the wizard + * This function executes only on the hosted service + * @param page - the page object + */ +export const registerLater = async (page: Page | FrameLocator) => { + if (isHosted()) { + await page.getByRole('button', { name: 'Register' }).click(); + await page.getByRole('radio', { name: 'Register later' }).click(); + } +}; + +/** + * Fill in the image output step in the wizard by selecting the Guest Image + * @param page - the page object + */ +export const fillInImageOutputGuest = async (page: Page | FrameLocator) => { + await page.getByRole('checkbox', { name: 'Virtualization' }).click(); + await page.getByRole('button', { name: 'Next' }).click(); +}; + +/** + * Delete the blueprint with the given name + * Will locate to the Image Builder page and search for the blueprint first + * @param page - the page object + * @param blueprintName - the name of the blueprint to delete + */ +export const deleteBlueprint = async (page: Page, blueprintName: string) => { + await test.step( + 'Delete the blueprint with name: ' + blueprintName, + async () => { + // Locate back to the Image Builder page every time because the test can fail at any stage + const frame = await ibFrame(page); + await frame + .getByRole('textbox', { name: 'Search input' }) + .fill(blueprintName); + await frame + .getByTestId('blueprint-card') + .getByText(blueprintName) + .click(); + await frame.getByRole('button', { name: 'Menu toggle' }).click(); + await frame.getByRole('menuitem', { name: 'Delete blueprint' }).click(); + await frame.getByRole('button', { name: 'Delete' }).click(); + }, + { box: true } + ); +}; + +/** + * Export the blueprint + * This function executes only on the hosted service + * @param page - the page object + */ +export const exportBlueprint = async (page: Page, blueprintName: string) => { + if (isHosted()) { + await page.getByRole('button', { name: 'Menu toggle' }).click(); + const downloadPromise = page.waitForEvent('download'); + await page + .getByRole('menuitem', { name: 'Download blueprint (.json)' }) + .click(); + const download = await downloadPromise; + await download.saveAs('../../downloads/' + blueprintName + '.json'); + } +}; + +/** + * Import the blueprint + * This function executes only on the hosted service + * @param page - the page object + */ +export const importBlueprint = async ( + page: Page | FrameLocator, + blueprintName: string +) => { + if (isHosted()) { + await page.getByRole('button', { name: 'Import' }).click(); + const dragBoxSelector = page.getByRole('presentation').first(); + await dragBoxSelector + .locator('input[type=file]') + .setInputFiles('../../downloads/' + blueprintName + '.json'); + await expect( + page.getByRole('textbox', { name: 'File upload' }) + ).not.toBeEmpty(); + await page.getByRole('button', { name: 'Review and Finish' }).click(); + } +}; diff --git a/playwright/test.spec.ts b/playwright/test.spec.ts index 1f9eb426..a6dd455a 100644 --- a/playwright/test.spec.ts +++ b/playwright/test.spec.ts @@ -1,7 +1,9 @@ import { expect, test } from '@playwright/test'; import { v4 as uuidv4 } from 'uuid'; -import { login, ibFrame, isHosted } from './lib/lib'; +import { isHosted } from './helpers/helpers'; +import { login } from './helpers/login'; +import { ibFrame } from './helpers/navHelpers'; test.describe.serial('test', () => { const blueprintName = uuidv4(); @@ -76,7 +78,9 @@ test.describe.serial('test', () => { await frame.getByTestId('close-button-saveandbuild-modal').click(); await frame.getByRole('button', { name: 'Create blueprint' }).click(); - await frame.getByText(blueprintName); + await expect( + frame.locator('.pf-v5-c-card__title-text').getByText(blueprintName) + ).toBeVisible(); }); test('edit blueprint', async ({ page }) => { diff --git a/src/Components/Blueprints/BlueprintCard.tsx b/src/Components/Blueprints/BlueprintCard.tsx index 1c33cab1..5e0bfac6 100644 --- a/src/Components/Blueprints/BlueprintCard.tsx +++ b/src/Components/Blueprints/BlueprintCard.tsx @@ -37,6 +37,7 @@ const BlueprintCard = ({ blueprint }: blueprintProps) => { dispatch(setBlueprintId(blueprint.id))}