Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ab2992333 |
@@ -19,6 +19,70 @@ interface Props {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
// --- Agent Abilities Section ---
|
||||
|
||||
function AgentAbilitiesSection({ workspaceId }: { workspaceId: string }) {
|
||||
const node = useCanvasStore((s) => s.nodes?.find?.((n) => n.id === workspaceId));
|
||||
const broadcastEnabled = (node?.data as Record<string, unknown>)?.broadcastEnabled as boolean | undefined;
|
||||
const talkToUserEnabled = (node?.data as Record<string, unknown>)?.talkToUserEnabled as boolean | undefined;
|
||||
|
||||
const [saving, setSaving] = useState<"broadcast" | "talk" | null>(null);
|
||||
const [error, setError] = useState<"broadcast" | "talk" | null>(null);
|
||||
const [success, setSuccess] = useState<"broadcast" | "talk" | null>(null);
|
||||
|
||||
const handleToggle = async (field: "broadcast" | "talk", newValue: boolean) => {
|
||||
setError(null);
|
||||
setSaving(field);
|
||||
const bodyKey = field === "broadcast" ? "broadcast_enabled" : "talk_to_user_enabled";
|
||||
try {
|
||||
await api.patch(`/workspaces/${workspaceId}/abilities`, { [bodyKey]: newValue });
|
||||
useCanvasStore.getState().updateNodeData(workspaceId, {
|
||||
[field === "broadcast" ? "broadcastEnabled" : "talkToUserEnabled"]: newValue,
|
||||
} as Record<string, unknown>);
|
||||
setSuccess(field);
|
||||
setTimeout(() => setSuccess(null), 2000);
|
||||
} catch {
|
||||
setError(field);
|
||||
setTimeout(() => setError(null), 3000);
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title="Agent Abilities">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Toggle
|
||||
label="Broadcast — agent may send org-wide messages"
|
||||
checked={broadcastEnabled ?? false}
|
||||
onChange={(v) => handleToggle("broadcast", v)}
|
||||
/>
|
||||
<p className="text-[10px] text-ink-soft mt-1 pl-5">
|
||||
When enabled the workspace can broadcast to every other workspace in the org.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Toggle
|
||||
label="Talk to User — agent may send chat messages to the canvas"
|
||||
checked={talkToUserEnabled ?? true}
|
||||
onChange={(v) => handleToggle("talk", v)}
|
||||
/>
|
||||
<p className="text-[10px] text-ink-soft mt-1 pl-5">
|
||||
When disabled the agent cannot reach the user in Chat. Useful for headless / research-only workspaces.
|
||||
</p>
|
||||
</div>
|
||||
{success && (
|
||||
<div className="px-2 py-1 bg-green-900/30 border border-green-800 rounded text-[10px] text-good">Updated</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-bad">Failed to update</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Agent Card Section ---
|
||||
|
||||
function AgentCardSection({ workspaceId }: { workspaceId: string }) {
|
||||
@@ -885,6 +949,8 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<AgentAbilitiesSection workspaceId={workspaceId} />
|
||||
|
||||
{/* Claude Settings — shown for claude-code runtime or claude/anthropic model names */}
|
||||
{(config.runtime === "claude-code" ||
|
||||
(config.runtime_config?.model || config.model || "").toLowerCase().includes("claude") ||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for AgentAbilitiesSection — two toggles for broadcast_enabled and
|
||||
* talk_to_user_enabled on PATCH /workspaces/:id/abilities.
|
||||
*
|
||||
* Covers:
|
||||
* - Section is always visible (no runtime gate)
|
||||
* - Both toggles reflect the store defaults (broadcast OFF / talk ON)
|
||||
* - Each toggle fires the correct PATCH body and optimistically updates the store
|
||||
* - Success and error banners
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// ── @/lib/api mock ────────────────────────────────────────────────────────────
|
||||
|
||||
const apiGet = vi.fn();
|
||||
const apiPatch = vi.fn();
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: (path: string) => apiGet(path),
|
||||
patch: (...args: unknown[]) => apiPatch(...args),
|
||||
put: vi.fn(),
|
||||
post: vi.fn(),
|
||||
del: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ── @/store/canvas mock ───────────────────────────────────────────────────────
|
||||
|
||||
const storeUpdateNodeData = vi.fn();
|
||||
const storeRestartWorkspace = vi.fn();
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
(selector: (s: unknown) => unknown) =>
|
||||
selector({
|
||||
nodes: [
|
||||
{
|
||||
id: "ws-test",
|
||||
data: {
|
||||
broadcastEnabled: false,
|
||||
talkToUserEnabled: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
restartWorkspace: storeRestartWorkspace,
|
||||
updateNodeData: storeUpdateNodeData,
|
||||
}),
|
||||
{
|
||||
getState: () => ({
|
||||
nodes: [
|
||||
{
|
||||
id: "ws-test",
|
||||
data: {
|
||||
broadcastEnabled: false,
|
||||
talkToUserEnabled: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
restartWorkspace: storeRestartWorkspace,
|
||||
updateNodeData: storeUpdateNodeData,
|
||||
}),
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Section / component stubs ──────────────────────────────────────────────────
|
||||
|
||||
vi.mock("../ExternalConnectionSection", () => ({
|
||||
ExternalConnectionSection: () => <div data-testid="external-connection-stub" />,
|
||||
}));
|
||||
|
||||
vi.mock("./config/secrets-section", () => ({
|
||||
SecretsSection: () => <div data-testid="secrets-section-stub" />,
|
||||
}));
|
||||
|
||||
// ── ConfigTab ─────────────────────────────────────────────────────────────────
|
||||
|
||||
import { ConfigTab } from "../ConfigTab";
|
||||
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
apiPatch.mockReset();
|
||||
storeUpdateNodeData.mockReset();
|
||||
|
||||
apiGet.mockImplementation((path: string) => {
|
||||
if (path === "/workspaces/ws-test") return Promise.resolve({ runtime: "langgraph" });
|
||||
if (path === "/workspaces/ws-test/model") return Promise.resolve({});
|
||||
if (path === "/workspaces/ws-test/provider") return Promise.resolve({});
|
||||
if (path === "/workspaces/ws-test/files/config.yaml")
|
||||
return Promise.resolve({ content: "name: test\nruntime: langgraph\n" });
|
||||
if (path === "/templates")
|
||||
return Promise.resolve([{ id: "langgraph", name: "LangGraph", runtime: "langgraph", providers: [] }]);
|
||||
return Promise.reject(new Error(`unmocked api.get: ${path}`));
|
||||
});
|
||||
|
||||
apiPatch.mockResolvedValue({ status: "updated" });
|
||||
});
|
||||
|
||||
describe("AgentAbilitiesSection", () => {
|
||||
it("renders the section even when runtime is not claude-code", async () => {
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("button", { name: /Agent Abilities/i })).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders both toggles with the correct initial checked states", async () => {
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("button", { name: /Agent Abilities/i })).not.toBeNull();
|
||||
});
|
||||
|
||||
// Confirm Agent Abilities section button is in the DOM
|
||||
const sectionButton = screen.getByRole("button", { name: /Agent Abilities/i });
|
||||
expect(sectionButton).not.toBeNull();
|
||||
|
||||
// Section is defaultOpen=true; check aria-expanded to confirm open
|
||||
expect(sectionButton.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
// Find toggles by label text (no click needed — section is already open)
|
||||
const broadcastLabel = screen.getByText(/Broadcast — agent may send org-wide messages/);
|
||||
const talkLabel = screen.getByText(/Talk to User — agent may send chat messages to the canvas/);
|
||||
|
||||
const broadcastInput = broadcastLabel.previousElementSibling as HTMLInputElement;
|
||||
const talkInput = talkLabel.previousElementSibling as HTMLInputElement;
|
||||
|
||||
expect(broadcastInput.type).toBe("checkbox");
|
||||
expect(broadcastInput.checked).toBe(false); // store default: broadcast OFF
|
||||
expect(talkInput.checked).toBe(true); // store default: talk ON
|
||||
});
|
||||
|
||||
it("PATCHes broadcast_enabled: true when the broadcast toggle is switched on", async () => {
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("button", { name: /Agent Abilities/i })).not.toBeNull();
|
||||
});
|
||||
|
||||
const broadcastLabel = screen.getByText(/Broadcast — agent may send org-wide messages/);
|
||||
const broadcastInput = broadcastLabel.previousElementSibling as HTMLInputElement;
|
||||
fireEvent.click(broadcastInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiPatch).toHaveBeenCalledWith("/workspaces/ws-test/abilities", {
|
||||
broadcast_enabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("PATCHes talk_to_user_enabled: false when the talk toggle is switched off", async () => {
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("button", { name: /Agent Abilities/i })).not.toBeNull();
|
||||
});
|
||||
|
||||
const talkLabel = screen.getByText(/Talk to User — agent may send chat messages to the canvas/);
|
||||
const talkInput = talkLabel.previousElementSibling as HTMLInputElement;
|
||||
fireEvent.click(talkInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiPatch).toHaveBeenCalledWith("/workspaces/ws-test/abilities", {
|
||||
talk_to_user_enabled: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("optimistically updates the store on a successful PATCH", async () => {
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("button", { name: /Agent Abilities/i })).not.toBeNull();
|
||||
});
|
||||
|
||||
const broadcastLabel = screen.getByText(/Broadcast — agent may send org-wide messages/);
|
||||
const broadcastInput = broadcastLabel.previousElementSibling as HTMLInputElement;
|
||||
fireEvent.click(broadcastInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storeUpdateNodeData).toHaveBeenCalledWith("ws-test", {
|
||||
broadcastEnabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a success banner after a successful update", async () => {
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("button", { name: /Agent Abilities/i })).not.toBeNull();
|
||||
});
|
||||
|
||||
const broadcastLabel = screen.getByText(/Broadcast — agent may send org-wide messages/);
|
||||
const broadcastInput = broadcastLabel.previousElementSibling as HTMLInputElement;
|
||||
fireEvent.click(broadcastInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Updated")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an error banner when the PATCH fails", async () => {
|
||||
apiPatch.mockRejectedValueOnce(new Error("server error"));
|
||||
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("button", { name: /Agent Abilities/i })).not.toBeNull();
|
||||
});
|
||||
|
||||
const broadcastLabel = screen.getByText(/Broadcast — agent may send org-wide messages/);
|
||||
const broadcastInput = broadcastLabel.previousElementSibling as HTMLInputElement;
|
||||
fireEvent.click(broadcastInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Failed to update")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for theme-cookie.ts:
|
||||
* - THEME_COOKIE constant
|
||||
* - readThemeCookie
|
||||
* - themeBootScript
|
||||
* Tests for readThemeCookie — parses a cookie value into a ThemePreference.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readThemeCookie, THEME_COOKIE, themeBootScript } from "../theme-cookie";
|
||||
import { readThemeCookie } from "../theme-cookie";
|
||||
|
||||
describe("readThemeCookie", () => {
|
||||
it('returns "light" when cookie value is "light"', () => {
|
||||
@@ -48,63 +45,3 @@ describe("readThemeCookie", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── THEME_COOKIE ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("THEME_COOKIE", () => {
|
||||
it("is a non-empty string", () => {
|
||||
expect(typeof THEME_COOKIE).toBe("string");
|
||||
expect(THEME_COOKIE.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("equals 'mol_theme'", () => {
|
||||
expect(THEME_COOKIE).toBe("mol_theme");
|
||||
});
|
||||
|
||||
it("is stable — constant is not reassigned", () => {
|
||||
const first = THEME_COOKIE;
|
||||
const second = THEME_COOKIE;
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
});
|
||||
|
||||
// ── themeBootScript ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("themeBootScript", () => {
|
||||
it("is a non-empty string", () => {
|
||||
expect(typeof themeBootScript).toBe("string");
|
||||
expect(themeBootScript.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("contains THEME_COOKIE value in the cookie-regex pattern", () => {
|
||||
// The script reads document.cookie looking for mol_theme=...
|
||||
expect(themeBootScript).toContain(THEME_COOKIE);
|
||||
});
|
||||
|
||||
it("contains 'system', 'light', 'dark' in the match pattern", () => {
|
||||
expect(themeBootScript).toContain("system");
|
||||
expect(themeBootScript).toContain("light");
|
||||
expect(themeBootScript).toContain("dark");
|
||||
});
|
||||
|
||||
it("contains data-theme assignment on documentElement", () => {
|
||||
// The script sets document.documentElement.dataset.theme = resolved
|
||||
expect(themeBootScript).toContain("dataset.theme");
|
||||
expect(themeBootScript).toContain("document.documentElement");
|
||||
});
|
||||
|
||||
it("contains matchMedia call for OS preference fallback", () => {
|
||||
expect(themeBootScript).toContain("matchMedia");
|
||||
expect(themeBootScript).toContain("prefers-color-scheme");
|
||||
});
|
||||
|
||||
it("wraps the entire body in an IIFE so it runs immediately", () => {
|
||||
expect(themeBootScript).toMatch(/^\(\(\)=>/);
|
||||
});
|
||||
|
||||
it("is pure — constant evaluated once, same value every time", () => {
|
||||
const a = themeBootScript;
|
||||
const b = themeBootScript;
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
"use client";
|
||||
/**
|
||||
* Tests for theme-provider.tsx:
|
||||
* - applyResolvedTheme — pure DOM side-effect function
|
||||
* - ThemeProvider — context, setTheme, resolvedTheme derivation
|
||||
* - useTheme — hook + noop fallback
|
||||
*
|
||||
* Coverage gaps filled vs theme-cookie.test.ts (which tests only readThemeCookie):
|
||||
* applyResolvedTheme, ThemeProvider initialTheme, resolvedTheme derivation
|
||||
* from system preference, writeThemeCookie integration, useTheme noop fallback.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import React from "react";
|
||||
import { render, screen, cleanup, act, waitFor } from "@testing-library/react";
|
||||
import { applyResolvedTheme, ThemeProvider, useTheme } from "../theme-provider";
|
||||
|
||||
// ─── applyResolvedTheme ────────────────────────────────────────────────────────
|
||||
|
||||
describe("applyResolvedTheme", () => {
|
||||
beforeEach(() => {
|
||||
if (typeof document !== "undefined") {
|
||||
delete (document.documentElement as Record<string, unknown>).dataset;
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
if (typeof document !== "undefined") {
|
||||
delete (document.documentElement as Record<string, unknown>).dataset;
|
||||
}
|
||||
});
|
||||
|
||||
it('sets data-theme="light" on document.documentElement', () => {
|
||||
applyResolvedTheme("light");
|
||||
expect(document.documentElement.dataset.theme).toBe("light");
|
||||
});
|
||||
|
||||
it('sets data-theme="dark" on document.documentElement', () => {
|
||||
applyResolvedTheme("dark");
|
||||
expect(document.documentElement.dataset.theme).toBe("dark");
|
||||
});
|
||||
|
||||
it("is idempotent — calling twice with same value keeps the same attribute", () => {
|
||||
applyResolvedTheme("dark");
|
||||
applyResolvedTheme("dark");
|
||||
expect(document.documentElement.dataset.theme).toBe("dark");
|
||||
});
|
||||
|
||||
it("is a pure function for its DOM side-effect — no return value", () => {
|
||||
expect(applyResolvedTheme("light")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("guards against undefined document (SSR safety)", () => {
|
||||
// In Node.js / SSR context document is undefined; the function returns
|
||||
// early without throwing. We simulate this by temporarily deleting document.
|
||||
const saved = globalThis.document;
|
||||
// @ts-expect-error — intentionally undefined for SSR test
|
||||
globalThis.document = undefined;
|
||||
expect(() => applyResolvedTheme("dark")).not.toThrow();
|
||||
globalThis.document = saved;
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ThemeProvider ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ThemeProvider", () => {
|
||||
beforeEach(() => {
|
||||
// Stub matchMedia so ThemeProvider's system-preference useEffect works in jsdom.
|
||||
// Default to light mode (matches=false) so resolvedTheme="light" when theme="system".
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false, // light preference by default
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
if (typeof document !== "undefined") {
|
||||
delete (document.documentElement as Record<string, unknown>).dataset;
|
||||
}
|
||||
// Clear cookies set by writeThemeCookie.
|
||||
if (typeof document !== "undefined") {
|
||||
document.cookie = "mol_theme=; Max-Age=0";
|
||||
}
|
||||
});
|
||||
|
||||
function ThemeChild() {
|
||||
const { theme, resolvedTheme, setTheme } = useTheme();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="theme">{theme}</span>
|
||||
<span data-testid="resolved">{resolvedTheme}</span>
|
||||
<button
|
||||
data-testid="set-light"
|
||||
onClick={() => setTheme("light")}
|
||||
>
|
||||
light
|
||||
</button>
|
||||
<button
|
||||
data-testid="set-dark"
|
||||
onClick={() => setTheme("dark")}
|
||||
>
|
||||
dark
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
it("renders children", () => {
|
||||
render(
|
||||
<ThemeProvider initialTheme="light">
|
||||
<span data-testid="child">Hello</span>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("child")).toBeTruthy();
|
||||
});
|
||||
|
||||
it('initialTheme="light" sets theme=light', () => {
|
||||
render(
|
||||
<ThemeProvider initialTheme="light">
|
||||
<ThemeChild />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("theme").textContent).toBe("light");
|
||||
});
|
||||
|
||||
it('initialTheme="dark" sets theme=dark', () => {
|
||||
render(
|
||||
<ThemeProvider initialTheme="dark">
|
||||
<ThemeChild />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("theme").textContent).toBe("dark");
|
||||
});
|
||||
|
||||
it('initialTheme="system" falls back to light (matchMedia stub)', () => {
|
||||
// matchMedia is not stubbed in jsdom by default; the provider calls it
|
||||
// and reads the OS preference. Without a stub, jsdom returns
|
||||
// { matches: false } → "light".
|
||||
render(
|
||||
<ThemeProvider initialTheme="system">
|
||||
<ThemeChild />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
// Resolved is "light" because jsdom matchMedia stub returns false for dark.
|
||||
expect(screen.getByTestId("resolved").textContent).toBe("light");
|
||||
});
|
||||
|
||||
it("setTheme('dark') updates both theme and resolvedTheme", async () => {
|
||||
render(
|
||||
<ThemeProvider initialTheme="light">
|
||||
<ThemeChild />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("theme").textContent).toBe("light");
|
||||
|
||||
await act(async () => {
|
||||
screen.getByTestId("set-dark").click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("theme").textContent).toBe("dark");
|
||||
// resolvedTheme tracks theme when not in system mode.
|
||||
expect(screen.getByTestId("resolved").textContent).toBe("dark");
|
||||
});
|
||||
|
||||
it("setTheme('light') updates both theme and resolvedTheme", async () => {
|
||||
render(
|
||||
<ThemeProvider initialTheme="dark">
|
||||
<ThemeChild />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
screen.getByTestId("set-light").click();
|
||||
});
|
||||
expect(screen.getByTestId("theme").textContent).toBe("light");
|
||||
expect(screen.getByTestId("resolved").textContent).toBe("light");
|
||||
});
|
||||
|
||||
it("writes mol_theme cookie when setTheme is called", async () => {
|
||||
render(
|
||||
<ThemeProvider initialTheme="light">
|
||||
<ThemeChild />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
screen.getByTestId("set-dark").click();
|
||||
});
|
||||
expect(document.cookie).toContain("mol_theme=dark");
|
||||
});
|
||||
|
||||
it("calls applyResolvedTheme on mount (data-theme set on <html>)", () => {
|
||||
render(
|
||||
<ThemeProvider initialTheme="dark">
|
||||
<span data-testid="child">hi</span>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(document.documentElement.dataset.theme).toBe("dark");
|
||||
});
|
||||
|
||||
it("calls applyResolvedTheme when resolvedTheme changes", async () => {
|
||||
render(
|
||||
<ThemeProvider initialTheme="light">
|
||||
<ThemeChild />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
// Start at light.
|
||||
expect(document.documentElement.dataset.theme).toBe("light");
|
||||
|
||||
await act(async () => {
|
||||
screen.getByTestId("set-dark").click();
|
||||
});
|
||||
|
||||
expect(document.documentElement.dataset.theme).toBe("dark");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── useTheme noop fallback ────────────────────────────────────────────────────
|
||||
|
||||
describe("useTheme without ThemeProvider", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("useTheme returns noopTheme when no provider is in the tree", () => {
|
||||
function ShowTheme() {
|
||||
const { theme, resolvedTheme, setTheme } = useTheme();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="theme">{theme}</span>
|
||||
<span data-testid="resolved">{resolvedTheme}</span>
|
||||
<span data-testid="setTheme-type">{typeof setTheme}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
render(<ShowTheme />);
|
||||
// noopTheme defaults: theme="system", resolvedTheme="light", setTheme no-op.
|
||||
expect(screen.getByTestId("theme").textContent).toBe("system");
|
||||
expect(screen.getByTestId("resolved").textContent).toBe("light");
|
||||
expect(screen.getByTestId("setTheme-type").textContent).toBe("function");
|
||||
});
|
||||
|
||||
it("setTheme is a no-op when no provider is present (no throw)", async () => {
|
||||
let threw = false;
|
||||
function ClickSetTheme() {
|
||||
const { setTheme } = useTheme();
|
||||
return (
|
||||
<button
|
||||
data-testid="call-setTheme"
|
||||
onClick={() => {
|
||||
try {
|
||||
setTheme("dark");
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
}}
|
||||
>
|
||||
call
|
||||
</button>
|
||||
);
|
||||
}
|
||||
render(<ClickSetTheme />);
|
||||
await act(async () => {
|
||||
screen.getByTestId("call-setTheme").click();
|
||||
});
|
||||
expect(threw).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -75,7 +75,7 @@ function writeThemeCookie(value: ThemePreference): void {
|
||||
document.cookie = parts.join("; ");
|
||||
}
|
||||
|
||||
export function applyResolvedTheme(resolved: ResolvedTheme): void {
|
||||
function applyResolvedTheme(resolved: ResolvedTheme): void {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.dataset.theme = resolved;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user