Merge branch 'webapp_unit_testing' into 'v3'

ui_migration feature(unit-testing) Add isolated component testing, coverage and configurations.

Closes #733

See merge request openflexure/openflexure-microscope-server!588
This commit is contained in:
Julian Stirling 2026-05-20 14:41:01 +00:00
commit 9fcd52ce76
13 changed files with 8237 additions and 306 deletions

View file

@ -16,8 +16,8 @@ skip = node_modules,
./src/openflexure_microscope_server/static,
.venv,
*.egg-info,
report.xml
src/tests/unit,
./webapp/coverage/*,
regex = [a-zA-Z0-9\-']+
ignore-words-list = fom
ignore-words-list = fom, afterAll

View file

@ -1,7 +1,7 @@
[codespell]
# A separate RC file for detecting camelCase, PascalCase, and kebab-case errors
# A little more prone to false positives than our standard regex, also fails to detect
# hyphenated words in the dictonary.
# hyphenated words in the dictionary.
# Same as the main RC-file except it also excludes CHANGELOG as this is well covered
# by the normal RC file, and we get false positives from git hashes.
@ -19,7 +19,9 @@ skip = node_modules,
.venv,
*.egg-info,
report.xml,
CHANGELOG.md
CHANGELOG.md,
./webapp/src/tests/fixtures/*,
./webapp/coverage/*,
# Ignores split by |
# Ignore hexadecimal numbers preceded by #

2
.gitignore vendored
View file

@ -94,7 +94,7 @@ openflexure_settings/
# Files created by test utilities
/tests/unit_tests/utilities/*.pstats
/tests/unit_tests/utilities/*.png
coverage/
# Files created by simulator
openflexure/

View file

@ -150,14 +150,29 @@ combined-tests:
coverage_format: cobertura
path: coverage.xml
# JavaScript linting with ESLint (via Vue CLI)
# JavaScript linting and testing
# ESLint, StyleLint and ViTest
# Only runs when a .js or .vue file is edited
eslint:
stage: analysis
javascript-lint-and-test:
stage: testing
extends: .javascript_webapp
script:
- npm run lint
- 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:

1
webapp/.prettierignore Normal file
View file

@ -0,0 +1 @@
coverage/

View file

@ -9,7 +9,13 @@ const isDev = process.env.NODE_ENV === "development";
export default [
{
ignores: ["dist/**/*", "lib/**/*", "**/*.min.js", "tools/architecture_dashboard/**/*"],
ignores: [
"dist/**/*",
"lib/**/*",
"**/*.min.js",
"tools/architecture_dashboard/**/*",
"coverage/",
],
},
js.configs.recommended,
@ -17,7 +23,7 @@ export default [
...vue.configs["flat/recommended"],
{
files: ["**/*.vue", "**/*.js", "**/*.jsx", "**/*.cjs", "**/*.mjs"],
files: ["**/*.vue", "**/*.js", "**/*.jsx", "**/*.cjs", "**/*.mjs", "**/*.spec.js"],
languageOptions: {
ecmaVersion: 2021,
sourceType: "module",
@ -73,5 +79,22 @@ export default [
"pinia/require-setup-store-properties-export": "warn",
},
},
{
files: ["src/tests/unit/**/*.spec.js"],
languageOptions: {
globals: {
describe: "readonly",
it: "readonly",
expect: "readonly",
vi: "readonly",
beforeEach: "readonly",
afterEach: "readonly",
mockLogData: "readonly", // Prevents unused global mock variable warnings
afterAll: "readonly",
},
},
},
prettierConfig,
];

2564
webapp/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -15,7 +15,8 @@
"lint": "eslint . --ignore-pattern .gitignore --max-warnings=0",
"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"
"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"
},
"dependencies": {
"@vueuse/core": "^14.1.0",
@ -31,15 +32,20 @@
},
"devDependencies": {
"@babel/eslint-parser": "^7.28.5",
"@pinia/testing": "^1.0.3",
"@vitejs/plugin-vue": "^6.0.6",
"@vitest/coverage-v8": "^4.1.6",
"@vue/eslint-config-prettier": "9.0.0",
"@vue/test-utils": "^2.4.10",
"autoprefixer": "^10.4.24",
"core-js": "^3.46.0",
"cross-env": "^10.1.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-pinia": "^0.4.2",
"eslint-plugin-prettier": "^5.1.0",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-vue": "^9.20.0",
"happy-dom": "^20.9.0",
"less": "^4.5.1",
"material-symbols": "^0.39.1",
"postcss-html": "^1.8.1",
@ -49,7 +55,8 @@
"stylelint-config-standard": "^40.0.0",
"stylelint-config-standard-less": "^4.1.0",
"typescript-eslint": "^8.59.0",
"vite": "^7.3.1"
"vite": "^7.3.1",
"vitest": "^4.1.6"
},
"postcss": {
"plugins": {

View file

@ -16,6 +16,7 @@
<button
class="uk-button uk-button-default uk-width-1-1"
type="button"
data-test-id="update-btn"
@click="updateLogs()"
>
Refresh Logs
@ -27,6 +28,7 @@
:url="logFileURI"
button-label="Download Log File"
:button-primary="false"
data-test-id="download-btn"
/>
</div>
</div>
@ -225,7 +227,7 @@ export default {
},
escapeText: function (unsafeText) {
let div = document.createElement("div");
div.innerText = unsafeText;
div.textContent = unsafeText;
return div.innerHTML;
},
},

5618
webapp/src/tests/fixtures/realLog.txt vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,163 @@
import { shallowMount, flushPromises } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createTestingPinia } from "@pinia/testing";
import { setActivePinia } from "pinia";
import axios from "axios";
import fs from "fs";
import path from "path";
import LoggingContent from "../../components/tabContentComponents/loggingContent.vue";
// Mock Axios
vi.mock("axios", () => {
return {
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
patch: vi.fn(),
},
};
});
// Mock VueUse to prevent IntersectionObserver crashes in JSDOM
// This will be removed once vue-route is implemented
vi.mock("@vueuse/core", () => ({
useIntersectionObserver: vi.fn(() => ({
stop: vi.fn(),
})),
}));
// Test Description
describe("Test LoggingContent.vue", () => {
let wrapper;
// Define path to a real log file and read it
const logFilePath = path.resolve(__dirname, "../fixtures/realLog.txt");
const mockLogData = fs.readFileSync(logFilePath, "utf-8");
// Things to do before each test
beforeEach(async () => {
vi.useFakeTimers();
// Reset mocks before each test
vi.resetAllMocks();
// Set axios to return our fake logs
axios.get.mockResolvedValue({ data: mockLogData });
// Mount the component with a fake Pinia store
wrapper = shallowMount(LoggingContent, {
attachTo: document.body,
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
initialState: {
settings: { origin: "http://microscope.local:5000/api/v3" },
},
}),
],
// Simplify child components
stubs: {
PaginateLinks: true,
EndpointButton: true,
transition: true,
teleport: true,
settings: true,
},
},
});
// flush so we avoid leaving uncompleted processes running on background
await flushPromises();
vi.advanceTimersByTime(1000);
vi.useRealTimers();
});
// Tear down wrapper, unmount testing component
afterEach(() => {
if (wrapper) {
wrapper.unmount(); // Clean up virtual DOM and memory bindings
}
setActivePinia(undefined);
document.body.innerHTML = "";
if (global.gc) {
global.gc(); // Forces V8 engine to immediately run Garbage Collection
}
vi.clearAllTimers();
vi.useRealTimers();
});
// Test 1: Render check, if things do exist in the page as intended
it("renders the component correctly", async () => {
// Check if component is been loaded and it is rendered
expect(wrapper.exists()).toBe(true);
// Check if navbar exists
expect(wrapper.find(".logging-navbar").exists()).toBe(true);
});
// Test 2: Check log parsing to see if specific INFO exists
it("fetches logs and parses them correctly when updateLogs is called", async () => {
// Trigger the method
await wrapper.vm.updateLogs();
await flushPromises();
// Verify Axios was called with the correct URI from the Pinia store
expect(axios.get).toHaveBeenCalledWith("http://microscope.local:5000/api/v3/log/");
// Verify the logs have correct file length
// Helps validate formatting, etc
expect(wrapper.vm.logs.length).toBe(5491);
// Check the most recent log
expect(wrapper.vm.logs[0].level).toBe("INFO");
// Check if the multi-line file has real first connection information
expect(wrapper.vm.logs[0].message).toContain("uvicorn");
expect(wrapper.vm.logs[0].summary).toContain("http://0.0.0.0:5000");
});
// Test 3: Filter logging file information
it("filters logs based on the selected level", async () => {
await wrapper.vm.updateLogs();
// Set filter to WARNING wait and compare expected length
wrapper.vm.filterLevel = "WARNING";
await wrapper.vm.$nextTick();
expect(wrapper.vm.filteredItems.length).toBe(1449);
expect(wrapper.vm.filteredItems[0].level).toBe("WARNING");
// Set filter to INFO wait and compare expected length
wrapper.vm.filterLevel = "INFO";
await wrapper.vm.$nextTick();
expect(wrapper.vm.filteredItems.length).toBe(5491);
expect(wrapper.vm.filteredItems[0].level).toBe("INFO");
// Set filter to ERROR wait and compare expected length
wrapper.vm.filterLevel = "ERROR";
await wrapper.vm.$nextTick();
expect(wrapper.vm.filteredItems.length).toBe(7);
expect(wrapper.vm.filteredItems[0].level).toBe("ERROR");
});
// Test 4: Click buttons
// This required the component file to be modified by adding data-test-id flags.
// Flags are needed as classes and text content may change by translation or style change.
it("click buttons", async () => {
// Click download button
const downloadButton = wrapper.find('[data-test-id="download-btn"]');
expect(downloadButton.exists()).toBe(true);
await downloadButton.trigger("click");
// Click update button
const updateButton = wrapper.find('[data-test-id="update-btn"]');
expect(updateButton.exists()).toBe(true);
await updateButton.trigger("click");
});
// Prevents Vitest "Async Leak" errors by giving Vue's internal
// DevTools timer a few milliseconds to finish before teardown.
afterAll(async () => {
await new Promise((resolve) => setTimeout(resolve, 15));
});
});

