From 71141b257ac7d96b3c68566366a0fefb42798d1f Mon Sep 17 00:00:00 2001 From: Volker Theile Date: Tue, 13 Oct 2026 18:00:38 +0200 Subject: [PATCH] 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 --- .github/actions/test/action.yaml | 12 + .github/workflows/run-lint.yaml | 8 + AGENTS.md | 25 ++ docs/agents.md/agents/tools.md | 12 + docs/agents.md/contributors/2_unit-tests.md | 12 + jest.config.js | 45 +++ jest.setup.js | 106 ++++++ package.json | 12 +- .../__tests__/HarvesterEditContainer.test.ts | 20 +- .../__tests__/HarvesterEditExisting.test.ts | 60 ++-- .../__tests__/HarvesterEditVMImage.test.ts | 91 +++-- .../__tests__/HarvesterEditVolume.test.ts | 111 ++++-- .../kubevirt.io.virtualmachine.test.js | 323 ++++++++++++++++++ ...kubevirt.io.virtualmachineinstance.test.js | 57 ++++ .../utils/__tests__/feature-flags.test.js | 37 ++ svgTransform.js | 8 + vue3JestRegisterTs.js | 15 + yarn.lock | 78 ++++- 18 files changed, 924 insertions(+), 108 deletions(-) create mode 100644 .github/actions/test/action.yaml create mode 100644 docs/agents.md/contributors/2_unit-tests.md create mode 100644 jest.config.js create mode 100644 jest.setup.js create mode 100644 pkg/harvester/models/__tests__/kubevirt.io.virtualmachine.test.js create mode 100644 pkg/harvester/models/__tests__/kubevirt.io.virtualmachineinstance.test.js create mode 100644 pkg/harvester/utils/__tests__/feature-flags.test.js create mode 100644 svgTransform.js create mode 100644 vue3JestRegisterTs.js diff --git a/.github/actions/test/action.yaml b/.github/actions/test/action.yaml new file mode 100644 index 00000000..5eefb29d --- /dev/null +++ b/.github/actions/test/action.yaml @@ -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 diff --git a/.github/workflows/run-lint.yaml b/.github/workflows/run-lint.yaml index e9fd1480..2a7570c2 100644 --- a/.github/workflows/run-lint.yaml +++ b/.github/workflows/run-lint.yaml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 736f6b87..7024a460 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `. CI runs `yarn test:ci` on every pull request. +- Test files live next to the code in `__tests__` folders and are named `.test.js` or `.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` diff --git a/docs/agents.md/agents/tools.md b/docs/agents.md/agents/tools.md index 8e3e836c..17dea261 100644 --- a/docs/agents.md/agents/tools.md +++ b/docs/agents.md/agents/tools.md @@ -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. diff --git a/docs/agents.md/contributors/2_unit-tests.md b/docs/agents.md/contributors/2_unit-tests.md new file mode 100644 index 00000000..0a3bebad --- /dev/null +++ b/docs/agents.md/contributors/2_unit-tests.md @@ -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 `. CI runs `yarn test:ci` on every pull request. +- Test files live next to the code in `__tests__` folders and are named `.test.js` or `.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. diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..13f43dbf --- /dev/null +++ b/jest.config.js @@ -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: ['/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: ['/jest.setup.js'], + watchman: false, + moduleFileExtensions: ['js', 'mjs', 'json', 'vue', 'ts'], + modulePaths: [''], + moduleNameMapper: { + '^~/(.*)$': '/$1', + '^@/(.*)$': '/$1', + '^@shell/(.*)$': '/node_modules/@rancher/shell/$1', + '^@components/(.*)$': '/node_modules/@rancher/shell/rancher-components/$1', + // At build time @pkg/ is resolved to the package that imports it (see vue.config.js of @rancher/shell) + '^@pkg/harvester/(.*)$': '/pkg/harvester/$1', + '^@pkg/(.*)$': '/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$': '/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)$': '/svgTransform.js', + }, + modulePathIgnorePatterns: ['/pkg/harvester/node_modules'], + testPathIgnorePatterns: ['/node_modules/', '(/.*)*/__tests__/utils/'], + transform: { + // vue3-jest, but with TypeScript registered for the Vue compiler first, see vue3JestRegisterTs.js + '^.+\\.vue$': '/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$': '/node_modules/babel-jest', + // process mjs (e.g. vee-validate ESM) with babel-jest + '^.+\\.mjs$': '/node_modules/babel-jest', + '^.+\\.svg$': '/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)/)', + ], +}; diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 00000000..a6bece84 --- /dev/null +++ b/jest.setup.js @@ -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: '' }; + +/** + * 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: "
"}; +}); diff --git a/package.json b/package.json index 2475df17..8d2965f2 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/__tests__/HarvesterEditContainer.test.ts b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/__tests__/HarvesterEditContainer.test.ts index 1c55598b..425642cd 100644 --- a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/__tests__/HarvesterEditContainer.test.ts +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/__tests__/HarvesterEditContainer.test.ts @@ -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'); diff --git a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/__tests__/HarvesterEditExisting.test.ts b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/__tests__/HarvesterEditExisting.test.ts index 25dce613..2a2c8ff6 100644 --- a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/__tests__/HarvesterEditExisting.test.ts +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/__tests__/HarvesterEditExisting.test.ts @@ -5,14 +5,17 @@ import HarvesterEditExisting from '../existing.vue'; describe('component: HarvesterEditExisting', () => { it('should display all the inputs', () => { const wrapper = mount(HarvesterEditExisting, { - propsData: { + props: { mode: _EDIT, value: {}, rows: [] }, - mocks: { - $store: { - getters: { - 'harvester/all': () => [], - 'i18n/t': jest.fn() + global: { + mocks: { + $store: { + getters: { + 'harvester-common/getFeatureEnabled': () => true, + 'harvester/all': () => [], + 'i18n/t': jest.fn() + } } } } @@ -27,14 +30,17 @@ describe('component: HarvesterEditExisting', () => { 'name', ])('should emit an update on %p input', (field) => { const wrapper = mount(HarvesterEditExisting, { - propsData: { + props: { mode: _EDIT, value: {}, rows: [] }, - mocks: { - $store: { - getters: { - 'harvester/all': () => [], - 'i18n/t': jest.fn() + global: { + mocks: { + $store: { + getters: { + 'harvester-common/getFeatureEnabled': () => true, + 'harvester/all': () => [], + 'i18n/t': jest.fn() + } } } } @@ -53,14 +59,17 @@ describe('component: HarvesterEditExisting', () => { 'size', ])('should emit an update on %p input and blur', (field) => { const wrapper = mount(HarvesterEditExisting, { - propsData: { + props: { mode: _EDIT, value: {}, rows: [] }, - mocks: { - $store: { - getters: { - 'harvester/all': () => [], - 'i18n/t': jest.fn() + global: { + mocks: { + $store: { + getters: { + 'harvester-common/getFeatureEnabled': () => true, + 'harvester/all': () => [], + 'i18n/t': jest.fn() + } } } } @@ -80,14 +89,17 @@ 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: [] }, - mocks: { - $store: { - getters: { - 'harvester/all': () => [], - 'i18n/t': jest.fn() + global: { + mocks: { + $store: { + getters: { + 'harvester-common/getFeatureEnabled': () => true, + 'harvester/all': () => [], + 'i18n/t': jest.fn() + } } } } diff --git a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/__tests__/HarvesterEditVMImage.test.ts b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/__tests__/HarvesterEditVMImage.test.ts index dae03f60..e627543a 100644 --- a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/__tests__/HarvesterEditVMImage.test.ts +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/__tests__/HarvesterEditVMImage.test.ts @@ -2,23 +2,29 @@ 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 }, - mocks: { - $store: { - getters: { - 'harvester/all': jest.fn(), - 'i18n/t': jest.fn(), - } - }, + global: { + mocks: { + $store: { + getters: { + 'harvester-common/getFeatureEnabled': () => true, + 'harvester/all': jest.fn(), + 'i18n/t': jest.fn(), + } + }, + } } }); @@ -31,20 +37,23 @@ 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 }, - mocks: { - $store: { - getters: { - 'harvester/all': jest.fn(), - 'i18n/t': jest.fn(), - } - }, + 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'); @@ -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 }, - mocks: { - $store: { - getters: { - 'harvester/all': jest.fn(), - 'i18n/t': jest.fn(), - } - }, + 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,20 +106,23 @@ 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 }, - mocks: { - $store: { - getters: { - 'harvester/all': jest.fn(), - 'i18n/t': jest.fn(), - } - }, + 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 }"]`); diff --git a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/__tests__/HarvesterEditVolume.test.ts b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/__tests__/HarvesterEditVolume.test.ts index ed2f5812..8465538f 100644 --- a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/__tests__/HarvesterEditVolume.test.ts +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/__tests__/HarvesterEditVolume.test.ts @@ -2,18 +2,24 @@ 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 }, - mocks: { - $store: { - getters: { - 'harvester/all': jest.fn(), - 'i18n/t': jest.fn(), - 'i18n/exists': jest.fn(), - } - }, + props: { validateRequired: true }, + global: { + mocks: { + $store: { + getters: { + 'harvester-common/getFeatureEnabled': () => true, + 'harvester/all': jest.fn(), + 'i18n/t': jest.fn(), + 'i18n/exists': jest.fn(), + } + }, + } } }); @@ -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 }, - mocks: { - $store: { - getters: { - 'harvester/all': jest.fn(), - 'i18n/t': jest.fn(), - 'i18n/exists': jest.fn(), - } - }, + 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 }, - mocks: { - $store: { - getters: { - 'harvester/all': jest.fn(), - 'i18n/t': jest.fn(), - 'i18n/exists': jest.fn(), - } - }, + 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); }); }); diff --git a/pkg/harvester/models/__tests__/kubevirt.io.virtualmachine.test.js b/pkg/harvester/models/__tests__/kubevirt.io.virtualmachine.test.js new file mode 100644 index 00000000..334b374b --- /dev/null +++ b/pkg/harvester/models/__tests__/kubevirt.io.virtualmachine.test.js @@ -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); + }); + }); +}); diff --git a/pkg/harvester/models/__tests__/kubevirt.io.virtualmachineinstance.test.js b/pkg/harvester/models/__tests__/kubevirt.io.virtualmachineinstance.test.js new file mode 100644 index 00000000..786d3fcd --- /dev/null +++ b/pkg/harvester/models/__tests__/kubevirt.io.virtualmachineinstance.test.js @@ -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(); + }); +}); diff --git a/pkg/harvester/utils/__tests__/feature-flags.test.js b/pkg/harvester/utils/__tests__/feature-flags.test.js new file mode 100644 index 00000000..9761b62f --- /dev/null +++ b/pkg/harvester/utils/__tests__/feature-flags.test.js @@ -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); + }); +}); diff --git a/svgTransform.js b/svgTransform.js new file mode 100644 index 00000000..f3b1c8a1 --- /dev/null +++ b/svgTransform.js @@ -0,0 +1,8 @@ +module.exports = { + process() { + return { code: 'module.exports = {};' }; + }, + getCacheKey() { + return 'svgTransform'; + }, +}; diff --git a/vue3JestRegisterTs.js b/vue3JestRegisterTs.js new file mode 100644 index 00000000..438569e7 --- /dev/null +++ b/vue3JestRegisterTs.js @@ -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'); diff --git a/yarn.lock b/yarn.lock index db8ac16d..3544fd94 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"