diff --git a/src/react/SciencePortalForm.js b/src/react/SciencePortalForm.js index e945ff5..ed4f611 100644 --- a/src/react/SciencePortalForm.js +++ b/src/react/SciencePortalForm.js @@ -14,22 +14,27 @@ import {faQuestionCircle} from "@fortawesome/free-solid-svg-icons"; // Utils import {getProjectImagesMap, getProjectNames} from "./utilities/utils"; +import { + DEFAULT_CORES_NUMBER, DEFAULT_IMAGE_NAMES, + DEFAULT_RAM_NUMBER, SKAHA_PROJECT +} from "./utilities/constants"; class SciencePortalForm extends React.Component { constructor(props) { super(props) - this.selectedRAM = "" - this.selectedCores = "" + this.selectedRAM = DEFAULT_RAM_NUMBER + this.selectedCores = DEFAULT_CORES_NUMBER if (typeof props.fData.contextData !== "undefined") { - this.selectedRAM = props.fData.contextData.defaultRAM - this.selectedCores = props.fData.contextData.defaultCores + this.selectedRAM = Math.max(props.fData.contextData.defaultRAM, DEFAULT_RAM_NUMBER) + this.selectedCores = Math.max(props.fData.contextData.defaultCores, DEFAULT_CORES_NUMBER) } this.state = { fData:props.fData, selectedRAM: this.selectedRAM, selectedCores: this.selectedCores, - selectedProject: undefined + selectedProject: undefined, + selectedImageId: undefined } this.handleChange = this.handleChange.bind(this); this.resetForm = this.resetForm.bind(this); @@ -39,7 +44,7 @@ class SciencePortalForm extends React.Component { // Entire session form state data object needs to be put back // into the form on session name input change or the // form can't render - var tmpData = this.state.fData + let tmpData = this.state.fData tmpData.sessionName = event.target.value this.setState({fData: tmpData}); } @@ -60,9 +65,10 @@ class SciencePortalForm extends React.Component { event.stopPropagation(); this.setState({ - selectedCores : this.state.fData.contextData.defaultCores, - selectedRAM : this.state.fData.contextData.defaultRAM, - selectedProject: '' + selectedCores : Math.max(this.props.fData.contextData.defaultCores, DEFAULT_CORES_NUMBER), + selectedRAM : Math.max(this.props.fData.contextData.defaultRAM, DEFAULT_RAM_NUMBER), + selectedProject: '', + selectedImageId: '', }); this.state.fData.resetHandler(); } @@ -138,9 +144,12 @@ class SciencePortalForm extends React.Component { const projectsOfType = getProjectImagesMap(this.state.fData.imageList) const availableProjects = getProjectNames(projectsOfType) || [] - const imagesOfProject = this.state.selectedProject ? projectsOfType[this.state.selectedProject] : [] + const defaultImages = projectsOfType[SKAHA_PROJECT] || [] + const imagesOfProject = this.state.selectedProject ? projectsOfType[this.state.selectedProject] : defaultImages + const defaultImageName = this.state.fData.selectedType ? DEFAULT_IMAGE_NAMES[this.state.fData.selectedType] : undefined + const defaultImageId = defaultImageName ? imagesOfProject.find(mObj => mObj.name === defaultImageName)?.id : imagesOfProject[0]?.id - return ( + return ( <> {Object.keys(this.state.fData).length !== 0 && this.state.fData.imageList && @@ -168,7 +177,7 @@ class SciencePortalForm extends React.Component { project - {this.renderPopover("Image Project","The project for which the image is used.")} + {this.renderPopover("Image Project","The project for which the image is used. Default: Use the Skaha project to access the default CANFAR image list.")} @@ -176,7 +185,7 @@ class SciencePortalForm extends React.Component { name="project" className="sp-form-cursor" onChange={(e) => this.setState({selectedProject: e.target.value || undefined})} - value={this.state.selectedProject} + value={this.state.selectedProject || SKAHA_PROJECT} > {availableProjects?.map(project => ( @@ -195,6 +204,8 @@ class SciencePortalForm extends React.Component { this.setState({selectedImageId: e.target.value || undefined})} + value={this.state.selectedImageId || defaultImageId} > {imagesOfProject?.map(mapObj => ( @@ -204,7 +215,7 @@ class SciencePortalForm extends React.Component { - name + session name {this.renderPopover("Session Name","Name for the session. Alphanumeric and '-' characters only.")} @@ -244,7 +255,7 @@ class SciencePortalForm extends React.Component { # cores - {this.renderPopover("# of Cores", "Number of cores used by the session. Default: 2")} + {this.renderPopover("# of Cores", "Number of cores used by the session.")} @@ -265,7 +276,7 @@ class SciencePortalForm extends React.Component { {/* placeholder column so buttons line up with form entry elements */} - + @@ -282,17 +293,25 @@ class SciencePortalForm extends React.Component { {this.renderPlaceholder()} + + + project + {this.renderPopover("Image Project","The project for which the image is used.")} + + + {this.renderPlaceholder()} + container image - {this.renderPopover("Container Image","Reference to an image to use to start the session container")} + {this.renderPopover("Container Image","Reference to an image to use to start the session container. Default: use skaha project access the default CANFAR image list.")} {this.renderPlaceholder()} - name + session name {this.renderPopover("Session Name","Name for the session. Default name reflects the current number of sessions of the selected type.\n" + "Alphanumeric characters only. 15 character maximum.")} @@ -302,7 +321,7 @@ class SciencePortalForm extends React.Component { memory - {this.renderPopover("Memory","System memory (RAM) to be used for the session. Default: 16G")} + {this.renderPopover("Memory","System memory (RAM) to be used for the session.")} {this.renderPlaceholder()} @@ -310,7 +329,7 @@ class SciencePortalForm extends React.Component { # cores - {this.renderPopover("# of Cores","Number of cores used by the session. Default: 2")} + {this.renderPopover("# of Cores","Number of cores used by the session.")} {this.renderPlaceholder()} diff --git a/src/react/SciencePortalPrivateForm.js b/src/react/SciencePortalPrivateForm.js index 019dffb..6ffac05 100644 --- a/src/react/SciencePortalPrivateForm.js +++ b/src/react/SciencePortalPrivateForm.js @@ -12,16 +12,19 @@ import Popover from 'react-bootstrap/Popover'; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {faQuestionCircle} from "@fortawesome/free-solid-svg-icons"; +// Constants +import {DEFAULT_CORES_NUMBER, DEFAULT_RAM_NUMBER} from "./utilities/constants"; + class SciencePortalPrivateForm extends React.Component { constructor(props) { super(props) - this.selectedRAM = "" - this.selectedCores = "" + this.selectedRAM = DEFAULT_RAM_NUMBER + this.selectedCores = DEFAULT_CORES_NUMBER this.repositoryUsername = props.authenticatedUsername && props.authenticatedUsername !== "Login" ? props.authenticatedUsername : "" if (typeof props.fData.contextData !== "undefined") { - this.selectedRAM = props.fData.contextData.defaultRAM - this.selectedCores = props.fData.contextData.defaultCores + this.selectedRAM = Math.max(props.fData.contextData.defaultRAM, DEFAULT_RAM_NUMBER) + this.selectedCores = Math.max(props.fData.contextData.defaultCores, DEFAULT_CORES_NUMBER) } const repositoryHostArray = props.fData.repositoryHosts @@ -84,8 +87,8 @@ class SciencePortalPrivateForm extends React.Component { const formProps = this.props; this.setState({ - selectedCores : this.state.fData.contextData.defaultCores, - selectedRAM : this.state.fData.contextData.defaultRAM, + selectedCores : Math.max(this.props.fData.contextData.defaultCores, DEFAULT_CORES_NUMBER), + selectedRAM : Math.max(this.props.fData.contextData.defaultRAM, DEFAULT_RAM_NUMBER), repositoryUsername: formProps.authenticatedUsername && formProps.authenticatedUsername !== "Login" ? formProps.authenticatedUsername : "" }); @@ -185,7 +188,7 @@ class SciencePortalPrivateForm extends React.Component {
- Image access details + Image access
@@ -245,7 +248,7 @@ class SciencePortalPrivateForm extends React.Component {
- Execution details + Launch session
@@ -270,7 +273,7 @@ class SciencePortalPrivateForm extends React.Component { - name + session name {this.renderPopover("Session Name", "Name for the session. Alphanumeric and '-' characters only.")} @@ -310,7 +313,7 @@ class SciencePortalPrivateForm extends React.Component { # cores - {this.renderPopover("# of Cores", "Number of cores used by the session. Default: 2")} + {this.renderPopover("# of Cores", "Number of cores used by the session.")} @@ -360,7 +363,7 @@ class SciencePortalPrivateForm extends React.Component { - name + session name {this.renderPopover("Session Name","Name for the session. Default name reflects the current number of sessions of the selected type.\n" + "Alphanumeric characters only. 15 character maximum.")} diff --git a/src/react/utilities/constants.js b/src/react/utilities/constants.js new file mode 100644 index 0000000..28bf13b --- /dev/null +++ b/src/react/utilities/constants.js @@ -0,0 +1,20 @@ +// Defaults +export const DEFAULT_CORES_NUMBER = 2 +export const DEFAULT_RAM_NUMBER = 8 +export const DEFAULT_NOTEBOOK_SKAHA_IMAGE = 'astroml-notebook:latest' +export const DEFAULT_DESKTOP_SKAHA_IMAGE = 'desktop:latest' +export const DEFAULT_CARTA_SKAHA_IMAGE = 'carta:latest' +export const DEFAULT_CONTRIBUTED_SKAHA_IMAGE = 'astroml-vscode:latest' +export const NOTEBOOK_TYPE = 'notebook' +export const CARTA_TYPE = 'carta' +export const CONTRIBUTED_TYPE = 'contributed' +export const DESKTOP_TYPE = 'desktop' +export const SKAHA_PROJECT = 'skaha' + +export const DEFAULT_IMAGE_NAMES = { + [CARTA_TYPE]: DEFAULT_CARTA_SKAHA_IMAGE, + [CONTRIBUTED_TYPE]: DEFAULT_CONTRIBUTED_SKAHA_IMAGE, + [DESKTOP_TYPE]: DEFAULT_DESKTOP_SKAHA_IMAGE, + [NOTEBOOK_TYPE]: DEFAULT_NOTEBOOK_SKAHA_IMAGE, + +} \ No newline at end of file diff --git a/src/react/utilities/utils.js b/src/react/utilities/utils.js index 9e2a9d3..c66bcb2 100644 --- a/src/react/utilities/utils.js +++ b/src/react/utilities/utils.js @@ -41,48 +41,76 @@ const getImageName = (image) => { return parts?.[2]; }; +const isValidImageId = (id) => { + if (!id || typeof id !== 'string') return false; + const parts = id.split('/'); + if (parts.length !== 3) return false; + const [registry, project, imageWithVersion] = parts; + if (!registry || !project || !imageWithVersion) return false; + const [imageName, version] = imageWithVersion.split(':'); + return Boolean(imageName && version); +}; + const getImagesNamesSorted = (images) => { if (!Array.isArray(images)) return []; return images .filter(image => image?.id) - .map(image => ({ - ...image, - imageName: image.id.split('/')?.[2] || '' - })) + .map(image => { + const parts = image.id.split('/'); + const imageWithVersion = parts[2] || ''; + const [imageName, version] = imageWithVersion.split(':'); + + return { + ...image, + name: imageWithVersion, + imageName, + version: version || '' + }; + }) .filter(image => image.imageName) - .sort((a, b) => - a.imageName.localeCompare(b.imageName, undefined, { + .sort((a, b) => { + if (a.imageName === b.imageName) { + // Handle version comparison for same image names + if (a.version === 'latest') return -1; + if (b.version === 'latest') return 1; + return b.version.localeCompare(a.version, undefined, { + numeric: true, + sensitivity: 'base' + }); + } + // Sort image names alphabetically + return a.imageName.localeCompare(b.imageName, undefined, { sensitivity: 'base', - }) - ); + }); + }); }; const getProjectImagesMap = (images) => { if (!Array.isArray(images)) return {}; - return images.reduce((acc, image) => { + // First group images by project + const projectGroups = images.reduce((acc, image) => { if (!image?.id) return acc; const projectName = getImageProject(image); - if (!projectName) return acc; + // Skip invalid project names or malformed IDs + if (!projectName || !isValidImageId(image.id)) return acc; if (!acc[projectName]) { acc[projectName] = []; } - const imageName = getImageName(image) - acc[projectName].push({ - ...image, - name: imageName, - }); - - acc[projectName].sort((a, b) => - a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) - ); - + acc[projectName].push(image); return acc; }, {}); + + // Then sort images within each project + Object.keys(projectGroups).forEach(projectName => { + projectGroups[projectName] = getImagesNamesSorted(projectGroups[projectName]); + }); + + return projectGroups; }; const getProjectNames = (keyedProjects) => { diff --git a/src/react/utilities/utils.test.js b/src/react/utilities/utils.test.js index 2a2aa07..f1d74a6 100644 --- a/src/react/utilities/utils.test.js +++ b/src/react/utilities/utils.test.js @@ -34,36 +34,54 @@ describe('Image List Processing Functions', () => { test('handles various image id formats', () => { const testCases = [ - { id: 'images.canfar.net/project/name', expected: 'project' }, - { id: 'project/name', expected: 'name' }, - { id: 'single', expected: undefined }, - { id: '', expected: undefined } + {id: 'images.canfar.net/project/name', expected: 'project'}, + {id: 'project/name', expected: 'name'}, + {id: 'single', expected: undefined}, + {id: '', expected: undefined} ]; - testCases.forEach(({ id, expected }) => { - expect(getImageProject({ id })).toBe(expected); + testCases.forEach(({id, expected}) => { + expect(getImageProject({id})).toBe(expected); }); }); test('handles invalid inputs', () => { expect(getImageProject(null)).toBeUndefined(); expect(getImageProject({})).toBeUndefined(); - expect(getImageProject({ id: null })).toBeUndefined(); + expect(getImageProject({id: null})).toBeUndefined(); }); }); describe('getImagesNamesSorted', () => { - test('sorts images by name correctly', () => { - const sorted = getImagesNamesSorted(imageResponse); - const firstFew = sorted.slice(0, 3).map(img => img.imageName); - const sortedCopy = [...firstFew].sort((a, b) => + const testImages = [ + { id: 'images.canfar.net/project/astroflow-gpu-notebook:23.11' }, + { id: 'images.canfar.net/project/astroflow-gpu-notebook:latest' }, + { id: 'images.canfar.net/project/astroflow-gpu-notebook:24.02' }, + { id: 'images.canfar.net/project/beta-notebook:1.0.0' }, + { id: 'images.canfar.net/project/alpha-notebook:2.0.0' } + ]; + + test('sorts images alphabetically by name', () => { + const sorted = getImagesNamesSorted(testImages); + const imageNames = sorted.map(img => img.imageName); + const sortedNames = [...imageNames].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }) ); - expect(firstFew).toEqual(sortedCopy); + expect(imageNames).toEqual(sortedNames); + }); + + test('sorts versions in reverse order with latest first', () => { + const sorted = getImagesNamesSorted(testImages); + const gpuNotebookVersions = sorted + .filter(img => img.imageName === 'astroflow-gpu-notebook') + .map(img => img.version); + + expect(gpuNotebookVersions).toEqual(['latest', '24.02', '23.11']); }); test('handles invalid input gracefully', () => { expect(getImagesNamesSorted(null)).toEqual([]); + expect(getImagesNamesSorted(undefined)).toEqual([]); expect(getImagesNamesSorted([])).toEqual([]); expect(getImagesNamesSorted([null, undefined])).toEqual([]); }); @@ -73,63 +91,239 @@ describe('Image List Processing Functions', () => { { id: 'images.canfar.net/skaha/carta:4.0' }, { id: '' }, null, - { notId: 'something' } + undefined, + { notId: 'something' }, + { id: 'invalid/format' } ]; const result = getImagesNamesSorted(mixedData); expect(result.length).toBe(1); - expect(result[0].imageName).toBe('carta:4.0'); + expect(result[0].imageName).toBe('carta'); + expect(result[0].version).toBe('4.0'); + }); + + test('preserves original image object properties', () => { + const imageWithExtra = { + id: 'images.canfar.net/skaha/carta:4.0', + types: ['desktop-app'], + digest: 'sha256:123' + }; + const result = getImagesNamesSorted([imageWithExtra]); + expect(result[0]).toMatchObject({ + ...imageWithExtra, + imageName: 'carta', + version: '4.0' + }); + }); + + test('correctly sorts complex version numbers', () => { + const versionsTest = [ + { id: 'images.canfar.net/project/app:1.10.0' }, + { id: 'images.canfar.net/project/app:1.2.0' }, + { id: 'images.canfar.net/project/app:latest' }, + { id: 'images.canfar.net/project/app:1.1.0' } + ]; + const sorted = getImagesNamesSorted(versionsTest); + const versions = sorted.map(img => img.version); + expect(versions).toEqual(['latest', '1.10.0', '1.2.0', '1.1.0']); + }); + + test('handles images with same name but different versions', () => { + const sameNameImages = [ + { id: 'images.canfar.net/project/notebook:24.03' }, + { id: 'images.canfar.net/project/notebook:latest' }, + { id: 'images.canfar.net/project/notebook:23.11' }, + { id: 'images.canfar.net/project/notebook:24.02' } + ]; + + const result = getImagesNamesSorted(sameNameImages); + const versions = result.map(img => img.version); + expect(versions).toEqual(['latest', '24.03', '24.02', '23.11']); + }); + + test('processes semver-style versions correctly', () => { + const semverImages = [ + { id: 'images.canfar.net/project/tool:2.1.0' }, + { id: 'images.canfar.net/project/tool:2.0.0' }, + { id: 'images.canfar.net/project/tool:2.1.1' }, + { id: 'images.canfar.net/project/tool:latest' } + ]; + + const result = getImagesNamesSorted(semverImages); + const versions = result.map(img => img.version); + expect(versions).toEqual(['latest', '2.1.1', '2.1.0', '2.0.0']); + }); + + test('handles malformed image IDs gracefully', () => { + const malformedData = [ + { id: 'images.canfar.net/project/noversion' }, + { id: 'images.canfar.net/project/:1.0' }, + { id: 'images.canfar.net/project/' }, + { id: 'images.canfar.net///:' } + ]; + + const result = getImagesNamesSorted(malformedData); + expect(result.every(item => item.name && item.version !== undefined)).toBe(true); }); }); }); describe('getProjectImagesMap', () => { - test('correctly groups notebook images by project', () => { - const notebookImages = [ - { id: 'images.canfar.net/skaha/jupyter:1.0', name: 'jupyter:1.0' }, - { id: 'images.canfar.net/skaha/notebook:2.0', name: 'notebook:2.0' }, - { id: 'images.canfar.net/canucs/analysis:1.0', name: 'analysis:1.0' } + test('correctly groups and sorts images by project and version', () => { + const images = [ + { id: 'images.canfar.net/skaha/jupyter:2.0' }, + { id: 'images.canfar.net/skaha/jupyter:latest' }, + { id: 'images.canfar.net/skaha/jupyter:1.0' }, + { id: 'images.canfar.net/skaha/notebook:2.0' }, + { id: 'images.canfar.net/canucs/analysis:1.0' } ]; - const result = getProjectImagesMap(notebookImages); + const result = getProjectImagesMap(images); - expect(Object.keys(result)).toEqual(['skaha', 'canucs']); - expect(result.skaha).toHaveLength(2); + // Check correct grouping by project + expect(Object.keys(result).sort()).toEqual(['canucs', 'skaha']); + expect(result.skaha).toHaveLength(4); expect(result.canucs).toHaveLength(1); - expect(result.skaha[0].name).toBe('jupyter:1.0'); + + // Check version sorting within same image name (latest first, then descending) + const jupyterVersions = result.skaha + .filter(img => img.imageName === 'jupyter') + .map(img => img.version); + expect(jupyterVersions).toEqual([ + 'latest', + '2.0', + '1.0' + ]); }); - test('sorts images within projects', () => { + test('sorts images alphabetically within projects', () => { const images = [ - { id: 'images.canfar.net/skaha/b:1.0', name: 'b:1.0' }, - { id: 'images.canfar.net/skaha/a:1.0', name: 'a:1.0' } + { id: 'images.canfar.net/skaha/zebra:1.0' }, + { id: 'images.canfar.net/skaha/alpha:1.0' }, + { id: 'images.canfar.net/skaha/beta:latest' }, + { id: 'images.canfar.net/skaha/beta:2.0' } ]; const result = getProjectImagesMap(images); - expect(result.skaha[0].name).toBe('a:1.0'); - expect(result.skaha[1].name).toBe('b:1.0'); + + const names = result.skaha.map(img => `${img.imageName}:${img.version}`); + expect(names).toEqual([ + 'alpha:1.0', + 'beta:latest', + 'beta:2.0', + 'zebra:1.0' + ]); }); - test('preserves original image properties', () => { + test('preserves all original image properties', () => { const image = { id: 'images.canfar.net/skaha/test:1.0', - name: 'test:1.0', types: ['notebook'], - digest: 'sha256:123' + digest: 'sha256:123', + customField: 'value' }; const result = getProjectImagesMap([image]); - expect(result.skaha[0]).toEqual({ + expect(result.skaha[0]).toEqual(expect.objectContaining({ ...image, - name: 'test:1.0' - }); + imageName: 'test', + version: '1.0' + })); }); - test('handles invalid inputs', () => { + test('handles invalid inputs and edge cases', () => { expect(getProjectImagesMap(null)).toEqual({}); expect(getProjectImagesMap(undefined)).toEqual({}); expect(getProjectImagesMap([])).toEqual({}); - expect(getProjectImagesMap([{ name: 'test' }])).toEqual({}); + expect(getProjectImagesMap([{ title: 'test' }])).toEqual({}); expect(getProjectImagesMap([{ id: 'invalid' }])).toEqual({}); + + const result = getProjectImagesMap([ + { id: 'images.canfar.net/skaha/test:1.0' }, + null, + undefined, + { id: '' }, + { id: 'invalid/format' }, + { id: 'registry/project/' }, + { id: 'registry//name:version' } + ]); + + expect(result).toEqual({ + skaha: expect.any(Array) + }); + + // Verify the content of skaha array + expect(result.skaha.length).toBe(1); + expect(result.skaha[0]).toHaveProperty('imageName', 'test'); + expect(result.skaha[0]).toHaveProperty('version', '1.0'); + }); + + test('filters out malformed image IDs', () => { + const malformedData = [ + { id: 'images.canfar.net/project/noversion' }, + { id: 'images.canfar.net/project/:1.0' }, + { id: 'images.canfar.net/project/' }, + { id: 'images.canfar.net///:' }, + { id: 'images.canfar.net/valid/image:1.0' } + ]; + + const result = getProjectImagesMap(malformedData); + expect(Object.keys(result)).toEqual(['valid']); + expect(result.valid.length).toBe(1); + expect(result.valid[0]).toHaveProperty('imageName', 'image'); + expect(result.valid[0]).toHaveProperty('version', '1.0'); + }); + + test('correctly handles multiple versions of same image in project', () => { + const images = [ + { id: 'images.canfar.net/project/app:1.0.0' }, + { id: 'images.canfar.net/project/app:2.0.0' }, + { id: 'images.canfar.net/project/app:latest' }, + { id: 'images.canfar.net/project/app:1.5.0' } + ]; + + const result = getProjectImagesMap(images); + const versions = result.project.map(img => img.version); + expect(versions).toEqual(['latest', '2.0.0', '1.5.0', '1.0.0']); + }); + + test('maintains distinct projects with same image names', () => { + const images = [ + { id: 'images.canfar.net/project1/app:1.0' }, + { id: 'images.canfar.net/project2/app:1.0' } + ]; + + const result = getProjectImagesMap(images); + expect(Object.keys(result).sort()).toEqual(['project1', 'project2']); + expect(result.project1).toHaveLength(1); + expect(result.project2).toHaveLength(1); + + // Verify both projects have the correct name/version structure + expect(result.project1[0]).toHaveProperty('imageName', 'app'); + expect(result.project1[0]).toHaveProperty('version', '1.0'); + expect(result.project2[0]).toHaveProperty('imageName', 'app'); + expect(result.project2[0]).toHaveProperty('version', '1.0'); + }); + + test('handles complex project structures', () => { + const images = [ + { id: 'images.canfar.net/project/app-v1:latest' }, + { id: 'images.canfar.net/project/app-v1:2.0.0' }, + { id: 'images.canfar.net/other/app-v1:1.0.0' }, + { id: 'images.canfar.net/project/app-v2:latest' } + ]; + + const result = getProjectImagesMap(images); + + expect(Object.keys(result).sort()).toEqual(['other', 'project']); + expect(result.project).toHaveLength(3); + + // Check sorting within project + const projectNames = result.project.map(img => `${img.imageName}:${img.version}`); + expect(projectNames).toEqual([ + 'app-v1:latest', + 'app-v1:2.0.0', + 'app-v2:latest' + ]); }); });