feat: Add a unit test target (jest) to the UI extension and run it in CI (#1192)

- Introduce test targets.
- Adding additional tests.

Related to: https://github.com/harvester/harvester/issues/11892

Signed-off-by: Volker Theile <vtheile@suse.com>
This commit is contained in:
Volker Theile
2026-10-13 18:00:38 +02:00
committed by GitHub
parent 02862bbeea
commit 71141b257a
18 changed files with 924 additions and 108 deletions
+12
View File
@@ -0,0 +1,12 @@
name: Run Unit Tests
description: Run Unit Tests
runs:
using: 'composite'
steps:
- name: Setup env
uses: ./.github/actions/setup
- name: Run unit tests
shell: bash
run: yarn test:ci
+8
View File
@@ -71,3 +71,11 @@ jobs:
GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
GITHUB_BEFORE: ${{ github.event.before }}
GITHUB_AFTER: ${{ github.event.after }}
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Run unit tests
uses: ./.github/actions/test
+25
View File
@@ -59,6 +59,8 @@ You are an expert Senior Software Engineer specializing in Vue.js and TypeScript
| `yarn serve-pkgs` | Serve the locally built extension for testing |
| `yarn lint` | Run ESLint (zero warnings allowed) |
| `yarn lint:fix` | Run ESLint with auto-fix |
| `yarn test` | Run the unit tests (Jest) in watch mode |
| `yarn test:ci` | Run the unit tests once, as CI does (silent) |
| `yarn clean` | Clean build artifacts |
| `yarn agents:generate` | Regenerate `AGENTS.md` from `docs/agents.md/` sources |
@@ -95,6 +97,16 @@ You are an expert Senior Software Engineer specializing in Vue.js and TypeScript
- ESLint covers `.js`, `.ts`, and `.vue` files.
- Always run `yarn lint:fix` before committing.
### Unit Tests
- **Run**:
```bash
yarn test
```
- `yarn test` starts Jest in watch mode (like in the Rancher dashboard) and only runs the tests related to changed files. It does not exit, so use `yarn test:ci` for a single run of all tests, e.g. in scripts.
- CI runs `yarn test:ci` on every pull request.
- See the Unit Tests section of the Contributors Guide for how the tests are set up.
### Commit Conventions
- Commits are validated by [commitlint](https://commitlint.js.org/) via a Husky `commit-msg` hook.
@@ -187,6 +199,19 @@ To add a feature in a new release, follow these steps:
See https://github.com/harvester/harvester-ui-tests
## Unit Tests (Jest)
Unit and component tests run with [Jest](https://jestjs.io/). The setup follows the Rancher dashboard and the Rancher extension template ([rancher/ui-plugin-examples](https://github.com/rancher/ui-plugin-examples)), so both stay aligned.
- Run the tests in watch mode with `yarn test`, all tests once with `yarn test:ci`, a single file with `yarn test <path>`. CI runs `yarn test:ci` on every pull request.
- Test files live next to the code in `__tests__` folders and are named `<name>.test.js` or `<name>.test.ts`.
- Configuration:
- `jest.config.js`: environment, module aliases (`@shell`, `@components`, `@pkg`) and transforms.
- `jest.setup.js`: global mocks (`t`, `$store`, common directives) and browser APIs that jsdom does not provide.
- `vue3JestRegisterTs.js`: makes TypeScript available to the Vue compiler, otherwise `.vue` files with typed props fail to compile.
- Components are tested with the Vue Test Utils 2 API: use `props` and `global.mocks`, not the Vue 2 options `propsData` and `mocks`.
- Models and utils can be tested without mounting anything, see `pkg/harvester/models/__tests__` for examples.
## Node Dependencies
Dependencies are managed via `package.json` and `yarn`
+12
View File
@@ -15,6 +15,8 @@
| `yarn serve-pkgs` | Serve the locally built extension for testing |
| `yarn lint` | Run ESLint (zero warnings allowed) |
| `yarn lint:fix` | Run ESLint with auto-fix |
| `yarn test` | Run the unit tests (Jest) in watch mode |
| `yarn test:ci` | Run the unit tests once, as CI does (silent) |
| `yarn clean` | Clean build artifacts |
| `yarn agents:generate` | Regenerate `AGENTS.md` from `docs/agents.md/` sources |
@@ -51,6 +53,16 @@
- ESLint covers `.js`, `.ts`, and `.vue` files.
- Always run `yarn lint:fix` before committing.
### Unit Tests
- **Run**:
```bash
yarn test
```
- `yarn test` starts Jest in watch mode (like in the Rancher dashboard) and only runs the tests related to changed files. It does not exit, so use `yarn test:ci` for a single run of all tests, e.g. in scripts.
- CI runs `yarn test:ci` on every pull request.
- See the Unit Tests section of the Contributors Guide for how the tests are set up.
### Commit Conventions
- Commits are validated by [commitlint](https://commitlint.js.org/) via a Husky `commit-msg` hook.
@@ -0,0 +1,12 @@
## Unit Tests (Jest)
Unit and component tests run with [Jest](https://jestjs.io/). The setup follows the Rancher dashboard and the Rancher extension template ([rancher/ui-plugin-examples](https://github.com/rancher/ui-plugin-examples)), so both stay aligned.
- Run the tests in watch mode with `yarn test`, all tests once with `yarn test:ci`, a single file with `yarn test <path>`. CI runs `yarn test:ci` on every pull request.
- Test files live next to the code in `__tests__` folders and are named `<name>.test.js` or `<name>.test.ts`.
- Configuration:
- `jest.config.js`: environment, module aliases (`@shell`, `@components`, `@pkg`) and transforms.
- `jest.setup.js`: global mocks (`t`, `$store`, common directives) and browser APIs that jsdom does not provide.
- `vue3JestRegisterTs.js`: makes TypeScript available to the Vue compiler, otherwise `.vue` files with typed props fail to compile.
- Components are tested with the Vue Test Utils 2 API: use `props` and `global.mocks`, not the Vue 2 options `propsData` and `mocks`.
- Models and utils can be tested without mounting anything, see `pkg/harvester/models/__tests__` for examples.
+45
View File
@@ -0,0 +1,45 @@
// Same settings in every environment, otherwise tests that format dates depend on the machine
process.env.TZ = 'UTC';
// The setup follows the one of the Rancher dashboard and of the Rancher extension template (rancher/ui-plugin-examples)
module.exports = {
roots: ['<rootDir>/pkg'],
testEnvironment: 'jsdom',
// Jest 28+ (jest-environment-jsdom) defaults package resolution to the "browser" export condition, which makes
// packages like @vue/test-utils resolve their browser (global-Vue) build and throw "Vue is not defined".
testEnvironmentOptions: { customExportConditions: ['node', 'node-addons'] },
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
watchman: false,
moduleFileExtensions: ['js', 'mjs', 'json', 'vue', 'ts'],
modulePaths: ['<rootDir>'],
moduleNameMapper: {
'^~/(.*)$': '<rootDir>/$1',
'^@/(.*)$': '<rootDir>/$1',
'^@shell/(.*)$': '<rootDir>/node_modules/@rancher/shell/$1',
'^@components/(.*)$': '<rootDir>/node_modules/@rancher/shell/rancher-components/$1',
// At build time @pkg/<path> is resolved to the package that imports it (see vue.config.js of @rancher/shell)
'^@pkg/harvester/(.*)$': '<rootDir>/pkg/harvester/$1',
'^@pkg/(.*)$': '<rootDir>/pkg/harvester/$1',
// clipboard-polyfill's package `exports` only declares an `import` condition, so map it to its ESM build
// (transpiled via transformIgnorePatterns below)
'^clipboard-polyfill$': '<rootDir>/node_modules/clipboard-polyfill/dist/es6/clipboard-polyfill.es6.js',
'\\.(jpe?g|png|gif|webp|svg|mp4|webm|ogg|mp3|wav|flac|aac|woff2?|eot|ttf|otf)$': '<rootDir>/svgTransform.js',
},
modulePathIgnorePatterns: ['<rootDir>/pkg/harvester/node_modules'],
testPathIgnorePatterns: ['<rootDir>/node_modules/', '<rootDir>(/.*)*/__tests__/utils/'],
transform: {
// vue3-jest, but with TypeScript registered for the Vue compiler first, see vue3JestRegisterTs.js
'^.+\\.vue$': '<rootDir>/vue3JestRegisterTs.js',
// isolatedModules: transpile only, the types are not checked while testing
'^.+\\.tsx?$': ['ts-jest', { tsconfig: { sourceMap: true, isolatedModules: true } }],
// Use the project babel config (babel.config.js, which extends the one of @rancher/shell)
'^.+\\.js$': '<rootDir>/node_modules/babel-jest',
// process mjs (e.g. vee-validate ESM) with babel-jest
'^.+\\.mjs$': '<rootDir>/node_modules/babel-jest',
'^.+\\.svg$': '<rootDir>/svgTransform.js',
},
transformIgnorePatterns: [
// @rancher/shell is shipped untranspiled, the other packages are ESM-only
'/node_modules/(?!(@rancher/shell|color|color-string|color-convert|color-name|vee-validate|@vee-validate|clipboard-polyfill|intl-messageformat|@formatjs)/)',
],
};
+106
View File
@@ -0,0 +1,106 @@
/* eslint-env jest */
import { createApp } from 'vue';
import { config } from '@vue/test-utils';
import i18n from '@shell/plugins/i18n';
import FloatingVue from 'floating-vue';
import { floatingVueOptions } from '@shell/plugins/floating-vue';
import vSelect from 'vue-select';
import cleanTooltipDirective from '@shell/directives/clean-tooltip';
import cleanHtmlDirective from '@shell/directives/clean-html';
import htmlStrippedAriaLabelDirective from '@shell/directives/strip-html-aria-label';
import '@shell/plugins/replaceall';
import { TextEncoder, TextDecoder } from 'util';
const vueApp = createApp({});
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;
// vueApp.config.productionTip = false;
vueApp.use(i18n, { store: { dispatch() {} } });
vueApp.use(FloatingVue, floatingVueOptions);
vueApp.directive('clean-html', cleanHtmlDirective);
vueApp.directive('clean-tooltip', cleanTooltipDirective);
vueApp.component('v-select', vSelect);
config.global.components['v-select'] = vSelect;
config.global.plugins = [FloatingVue];
config.global.mocks['t'] = (key) => `%${ key }%`;
config.global.mocks['$store'] = {
getters: {},
dispatch: jest.fn(),
commit: jest.fn(),
};
config.global.directives = {
...config.global.directives,
t: {
mounted(el, binding) {
el.textContent = `%${ binding.value }%`;
},
updated(el, binding) {
el.textContent = `%${ binding.value }%`;
}
},
'clean-tooltip': cleanTooltipDirective,
'clean-html': cleanHtmlDirective,
'stripped-aria-label': htmlStrippedAriaLabelDirective,
};
config.global.stubs['t'] = { template: '<span><slot /></span>' };
/**
* Global configuration for Jest tests
*/
beforeAll(() => {
// matchMedia and getContext methods aren't included in jest's version of jsdom for us to mock
// implemented as per jest documentation: https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
Object.defineProperty(window, 'matchMedia', {
value: jest.fn().mockImplementation(() => ({
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn()
}))
});
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
value: jest.fn().mockImplementation(() => ({
createLinearGradient: jest.fn(),
fillRect: jest.fn(),
getImageData: jest.fn(() => ({ data: [] }))
}))
});
Object.defineProperty(window, 'IntersectionObserver', {
writable: true,
value: jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
})),
});
});
jest.mock('@shell/composables/useI18n', () => {
return {
useI18n() {
return { t: (key, options) => `${ key }${ options ? `-${ JSON.stringify(options) }` : '' }` };
}
};
});
// eslint-disable-next-line no-console
jest.spyOn(console, 'warn').mockImplementation((warning) => warning.includes('[Vue warn]') ? null : console.log(warning));
/**
* Common initialization for each test, required for resetting states
*/
beforeEach(() => {
// jest.resetModules(); // Use this function inside your test if you need to remove any cache stored
// jest.clearAllMocks(); // Use this function inside your test if you need to reset mocks
jest.restoreAllMocks(); // Use this function inside your test if you need to reset mocks and restore existing functionality
// Mock the $extension object
// config.mocks['$extension'] = { getDynamic: () => undefined };
config.global.mocks['$store'] = { getters: { 'i18n/t': jest.fn() } };
// Overrides some components
// config.stubs['my-component'] = { template: "<div></div> "};
});
+11 -1
View File
@@ -50,6 +50,8 @@
"clean": "./node_modules/@rancher/shell/scripts/clean",
"lint": "./node_modules/@rancher/shell/scripts/lint --max-warnings 0 --ext .js,.ts,.vue .",
"lint:fix": "./node_modules/@rancher/shell/scripts/lint --fix --max-warnings 0 --ext .js,.ts,.vue .",
"test": "jest --watch",
"test:ci": "NODE_OPTIONS=--max_old_space_size=8192 jest --silent",
"build-pkg": "./node_modules/@rancher/shell/scripts/build-pkg.sh",
"serve-pkgs": "./node_modules/@rancher/shell/scripts/serve-pkgs",
"publish-pkgs": "./node_modules/@rancher/shell/scripts/extension/publish",
@@ -66,6 +68,14 @@
"@commitlint/config-conventional": "^19.8.1",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^7.1.0",
"husky": "^9.1.7"
"husky": "^9.1.7",
"@types/jest": "29.5.12",
"@vue/test-utils": "2.4.6",
"@vue/vue3-jest": "29.2.6",
"babel-jest": "29.7.0",
"babel-plugin-transform-require-context": "0.1.1",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"ts-jest": "29.4.12"
}
}
@@ -2,9 +2,23 @@ import { mount } from '@vue/test-utils';
import { _EDIT } from '@shell/config/query-params';
import HarvesterEditContainer from '../container.vue';
const mountOptions = () => ({
props: { mode: _EDIT, value: {} },
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'i18n/t': jest.fn(),
}
}
}
}
});
describe('component: HarvesterEditContainer', () => {
it('should display all the inputs', () => {
const wrapper = mount(HarvesterEditContainer, { propsData: { mode: _EDIT, value: {} } });
const wrapper = mount(HarvesterEditContainer, mountOptions());
const inputWraps = wrapper.findAll('[data-testid^=input-hec-]');
@@ -15,7 +29,7 @@ describe('component: HarvesterEditContainer', () => {
'name',
'container',
])('should emit an update on %p input', (field) => {
const wrapper = mount(HarvesterEditContainer, { propsData: { mode: _EDIT, value: {} } });
const wrapper = mount(HarvesterEditContainer, mountOptions());
const input = wrapper.find(`[data-testid="input-hec-${ field }"]`).find('input');
const newValue = 123;
@@ -28,7 +42,7 @@ describe('component: HarvesterEditContainer', () => {
'type',
'bus',
])('should emit an update on %p selection change', async(field) => {
const wrapper = mount(HarvesterEditContainer, { propsData: { mode: _EDIT, value: {} } });
const wrapper = mount(HarvesterEditContainer, mountOptions());
const select = wrapper.find(`[data-testid="input-hec-${ field }"]`);
select.find('button').trigger('click');
@@ -5,17 +5,20 @@ import HarvesterEditExisting from '../existing.vue';
describe('component: HarvesterEditExisting', () => {
it('should display all the inputs', () => {
const wrapper = mount(HarvesterEditExisting, {
propsData: {
props: {
mode: _EDIT, value: {}, rows: []
},
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'harvester/all': () => [],
'i18n/t': jest.fn()
}
}
}
}
});
const inputWraps = wrapper.findAll('[data-testid^=input-hee-]');
@@ -27,17 +30,20 @@ describe('component: HarvesterEditExisting', () => {
'name',
])('should emit an update on %p input', (field) => {
const wrapper = mount(HarvesterEditExisting, {
propsData: {
props: {
mode: _EDIT, value: {}, rows: []
},
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'harvester/all': () => [],
'i18n/t': jest.fn()
}
}
}
}
});
const input = wrapper.find(`[data-testid="input-hee-${ field }"]`).find('input');
const newValue = 123;
@@ -53,17 +59,20 @@ describe('component: HarvesterEditExisting', () => {
'size',
])('should emit an update on %p input and blur', (field) => {
const wrapper = mount(HarvesterEditExisting, {
propsData: {
props: {
mode: _EDIT, value: {}, rows: []
},
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'harvester/all': () => [],
'i18n/t': jest.fn()
}
}
}
}
});
const input = wrapper.find(`[data-testid="input-hee-${ field }"]`).find('input');
const newValue = 123;
@@ -80,17 +89,20 @@ describe('component: HarvesterEditExisting', () => {
// 'bus', // disabled
])('should emit an update on %p selection change', async(field) => {
const wrapper = mount(HarvesterEditExisting, {
propsData: {
props: {
mode: _EDIT, value: {}, rows: []
},
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'harvester/all': () => [],
'i18n/t': jest.fn()
}
}
}
}
});
const select = wrapper.find(`[data-testid="input-hee-${ field }"]`);
@@ -2,24 +2,30 @@ import { mount } from '@vue/test-utils';
import { _CREATE } from '@shell/config/query-params';
import HarvesterEditVMImage from '../vmImage.vue';
// The form emits an update when it is created, so count only the updates that an interaction adds
const updates = (wrapper) => wrapper.emitted('update')?.length ?? 0;
describe('component: HarvesterEditVMImage', () => {
it('should display all the inputs', () => {
const wrapper = mount(HarvesterEditVMImage, {
propsData: {
props: {
mode: _CREATE,
isVirtualType: false,
newCreateId: '123',
validateRequired: true,
idx: 1
},
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
}
},
}
}
});
const inputWraps = wrapper.findAll('[data-testid^=input-hevi-]');
@@ -31,21 +37,24 @@ describe('component: HarvesterEditVMImage', () => {
'name',
])('should emit an update on %p input', (field) => {
const wrapper = mount(HarvesterEditVMImage, {
propsData: {
props: {
mode: _CREATE,
isVirtualType: false,
newCreateId: '123',
validateRequired: true,
idx: 1
},
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
}
},
}
}
});
const input = wrapper.find(`[data-testid="input-hevi-${ field }"]`).find('input');
const newValue = 123;
@@ -57,31 +66,38 @@ describe('component: HarvesterEditVMImage', () => {
it.each([
'size',
])('should emit an update on %p input blur', (field) => {
])('should emit an update on %p input blur', async(field) => {
const wrapper = mount(HarvesterEditVMImage, {
propsData: {
props: {
mode: _CREATE,
isVirtualType: false,
newCreateId: '123',
validateRequired: true,
idx: 1
},
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
}
},
}
}
});
const input = wrapper.find(`[data-testid="input-hevi-${ field }"]`).find('input');
const newValue = 123;
input.setValue(newValue);
input.trigger('blur');
// setting the value already emits an update, so the blur has to add one on top of that
await input.setValue(newValue);
expect(wrapper.emitted('update')).toHaveLength(1);
const afterSetValue = updates(wrapper);
await input.trigger('blur');
expect(updates(wrapper)).toBeGreaterThan(afterSetValue);
});
it.each([
@@ -90,21 +106,24 @@ describe('component: HarvesterEditVMImage', () => {
'bus',
])('should emit an update on %p selection change', async(field) => {
const wrapper = mount(HarvesterEditVMImage, {
propsData: {
props: {
mode: _CREATE,
isVirtualType: false,
newCreateId: '123',
validateRequired: true,
idx: 1
},
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
}
},
}
}
});
const select = wrapper.find(`[data-testid="input-hevi-${ field }"]`);
@@ -2,19 +2,25 @@ import { mount } from '@vue/test-utils';
import { _EDIT } from '@shell/config/query-params';
import HarvesterEditVolume from '../volume.vue';
// The form emits an update when it is created, so count only the updates that an interaction adds
const updates = (wrapper) => wrapper.emitted('update')?.length ?? 0;
describe('component: HarvesterEditVolume', () => {
it('should display all the inputs', () => {
const wrapper = mount(HarvesterEditVolume, {
propsData: { validateRequired: true },
props: { validateRequired: true },
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
'i18n/exists': jest.fn(),
}
},
}
}
});
const inputWraps = wrapper.findAll('[data-testid^=input-hev-]');
@@ -22,29 +28,56 @@ describe('component: HarvesterEditVolume', () => {
expect(inputWraps).toHaveLength(4);
});
it.each([
'name',
'size',
])('should emit an update on %p input blur', (field) => {
it('should emit an update on name input', async() => {
const wrapper = mount(HarvesterEditVolume, {
propsData: { mode: _EDIT, validateRequired: true },
props: { mode: _EDIT, validateRequired: true },
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
'i18n/exists': jest.fn(),
}
},
}
}
});
const input = wrapper.find(`[data-testid="input-hev-${ field }"]`).find('input');
const newValue = 123;
const before = updates(wrapper);
const input = wrapper.find('[data-testid="input-hev-name"]').find('input');
input.setValue(newValue);
input.trigger('blur');
await input.setValue(123);
expect(wrapper.emitted('update')).toHaveLength(1);
expect(updates(wrapper)).toBeGreaterThan(before);
});
it('should emit an update on size input blur', async() => {
const wrapper = mount(HarvesterEditVolume, {
props: { mode: _EDIT, validateRequired: true },
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
'i18n/exists': jest.fn(),
}
},
}
}
});
const input = wrapper.find('[data-testid="input-hev-size"]').find('input');
// setting the value already emits an update, so the blur has to add one on top of that
await input.setValue(123);
const afterSetValue = updates(wrapper);
await input.trigger('blur');
expect(updates(wrapper)).toBeGreaterThan(afterSetValue);
});
it.each([
@@ -52,23 +85,27 @@ describe('component: HarvesterEditVolume', () => {
'bus'
])('should emit an update on %p selection change', async(field) => {
const wrapper = mount(HarvesterEditVolume, {
propsData: { mode: _EDIT, validateRequired: true },
props: { mode: _EDIT, validateRequired: true },
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
'i18n/exists': jest.fn(),
}
},
}
}
});
const before = updates(wrapper);
const select = wrapper.find(`[data-testid="input-hev-${ field }"]`);
select.find('button').trigger('click');
await wrapper.trigger('keydown.down');
await wrapper.trigger('keydown.enter');
expect(wrapper.emitted('update')).toHaveLength(1);
expect(updates(wrapper)).toBeGreaterThan(before);
});
});
@@ -0,0 +1,323 @@
import { reactive } from 'vue';
import VirtVm from '../kubevirt.io.virtualmachine';
import { POD, PVC } from '@shell/config/types';
import { HCI } from '../../types';
// The Steve base model and the shell resource class pull in most of the dashboard, none of that is needed here
jest.mock('../harvester', () => ({ __esModule: true, default: class {} }));
jest.mock('@shell/plugins/dashboard-store/resource-class', () => ({ colorForState: (state) => (state === 'Running' ? 'text-success' : 'text-info') }));
const VM_ID = 'ns1/vm1';
/**
* Create a VM model instance without the full Steve model plumbing. Only the store getters that the
* VM model reads are mocked, backed by plain lists (like the real store, `all` returns the same array
* that is mutated in place on updates).
*
* The model reads its store either through `$rootGetters` (`harvester/...`) or through `$getters`, the getters of the
* store it lives in. Both are backed by the same data, so the tests do not depend on which of them the model uses.
*/
function createVm({
vm = {}, vmi, pods = [], pvcs = [], restores = [], backups = [], claims = ['disk-pvc'], printableStatus
} = {}) {
const lists = reactive({
[POD]: pods,
[PVC]: pvcs,
[HCI.RESTORE]: restores,
[HCI.BACKUP]: backups,
});
const byId = { [HCI.VMI]: vmi ? [vmi] : [] };
const getters = {
all: (type) => lists[type] || [],
byId: (type, id) => (byId[type] || lists[type] || []).find((x) => x.id === id),
schemaFor: () => undefined,
};
const rootGetters = {
'harvester-common/getFeatureEnabled': () => false,
currentProduct: { inStore: 'harvester' },
'harvester/all': getters.all,
'harvester/byId': getters.byId,
};
const obj = Object.create(VirtVm.prototype);
Object.assign(obj, {
id: VM_ID,
type: HCI.VM,
metadata: {
name: 'vm1', namespace: 'ns1', annotations: {}
},
spec: {
runStrategy: 'Always',
template: {
spec: {
domain: { cpu: { cores: 1 }, resources: { limits: { memory: '1Gi' } } },
volumes: claims.map((c) => ({ name: c, persistentVolumeClaim: { claimName: c } })),
}
}
},
status: {
created: true, ready: true, printableStatus: printableStatus || 'Running', conditions: []
},
...vm,
});
Object.defineProperty(obj, '$rootGetters', { value: rootGetters });
Object.defineProperty(obj, '$getters', { value: getters });
Object.defineProperty(obj, 't', { value: (k) => k });
return { vm: obj, lists };
}
const readyVmi = (extra = {}) => ({
id: VM_ID,
metadata: { name: 'vm1', namespace: 'ns1' },
status: {
phase: 'Running',
conditions: [{ type: 'Ready', status: 'True' }],
...extra
}
});
const pvc = (name, encrypted, ns = 'ns1') => ({
id: `${ ns }/${ name }`,
metadata: { name, namespace: ns },
isEncrypted: encrypted,
});
describe('class VirtVm', () => {
describe('restoreResource', () => {
it('should distinguish snapshots from backups and follow backup updates', () => {
const { vm, lists } = createVm({
vm: {
metadata: {
namespace: 'ns1', name: 'vm1', annotations: { 'restore.harvesterhci.io/name': 'r1' }
}
},
restores: [{
id: 'ns1/r1', isComplete: false, spec: { virtualMachineBackupNamespace: 'ns1', virtualMachineBackupName: 'b1' }
}],
backups: [
{ id: 'ns2/b1', spec: { type: 'snapshot' } },
{ id: 'ns1/b1', spec: { type: 'backup' } },
],
});
expect(vm.restoreResource.fromSnapshot).toBe(false);
expect(vm.restoreState).toBe(false);
lists[HCI.BACKUP][1].spec.type = 'snapshot';
expect(vm.restoreResource.fromSnapshot).toBe(true);
lists[HCI.BACKUP].splice(1, 1);
expect(vm.restoreResource.fromSnapshot).toBe(false);
lists[HCI.RESTORE][0].isComplete = true;
expect(vm.restoreState).toBe(true);
});
});
describe('encryptedVolumeType', () => {
it('should be none without volumes', () => {
const { vm } = createVm({ claims: [] });
expect(vm.encryptedVolumeType).toBe('none');
});
it('should be none if no PVC is encrypted', () => {
const { vm } = createVm({ pvcs: [pvc('disk-pvc', false)] });
expect(vm.encryptedVolumeType).toBe('none');
});
it('should be all if every PVC is encrypted', () => {
const { vm } = createVm({
claims: ['a', 'b'],
pvcs: [pvc('a', true), pvc('b', true), pvc('other', false)]
});
expect(vm.encryptedVolumeType).toBe('all');
});
it('should be partial if some PVCs are encrypted', () => {
const { vm } = createVm({
claims: ['a', 'b'],
pvcs: [pvc('a', true), pvc('b', false)]
});
expect(vm.encryptedVolumeType).toBe('partial');
});
it('should follow PVCs that are added to the store later', () => {
const { vm, lists } = createVm({ claims: ['a'], pvcs: [] });
expect(vm.encryptedVolumeType).toBe('none');
lists[PVC].push(pvc('a', true));
expect(vm.encryptedVolumeType).toBe('all');
});
});
describe('actualState', () => {
it('should be Off if the VM is halted', () => {
const { vm } = createVm({
vm: {
spec: { runStrategy: 'Halted', template: { spec: { volumes: [], domain: {} } } },
status: { created: false, conditions: [] }
}
});
expect(vm.actualState).toBe('Off');
});
it('should be Running if the VMI is running and ready', () => {
const { vm } = createVm({ vmi: readyVmi() });
expect(vm.actualState).toBe('Running');
});
it('should be Not Ready if the VMI is running but not ready', () => {
const { vm } = createVm({ vmi: readyVmi({ conditions: [{ type: 'Ready', status: 'False' }] }) });
expect(vm.actualState).toBe('Not Ready');
});
it('should be Paused if the VMI has the Paused condition', () => {
const { vm } = createVm({ vmi: readyVmi({ conditions: [{ type: 'Ready', status: 'True' }, { type: 'Paused', status: 'True' }] }) });
expect(vm.actualState).toBe('Paused');
});
it('should be Stopping if the VM is not expected to run anymore but the VMI exists', () => {
const { vm } = createVm({
vm: { spec: { runStrategy: 'Halted', template: { spec: { volumes: [], domain: {} } } } },
vmi: readyVmi()
});
expect(vm.actualState).toBe('Stopping');
});
it('should be Starting while the launcher pod is not ready', () => {
const { vm } = createVm({
vmi: readyVmi({ phase: 'Scheduling', conditions: [] }),
pods: [{
metadata: { namespace: 'ns1', ownerReferences: [{ name: 'vm1' }] },
getPodStatus: { status: 'POD_NOT_SCHEDULABLE' }
}]
});
expect(vm.actualState).toBe('Starting');
});
it('should be Unschedulable if the VM is starting and has an unschedulable condition', () => {
const { vm } = createVm({
vmi: readyVmi({ phase: 'Pending', conditions: [] }),
vm: { status: { created: true, conditions: [{ reason: 'Unschedulable', message: 'no node' }] } }
});
expect(vm.actualState).toBe('Unschedulable');
expect(vm.isUnschedulable.message).toBe('no node');
});
it('should not be Unschedulable without an unschedulable condition', () => {
const { vm } = createVm({ vmi: readyVmi() });
expect(vm.isUnschedulable).toBeNull();
expect(vm.actualState).toBe('Running');
});
it('should not be Unschedulable if the VM is neither starting nor stopping', () => {
const { vm } = createVm({
vm: {
spec: { runStrategy: 'Halted', template: { spec: { volumes: [], domain: {} } } },
status: { created: false, conditions: [{ reason: 'Unschedulable' }] }
}
});
expect(vm.isUnschedulable).toBeNull();
expect(vm.actualState).toBe('Off');
});
it('should be Terminating if the VM is being deleted', () => {
const { vm } = createVm({
vmi: readyVmi(),
vm: {
metadata: {
name: 'vm1', namespace: 'ns1', annotations: {}, deletionTimestamp: 'now'
}
}
});
expect(vm.actualState).toBe('Terminating');
});
it('should be VM error if the VM has a failure condition', () => {
const { vm } = createVm({ vm: { status: { created: true, conditions: [{ type: 'Failure', message: 'boom' }] } } });
expect(vm.actualState).toBe('VM error');
});
it('should be Restoring while a restore is in progress', () => {
const { vm } = createVm({
vmi: readyVmi(),
vm: {
metadata: {
name: 'vm1', namespace: 'ns1', annotations: { 'restore.harvesterhci.io/name': 'r1' }
}
},
restores: [{ id: 'ns1/r1', isComplete: false }]
});
expect(vm.actualState).toBe('Restoring');
});
it('should not be Restoring if the restore of the annotation does not exist', () => {
const { vm } = createVm({
vmi: readyVmi(),
vm: {
metadata: {
name: 'vm1', namespace: 'ns1', annotations: { 'restore.harvesterhci.io/name': 'other' }
}
},
restores: [{ id: 'ns1/r1', isComplete: false }]
});
expect(vm.actualState).toBe('Running');
});
});
describe('podResource', () => {
it('should find the launcher pod by owner name', () => {
const pod = { metadata: { namespace: 'ns1', ownerReferences: [{ name: 'vm1' }] } };
const { vm } = createVm({ vmi: readyVmi(), pods: [pod] });
expect(vm.podResource).toStrictEqual(pod);
});
it('should return the last pod if several pods have the same owner', () => {
// e.g. the source and the target pod while the VM migrates
const source = {
metadata: {
name: 'launcher-source', namespace: 'ns1', ownerReferences: [{ name: 'vm1' }]
}
};
const target = {
metadata: {
name: 'launcher-target', namespace: 'ns1', ownerReferences: [{ name: 'vm1' }]
}
};
const { vm } = createVm({ vmi: readyVmi(), pods: [source, target] });
expect(vm.podResource.metadata.name).toBe('launcher-target');
});
});
describe('isRestartRequired', () => {
it('should reflect the RestartRequired condition', () => {
const { vm } = createVm({ vm: { status: { conditions: [{ type: 'RestartRequired', status: 'True' }] } } });
expect(vm.isRestartRequired).toBe(true);
});
});
});
@@ -0,0 +1,57 @@
import { reactive } from 'vue';
import VirtVmInstance from '../kubevirt.io.virtualmachineinstance';
import { HCI } from '../../types';
import { HCI as HCI_ANNOTATIONS } from '../../config/labels-annotations';
jest.mock('../harvester', () => ({ __esModule: true, default: class {} }));
jest.mock('@shell/plugins/dashboard-store/resource-class', () => ({ colorForState: () => 'text-info' }));
function migration(namespace, timestamp, phase = 'Running') {
return {
metadata: { namespace, creationTimestamp: timestamp },
spec: { vmiName: 'vm1' },
status: { phase },
};
}
function createVmi(migrations) {
const list = reactive(migrations);
const vmi = Object.create(VirtVmInstance.prototype);
Object.assign(vmi, {
metadata: {
namespace: 'ns1',
name: 'vm1',
annotations: { [HCI_ANNOTATIONS.MIGRATION_STATE]: 'Migrating' },
},
$getters: { all: (type) => type === HCI.VMIM ? list : [] },
$rootGetters: { 'harvester/all': (type) => type === HCI.VMIM ? list : [] },
});
return { vmi, list };
}
describe('class VirtVmInstance', () => {
it('should follow additions, timestamp changes, phase changes and removals in the same list', () => {
const { vmi, list } = createVmi([]);
expect(vmi.vmimResource).toStrictEqual([]);
expect(vmi.migrationState).toBeNull();
list.push(migration('ns1', '2026-01-01T00:00:00Z'));
expect(vmi.migrationState.status).toBe('Migrating');
list.push(migration('ns1', '2026-01-02T00:00:00Z', 'Failed'));
expect(vmi.migrationState.status).toBe('Failed');
list[0].metadata.creationTimestamp = '2026-01-03T00:00:00Z';
expect(vmi.vmimResource).toBe(list[0]);
expect(vmi.migrationState.status).toBe('Migrating');
list[0].status.phase = 'Failed';
expect(vmi.migrationState.status).toBe('Failed');
list.splice(0, 2);
expect(vmi.migrationState).toBeNull();
});
});
@@ -0,0 +1,37 @@
import { featureEnabled } from '../feature-flags';
describe('featureEnabled', () => {
it('should enable a feature from the version it was released in', () => {
expect(featureEnabled('hotplugNic', 'v1.7.0')).toBe(true);
expect(featureEnabled('hotplugNic', 'v1.8.2')).toBe(true);
});
it('should not enable a feature before the version it was released in', () => {
expect(featureEnabled('hotplugNic', 'v1.6.1')).toBe(false);
expect(featureEnabled('hotplugCdRom', 'v1.7.1')).toBe(false);
});
it('should ignore pre-release suffixes of the server version', () => {
expect(featureEnabled('hotplugCdRom', 'v1.8.0-rc1')).toBe(true);
expect(featureEnabled('hotplugCdRom', 'v1.8.0-dev-20260101')).toBe(true);
});
it('should return false for unknown features', () => {
expect(featureEnabled('doesNotExist', 'v1.8.0')).toBe(false);
});
it('should not enable anything for versions that are no longer supported', () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
expect(featureEnabled('cpuPinning', 'v1.2.0')).toBe(false);
spy.mockRestore();
});
it('should return the same result for repeated calls with different arguments', () => {
expect(featureEnabled('hotplugNic', 'v1.6.0')).toBe(false);
expect(featureEnabled('hotplugNic', 'v1.7.0')).toBe(true);
expect(featureEnabled('hotplugNic', 'v1.6.0')).toBe(false);
expect(featureEnabled('hotplugNic', 'v1.7.0')).toBe(true);
});
});
+8
View File
@@ -0,0 +1,8 @@
module.exports = {
process() {
return { code: 'module.exports = {};' };
},
getCacheKey() {
return 'svgTransform';
},
};
+15
View File
@@ -0,0 +1,15 @@
/**
* Unit tests are failing with the following error:
*
* ```
* [@vue/compiler-sfc] No fs option provided to `compileScript` in non-Node environment. File system access is required for resolving imported types.
* ```
*
* It seems TypeScript does not populate ts.sys when loaded in Jest. In order to
* resolve this issue, we can use the hack below to point to a different
* transformer than vue-jest and call registerTs before exporting vue-jest.
*
* SEE: https://github.com/vuejs/core/issues/8301
*/
require('@vue/compiler-sfc').registerTS(() => require('typescript'));
module.exports = require('@vue/vue3-jest');
+71 -7
View File
@@ -1467,7 +1467,7 @@
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.2.tgz#9a6e2d05f4b6692e1801cd4fb176ad823930ed5e"
integrity sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==
"@babel/template@^7.27.2", "@babel/template@^7.28.6", "@babel/template@^7.29.7", "@babel/template@^7.3.3":
"@babel/template@7", "@babel/template@^7.27.2", "@babel/template@^7.28.6", "@babel/template@^7.29.7", "@babel/template@^7.3.3":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.29.7.tgz#4d9d4004f645cdd304de958c725162784ecac700"
integrity sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==
@@ -2894,6 +2894,14 @@
dependencies:
"@types/istanbul-lib-report" "*"
"@types/jest@29.5.12":
version "29.5.12"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.12.tgz#7f7dc6eb4cf246d2474ed78744b05d06ce025544"
integrity sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==
dependencies:
expect "^29.0.0"
pretty-format "^29.0.0"
"@types/jsdom@^20.0.0":
version "20.0.1"
resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-20.0.1.tgz#07c14bc19bd2f918c1929541cdaacae894744808"
@@ -4214,7 +4222,7 @@ babel-helper-vue-jsx-merge-props@^2.0.2:
resolved "https://registry.yarnpkg.com/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-2.0.3.tgz#22aebd3b33902328e513293a8e4992b384f9f1b6"
integrity sha512-gsLiKK7Qrb7zYJNgiXKpXblxbV5ffSwR0f5whkPAaBAR4fhi6bwRZxX9wBlIc5M/v8CCkXUbXZL4N/nSE97cqg==
babel-jest@^29.7.0:
babel-jest@29.7.0, babel-jest@^29.7.0:
version "29.7.0"
resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5"
integrity sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==
@@ -4316,6 +4324,13 @@ babel-plugin-syntax-jsx@^6.18.0:
resolved "https://registry.yarnpkg.com/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946"
integrity sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==
babel-plugin-transform-require-context@0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/babel-plugin-transform-require-context/-/babel-plugin-transform-require-context-0.1.1.tgz#319b545ca83080b5062776b46cc9b8b346fea9a6"
integrity sha512-4ceqYOtzgmq4/QsB8dP7pUrUOCjY/jrRYdt7YkIOWHxtGDQbcf6YZDyLCiPQf6KsEIcIbSQiTRXOsbLiuJfgNQ==
dependencies:
"@babel/template" "7"
babel-plugin-transform-vue-jsx@^3.5.0:
version "3.7.0"
resolved "https://registry.yarnpkg.com/babel-plugin-transform-vue-jsx/-/babel-plugin-transform-vue-jsx-3.7.0.tgz#d40492e6692a36b594f7e9a1928f43e969740960"
@@ -4570,6 +4585,13 @@ browserslist@^4.0.0, browserslist@^4.16.3, browserslist@^4.21.4, browserslist@^4
node-releases "^2.0.36"
update-browserslist-db "^1.2.3"
bs-logger@^0.2.6:
version "0.2.6"
resolved "https://registry.yarnpkg.com/bs-logger/-/bs-logger-0.2.6.tgz#eb7d365307a72cf974cc6cda76b68354ad336bd8"
integrity sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==
dependencies:
fast-json-stable-stringify "2.x"
bser@2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/bser/-/bser-2.1.1.tgz#e6787da20ece9d07998533cfd9de6f5c38f4bc05"
@@ -6750,7 +6772,7 @@ exit@^0.1.2:
resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c"
integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==
expect@^29.7.0:
expect@^29.0.0, expect@^29.7.0:
version "29.7.0"
resolved "https://registry.yarnpkg.com/expect/-/expect-29.7.0.tgz#578874590dcb3214514084c08115d8aee61e11bc"
integrity sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==
@@ -6821,7 +6843,7 @@ fast-glob@^3.2.7, fast-glob@^3.2.9:
merge2 "^1.3.0"
micromatch "^4.0.8"
fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0:
fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
@@ -7323,6 +7345,18 @@ handle-thing@^2.0.0:
resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e"
integrity sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==
handlebars@^4.7.9:
version "4.7.10"
resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.10.tgz#12419ea1b41391b3ffcf003f2164dcbce090c417"
integrity sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==
dependencies:
minimist "^1.2.8"
neo-async "^2.6.2"
source-map "^0.6.1"
wordwrap "^1.0.0"
optionalDependencies:
uglify-js "^3.1.4"
has-bigints@^1.0.2:
version "1.1.0"
resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.1.0.tgz#28607e965ac967e03cd2a2c70a2636a1edad49fe"
@@ -9101,6 +9135,11 @@ make-dir@^4.0.0:
dependencies:
semver "^7.5.3"
make-error@^1.3.6:
version "1.3.6"
resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
makeerror@1.0.12:
version "1.0.12"
resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.12.tgz#3e5dd2079a82e812e983cc6610c4a2cb0eaa801a"
@@ -10363,7 +10402,7 @@ pretty-error@^4.0.0:
lodash "^4.17.20"
renderkid "^3.0.0"
pretty-format@^29.7.0:
pretty-format@^29.0.0, pretty-format@^29.7.0:
version "29.7.0"
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812"
integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==
@@ -10977,7 +11016,7 @@ semver-compare@^1.0.0:
resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc"
integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==
"semver@2 || 3 || 4 || 5", semver@7.8.5, semver@^5.5.0, semver@^5.7.1, semver@^6.0.0, semver@^6.1.0, semver@^6.3.0, semver@^6.3.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.8, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.3, semver@^7.7.3, semver@~7.0.0:
"semver@2 || 3 || 4 || 5", semver@7.8.5, semver@^5.5.0, semver@^5.7.1, semver@^6.0.0, semver@^6.1.0, semver@^6.3.0, semver@^6.3.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.8, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.3, semver@^7.7.3, semver@^7.8.5, semver@~7.0.0:
version "7.8.5"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69"
integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==
@@ -11784,6 +11823,21 @@ ts-declaration-location@^1.0.6:
dependencies:
picomatch "^4.0.2"
ts-jest@29.4.12:
version "29.4.12"
resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.4.12.tgz#c34cd91f02d364e9286d2c016843315fd0efff76"
integrity sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==
dependencies:
bs-logger "^0.2.6"
fast-json-stable-stringify "^2.1.0"
handlebars "^4.7.9"
json5 "^2.2.3"
lodash.memoize "^4.1.2"
make-error "^1.3.6"
semver "^7.8.5"
type-fest "^4.41.0"
yargs-parser "^21.1.1"
ts-loader@^9.2.5:
version "9.5.7"
resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-9.5.7.tgz#582663e853646e18506cd5cc79feb354952731c0"
@@ -11852,7 +11906,7 @@ type-fest@^0.8.0, type-fest@^0.8.1:
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d"
integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==
type-fest@^4.4.0, type-fest@^4.8.3:
type-fest@^4.4.0, type-fest@^4.41.0, type-fest@^4.8.3:
version "4.41.0"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.41.0.tgz#6ae1c8e5731273c2bf1f58ad39cbae2c91a46c58"
integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==
@@ -11942,6 +11996,11 @@ ufo@0.7.11:
resolved "https://registry.yarnpkg.com/ufo/-/ufo-0.7.11.tgz#17defad497981290383c5d26357773431fdbadcb"
integrity sha512-IT3q0lPvtkqQ8toHQN/BkOi4VIqoqheqM1FnkNWT9y0G8B3xJhwnoKBu5OHx8zHDOvveQzfKuFowJ0VSARiIDg==
uglify-js@^3.1.4:
version "3.19.3"
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.19.3.tgz#82315e9bbc6f2b25888858acd1fff8441035b77f"
integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==
un-eval@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/un-eval/-/un-eval-1.2.0.tgz#22a95c650334d59d21697efae32612218ecad65f"
@@ -12534,6 +12593,11 @@ word-wrap@^1.2.5:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
wordwrap@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
worker-loader@3.0.8:
version "3.0.8"
resolved "https://registry.yarnpkg.com/worker-loader/-/worker-loader-3.0.8.tgz#5fc5cda4a3d3163d9c274a4e3a811ce8b60dbb37"