Frontend: Integrate Vitest test framework #4990

- Includes fixtures and mocks system for API and models as well as npm scripts for running tests, watch mode, coverage and UI
- Adds test setup with JSDOM environment and utility function tests
- Converts marker model tests from Mocha/Chai to Vitest
This commit is contained in:
Ömer Duran
2025-05-06 17:18:39 +03:00
committed by GitHub
parent b5d2d1c7fb
commit 4f04ffe133
9 changed files with 3091 additions and 274 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,10 @@
"gettext-extract": "gettext-extract --output src/locales/translations.pot $(find ${SRC:-src} -type f \\( -iname \\*.vue -o -iname \\*.js \\) -not -path src/common/gettext.js)", "gettext-extract": "gettext-extract --output src/locales/translations.pot $(find ${SRC:-src} -type f \\( -iname \\*.vue -o -iname \\*.js \\) -not -path src/common/gettext.js)",
"lint": "eslint --cache src/ *.js", "lint": "eslint --cache src/ *.js",
"test": "karma start", "test": "karma start",
"test:vitest": "vitest run",
"test:vitest:watch": "vitest",
"test:vitest:coverage": "vitest run --coverage",
"test:vitest:ui": "vitest --ui",
"testcafe": "testcafe", "testcafe": "testcafe",
"trace": "webpack --stats-children", "trace": "webpack --stats-children",
"upgrade": "npm update && npm audit fix", "upgrade": "npm update && npm audit fix",
@@ -37,6 +41,11 @@
"@eslint/js": "^9.26.0", "@eslint/js": "^9.26.0",
"@lcdp/offline-plugin": "^5.1.1", "@lcdp/offline-plugin": "^5.1.1",
"@mdi/font": "^7.4.47", "@mdi/font": "^7.4.47",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@vitejs/plugin-react": "^4.4.1",
"@vitest/coverage-v8": "^3.1.3",
"@vitest/ui": "^3.1.3",
"@vue/compiler-sfc": "^3.5.13", "@vue/compiler-sfc": "^3.5.13",
"@vue/language-server": "^2.2.10", "@vue/language-server": "^2.2.10",
"@vvo/tzdb": "^6.161.0", "@vvo/tzdb": "^6.161.0",
@@ -52,7 +61,7 @@
"core-js": "^3.42.0", "core-js": "^3.42.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"css-loader": "^7.1.2", "css-loader": "^7.1.2",
"cssnano": "^7.0.6", "cssnano": "^7.0.7",
"easygettext": "^2.17.0", "easygettext": "^2.17.0",
"eslint": "^9.26.0", "eslint": "^9.26.0",
"eslint-config-prettier": "^10.1.2", "eslint-config-prettier": "^10.1.2",
@@ -60,7 +69,7 @@
"eslint-plugin-html": "^8.1.2", "eslint-plugin-html": "^8.1.2",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.31.0",
"eslint-plugin-node": "^11.1.0", "eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^5.3.1", "eslint-plugin-prettier": "^5.4.0",
"eslint-plugin-promise": "^7.2.1", "eslint-plugin-promise": "^7.2.1",
"eslint-plugin-vue": "^10.1.0", "eslint-plugin-vue": "^10.1.0",
"eslint-plugin-vuetify": "^2.5.2", "eslint-plugin-vuetify": "^2.5.2",
@@ -72,6 +81,7 @@
"globals": "^16.0.0", "globals": "^16.0.0",
"hls.js": "^1.6.2", "hls.js": "^1.6.2",
"i": "^0.3.7", "i": "^0.3.7",
"jsdom": "^26.1.0",
"karma": "^6.4.4", "karma": "^6.4.4",
"karma-chrome-launcher": "^3.2.0", "karma-chrome-launcher": "^3.2.0",
"karma-coverage-istanbul-reporter": "^3.0.3", "karma-coverage-istanbul-reporter": "^3.0.3",
@@ -80,7 +90,7 @@
"karma-verbose-reporter": "^0.0.8", "karma-verbose-reporter": "^0.0.8",
"karma-webpack": "^5.0.1", "karma-webpack": "^5.0.1",
"luxon": "^3.6.1", "luxon": "^3.6.1",
"maplibre-gl": "^5.4.0", "maplibre-gl": "^5.5.0",
"memoize-one": "^6.0.0", "memoize-one": "^6.0.0",
"mini-css-extract-plugin": "^2.9.2", "mini-css-extract-plugin": "^2.9.2",
"minimist": ">=1.2.8", "minimist": ">=1.2.8",
@@ -108,6 +118,8 @@
"tar": "^7.4.3", "tar": "^7.4.3",
"url-loader": "^4.1.1", "url-loader": "^4.1.1",
"util": "^0.12.5", "util": "^0.12.5",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.1.3",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-3-sanitize": "^0.1.4", "vue-3-sanitize": "^0.1.4",
"vue-loader": "^17.4.2", "vue-loader": "^17.4.2",
@@ -118,7 +130,7 @@
"vue-style-loader": "^4.1.3", "vue-style-loader": "^4.1.3",
"vue3-gettext": "^2.4.0", "vue3-gettext": "^2.4.0",
"vuetify": "^3.8.3", "vuetify": "^3.8.3",
"webpack": "^5.99.7", "webpack": "^5.99.8",
"webpack-bundle-analyzer": "^4.10.2", "webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^6.0.1", "webpack-cli": "^6.0.1",
"webpack-hot-middleware": "^2.26.1", "webpack-hot-middleware": "^2.26.1",

View File

@@ -1,3 +1,3 @@
module.exports = { module.exports = {
plugins: ["postcss-import", "postcss-preset-env", "cssnano"], plugins: [require("postcss-import"), require("postcss-preset-env"), require("cssnano")],
}; };

View File

@@ -0,0 +1,108 @@
import { describe, it, expect } from "vitest";
import $util from "common/util";
describe("$util", () => {
describe("formatBytes", () => {
it("should format bytes as KB", () => {
expect($util.formatBytes(1000)).toBe("1 KB");
expect($util.formatBytes(2000)).toBe("2 KB");
expect($util.formatBytes("3000")).toBe("3 KB");
});
it("should format bytes as MB", () => {
expect($util.formatBytes(1048576)).toBe("1.0 MB");
expect($util.formatBytes(2097152)).toBe("2.0 MB");
expect($util.formatBytes(3145728)).toBe("3.0 MB");
});
it("should format bytes as GB", () => {
expect($util.formatBytes(1073741824)).toBe("1.0 GB");
expect($util.formatBytes(2147483648)).toBe("2.0 GB");
expect($util.formatBytes(3221225472)).toBe("3.0 GB");
});
it("should handle zero and falsy values", () => {
expect($util.formatBytes(0)).toBe("0 KB");
expect($util.formatBytes(null)).toBe("0 KB");
expect($util.formatBytes(undefined)).toBe("0 KB");
expect($util.formatBytes("")).toBe("0 KB");
});
});
describe("truncate", () => {
it("should truncate text longer than specified length", () => {
expect($util.truncate("This is a test", 7)).toBe("This i…");
expect($util.truncate("Hello world!", 5)).toBe("Hell…");
});
it("should not truncate text shorter than specified length", () => {
expect($util.truncate("Test", 10)).toBe("Test");
expect($util.truncate("Short", 10)).toBe("Short");
});
it("should use custom ending if specified", () => {
expect($util.truncate("This is a test", 7, "...")).toBe("This...");
expect($util.truncate("Hello world!", 5, " [more]")).toBe(" [more]");
});
it("should use default values if not specified", () => {
expect($util.truncate("This is a very long text that should be truncated")).toBe(
"This is a very long text that should be truncated"
);
// Default length is 100 characters
});
});
describe("capitalize", () => {
it("should capitalize first letter of each word", () => {
expect($util.capitalize("hello world")).toBe("Hello World");
expect($util.capitalize("test string")).toBe("Test String");
});
it("should handle empty strings", () => {
expect($util.capitalize("")).toBe("");
expect($util.capitalize(null)).toBe("");
expect($util.capitalize(undefined)).toBe("");
});
it("should handle already capitalized text", () => {
expect($util.capitalize("Hello World")).toBe("Hello World");
expect($util.capitalize("HELLO WORLD")).toBe("HELLO WORLD");
});
});
describe("ucFirst", () => {
it("should capitalize only first letter of string", () => {
expect($util.ucFirst("hello world")).toBe("Hello world");
expect($util.ucFirst("test string")).toBe("Test string");
});
it("should handle empty strings", () => {
expect($util.ucFirst("")).toBe("");
expect($util.ucFirst(null)).toBe("");
expect($util.ucFirst(undefined)).toBe("");
});
it("should handle already capitalized text", () => {
expect($util.ucFirst("Hello world")).toBe("Hello world");
expect($util.ucFirst("HELLO world")).toBe("HELLO world");
});
});
describe("formatSeconds", () => {
it("should format seconds as mm:ss", () => {
expect($util.formatSeconds(0)).toBe("0:00");
expect($util.formatSeconds(1)).toBe("0:01");
expect($util.formatSeconds(10)).toBe("0:10");
expect($util.formatSeconds(60)).toBe("1:00");
expect($util.formatSeconds(65)).toBe("1:05");
expect($util.formatSeconds(125)).toBe("2:05");
});
it("should handle negative or falsy values", () => {
expect($util.formatSeconds(-1)).toBe("0:00");
expect($util.formatSeconds(null)).toBe("0:00");
expect($util.formatSeconds(undefined)).toBe("0:00");
});
});
});

View File

@@ -0,0 +1,100 @@
export default {
mode: "user",
name: "PhotoPrism",
about: "PhotoPrism® CE",
edition: "ce",
version: "210710-bae1f2d7-Linux-x86_64-DEBUG",
copyright: "(c) 2018-2025 PhotoPrism UG. All rights reserved.",
flags: "public debug develop experimental settings",
baseUri: "",
staticUri: "/static",
apiUri: "/api/v1",
contentUri: "/api/v1",
siteUrl: "http://localhost:2342/",
sitePreview: "http://localhost:2342/static/img/preview.jpg",
siteTitle: "PhotoPrism",
siteCaption: "AI-Powered Photos App",
siteDescription: "Open-Source Photo Management",
siteAuthor: "@photoprism_app",
debug: false,
readonly: false,
uploadNSFW: false,
public: false,
develop: true,
experimental: true,
disableSettings: false,
test: true,
demo: false,
sponsor: true,
albumCategories: ["Animal", "Holiday"],
albums: [
{
ID: 69,
UID: "aqw0vmr32zb4560f",
Slug: "test-album-1",
Type: "album",
Title: "Test Album 1",
Favorite: true,
Private: false,
},
{
ID: 70,
UID: "aqw0vmzrkc202vty",
Slug: "test-album-2",
Type: "album",
Title: "Test Album 2",
Favorite: true,
Private: false,
},
],
cameras: [
{
ID: 7,
Slug: "apple-iphone-se",
Name: "Apple iPhone SE",
Make: "Apple",
Model: "iPhone SE",
},
{
ID: 2,
Slug: "canon-eos-6d",
Name: "Canon EOS 6D",
Make: "Canon",
Model: "EOS 6D",
},
],
mapKey: "D9ve6edlcVR2mEsNvCXa",
downloadToken: "2lbh9x09",
previewToken: "public",
settings: {
ui: {
scrollbar: true,
zoom: false,
theme: "default",
language: "en",
},
search: {
batchSize: 90,
},
maps: {
animate: 0,
style: "streets",
},
features: {
upload: true,
download: true,
private: true,
review: false,
files: true,
videos: true,
folders: true,
albums: true,
moments: true,
places: true,
edit: true,
share: true,
library: true,
import: true,
},
},
};

View File

@@ -0,0 +1,138 @@
import { vi } from "vitest";
import { Settings } from "luxon";
Settings.defaultLocale = "en";
Settings.defaultZoneName = "UTC";
// Mock Config
export const mockConfig = {
contentUri: "/api/v1",
previewToken: "public",
apiUri: "/api/v1",
baseUri: "",
staticUri: "/static",
downloadToken: "2lbh9x09",
mode: "user",
debug: false,
};
// Mock RestModel
export class MockRestModel {
constructor(values) {
this.__originalValues = {};
this.setValues(values || {});
}
setValues(values) {
if (!values) return this;
for (let key in values) {
if (values.hasOwnProperty(key)) {
this[key] = values[key];
this.__originalValues[key] = values[key];
}
}
return this;
}
getId() {
return this.UID || this.ID;
}
getValues() {
return { ...this.__originalValues };
}
getEntityResource() {
return `${this.constructor.getCollectionResource()}/${this.getId()}`;
}
update() {
return Promise.resolve({ success: "ok" });
}
static getCollectionResource() {
return "items";
}
}
// API Response Helpers
export const mockApiResponse = (data) => {
return { data };
};
export const mockPutResponse = (data = { success: "ok" }) => {
return vi.fn().mockResolvedValue(mockApiResponse(data));
};
export const mockDeleteResponse = (data = { success: "ok" }) => {
return vi.fn().mockResolvedValue(mockApiResponse(data));
};
export const mockGetResponse = (data) => {
return vi.fn().mockResolvedValue(mockApiResponse(data));
};
export const mockPostResponse = (data = { success: "ok" }) => {
return vi.fn().mockResolvedValue(mockApiResponse(data));
};
// Global mock variables
export const apiMock = {
put: mockPutResponse(),
delete: mockDeleteResponse(),
get: mockGetResponse(),
post: mockPostResponse(),
};
// Setup common mocks
export const setupCommonMocks = () => {
// Mock Model
vi.mock("model/rest", () => ({
default: MockRestModel,
}));
// Mock API
vi.mock("common/api", () => ({
default: apiMock,
}));
// Mock session
vi.mock("app/session", () => ({
$config: mockConfig,
}));
// Mock gettext
vi.mock("common/gettext", () => ({
$gettext: vi.fn((text) => text),
}));
};
// Setup common headers
export const mockHeaders = {
"Content-Type": "application/json; charset=utf-8",
};
export const setupMarkerMocks = () => {
apiMock.put.mockImplementation((url, data) => {
if (url.includes("markers/mBC123ghytr")) {
return Promise.resolve({ data: { success: "ok" } });
} else if (url.includes("markers/mCC123ghytr")) {
return Promise.resolve({ data: { success: "ok" } });
} else if (url.includes("markers/mDC123ghytr")) {
return Promise.resolve({ data: { success: "ok", Name: "testname" } });
}
return Promise.resolve({ data: { success: "ok" } });
});
apiMock.delete.mockImplementation((url) => {
if (url.includes("markers/mEC123ghytr/subject")) {
return Promise.resolve({ data: { success: "ok" } });
}
return Promise.resolve({ data: { success: "ok" } });
});
};
export default { setupCommonMocks, setupMarkerMocks };

View File

@@ -0,0 +1,213 @@
import { describe, it, expect, beforeEach } from "vitest";
import { Marker, BatchSize } from "model/marker";
import { setupMarkerMocks } from "../fixtures";
beforeEach(() => {
setupMarkerMocks();
});
describe("model/marker", () => {
it("should get marker defaults", () => {
const values = { FileUID: "fghjojp" };
const marker = new Marker(values);
const result = marker.getDefaults();
expect(result.UID).toBe("");
expect(result.FileUID).toBe("");
});
it("should get route view", () => {
const values = { UID: "ABC123ghytr", FileUID: "fhjouohnnmnd", Type: "face", Src: "image" };
const marker = new Marker(values);
const result = marker.route("test");
expect(result.name).toBe("test");
expect(result.query.q).toBe("marker:ABC123ghytr");
});
it("should return classes", () => {
const values = { UID: "ABC123ghytr", FileUID: "fhjouohnnmnd", Type: "face", Src: "image" };
const marker = new Marker(values);
const result = marker.classes(true);
expect(result).toContain("is-marker");
expect(result).toContain("uid-ABC123ghytr");
expect(result).toContain("is-selected");
expect(result).not.toContain("is-review");
expect(result).not.toContain("is-invalid");
const result2 = marker.classes(false);
expect(result2).toContain("is-marker");
expect(result2).toContain("uid-ABC123ghytr");
expect(result2).not.toContain("is-selected");
expect(result2).not.toContain("is-review");
expect(result2).not.toContain("is-invalid");
const values2 = {
UID: "mBC123ghytr",
FileUID: "fhjouohnnmnd",
Type: "face",
Src: "image",
Invalid: true,
Review: true,
};
const marker2 = new Marker(values2);
const result3 = marker2.classes(true);
expect(result3).toContain("is-marker");
expect(result3).toContain("uid-mBC123ghytr");
expect(result3).toContain("is-selected");
expect(result3).toContain("is-review");
expect(result3).toContain("is-invalid");
});
it("should get marker entity name", () => {
const values = {
UID: "ABC123ghytr",
FileUID: "fhjouohnnmnd",
Type: "face",
Src: "image",
Name: "test",
};
const marker = new Marker(values);
const result = marker.getEntityName();
expect(result).toBe("test");
});
it("should get marker title", () => {
const values = {
UID: "ABC123ghytr",
FileUID: "fhjouohnnmnd",
Type: "face",
Src: "image",
Name: "test",
};
const marker = new Marker(values);
const result = marker.getTitle();
expect(result).toBe("test");
});
it("should get thumbnail url", () => {
const values = { UID: "ABC123ghytr", FileUID: "fhjouohnnmnd", Type: "face", Src: "image" };
const marker = new Marker(values);
const result = marker.thumbnailUrl("xyz");
expect(result).toBe("/api/v1/svg/portrait");
const values2 = {
UID: "ABC123ghytr",
FileUID: "fhjouohnnmnd",
Type: "face",
Src: "image",
Thumb: "nicethumbuid",
};
const marker2 = new Marker(values2);
const result2 = marker2.thumbnailUrl();
expect(result2).toBe("/api/v1/t/nicethumbuid/public/tile_160");
});
it("should get date string", () => {
const values = {
UID: "ABC123ghytr",
FileUID: "fhjouohnnmnd",
Type: "face",
Src: "image",
CreatedAt: "2012-07-08T14:45:39Z",
};
const marker = new Marker(values);
const result = marker.getDateString();
expect(result).toBe("2023-10-01 10:00:00");
});
it("should approve marker", () => {
const values = {
UID: "mBC123ghytr",
FileUID: "fhjouohnnmnd",
Type: "face",
Src: "image",
Invalid: true,
Review: true,
};
const marker = new Marker(values);
expect(marker.Review).toBe(true);
expect(marker.Invalid).toBe(true);
marker.approve();
expect(marker.Review).toBe(false);
expect(marker.Invalid).toBe(false);
});
it("should reject marker", () => {
const values = {
UID: "mCC123ghytr",
FileUID: "fhjouohnnmnd",
Type: "face",
Src: "image",
Invalid: false,
Review: true,
};
const marker = new Marker(values);
expect(marker.Review).toBe(true);
expect(marker.Invalid).toBe(false);
marker.reject();
expect(marker.Review).toBe(false);
expect(marker.Invalid).toBe(true);
});
it("should rename marker", async () => {
const values = {
UID: "mDC123ghytr",
FileUID: "fhjouohnnmnd",
Type: "face",
Src: "image",
Subject: "skhljkpigh",
Name: "",
SubjSrc: "manual",
};
const marker = new Marker(values);
expect(marker.Name).toBe("");
marker.setName();
expect(marker.Name).toBe("");
const values2 = {
UID: "mDC123ghytr",
FileUID: "fhjouohnnmnd",
Type: "face",
Src: "image",
Subject: "skhljkpigh",
Name: "testname",
SubjSrc: "manual",
};
const marker2 = new Marker(values2);
expect(marker2.Name).toBe("testname");
const response = await marker2.setName();
expect(response.success).toBe("ok");
});
it("should clear subject", async () => {
const values = {
UID: "mEC123ghytr",
FileUID: "fhjouohnnmnd",
Type: "face",
Src: "image",
Subject: "skhljkpigh",
Name: "testname",
SubjSrc: "manual",
};
const marker = new Marker(values);
const response = await marker.clearSubject();
expect(response.success).toBe("ok");
});
it("should return batch size", () => {
expect(Marker.batchSize()).toBe(BatchSize);
Marker.setBatchSize(30);
expect(Marker.batchSize()).toBe(30);
Marker.setBatchSize(BatchSize);
});
it("should get collection resource", () => {
const result = Marker.getCollectionResource();
expect(result).toBe("markers");
});
it("should get model name", () => {
const result = Marker.getModelName();
expect(result).toBe("Marker");
});
});

