feat: finish the homepage plugin, move service links and canvas out of core (B19)

This commit is contained in:
LukeGus committed 2026-09-24 15:07:25 -05:00
1 parent 21937a6b02
commit d4ec444558
256 files changed
+27365 -9077

No files matched your search

@@ -0,0 +1,184 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useTranslation: () => ({ t: (key: string) => key, language: "en" }),
}));
import { ClockWidget } from "../../src/frontend/widgets/ClockWidget.js";
import { ClockEditForm } from "../../src/frontend/dialogs/ClockEditForm.js";
import {
normalizeTimezone,
validateClockTimezone,
} from "../../src/frontend/clock-timezone.js";
import type { CanvasWidget, ClockConfig } from "../../src/frontend/types.js";
const widget: CanvasWidget = {
id: 1,
typeId: "clock",
title: "Clock",
config: {},
x: 0,
y: 0,
w: 8,
h: 5,
zOrder: 0,
};
function renderClock(config: Partial<ClockConfig> = {}) {
return render(
<ClockWidget
widget={widget}
config={{ showSeconds: false, format: "24h", ...config }}
/>,
);
}
function renderForm(config: Partial<ClockConfig> = {}) {
const onChange = vi.fn();
render(
<ClockEditForm
config={{ showSeconds: false, format: "24h", ...config }}
onChange={onChange}
/>,
);
return {
onChange,
input: screen.getByPlaceholderText(/America\/New_York/) as HTMLInputElement,
};
}
afterEach(cleanup);
describe("ClockWidget timezone handling", () => {
it("renders instead of throwing when a saved config holds an invalid timezone", () => {
// Regression: "America/New York" (space, not underscore) made
// toLocaleTimeString throw a RangeError and took down the whole canvas.
expect(() => renderClock({ timezone: "America/New York" })).not.toThrow();
expect(screen.queryByText("America/New York")).toBeNull();
});
it("falls back to local time formatting for an invalid timezone", () => {
renderClock({ timezone: "Not/AZone" });
const local = new Date().toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
expect(screen.getByText(local)).toBeTruthy();
});
it("keeps and displays a valid timezone", () => {
renderClock({ timezone: "America/New_York" });
expect(screen.getByText("America/New_York")).toBeTruthy();
});
it("shows no timezone label when none is configured", () => {
const { container } = renderClock();
expect(
container.querySelector('[class*="text-muted-foreground/60"]'),
).toBeNull();
});
it("formats in the configured timezone, not the host timezone", () => {
renderClock({ timezone: "Asia/Tokyo" });
const tokyo = new Date().toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Asia/Tokyo",
});
expect(screen.getByText(tokyo)).toBeTruthy();
});
});
describe("validateClockTimezone", () => {
it("turns spaces into underscores", () => {
expect(validateClockTimezone("America/New York")).toEqual({
timezone: "America/New_York",
valid: true,
});
});
it("trims surrounding whitespace", () => {
expect(validateClockTimezone(" Europe/Paris ")).toEqual({
timezone: "Europe/Paris",
valid: true,
});
});
it("reports an unusable zone as invalid but still returns it normalized", () => {
expect(validateClockTimezone("Not/AZone")).toEqual({
timezone: "Not/AZone",
valid: false,
});
});
it("treats blank input as clearing the timezone", () => {
expect(validateClockTimezone(" ")).toEqual({
timezone: undefined,
valid: true,
});
expect(validateClockTimezone(undefined)).toEqual({
timezone: undefined,
valid: true,
});
});
it("normalizes every run of whitespace", () => {
expect(normalizeTimezone("America/Argentina/Buenos Aires")).toBe(
"America/Argentina/Buenos_Aires",
);
});
});
describe("ClockEditForm", () => {
it("stores what the user typed without rewriting it mid-edit", () => {
const { onChange, input } = renderForm();
fireEvent.change(input, { target: { value: "America/Los Angeles" } });
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ timezone: "America/Los Angeles" }),
);
});
it("flags an unusable zone the same way FolderMetadataDialog flags a duplicate name", () => {
const { input } = renderForm({ timezone: "Not/AZone" });
expect(input.getAttribute("aria-invalid")).toBe("true");
expect(input.className).toContain("border-destructive");
expect(screen.getByText("homepage.invalidTimezone")).toBeTruthy();
});
it("accepts a zone typed with a space, since it is normalized before checking", () => {
const { input } = renderForm({ timezone: "America/New York" });
expect(input.getAttribute("aria-invalid")).toBe("false");
expect(screen.queryByText("homepage.invalidTimezone")).toBeNull();
});
it("says nothing about an empty field", () => {
const { input } = renderForm();
expect(input.getAttribute("aria-invalid")).toBe("false");
expect(screen.queryByText("homepage.invalidTimezone")).toBeNull();
});
it("clears the timezone when the field is emptied", () => {
const { onChange, input } = renderForm({ timezone: "Europe/Paris" });
fireEvent.change(input, { target: { value: "" } });
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ timezone: undefined }),
);
});
it("preserves the other clock settings when the timezone changes", () => {
const { onChange, input } = renderForm({
showSeconds: true,
format: "12h",
});
fireEvent.change(input, { target: { value: "UTC" } });
expect(onChange).toHaveBeenCalledWith({
timezone: "UTC",
showSeconds: true,
format: "12h",
});
});
});
@@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, waitFor } from "@testing-library/react";
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useTranslation: () => ({ t: (key: string) => key, language: "en" }),
}));
const getVersionInfo = vi.fn();
const getDatabaseHealth = vi.fn(async () => ({ status: "ok" }));
const getUptime = vi.fn(async () => ({ formatted: "1d 2h" }));
vi.mock("@termix/plugin-sdk/ui", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
getVersionInfo: (...args: unknown[]) => getVersionInfo(...args),
getDatabaseHealth: () => getDatabaseHealth(),
getUptime: () => getUptime(),
// The real one installs an interval and a visibilitychange listener; the
// component's first fetch happens outside it, which is all these tests need.
runVisibleInterval: () => () => {},
}));
import { SystemOverviewWidget } from "../../src/frontend/widgets/SystemOverviewWidget.js";
import type { CanvasWidget } from "../../src/frontend/types.js";
const widget: CanvasWidget = {
id: 1,
typeId: "system_overview",
title: "Termix",
config: {},
x: 0,
y: 0,
w: 10,
h: 6,
zOrder: 0,
};
function renderWidget() {
render(
<SystemOverviewWidget
widget={widget}
config={{ showVersion: true, showDbHealth: false, showUptime: false }}
/>,
);
}
beforeEach(() => {
getVersionInfo.mockReset();
});
afterEach(cleanup);
describe("SystemOverviewWidget update indicator", () => {
it("asks /version to actually check the remote release", async () => {
// Regression: it used to pass checkRemote=false, which makes the endpoint
// return early without ever comparing against the latest release.
getVersionInfo.mockResolvedValue({
status: "up_to_date",
localVersion: "2.6.1",
});
renderWidget();
await waitFor(() => expect(getVersionInfo).toHaveBeenCalledTimes(1));
expect(getVersionInfo).toHaveBeenCalledWith();
});
it("flags an available update from the status the endpoint returns", async () => {
// Regression: it used to read `updateAvailable`, a field /version does not
// return in either mode, so the row could never appear.
getVersionInfo.mockResolvedValue({
status: "requires_update",
localVersion: "2.6.0",
remoteVersion: "2.6.1",
});
renderWidget();
await waitFor(() =>
expect(screen.getByText("homepage.overviewUpdateAvailable")).toBeTruthy(),
);
});
it("labels that row 'Update' rather than 'Up to date'", async () => {
// homepage.overviewUpdate is the string "Up to date", which read as the
// label of an update-available row said "Up to date Update available".
getVersionInfo.mockResolvedValue({
status: "requires_update",
localVersion: "2.6.0",
});
renderWidget();
await waitFor(() =>
expect(screen.getByText("homepage.overviewUpdateLabel")).toBeTruthy(),
);
expect(screen.queryByText("homepage.overviewUpdate")).toBeNull();
});
it("shows no update row when the server is current", async () => {
getVersionInfo.mockResolvedValue({
status: "up_to_date",
localVersion: "2.6.1",
});
renderWidget();
await waitFor(() => expect(screen.getByText("2.6.1")).toBeTruthy());
expect(screen.queryByText("homepage.overviewUpdateAvailable")).toBeNull();
expect(screen.queryByText("homepage.overviewUpdateLabel")).toBeNull();
});
it("shows no update row on a beta build, which is ahead rather than behind", async () => {
getVersionInfo.mockResolvedValue({
status: "beta",
localVersion: "2.7.0",
remoteVersion: "2.6.1",
});
renderWidget();
await waitFor(() => expect(screen.getByText("2.7.0")).toBeTruthy());
expect(screen.queryByText("homepage.overviewUpdateAvailable")).toBeNull();
});
});
@@ -0,0 +1,105 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useTranslation: () => ({ t: (key: string) => key, language: "en" }),
}));
import { WidgetEditDialog } from "../../src/frontend/dialogs/WidgetEditDialog.js";
import type { CanvasWidget } from "../../src/frontend/types.js";
function clockWidget(timezone?: string): CanvasWidget {
return {
id: 7,
typeId: "clock",
title: "Clock",
config: { timezone, showSeconds: true, format: "24h" },
x: 0,
y: 0,
w: 8,
h: 5,
zOrder: 0,
};
}
function open(timezone?: string) {
const onSave = vi.fn();
const onClose = vi.fn();
render(
<WidgetEditDialog
widget={clockWidget(timezone)}
onSave={onSave}
onClose={onClose}
/>,
);
return {
onSave,
onClose,
input: screen.getByPlaceholderText(/America\/New_York/) as HTMLInputElement,
save: screen
.getByText("homepage.save")
.closest("button") as HTMLButtonElement,
};
}
afterEach(cleanup);
describe("WidgetEditDialog clock timezone validation", () => {
it("stores the normalized zone when the user typed a space", () => {
const { onSave, onClose, input, save } = open();
fireEvent.change(input, { target: { value: "America/New York" } });
expect(screen.queryByText("homepage.invalidTimezone")).toBeNull();
expect(save.disabled).toBe(false);
fireEvent.click(save);
expect(onSave).toHaveBeenCalledWith(
7,
"Clock",
expect.objectContaining({ timezone: "America/New_York" }),
);
expect(onClose).toHaveBeenCalled();
});
it("disables Save while the zone is unusable", () => {
const { onSave, onClose, input, save } = open();
fireEvent.change(input, { target: { value: "Not/AZone" } });
expect(save.disabled).toBe(true);
expect(screen.getByText("homepage.invalidTimezone")).toBeTruthy();
fireEvent.click(save);
expect(onSave).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
});
it("re-enables Save once the zone becomes usable", () => {
const { onSave, input, save } = open();
fireEvent.change(input, { target: { value: "Not/AZone" } });
expect(save.disabled).toBe(true);
fireEvent.change(input, { target: { value: "Asia/Tokyo" } });
expect(save.disabled).toBe(false);
expect(screen.queryByText("homepage.invalidTimezone")).toBeNull();
fireEvent.click(save);
expect(onSave).toHaveBeenCalledWith(
7,
"Clock",
expect.objectContaining({ timezone: "Asia/Tokyo" }),
);
});
it("saves a blank field as no timezone", () => {
const { onSave, input, save } = open("Europe/Paris");
fireEvent.change(input, { target: { value: " " } });
fireEvent.click(save);
expect(onSave).toHaveBeenCalledWith(
7,
"Clock",
expect.objectContaining({ timezone: undefined }),
);
});
});
@@ -0,0 +1,66 @@
import { afterEach, describe, expect, it } from "vitest";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
const declared = (views?: Array<{ id: string }>) =>
(views ?? []).map((view) => view.id).sort();
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
describe(`${manifest.id} activate`, () => {
it("activates and registers only views its manifest declares", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
const contributes = manifest.contributes as unknown as Record<
string,
Array<{ id: string }> | undefined
>;
const tabs = declared(contributes.tabs);
const cards = declared(contributes.dashboardCards);
for (const id of rendered.registered.tabs()) expect(tabs).toContain(id);
for (const id of rendered.registered.dashboardCards()) {
expect(cards).toContain(id);
}
});
it("registers the homepage tab as a singleton", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.tabs()).toContain("homepage");
});
it("registers both dashboard cards", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.dashboardCards().sort()).toEqual([
"homepage_preview",
"service_links",
]);
});
it("contributes to the dashboard.secondaryView slot", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.slot("dashboard.secondaryView")).toEqual([
"homepage",
]);
});
it("removes everything it registered on deactivate", async () => {
const app = await renderWithApp(plugin, { manifest, locales });
await app.deactivate();
expect(app.registered.tabs()).toEqual([]);
expect(app.registered.dashboardCards()).toEqual([]);
expect(app.registered.slot("dashboard.secondaryView")).toEqual([]);
expect(app.registered.actions()).toEqual([]);
});
});
@@ -0,0 +1,80 @@
import { describe, it, expect } from "vitest";
import {
screenToCanvas,
canvasToScreen,
zoomAroundPoint,
} from "../../../src/frontend/canvas/canvasGeometry.js";
const PAN = { x: 100, y: 200 };
const ZOOM = 2;
describe("screenToCanvas", () => {
it("inverts canvasToScreen", () => {
const canvas = { x: 50, y: 75 };
const screen = canvasToScreen(canvas.x, canvas.y, PAN, ZOOM);
const back = screenToCanvas(screen.x, screen.y, PAN, ZOOM);
expect(back.x).toBeCloseTo(canvas.x);
expect(back.y).toBeCloseTo(canvas.y);
});
it("accounts for pan offset", () => {
const result = screenToCanvas(100, 200, PAN, 1);
expect(result.x).toBe(0);
expect(result.y).toBe(0);
});
it("accounts for zoom", () => {
const result = screenToCanvas(300, 400, PAN, ZOOM);
expect(result.x).toBe((300 - 100) / 2);
expect(result.y).toBe((400 - 200) / 2);
});
});
describe("canvasToScreen", () => {
it("maps origin with pan offset", () => {
const result = canvasToScreen(0, 0, PAN, ZOOM);
expect(result.x).toBe(100);
expect(result.y).toBe(200);
});
it("scales by zoom", () => {
const result = canvasToScreen(10, 20, PAN, ZOOM);
expect(result.x).toBe(120);
expect(result.y).toBe(240);
});
});
describe("zoomAroundPoint", () => {
it("keeps the mouse point on the same canvas coordinate", () => {
const mouseX = 400;
const mouseY = 300;
const pan = { x: 0, y: 0 };
const oldZoom = 1;
const newZoom = 2;
const newPan = zoomAroundPoint(mouseX, mouseY, pan, oldZoom, newZoom);
// Canvas coordinate under mouse before zoom
const canvasBefore = screenToCanvas(mouseX, mouseY, pan, oldZoom);
// Canvas coordinate under mouse after zoom with newPan
const canvasAfter = screenToCanvas(mouseX, mouseY, newPan, newZoom);
expect(canvasAfter.x).toBeCloseTo(canvasBefore.x);
expect(canvasAfter.y).toBeCloseTo(canvasBefore.y);
});
it("returns correct pan for zoom-out", () => {
const mouseX = 200;
const mouseY = 150;
const pan = { x: 50, y: 50 };
const oldZoom = 2;
const newZoom = 1;
const newPan = zoomAroundPoint(mouseX, mouseY, pan, oldZoom, newZoom);
const canvasBefore = screenToCanvas(mouseX, mouseY, pan, oldZoom);
const canvasAfter = screenToCanvas(mouseX, mouseY, newPan, newZoom);
expect(canvasAfter.x).toBeCloseTo(canvasBefore.x);
expect(canvasAfter.y).toBeCloseTo(canvasBefore.y);
});
});
@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import {
snapToGrid,
snapToGridFloor,
} from "../../../src/frontend/canvas/snapToGrid.js";
import { GRID_SIZE } from "../../../src/frontend/types.js";
describe("snapToGrid", () => {
it("snaps to nearest grid multiple", () => {
expect(snapToGrid(0)).toBe(0);
expect(snapToGrid(GRID_SIZE)).toBe(GRID_SIZE);
expect(snapToGrid(GRID_SIZE * 2)).toBe(GRID_SIZE * 2);
});
it("rounds to nearest (up when exactly halfway)", () => {
expect(snapToGrid(GRID_SIZE / 2)).toBe(GRID_SIZE);
expect(snapToGrid(GRID_SIZE * 1.5)).toBe(GRID_SIZE * 2);
});
it("rounds down when below halfway", () => {
expect(snapToGrid(GRID_SIZE * 0.4)).toBe(0);
expect(snapToGrid(GRID_SIZE * 1.4)).toBe(GRID_SIZE);
});
it("works with negative values", () => {
expect(snapToGrid(-GRID_SIZE)).toBe(-GRID_SIZE);
expect(snapToGrid(-GRID_SIZE * 0.4)).toBeCloseTo(0);
});
});
describe("snapToGridFloor", () => {
it("always rounds down to floor multiple", () => {
expect(snapToGridFloor(GRID_SIZE - 1)).toBe(0);
expect(snapToGridFloor(GRID_SIZE)).toBe(GRID_SIZE);
expect(snapToGridFloor(GRID_SIZE + 1)).toBe(GRID_SIZE);
expect(snapToGridFloor(GRID_SIZE * 2 - 1)).toBe(GRID_SIZE);
});
it("handles zero", () => {
expect(snapToGridFloor(0)).toBe(0);
});
});