View file

@ -10,6 +10,7 @@ import vue from "@vitejs/plugin-vue";
// https://vitejs.dev/config/
export default defineConfig(({ command, mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const isProduction = mode === "production";
var target;
@ -27,7 +28,29 @@ export default defineConfig(({ command, mode }) => {
}
return {
plugins: [vue()],
plugins: [
vue({
// Strip "data-test-id" attributes (used by Vitest) from the production build.
// This enforces separation of concerns and prevents developers from relying on
// test IDs for styling or core application logic.
template: {
compilerOptions: {
// Only apply this AST transformation during production builds
nodeTransforms: isProduction
? [
(node) => {
// Check if the current AST node is an HTML element
if (node.type === 1) {
// Filter out any property named "data-test-id"
node.props = node.props.filter((prop) => prop.name !== "data-test-id");
}
},
]
: [],
},
},
}),
],
build: {
// Output directory for production builds

93
webapp/vitest.config.js Normal file
View file

@ -0,0 +1,93 @@
// OFM unit testing configuration file.
import { defineConfig } from "vitest/config";
import vue from "@vitejs/plugin-vue";
import path from "path";
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
// This configuration is set to avoid vue warns about missing refs.
// Forces the test to render all html as dynamic, and avoid hoisting.
hoistStatic: false,
},
},
}),
],
test: {
// CSS is disabled for now, the test loads the DOM without styles.
css: false,
pool: "forks",
// Runs in a single thread to avoid CI pipeline crashing.
singleThread: true,
// Switch loading vue features
define: {
// Feature not needed for current testing
__VUE_PROD_DEVTOOLS__: false,
// We use and enforce VUE optionsAPI
__VUE_OPTIONS_API__: true,
},
// Coverage analysis tools setup
coverage: {
enabled: true,
provider: "v8",
clean: true,
cleanOnStart: true,
// text gives output on STOUT and html creates an artifact
reporter: ["text", "html", "cobertura"],
all: true,
// Files to include or exclude in coverage
include: ["src/**/*.vue", "src/**/*.js"],
exclude: [],
// 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.
thresholds: {
lines: 4,
statements: 3,
functions: 3,
branches: 1,
},
},
// Add largest component dependencies here
// This chunks together the largest libraries to avoid recurrent parsing and loading
// and increasing memory overhead. Good for CI.
deps: {
optimizer: {
web: {
enabled: true,
include: ["uikit", "openseadragon", "material-design-icons"],
},
},
},
// This is the package responsible for simulating the DOM the other option is JSDOM
// Happy-dom is the most efficient in memory and its future proof for later node versions.
environment: "happy-dom",
globals: true, // Probably good for Mixins
// This tells vitest to not skip loading these packages,
// and load them from the local node_modules at run time.
server: {
deps: {
inline: ["vue", "@vue/test-utils", "pinia", "@pinia/testing"],
},
},
},
// This is to enforce the use of specific packages versions on the testing.
// This one for example says to load explicitly the vue package at the project's level,
// and not the global vue if existing.
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
vue: path.resolve(__dirname, "./node_modules/vue"),
},
},
});