feat: make each plugin an npm workspace built by a termix-plugin CLI

This commit is contained in:
LukeGus committed 2026-09-22 15:09:50 -05:00
1 parent 4758016f41
commit 5796186a1c
448 files changed
+3087 -2003

No files matched your search

@@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import type { AiProvider } from "@/api/ai-api";
const api = vi.hoisted(() => ({
createAiProvider: vi.fn(),
deleteAiProvider: vi.fn(),
getAiProviderModels: vi.fn(),
probeAiModels: vi.fn(),
updateAiProvider: vi.fn(),
}));
vi.mock("@/api/ai-api", () => api);
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
import { AiProviderSettings } from "@/features/ai/AiProviderSettings";
const provider: AiProvider = {
id: 7,
providerType: "ollama",
label: "Local Ollama",
baseUrl: "http://localhost:11434",
apiKeyPrefix: null,
defaultModel: "llama3.1",
enabled: true,
createdAt: "2026-08-21T00:00:00Z",
};
beforeEach(() => {
api.getAiProviderModels.mockReset();
api.getAiProviderModels.mockResolvedValue([]);
api.updateAiProvider.mockReset();
api.updateAiProvider.mockResolvedValue(provider);
});
afterEach(cleanup);
describe("AiProviderSettings", () => {
it("edits an existing provider name and configured model", async () => {
const onChanged = vi.fn();
render(<AiProviderSettings providers={[provider]} onChanged={onChanged} />);
expect(screen.getByText(/llama3\.1/)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "ai.editProvider" }));
const label = screen.getByLabelText("ai.providerLabel");
const model = screen.getByLabelText("ai.defaultModel");
fireEvent.change(label, { target: { value: "Production Ollama" } });
fireEvent.change(model, { target: { value: "qwen3:32b" } });
fireEvent.click(screen.getByRole("button", { name: "ai.save" }));
await waitFor(() => {
expect(api.updateAiProvider).toHaveBeenCalledWith(7, {
label: "Production Ollama",
defaultModel: "qwen3:32b",
});
});
expect(onChanged).toHaveBeenCalledWith(7);
});
it("loads the saved provider model list when editing starts", async () => {
api.getAiProviderModels.mockResolvedValue(["llama3.1", "qwen3:32b"]);
render(<AiProviderSettings providers={[provider]} onChanged={() => {}} />);
fireEvent.click(screen.getByRole("button", { name: "ai.editProvider" }));
await waitFor(() => {
expect(api.getAiProviderModels).toHaveBeenCalledWith(7);
});
expect(
screen.getByRole("combobox", { name: "ai.defaultModel" }),
).toBeTruthy();
});
it("shows the provider error when refreshing models fails", async () => {
api.getAiProviderModels.mockRejectedValue(
new Error("Add llm.internal to the AI endpoint allowlist"),
);
render(<AiProviderSettings providers={[provider]} onChanged={() => {}} />);
fireEvent.click(screen.getByRole("button", { name: "ai.editProvider" }));
expect(
await screen.findByText("Add llm.internal to the AI endpoint allowlist"),
).toBeTruthy();
});
});
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import {
fieldLabel,
humanize,
mentionLabel,
toolLabel,
} from "@/features/ai/labels";
import { AI_TOOLS } from "../../src/backend/tools/catalog.js";
describe("humanize", () => {
it("splits camelCase into words", () => {
// The case that shipped as "thresholddurationseconds".
expect(humanize("thresholdDurationSeconds")).toBe(
"Threshold duration seconds",
);
});
it("splits snake_case into words", () => {
expect(humanize("propose_create_automation")).toBe(
"Propose create automation",
);
});
it("leaves a single word alone apart from casing", () => {
expect(humanize("port")).toBe("Port");
});
it("returns the input when there is nothing to split", () => {
expect(humanize("")).toBe("");
});
});
describe("fieldLabel", () => {
it("uses the mapped name where there is one", () => {
expect(fieldLabel("thresholdDurationSeconds")).toBe("Duration (seconds)");
expect(fieldLabel("cooldownMinutes")).toBe("Cooldown (minutes)");
expect(fieldLabel("ip")).toBe("Address");
});
it("falls back to humanizing an unmapped key", () => {
expect(fieldLabel("someFutureField")).toBe("Some future field");
});
it("never returns a run-on identifier", () => {
for (const key of ["thresholdDurationSeconds", "hostIds", "triggerType"]) {
expect(fieldLabel(key)).not.toBe(key.toLowerCase());
}
});
});
describe("toolLabel", () => {
it("phrases tools as actions rather than function names", () => {
expect(toolLabel("list_hosts")).toBe("Reading hosts");
expect(toolLabel("propose_create_fleet")).toBe("Create fleet");
});
it("covers every tool in the catalog", () => {
// A new tool without a label would surface its raw snake_case name.
for (const tool of AI_TOOLS) {
const label = toolLabel(tool.name);
expect(label, tool.name).not.toContain("_");
expect(label, tool.name).not.toBe(tool.name);
}
});
});
describe("mentionLabel", () => {
it("capitalises the mention kinds", () => {
expect(mentionLabel("host")).toBe("Host");
expect(mentionLabel("snippet")).toBe("Snippet");
expect(mentionLabel("automation")).toBe("Automation");
});
});