From 3cc5875fdf7fa625730b679a6771df840b2153dd Mon Sep 17 00:00:00 2001 From: Antonio Anaya Date: Fri, 5 Jun 2026 13:28:08 -0600 Subject: [PATCH 1/8] ui_migration feature(vitest) Add setup for mock localStorage, a .spec.js file that loads all components for testing --- webapp/src/tests/unit/allComponents.spec.js | 247 ++++++++++++++++++++ webapp/src/tests/unit/setup.js | 26 +++ webapp/vitest.config.js | 2 + 3 files changed, 275 insertions(+) create mode 100644 webapp/src/tests/unit/allComponents.spec.js create mode 100644 webapp/src/tests/unit/setup.js diff --git a/webapp/src/tests/unit/allComponents.spec.js b/webapp/src/tests/unit/allComponents.spec.js new file mode 100644 index 00000000..606b4a92 --- /dev/null +++ b/webapp/src/tests/unit/allComponents.spec.js @@ -0,0 +1,247 @@ +import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest"; +import { shallowMount, flushPromises } from "@vue/test-utils"; +import { createTestingPinia } from "@pinia/testing"; + +// Eagerly import ALL .vue files at the components directory +const componentModules = import.meta.glob("../../components/**/*.vue", { eager: true }); + +// Skip list, for components that have their own test spec.js file +const skipList = ["loggingContent.vue"]; + +// Map filenames to the specific props or mocks required +// Not all components ask for props +const componentOverrides = { + "paginateLinks.vue": { + props: { + totalPages: 5, + currentPage: 1, + }, + }, + "simpleAccordion.vue": { + props: { title: "Test Accordion Title" }, + }, + "tabContent.vue": { + props: { + tabID: "tab-1", + currentTab: "tab-1", + }, + }, + "tabIcon.vue": { + props: { + tabID: "tab-1", + currentTab: "tab-1", + }, + }, + "actionButton.vue": { + props: { + action: "test-action", + thing: "test-thing", + }, + }, + "actionLogDisplay.vue": { + props: { + taskStatus: "", + log: [], + }, + }, + "endActionButton.vue": { + props: { url: "http://microscope.local/api/v3" }, + }, + "endpointButton.vue": { + props: { url: "http://microscope.local/api/v3" }, + }, + "matrixDisplay.vue": { + props: { matrix: [] }, + }, + "actionProgressBar.vue": { + props: { taskStatus: "" }, + }, + "actionStatusModal.vue": { + props: { + title: "", + log: [], + canTerminate: true, + taskRunning: true, + taskStarted: true, + taskStatus: "", + }, + }, + "inputFromSchema.vue": { + props: { + dataSchema: {}, + }, + }, + "propertyControl.vue": { + props: { + propertyName: "", + thingName: "", + dataSchema: {}, + }, + mocks: { + wotStore: { + thingDescriptions: { + actions: {}, + }, + }, + }, + }, + "serverSpecifiedActionButton.vue": { + props: { + actionData: {}, + elements: {}, + propertyData: {}, + action: "test-action", + thing: "test-thing", + }, + }, + "serverSpecifiedInterface.vue": { + props: { + elements: [], + }, + }, + "serverSpecifiedPropertyControl.vue": { + props: { + propertyData: {}, + propertyName: "", + thingName: "", + }, + }, + "calibrationWizardTask.vue": { + props: { + steps: [], + }, + }, + "singleStepTask.vue": { + props: { + stepComponent: {}, + }, + }, + "actionTab.vue": { + props: { + action: "test-action", + thing: "test-thing", + taskId: "", + taskUrl: "", + }, + }, + "galleryCard.vue": { + props: { + itemData: {}, + }, + }, + "openSeadragonViewer.vue": { + props: { + src: "", + brightness: 0, + contrast: 0, + saturation: 0, + }, + }, + "cameraCalibrationSettings.vue": { + props: { + cameraUri: "", + }, + }, + "galleryContent.vue": { + mocks: { + readThingProperty: vi.fn(() => []), + }, + }, + "CSMCalibrationSettings.vue": { + mocks: { + thingDescriptions: { + csm: { + actions: {}, + }, + }, + }, + }, +}; + +describe("Automated Component Smoke Tests", () => { + // Declare wrapper at this scope so afterEach can clean it up + let wrapper; + + beforeEach(() => { + // Environment setup + vi.useFakeTimers(); + vi.resetAllMocks(); + }); + + afterEach(() => { + if (wrapper) { + wrapper.unmount(); + wrapper = null; + } + + // Clean up virtual DOM and memory bindings + document.body.innerHTML = ""; + + // Forces V8 engine to immediately run Garbage Collection + if (global.gc) { + global.gc(); + } + + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + afterAll(async () => { + await new Promise((resolve) => setTimeout(resolve, 15)); + }); + + // Loop through the file paths + for (const path in componentModules) { + const component = componentModules[path].default; + const fileName = path.split("/").pop(); + const isSkipped = skipList.includes(fileName); + + if (!component) return; + + // Dynamically assign the test runner (it.skip if in the list, otherwise standard it) + const testRunner = isSkipped ? it.skip : it; + + // Run the test + testRunner(`shallow mounts ${fileName} without crashing`, async () => { + // Look up the specific config for this file, default to an empty object + const override = componentOverrides[fileName] || {}; + + wrapper = shallowMount(component, { + attachTo: document.body, + + // Inject the specific props here! + props: override.props || {}, + + global: { + plugins: [ + createTestingPinia({ + createSpy: vi.fn, + // Inject the specific state if it exists, otherwise use empty object + initialState: override.initialState || {}, + }), + ], + stubs: {}, + mocks: { + // These values come from MIXINS + thingActionAvailable: vi.fn(() => true), + readThingProperty: vi.fn(() => ({})), + thingAvailable: vi.fn(() => ({})), + thingDescription: vi.fn(() => ({ + actions: {}, + properties: {}, + })), + thingDescriptions: vi.fn(() => ({})), + getOngoingAction: vi.fn(() => Promise.resolve()), + getThingEndpoint: vi.fn(() => Promise.resolve([])), + modalError: "", + ...(override.mocks || {}), + }, + }, + }); + + await flushPromises(); + vi.advanceTimersByTime(1000); + expect(wrapper.exists()).toBe(true); + }); + } +}); diff --git a/webapp/src/tests/unit/setup.js b/webapp/src/tests/unit/setup.js new file mode 100644 index 00000000..9fbbf9f1 --- /dev/null +++ b/webapp/src/tests/unit/setup.js @@ -0,0 +1,26 @@ +import { vi, beforeEach } from "vitest"; + +// Create an isolated, in-memory mock for localStorage +const localStorageMock = (() => { + let store = {}; + return { + getItem: (key) => store[key] || null, + setItem: (key, value) => { + store[key] = value.toString(); + }, + removeItem: (key) => { + delete store[key]; + }, + clear: () => { + store = {}; + }, + }; +})(); + +// Override Node's native localStorage +vi.stubGlobal("localStorage", localStorageMock); + +// Clean up +beforeEach(() => { + localStorage.clear(); +}); diff --git a/webapp/vitest.config.js b/webapp/vitest.config.js index dc9dbbf4..7ad5f011 100644 --- a/webapp/vitest.config.js +++ b/webapp/vitest.config.js @@ -18,6 +18,8 @@ export default defineConfig({ ], test: { + // Set localStorage mock + setupFiles: ["./src/tests/unit/setup.js"], // CSS is disabled for now, the test loads the DOM without styles. css: false, pool: "forks", From 8f20e1211e6037367a71791f7e378bfe30d1d262 Mon Sep 17 00:00:00 2001 From: Antonio Anaya Date: Tue, 9 Jun 2026 04:54:49 -0600 Subject: [PATCH 2/8] ui_migration feature(vitest) Add setup for skipped experimental components --- .gitlab-ci.yml | 12 -- webapp/.gitignore | 8 +- webapp/package.json | 2 +- webapp/src/tests/unit/allComponents.spec.js | 177 +++++++++++++------- webapp/src/tests/unit/setup.js | 30 +++- webapp/vitest.config.js | 5 +- 6 files changed, 158 insertions(+), 76 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f22c066a..6dfd7d6e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -161,18 +161,6 @@ javascript-lint-and-test: - npm run lint:style # The test runs only if a test spec file has changed - 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: diff --git a/webapp/.gitignore b/webapp/.gitignore index 2f59b8bf..2384ab27 100644 --- a/webapp/.gitignore +++ b/webapp/.gitignore @@ -27,4 +27,10 @@ yarn-error.log* # NativeScript application hooks platforms -./webpack.config.js \ No newline at end of file +./webpack.config.js + +# Vue test skipped files +**/*experimental*.vue + +# Experimental Vue components +src/components/experimental/ \ No newline at end of file diff --git a/webapp/package.json b/webapp/package.json index c9e95b23..076b66f2 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -16,7 +16,7 @@ "lint:fix": "eslint . --fix --ignore-pattern .gitignore", "lint:style": "stylelint \"src/**/*.{vue,less,css}\" --max-warnings=0", "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": { "@vueuse/core": "^14.3.0", diff --git a/webapp/src/tests/unit/allComponents.spec.js b/webapp/src/tests/unit/allComponents.spec.js index 606b4a92..8843bdae 100644 --- a/webapp/src/tests/unit/allComponents.spec.js +++ b/webapp/src/tests/unit/allComponents.spec.js @@ -1,48 +1,98 @@ import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest"; import { shallowMount, flushPromises } from "@vue/test-utils"; import { createTestingPinia } from "@pinia/testing"; +import { markRaw } from "vue"; // Eagerly import ALL .vue files at the components directory -const componentModules = import.meta.glob("../../components/**/*.vue", { eager: true }); - -// Skip list, for components that have their own test spec.js file +// Except the ones inside experimental +const componentModules = Object.fromEntries( + 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"]; // Map filenames to the specific props or mocks required // Not all components ask for props const componentOverrides = { - "paginateLinks.vue": { + "serverSpecifiedPropertyControl.vue": { props: { - totalPages: 5, - currentPage: 1, + propertyData: {}, + 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": { props: { title: "Test Accordion Title" }, }, "tabContent.vue": { - props: { - tabID: "tab-1", - currentTab: "tab-1", - }, + props: { tabID: "tab-1", currentTab: "tab-1" }, }, "tabIcon.vue": { - props: { - tabID: "tab-1", - currentTab: "tab-1", - }, + props: { tabID: "tab-1", currentTab: "tab-1" }, }, "actionButton.vue": { - props: { - action: "test-action", - thing: "test-thing", - }, + props: { action: "test-action", thing: "test-thing" }, }, "actionLogDisplay.vue": { - props: { - taskStatus: "", - log: [], - }, + props: { taskStatus: "", log: [] }, }, "endActionButton.vue": { props: { url: "http://microscope.local/api/v3" }, @@ -67,20 +117,23 @@ const componentOverrides = { }, }, "inputFromSchema.vue": { - props: { - dataSchema: {}, - }, + props: { dataSchema: {} }, }, + "propertyControl.vue": { props: { propertyName: "", - thingName: "", + thingName: "test_thing", dataSchema: {}, }, + mocks: { wotStore: { thingDescriptions: { - actions: {}, + test_thing: { + actions: {}, + properties: {}, + }, }, }, }, @@ -95,25 +148,14 @@ const componentOverrides = { }, }, "serverSpecifiedInterface.vue": { - props: { - elements: [], - }, - }, - "serverSpecifiedPropertyControl.vue": { - props: { - propertyData: {}, - propertyName: "", - thingName: "", - }, + props: { elements: [] }, }, "calibrationWizardTask.vue": { - props: { - steps: [], - }, + props: { steps: [] }, }, "singleStepTask.vue": { props: { - stepComponent: {}, + stepComponent: markRaw({ template: '
' }), }, }, "actionTab.vue": { @@ -125,9 +167,7 @@ const componentOverrides = { }, }, "galleryCard.vue": { - props: { - itemData: {}, - }, + props: { itemData: {} }, }, "openSeadragonViewer.vue": { props: { @@ -138,20 +178,24 @@ const componentOverrides = { }, }, "cameraCalibrationSettings.vue": { - props: { - cameraUri: "", - }, + props: { cameraUri: "" }, }, + "galleryContent.vue": { - mocks: { - readThingProperty: vi.fn(() => []), + global: { + mocks: { + readThingProperty: vi.fn(() => []), + }, }, }, + "CSMCalibrationSettings.vue": { - mocks: { - thingDescriptions: { - csm: { - actions: {}, + global: { + mocks: { + thingDescriptions: { + csm: { + actions: {}, + }, }, }, }, @@ -194,15 +238,30 @@ describe("Automated Component Smoke Tests", () => { for (const path in componentModules) { const component = componentModules[path].default; 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) const testRunner = isSkipped ? it.skip : it; // 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 const override = componentOverrides[fileName] || {}; @@ -222,7 +281,7 @@ describe("Automated Component Smoke Tests", () => { ], stubs: {}, mocks: { - // These values come from MIXINS + // These mocks come from MIXINS thingActionAvailable: vi.fn(() => true), readThingProperty: vi.fn(() => ({})), thingAvailable: vi.fn(() => ({})), @@ -230,17 +289,17 @@ describe("Automated Component Smoke Tests", () => { actions: {}, properties: {}, })), - thingDescriptions: vi.fn(() => ({})), getOngoingAction: vi.fn(() => Promise.resolve()), getThingEndpoint: vi.fn(() => Promise.resolve([])), modalError: "", + ...(override.global?.mocks || {}), ...(override.mocks || {}), }, }, }); await flushPromises(); - vi.advanceTimersByTime(1000); + vi.advanceTimersByTime(200); expect(wrapper.exists()).toBe(true); }); } diff --git a/webapp/src/tests/unit/setup.js b/webapp/src/tests/unit/setup.js index 9fbbf9f1..8abd3ae1 100644 --- a/webapp/src/tests/unit/setup.js +++ b/webapp/src/tests/unit/setup.js @@ -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 + const localStorageMock = (() => { let store = {}; return { @@ -23,4 +26,29 @@ vi.stubGlobal("localStorage", localStorageMock); // Clean up beforeEach(() => { 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}`; + } }); diff --git a/webapp/vitest.config.js b/webapp/vitest.config.js index 7ad5f011..1e181a01 100644 --- a/webapp/vitest.config.js +++ b/webapp/vitest.config.js @@ -41,11 +41,12 @@ export default defineConfig({ clean: true, cleanOnStart: true, // text gives output on STOUT and html creates an artifact - reporter: ["text", "html", "cobertura"], + reporter: ["text"], all: true, // Files to include or exclude in coverage 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. // As the coverage is low we set the limit low, as testing spec files increase, // these thresholds need to increase accordingly. From 5ec088e07ab38c1a00052b05a0e778976c4a0e30 Mon Sep 17 00:00:00 2001 From: Antonio Anaya Date: Tue, 9 Jun 2026 15:36:38 -0600 Subject: [PATCH 3/8] ui_migration feature(vitest) Revisit allComponents.spec.js add jsdocstrings --- webapp/src/tests/unit/allComponents.spec.js | 116 ++++++++++++++------ webapp/src/tests/unit/setup.js | 2 +- 2 files changed, 81 insertions(+), 37 deletions(-) diff --git a/webapp/src/tests/unit/allComponents.spec.js b/webapp/src/tests/unit/allComponents.spec.js index 8843bdae..83c3aded 100644 --- a/webapp/src/tests/unit/allComponents.spec.js +++ b/webapp/src/tests/unit/allComponents.spec.js @@ -3,16 +3,33 @@ import { shallowMount, flushPromises } from "@vue/test-utils"; import { createTestingPinia } from "@pinia/testing"; import { markRaw } from "vue"; -// Eagerly import ALL .vue files at the components directory -// Except the ones inside experimental -const componentModules = Object.fromEntries( - Object.entries(import.meta.glob("../../components/**/*.vue", { eager: true })), +/** + * @file Automated component smoke testing suite. + * @description Iterates through all project components and performs a + * shallow mount on each. This acts as a first line of defense to catch + * fatal DOM runtime errors, missing dependencies, or script crashes + * before they reach production. + */ + +/** * Vite eagerly grabs all .vue components, explicitly excluding the experimental folder. + * * @type {Record} A dictionary mapping file paths to their Vue component modules. + */ +const componentModules = import.meta.glob( + ["../../components/**/*.vue", "!../../components/experimental/**/*.vue"], + { eager: true }, ); -// Skip list, add here components that have their own test spec.js file + +/** + * IMPORTANT + * Skip list, add here components that have their own test .spec.js file + * */ const skipList = ["loggingContent.vue"]; -// Map filenames to the specific props or mocks required -// Not all components ask for props +/** + * Map filenames to the specific props or mocks required + * Not all components ask for props + * If a component has unmet props or global mocks setup.js is set to fail the test + **/ const componentOverrides = { "serverSpecifiedPropertyControl.vue": { props: { @@ -26,8 +43,8 @@ const componentOverrides = { thingDescriptions: { test_thing: { properties: { - property_1: {}, - fancy_property_2: {}, + test_property_1: {}, + test_property_2: {}, }, }, }, @@ -43,8 +60,8 @@ const componentOverrides = { thingDescriptions: { test_thing: { properties: { - fancy_property_1: {}, - fancy_property_2: {}, + test_property_1: {}, + test_property_2: {}, }, }, }, @@ -60,8 +77,8 @@ const componentOverrides = { thingDescriptions: { test_thing: { properties: { - fancy_property_1: {}, - fancy_property_2: {}, + test_property_1: {}, + test_property_2: {}, }, }, }, @@ -202,26 +219,39 @@ const componentOverrides = { }, }; -describe("Automated Component Smoke Tests", () => { - // Declare wrapper at this scope so afterEach can clean it up +/** + * Test description this is a fast unit test + * The objective of this testing is find early + * component loading issues like bad component setups + */ + +describe("Unit Test", () => { let wrapper; + /** + * Environment setup + * set Fake timers and Mocks + */ beforeEach(() => { - // Environment setup vi.useFakeTimers(); vi.resetAllMocks(); }); + /** + * Clean up after every single test + * Unmounts any present component wrapper= + * Clean up virtual DOM and memory bindings + * Forces V8 engine to immediately run Garbage Collection + * clears out timers to avoid leaks + */ afterEach(() => { if (wrapper) { wrapper.unmount(); wrapper = null; } - // Clean up virtual DOM and memory bindings document.body.innerHTML = ""; - // Forces V8 engine to immediately run Garbage Collection if (global.gc) { global.gc(); } @@ -234,54 +264,68 @@ describe("Automated Component Smoke Tests", () => { await new Promise((resolve) => setTimeout(resolve, 15)); }); - // Loop through the file paths + /** + * Loop through the file paths + * Unwraps the component and exposes its definition using ".default" + * Includes meta information like name, props, components, methods. + * Do not confuse with export default from script component section + * Skips components at the skipList from files that are explicitly skipped + * Also skips components which name starts with the word "experimental" + * allowing to work with edge case components if needed + * is discouraged to push components that won't pass a smoke test + * */ for (const path in componentModules) { const component = componentModules[path].default; const fileName = path.split("/").pop(); const isSkipped = skipList.includes(fileName) || path.toLowerCase().includes("experimental"); - // 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) { + /** + * This check skips vue files that are corrupted or have no meta content + * return is preferred as a wrong path in file could lead to halt on CI pipeline or over verbose output + * An error here means a wrong path or file access error + * This was needed during the writing of this script + * Leaving for other future edge cases usage + * This fails the overall test + * */ + if (!component || undefined) { 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.`, - ); + + const errorName = fileName + "Wrong Path"; + + it(`${errorName} has no correct Path`, () => { + expect.fail(`Incorrect Path parsing for "${fileName}"`); }); - // 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) + * Run the test + * Look up the specific config for this file, default to an empty object + * Override specific props + * Inject the specific state if it exists, otherwise use empty object + * Mocks values come from MIXINS + */ const testRunner = isSkipped ? it.skip : it; - // Run the test testRunner(`Shallow mounts ${fileName} without crashing`, async () => { - // Look up the specific config for this file, default to an empty object const override = componentOverrides[fileName] || {}; wrapper = shallowMount(component, { attachTo: document.body, - // Inject the specific props here! props: override.props || {}, global: { plugins: [ createTestingPinia({ createSpy: vi.fn, - // Inject the specific state if it exists, otherwise use empty object initialState: override.initialState || {}, }), ], stubs: {}, mocks: { - // These mocks come from MIXINS thingActionAvailable: vi.fn(() => true), readThingProperty: vi.fn(() => ({})), thingAvailable: vi.fn(() => ({})), diff --git a/webapp/src/tests/unit/setup.js b/webapp/src/tests/unit/setup.js index 8abd3ae1..b2870900 100644 --- a/webapp/src/tests/unit/setup.js +++ b/webapp/src/tests/unit/setup.js @@ -43,7 +43,7 @@ afterEach(({ task }) => { (args) => typeof args[0] === "string" && args[0].includes("[Vue warn]"), ); - // If Vue has warns forcefully fail this test block + // If Vue has warns forcefully fail this test block --max-warnings=0 if (vueWarnings.length > 0) { const warningMessages = vueWarnings.map((args) => args.join(" ")).join("\n\n"); From 7e8f0b395757b9e73e580c89205502425b892809 Mon Sep 17 00:00:00 2001 From: Antonio Anaya Date: Thu, 11 Jun 2026 13:22:18 -0600 Subject: [PATCH 4/8] ui_migration chore(gitignore) Ignore fixtures --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0a17713c..c2cd1c95 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ pytest_report.xml .coverage.picamera .coverage.main picamera_test_hashes.json +webapp/src/tests/fixtures/* # Translations *.mo From ffcc7f936bb362a8f527676342e88fbf16075c29 Mon Sep 17 00:00:00 2001 From: Antonio Anaya Date: Tue, 16 Jun 2026 16:34:12 -0600 Subject: [PATCH 5/8] ui_migration feature(vitest) Split allComponents.spec.js for readability using now overrides.js for prop,mock component injection --- webapp/src/tests/unit/allComponents.spec.js | 201 +------------------- webapp/src/tests/unit/overrides.js | 188 ++++++++++++++++++ 2 files changed, 191 insertions(+), 198 deletions(-) create mode 100644 webapp/src/tests/unit/overrides.js diff --git a/webapp/src/tests/unit/allComponents.spec.js b/webapp/src/tests/unit/allComponents.spec.js index 83c3aded..05572d19 100644 --- a/webapp/src/tests/unit/allComponents.spec.js +++ b/webapp/src/tests/unit/allComponents.spec.js @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest"; import { shallowMount, flushPromises } from "@vue/test-utils"; import { createTestingPinia } from "@pinia/testing"; -import { markRaw } from "vue"; +import { componentOverrides } from "./overrides"; /** * @file Automated component smoke testing suite. @@ -9,6 +9,7 @@ import { markRaw } from "vue"; * shallow mount on each. This acts as a first line of defense to catch * fatal DOM runtime errors, missing dependencies, or script crashes * before they reach production. + * @requires overrides.js for component-specific prop and mock configurations. */ /** * Vite eagerly grabs all .vue components, explicitly excluding the experimental folder. @@ -20,211 +21,15 @@ const componentModules = import.meta.glob( ); /** - * IMPORTANT - * Skip list, add here components that have their own test .spec.js file + * @note IMPORTANT: Skip list, add here components that have their own test .spec.js file * */ const skipList = ["loggingContent.vue"]; -/** - * Map filenames to the specific props or mocks required - * Not all components ask for props - * If a component has unmet props or global mocks setup.js is set to fail the test - **/ -const componentOverrides = { - "serverSpecifiedPropertyControl.vue": { - props: { - propertyData: {}, - propertyName: "", - thingName: "", - }, - global: { - mocks: { - wotStore: { - thingDescriptions: { - test_thing: { - properties: { - test_property_1: {}, - test_property_2: {}, - }, - }, - }, - }, - }, - }, - }, - - "slideScanContent.vue": { - global: { - mocks: { - wotStore: { - thingDescriptions: { - test_thing: { - properties: { - test_property_1: {}, - test_property_2: {}, - }, - }, - }, - }, - }, - }, - }, - - "slideScanControls.vue": { - global: { - mocks: { - wotStore: { - thingDescriptions: { - test_thing: { - properties: { - test_property_1: {}, - test_property_2: {}, - }, - }, - }, - }, - }, - }, - }, - - "miniStreamDisplay.vue": { - props: { streamId: "testProp" }, - }, - "streamContent.vue": { - props: { streamId: "testProp" }, - }, - "paginateLinks.vue": { - props: { totalPages: 5, currentPage: 1 }, - }, - "simpleAccordion.vue": { - props: { title: "Test Accordion Title" }, - }, - "tabContent.vue": { - props: { tabID: "tab-1", currentTab: "tab-1" }, - }, - "tabIcon.vue": { - props: { tabID: "tab-1", currentTab: "tab-1" }, - }, - "actionButton.vue": { - props: { action: "test-action", thing: "test-thing" }, - }, - "actionLogDisplay.vue": { - props: { taskStatus: "", log: [] }, - }, - "endActionButton.vue": { - props: { url: "http://microscope.local/api/v3" }, - }, - "endpointButton.vue": { - props: { url: "http://microscope.local/api/v3" }, - }, - "matrixDisplay.vue": { - props: { matrix: [] }, - }, - "actionProgressBar.vue": { - props: { taskStatus: "" }, - }, - "actionStatusModal.vue": { - props: { - title: "", - log: [], - canTerminate: true, - taskRunning: true, - taskStarted: true, - taskStatus: "", - }, - }, - "inputFromSchema.vue": { - props: { dataSchema: {} }, - }, - - "propertyControl.vue": { - props: { - propertyName: "", - thingName: "test_thing", - dataSchema: {}, - }, - - mocks: { - wotStore: { - thingDescriptions: { - test_thing: { - actions: {}, - properties: {}, - }, - }, - }, - }, - }, - "serverSpecifiedActionButton.vue": { - props: { - actionData: {}, - elements: {}, - propertyData: {}, - action: "test-action", - thing: "test-thing", - }, - }, - "serverSpecifiedInterface.vue": { - props: { elements: [] }, - }, - "calibrationWizardTask.vue": { - props: { steps: [] }, - }, - "singleStepTask.vue": { - props: { - stepComponent: markRaw({ template: '
' }), - }, - }, - "actionTab.vue": { - props: { - action: "test-action", - thing: "test-thing", - taskId: "", - taskUrl: "", - }, - }, - "galleryCard.vue": { - props: { itemData: {} }, - }, - "openSeadragonViewer.vue": { - props: { - src: "", - brightness: 0, - contrast: 0, - saturation: 0, - }, - }, - "cameraCalibrationSettings.vue": { - props: { cameraUri: "" }, - }, - - "galleryContent.vue": { - global: { - mocks: { - readThingProperty: vi.fn(() => []), - }, - }, - }, - - "CSMCalibrationSettings.vue": { - global: { - mocks: { - thingDescriptions: { - csm: { - actions: {}, - }, - }, - }, - }, - }, -}; - /** * Test description this is a fast unit test * The objective of this testing is find early * component loading issues like bad component setups */ - describe("Unit Test", () => { let wrapper; diff --git a/webapp/src/tests/unit/overrides.js b/webapp/src/tests/unit/overrides.js new file mode 100644 index 00000000..cc07fb8f --- /dev/null +++ b/webapp/src/tests/unit/overrides.js @@ -0,0 +1,188 @@ +import { markRaw } from "vue"; + +/** + * @file Component-specific overrides for unit testing. + * @description Map filenames to the specific props or mocks required + * Not all components ask for props + * If a component has unmet props or global mocks setup.js is set to fail the test + **/ +export const componentOverrides = { + "serverSpecifiedPropertyControl.vue": { + props: { + propertyData: {}, + propertyName: "", + thingName: "", + }, + global: { + mocks: { + wotStore: { + thingDescriptions: { + test_thing: { + properties: { + test_property_1: {}, + test_property_2: {}, + }, + }, + }, + }, + }, + }, + }, + + "slideScanContent.vue": { + global: { + mocks: { + wotStore: { + thingDescriptions: { + test_thing: { + properties: { + test_property_1: {}, + test_property_2: {}, + }, + }, + }, + }, + }, + }, + }, + + "slideScanControls.vue": { + global: { + mocks: { + wotStore: { + thingDescriptions: { + test_thing: { + properties: { + test_property_1: {}, + test_property_2: {}, + }, + }, + }, + }, + }, + }, + }, + + "miniStreamDisplay.vue": { + props: { streamId: "testProp" }, + }, + "streamContent.vue": { + props: { streamId: "testProp" }, + }, + "paginateLinks.vue": { + props: { totalPages: 5, currentPage: 1 }, + }, + "simpleAccordion.vue": { + props: { title: "Test Accordion Title" }, + }, + "tabContent.vue": { + props: { tabID: "tab-1", currentTab: "tab-1" }, + }, + "tabIcon.vue": { + props: { tabID: "tab-1", currentTab: "tab-1" }, + }, + "actionButton.vue": { + props: { action: "test-action", thing: "test-thing" }, + }, + "actionLogDisplay.vue": { + props: { taskStatus: "", log: [] }, + }, + "endActionButton.vue": { + props: { url: "http://microscope.local/api/v3" }, + }, + "endpointButton.vue": { + props: { url: "http://microscope.local/api/v3" }, + }, + "matrixDisplay.vue": { + props: { matrix: [] }, + }, + "actionProgressBar.vue": { + props: { taskStatus: "" }, + }, + "actionStatusModal.vue": { + props: { + title: "", + log: [], + canTerminate: true, + taskRunning: true, + taskStarted: true, + taskStatus: "", + }, + }, + "inputFromSchema.vue": { + props: { dataSchema: {} }, + }, + + "propertyControl.vue": { + props: { + propertyName: "", + thingName: "test_thing", + dataSchema: {}, + }, + + mocks: { + wotStore: { + thingDescriptions: { + test_thing: { + actions: {}, + properties: {}, + }, + }, + }, + }, + }, + "serverSpecifiedActionButton.vue": { + props: { + actionData: {}, + elements: {}, + propertyData: {}, + action: "test-action", + thing: "test-thing", + }, + }, + "serverSpecifiedInterface.vue": { + props: { elements: [] }, + }, + "calibrationWizardTask.vue": { + props: { steps: [] }, + }, + "singleStepTask.vue": { + props: { + stepComponent: markRaw({ template: '
' }), + }, + }, + "actionTab.vue": { + props: { + action: "test-action", + thing: "test-thing", + taskId: "", + taskUrl: "", + }, + }, + "galleryCard.vue": { + props: { itemData: {} }, + }, + "openSeadragonViewer.vue": { + props: { + src: "", + brightness: 0, + contrast: 0, + saturation: 0, + }, + }, + "cameraCalibrationSettings.vue": { + props: { cameraUri: "" }, + }, + + "CSMCalibrationSettings.vue": { + global: { + mocks: { + thingDescriptions: { + csm: { + actions: {}, + }, + }, + }, + }, + }, +}; From c33782e9d619253fde4245ed712d864893f53e45 Mon Sep 17 00:00:00 2001 From: Antonio Anaya Date: Wed, 17 Jun 2026 01:53:51 -0600 Subject: [PATCH 6/8] ui_migration feature(vitest) Reduce boilerplate code at overrides --- webapp/src/tests/unit/allComponents.spec.js | 12 +++- webapp/src/tests/unit/overrides.js | 62 --------------------- 2 files changed, 11 insertions(+), 63 deletions(-) diff --git a/webapp/src/tests/unit/allComponents.spec.js b/webapp/src/tests/unit/allComponents.spec.js index 05572d19..ca98e99d 100644 --- a/webapp/src/tests/unit/allComponents.spec.js +++ b/webapp/src/tests/unit/allComponents.spec.js @@ -110,7 +110,9 @@ describe("Unit Test", () => { * Look up the specific config for this file, default to an empty object * Override specific props * Inject the specific state if it exists, otherwise use empty object - * Mocks values come from MIXINS + * * Note on Global Mocks: + * The mock functions below stub out standard Mixin methods that typically interact with the Pinia store. + * These should not be moved to `overrides.js` Defining them here significantly reduces boilerplate configuration inside `overrides.js`. */ const testRunner = isSkipped ? it.skip : it; @@ -138,6 +140,14 @@ describe("Unit Test", () => { actions: {}, properties: {}, })), + thingDescriptions: vi.fn(() => ({ + test_thing: { + properties: { + test_property_1: {}, + test_property_2: {}, + }, + }, + })), getOngoingAction: vi.fn(() => Promise.resolve()), getThingEndpoint: vi.fn(() => Promise.resolve([])), modalError: "", diff --git a/webapp/src/tests/unit/overrides.js b/webapp/src/tests/unit/overrides.js index cc07fb8f..39fc3dd0 100644 --- a/webapp/src/tests/unit/overrides.js +++ b/webapp/src/tests/unit/overrides.js @@ -13,56 +13,7 @@ export const componentOverrides = { propertyName: "", thingName: "", }, - global: { - mocks: { - wotStore: { - thingDescriptions: { - test_thing: { - properties: { - test_property_1: {}, - test_property_2: {}, - }, - }, - }, - }, - }, - }, }, - - "slideScanContent.vue": { - global: { - mocks: { - wotStore: { - thingDescriptions: { - test_thing: { - properties: { - test_property_1: {}, - test_property_2: {}, - }, - }, - }, - }, - }, - }, - }, - - "slideScanControls.vue": { - global: { - mocks: { - wotStore: { - thingDescriptions: { - test_thing: { - properties: { - test_property_1: {}, - test_property_2: {}, - }, - }, - }, - }, - }, - }, - }, - "miniStreamDisplay.vue": { props: { streamId: "testProp" }, }, @@ -112,7 +63,6 @@ export const componentOverrides = { "inputFromSchema.vue": { props: { dataSchema: {} }, }, - "propertyControl.vue": { props: { propertyName: "", @@ -173,16 +123,4 @@ export const componentOverrides = { "cameraCalibrationSettings.vue": { props: { cameraUri: "" }, }, - - "CSMCalibrationSettings.vue": { - global: { - mocks: { - thingDescriptions: { - csm: { - actions: {}, - }, - }, - }, - }, - }, }; From 3cc7a9f4e8f0ca2ea8eb8b5d4cfa101bdceef9b7 Mon Sep 17 00:00:00 2001 From: Antonio Anaya Date: Wed, 17 Jun 2026 03:38:10 -0600 Subject: [PATCH 7/8] ui_migration fix(vitest) Add Error watchdog that fails test if vue raises functioning errors, add specific global to gallery that raised silent error and passed test, add JDDocs for documentation --- webapp/src/tests/unit/allComponents.spec.js | 35 +++++++----- webapp/src/tests/unit/overrides.js | 7 +++ webapp/src/tests/unit/setup.js | 59 ++++++++++++++++----- 3 files changed, 74 insertions(+), 27 deletions(-) diff --git a/webapp/src/tests/unit/allComponents.spec.js b/webapp/src/tests/unit/allComponents.spec.js index ca98e99d..00c47c30 100644 --- a/webapp/src/tests/unit/allComponents.spec.js +++ b/webapp/src/tests/unit/allComponents.spec.js @@ -7,13 +7,23 @@ import { componentOverrides } from "./overrides"; * @file Automated component smoke testing suite. * @description Iterates through all project components and performs a * shallow mount on each. This acts as a first line of defense to catch - * fatal DOM runtime errors, missing dependencies, or script crashes + * fatal DOM runtime errors, missing dependencies, memory leaks, lifecycle or script crashes * before they reach production. + * @note CI Execution: + * When running this script in a CI/CD pipeline, it must be executed with strict memory + * safety and process isolation to handle the heavy footprint of mounting the whole set of Vue components (). + * The `test:unit` script implements the following safety nets: + * - `NODE_OPTIONS=--expose-gc` & `--logHeapUsage`: Unlocks the V8 garbage collector to aggressively clear RAM. + * - `--pool=forks` & `--isolate=true`: Boots a dedicated Node.js child process per file to prevent cross-DOM pollution. + * - `--detectAsyncLeaks`: Actively catches hanging `setIntervals` or unresolved API promises. + * - `--no-cache`: Bypasses Vite caching to guarantee a pristine execution environment. + * - `--reporter=verbose`: Forces cloud logs to print exact component names for easier debugging. * @requires overrides.js for component-specific prop and mock configurations. */ -/** * Vite eagerly grabs all .vue components, explicitly excluding the experimental folder. - * * @type {Record} A dictionary mapping file paths to their Vue component modules. +/** + * Vite eagerly grabs all .vue components, explicitly excluding the experimental folder. + * @type {Record} A dictionary mapping file paths to their Vue component modules. */ const componentModules = import.meta.glob( ["../../components/**/*.vue", "!../../components/experimental/**/*.vue"], @@ -131,25 +141,24 @@ describe("Unit Test", () => { initialState: override.initialState || {}, }), ], - stubs: {}, mocks: { - thingActionAvailable: vi.fn(() => true), - readThingProperty: vi.fn(() => ({})), - thingAvailable: vi.fn(() => ({})), - thingDescription: vi.fn(() => ({ + readThingProperty: () => ({}), + thingActionAvailable: () => true, + thingAvailable: () => ({}), + thingDescription: () => ({ actions: {}, properties: {}, - })), - thingDescriptions: vi.fn(() => ({ + }), + thingDescriptions: () => ({ test_thing: { properties: { test_property_1: {}, test_property_2: {}, }, }, - })), - getOngoingAction: vi.fn(() => Promise.resolve()), - getThingEndpoint: vi.fn(() => Promise.resolve([])), + }), + getOngoingAction: () => Promise.resolve(), + getThingEndpoint: () => Promise.resolve([]), modalError: "", ...(override.global?.mocks || {}), ...(override.mocks || {}), diff --git a/webapp/src/tests/unit/overrides.js b/webapp/src/tests/unit/overrides.js index 39fc3dd0..bce3a3c7 100644 --- a/webapp/src/tests/unit/overrides.js +++ b/webapp/src/tests/unit/overrides.js @@ -7,6 +7,13 @@ import { markRaw } from "vue"; * If a component has unmet props or global mocks setup.js is set to fail the test **/ export const componentOverrides = { + "galleryContent.vue": { + global: { + mocks: { + readThingProperty: () => [], + }, + }, + }, "serverSpecifiedPropertyControl.vue": { props: { propertyData: {}, diff --git a/webapp/src/tests/unit/setup.js b/webapp/src/tests/unit/setup.js index b2870900..fff8ba25 100644 --- a/webapp/src/tests/unit/setup.js +++ b/webapp/src/tests/unit/setup.js @@ -1,9 +1,20 @@ import { vi, beforeEach, afterEach } from "vitest"; -let consoleWatchdog; +/** + * @file: Global setup for unit tests. + * @description This file sets up the testing environment for all unit tests, including: + * - Mocking localStorage to prevent side effects and ensure test isolation. + * - Spying on console.warn and console.error to catch any Vue warnings or uncaught exceptions during component mounting. + * - Cleaning up the DOM and memory after each test to prevent leaks and ensure a fresh state for subsequent tests. + * - Enforcing strict failure on any Vue warnings or console errors to maintain high code quality and catch issues early. + */ -// Create an isolated, in-memory mock for localStorage +let consoleWarnWatchdog; +let consoleErrorWatchdog; +/** + * Mock implementation of localStorage for testing purposes. + */ const localStorageMock = (() => { let store = {}; return { @@ -20,35 +31,55 @@ const localStorageMock = (() => { }; })(); -// Override Node's native localStorage +/** + * Override Node's native localStorage + */ vi.stubGlobal("localStorage", localStorageMock); -// Clean up +/** + * clean initial state before each test and set up console spies to catch warnings and errors. + */ beforeEach(() => { localStorage.clear(); - consoleWatchdog = vi.spyOn(console, "warn"); + consoleWarnWatchdog = vi.spyOn(console, "warn"); + consoleErrorWatchdog = vi.spyOn(console, "error"); }); +/** + * After each test, check for any Vue warnings or console errors that occurred during component mounting. + * If any warnings or errors are detected, fail the test and print the relevant messages for debugging. + */ afterEach(({ task }) => { - if (!consoleWatchdog) return; + if (!consoleWarnWatchdog) return; - // Grab all warnings - const warnings = consoleWatchdog.mock.calls; + /** Grab all warnings */ + const warnings = consoleWarnWatchdog.mock.calls; - // Clean up the spy so it doesn't leak - consoleWatchdog.mockRestore(); + /** Clean up the spy so it doesn't leak */ + consoleWarnWatchdog.mockRestore(); - // Filter specifically for Vue warnings + /** 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 --max-warnings=0 + /** If Vue has warns forcefully fail this test block --max-warnings=0 */ 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 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}`; } + /** Add watchdog for console.error to catch any uncaught exceptions during component mounting */ + if (consoleErrorWatchdog) { + const errors = consoleErrorWatchdog.mock.calls; + consoleErrorWatchdog.mockRestore(); + + if (errors.length > 0) { + const errorMessages = errors.map((args) => args.join(" ")).join("\n\n"); + throw `[Console Error] Hard crash in "${task.name}":\n\n${errorMessages}`; + } + } }); From 131342253ccc00b2d3c5d6c60f2512a15f5f7943 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Sun, 21 Jun 2026 13:58:54 +0000 Subject: [PATCH 8/8] Apply suggestions from code review of branch smoke_test_components Co-authored-by: Beth Probert --- webapp/src/tests/unit/allComponents.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/tests/unit/allComponents.spec.js b/webapp/src/tests/unit/allComponents.spec.js index 00c47c30..7a4bc377 100644 --- a/webapp/src/tests/unit/allComponents.spec.js +++ b/webapp/src/tests/unit/allComponents.spec.js @@ -36,7 +36,7 @@ const componentModules = import.meta.glob( const skipList = ["loggingContent.vue"]; /** - * Test description this is a fast unit test + * Test description: this is a fast unit test * The objective of this testing is find early * component loading issues like bad component setups */