) => (
- setIsOpen((prev) => !prev)}
- isExpanded={isOpen}
- isDisabled={isDisabled}
- style={toggleStyle}
- aria-describedby={ariaDescribedBy}
- >
- {selectedItem ? getLabel(selectedItem) : placeholder}
-
- );
-
- return (
-
-
-
- );
-};
-
-export default ImageSelect;
diff --git a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx
index 257ae50ef1..f9c99e4a57 100644
--- a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx
+++ b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx
@@ -1,120 +1,24 @@
-import React, { useMemo } from 'react';
+import React from 'react';
+import { KNOWN_IMAGES } from '@/store/api/backend/onprem/constants';
+import { useAppSelector } from '@/store/hooks';
import {
- Button,
- Flex,
- FlexItem,
- FormHelperText,
- HelperText,
- HelperTextItem,
- Spinner,
- Tooltip,
-} from '@patternfly/react-core';
-
-import {
- useGetImageExistsQuery,
- useGetRegistryAuthStatusQuery,
- usePullImageMutation,
-} from '@/store/api/backend';
-import { Distributions } from '@/store/api/backend/hosted';
-import {
- IMAGE_REGISTRY_HOST,
- KNOWN_IMAGES,
-} from '@/store/api/backend/onprem/constants';
-import { useAppDispatch, useAppSelector } from '@/store/hooks';
-import {
- changeDistribution,
- changeImageSource,
- changeImageTypes,
- selectArchitecture,
- selectForceShowErrors,
selectImageSource,
selectImageSourceType,
- selectIsOfficialImage,
- type SupportedImageTypes,
} from '@/store/slices/wizard';
-import ImageSelect from './ImageSelect';
+import ContainerSection from './ContainerSection';
import RegistryAuth from './RegistryAuth';
-type PullButtonProps = {
- onPull: () => void;
- isPulling: boolean;
- isAuthenticated: boolean;
- isDisabled?: boolean;
-};
-
-const PullButton = ({
- onPull,
- isPulling,
- isAuthenticated,
- isDisabled,
-}: PullButtonProps) => {
- const button = (
- : undefined}
- >
- {isPulling ? 'Pulling image...' : 'Pull latest image'}
-
- );
-
- if (isAuthenticated) {
- return button;
- }
-
- return (
-
- {button}
-
- );
-};
-
+// The target environment radios are the only way to choose an official
+// image (the output slice resolves the image from the selected type);
+// this section just shows what that choice maps to and lets the user
+// pull it.
const OfficialImageSource = () => {
- const dispatch = useAppDispatch();
- const arch = useAppSelector(selectArchitecture);
const selectedRef = useAppSelector(selectImageSource);
const imageSourceType = useAppSelector(selectImageSourceType);
- const forceShowErrors = useAppSelector(selectForceShowErrors);
- const hasOfficialSelection = useAppSelector(selectIsOfficialImage);
-
- const { data: authStatus, isLoading: isAuthLoading } =
- useGetRegistryAuthStatusQuery(undefined, {
- refetchOnMountOrArgChange: true,
- });
- const isAuthenticated = authStatus?.status === 'authenticated';
-
- const images = useMemo(
- () => KNOWN_IMAGES.map((known) => ({ ...known, arch })),
- [arch],
- );
-
- // Local images can be removed outside the wizard (e.g. podman rmi),
- // so bypass the cache and re-check whenever this section mounts.
- const { data: imageExists } = useGetImageExistsQuery(
- { reference: selectedRef! },
- { skip: !selectedRef, refetchOnMountOrArgChange: true },
- );
-
- // The mutation state is scoped to the reference it was started with,
- // so switching to another image doesn't show its busy/error state.
- const [pullImage, pullState] = usePullImageMutation();
- const isPulling =
- pullState.isLoading && pullState.originalArgs?.reference === selectedRef;
- const isPullError =
- pullState.isError && pullState.originalArgs?.reference === selectedRef;
-
- const showSelectionError = forceShowErrors && !hasOfficialSelection;
- const showPullValidation = hasOfficialSelection && imageExists === false;
- const errorId = showSelectionError
- ? 'official-image-selection-error'
- : showPullValidation
- ? 'official-image-pull-error'
- : undefined;
+ const selected = KNOWN_IMAGES.find((img) => img.reference === selectedRef);
if (imageSourceType !== 'official') {
return null;
@@ -123,64 +27,11 @@ const OfficialImageSource = () => {
return (
<>
-
-
- {
- const selected = images.find(
- (img) => img.reference === selection,
- );
- if (selected) {
- dispatch(changeImageSource(selected.reference));
- dispatch(changeDistribution(selected.distro as Distributions));
- dispatch(
- changeImageTypes([selected.type as SupportedImageTypes]),
- );
- }
- }}
- getLabel={(item) => item.name}
- placeholder={'Select an official image'}
- />
-
- {hasOfficialSelection && (
-
- pullImage({ reference: selectedRef! })}
- isPulling={isPulling}
- isAuthenticated={isAuthenticated}
- isDisabled={isAuthLoading}
- />
-
- )}
-
- {showSelectionError && (
-
-
-
- Select an official image to proceed.
-
-
-
- )}
- {showPullValidation && (
-
-
-
- {isPullError
- ? 'Failed to pull image. Please try again.'
- : isAuthenticated
- ? 'Bootc container must be pulled before proceeding.'
- : `Bootc container is not in local storage. Log in to ${IMAGE_REGISTRY_HOST} to pull it.`}
-
-
-
- )}
+
>
);
};
diff --git a/src/Components/CreateImageWizard/steps/ImageOutput/components/TargetEnvironment.tsx b/src/Components/CreateImageWizard/steps/ImageOutput/components/TargetEnvironment.tsx
index cef5535840..91bdfa79c4 100644
--- a/src/Components/CreateImageWizard/steps/ImageOutput/components/TargetEnvironment.tsx
+++ b/src/Components/CreateImageWizard/steps/ImageOutput/components/TargetEnvironment.tsx
@@ -14,12 +14,15 @@ import {
import { rhsmApi } from '@/store/api';
import {
+ categorizeEnvironments,
type Distributions,
useGetArchitectureEnvironmentsQuery,
useGetDistributionEnvironmentsQuery,
} from '@/store/api/backend';
+import { KNOWN_IMAGES } from '@/store/api/backend/onprem/constants';
import { useCustomizationRestrictions } from '@/store/api/distributions';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
+import { selectIsOnPremise } from '@/store/slices/env';
import {
changeImageTypes,
changeIsoPayloadReference,
@@ -27,7 +30,6 @@ import {
selectArchitecture,
selectDistribution,
selectForceShowErrors,
- selectImageSourceFilter,
selectImageTypes,
selectIsImageMode,
selectIsOnlyNetworkInstallerSelected,
@@ -42,6 +44,13 @@ import TargetEnvironmentOption from './TargetEnvironmentOption';
const TEXT_WRAP_WIDTH = '54rem';
+// On-prem image mode always offers the same environments: the ones the
+// official images support. Keeping the list independent of the current
+// selection means it doesn't shrink while no image is selected.
+const KNOWN_IMAGE_ENVIRONMENTS = categorizeEnvironments([
+ ...new Set(KNOWN_IMAGES.map((image) => image.type)),
+]);
+
const createLabelWithTooltip = (
prefix: string,
tooltipText: string,
@@ -81,19 +90,24 @@ const TargetEnvironment = () => {
selectedImageTypes: environments,
});
+ const isOnPremise = useAppSelector(selectIsOnPremise);
+ const isOnPremImageMode = isImageMode && isOnPremise;
+
const archResult = useGetArchitectureEnvironmentsQuery(
{ distribution: distribution as Distributions, arch },
{ skip: isImageMode },
);
- const imageSourceFilter = useAppSelector(selectImageSourceFilter);
-
const distroResult = useGetDistributionEnvironmentsQuery(
- { arch, distro: distribution, ...imageSourceFilter },
- { skip: !isImageMode },
+ { arch, distro: distribution },
+ { skip: !isImageMode || isOnPremise },
);
- const { data, isFetching, isError } = isImageMode ? distroResult : archResult;
+ const { data, isFetching, isError } = isOnPremImageMode
+ ? { data: KNOWN_IMAGE_ENVIRONMENTS, isFetching: false, isError: false }
+ : isImageMode
+ ? distroResult
+ : archResult;
const {
publicClouds = [],
diff --git a/src/Components/CreateImageWizard/steps/ImageOutput/tests/ImageSourceSelect.test.tsx b/src/Components/CreateImageWizard/steps/ImageOutput/tests/ImageSourceSelect.test.tsx
index 888151e099..24e52e88ff 100644
--- a/src/Components/CreateImageWizard/steps/ImageOutput/tests/ImageSourceSelect.test.tsx
+++ b/src/Components/CreateImageWizard/steps/ImageOutput/tests/ImageSourceSelect.test.tsx
@@ -50,8 +50,9 @@ const renderHostedImageSourceSelect = () => {
});
};
-// Tests preset the selected image in state rather than driving the
-// dropdown: the dropdown is on its way out as a selection mechanism.
+// The image is implied by the selected target environment; these tests
+// preset the resulting state (the radio interaction itself is covered
+// by the TargetEnvironment and output slice tests).
const renderWithGuestImage = () => {
return renderImageSourceSelect({
output: {
@@ -121,6 +122,29 @@ describe('ImageSourceSelect', () => {
});
describe('Official images', () => {
+ test('shows a hint until a target environment is selected', async () => {
+ renderImageSourceSelect();
+
+ expect(await screen.findByText('Bootc container')).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /select a target environment to see the container image it uses/i,
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: /pull latest image/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ test('shows the container reference for the selected target environment', async () => {
+ renderWithGuestImage();
+
+ expect(await screen.findByDisplayValue(KVM_REF)).toBeInTheDocument();
+ expect(
+ screen.getByText('Red Hat Enterprise Linux (RHEL) 10.3'),
+ ).toBeInTheDocument();
+ });
+
test('pulls the selected container', async () => {
renderWithGuestImage();
const user = createUser();
@@ -216,6 +240,12 @@ describe('ImageSourceSelect', () => {
).not.toBeInTheDocument();
});
+ test('still shows the selected container reference', async () => {
+ renderWithGuestImage();
+
+ expect(await screen.findByDisplayValue(KVM_REF)).toBeInTheDocument();
+ });
+
test('disables the pull button', async () => {
renderWithGuestImage();
const user = createUser();
@@ -279,6 +309,7 @@ describe('ImageSourceSelect', () => {
expect(
screen.getByText(/cockpit image builder 10\.4/i),
).toBeInTheDocument();
+ expect(screen.queryByText('Bootc container')).not.toBeInTheDocument();
});
test('displays the image-builder CLI example', async () => {
diff --git a/src/Components/CreateImageWizard/steps/ImageOutput/tests/TargetEnvironment.test.tsx b/src/Components/CreateImageWizard/steps/ImageOutput/tests/TargetEnvironment.test.tsx
index d1818a8462..8f922680cb 100644
--- a/src/Components/CreateImageWizard/steps/ImageOutput/tests/TargetEnvironment.test.tsx
+++ b/src/Components/CreateImageWizard/steps/ImageOutput/tests/TargetEnvironment.test.tsx
@@ -1,14 +1,22 @@
+import React from 'react';
+
import { screen } from '@testing-library/react';
+import { vi } from 'vitest';
import { RHEL_10 } from '@/constants';
import { Distributions } from '@/store/api/backend';
-import { initialState, selectImageTypes } from '@/store/slices/wizard';
+import {
+ initialState,
+ selectImageSource,
+ selectImageTypes,
+} from '@/store/slices/wizard';
import {
clickWithWait,
composeHandlers,
createArchitecturesHandler,
createUser,
fetchMock,
+ renderWithRedux,
type WizardStateOverrides,
} from '@/test/testUtils';
@@ -24,6 +32,8 @@ import {
setupErrorHandler,
} from './mocks';
+import TargetEnvironment from '../components/TargetEnvironment';
+
fetchMock.enableMocks();
beforeEach(() => {
@@ -333,6 +343,103 @@ describe('TargetEnvironment', () => {
expect(selectImageTypes(store.getState())).toEqual(['aws']);
});
+ // On-prem image mode always offers the official image environments,
+ // independent of whether an image is selected yet.
+ const renderOnPremTargetEnvironment = (
+ outputOverrides: Partial = {},
+ ) => {
+ return renderWithRedux(
+ ,
+ {
+ ...imageModeOverrides,
+ output: {
+ ...initialState.output,
+ distribution: RHEL_10 as Distributions,
+ imageSource: 'registry.redhat.io/rhel10/rhel-bootc-kvm:latest',
+ imageTypes: ['guest-image'],
+ ...outputOverrides,
+ },
+ },
+ {
+ preloadedState: {
+ env: { isOnPremise: true },
+ },
+ },
+ );
+ };
+
+ test('offers every official image type on-prem', async () => {
+ renderOnPremTargetEnvironment();
+
+ expect(
+ await screen.findByRole('radio', {
+ name: /Virtualization.*Guest image/i,
+ }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('radio', { name: /Amazon Web Services/i }),
+ ).toBeInTheDocument();
+ });
+
+ test('offers the same environments when no image is selected', async () => {
+ renderOnPremTargetEnvironment({
+ imageSource: undefined,
+ imageTypes: [],
+ });
+
+ expect(
+ await screen.findByRole('radio', {
+ name: /Virtualization.*Guest image/i,
+ }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('radio', { name: /Amazon Web Services/i }),
+ ).toBeInTheDocument();
+ });
+
+ test('selecting a radio selects the matching official image', async () => {
+ // The image selection in the output slice only ships on-prem
+ vi.stubEnv('IS_ON_PREMISE', 'true');
+
+ const user = createUser();
+ const { store } = renderOnPremTargetEnvironment({
+ imageSource: undefined,
+ imageTypes: [],
+ });
+
+ const guestRadio = await screen.findByRole('radio', {
+ name: /Virtualization.*Guest image/i,
+ });
+ await clickWithWait(user, guestRadio);
+
+ expect(selectImageTypes(store.getState())).toEqual(['guest-image']);
+ expect(selectImageSource(store.getState())).toBe(
+ 'registry.redhat.io/rhel10/rhel-bootc-kvm:latest',
+ );
+
+ vi.unstubAllEnvs();
+ });
+
+ test('selecting a radio switches the official image to the sibling type', async () => {
+ // The image selection in the output slice only ships on-prem
+ vi.stubEnv('IS_ON_PREMISE', 'true');
+
+ const user = createUser();
+ const { store } = renderOnPremTargetEnvironment();
+
+ const awsRadio = await screen.findByRole('radio', {
+ name: /Amazon Web Services/i,
+ });
+ await clickWithWait(user, awsRadio);
+
+ expect(selectImageTypes(store.getState())).toEqual(['aws']);
+ expect(selectImageSource(store.getState())).toBe(
+ 'registry.redhat.io/rhel10/rhel-bootc-aws:latest',
+ );
+
+ vi.unstubAllEnvs();
+ });
+
test('shows loading state while fetching distributions', async () => {
fetchMock.mockResponse(() => new Promise(() => {}));
diff --git a/src/store/api/backend/derived.ts b/src/store/api/backend/derived.ts
index eb7175dac9..5d24024ee1 100644
--- a/src/store/api/backend/derived.ts
+++ b/src/store/api/backend/derived.ts
@@ -14,7 +14,6 @@ import type {
Distributions,
} from './hosted';
import { imageBuilderApi } from './hosted/enhancedImageBuilderApi';
-import { KNOWN_IMAGES } from './onprem/constants';
import { composerApi } from './onprem/enhancedComposerApi';
export type CategorizedEnvironments = {
@@ -92,9 +91,9 @@ const derivedApi = backendApi.injectEndpoints({
getDistributionEnvironments: builder.query<
DistributionEnvironmentsResult,
- { arch: string; distro?: string; imageSource?: string }
+ { arch: string; distro?: string }
>({
- queryFn: async ({ imageSource, ...queryArgs }, api) => {
+ queryFn: async (queryArgs, api) => {
const result = await api.dispatch(
backendApi.endpoints.getDistributions.initiate(
{ kind: 'bootc', ...queryArgs },
@@ -116,18 +115,7 @@ const derivedApi = backendApi.injectEndpoints({
const distributions = result.data.filter(isBootcDistribution);
- // When an exact image reference is provided, narrow the
- // available target types to only those matching that image.
- // This is used on-prem where each container image supports
- // a single output type via its image-builder.image.type label.
- const selectedType = imageSource
- ? (distributions.find((d) => d.reference === imageSource)?.type ??
- KNOWN_IMAGES.find((k) => k.reference === imageSource)?.type)
- : undefined;
-
- const imageTypes = selectedType
- ? [selectedType]
- : [...new Set(distributions.map((d) => d.type))];
+ const imageTypes = [...new Set(distributions.map((d) => d.type))];
return {
data: {
diff --git a/src/store/middleware/listeners.ts b/src/store/middleware/listeners.ts
index 6ab9be0450..02fbd5926b 100644
--- a/src/store/middleware/listeners.ts
+++ b/src/store/middleware/listeners.ts
@@ -6,6 +6,7 @@ import {
changeArchitecture,
changeBlueprintMode,
changeDistribution,
+ changeImageTypes,
} from '../slices/wizard';
// export from slices/wizard/listeners rather than slices/wizard
// this is needed to avoid circular dependencies
@@ -13,6 +14,7 @@ import {
clearUnsupportedRegistration,
filterImageTypes,
registerLater,
+ resolveOfficialImage,
} from '../slices/wizard/listeners';
export const listenerMiddleware = createListenerMiddleware();
@@ -39,3 +41,8 @@ startListening({
actionCreator: changeBlueprintMode,
effect: clearUnsupportedRegistration,
});
+
+startListening({
+ actionCreator: changeImageTypes,
+ effect: resolveOfficialImage,
+});
diff --git a/src/store/slices/wizard/listeners.ts b/src/store/slices/wizard/listeners.ts
index 2f0190752a..4b3631209b 100644
--- a/src/store/slices/wizard/listeners.ts
+++ b/src/store/slices/wizard/listeners.ts
@@ -1,12 +1,17 @@
-import { backendApi } from '@/store/api/backend';
+import { backendApi, type Distributions } from '@/store/api/backend';
+import { KNOWN_IMAGES } from '@/store/api/backend/onprem/constants';
import type { WizardListenerEffect } from '@/store/middleware/types';
import { selectIsImageMode } from './details';
import {
+ changeDistribution,
+ changeImageSource,
changeImageTypes,
isRhel,
selectArchitecture,
selectDistribution,
+ selectImageSource,
+ selectImageSourceType,
selectImageTypes,
} from './output';
import {
@@ -90,3 +95,59 @@ export const clearUnsupportedRegistration: WizardListenerEffect = (
listenerApi.dispatch(changeAapEnabled(false));
}
};
+
+// On-prem, the target environment radios are the only way to choose an
+// official image: each type maps to exactly one official image, so a
+// type change resolves the image source and distribution. Guarded by
+// IS_ON_PREMISE, a build-time constant, so the hosted bundle
+// dead-code-eliminates it and can never have its image source
+// rewritten.
+export const resolveOfficialImage: WizardListenerEffect = (
+ _action,
+ listenerApi,
+) => {
+ if (!process.env.IS_ON_PREMISE) {
+ return;
+ }
+
+ const state = listenerApi.getState();
+ if (
+ !selectIsImageMode(state) ||
+ selectImageSourceType(state) !== 'official'
+ ) {
+ return;
+ }
+
+ const imageTypes = selectImageTypes(state);
+ if (imageTypes.length === 0) {
+ return;
+ }
+ const targetType = imageTypes[0];
+
+ const currentRef = selectImageSource(state);
+ const current = KNOWN_IMAGES.find((k) => k.reference === currentRef);
+ // A set but unknown reference belongs to another flow (e.g. an
+ // imported blueprint); leave it alone.
+ if (currentRef && !current) {
+ return;
+ }
+
+ // Prefer the sibling published under the same name, so a type change
+ // stays within the same release once several are offered.
+ const selected =
+ current?.type === targetType
+ ? current
+ : ((current &&
+ KNOWN_IMAGES.find(
+ (k) => k.name === current.name && k.type === targetType,
+ )) ??
+ KNOWN_IMAGES.find((k) => k.type === targetType));
+ if (!selected) {
+ return;
+ }
+
+ if (selected !== current) {
+ listenerApi.dispatch(changeImageSource(selected.reference));
+ listenerApi.dispatch(changeDistribution(selected.distro as Distributions));
+ }
+};
diff --git a/src/store/slices/wizard/output/selectors.ts b/src/store/slices/wizard/output/selectors.ts
index 8b4650a5d5..95854474bc 100644
--- a/src/store/slices/wizard/output/selectors.ts
+++ b/src/store/slices/wizard/output/selectors.ts
@@ -2,7 +2,6 @@ import { createSelector } from '@reduxjs/toolkit';
import { RootState } from '@/store';
import { isKnownImageRef } from '@/store/api/backend/onprem/constants';
-import { selectIsOnPremise } from '@/store/slices/env';
export const selectImageSource = (state: RootState) => {
return state.wizard.output.imageSource;
@@ -48,13 +47,3 @@ export const selectIsOfficialImage = createSelector(
selectImageSource,
(imageSource) => !!imageSource && isKnownImageRef(imageSource),
);
-
-export const selectImageSourceFilter = createSelector(
- selectIsOnPremise,
- selectImageSource,
- (
- isOnPremise,
- imageSource,
- ): { imageSource: string } | Record =>
- isOnPremise && imageSource ? { imageSource } : {},
-);
diff --git a/src/store/slices/wizard/output/tests/output.test.ts b/src/store/slices/wizard/output/tests/output.test.ts
index 8aa757679e..17dd5c6289 100644
--- a/src/store/slices/wizard/output/tests/output.test.ts
+++ b/src/store/slices/wizard/output/tests/output.test.ts
@@ -15,7 +15,6 @@ import {
selectBootcDistributions,
selectDistribution,
selectImageSource,
- selectImageSourceFilter,
selectImageTypes,
selectIsOnlyNetworkInstallerSelected,
selectIsoPayloadReference,
@@ -24,7 +23,7 @@ import {
type WizardState,
} from '@/store/slices/wizard';
-import { createMockState, mockRootState } from '../../tests/mockWizardState';
+import { createMockState } from '../../tests/mockWizardState';
describe('output reducers', () => {
describe('changeImageSource', () => {
@@ -410,44 +409,4 @@ describe('output selectors', () => {
expect(selectIsOtherEnvironmentSelected(state)).toBe(false);
});
});
-
- describe('selectImageSourceFilter', () => {
- it('should return imageSource when on-premise with imageSource set', () => {
- const state = {
- ...mockRootState,
- env: { isOnPremise: true },
- wizard: {
- ...mockRootState.wizard,
- output: {
- ...initialState.output,
- imageSource: 'registry.redhat.io/rhel10/rhel-bootc:10.0',
- },
- },
- };
-
- expect(selectImageSourceFilter(state)).toEqual({
- imageSource: 'registry.redhat.io/rhel10/rhel-bootc:10.0',
- });
- });
-
- it('should return empty object when on-premise with no imageSource', () => {
- const state = {
- ...mockRootState,
- env: { isOnPremise: true },
- };
-
- expect(selectImageSourceFilter(state)).toEqual({});
- });
-
- it('should return empty object when hosted, even with imageSource set', () => {
- const state = createMockState({
- output: {
- ...initialState.output,
- imageSource: 'registry.redhat.io/rhel10/rhel-bootc:10.0',
- },
- });
-
- expect(selectImageSourceFilter(state)).toEqual({});
- });
- });
});
diff --git a/src/store/slices/wizard/tests/resolveOfficialImage.test.ts b/src/store/slices/wizard/tests/resolveOfficialImage.test.ts
new file mode 100644
index 0000000000..c355d16b6e
--- /dev/null
+++ b/src/store/slices/wizard/tests/resolveOfficialImage.test.ts
@@ -0,0 +1,132 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { changeImageTypes, initialState } from '@/store/slices/wizard';
+import {
+ createListenerApi,
+ createMockState,
+} from '@/store/slices/wizard/tests/mockWizardState';
+
+import { resolveOfficialImage } from '../listeners';
+
+const KVM_REF = 'registry.redhat.io/rhel10/rhel-bootc-kvm:latest';
+const AWS_REF = 'registry.redhat.io/rhel10/rhel-bootc-aws:latest';
+
+const imageModeState = (outputOverrides = {}) =>
+ createMockState({
+ details: {
+ ...initialState.details,
+ blueprint: {
+ ...initialState.details.blueprint,
+ mode: 'image',
+ },
+ },
+ output: {
+ ...initialState.output,
+ imageSourceType: 'official',
+ ...outputOverrides,
+ },
+ });
+
+describe('resolveOfficialImage', () => {
+ beforeEach(() => {
+ vi.stubEnv('IS_ON_PREMISE', 'true');
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('selects the official image and distribution for the chosen type', () => {
+ const listenerApi = createListenerApi(
+ imageModeState({ imageTypes: ['guest-image'] }),
+ );
+
+ resolveOfficialImage(changeImageTypes(['guest-image']), listenerApi);
+
+ expect(listenerApi.dispatch).toHaveBeenCalledWith({
+ type: 'wizard/output/changeImageSource',
+ payload: KVM_REF,
+ });
+ expect(listenerApi.dispatch).toHaveBeenCalledWith({
+ type: 'wizard/output/changeDistribution',
+ payload: 'rhel-10.3',
+ });
+ });
+
+ it('switches a known image to its sibling for the selected type', () => {
+ const listenerApi = createListenerApi(
+ imageModeState({ imageSource: KVM_REF, imageTypes: ['aws'] }),
+ );
+
+ resolveOfficialImage(changeImageTypes(['aws']), listenerApi);
+
+ expect(listenerApi.dispatch).toHaveBeenCalledWith({
+ type: 'wizard/output/changeImageSource',
+ payload: AWS_REF,
+ });
+ });
+
+ it('keeps a known image when its type is selected', () => {
+ const listenerApi = createListenerApi(
+ imageModeState({ imageSource: AWS_REF, imageTypes: ['aws'] }),
+ );
+
+ resolveOfficialImage(changeImageTypes(['aws']), listenerApi);
+
+ expect(listenerApi.dispatch).not.toHaveBeenCalled();
+ });
+
+ it('does not change an unknown image reference', () => {
+ const listenerApi = createListenerApi(
+ imageModeState({
+ imageSource: 'localhost/my-derived-image:latest',
+ imageTypes: ['aws'],
+ }),
+ );
+
+ resolveOfficialImage(changeImageTypes(['aws']), listenerApi);
+
+ expect(listenerApi.dispatch).not.toHaveBeenCalled();
+ });
+
+ it('does not select an image for the local source type', () => {
+ const listenerApi = createListenerApi(
+ imageModeState({
+ imageSourceType: 'local',
+ imageTypes: ['guest-image'],
+ }),
+ );
+
+ resolveOfficialImage(changeImageTypes(['guest-image']), listenerApi);
+
+ expect(listenerApi.dispatch).not.toHaveBeenCalled();
+ });
+
+ it('does not run in package mode', () => {
+ // On-prem package mode also dispatches changeImageTypes (e.g. the
+ // architecture filter); the resolver must not touch the state then.
+ const listenerApi = createListenerApi(
+ createMockState({
+ output: {
+ ...initialState.output,
+ imageTypes: ['aws', 'guest-image'],
+ },
+ }),
+ );
+
+ resolveOfficialImage(changeImageTypes(['aws', 'guest-image']), listenerApi);
+
+ expect(listenerApi.dispatch).not.toHaveBeenCalled();
+ });
+
+ it('does not run in the hosted bundle', () => {
+ vi.stubEnv('IS_ON_PREMISE', '');
+ const listenerApi = createListenerApi(
+ imageModeState({ imageTypes: ['guest-image'] }),
+ );
+
+ resolveOfficialImage(changeImageTypes(['guest-image']), listenerApi);
+
+ expect(listenerApi.dispatch).not.toHaveBeenCalled();
+ });
+});