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:
commit
9fcd52ce76
13 changed files with 8237 additions and 306 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
2
.gitignore
vendored
|
|
@ -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/
|
||||
|
||||
|
|
|
|||
|
|
@ -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
1
webapp/.prettierignore
Normal file
|
|
@ -0,0 +1 @@
|
|||
coverage/
|
||||
|
|
@ -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
2564
webapp/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
5618
webapp/src/tests/fixtures/realLog.txt
vendored
Normal file
File diff suppressed because it is too large
Load diff
163
webapp/src/tests/unit/LoggingContent.spec.js
Normal file
163
webapp/src/tests/unit/LoggingContent.spec.js
Normal 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));
|
||||
});
|
||||
});
|
||||
|
|
@ -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
93
webapp/vitest.config.js
Normal 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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue