diff --git a/packages/manager/.changeset/pr-13072-tests-1762800216646.md b/packages/manager/.changeset/pr-13072-tests-1762800216646.md new file mode 100644 index 00000000000..d5d66b9ecfc --- /dev/null +++ b/packages/manager/.changeset/pr-13072-tests-1762800216646.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Fix flakey stackscript tests ([#13072](https://github.com/linode/manager/pull/13072)) diff --git a/packages/manager/cypress.config.ts b/packages/manager/cypress.config.ts index aab31745a48..c122e309fd2 100644 --- a/packages/manager/cypress.config.ts +++ b/packages/manager/cypress.config.ts @@ -10,6 +10,7 @@ import { discardPassedTestRecordings } from './cypress/support/plugins/discard-p import { featureFlagOverrides } from './cypress/support/plugins/feature-flag-override'; import { fetchAccount } from './cypress/support/plugins/fetch-account'; import { fetchLinodeClusters } from './cypress/support/plugins/fetch-linode-clusters'; +import { fetchLinodeImages } from './cypress/support/plugins/fetch-linode-images'; import { fetchLinodeRegions } from './cypress/support/plugins/fetch-linode-regions'; import { generateTestWeights } from './cypress/support/plugins/generate-weights'; import { enableHtmlReport } from './cypress/support/plugins/html-report'; @@ -102,6 +103,7 @@ export default defineConfig({ fetchAccount, fetchLinodeRegions, fetchLinodeClusters, + fetchLinodeImages, resetUserPreferences, regionOverrideCheck, clusterOverrideCheck, diff --git a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts index a0e95b46b21..01a7dbe891e 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts @@ -11,6 +11,7 @@ import { import { ui } from 'support/ui'; import { SimpleBackoffMethod } from 'support/util/backoff'; import { cleanUp } from 'support/util/cleanup'; +import { chooseImage } from 'support/util/images'; import { createTestLinode } from 'support/util/linodes'; import { pollImageStatus, @@ -185,8 +186,9 @@ describe('Create stackscripts', () => { it('creates a StackScript and deploys a Linode with it', () => { const stackscriptLabel = randomLabel(); const stackscriptDesc = randomPhrase(); - const stackscriptImage = 'Alpine 3.19'; - + // use random image. can specify image w/ getImageByLabel, then set images option in chooseImage + const randomImage = chooseImage(); + const stackscriptImage = randomImage.label; const linodeLabel = randomLabel(); const linodeRegion = chooseRegion({ capabilities: ['Vlans'] }); diff --git a/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts index e44b741e5ce..183c0af195e 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts @@ -11,6 +11,8 @@ import { ui } from 'support/ui'; import { depaginate } from 'support/util/paginate'; import { randomLabel, randomPhrase } from 'support/util/random'; +import { isImageDeprecated } from 'src/components/ImageSelect/utilities'; + import type { Image, StackScript } from '@linode/api-v4'; // StackScript fixture paths. @@ -98,7 +100,7 @@ describe('Update stackscripts', () => { getImages({ page }, { is_public: true }) ); return allPublicImages.find( - (image) => image.vendor === 'Alpine' && image.deprecated === false + (image) => image.vendor === 'Alpine' && !isImageDeprecated(image) ); }; diff --git a/packages/manager/cypress/support/plugins/fetch-linode-images.ts b/packages/manager/cypress/support/plugins/fetch-linode-images.ts new file mode 100644 index 00000000000..7edf07677ee --- /dev/null +++ b/packages/manager/cypress/support/plugins/fetch-linode-images.ts @@ -0,0 +1,20 @@ +import { getImages } from '@linode/api-v4'; + +import type { CypressPlugin } from './plugin'; +import type { Image, ResourcePage } from '@linode/api-v4'; + +/** + * Fetches and stores Linode image data in Cypress environment object. + */ +export const fetchLinodeImages: CypressPlugin = async (_, config) => { + const data: ResourcePage = await getImages({ page_size: 500 }); + + const images = data.data; + return { + ...config, + env: { + ...config.env, + cloudManagerImages: images, + }, + }; +}; diff --git a/packages/manager/cypress/support/util/images.ts b/packages/manager/cypress/support/util/images.ts new file mode 100644 index 00000000000..03b066de027 --- /dev/null +++ b/packages/manager/cypress/support/util/images.ts @@ -0,0 +1,138 @@ +import { randomItem } from 'support/util/random'; + +import { isImageDeprecated } from 'src/components/ImageSelect/utilities'; + +import type { Image, ImageCapabilities } from '@linode/api-v4'; +/** + * Images that cannot be selected using `chooseImages()`. + */ +const disallowedImageIds: string[] = []; + +/** + * All Linode images available to the current Cloud Manager user. + * + * Retrieved via Linode APIv4 during Cypress start-up. + */ +export const images: Image[] = Cypress.env('cloudManagerImages') as Image[]; + +/** + * Returns a known Cloud Manager image at random, or returns a user-chosen + * image if one was specified. + * + * @param options - Image selection options. + * + * @returns Object describing a Cloud Manager image to use during tests. + */ +export const chooseImage = (options?: ChooseImageOptions): Image => { + return randomItem(resolveSearchImages(options)); +}; + +/** + * Returns an array of Image objects that meet the given criteria. + * + * @param options - Object describing Image selection criteria. + * + * @throws If no images meet the desired criteria. + * @throws If an override image is specified which does not meet the given criteria. + * + * @returns Array of Image objects that meet criteria specified by `options` param. + */ +const resolveSearchImages = (options?: ChooseImageOptions): Image[] => { + const imageFixtures = options?.images ?? images; + const currentImages = imageFixtures.filter( + (image) => !isImageDeprecated(image) + ); + const requiredCapabilities = options?.capabilities ?? []; + const allDisallowedImageIds = [ + ...disallowedImageIds, + ...(options?.exclude ?? []), + ]; + const capableImages = imagesWithCapabilities( + currentImages, + requiredCapabilities + ).filter((image: Image) => !allDisallowedImageIds.includes(image.id)); + + if (!capableImages.length) { + throw new Error( + `No images are available with the required capabilities: ${requiredCapabilities.join( + ', ' + )}` + ); + } + return capableImages; +}; + +/** + * Returns `true` if the given Image has all of the given capabilities and availability for each capability. + * + * @param image - Image to check capabilities. + * @param capabilities - ImageCapabilities to check. + * + * @returns `true` if `image` has all of the given capabilities. + */ +const imageHasCapabilities = ( + image: Image, + capabilities: ImageCapabilities[] +): boolean => { + return capabilities.every((capability) => + image.capabilities.includes(capability) + ); +}; + +/** + * Returns an array of Image objects that have all of the given capabilities. + * + * @param images - Images from which to search. + * @param capabilities - ImageCapabilities to check. + * + * @returns Array of Image objects containing the required capabilities. + */ +const imagesWithCapabilities = ( + images: Image[], + capabilities: ImageCapabilities[] +): Image[] => { + return images.filter((image: Image) => + imageHasCapabilities(image, capabilities) + ); +}; + +/** + * Returns an object describing a Cloud Manager image with the given label. + * + * If no known image exists with the given human-readable label, an error is + * thrown. + * + * @param label - Label (API or Cloud-specific) of the image to find. + * @param searchImages - Optional array of Images from which to search. + * + * @throws When no image exists in the `images` array with the given label. + */ +export const getImageByLabel = (label: string, searchImages?: Image[]) => { + const image = (searchImages ?? images).find( + (findImage: Image) => findImage.label === label + ); + if (!image) { + throw new Error( + `Unable to find image by label. Unknown image label '${label}'.` + ); + } + return image; +}; + +interface ChooseImageOptions { + /** + * If specified, the image returned will support the defined capabilities + * @example ['cloud-init', 'distributed-sites'] + */ + capabilities?: ImageCapabilities[]; + + /** + * Array of image IDs to exclude from results. + */ + exclude?: string[]; + + /** + * Images from which to choose. If unspecified, Images exposed by the API will be used. + */ + images?: Image[]; +}