Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Unit Tests

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for doing this Adam. Similar to Argo CD, it uses jest as well.

I think we should keep what you have.

https://github.com/argoproj/argo-cd/blob/cc3165f4189243d7b9a627b07b60138ed80ca795/.github/workflows/ci-build.yaml#L353

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Pin all GitHub Actions to immutable commit SHAs.

Line 13, Line 15, and Line 24 use mutable tags (@v4, @v5). That leaves CI behavior vulnerable to upstream tag movement or supply-chain compromise. Pin each uses: reference to a full commit SHA.

Suggested hardening patch
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@<full-commit-sha>

-      - uses: actions/setup-node@v4
+      - uses: actions/setup-node@<full-commit-sha>

-      - uses: codecov/codecov-action@v5
+      - uses: codecov/codecov-action@<full-commit-sha>

Also applies to: 15-15, 24-24

🧰 Tools
🪛 zizmor (1.25.2)

[warning] 13-13: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[error] 13-13: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/unit-tests.yml at line 13, Replace mutable action tags
with pinned commit SHAs for every `uses:` entry that currently references a
floating tag (e.g., `actions/checkout@v4`, `actions/setup-node@v5`, and the
other `uses:` entry at line 24) by looking up the corresponding official action
repository and substituting the full immutable commit SHA (e.g.,
`actions/checkout@<full-sha>`). Update each `uses:` reference in the workflow to
the exact commit SHA while preserving the existing inputs and behavior so CI
execution is stable and supply-chain hardened.

Source: Linters/SAST tools


⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Disable checkout credential persistence before running dependency-controlled scripts.

On Line 13, actions/checkout defaults to persisting the token in local git config. Since Line 20/22 execute package-managed scripts (yarn install, yarn test:coverage), this unnecessarily enlarges token exfiltration risk. Set persist-credentials: false and scope job permissions minimally.

Suggested hardening patch
+permissions:
+  contents: read
+
 jobs:
   test:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@<full-commit-sha>
+        with:
+          persist-credentials: false
🧰 Tools
🪛 zizmor (1.25.2)

[warning] 13-13: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[error] 13-13: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/unit-tests.yml at line 13, Update the actions/checkout
step (actions/checkout@v4) to disable credential persistence by adding
persist-credentials: false and restrict the workflow job permissions to the
minimal scopes needed for npm/yarn (e.g., remove write or repo-level tokens and
grant only contents: read and packages: read if necessary) so that running
dependency-managed scripts like yarn install and yarn test:coverage cannot
persist or exfiltrate the checkout token.

Source: Linters/SAST tools


- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'yarn'

- run: yarn install --frozen-lockfile

- run: yarn test:coverage

- uses: codecov/codecov-action@v5
with:
files: coverage/coverage-final.json
flags: unit-tests
fail_ci_if_error: false
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
node_modules
.DS_Store
dist
coverage
yarn-error.log

# VisualStudioCode ###
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,20 @@ spec:
source ./contrib/oc-environment.sh
./bin/bridge -plugins gitops-plugin=http://localhost:9001/
```

## Testing

Unit tests use [Jest](https://jestjs.io/) with inline snapshots (`toMatchInlineSnapshot()`), so expected values live directly in the test source files.

```bash
# Run all tests
yarn test

# Run tests and update inline snapshots
yarn test:update

