tests/playwright: Add Hostname customization test

Adds a Hostname customization test that creates a BP, edits BP, exports and imports it back and verifies the BP content.
This test also servers as a template for other customization tests. Includes a refactor of existing helper functions.
This commit is contained in:
Tom Koscielniak 2025-03-28 10:44:40 +01:00 committed by Klara Simickova
parent 91ceaca760
commit 0bddb80e94
10 changed files with 341 additions and 49 deletions

View file

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

View file

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

View file

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

View file

@ -0,0 +1,45 @@
import { test as oldTest } from '@playwright/test';
type WithCleanup = {
cleanup: Cleanup;
};
export interface Cleanup {
add: (cleanupFn: () => Promise<unknown>) => symbol;
runAndAdd: (cleanupFn: () => Promise<unknown>) => Promise<symbol>;
remove: (key: symbol) => void;
}
export const test = oldTest.extend<WithCleanup>({
// eslint-disable-next-line no-empty-pattern
cleanup: async ({}, use) => {
const cleanupFns: Map<symbol, () => Promise<unknown>> = 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 }
);
},
});

View file

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

View file

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

View file

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

View file

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

View file

@ -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 }) => {

View file

@ -37,6 +37,7 @@ const BlueprintCard = ({ blueprint }: blueprintProps) => {
<Card
isSelected={blueprint.id === selectedBlueprintId}
ouiaId={`blueprint-card-${blueprint.id}`}
data-testid={`blueprint-card`}
isCompact
isClickable
onClick={() => dispatch(setBlueprintId(blueprint.id))}