test(bstein-home): cover ai and frontend entry
This commit is contained in:
parent
3675302596
commit
a9b7e22a13
@ -15,6 +15,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
"^@/assets/.*\\.(jpg|jpeg|png|gif|webp|svg)$": path.resolve(__dirname, "mocks/file.js"),
|
"^@/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"),
|
"\\.(jpg|jpeg|png|gif|webp|svg)$": path.resolve(__dirname, "mocks/file.js"),
|
||||||
"^@/(.*)$": path.resolve(frontendRoot, "src/$1"),
|
"^@/(.*)$": path.resolve(frontendRoot, "src/$1"),
|
||||||
"^vue$": path.resolve(frontendRoot, "node_modules/vue/dist/vue.cjs.js"),
|
"^vue$": path.resolve(frontendRoot, "node_modules/vue/dist/vue.cjs.js"),
|
||||||
@ -24,13 +25,8 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
coverageProvider: "v8",
|
coverageProvider: "v8",
|
||||||
collectCoverageFrom: [
|
collectCoverageFrom: [
|
||||||
"src/auth.js",
|
"src/**/*.js",
|
||||||
"src/components/MetricRow.vue",
|
"src/**/*.vue",
|
||||||
"src/components/MermaidCard.vue",
|
|
||||||
"src/components/ServiceGrid.vue",
|
|
||||||
"src/components/StatsGrid.vue",
|
|
||||||
"src/data/sample.js",
|
|
||||||
"src/views/HomeView.vue",
|
|
||||||
],
|
],
|
||||||
coverageReporters: ["text", "lcov", "json-summary"],
|
coverageReporters: ["text", "lcov", "json-summary"],
|
||||||
};
|
};
|
||||||
|
|||||||
1
testing/frontend/mocks/style.js
Normal file
1
testing/frontend/mocks/style.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
module.exports = {};
|
||||||
181
testing/frontend/unit/ai-view.spec.js
Normal file
181
testing/frontend/unit/ai-view.spec.js
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
68
testing/frontend/unit/entry-router.spec.js
Normal file
68
testing/frontend/unit/entry-router.spec.js
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user