Merge branch 'smoke_test_components' into 'v3'

ui_migration Smoke test VUE components

Closes #733

See merge request openflexure/openflexure-microscope-server!604
This commit is contained in:
Julian Stirling 2026-06-23 13:41:18 +00:00
commit 2bcc98094f
8 changed files with 406 additions and 16 deletions

View file

@ -0,0 +1,174 @@
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
import { shallowMount, flushPromises } from "@vue/test-utils";
import { createTestingPinia } from "@pinia/testing";
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, 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<string, { default: any }>} A dictionary mapping file paths to their Vue component modules.
*/
const componentModules = import.meta.glob(
["../../components/**/*.vue", "!../../components/experimental/**/*.vue"],
{ eager: true },
);
/**
* @note IMPORTANT: Skip list, add here components that have their own test .spec.js file
* */
const skipList = ["loggingContent.vue"];
/**
* 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(() => {
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;
}
document.body.innerHTML = "";
if (global.gc) {
global.gc();
}
vi.clearAllTimers();
vi.useRealTimers();
});
afterAll(async () => {
await new Promise((resolve) => setTimeout(resolve, 15));
});
/**
* 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
* 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);
const errorName = fileName + "Wrong Path";
it(`${errorName} has no correct Path`, () => {
expect.fail(`Incorrect Path parsing for "${fileName}"`);
});
return;
}
/**
* 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
* * 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;
testRunner(`Shallow mounts ${fileName} without crashing`, async () => {
const override = componentOverrides[fileName] || {};
wrapper = shallowMount(component, {
attachTo: document.body,
props: override.props || {},
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
initialState: override.initialState || {},
}),
],
mocks: {
readThingProperty: () => ({}),
thingActionAvailable: () => true,
thingAvailable: () => ({}),
thingDescription: () => ({
actions: {},
properties: {},
}),
thingDescriptions: () => ({
test_thing: {
properties: {
test_property_1: {},
test_property_2: {},
},
},
}),
getOngoingAction: () => Promise.resolve(),
getThingEndpoint: () => Promise.resolve([]),
modalError: "",
...(override.global?.mocks || {}),
...(override.mocks || {}),
},
},
});
await flushPromises();
vi.advanceTimersByTime(200);
expect(wrapper.exists()).toBe(true);
});
}
});

View file

@ -0,0 +1,133 @@
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 = {
"galleryContent.vue": {
global: {
mocks: {
readThingProperty: () => [],
},
},
},
"serverSpecifiedPropertyControl.vue": {
props: {
propertyData: {},
propertyName: "",
thingName: "",
},
},
"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: '<div class="dummy"></div>' }),
},
},
"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: "" },
},
};

View file

@ -0,0 +1,85 @@
import { vi, beforeEach, afterEach } from "vitest";
/**
* @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.
*/
let consoleWarnWatchdog;
let consoleErrorWatchdog;
/**
* Mock implementation of localStorage for testing purposes.
*/
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 initial state before each test and set up console spies to catch warnings and errors.
*/
beforeEach(() => {
localStorage.clear();
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 (!consoleWarnWatchdog) return;
/** Grab all warnings */
const warnings = consoleWarnWatchdog.mock.calls;
/** Clean up the spy so it doesn't leak */
consoleWarnWatchdog.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 --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 `[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}`;
}
}
});