# Run tests with coverage report
yarn test:coverage
```

When a component's rendered output or a utility's return value changes, the inline snapshots will fail. Run `yarn test:update` to accept the new output — the diff will show exactly what changed.
115 changes: 115 additions & 0 deletions __mocks__/dynamic-plugin-sdk.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// @ts-ignore — React is used by JSX but react-jsx transform flags it as unused
import * as React from 'react';

export type K8sResourceCommon = {
apiVersion?: string;
kind?: string;
metadata?: {
name?: string;
namespace?: string;
labels?: Record<string, string>;
annotations?: Record<string, string>;
deletionTimestamp?: string;
[key: string]: unknown;
};
[key: string]: unknown;
};

export const k8sListItems = jest.fn();

export const K8sResourceConditionStatus = {
True: 'True',
False: 'False',
Unknown: 'Unknown',
};

export const getGroupVersionKindForModel = jest.fn(
(model: any) => `${model.apiGroup || 'core'}~${model.apiVersion}~${model.kind}`,
);

export const useAccessReview = jest.fn(() => [true, true]);

export type ColoredIconProps = {
className?: string;
title?: string;
};

export const GreenCheckCircleIcon = ({ className, title }: any) => (
<svg data-icon="GreenCheckCircleIcon" className={className} aria-label={title} />
);
export const BlueInfoCircleIcon = ({ className, title }: any) => (
<svg data-icon="BlueInfoCircleIcon" className={className} aria-label={title} />
);
export const RedExclamationCircleIcon = ({ className, title }: any) => (
<svg data-icon="RedExclamationCircleIcon" className={className} aria-label={title} />
);
export const YellowExclamationTriangleIcon = ({ className, title }: any) => (
<svg data-icon="YellowExclamationTriangleIcon" className={className} aria-label={title} />
);

export const CamelCaseWrap = ({ value }: { value: string }) => value || '';
export const Timestamp = ({ timestamp }: { timestamp: string }) => timestamp || '';
export const ResourceLink = ({ kind, name, namespace, groupVersionKind }: any) =>
`[${groupVersionKind ? `${groupVersionKind.kind}` : kind}] ${name}`;

export const k8sUpdate = jest.fn((opts: any) => Promise.resolve(opts.data));
export const k8sPatch = jest.fn((opts: any) => Promise.resolve(opts.resource));

export enum Operator {
Exists = 'Exists',
DoesNotExist = 'DoesNotExist',
In = 'In',
NotIn = 'NotIn',
Equals = 'Equals',
NotEquals = 'NotEquals',
GreaterThan = 'GreaterThan',
LessThan = 'LessThan',
NotEqual = 'NotEqual',
}

export type K8sModel = any;
export type Selector = any;
export type K8sResourceCondition = any;
export type K8sResourceKind = any;
export type K8sResourceKindReference = string;
export type GroupVersionKind = string;
export type K8sVerb = string;
export type SetFeatureFlag = (flag: string, value: boolean) => void;
export type MatchLabels = Record<string, string>;
export type ObjectMetadata = any;
export type NodeAddress = any;
export type NodeCondition = any;
export type ObjectReference = any;
export type TaintEffect = string;
export type OwnerReference = {
apiVersion: string;
kind: string;
name: string;
uid: string;
};
export type Action = {
id: string;
label: string;
description?: string;
cta?: () => void;
disabled?: boolean;
icon?: any;
accessReview?: any;
};

export enum AllPodStatus {
Running = 'Running',
NotReady = 'Not Ready',
Warning = 'Warning',
Empty = 'Empty',
Failed = 'Failed',
Pending = 'Pending',
Succeeded = 'Succeeded',
Terminating = 'Terminating',
Unknown = 'Unknown',
ScaledTo0 = 'Scaled to 0',
Idle = 'Idle',
AutoScaledTo0 = 'Autoscaled to 0',
ScalingUp = 'Scaling Up',
CrashLoopBackOff = 'CrashLoopBackOff',
}
1 change: 1 addition & 0 deletions __mocks__/json-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type JSONSchema7 = any;
63 changes: 63 additions & 0 deletions __mocks__/patternfly-react-core.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as React from 'react';

export const Button: React.FC<any> = ({ children, variant, isInline, component, ...rest }) => (
<button data-variant={variant} {...rest}>{children}</button>
);

export const Popover: React.FC<any> = ({ headerContent, bodyContent, children }) => (
<div data-testid="popover">
<div data-testid="popover-header">{headerContent}</div>
<div data-testid="popover-body">{bodyContent}</div>
{children}
</div>
);

export const MenuToggle = React.forwardRef<any, any>(({ children, variant, ...rest }, ref) => (
<button ref={ref} data-variant={variant} {...rest}>{children}</button>
));
MenuToggle.displayName = 'MenuToggle';

export type MenuToggleElement = HTMLButtonElement;
export type MenuToggleProps = any;

export const Dropdown: React.FC<any> = ({ children, isOpen, toggle, ...props }) => (
<div data-testid="dropdown" data-open={isOpen} {...props}>
{typeof toggle === 'function' ? toggle(null) : toggle}
{isOpen && children}
</div>
);

export const DropdownList: React.FC<any> = ({ children }) => <ul>{children}</ul>;

export const DropdownItem: React.FC<any> = ({ children, description, isDisabled, ...props }) => (
<li data-disabled={isDisabled} {...props}>{children}{description && <small>{description}</small>}</li>
);

export const Tooltip: React.FC<any> = ({ content, children }) => (
<span data-testid="tooltip" data-tooltip={typeof content === 'string' ? content : undefined}>
{children}
</span>
);

export const TooltipPosition = {
top: 'top',
bottom: 'bottom',
left: 'left',
right: 'right',
};

export const Title: React.FC<any> = ({ children, headingLevel: Tag = 'h2', className }) => (
<Tag className={className}>{children}</Tag>
);

export const Label: React.FC<any> = ({ children, className, color, href }) => (
<span data-testid="label" className={className} data-color={color} data-href={href}>{children}</span>
);

export const LabelGroup: React.FC<any> = ({ children, className, numLabels }) => (
<div data-testid="label-group" className={className} data-num-labels={numLabels}>{children}</div>
);

export const Icon: React.FC<any> = ({ children, size }) => (
<span data-testid="icon" data-size={size}>{children}</span>
);
29 changes: 29 additions & 0 deletions __mocks__/patternfly-react-icons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as React from 'react';

const icon = (name: string): React.FC<any> => {
const Icon: React.FC<any> = ({ className, title, color }) => (
<svg data-icon={name} className={className} style={color ? { color } : undefined} aria-label={title} />
);
Icon.displayName = name;
return Icon;
};

export const ArrowCircleUpIcon = icon('ArrowCircleUpIcon');
export const BanIcon = icon('BanIcon');
export const CheckIcon = icon('CheckIcon');
export const CircleNotchIcon = icon('CircleNotchIcon');
export const ExclamationCircleIcon = icon('ExclamationCircleIcon');
export const GhostIcon = icon('GhostIcon');
export const HeartBrokenIcon = icon('HeartBrokenIcon');
export const HeartIcon = icon('HeartIcon');
export const MonitoringIcon = icon('MonitoringIcon');
export const OutlinedPauseCircleIcon = icon('OutlinedPauseCircleIcon');
export const PausedIcon = icon('PausedIcon');
export const PendingIcon = icon('PendingIcon');
export const ResourcesAlmostFullIcon = icon('ResourcesAlmostFullIcon');
export const ResourcesFullIcon = icon('ResourcesFullIcon');
export const SyncAltIcon = icon('SyncAltIcon');
export const UnknownIcon = icon('UnknownIcon');
export const EllipsisVIcon = icon('EllipsisVIcon');
export const OutlinedQuestionCircleIcon = icon('OutlinedQuestionCircleIcon');
export const TopologyIcon = icon('TopologyIcon');
10 changes: 10 additions & 0 deletions __mocks__/patternfly-react-table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as React from 'react';

export const Table: React.FC<any> = ({ children, ...props }) => <table {...props}>{children}</table>;
export const Thead: React.FC<any> = ({ children }) => <thead>{children}</thead>;
export const Tbody: React.FC<any> = ({ children }) => <tbody>{children}</tbody>;
export const Tr: React.FC<any> = ({ children, ...props }) => <tr {...props}>{children}</tr>;
export const Th: React.FC<any> = ({ children }) => <th>{children}</th>;
export const Td: React.FC<any> = ({ children, dataLabel, ...props }) => (
<td data-label={dataLabel} {...props}>{children}</td>
);
5 changes: 5 additions & 0 deletions __mocks__/patternfly-react-tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const token = { name: 'mock-token', value: '#000000', var: 'var(--mock-token)' };

export default token;
module.exports = token;
module.exports.default = token;
9 changes: 9 additions & 0 deletions __mocks__/patternfly-react-topology.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export enum NodeStatus {
default = 'default',
success = 'success',
warning = 'warning',
danger = 'danger',
info = 'info',
}

export type NodeModel = any;
3 changes: 3 additions & 0 deletions __mocks__/react-i18next.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const t = (key: string) => key;
export const useTranslation = () => ({ t, i18n: { language: 'en' } });
export const getI18n = () => ({ t });
1 change: 1 addition & 0 deletions __mocks__/style-mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = {};
34 changes: 34 additions & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { Config } from '@jest/types';

const config: Config.InitialOptions = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
testRegex: '.*\\.test\\.tsx?$',
moduleNameMapper: {
'^src/(.*)': '<rootDir>/src/$1',
'@gitops/(.*)': '<rootDir>/src/gitops/$1',
'@gitops-models/(.*)': '<rootDir>/src/gitops/models/$1',
'@gitops-services/(.*)': '<rootDir>/src/gitops/services/$1',
'@gitops-shared/(.*)': '<rootDir>/src/gitops/components/shared/$1',
'@openshift-console/dynamic-plugin-sdk/lib/(.*)': '<rootDir>/__mocks__/dynamic-plugin-sdk.tsx',
'@openshift-console/dynamic-plugin-sdk': '<rootDir>/__mocks__/dynamic-plugin-sdk.tsx',
'@openshift-console/dynamic-plugin-sdk-internal/(.*)': '<rootDir>/__mocks__/dynamic-plugin-sdk.tsx',
'@patternfly/react-icons': '<rootDir>/__mocks__/patternfly-react-icons.tsx',
'@patternfly/react-core': '<rootDir>/__mocks__/patternfly-react-core.tsx',
'@patternfly/react-tokens/(.*)': '<rootDir>/__mocks__/patternfly-react-tokens.ts',
'@patternfly/react-table': '<rootDir>/__mocks__/patternfly-react-table.tsx',
'@patternfly/react-topology': '<rootDir>/__mocks__/patternfly-react-topology.ts',
'^lodash-es$': 'lodash',
'^json-schema$': '<rootDir>/__mocks__/json-schema.ts',
'react-i18next': '<rootDir>/__mocks__/react-i18next.ts',
'\\.(css|scss)$': '<rootDir>/__mocks__/style-mock.ts',
},
testPathIgnorePatterns: ['<rootDir>/node_modules/', '<rootDir>/dist/'],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/index.ts',
],
};

export default config;
15 changes: 11 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
"start-console": "./start-console.sh",
"i18n": "i18next \"src/**/*.{js,jsx,ts,tsx}\" [-oc] -c i18next-parser.config.js",
"ts-node": "ts-node -O '{\"module\":\"commonjs\"}'",
"lint": "eslint ./src --fix"
"lint": "eslint ./src --fix",
"test": "jest",
"test:update": "jest -u",
"test:coverage": "jest --coverage"
},
"devDependencies": {
"@dagrejs/dagre": "^1.1.8",
Expand Down Expand Up @@ -72,19 +75,23 @@
"typescript": "5.9.3",
"webpack": "^5.1.0",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.9.3"
"webpack-dev-server": "^4.9.3",
"jest": "^29.7.0",
"ts-jest": "^29.3.4",
"@types/jest": "^29.5.0",
"jest-environment-jsdom": "^29.7.0"
},
"resolutions": {
"glob-parent": "^6.0.0",
"showdown": "^2.1.0",
"express": "4.22.1",
"@types/jest": "21.x",
"@types/jest": "^29.5.0",
"hosted-git-info": "^3.0.8",
"jquery": "4.0.0",
"lodash-es": "^4.17.23",
"minimist": "1.2.8",
"ua-parser-js": "^0.7.24",
"jest": "21.x",
"jest": "^29.7.0",
"postcss": "^8.2.13"
},
"consolePlugin": {
Expand Down
Loading
Loading