From b47e4e9ae0289415ea40ca7fa0edd8198c011b9e Mon Sep 17 00:00:00 2001 From: Hannah Brownson Date: Fri, 30 Oct 2026 13:39:46 -0400 Subject: [PATCH] chore: EPINIO-790 unit test shared utils Add Jest coverage for table-formatters, permissions, namespaces, application, custom-routing and embedded-helpers (116 tests). Move each util into a folder with an index.ts barrel to match trailhand-ui's layout. Importers are unchanged. Swap application.ts off @shell/utils/url to the local utils/url copy. Add @types/jest, @types/node and @types/webpack-env as devDependencies and fix the tsconfig types list so tsc --noEmit runs clean. --- .github/workflows/dashboard-test.yaml | 2 - dashboard/package.json | 3 + dashboard/pkg/epinio/tsconfig.json | 3 +- .../utils/application/application.test.ts | 114 +++++ .../utils/{ => application}/application.ts | 6 +- .../pkg/epinio/utils/application/index.ts | 1 + .../custom-routing/custom-routing.test.ts | 26 ++ .../{ => custom-routing}/custom-routing.ts | 2 +- .../pkg/epinio/utils/custom-routing/index.ts | 1 + .../embedded-helpers/embedded-helpers.test.ts | 25 ++ .../embedded-helpers.ts | 0 .../epinio/utils/embedded-helpers/index.ts | 1 + .../pkg/epinio/utils/namespaces/index.ts | 1 + .../utils/namespaces/namespaces.test.ts | 121 ++++++ .../utils/{ => namespaces}/namespaces.ts | 2 +- .../pkg/epinio/utils/permissions/index.ts | 1 + .../utils/permissions/permissions.test.ts | 82 ++++ .../utils/{ => permissions}/permissions.ts | 2 +- .../table-formatters/table-formatters.test.ts | 388 +++++++++++++++++- dashboard/yarn.lock | 14 +- 20 files changed, 770 insertions(+), 25 deletions(-) create mode 100644 dashboard/pkg/epinio/utils/application/application.test.ts rename dashboard/pkg/epinio/utils/{ => application}/application.ts (96%) create mode 100644 dashboard/pkg/epinio/utils/application/index.ts create mode 100644 dashboard/pkg/epinio/utils/custom-routing/custom-routing.test.ts rename dashboard/pkg/epinio/utils/{ => custom-routing}/custom-routing.ts (91%) create mode 100644 dashboard/pkg/epinio/utils/custom-routing/index.ts create mode 100644 dashboard/pkg/epinio/utils/embedded-helpers/embedded-helpers.test.ts rename dashboard/pkg/epinio/utils/{ => embedded-helpers}/embedded-helpers.ts (100%) create mode 100644 dashboard/pkg/epinio/utils/embedded-helpers/index.ts create mode 100644 dashboard/pkg/epinio/utils/namespaces/index.ts create mode 100644 dashboard/pkg/epinio/utils/namespaces/namespaces.test.ts rename dashboard/pkg/epinio/utils/{ => namespaces}/namespaces.ts (98%) create mode 100644 dashboard/pkg/epinio/utils/permissions/index.ts create mode 100644 dashboard/pkg/epinio/utils/permissions/permissions.test.ts rename dashboard/pkg/epinio/utils/{ => permissions}/permissions.ts (99%) diff --git a/.github/workflows/dashboard-test.yaml b/.github/workflows/dashboard-test.yaml index 43219ece..6d333349 100644 --- a/.github/workflows/dashboard-test.yaml +++ b/.github/workflows/dashboard-test.yaml @@ -6,8 +6,6 @@ on: paths: - dashboard/** pull_request: - branches: - - main paths: - dashboard/** workflow_dispatch: diff --git a/dashboard/package.json b/dashboard/package.json index 45249dcd..5fdc30c2 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -51,7 +51,10 @@ "@rushstack/eslint-patch": "^1.3.2", "@types/file-saver": "^2.0.7", "@types/is-url": "1.2.30", + "@types/jest": "27.5.2", + "@types/node": "~20.10.0", "@types/vue": "^1.0.31", + "@types/webpack-env": "^1.18.5", "@vue/eslint-config-standard": "5.1.2", "@vue/eslint-config-typescript": "^11.0.3", "@vue/test-utils": "2.4.6", diff --git a/dashboard/pkg/epinio/tsconfig.json b/dashboard/pkg/epinio/tsconfig.json index 55a17819..12ef8ef3 100644 --- a/dashboard/pkg/epinio/tsconfig.json +++ b/dashboard/pkg/epinio/tsconfig.json @@ -19,8 +19,7 @@ "../../node_modules/@rancher/shell/types" ], "types": [ - "node", - "webpack-env", + "@types/webpack-env", "@types/node", "@types/jest", "@types/lodash", diff --git a/dashboard/pkg/epinio/utils/application/application.test.ts b/dashboard/pkg/epinio/utils/application/application.test.ts new file mode 100644 index 00000000..4b5a9353 --- /dev/null +++ b/dashboard/pkg/epinio/utils/application/application.test.ts @@ -0,0 +1,114 @@ +import { AppUtils } from './application'; +import { APPLICATION_MANIFEST_SOURCE_TYPE, APPLICATION_SOURCE_TYPE } from '../../types'; + +const { getSourceType, getManifestSourceType, getGitData, sourceFingerprint } = AppUtils; + +describe('getSourceType', () => { + it.each([ + [{ Kind: APPLICATION_MANIFEST_SOURCE_TYPE.PATH }, APPLICATION_SOURCE_TYPE.FOLDER], + [{ Kind: APPLICATION_MANIFEST_SOURCE_TYPE.PATH, archive: true }, APPLICATION_SOURCE_TYPE.ARCHIVE], + [{ Kind: APPLICATION_MANIFEST_SOURCE_TYPE.GIT }, APPLICATION_SOURCE_TYPE.GIT_URL], + [{ Kind: APPLICATION_MANIFEST_SOURCE_TYPE.GIT, git: { provider: 'github' } }, APPLICATION_SOURCE_TYPE.GIT_HUB], + [{ Kind: APPLICATION_MANIFEST_SOURCE_TYPE.CONTAINER }, APPLICATION_SOURCE_TYPE.CONTAINER_URL], + [{ Kind: APPLICATION_MANIFEST_SOURCE_TYPE.NONE }, APPLICATION_SOURCE_TYPE.FOLDER], + [{}, APPLICATION_SOURCE_TYPE.FOLDER], + ])('maps %p to %s', (origin, expected) => { + expect(getSourceType(origin as any)).toBe(expected); + }); +}); + +describe('getManifestSourceType', () => { + it.each([ + [{ git: { provider: 'gitlab' } }, APPLICATION_SOURCE_TYPE.GIT_LAB], + [{ git: {} }, APPLICATION_SOURCE_TYPE.GIT_URL], + [{ archive: true }, APPLICATION_SOURCE_TYPE.ARCHIVE], + [{ container: 'nginx' }, APPLICATION_SOURCE_TYPE.CONTAINER_URL], + [{}, APPLICATION_SOURCE_TYPE.FOLDER], + ])('maps %p to %s', (origin, expected) => { + expect(getManifestSourceType(origin as any)).toBe(expected); + }); +}); + +describe('getGitData', () => { + it('splits org and repo out of the url', () => { + expect(getGitData({ + repository: 'https://github.com/epinio/epinio', + branch: 'main', + revision: 'abc123', + gitconfig: 'gh', + })).toEqual({ + usernameOrOrg: 'epinio', + branch: { name: 'main' }, + commit: 'abc123', + repo: { name: 'epinio' }, + gitconfig: 'gh', + url: 'https://github.com/epinio/epinio', + }); + }); + + it('falls back to url when repository is missing', () => { + expect(getGitData({ url: 'https://gitlab.com/org/repo' }).repo).toEqual({ name: 'repo' }); + }); +}); + +describe('sourceFingerprint', () => { + const git = (url: string, branch: any, extra = {}) => ({ + type: APPLICATION_SOURCE_TYPE.GIT_HUB, + git: { url, branch, commit: 'abc', ...extra }, + }) as any; + + it('handles a missing source', () => { + expect(sourceFingerprint(null)).toBe(JSON.stringify({ type: '' })); + expect(sourceFingerprint()).toBe(JSON.stringify({ type: '' })); + }); + + it('ignores trailing slashes and .git in git urls', () => { + expect(sourceFingerprint(git('https://github.com/o/r.git', 'main'))) + .toBe(sourceFingerprint(git('https://github.com/o/r/', 'main'))); + }); + + it('treats a branch object and branch name as the same', () => { + expect(sourceFingerprint(git('https://github.com/o/r', { name: 'main', sha: 'x' }))) + .toBe(sourceFingerprint(git('https://github.com/o/r', 'main'))); + }); + + it('ignores fields from other source types', () => { + const base = git('https://github.com/o/r', 'main'); + + expect(sourceFingerprint({ ...base, container: { url: 'nginx' } })) + .toBe(sourceFingerprint(base)); + }); + + it('changes when the commit changes', () => { + expect(sourceFingerprint(git('https://github.com/o/r', 'main', { commit: 'def' }))) + .not.toBe(sourceFingerprint(git('https://github.com/o/r', 'main'))); + }); + + it('compares git url sources by url, branch, and gitconfig', () => { + const src = (url: string, branch: string) => ({ + type: APPLICATION_SOURCE_TYPE.GIT_URL, + gitUrl: { url, branch }, + }) as any; + + expect(sourceFingerprint(src('https://x/o/r.git', 'main'))).toBe(sourceFingerprint(src('https://x/o/r', 'main'))); + expect(sourceFingerprint(src('https://x/o/r', 'main'))).not.toBe(sourceFingerprint(src('https://x/o/r', 'dev'))); + }); + + it('only tracks whether an archive or folder was picked', () => { + const src = (type: string, tarball?: any) => ({ type, archive: { tarball } }) as any; + + expect(sourceFingerprint(src(APPLICATION_SOURCE_TYPE.ARCHIVE, 'a.tgz'))) + .toBe(sourceFingerprint(src(APPLICATION_SOURCE_TYPE.ARCHIVE, 'b.tgz'))); + expect(sourceFingerprint(src(APPLICATION_SOURCE_TYPE.FOLDER, 'a'))) + .not.toBe(sourceFingerprint(src(APPLICATION_SOURCE_TYPE.FOLDER))); + }); + + it('only tracks the image for container sources', () => { + const src = (url: string, builderImage?: string) => ({ + type: APPLICATION_SOURCE_TYPE.CONTAINER_URL, container: { url }, builderImage + }) as any; + + expect(sourceFingerprint(src('nginx', 'a'))).toBe(sourceFingerprint(src('nginx', 'b'))); + expect(sourceFingerprint(src('nginx'))).not.toBe(sourceFingerprint(src('redis'))); + }); +}); diff --git a/dashboard/pkg/epinio/utils/application.ts b/dashboard/pkg/epinio/utils/application/application.ts similarity index 96% rename from dashboard/pkg/epinio/utils/application.ts rename to dashboard/pkg/epinio/utils/application/application.ts index 8fe4d3ef..0ef7ebcd 100644 --- a/dashboard/pkg/epinio/utils/application.ts +++ b/dashboard/pkg/epinio/utils/application/application.ts @@ -1,6 +1,6 @@ -import { APPLICATION_MANIFEST_SOURCE_TYPE, APPLICATION_SOURCE_TYPE, EpinioApplicationResource, EpinioAppSource, EPINIO_APP_GIT_SOURCE } from '../types'; -import { parse as parseUrl } from '@shell/utils/url'; -import { AppOrigin } from '../models/application/ui-types'; +import { APPLICATION_MANIFEST_SOURCE_TYPE, APPLICATION_SOURCE_TYPE, EpinioApplicationResource, EpinioAppSource, EPINIO_APP_GIT_SOURCE } from '../../types'; +import { parse as parseUrl } from '../url'; +import { AppOrigin } from '../../models/application/ui-types'; interface Utils { getSourceType: (origin: AppOrigin) => APPLICATION_SOURCE_TYPE; diff --git a/dashboard/pkg/epinio/utils/application/index.ts b/dashboard/pkg/epinio/utils/application/index.ts new file mode 100644 index 00000000..2b2fe9a8 --- /dev/null +++ b/dashboard/pkg/epinio/utils/application/index.ts @@ -0,0 +1 @@ +export * from './application'; diff --git a/dashboard/pkg/epinio/utils/custom-routing/custom-routing.test.ts b/dashboard/pkg/epinio/utils/custom-routing/custom-routing.test.ts new file mode 100644 index 00000000..b01f8179 --- /dev/null +++ b/dashboard/pkg/epinio/utils/custom-routing/custom-routing.test.ts @@ -0,0 +1,26 @@ +import { rootEpinioRoute, createEpinioRoute } from './custom-routing'; + +describe('rootEpinioRoute', () => { + it('points at the epinio product', () => { + expect(rootEpinioRoute()).toEqual({ name: 'epinio', params: { product: 'epinio' } }); + }); +}); + +describe('createEpinioRoute', () => { + it('prefixes the name and merges params with the product', () => { + expect(createEpinioRoute('c-cluster-applications', { cluster: 'local' })).toEqual({ + name: 'epinio-c-cluster-applications', + params: { product: 'epinio', cluster: 'local' }, + }); + }); + + it('spreads extra fields onto the route', () => { + const route = createEpinioRoute('c-cluster', { cluster: 'local' }, { query: { tab: 'logs' } }); + + expect(route.query).toEqual({ tab: 'logs' }); + }); + + it('lets params override the product', () => { + expect(createEpinioRoute('x', { product: 'other' }).params.product).toBe('other'); + }); +}); diff --git a/dashboard/pkg/epinio/utils/custom-routing.ts b/dashboard/pkg/epinio/utils/custom-routing/custom-routing.ts similarity index 91% rename from dashboard/pkg/epinio/utils/custom-routing.ts rename to dashboard/pkg/epinio/utils/custom-routing/custom-routing.ts index 548595bc..8e004df1 100644 --- a/dashboard/pkg/epinio/utils/custom-routing.ts +++ b/dashboard/pkg/epinio/utils/custom-routing/custom-routing.ts @@ -1,4 +1,4 @@ -import { EPINIO_PRODUCT_NAME } from '../types'; +import { EPINIO_PRODUCT_NAME } from '../../types'; type Dictionary = { [key: string]: T } diff --git a/dashboard/pkg/epinio/utils/custom-routing/index.ts b/dashboard/pkg/epinio/utils/custom-routing/index.ts new file mode 100644 index 00000000..9e310a15 --- /dev/null +++ b/dashboard/pkg/epinio/utils/custom-routing/index.ts @@ -0,0 +1 @@ +export * from './custom-routing'; diff --git a/dashboard/pkg/epinio/utils/embedded-helpers/embedded-helpers.test.ts b/dashboard/pkg/epinio/utils/embedded-helpers/embedded-helpers.test.ts new file mode 100644 index 00000000..c649f9ee --- /dev/null +++ b/dashboard/pkg/epinio/utils/embedded-helpers/embedded-helpers.test.ts @@ -0,0 +1,25 @@ +import { dashboardUrl } from './embedded-helpers'; + +describe('dashboardUrl', () => { + const original = process.env.dev; + + afterEach(() => { + if (original === undefined) { + delete process.env.dev; + } else { + process.env.dev = original; + } + }); + + it('appends /dashboard in production', () => { + delete process.env.dev; + + expect(dashboardUrl()).toBe(`${ window.origin }/dashboard`); + }); + + it('returns the bare origin in dev', () => { + process.env.dev = 'true'; + + expect(dashboardUrl()).toBe(window.origin); + }); +}); diff --git a/dashboard/pkg/epinio/utils/embedded-helpers.ts b/dashboard/pkg/epinio/utils/embedded-helpers/embedded-helpers.ts similarity index 100% rename from dashboard/pkg/epinio/utils/embedded-helpers.ts rename to dashboard/pkg/epinio/utils/embedded-helpers/embedded-helpers.ts diff --git a/dashboard/pkg/epinio/utils/embedded-helpers/index.ts b/dashboard/pkg/epinio/utils/embedded-helpers/index.ts new file mode 100644 index 00000000..d28a4662 --- /dev/null +++ b/dashboard/pkg/epinio/utils/embedded-helpers/index.ts @@ -0,0 +1 @@ +export * from './embedded-helpers'; diff --git a/dashboard/pkg/epinio/utils/namespaces/index.ts b/dashboard/pkg/epinio/utils/namespaces/index.ts new file mode 100644 index 00000000..c814162b --- /dev/null +++ b/dashboard/pkg/epinio/utils/namespaces/index.ts @@ -0,0 +1 @@ +export * from './namespaces'; diff --git a/dashboard/pkg/epinio/utils/namespaces/namespaces.test.ts b/dashboard/pkg/epinio/utils/namespaces/namespaces.test.ts new file mode 100644 index 00000000..2928b5f3 --- /dev/null +++ b/dashboard/pkg/epinio/utils/namespaces/namespaces.test.ts @@ -0,0 +1,121 @@ +import { useNamespaces } from './namespaces'; + +const ns = (name: string) => ({ meta: { name } }); + +const makeStore = (data: any[] = [], active: Record = {}) => ({ + state: { activeNamespaceCache: active }, + dispatch: jest.fn((action: string, payload: any) => { + if (action === 'epinio/request') { + return Promise.resolve({ data }); + } + + // epinio/create: hand the payload back as the "model" + return Promise.resolve(payload); + }), +}); + +describe('useNamespaces', () => { + it('fetches, sorts, and maps namespaces to options', async() => { + const store = makeStore([ns('zeta'), ns('alpha')]); + const { fetchAll, options, firstName } = useNamespaces(store); + + await fetchAll(); + + expect(options.value).toEqual([ + { label: 'alpha', value: 'alpha' }, + { label: 'zeta', value: 'zeta' }, + ]); + expect(firstName.value).toBe('alpha'); + }); + + it('caches fetchAll results', async() => { + const store = makeStore([ns('a')]); + const { fetchAll } = useNamespaces(store); + + await fetchAll(); + await fetchAll(); + + const requests = store.dispatch.mock.calls.filter(([action]) => action === 'epinio/request'); + + expect(requests).toHaveLength(1); + }); + + it('reports fetch errors through onError', async() => { + const store = makeStore(); + const onError = jest.fn(); + + store.dispatch.mockRejectedValueOnce(new Error('boom')); + + const { fetchAll, isLoading } = useNamespaces(store, { onError }); + + await fetchAll(); + + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + expect(isLoading.value).toBe(false); + }); + + it('url-encodes search queries', async() => { + const store = makeStore([ns('a b')]); + const { search, namespaces } = useNamespaces(store); + + await search('a b'); + + expect(store.dispatch.mock.calls[0][1].opt.url).toBe('/api/v1/namespaces?search=a%20b'); + expect(namespaces.value).toHaveLength(1); + }); + + it('clears results when search fails', async() => { + const store = makeStore([ns('a')]); + const { fetchAll, search, namespaces } = useNamespaces(store); + + await fetchAll(); + store.dispatch.mockRejectedValueOnce(new Error('boom')); + await search('x'); + + expect(namespaces.value).toEqual([]); + }); + + it('seeds a single namespace and drops the cache', async() => { + const store = makeStore([ns('a')]); + const { fetchAll, seed, firstName } = useNamespaces(store); + + await fetchAll(); + seed('picked'); + expect(firstName.value).toBe('picked'); + + seed(''); + expect(firstName.value).toBe(''); + + await fetchAll(); + const requests = store.dispatch.mock.calls.filter(([action]) => action === 'epinio/request'); + + expect(requests).toHaveLength(2); + }); + + it('scopes to the active namespace filter when asked', async() => { + const store = makeStore([ns('a'), ns('b')], { b: true }); + const { fetchAll, options } = useNamespaces(store, { scopeToActiveFilter: true }); + + await fetchAll(); + + expect(options.value.map((o) => o.value)).toEqual(['b']); + }); + + it('ignores the active filter by default', async() => { + const store = makeStore([ns('a'), ns('b')], { b: true }); + const { fetchAll, options } = useNamespaces(store); + + await fetchAll(); + + expect(options.value).toHaveLength(2); + }); + + it('shows everything when the active filter is empty', async() => { + const store = makeStore([ns('a'), ns('b')], {}); + const { fetchAll, options } = useNamespaces(store, { scopeToActiveFilter: true }); + + await fetchAll(); + + expect(options.value).toHaveLength(2); + }); +}); diff --git a/dashboard/pkg/epinio/utils/namespaces.ts b/dashboard/pkg/epinio/utils/namespaces/namespaces.ts similarity index 98% rename from dashboard/pkg/epinio/utils/namespaces.ts rename to dashboard/pkg/epinio/utils/namespaces/namespaces.ts index 5dba2f1c..29b4beee 100644 --- a/dashboard/pkg/epinio/utils/namespaces.ts +++ b/dashboard/pkg/epinio/utils/namespaces/namespaces.ts @@ -1,7 +1,7 @@ import { computed, ref } from 'vue'; import sortBy from 'lodash/sortBy'; -import { EPINIO_TYPES } from '../types'; +import { EPINIO_TYPES } from '../../types'; interface UseNamespacesOptions { // Limit the default list to the navbar's namespace filter. Never applied to diff --git a/dashboard/pkg/epinio/utils/permissions/index.ts b/dashboard/pkg/epinio/utils/permissions/index.ts new file mode 100644 index 00000000..c85954d3 --- /dev/null +++ b/dashboard/pkg/epinio/utils/permissions/index.ts @@ -0,0 +1 @@ +export * from './permissions'; diff --git a/dashboard/pkg/epinio/utils/permissions/permissions.test.ts b/dashboard/pkg/epinio/utils/permissions/permissions.test.ts new file mode 100644 index 00000000..f1c36b66 --- /dev/null +++ b/dashboard/pkg/epinio/utils/permissions/permissions.test.ts @@ -0,0 +1,82 @@ +import { buildPermissionsFromRoles } from './permissions'; + +const role = (id: string, extra: Record = {}) => ({ id, name: id, ...extra }); + +describe('buildPermissionsFromRoles', () => { + it('returns only derived flags (all false) for no roles', () => { + const perms = buildPermissionsFromRoles([]); + + expect(Object.values(perms).every((v) => v === false)).toBe(true); + expect(buildPermissionsFromRoles(null as any)).toEqual(perms); + }); + + it('grants everything to a global admin', () => { + const perms = buildPermissionsFromRoles([role('admin')]); + + expect(perms.app_delete).toBe(true); + expect(perms.namespace).toBe(true); + expect(perms.chart_write).toBe(true); + expect(perms.catalog_service_write).toBe(true); + expect(perms.namespace_create).toBe(true); + }); + + it('withholds cluster-scoped actions from a namespaced admin', () => { + const perms = buildPermissionsFromRoles([role('admin', { namespace: 'workspace' })]); + + expect(perms.app_delete).toBe(true); + expect(perms.namespace_write).toBe(true); + expect(perms.chart_write).toBe(false); + expect(perms.builderimage_write).toBe(false); + expect(perms.gitconfig_write).toBe(false); + expect(perms.catalog_service_write).toBe(false); + expect(perms.namespace_create).toBe(false); + }); + + it('still grants cluster-scoped actions when a global role also has them', () => { + const perms = buildPermissionsFromRoles([ + role('admin', { namespace: 'workspace' }), + role('application_manager'), + ]); + + expect(perms.chart_write).toBe(true); + }); + + it('falls back to the hardcoded map when the server sends no actions', () => { + const perms = buildPermissionsFromRoles([role('view_only')]); + + expect(perms.app_read).toBe(true); + expect(perms.app_create).toBeUndefined(); + }); + + it('prefers server-sent actions over the hardcoded map', () => { + const perms = buildPermissionsFromRoles([role('view_only', { actions: ['app_create'] })]); + + expect(perms.app_create).toBe(true); + expect(perms.app_read).toBeUndefined(); + }); + + it('normalizes ConfigMap-style role ids', () => { + const perms = buildPermissionsFromRoles([role('epinio-role-application-manager')]); + + expect(perms.app_delete).toBe(true); + }); + + it('treats the default user role as read-only', () => { + const perms = buildPermissionsFromRoles([role('user')]); + + expect(perms.app_read).toBe(true); + expect(perms.app_create).toBeUndefined(); + expect(perms.app_delete).toBeUndefined(); + }); + + it('grants nothing for an unknown role', () => { + const perms = buildPermissionsFromRoles([role('mystery')]); + + expect(Object.values(perms).some(Boolean)).toBe(false); + }); + + it('derives catalog_service_write from global service_write only', () => { + expect(buildPermissionsFromRoles([role('custom', { actions: ['service_write'] })]).catalog_service_write).toBe(true); + expect(buildPermissionsFromRoles([role('custom', { namespace: 'ws', actions: ['service_write'] })]).catalog_service_write).toBe(false); + }); +}); diff --git a/dashboard/pkg/epinio/utils/permissions.ts b/dashboard/pkg/epinio/utils/permissions/permissions.ts similarity index 99% rename from dashboard/pkg/epinio/utils/permissions.ts rename to dashboard/pkg/epinio/utils/permissions/permissions.ts index 669adad5..a57095ce 100644 --- a/dashboard/pkg/epinio/utils/permissions.ts +++ b/dashboard/pkg/epinio/utils/permissions/permissions.ts @@ -1,4 +1,4 @@ -import type { EpinioRole } from '../types'; +import type { EpinioRole } from '../../types'; // Mapping from Epinio role IDs to the action IDs they imply. // These should stay in sync with the server-side role definitions. diff --git a/dashboard/pkg/epinio/utils/table-formatters/table-formatters.test.ts b/dashboard/pkg/epinio/utils/table-formatters/table-formatters.test.ts index 26d15067..daef6519 100644 --- a/dashboard/pkg/epinio/utils/table-formatters/table-formatters.test.ts +++ b/dashboard/pkg/epinio/utils/table-formatters/table-formatters.test.ts @@ -1,25 +1,389 @@ -import { stateToTagVariant, stateToIcon } from './table-formatters'; +import { + makeEmptyCell, + makeRouterLinks, + makeRouterLinksOrEmpty, + makeNameLinks, + makeTextList, + makeActionMenu, + attachActionMenu, + makeRouterLink, + makeAppRoutesCell, + makeBoundServicesCell, + stateToTagVariant, + stateToIcon, + makeStateTag, + makeCommitShaCell, + makeCommitAuthorCell, + makeProgressStateCell, + overrideTableRowProp, + overrideTableRows, +} from './table-formatters'; -describe('stateToTagVariant', () => { - it('maps running to success', () => { - expect(stateToTagVariant('running')).toBe('success'); +const makeRouter = () => ({ + resolve: jest.fn((loc: any) => ({ href: `/${ loc.name }` })), + push: jest.fn(), +}) as any; + +const item = (name: string) => ({ meta: { name }, detailLocation: { name } }); + +describe('makeEmptyCell', () => { + it('returns a span with a non-breaking space', () => { + const el = makeEmptyCell(); + + expect(el.tagName).toBe('SPAN'); + expect(el.textContent).toBe(' '); + }); +}); + +describe('makeRouterLinks', () => { + it('renders one anchor per item, comma separated', () => { + const el = makeRouterLinks([item('a'), item('b')], makeRouter()); + const links = el.querySelectorAll('a'); + + expect(links).toHaveLength(2); + expect(links[0].getAttribute('href')).toBe('/a'); + expect(el.textContent).toBe('a, b'); + }); + + it('pushes the location on click instead of following the href', () => { + const router = makeRouter(); + const el = makeRouterLinks([item('a')], router); + const event = new MouseEvent('click', { cancelable: true }); + + el.querySelector('a')!.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(router.push).toHaveBeenCalledWith({ name: 'a' }); + }); + + it('falls back to # when the router cannot resolve', () => { + const router = makeRouter(); + + router.resolve.mockImplementation(() => { + throw new Error('no route'); + }); + + const el = makeRouterLinks([item('a')], router); + + expect(el.querySelector('a')!.getAttribute('href')).toBe('#'); + }); +}); + +describe('makeRouterLinksOrEmpty', () => { + it.each([null, undefined, []])('returns an empty cell for %p', (items) => { + expect(makeRouterLinksOrEmpty(items as any, makeRouter()).textContent).toBe(' '); + }); + + it('returns links when there are items', () => { + expect(makeRouterLinksOrEmpty([item('a')], makeRouter()).querySelectorAll('a')).toHaveLength(1); + }); +}); + +describe('makeNameLinks', () => { + it('builds namespaced detail routes when a namespace is given', () => { + const router = makeRouter(); + + makeNameLinks(['svc'], { cluster: 'local', namespace: 'ws', resource: 'services' }, router); + + expect(router.resolve).toHaveBeenCalledWith(expect.objectContaining({ + name: 'epinio-c-cluster-resource-namespace-id', + params: expect.objectContaining({ + cluster: 'local', namespace: 'ws', resource: 'services', id: 'svc' + }), + })); + }); + + it('builds non-namespaced detail routes otherwise', () => { + const router = makeRouter(); + + makeNameLinks(['chart'], { cluster: 'local', resource: 'appcharts' }, router); + + expect(router.resolve.mock.calls[0][0].name).toBe('epinio-c-cluster-resource-id'); + }); + + it('returns an empty cell for no names', () => { + expect(makeNameLinks(null, { cluster: 'local', resource: 'x' }, makeRouter()).textContent).toBe(' '); + }); +}); + +describe('makeTextList', () => { + it('joins items with commas', () => { + expect(makeTextList(['a', 'b', 'c']).textContent).toBe('a, b, c'); + }); + + it('is empty for no items', () => { + expect(makeTextList([]).textContent).toBe(''); + }); +}); + +describe('makeActionMenu', () => { + it('drops showConfiguration and wraps string actions as callables', () => { + const row = { + id: 'menu-1', + edit: jest.fn(), + availableActions: [ + { action: 'showConfiguration' }, + { action: 'edit', label: 'Edit' }, + { divider: true }, + ], + }; + const el = makeActionMenu(row) as any; + + expect(el.tagName.toLowerCase()).toBe('trailhand-action-menu'); + expect(el.resource).toBe(row); + expect(el.actions).toHaveLength(2); + expect(el.actions[1]).toEqual({ divider: true }); + + el.actions[0].action(); + expect(row.edit).toHaveBeenCalled(); + }); + + it('returns the same element for the same row id', () => { + const first = makeActionMenu({ id: 'menu-2', availableActions: [] }); + const second = makeActionMenu({ id: 'menu-2', availableActions: [{ action: 'x' }] }) as any; + + expect(second).toBe(first); + expect(second.actions).toHaveLength(1); + }); + + it('falls back to meta.name for caching', () => { + const first = makeActionMenu({ meta: { name: 'menu-3' } }); + + expect(makeActionMenu({ meta: { name: 'menu-3' } })).toBe(first); + }); + + it('does not cache rows without an id', () => { + expect(makeActionMenu({})).not.toBe(makeActionMenu({})); + }); +}); + +describe('attachActionMenu', () => { + it('passes actions through unchanged', () => { + const actions = [{ label: 'Delete', action: jest.fn() }] as any; + const el = attachActionMenu({ id: 'attach-1', availableActions: actions }) as any; + + expect(el.actions).toBe(actions); + }); +}); + +describe('makeRouterLink', () => { + it('returns a plain span without a location', () => { + const el = makeRouterLink('text', null, makeRouter()); + + expect(el.tagName).toBe('SPAN'); + expect(el.textContent).toBe('text'); + }); + + it('returns an anchor that pushes on click', () => { + const router = makeRouter(); + const el = makeRouterLink('text', { name: 'x' }, router); + + expect(el.getAttribute('href')).toBe('/x'); + el.click(); + expect(router.push).toHaveBeenCalledWith({ name: 'x' }); }); +}); + +describe('makeAppRoutesCell', () => { + const app = (status: string, routes?: string[]) => ({ status, configuration: { routes } }) as any; + + it('returns an empty cell with no routes', () => { + expect(makeAppRoutesCell(app('running', [])).textContent).toBe(' '); + expect(makeAppRoutesCell(app('running')).textContent).toBe(' '); + }); + + it('renders external links when running', () => { + const el = makeAppRoutesCell(app('running', ['a.io', 'b.io'])); + const links = el.querySelectorAll('a'); + + expect(links).toHaveLength(2); + expect(links[0].href).toBe('https://a.io/'); + expect(links[0].target).toBe('_blank'); + expect(el.textContent).toBe('https://a.io, https://b.io'); + }); + + it('renders plain text when not running', () => { + const el = makeAppRoutesCell(app('error', ['a.io'])); - it('maps error to error', () => { - expect(stateToTagVariant('error')).toBe('error'); + expect(el.querySelector('a')).toBeNull(); + expect(el.textContent).toBe('https://a.io'); + }); +}); + +describe('makeBoundServicesCell', () => { + it('prefers row.services as links', () => { + const el = makeBoundServicesCell({ services: [item('db')], configuration: { services: ['other'] } }, makeRouter()); + + expect(el.querySelector('a')!.textContent).toBe('db'); }); - it('falls back to default for unknown states', () => { - expect(stateToTagVariant('something-unknown')).toBe('default'); + it('falls back to configuration.services as text', () => { + const el = makeBoundServicesCell({ services: [], configuration: { services: ['db'] } }, makeRouter()); + + expect(el.querySelector('a')).toBeNull(); + expect(el.textContent).toBe('db'); + }); + + it('returns an empty cell with no services', () => { + expect(makeBoundServicesCell({}, makeRouter()).textContent).toBe(' '); + }); +}); + +describe('stateToTagVariant', () => { + it.each([ + ['running', 'success'], + ['ready', 'success'], + ['deployed', 'success'], + ['available', 'success'], + ['error', 'error'], + ['notready', 'warning'], + ['not-ready', 'warning'], + ['uninstalled', 'warning'], + ['building', 'info'], + ['deploying', 'info'], + ['created', 'info'], + ['staging', 'info'], + ['updating', 'info'], + ['something-unknown', 'default'], + ])('maps %s to %s', (state, variant) => { + expect(stateToTagVariant(state)).toBe(variant); }); }); describe('stateToIcon', () => { - it('maps running to rocket', () => { - expect(stateToIcon('running')).toBe('rocket'); + it.each([ + ['running', 'rocket'], + ['deployed', 'rocket'], + ['notready', 'warning'], + ['not-ready', 'warning'], + ['error', 'error'], + ['fail', 'error'], + ['building', 'tools'], + ['staging', 'info'], + ['deploying', 'info'], + ['created', 'gear'], + ['something-unknown', ''], + ])('maps %s to %s', (state, icon) => { + expect(stateToIcon(state)).toBe(icon); + }); +}); + +describe('makeStateTag', () => { + it('builds the tag from row.status', () => { + const tag = makeStateTag({ status: 'running', stateDisplay: 'Running' }) as any; + + expect(tag.tagName.toLowerCase()).toBe('trailhand-tag'); + expect(tag.label).toBe('Running'); + expect(tag.variant).toBe('success'); + expect(tag.icon).toBe('rocket'); + expect(tag.size).toBe('md'); + }); + + it('falls back to an empty label without stateDisplay', () => { + const tag = makeStateTag({ status: 'error' }) as any; + + expect(tag.variant).toBe('error'); + expect(tag.label).toBe(''); + }); +}); + +describe('makeCommitShaCell', () => { + it('links to the commit', () => { + const el = makeCommitShaCell({ sha: 'abc123', htmlUrl: 'https://git/c/abc123' }); + const a = el.querySelector('a')!; + + expect(a.href).toBe('https://git/c/abc123'); + expect(a.textContent).toBe('abc123'); + expect(el.querySelector('trailhand-icon')).toBeNull(); + }); + + it('adds the deployed icon when the commit matches', () => { + const el = makeCommitShaCell({ sha: 'abc', commitId: 'abc' }, 'abc', 'Deployed'); + const icon = el.querySelector('trailhand-icon') as any; + + expect(icon.name).toBe('codeBranch'); + expect(icon.title).toBe('Deployed'); + expect(el.querySelector('a')!.getAttribute('href')).toBe('#'); + }); +}); + +describe('makeCommitAuthorCell', () => { + it('renders avatar and link when author data exists', () => { + const el = makeCommitAuthorCell({ + author: { avatarUrl: 'https://a/img.png', htmlUrl: 'https://a/u', login: 'hb' } + }, 'Unknown'); + + expect(el.querySelector('img')!.src).toBe('https://a/img.png'); + expect(el.querySelector('a')!.textContent).toBe('hb'); + }); + + it('prefers name over login', () => { + const el = makeCommitAuthorCell({ author: { name: 'Hannah', login: 'hb' } }, 'Unknown'); + + expect(el.querySelector('a')!.textContent).toBe('Hannah'); + }); + + it('shows the unknown label without author data', () => { + const el = makeCommitAuthorCell({}, 'Unknown'); + + expect(el.querySelector('img')).toBeNull(); + expect(el.textContent).toBe('Unknown'); + }); +}); + +describe('makeProgressStateCell', () => { + it('uses a contextual label while running', () => { + const tag = makeProgressStateCell({ action: 'build', state: 'running' }) as any; + + expect(tag.label).toBe('Building'); + expect(tag.variant).toBe('info'); }); - it('falls back to empty string for unknown states', () => { - expect(stateToIcon('something-unknown')).toBe(''); + it('falls back to Running for unknown running actions', () => { + expect((makeProgressStateCell({ action: 'mystery', state: 'running' }) as any).label).toBe('Running'); + }); + + it.each([ + ['success', 'Success', 'success'], + ['fail', 'Fail', 'error'], + ['pending', 'Pending', 'default'], + ] as const)('maps %s to label %s and variant %s', (state, label, variant) => { + const tag = makeProgressStateCell({ action: 'build', state }) as any; + + expect(tag.label).toBe(label); + expect(tag.variant).toBe(variant); + }); +}); + +describe('overrideTableRowProp', () => { + it('overrides a getter-only property', () => { + const row = Object.defineProperty({}, 'name', { get: () => 'old', configurable: true }) as any; + + overrideTableRowProp(row, 'name', 'new'); + + expect(row.name).toBe('new'); + row.name = 'newer'; + expect(row.name).toBe('newer'); + }); +}); + +describe('overrideTableRows', () => { + it('applies static and computed overrides', () => { + const rows = overrideTableRows([{ n: 1 }, { n: 2 }] as any[], [ + { prop: 'label', value: 'x' }, + { prop: 'double', value: (row: any) => row.n * 2 }, + ]); + + // overrides are defined non-enumerable, so check them directly rather than with toEqual + expect(rows.map((r: any) => [r.label, r.double])).toEqual([['x', 2], ['x', 4]]); + }); + + it('skips rows that fail the condition', () => { + const rows = overrideTableRows([{ n: 1 }, { n: 2 }] as any[], [ + { prop: 'big', value: true, conditionFn: (row: any) => row.n > 1 }, + ]); + + expect(rows[0].big).toBeUndefined(); + expect(rows[1].big).toBe(true); }); }); diff --git a/dashboard/yarn.lock b/dashboard/yarn.lock index 528bbe0a..41324dce 100644 --- a/dashboard/yarn.lock +++ b/dashboard/yarn.lock @@ -3562,6 +3562,14 @@ dependencies: "@types/istanbul-lib-report" "*" +"@types/jest@27.5.2": + version "27.5.2" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-27.5.2.tgz#ec49d29d926500ffb9fd22b84262e862049c026c" + integrity sha512-mpT8LJJ4CMeeahobofYWIjFo0xonRS/HfxnVEPMPFSQdGUt1uHCnoPT7Zhb+sjDU2wz0oKV0OLUR0WzrHNgfeA== + dependencies: + jest-matcher-utils "^27.0.0" + pretty-format "^27.0.0" + "@types/json-schema@*", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.7", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": version "7.0.15" resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz" @@ -3727,7 +3735,7 @@ resolved "https://registry.npmjs.org/@types/vue/-/vue-1.0.31.tgz" integrity sha512-unhlPKHFYEc+NA7nBz8yYSg7MOhWFJF2M/tzjb0rgVuK4pQzUBGmBp3IhfuhYDo3wr3dgA5sm9GUHmUJz2Hb9w== -"@types/webpack-env@^1.15.2": +"@types/webpack-env@^1.15.2", "@types/webpack-env@^1.18.5": version "1.18.8" resolved "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.18.8.tgz" integrity sha512-G9eAoJRMLjcvN4I08wB5I7YofOb/kaJNd5uoCMX+LbKXTPCF+ZIHuqTnFaK9Jz1rgs035f9JUPUhNFtqgucy/A== @@ -9676,7 +9684,7 @@ jest-leak-detector@^27.5.1: jest-get-type "^27.5.1" pretty-format "^27.5.1" -jest-matcher-utils@^27.5.1: +jest-matcher-utils@^27.0.0, jest-matcher-utils@^27.5.1: version "27.5.1" resolved "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz" integrity sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw== @@ -11830,7 +11838,7 @@ pretty-error@^4.0.0: lodash "^4.17.20" renderkid "^3.0.0" -pretty-format@^27.5.1: +pretty-format@^27.0.0, pretty-format@^27.5.1: version "27.5.1" resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz" integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==