View File

@@ -0,0 +1,59 @@
import "@testing-library/jest-dom";
import { cleanup } from "@testing-library/react";
import { afterEach, vi, beforeAll } from "vitest";
import { setupCommonMocks } from "./fixtures";
global.window = global.window || {};
global.window.__CONFIG__ = {
debug: false,
trace: false,
};
global.window.location = {
protocol: "https:",
};
global.navigator = {
userAgent: "node.js",
maxTouchPoints: 0,
};
afterEach(() => {
cleanup();
});
beforeAll(() => {
setupCommonMocks();
});
vi.mock("luxon", () => ({
DateTime: {
fromISO: vi.fn().mockReturnValue({
toLocaleString: vi.fn().mockReturnValue("2023-10-01 10:00:00"),
}),
DATETIME_MED: {},
DATETIME_MED_WITH_WEEKDAY: {},
DATE_MED: {},
TIME_24_SIMPLE: {},
},
Settings: {
defaultLocale: "en",
defaultZoneName: "UTC",
},
}));
vi.mock("common/gettext", () => ({
$gettext: vi.fn((text) => text),
}));
vi.mock("app/session", () => ({
$config: {},
}));
vi.mock("common/notify", () => ({
default: {
success: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));

27
frontend/vitest.config.js Normal file
View File

@@ -0,0 +1,27 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import path from "path";
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
globals: true,
environment: "jsdom",
setupFiles: "./tests/vitest/setup.js",
include: ["tests/vitest/**/*.{test,spec}.{js,jsx}"],
coverage: {
reporter: ["text", "html"],
include: ["src/**/*.{js,jsx}"],
exclude: ["src/locales/**"],
},
alias: {
app: path.resolve(__dirname, "./src/app"),
common: path.resolve(__dirname, "./src/common"),
component: path.resolve(__dirname, "./src/component"),
model: path.resolve(__dirname, "./src/model"),
options: path.resolve(__dirname, "./src/options"),
page: path.resolve(__dirname, "./src/page"),
},
},
});