diff --git a/testing/frontend/jest.config.cjs b/testing/frontend/jest.config.cjs
index 951e41b..7ad6b7c 100644
--- a/testing/frontend/jest.config.cjs
+++ b/testing/frontend/jest.config.cjs
@@ -14,6 +14,9 @@ module.exports = {
"^.+\\.[cm]?js$": "babel-jest",
},
moduleNameMapper: {
+ "^@/assets/.*\\.(jpg|jpeg|png|gif|webp|svg)$": path.resolve(__dirname, "mocks/file.js"),
+ "\\.(jpg|jpeg|png|gif|webp|svg)$": path.resolve(__dirname, "mocks/file.js"),
+ "^@/(.*)$": path.resolve(frontendRoot, "src/$1"),
"^@vue/test-utils$": path.resolve(frontendRoot, "node_modules/@vue/test-utils/dist/vue-test-utils.cjs.js"),
"^keycloak-js$": path.resolve(__dirname, "mocks/keycloak-js.js"),
"^mermaid$": path.resolve(__dirname, "mocks/mermaid.js"),
diff --git a/testing/frontend/mocks/file.js b/testing/frontend/mocks/file.js
new file mode 100644
index 0000000..0a445d0
--- /dev/null
+++ b/testing/frontend/mocks/file.js
@@ -0,0 +1 @@
+module.exports = "test-file-stub";
diff --git a/testing/frontend/unit/static-views.spec.js b/testing/frontend/unit/static-views.spec.js
new file mode 100644
index 0000000..5b059bd
--- /dev/null
+++ b/testing/frontend/unit/static-views.spec.js
@@ -0,0 +1,178 @@
+import { afterEach, describe, expect, it, jest } from "@jest/globals";
+import { flushPromises, mount, shallowMount } from "@vue/test-utils";
+
+import axios from "axios";
+
+import App from "../../../frontend/src/App.vue";
+import HeroSection from "../../../frontend/src/components/HeroSection.vue";
+import MetricsPanel from "../../../frontend/src/components/MetricsPanel.vue";
+import TopBar from "../../../frontend/src/components/TopBar.vue";
+import { auth } from "../../../frontend/src/auth.js";
+import AboutView from "../../../frontend/src/views/AboutView.vue";
+import AiPlanView from "../../../frontend/src/views/AiPlanView.vue";
+import AppsView from "../../../frontend/src/views/AppsView.vue";
+import MoneroView from "../../../frontend/src/views/MoneroView.vue";
+
+const mockRouterPush = jest.fn();
+
+jest.mock("axios", () => ({
+ __esModule: true,
+ default: {
+ get: jest.fn(),
+ },
+}), { virtual: true });
+
+jest.mock("vue-router", () => ({
+ RouterLink: {
+ name: "RouterLink",
+ props: ["to"],
+ template: "",
+ },
+ useRouter: () => ({ push: mockRouterPush }),
+}), { virtual: true });
+
+describe("static shell views and components", () => {
+ afterEach(() => {
+ jest.restoreAllMocks();
+ jest.useRealTimers();
+ mockRouterPush.mockClear();
+ auth.enabled = false;
+ auth.authenticated = false;
+ auth.resetUrl = "";
+ });
+
+ it("renders the simple static pages", () => {
+ const about = shallowMount(AboutView);
+ expect(about.text()).toContain("About Me");
+ expect(about.text()).toContain("Titan Lab");
+ expect(about.vm.skills).toContain("Kubernetes (k3s/k8s)");
+
+ const apps = shallowMount(AppsView);
+ expect(apps.text()).toContain("Apps");
+ expect(apps.text()).toContain("Nextcloud");
+ expect(apps.vm.sections.map((section) => section.title)).toContain("Security");
+
+ const aiPlan = shallowMount(AiPlanView);
+ expect(aiPlan.text()).toContain("Roadmap");
+ expect(aiPlan.text()).toContain("AI Image");
+ });
+
+ it("renders hero and metrics panels with status variants", () => {
+ const loadingHero = shallowMount(HeroSection, {
+ props: {
+ title: "Atlas",
+ subtitle: "Homelab",
+ links: [{ label: "Metrics", href: "https://metrics.example.dev" }],
+ loading: true,
+ error: "",
+ },
+ });
+ expect(loadingHero.text()).toContain("Loading live data");
+
+ const errorHero = shallowMount(HeroSection, {
+ props: { title: "Atlas", subtitle: "Homelab", links: [], loading: false, error: "offline" },
+ });
+ expect(errorHero.text()).toContain("offline");
+
+ const connectedHero = shallowMount(HeroSection, {
+ props: { title: "Atlas", subtitle: "Homelab", links: [], loading: false, error: "" },
+ });
+ expect(connectedHero.text()).toContain("Live data connected");
+
+ const metrics = shallowMount(MetricsPanel, {
+ props: {
+ metrics: {
+ dashboard: "https://metrics.example.dev/d/atlas",
+ description: "Live Atlas metrics",
+ },
+ },
+ });
+ expect(metrics.find("iframe").attributes("src")).toBe("https://metrics.example.dev/d/atlas");
+ expect(metrics.text()).toContain("Live Atlas metrics");
+ });
+
+ it("drives top bar navigation and auth state rendering", async () => {
+ auth.enabled = true;
+ auth.authenticated = false;
+ auth.resetUrl = "https://sso.example.dev/reset";
+ const login = jest.spyOn(await import("../../../frontend/src/auth.js"), "login").mockImplementation(() => {});
+ const logout = jest.spyOn(await import("../../../frontend/src/auth.js"), "logout").mockImplementation(() => {});
+
+ const wrapper = mount(TopBar);
+ expect(wrapper.text()).toContain("Login");
+ expect(wrapper.text()).toContain("Request Access");
+ await wrapper.find(".profile").trigger("click");
+ expect(mockRouterPush).toHaveBeenCalledWith("/about");
+
+ await wrapper.find("button").trigger("click");
+ expect(login).toHaveBeenCalled();
+
+ auth.authenticated = true;
+ await wrapper.vm.$nextTick();
+ expect(wrapper.text()).toContain("Account");
+ await wrapper.find("button").trigger("click");
+ expect(logout).toHaveBeenCalled();
+ });
+
+ it("loads Monero status and handles API failures", async () => {
+ axios.get.mockResolvedValueOnce({
+ data: { nettype: "mainnet", status: "OK", height: 123, target_height: 125 },
+ });
+ const ok = mount(MoneroView);
+ await flushPromises();
+ expect(ok.text()).toContain("mainnet");
+ expect(ok.text()).toContain("123");
+
+ axios.get.mockRejectedValueOnce(new Error("offline"));
+ const failed = mount(MoneroView);
+ await flushPromises();
+ expect(failed.text()).toContain("Could not reach monerod");
+ });
+
+ it("refreshes the app shell status and handles fetch failures", async () => {
+ jest.useFakeTimers();
+ jest.spyOn(window, "setTimeout");
+ jest.spyOn(window, "clearTimeout");
+ global.fetch = jest.fn(async () =>
+ new Response(JSON.stringify({ connected: true, atlas: { up: true } }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ );
+
+ const app = mount(App, {
+ global: {
+ stubs: {
+ TopBar: true,
+ "router-view": {
+ props: ["labStatus", "loading", "error"],
+ template: "
{{ loading }} {{ error }} {{ labStatus?.connected }}
",
+ },
+ },
+ },
+ });
+ await flushPromises();
+ expect(app.text()).toContain("true");
+
+ await app.unmount();
+
+ global.fetch = jest.fn(async () => {
+ const err = new Error("aborted");
+ err.name = "AbortError";
+ throw err;
+ });
+ const failed = mount(App, {
+ global: {
+ stubs: {
+ TopBar: true,
+ "router-view": {
+ props: ["labStatus", "loading", "error"],
+ template: "{{ loading }} {{ error }}
",
+ },
+ },
+ },
+ });
+ await flushPromises();
+ expect(failed.text()).toContain("Live data timed out");
+ });
+});