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:
commit
2bcc98094f
8 changed files with 406 additions and 16 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -56,6 +56,7 @@ pytest_report.xml
|
||||||
.coverage.picamera
|
.coverage.picamera
|
||||||
.coverage.main
|
.coverage.main
|
||||||
picamera_test_hashes.json
|
picamera_test_hashes.json
|
||||||
|
webapp/src/tests/fixtures/*
|
||||||
|
|
||||||
# Translations
|
# Translations
|
||||||
*.mo
|
*.mo
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
8
webapp/.gitignore
vendored
8
webapp/.gitignore
vendored
|
|
@ -27,4 +27,10 @@ yarn-error.log*
|
||||||
# NativeScript application
|
# NativeScript application
|
||||||
hooks
|
hooks
|
||||||
platforms
|
platforms
|
||||||
./webpack.config.js
|
./webpack.config.js
|
||||||
|
|
||||||
|
# Vue test skipped files
|
||||||
|
**/*experimental*.vue
|
||||||
|
|
||||||
|
# Experimental Vue components
|
||||||
|
src/components/experimental/
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
174
webapp/src/tests/unit/allComponents.spec.js
Normal file
174
webapp/src/tests/unit/allComponents.spec.js
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
133
webapp/src/tests/unit/overrides.js
Normal file
133
webapp/src/tests/unit/overrides.js
Normal 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: "" },
|
||||||
|
},
|
||||||
|
};
|
||||||
85
webapp/src/tests/unit/setup.js
Normal file
85
webapp/src/tests/unit/setup.js
Normal 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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -18,6 +18,8 @@ export default defineConfig({
|
||||||
],
|
],
|
||||||
|
|
||||||
test: {
|
test: {
|
||||||
|
// Set localStorage mock
|
||||||
|
setupFiles: ["./src/tests/unit/setup.js"],
|
||||||
// CSS is disabled for now, the test loads the DOM without styles.
|
// CSS is disabled for now, the test loads the DOM without styles.
|
||||||
css: false,
|
css: false,
|
||||||
pool: "forks",
|
pool: "forks",
|
||||||
|
|
@ -39,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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue