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 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 new file mode 100644 index 00000000..7a4bc377 --- /dev/null +++ b/webapp/src/tests/unit/allComponents.spec.js @@ -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} 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); + }); + } +}); diff --git a/webapp/src/tests/unit/overrides.js b/webapp/src/tests/unit/overrides.js new file mode 100644 index 00000000..bce3a3c7 --- /dev/null +++ b/webapp/src/tests/unit/overrides.js @@ -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: '
' }), + }, + }, + "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: "" }, + }, +}; diff --git a/webapp/src/tests/unit/setup.js b/webapp/src/tests/unit/setup.js new file mode 100644 index 00000000..fff8ba25 --- /dev/null +++ b/webapp/src/tests/unit/setup.js @@ -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}`; + } + } +}); diff --git a/webapp/vitest.config.js b/webapp/vitest.config.js index dc9dbbf4..1e181a01 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", @@ -39,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.