ui_migration feature(vitest) Add setup for skipped experimental components

This commit is contained in:
Antonio Anaya 2026-06-09 04:54:49 -06:00 committed by Antonio Anaya
parent 3cc5875fdf
commit 8f20e1211e
6 changed files with 158 additions and 76 deletions

View file

@ -161,18 +161,6 @@ javascript-lint-and-test:
- npm run lint:style - npm run lint:style
# The test runs only if a test spec file has changed # The test runs only if a test spec file has changed
- npm run test:unit - npm run test:unit
artifacts:
name: "vitest-coverage-$CI_COMMIT_REF_SLUG"
when: always
expire_in: 1 week
paths:
- webapp/coverage/
reports:
# Tells GitLab where to find the Cobertura file for MR integration
coverage_report:
coverage_format: cobertura
path: webapp/coverage/cobertura-coverage.xml
# Build JS app # Build JS app
build: build:

6
webapp/.gitignore vendored
View file

@ -28,3 +28,9 @@ yarn-error.log*
hooks hooks
platforms platforms
./webpack.config.js ./webpack.config.js
# Vue test skipped files
**/*experimental*.vue
# Experimental Vue components
src/components/experimental/

View file

@ -16,7 +16,7 @@
"lint:fix": "eslint . --fix --ignore-pattern .gitignore", "lint:fix": "eslint . --fix --ignore-pattern .gitignore",
"lint:style": "stylelint \"src/**/*.{vue,less,css}\" --max-warnings=0", "lint:style": "stylelint \"src/**/*.{vue,less,css}\" --max-warnings=0",
"lint:style:fix": "stylelint \"src/**/*.{vue,less,css}\" --fix", "lint:style:fix": "stylelint \"src/**/*.{vue,less,css}\" --fix",
"test:unit": "NODE_OPTIONS=--expose-gc vitest run src/tests/unit/ --no-cache --mode test --detectAsyncLeaks --coverage --isolate=true --pool=forks --silent --logHeapUsage" "test:unit": "NODE_OPTIONS=--expose-gc vitest run src/tests/unit/ --no-cache --mode test --detectAsyncLeaks --isolate=true --pool=forks --logHeapUsage --reporter=verbose"
}, },
"dependencies": { "dependencies": {
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.3.0",

View file

@ -1,48 +1,98 @@
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
import { shallowMount, flushPromises } from "@vue/test-utils"; import { shallowMount, flushPromises } from "@vue/test-utils";
import { createTestingPinia } from "@pinia/testing"; import { createTestingPinia } from "@pinia/testing";
import { markRaw } from "vue";
// Eagerly import ALL .vue files at the components directory // Eagerly import ALL .vue files at the components directory
const componentModules = import.meta.glob("../../components/**/*.vue", { eager: true }); // Except the ones inside experimental
const componentModules = Object.fromEntries(
// Skip list, for components that have their own test spec.js file Object.entries(import.meta.glob("../../components/**/*.vue", { eager: true })),
);
// Skip list, add here components that have their own test spec.js file
const skipList = ["loggingContent.vue"]; const skipList = ["loggingContent.vue"];
// Map filenames to the specific props or mocks required // Map filenames to the specific props or mocks required
// Not all components ask for props // Not all components ask for props
const componentOverrides = { const componentOverrides = {
"paginateLinks.vue": { "serverSpecifiedPropertyControl.vue": {
props: { props: {
totalPages: 5, propertyData: {},
currentPage: 1, propertyName: "",
thingName: "",
}, },
global: {
mocks: {
wotStore: {
thingDescriptions: {
test_thing: {
properties: {
property_1: {},
fancy_property_2: {},
},
},
},
},
},
},
},
"slideScanContent.vue": {
global: {
mocks: {
wotStore: {
thingDescriptions: {
test_thing: {
properties: {
fancy_property_1: {},
fancy_property_2: {},
},
},
},
},
},
},
},
"slideScanControls.vue": {
global: {
mocks: {
wotStore: {
thingDescriptions: {
test_thing: {
properties: {
fancy_property_1: {},
fancy_property_2: {},
},
},
},
},
},
},
},
"miniStreamDisplay.vue": {
props: { streamId: "testProp" },
},
"streamContent.vue": {
props: { streamId: "testProp" },
},
"paginateLinks.vue": {
props: { totalPages: 5, currentPage: 1 },
}, },
"simpleAccordion.vue": { "simpleAccordion.vue": {
props: { title: "Test Accordion Title" }, props: { title: "Test Accordion Title" },
}, },
"tabContent.vue": { "tabContent.vue": {
props: { props: { tabID: "tab-1", currentTab: "tab-1" },
tabID: "tab-1",
currentTab: "tab-1",
},
}, },
"tabIcon.vue": { "tabIcon.vue": {
props: { props: { tabID: "tab-1", currentTab: "tab-1" },
tabID: "tab-1",
currentTab: "tab-1",
},
}, },
"actionButton.vue": { "actionButton.vue": {
props: { props: { action: "test-action", thing: "test-thing" },
action: "test-action",
thing: "test-thing",
},
}, },
"actionLogDisplay.vue": { "actionLogDisplay.vue": {
props: { props: { taskStatus: "", log: [] },
taskStatus: "",
log: [],
},
}, },
"endActionButton.vue": { "endActionButton.vue": {
props: { url: "http://microscope.local/api/v3" }, props: { url: "http://microscope.local/api/v3" },
@ -67,20 +117,23 @@ const componentOverrides = {
}, },
}, },
"inputFromSchema.vue": { "inputFromSchema.vue": {
props: { props: { dataSchema: {} },
dataSchema: {},
},
}, },
"propertyControl.vue": { "propertyControl.vue": {
props: { props: {
propertyName: "", propertyName: "",
thingName: "", thingName: "test_thing",
dataSchema: {}, dataSchema: {},
}, },
mocks: { mocks: {
wotStore: { wotStore: {
thingDescriptions: { thingDescriptions: {
test_thing: {
actions: {}, actions: {},
properties: {},
},
}, },
}, },
}, },
@ -95,25 +148,14 @@ const componentOverrides = {
}, },
}, },
"serverSpecifiedInterface.vue": { "serverSpecifiedInterface.vue": {
props: { props: { elements: [] },
elements: [],
},
},
"serverSpecifiedPropertyControl.vue": {
props: {
propertyData: {},
propertyName: "",
thingName: "",
},
}, },
"calibrationWizardTask.vue": { "calibrationWizardTask.vue": {
props: { props: { steps: [] },
steps: [],
},
}, },
"singleStepTask.vue": { "singleStepTask.vue": {
props: { props: {
stepComponent: {}, stepComponent: markRaw({ template: '<div class="dummy"></div>' }),
}, },
}, },
"actionTab.vue": { "actionTab.vue": {
@ -125,9 +167,7 @@ const componentOverrides = {
}, },
}, },
"galleryCard.vue": { "galleryCard.vue": {
props: { props: { itemData: {} },
itemData: {},
},
}, },
"openSeadragonViewer.vue": { "openSeadragonViewer.vue": {
props: { props: {
@ -138,16 +178,19 @@ const componentOverrides = {
}, },
}, },
"cameraCalibrationSettings.vue": { "cameraCalibrationSettings.vue": {
props: { props: { cameraUri: "" },
cameraUri: "",
},
}, },
"galleryContent.vue": { "galleryContent.vue": {
global: {
mocks: { mocks: {
readThingProperty: vi.fn(() => []), readThingProperty: vi.fn(() => []),
}, },
}, },
},
"CSMCalibrationSettings.vue": { "CSMCalibrationSettings.vue": {
global: {
mocks: { mocks: {
thingDescriptions: { thingDescriptions: {
csm: { csm: {
@ -156,6 +199,7 @@ const componentOverrides = {
}, },
}, },
}, },
},
}; };
describe("Automated Component Smoke Tests", () => { describe("Automated Component Smoke Tests", () => {
@ -194,15 +238,30 @@ describe("Automated Component Smoke Tests", () => {
for (const path in componentModules) { for (const path in componentModules) {
const component = componentModules[path].default; const component = componentModules[path].default;
const fileName = path.split("/").pop(); const fileName = path.split("/").pop();
const isSkipped = skipList.includes(fileName); const isSkipped = skipList.includes(fileName) || path.toLowerCase().includes("experimental");
if (!component) return; // This check skips vue files that are corrupted or have no meta content
// .default has meta content even without explicit default export
// This check serves the purpose to avoid a file halting a CI pipeline
if (!component) {
console.error(component);
// Generate a test specifically to report this failure to Vitest
it(`${fileName} has a default export`, () => {
expect.fail(
`Test Generation Failed: No content in "${fileName}". ` +
`Please fix this file or add it to the skipList.`,
);
});
// Skip generating the REST of the test suite for this broken file
return;
}
// Dynamically assign the test runner (it.skip if in the list, otherwise standard it) // Dynamically assign the test runner (it.skip if in the list, otherwise standard it)
const testRunner = isSkipped ? it.skip : it; const testRunner = isSkipped ? it.skip : it;
// Run the test // Run the test
testRunner(`shallow mounts ${fileName} without crashing`, async () => { testRunner(`Shallow mounts ${fileName} without crashing`, async () => {
// Look up the specific config for this file, default to an empty object // Look up the specific config for this file, default to an empty object
const override = componentOverrides[fileName] || {}; const override = componentOverrides[fileName] || {};
@ -222,7 +281,7 @@ describe("Automated Component Smoke Tests", () => {
], ],
stubs: {}, stubs: {},
mocks: { mocks: {
// These values come from MIXINS // These mocks come from MIXINS
thingActionAvailable: vi.fn(() => true), thingActionAvailable: vi.fn(() => true),
readThingProperty: vi.fn(() => ({})), readThingProperty: vi.fn(() => ({})),
thingAvailable: vi.fn(() => ({})), thingAvailable: vi.fn(() => ({})),
@ -230,17 +289,17 @@ describe("Automated Component Smoke Tests", () => {
actions: {}, actions: {},
properties: {}, properties: {},
})), })),
thingDescriptions: vi.fn(() => ({})),
getOngoingAction: vi.fn(() => Promise.resolve()), getOngoingAction: vi.fn(() => Promise.resolve()),
getThingEndpoint: vi.fn(() => Promise.resolve([])), getThingEndpoint: vi.fn(() => Promise.resolve([])),
modalError: "", modalError: "",
...(override.global?.mocks || {}),
...(override.mocks || {}), ...(override.mocks || {}),
}, },
}, },
}); });
await flushPromises(); await flushPromises();
vi.advanceTimersByTime(1000); vi.advanceTimersByTime(200);
expect(wrapper.exists()).toBe(true); expect(wrapper.exists()).toBe(true);
}); });
} }

View file

@ -1,6 +1,9 @@
import { vi, beforeEach } from "vitest"; import { vi, beforeEach, afterEach } from "vitest";
let consoleWatchdog;
// Create an isolated, in-memory mock for localStorage // Create an isolated, in-memory mock for localStorage
const localStorageMock = (() => { const localStorageMock = (() => {
let store = {}; let store = {};
return { return {
@ -23,4 +26,29 @@ vi.stubGlobal("localStorage", localStorageMock);
// Clean up // Clean up
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
consoleWatchdog = vi.spyOn(console, "warn");
});
afterEach(({ task }) => {
if (!consoleWatchdog) return;
// Grab all warnings
const warnings = consoleWatchdog.mock.calls;
// Clean up the spy so it doesn't leak
consoleWatchdog.mockRestore();
// Filter specifically for Vue warnings
const vueWarnings = warnings.filter(
(args) => typeof args[0] === "string" && args[0].includes("[Vue warn]"),
);
// If Vue has warns forcefully fail this test block
if (vueWarnings.length > 0) {
const warningMessages = vueWarnings.map((args) => args.join(" ")).join("\n\n");
// Throw a plain string instead of using expect.fail() or new Error()!
// Without an Error object, there is no stack trace, so Vitest CANNOT show a code snippet.
throw `[Vue warn] Failure in "${task.name}":\n\n${warningMessages}`;
}
}); });

View file

@ -41,11 +41,12 @@ export default defineConfig({
clean: true, clean: true,
cleanOnStart: true, cleanOnStart: true,
// text gives output on STOUT and html creates an artifact // text gives output on STOUT and html creates an artifact
reporter: ["text", "html", "cobertura"], reporter: ["text"],
all: true, all: true,
// Files to include or exclude in coverage // Files to include or exclude in coverage
include: ["src/**/*.vue", "src/**/*.js"], include: ["src/**/*.vue", "src/**/*.js"],
exclude: [], // Exclude from coverage all files with keyword in path and name "Experimental"
exclude: ["*/experimental/*", "experimental"],
// These thresholds raise ERROR if the coverage it's under the provided percentages. // These thresholds raise ERROR if the coverage it's under the provided percentages.
// As the coverage is low we set the limit low, as testing spec files increase, // As the coverage is low we set the limit low, as testing spec files increase,
// these thresholds need to increase accordingly. // these thresholds need to increase accordingly.