Files
kandev/apps/web/src/settings-routes.test.ts
T

356 lines
12 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { isValidElement, type ReactElement } from "react";
import { render, screen } from "@testing-library/react";
import ExecutorEditPage from "@/app/settings/executor/[id]/page";
import ProfileDetailPage from "@/app/settings/executor/[id]/profile/[profileId]/page";
import ExecutorCreatePage from "@/app/settings/executor/new/page";
import ProfileEditPage from "@/app/settings/executors/[profileId]/page";
import CreateProfilePage from "@/app/settings/executors/new/[type]/page";
import SSHExecutorPage from "@/app/settings/executors/ssh/[executorId]/page";
import IntegrationsGitLabPage from "@/app/settings/integrations/gitlab/page";
import PluginDetailPage from "@/app/settings/plugins/[pluginId]/page";
import AutomationEditorPage from "@/app/settings/workspace/[id]/automations/[automationId]/page";
import NewAutomationPage from "@/app/settings/workspace/[id]/automations/new/page";
import WorkspaceEditPage from "@/app/settings/workspace/[id]/page";
import { TaskActionsSettings } from "@/components/settings/general-settings";
import { SecretsSettings } from "@/components/settings/secrets-settings";
import { workspaceId, workflowId } from "@/lib/types/ids";
import type { ListWorkspacesResponse, UserSettingsResponse } from "@/lib/types/http";
import { buildSettingsInitialStateForRoute, renderSettingsRoute } from "./settings-routes";
vi.mock("@/components/settings/system/updates-card", () => ({ UpdatesCard: () => null }));
const ACTIVE_WORKSPACE_COOKIE = "kandev-active-workspace";
const OWNER_ID = "owner-1";
const TIMESTAMP = "2026-01-01T00:00:00Z";
const ENCODED_WORKSPACE_ID = "workspace%20one";
const DECODED_WORKSPACE_ID = "workspace one";
describe("buildSettingsInitialStateForRoute", () => {
beforeEach(() => {
document.cookie = `${ACTIVE_WORKSPACE_COOKIE}=; path=/; max-age=0`;
});
describe("workspace selection", () => {
it("keeps the saved active workspace for settings hydration", () => {
const state = buildState({
workspaces: workspaceRows(["ws-1", "ws-2"]),
userSettingsResponse: userSettings({ workspace_id: workspaceId("ws-1") }),
});
expect(state.workspaces?.activeId).toBe("ws-1");
expect(state.userSettings?.workspaceId).toBe("ws-1");
});
it("keeps the active workspace cookie on global settings pages", () => {
document.cookie = `${ACTIVE_WORKSPACE_COOKIE}=ws-2; path=/`;
const state = buildState({
workspaces: workspaceRows(["ws-1", "ws-2"]),
userSettingsResponse: userSettings({ workspace_id: workspaceId("ws-1") }),
});
expect(state.workspaces?.activeId).toBe("ws-2");
expect(state.userSettings?.workspaceId).toBe("ws-2");
});
it("falls back to user settings when cookie has an office workspace", () => {
document.cookie = `${ACTIVE_WORKSPACE_COOKIE}=ws-office; path=/`;
const state = buildState({
workspaces: [
buildWorkspace({ id: "ws-office", office_workflow_id: workflowId("office") }),
buildWorkspace({ id: "ws-kanban", office_workflow_id: null }),
],
userSettingsResponse: userSettings({ workspace_id: workspaceId("ws-kanban") }),
});
expect(state.workspaces?.activeId).toBe("ws-kanban");
expect(state.userSettings?.workspaceId).toBe("ws-kanban");
});
});
describe("fallbacks", () => {
it("falls back to the settings workspace_id when no cookie matches", () => {
const state = buildState({
workspaces: workspaceRows(["ws-1", "ws-2"]),
userSettingsResponse: userSettings({ workspace_id: workspaceId("ws-2") }),
});
expect(state.workspaces?.activeId).toBe("ws-2");
expect(state.userSettings?.workspaceId).toBe("ws-2");
});
it("falls back to the first workspace when neither cookie nor settings match", () => {
const state = buildState({
workspaces: workspaceRows(["ws-1", "ws-2"]),
userSettingsResponse: userSettings({ workspace_id: workspaceId("missing") }),
});
expect(state.workspaces?.activeId).toBe("ws-1");
expect(state.userSettings?.workspaceId).toBe("ws-1");
});
it("returns empty state defaults when all API calls fail", () => {
const state = buildState({ userSettingsResponse: null });
expect(state.workspaces).toEqual({ items: [], activeId: null });
expect(state.executors).toEqual({ items: [] });
expect(state.agentProfiles).toEqual({ items: [], version: 0 });
expect(state.settingsAgents).toEqual({ items: [] });
expect(state.agentDiscovery).toEqual({ items: [], loading: false, loaded: true });
expect(state.availableAgents).toEqual({
items: [],
tools: [],
loading: false,
loaded: true,
});
expect(state.settingsData).toEqual({ executorsLoaded: true, agentsLoaded: true });
expect(state.userSettings).toBeUndefined();
});
});
it("only spreads userSettings when settings were loaded", () => {
const loaded = buildState({
workspaces: workspaceRows(["ws-1"]),
userSettingsResponse: userSettings({ workspace_id: workspaceId("ws-1") }),
});
const failed = buildState({
workspaces: workspaceRows(["ws-1"]),
userSettingsResponse: null,
});
expect(loaded.userSettings?.loaded).toBe(true);
expect(failed.userSettings).toBeUndefined();
});
it("does not recreate the retired update-notification state during hydration", () => {
const state = buildState();
expect(state.system).toBeUndefined();
});
});
describe("message queue settings route", () => {
it("registers the Message Queue General settings leaf", () => {
const route = renderSettingsRoute("/settings/general/message-queue");
expect(isValidElement(route)).toBe(true);
expect(((route as ReactElement).type as { name?: string }).name).toBe(
"MessageQueueSettingsPage",
);
});
it("redirects the former System URL to the General settings leaf", () => {
const route = renderSettingsRoute("/settings/system/message-queue") as ReactElement<{
to: string;
}>;
expect(isValidElement(route)).toBe(true);
expect((route.type as { name?: string }).name).toBe("SettingsRedirect");
expect(route.props.to).toBe("/settings/general/message-queue");
});
});
describe("renderSettingsRoute", () => {
it("directs update notification settings to Notifications", () => {
render(renderSettingsRoute("/settings/system/updates"));
expect(screen.getByText(/notification preferences are managed/i)).toBeTruthy();
expect(screen.getByRole("link", { name: /notifications/i }).getAttribute("href")).toBe(
"/settings/general/notifications",
);
});
it("renders layout profile settings from General settings", () => {
const route = renderSettingsRoute("/settings/general/layouts");
expect(isValidElement(route)).toBe(true);
expect(((route as ReactElement).type as { name?: string }).name).toBe("LayoutSettings");
});
it("renders task action preferences from General settings", () => {
const route = renderSettingsRoute("/settings/general/task-actions");
expect(isValidElement(route)).toBe(true);
expect((route as ReactElement).type).toBe(TaskActionsSettings);
});
it("passes the route workspace id to the GitLab integration page", () => {
expect(gitLabRouteWorkspaceId("/settings/workspace/ws-2/integrations/gitlab")).toBe("ws-2");
expect(gitLabRouteWorkspaceId("/settings/workspace/ws%202/integrations/gitlab")).toBe("ws 2");
});
it.each([
{
pathname: "/settings/plugins/plugin%20one",
component: PluginDetailPage,
identifiers: { pluginId: "plugin one" },
},
{
pathname: "/settings/executor/executor%20one/profile/profile%20one",
component: ProfileDetailPage,
identifiers: { executorId: "executor one", profileId: "profile one" },
},
{
pathname: "/settings/executor/executor%20one",
component: ExecutorEditPage,
identifiers: { executorId: "executor one" },
},
{
pathname: "/settings/executors/profile%20one",
component: ProfileEditPage,
identifiers: { profileId: "profile one" },
},
{
pathname: "/settings/executors/new/local_docker",
component: CreateProfilePage,
identifiers: { executorType: "local_docker" },
},
{
pathname: "/settings/executors/ssh/executor%20one",
component: SSHExecutorPage,
identifiers: { executorId: "executor one" },
},
{
pathname: `/settings/workspace/${ENCODED_WORKSPACE_ID}`,
component: WorkspaceEditPage,
identifiers: { workspaceId: DECODED_WORKSPACE_ID },
},
{
pathname: `/settings/workspace/${ENCODED_WORKSPACE_ID}/automations/new`,
component: NewAutomationPage,
identifiers: { workspaceId: DECODED_WORKSPACE_ID },
},
{
pathname: `/settings/workspace/${ENCODED_WORKSPACE_ID}/automations/automation%20one`,
component: AutomationEditorPage,
identifiers: { workspaceId: DECODED_WORKSPACE_ID, automationId: "automation one" },
},
{
pathname: `/settings/workspace/${ENCODED_WORKSPACE_ID}/secrets`,
component: SecretsSettings,
identifiers: { scope: "workspace", workspaceId: DECODED_WORKSPACE_ID },
},
])(
"passes decoded synchronous identifiers for $pathname",
({ pathname, component, identifiers }) => {
assertSynchronousRouteIdentifiers(pathname, component, identifiers);
},
);
it("reserves /settings/executor/new for executor creation", () => {
const route = renderSettingsRoute("/settings/executor/new");
expect(isValidElement(route)).toBe(true);
expect((route as ReactElement).type).toBe(ExecutorCreatePage);
});
});
function assertSynchronousRouteIdentifiers(
pathname: string,
component: unknown,
identifiers: Record<string, unknown>,
) {
const route = renderSettingsRoute(pathname);
if (!isValidElement<Record<string, unknown>>(route)) {
throw new Error(`expected a route element for ${pathname}`);
}
expect({
component: route.type,
identifiers: pickProps(route.props, Object.keys(identifiers)),
thenableProps: Object.entries(route.props)
.filter(([, value]) => isThenable(value))
.map(([name]) => name),
asyncComponent:
typeof route.type === "function" && route.type.constructor.name === "AsyncFunction",
}).toEqual({
component,
identifiers,
thenableProps: [],
asyncComponent: false,
});
}
function buildState(
overrides: Partial<Parameters<typeof buildSettingsInitialStateForRoute>[0]> = {},
) {
return buildSettingsInitialStateForRoute({
workspaces: [],
executors: [],
agents: [],
discoveryAgents: [],
availableAgents: [],
availableTools: [],
userSettingsResponse: null,
...overrides,
});
}
function buildWorkspace(
params: Omit<
Partial<ListWorkspacesResponse["workspaces"][number]>,
"id" | "office_workflow_id"
> & {
id: string;
office_workflow_id: ReturnType<typeof workflowId> | null;
},
) {
const { id, office_workflow_id, ...rest } = params;
return {
id: workspaceId(id),
name: `Workspace ${id}`,
description: null,
owner_id: OWNER_ID,
default_executor_id: null,
default_environment_id: null,
default_agent_profile_id: null,
default_config_agent_profile_id: null,
office_workflow_id,
created_at: TIMESTAMP,
updated_at: TIMESTAMP,
...rest,
} as unknown as ListWorkspacesResponse["workspaces"][number];
}
function workspaceRows(ids: string[]): ListWorkspacesResponse["workspaces"] {
return ids.map((id) => buildWorkspace({ id, office_workflow_id: null }));
}
function userSettings(
settings: Partial<NonNullable<UserSettingsResponse["settings"]>>,
): UserSettingsResponse {
return {
settings: {
user_id: OWNER_ID,
workspace_id: workspaceId(""),
workflow_filter_id: workflowId(""),
repository_ids: [],
updated_at: TIMESTAMP,
...settings,
},
};
}
function gitLabRouteWorkspaceId(pathname: string): string | undefined {
const route = renderSettingsRoute(pathname);
if (!isValidElement(route)) {
throw new Error("expected GitLab integration route element");
}
expect(route.type).toBe(IntegrationsGitLabPage);
return (route as ReactElement<{ workspaceId?: string }>).props.workspaceId;
}
function pickProps(props: Record<string, unknown>, names: string[]): Record<string, unknown> {
return Object.fromEntries(names.map((name) => [name, props[name]]));
}
function isThenable(value: unknown): value is PromiseLike<unknown> {
return (
(typeof value === "object" || typeof value === "function") &&
value !== null &&
"then" in value &&
typeof value.then === "function"
);
}