From a9b7e22a133046772bf4eaece6384ac7bef3975c Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 21 Apr 2026 09:00:20 -0300 Subject: [PATCH] test(bstein-home): cover ai and frontend entry --- testing/frontend/jest.config.cjs | 10 +- testing/frontend/mocks/style.js | 1 + testing/frontend/unit/ai-view.spec.js | 181 +++++++++++++++++++++ testing/frontend/unit/entry-router.spec.js | 68 ++++++++ 4 files changed, 253 insertions(+), 7 deletions(-) create mode 100644 testing/frontend/mocks/style.js create mode 100644 testing/frontend/unit/ai-view.spec.js create mode 100644 testing/frontend/unit/entry-router.spec.js diff --git a/testing/frontend/jest.config.cjs b/testing/frontend/jest.config.cjs index e2cede1..cc32942 100644 --- a/testing/frontend/jest.config.cjs +++ b/testing/frontend/jest.config.cjs @@ -15,6 +15,7 @@ module.exports = { }, moduleNameMapper: { "^@/assets/.*\\.(jpg|jpeg|png|gif|webp|svg)$": path.resolve(__dirname, "mocks/file.js"), + "\\.(css)$": path.resolve(__dirname, "mocks/style.js"), "\\.(jpg|jpeg|png|gif|webp|svg)$": path.resolve(__dirname, "mocks/file.js"), "^@/(.*)$": path.resolve(frontendRoot, "src/$1"), "^vue$": path.resolve(frontendRoot, "node_modules/vue/dist/vue.cjs.js"), @@ -24,13 +25,8 @@ module.exports = { }, coverageProvider: "v8", collectCoverageFrom: [ - "src/auth.js", - "src/components/MetricRow.vue", - "src/components/MermaidCard.vue", - "src/components/ServiceGrid.vue", - "src/components/StatsGrid.vue", - "src/data/sample.js", - "src/views/HomeView.vue", + "src/**/*.js", + "src/**/*.vue", ], coverageReporters: ["text", "lcov", "json-summary"], }; diff --git a/testing/frontend/mocks/style.js b/testing/frontend/mocks/style.js new file mode 100644 index 0000000..f053ebf --- /dev/null +++ b/testing/frontend/mocks/style.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/testing/frontend/unit/ai-view.spec.js b/testing/frontend/unit/ai-view.spec.js new file mode 100644 index 0000000..7e63476 --- /dev/null +++ b/testing/frontend/unit/ai-view.spec.js @@ -0,0 +1,181 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { flushPromises, mount } from "@vue/test-utils"; +import { ReadableStream } from "node:stream/web"; +import { TextDecoder, TextEncoder } from "node:util"; + +import AiView from "../../../frontend/src/views/AiView.vue"; + +function jsonResponse(body, status = 200, statusText = "OK") { + return new Response(JSON.stringify(body), { + status, + statusText, + headers: { "content-type": "application/json" }, + }); +} + +function streamResponse(chunks) { + const encoder = new TextEncoder(); + return new Response(new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)); + controller.close(); + }, + }), { + status: 200, + headers: { "content-type": "text/plain" }, + }); +} + +function installFetch(handler) { + global.fetch = jest.fn(async (url, options = {}) => handler(String(url), options)); +} + +describe("AI chat view", () => { + beforeEach(() => { + localStorage.clear(); + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: () => "uuid-1" }, + }); + Object.defineProperty(globalThis, "TextDecoder", { + configurable: true, + value: TextDecoder, + }); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: jest.fn(async () => {}) }, + }); + Object.defineProperty(performance, "now", { + configurable: true, + value: jest.fn() + .mockReturnValueOnce(100) + .mockReturnValueOnce(145) + .mockReturnValue(200), + }); + installFetch((url) => { + if (url.includes("/api/ai/info")) { + return jsonResponse({ + model: url.includes("atlas-smart") ? "smart-model" : "quick-model", + gpu: "titan-24", + node: "titan-24", + endpoint: "https://ai.example.dev/chat", + }); + } + return jsonResponse({ reply: "typed assistant response", latency_ms: 42 }); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + localStorage.clear(); + Reflect.deleteProperty(global, "fetch"); + Reflect.deleteProperty(globalThis, "__ATLAS_IMPORT_META_ENV__"); + }); + + it("loads profile metadata, switches profiles, and copies curl examples", async () => { + const wrapper = mount(AiView); + await flushPromises(); + + expect(wrapper.text()).toContain("quick-model"); + expect(wrapper.text()).toContain("titan-24"); + + await wrapper.find(".endpoint-copy").trigger("click"); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(expect.stringContaining("curl -X POST https://ai.example.dev/chat")); + expect(wrapper.text()).toContain("copied"); + + await wrapper.findAll(".profile-tab").find((button) => button.text() === "Atlas Smart").trigger("click"); + await flushPromises(); + expect(wrapper.text()).toContain("smart-model"); + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: jest.fn(async () => { throw new Error("blocked"); }) }, + }); + await wrapper.find(".endpoint-copy").trigger("click"); + expect(wrapper.text()).not.toContain("copied"); + + wrapper.unmount(); + }); + + it("sends JSON chat requests and reveals typed responses", async () => { + const bodies = []; + installFetch((url, options) => { + if (url.includes("/api/ai/info")) return jsonResponse({ model: "quick-model" }); + bodies.push(JSON.parse(options.body)); + return jsonResponse({ reply: "typed assistant response", latency_ms: 42 }); + }); + const wrapper = mount(AiView); + await flushPromises(); + + await wrapper.find("textarea").setValue(" hello atlas "); + await wrapper.find("form").trigger("submit.prevent"); + await new Promise((resolve) => setTimeout(resolve, 80)); + await flushPromises(); + + expect(wrapper.text()).toContain("hello atlas"); + expect(wrapper.text()).toContain("typed assistant response"); + expect(wrapper.text()).toContain("42 ms"); + expect(bodies[0]).toMatchObject({ + message: "hello atlas", + profile: "atlas-quick", + conversation_id: expect.stringContaining("atlas-quick"), + }); + expect(localStorage.getItem("atlas-ai-conversation:atlas-quick")).toContain("uuid-1"); + + await wrapper.find("textarea").setValue("second"); + await wrapper.find("form").trigger("submit.prevent"); + await new Promise((resolve) => setTimeout(resolve, 80)); + expect(bodies[1].history.map((item) => item.role)).toContain("assistant"); + + wrapper.unmount(); + }); + + it("handles streaming responses and keyboard submission", async () => { + const seen = []; + installFetch((url, options) => { + if (url.includes("/api/ai/info")) return jsonResponse({}, 204, "No Content"); + seen.push(JSON.parse(options.body)); + return streamResponse(["stream ", "reply"]); + }); + const wrapper = mount(AiView); + await flushPromises(); + + const preventDefault = jest.fn(); + await wrapper.find("textarea").setValue("stream please"); + await wrapper.find("textarea").trigger("keydown", { key: "Enter", shiftKey: false, preventDefault }); + await flushPromises(); + expect(preventDefault).toHaveBeenCalled(); + expect(wrapper.text()).toContain("stream reply"); + expect(seen).toHaveLength(1); + + await wrapper.find("textarea").setValue("line one"); + await wrapper.find("textarea").trigger("keydown", { key: "Enter", shiftKey: true, preventDefault: jest.fn() }); + await flushPromises(); + expect(seen).toHaveLength(1); + + wrapper.unmount(); + }); + + it("shows request failures without losing the conversation", async () => { + installFetch((url) => { + if (url.includes("/api/ai/info")) throw new Error("info offline"); + return jsonResponse({ error: "model offline" }, 503, "Service Unavailable"); + }); + const wrapper = mount(AiView); + await flushPromises(); + + await wrapper.find("textarea").setValue("break"); + await wrapper.find("form").trigger("submit.prevent"); + await flushPromises(); + + expect(wrapper.text()).toContain("model offline"); + expect(wrapper.text()).toContain("(no response)"); + + await wrapper.find("textarea").setValue(" "); + await wrapper.find("form").trigger("submit.prevent"); + await flushPromises(); + expect(fetch).toHaveBeenCalledTimes(2); + + wrapper.unmount(); + }); +}); diff --git a/testing/frontend/unit/entry-router.spec.js b/testing/frontend/unit/entry-router.spec.js new file mode 100644 index 0000000..451d3a4 --- /dev/null +++ b/testing/frontend/unit/entry-router.spec.js @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it, jest } from "@jest/globals"; + +describe("frontend entrypoint and router", () => { + afterEach(() => { + jest.resetModules(); + jest.restoreAllMocks(); + }); + + it("registers the router, mounts the app, and starts auth", async () => { + const use = jest.fn(); + const mount = jest.fn(); + const createApp = jest.fn(() => ({ use, mount })); + const initAuth = jest.fn(); + const router = { name: "router" }; + const app = { name: "App" }; + + jest.doMock("vue", () => ({ createApp })); + jest.doMock("../../../frontend/src/App.vue", () => ({ __esModule: true, default: app })); + jest.doMock("../../../frontend/src/router", () => ({ __esModule: true, default: router })); + jest.doMock("../../../frontend/src/auth", () => ({ initAuth })); + + await import("../../../frontend/src/main.js"); + + expect(createApp).toHaveBeenCalledWith(app); + expect(use).toHaveBeenCalledWith(router); + expect(mount).toHaveBeenCalledWith("#app"); + expect(initAuth).toHaveBeenCalledTimes(1); + }); + + it("declares the application routes and ai redirect", async () => { + jest.dontMock("../../../frontend/src/router"); + for (const view of [ + "HomeView", + "AboutView", + "AiView", + "AiPlanView", + "MoneroView", + "AppsView", + "AccountView", + "RequestAccessView", + "OnboardingView", + ]) { + jest.doMock(`../../../frontend/src/views/${view}.vue`, () => ({ __esModule: true, default: { name: view } })); + } + const createWebHistory = jest.fn(() => ({ mode: "history" })); + const createRouter = jest.fn((config) => ({ ...config, ready: true })); + jest.doMock("vue-router", () => ({ createRouter, createWebHistory }), { virtual: true }); + + const router = (await import("../../../frontend/src/router.js")).default; + + expect(createWebHistory).toHaveBeenCalledTimes(1); + expect(createRouter).toHaveBeenCalledWith(expect.objectContaining({ history: { mode: "history" } })); + expect(router.routes.map((route) => route.path)).toEqual([ + "/", + "/about", + "/ai", + "/ai/chat", + "/ai/roadmap", + "/monero", + "/apps", + "/account", + "/request-access", + "/onboarding", + ]); + expect(router.routes.find((route) => route.path === "/ai")).toMatchObject({ redirect: "/ai/chat" }); + expect(router.routes.find((route) => route.name === "account").component).toBeDefined(); + }); +});