Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ba08a2dc8 |
@@ -1,204 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for actionable error rendering in useChatSocket (issue #1420).
|
||||
*
|
||||
* When a workspace agent returns an error on a canvas message/send, the canvas
|
||||
* should surface the actionable error_detail (e.g. oauth_org_not_allowed)
|
||||
* rather than the opaque "Agent error (Exception) — see workspace logs for details."
|
||||
* fallback. Falls back to summary, then a generic hint.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { useChatSocket, type UseChatSocketCallbacks } from "../hooks/useChatSocket";
|
||||
import { emitSocketEvent, _resetSocketEventListenersForTests } from "@/store/socket-events";
|
||||
import type { WSMessage } from "@/store/socket";
|
||||
|
||||
// Silence React StrictMode double-invoke noise.
|
||||
const WARN = console.warn;
|
||||
beforeEach(() => { console.warn = () => {}; });
|
||||
afterEach(() => { console.warn = WARN; });
|
||||
|
||||
beforeEach(() => {
|
||||
_resetSocketEventListenersForTests();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-18T10:00:00Z"));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
_resetSocketEventListenersForTests();
|
||||
});
|
||||
|
||||
const WORKSPACE_ID = "00000000-0000-0000-0000-000000000001";
|
||||
|
||||
function makeActivityErrorEvent(
|
||||
workspaceId: string,
|
||||
overrides: Partial<{
|
||||
error_detail: string;
|
||||
summary: string;
|
||||
method: string;
|
||||
status: string;
|
||||
}> = {},
|
||||
): WSMessage {
|
||||
const {
|
||||
error_detail = "",
|
||||
summary = "",
|
||||
method = "message/send",
|
||||
status = "error",
|
||||
} = overrides;
|
||||
return {
|
||||
event: "ACTIVITY_LOGGED",
|
||||
workspace_id: workspaceId,
|
||||
timestamp: "2026-05-18T10:00:00Z",
|
||||
payload: {
|
||||
activity_type: "a2a_receive",
|
||||
method,
|
||||
status,
|
||||
target_id: workspaceId,
|
||||
duration_ms: 500,
|
||||
summary,
|
||||
...(error_detail ? { error_detail } : {}),
|
||||
} as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useChatSocket actionable error rendering", () => {
|
||||
it("calls onSendError with error_detail when present in the payload", () => {
|
||||
const onSendError = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onSendError };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(
|
||||
makeActivityErrorEvent(WORKSPACE_ID, {
|
||||
error_detail: "403 Forbidden: oauth_org_not_allowed — Your organization has disabled Claude subscription access. Use an API key instead.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onSendError).toHaveBeenCalledTimes(1);
|
||||
expect(onSendError.mock.calls[0][0]).toContain("oauth_org_not_allowed");
|
||||
expect(onSendError.mock.calls[0][0]).not.toContain("workspace logs");
|
||||
expect(onSendError.mock.calls[0][0]).not.toContain("Agent error (Exception)");
|
||||
});
|
||||
|
||||
it("falls back to summary when error_detail is absent", () => {
|
||||
const onSendError = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onSendError };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(
|
||||
makeActivityErrorEvent(WORKSPACE_ID, {
|
||||
summary: "A2A request to ws-agent failed: connection refused",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onSendError).toHaveBeenCalledTimes(1);
|
||||
expect(onSendError.mock.calls[0][0]).toBe("A2A request to ws-agent failed: connection refused");
|
||||
});
|
||||
|
||||
it("falls back to generic hint when neither error_detail nor summary is present", () => {
|
||||
const onSendError = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onSendError };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(makeActivityErrorEvent(WORKSPACE_ID, {}));
|
||||
});
|
||||
|
||||
expect(onSendError).toHaveBeenCalledTimes(1);
|
||||
expect(onSendError.mock.calls[0][0]).toContain("Agent error");
|
||||
// Should NOT be the old opaque phrase
|
||||
expect(onSendError.mock.calls[0][0]).not.toContain("workspace logs");
|
||||
expect(onSendError.mock.calls[0][0]).not.toContain("Agent error (Exception)");
|
||||
});
|
||||
|
||||
it("does NOT call onSendError for other workspaces", () => {
|
||||
const onSendError = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onSendError };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(
|
||||
makeActivityErrorEvent("00000000-0000-0000-0000-000000000099", {
|
||||
error_detail: "some provider error",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onSendError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call onSendError for ok status", () => {
|
||||
const onSendError = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onSendError };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(
|
||||
makeActivityErrorEvent(WORKSPACE_ID, {
|
||||
status: "ok",
|
||||
error_detail: "this should not appear",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onSendError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call onSendError when error_detail is an empty string", () => {
|
||||
const onSendError = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onSendError };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(
|
||||
makeActivityErrorEvent(WORKSPACE_ID, {
|
||||
error_detail: "",
|
||||
summary: "",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// Empty strings are falsy — falls through to the generic hint
|
||||
expect(onSendError).toHaveBeenCalledTimes(1);
|
||||
expect(onSendError.mock.calls[0][0]).toContain("Agent error");
|
||||
expect(onSendError.mock.calls[0][0]).not.toContain("workspace logs");
|
||||
});
|
||||
|
||||
it("prefers error_detail over summary (error_detail is more actionable)", () => {
|
||||
const onSendError = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onSendError };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(
|
||||
makeActivityErrorEvent(WORKSPACE_ID, {
|
||||
error_detail: "403: api_key_expired",
|
||||
summary: "A2A request failed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onSendError).toHaveBeenCalledTimes(1);
|
||||
expect(onSendError.mock.calls[0][0]).toBe("403: api_key_expired");
|
||||
});
|
||||
|
||||
it("does NOT call onSendError when onSendError is undefined (no-op guard)", () => {
|
||||
const callbacks: UseChatSocketCallbacks = { onAgentMessage: vi.fn() };
|
||||
expect(() =>
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks)),
|
||||
).not.toThrow();
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(
|
||||
makeActivityErrorEvent(WORKSPACE_ID, {
|
||||
error_detail: "some error",
|
||||
}),
|
||||
);
|
||||
});
|
||||
// No error thrown even without onSendError
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,6 @@ export function useChatSocket(
|
||||
const targetId = (p.target_id as string) || "";
|
||||
const durationMs = p.duration_ms as number | undefined;
|
||||
const summary = (p.summary as string) || "";
|
||||
const errorDetail = typeof p.error_detail === "string" ? p.error_detail : "";
|
||||
|
||||
let line = "";
|
||||
if (type === "a2a_receive" && method === "message/send") {
|
||||
@@ -68,14 +67,9 @@ export function useChatSocket(
|
||||
const own = (targetId || msg.workspace_id) === workspaceId;
|
||||
if (own) {
|
||||
callbacksRef.current.onSendComplete?.();
|
||||
// Prefer the actionable error_detail from the workspace agent
|
||||
// (e.g. "403 Forbidden: oauth_org_not_allowed ...") over the
|
||||
// opaque generic. Fall back to a generic hint so the user
|
||||
// always sees something actionable. Closes #1420.
|
||||
const displayError = errorDetail
|
||||
|| summary
|
||||
|| "Agent error — please try again or check the agent's configuration.";
|
||||
callbacksRef.current.onSendError?.(displayError);
|
||||
callbacksRef.current.onSendError?.(
|
||||
"Agent error (Exception) — see workspace logs for details.",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (type === "a2a_send") {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for design-tokens.ts — STATUS_CONFIG, TIER_CONFIG, COMM_TYPE_LABELS
|
||||
* plus the statusDotClass function exported from design-tokens.ts.
|
||||
*
|
||||
* Note: statusDotClass is also tested in statusDotClass.test.ts; this file
|
||||
* covers the remaining exports and edge cases.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
STATUS_CONFIG,
|
||||
statusDotClass,
|
||||
TIER_CONFIG,
|
||||
COMM_TYPE_LABELS,
|
||||
} from "../design-tokens";
|
||||
|
||||
describe("STATUS_CONFIG", () => {
|
||||
it("has entries for all known status values", () => {
|
||||
const statuses = ["online", "offline", "paused", "degraded", "failed", "provisioning", "not_configured"];
|
||||
for (const s of statuses) {
|
||||
expect(STATUS_CONFIG[s]).toBeTruthy();
|
||||
expect(typeof STATUS_CONFIG[s].dot).toBe("string");
|
||||
expect(typeof STATUS_CONFIG[s].label).toBe("string");
|
||||
expect(typeof STATUS_CONFIG[s].bar).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("provisioning has motion-safe:animate-pulse in dot class", () => {
|
||||
expect(STATUS_CONFIG.provisioning.dot).toContain("animate-pulse");
|
||||
});
|
||||
|
||||
it("failed and degraded have glow classes", () => {
|
||||
expect(STATUS_CONFIG.failed.glow).toBeTruthy();
|
||||
expect(STATUS_CONFIG.degraded.glow).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("statusDotClass", () => {
|
||||
it("returns dot class for known status", () => {
|
||||
expect(statusDotClass("online")).toBe("bg-emerald-400");
|
||||
});
|
||||
|
||||
it("returns fallback bg-zinc-500 for unknown status", () => {
|
||||
expect(statusDotClass("nonsense")).toBe("bg-zinc-500");
|
||||
});
|
||||
|
||||
it("returns fallback bg-zinc-500 for empty string", () => {
|
||||
expect(statusDotClass("")).toBe("bg-zinc-500");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TIER_CONFIG", () => {
|
||||
it("has entries for tiers 1-4", () => {
|
||||
for (let tier = 1; tier <= 4; tier++) {
|
||||
expect(TIER_CONFIG[tier]).toBeTruthy();
|
||||
expect(typeof TIER_CONFIG[tier].label).toBe("string");
|
||||
expect(typeof TIER_CONFIG[tier].color).toBe("string");
|
||||
expect(typeof TIER_CONFIG[tier].border).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("tier labels are T{num}", () => {
|
||||
expect(TIER_CONFIG[1].label).toBe("T1");
|
||||
expect(TIER_CONFIG[2].label).toBe("T2");
|
||||
expect(TIER_CONFIG[3].label).toBe("T3");
|
||||
expect(TIER_CONFIG[4].label).toBe("T4");
|
||||
});
|
||||
|
||||
it("tier 1 uses ink-mid (safe/read-only)", () => {
|
||||
expect(TIER_CONFIG[1].color).toContain("text-ink-mid");
|
||||
});
|
||||
|
||||
it("tier 2 uses accent (full agents, read+write)", () => {
|
||||
expect(TIER_CONFIG[2].color).toContain("bg-accent");
|
||||
});
|
||||
|
||||
it("tier 3 uses violet (privileged)", () => {
|
||||
expect(TIER_CONFIG[3].color).toContain("bg-violet-600");
|
||||
});
|
||||
|
||||
it("tier 4 uses warm (full-host)", () => {
|
||||
expect(TIER_CONFIG[4].color).toContain("bg-warm");
|
||||
});
|
||||
});
|
||||
|
||||
describe("COMM_TYPE_LABELS", () => {
|
||||
it("maps a2a_send to 'sent'", () => {
|
||||
expect(COMM_TYPE_LABELS.a2a_send).toBe("sent");
|
||||
});
|
||||
|
||||
it("maps a2a_receive to 'received'", () => {
|
||||
expect(COMM_TYPE_LABELS.a2a_receive).toBe("received");
|
||||
});
|
||||
|
||||
it("maps task_update to 'task update'", () => {
|
||||
expect(COMM_TYPE_LABELS.task_update).toBe("task update");
|
||||
});
|
||||
});
|
||||
@@ -1,205 +1,108 @@
|
||||
// @vitest-environment jsdom
|
||||
"use client";
|
||||
/**
|
||||
* Tests for palette-context.tsx — MobileAccentProvider context + usePalette hook.
|
||||
* Tests for palette-context.tsx — normalizeStatus, tierCode, getPalette.
|
||||
*
|
||||
* Test coverage (9 cases):
|
||||
* 1. MobileAccentProvider renders children
|
||||
* 2. usePalette(false) without provider → MOL_LIGHT
|
||||
* 3. usePalette(true) without provider → MOL_DARK
|
||||
* 4. accent=null returns base palette unchanged
|
||||
* 5. accent=base.accent returns base palette unchanged (identity guard)
|
||||
* 6. accent="#custom" overrides both accent and online
|
||||
* 7. MOL_LIGHT singleton never mutated
|
||||
* 8. MOL_DARK singleton never mutated
|
||||
*
|
||||
* Plus pure-function coverage for normalizeStatus + tierCode.
|
||||
* Pure functions that don't require the React context to test.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import React from "react";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
MOL_LIGHT,
|
||||
MOL_DARK,
|
||||
getPalette,
|
||||
normalizeStatus,
|
||||
tierCode,
|
||||
MobileAccentProvider,
|
||||
usePalette,
|
||||
getPalette,
|
||||
MOL_LIGHT,
|
||||
MOL_DARK,
|
||||
} from "../palette-context";
|
||||
|
||||
// ─── usePalette test helper ───────────────────────────────────────────────────
|
||||
// usePalette reads document.documentElement.dataset.theme internally.
|
||||
// We set this before rendering so the hook sees the right value.
|
||||
|
||||
function setDataTheme(theme: "light" | "dark") {
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pure function tests ──────────────────────────────────────────────────────
|
||||
|
||||
describe("normalizeStatus", () => {
|
||||
it("returns emerald-400 for online status", () => {
|
||||
it("online → bg-emerald-400", () => {
|
||||
expect(normalizeStatus("online", false)).toBe("bg-emerald-400");
|
||||
expect(normalizeStatus("online", true)).toBe("bg-emerald-400");
|
||||
});
|
||||
|
||||
it("returns emerald-400 for degraded status", () => {
|
||||
it("degraded → bg-emerald-400", () => {
|
||||
expect(normalizeStatus("degraded", false)).toBe("bg-emerald-400");
|
||||
expect(normalizeStatus("degraded", true)).toBe("bg-emerald-400");
|
||||
});
|
||||
|
||||
it("returns red-400 for failed status", () => {
|
||||
it("failed → bg-red-400", () => {
|
||||
expect(normalizeStatus("failed", false)).toBe("bg-red-400");
|
||||
expect(normalizeStatus("failed", true)).toBe("bg-red-400");
|
||||
});
|
||||
|
||||
it("returns amber-400 for paused status", () => {
|
||||
it("paused → bg-amber-400", () => {
|
||||
expect(normalizeStatus("paused", false)).toBe("bg-amber-400");
|
||||
expect(normalizeStatus("paused", true)).toBe("bg-amber-400");
|
||||
});
|
||||
|
||||
it("returns amber-400 for not_configured status", () => {
|
||||
it("not_configured → bg-amber-400", () => {
|
||||
expect(normalizeStatus("not_configured", false)).toBe("bg-amber-400");
|
||||
});
|
||||
|
||||
it("returns zinc-400 for unknown status", () => {
|
||||
expect(normalizeStatus("unknown", false)).toBe("bg-zinc-400");
|
||||
it("unknown status → bg-zinc-400", () => {
|
||||
expect(normalizeStatus("offline", false)).toBe("bg-zinc-400");
|
||||
expect(normalizeStatus("provisioning", false)).toBe("bg-zinc-400");
|
||||
expect(normalizeStatus("nonsense", false)).toBe("bg-zinc-400");
|
||||
expect(normalizeStatus("", false)).toBe("bg-zinc-400");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tierCode", () => {
|
||||
it("returns T1 for tier 1", () => {
|
||||
it("maps tier 1-4 to T1-T4", () => {
|
||||
expect(tierCode(1)).toBe("T1");
|
||||
});
|
||||
|
||||
it("returns T2 for tier 2", () => {
|
||||
expect(tierCode(2)).toBe("T2");
|
||||
});
|
||||
|
||||
it("returns T4 for tier 4", () => {
|
||||
expect(tierCode(3)).toBe("T3");
|
||||
expect(tierCode(4)).toBe("T4");
|
||||
});
|
||||
|
||||
it("returns generic T{n} for non-standard tiers", () => {
|
||||
expect(tierCode(99)).toBe("T99");
|
||||
it("negative tier", () => {
|
||||
expect(tierCode(0)).toBe("T0");
|
||||
expect(tierCode(-1)).toBe("T-1");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getPalette tests ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("getPalette — accent override", () => {
|
||||
it("accent=null returns base palette unchanged (light)", () => {
|
||||
const result = getPalette(null, false);
|
||||
expect(result).toEqual({ ...MOL_LIGHT });
|
||||
expect(result).not.toBe(MOL_LIGHT); // returned object is a copy
|
||||
describe("getPalette", () => {
|
||||
it("null accent with light → MOL_LIGHT", () => {
|
||||
const p = getPalette(null, false);
|
||||
expect(p.accent).toBe(MOL_LIGHT.accent);
|
||||
expect(p.online).toBe(MOL_LIGHT.online);
|
||||
});
|
||||
|
||||
it("accent=null returns base palette unchanged (dark)", () => {
|
||||
const result = getPalette(null, true);
|
||||
expect(result).toEqual({ ...MOL_DARK });
|
||||
expect(result).not.toBe(MOL_DARK);
|
||||
it("null accent with dark → MOL_DARK", () => {
|
||||
const p = getPalette(null, true);
|
||||
expect(p.accent).toBe(MOL_DARK.accent);
|
||||
expect(p.online).toBe(MOL_DARK.online);
|
||||
});
|
||||
|
||||
it("accent=base.accent returns base palette unchanged (identity guard, light)", () => {
|
||||
const result = getPalette(MOL_LIGHT.accent, false);
|
||||
expect(result).toEqual({ ...MOL_LIGHT });
|
||||
expect(result).not.toBe(MOL_LIGHT);
|
||||
it("returns a new object, not the singleton", () => {
|
||||
const p = getPalette(null, false);
|
||||
expect(p).not.toBe(MOL_LIGHT);
|
||||
expect(p).not.toBe(MOL_DARK);
|
||||
});
|
||||
|
||||
it("accent=base.accent returns base palette unchanged (identity guard, dark)", () => {
|
||||
const result = getPalette(MOL_DARK.accent, true);
|
||||
expect(result).toEqual({ ...MOL_DARK });
|
||||
expect(result).not.toBe(MOL_DARK);
|
||||
it("identity guard: same accent as base → returns copy of base", () => {
|
||||
const p = getPalette(MOL_LIGHT.accent, false);
|
||||
expect(p.accent).toBe(MOL_LIGHT.accent);
|
||||
expect(p).not.toBe(MOL_LIGHT);
|
||||
});
|
||||
|
||||
it("accent='#custom' overrides accent and online (light)", () => {
|
||||
const result = getPalette("#ff0000", false);
|
||||
expect(result.accent).toBe("#ff0000");
|
||||
expect(result.online).toBe("bg-emerald-400"); // normalizeStatus("online", false)
|
||||
it("custom accent → overrides accent and online", () => {
|
||||
const p = getPalette("#ff0000", false);
|
||||
expect(p.accent).toBe("#ff0000");
|
||||
// online should be normalizeStatus("online", false) = bg-emerald-400
|
||||
expect(p.online).toBe("bg-emerald-400");
|
||||
// other fields unchanged
|
||||
expect(p.ink).toBe(MOL_LIGHT.ink);
|
||||
expect(p.surface).toBe(MOL_LIGHT.surface);
|
||||
});
|
||||
|
||||
it("accent='#custom' overrides accent and online (dark)", () => {
|
||||
const result = getPalette("#00ff00", true);
|
||||
expect(result.accent).toBe("#00ff00");
|
||||
expect(result.online).toBe("bg-emerald-400"); // normalizeStatus("online", true)
|
||||
it("custom accent in dark mode", () => {
|
||||
const p = getPalette("#00ff00", true);
|
||||
expect(p.accent).toBe("#00ff00");
|
||||
expect(p.online).toBe("bg-emerald-400"); // normalizeStatus is dark-agnostic for online
|
||||
});
|
||||
|
||||
it("MOL_LIGHT singleton is never mutated", () => {
|
||||
getPalette("#mutate", false);
|
||||
// All fields must still match the original freeze definition
|
||||
expect(MOL_LIGHT.accent).toBe("bg-blue-500");
|
||||
expect(MOL_LIGHT.online).toBe("bg-emerald-400");
|
||||
expect(MOL_LIGHT.surface).toBe("bg-zinc-900");
|
||||
expect(MOL_LIGHT.ink).toBe("text-zinc-100");
|
||||
expect(MOL_LIGHT.line).toBe("border-zinc-700");
|
||||
expect(MOL_LIGHT.bg).toBe("bg-zinc-950");
|
||||
});
|
||||
|
||||
it("MOL_DARK singleton is never mutated", () => {
|
||||
getPalette("#mutate", true);
|
||||
expect(MOL_DARK.accent).toBe("bg-sky-400");
|
||||
expect(MOL_DARK.online).toBe("bg-emerald-400");
|
||||
expect(MOL_DARK.surface).toBe("bg-zinc-800");
|
||||
expect(MOL_DARK.ink).toBe("text-zinc-100");
|
||||
expect(MOL_DARK.line).toBe("border-zinc-700");
|
||||
expect(MOL_DARK.bg).toBe("bg-zinc-950");
|
||||
});
|
||||
|
||||
it("getPalette always returns a new object (no shared mutation risk)", () => {
|
||||
const a = getPalette("#a", false);
|
||||
const b = getPalette("#b", false);
|
||||
expect(a).not.toBe(b);
|
||||
expect(a.accent).not.toBe(b.accent);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── MobileAccentProvider tests ───────────────────────────────────────────────
|
||||
|
||||
describe("MobileAccentProvider", () => {
|
||||
beforeEach(() => {
|
||||
setDataTheme("light");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.dataset.theme = "";
|
||||
}
|
||||
});
|
||||
|
||||
it("renders children", () => {
|
||||
render(
|
||||
<MobileAccentProvider accent={null}>
|
||||
<span data-testid="child">Hello</span>
|
||||
</MobileAccentProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("child")).toBeTruthy();
|
||||
});
|
||||
|
||||
// usePalette hook reads data-theme from <html> to determine light/dark.
|
||||
// In the test environment, data-theme is empty, which falls through to
|
||||
// the "light" default in usePalette, giving MOL_LIGHT.
|
||||
it("usePalette(false) without provider → MOL_LIGHT", () => {
|
||||
setDataTheme("light");
|
||||
function ShowPalette() {
|
||||
const p = usePalette(false);
|
||||
return <span data-testid="accent-light">{p.accent}</span>;
|
||||
}
|
||||
render(<ShowPalette />);
|
||||
expect(screen.getByTestId("accent-light").textContent).toBe(MOL_LIGHT.accent);
|
||||
});
|
||||
|
||||
it("usePalette(true) without provider → MOL_DARK when data-theme=dark", () => {
|
||||
setDataTheme("dark");
|
||||
function ShowPalette() {
|
||||
const p = usePalette(true);
|
||||
return <span data-testid="accent-dark">{p.accent}</span>;
|
||||
}
|
||||
render(<ShowPalette />);
|
||||
expect(screen.getByTestId("accent-dark").textContent).toBe(MOL_DARK.accent);
|
||||
it("custom accent does not mutate MOL_LIGHT or MOL_DARK", () => {
|
||||
getPalette("#custom", false);
|
||||
expect(MOL_LIGHT.accent).toBe("bg-blue-500"); // unchanged
|
||||
getPalette("#custom2", true);
|
||||
expect(MOL_DARK.accent).toBe("bg-sky-400"); // unchanged
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for theme-provider.tsx.
|
||||
*
|
||||
* Re-export contract:
|
||||
* - THEME_COOKIE value (string "mol_theme") from theme-cookie
|
||||
* - themeBootScript value from theme-cookie
|
||||
* - ThemePreference + ResolvedTheme types (runtime value = undefined)
|
||||
*
|
||||
* The ThemeProvider component itself requires full React context rendering;
|
||||
* prop contract is enforced by TypeScript.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
describe("applyResolvedTheme", () => {
|
||||
beforeEach(() => {
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
});
|
||||
|
||||
it("sets data-theme on html element", () => {
|
||||
document.documentElement.dataset.theme = "dark";
|
||||
expect(document.documentElement.dataset.theme).toBe("dark");
|
||||
document.documentElement.dataset.theme = "light";
|
||||
expect(document.documentElement.dataset.theme).toBe("light");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ThemeProvider component", () => {
|
||||
it("is a function (React component)", async () => {
|
||||
const { ThemeProvider } = await import("../theme-provider");
|
||||
expect(typeof ThemeProvider).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("re-exports from theme-cookie", () => {
|
||||
it("re-exports THEME_COOKIE = 'mol_theme'", async () => {
|
||||
const { THEME_COOKIE } = await import("../theme-provider");
|
||||
expect(THEME_COOKIE).toBe("mol_theme");
|
||||
});
|
||||
|
||||
it("re-exports themeBootScript as a string value", async () => {
|
||||
const { themeBootScript } = await import("../theme-provider");
|
||||
expect(typeof themeBootScript).toBe("string");
|
||||
expect(themeBootScript.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -672,13 +672,6 @@ func logActivityExec(ctx context.Context, exec activityExecutor, broadcaster eve
|
||||
if len(params.ToolTrace) > 0 {
|
||||
payload["tool_trace"] = json.RawMessage(params.ToolTrace)
|
||||
}
|
||||
// Include error_detail in the live broadcast so the canvas can surface
|
||||
// an actionable error reason (e.g. oauth_org_not_allowed) instead of the
|
||||
// opaque "Agent error (Exception)" fallback. The runtime's
|
||||
// report_activity helper caps this at 4096 chars.
|
||||
if params.ErrorDetail != nil {
|
||||
payload["error_detail"] = *params.ErrorDetail
|
||||
}
|
||||
// Include request/response bodies in the live broadcast so the
|
||||
// canvas's Agent Comms panel can render the actual task text
|
||||
// and reply text immediately, instead of falling back to the
|
||||
|
||||
@@ -934,93 +934,6 @@ func TestLogActivity_Broadcast_IncludesRequestAndResponseBodies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogActivity_Broadcast_IncludesErrorDetail pins the fix for #1420:
|
||||
// error_detail was stored in the DB but never included in the live
|
||||
// ACTIVITY_LOGGED WebSocket broadcast, so the canvas could only show
|
||||
// "Agent error (Exception) — see workspace logs for details." without
|
||||
// surfacing the actionable error reason (e.g. oauth_org_not_allowed).
|
||||
func TestLogActivity_Broadcast_IncludesErrorDetail(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
defer mock.ExpectationsWereMet()
|
||||
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
cb := &recordingBroadcaster{}
|
||||
srcID := "ws-canvas"
|
||||
tgtID := "ws-agent"
|
||||
method := "message/send"
|
||||
summary := "A2A request to ws-agent failed"
|
||||
errorDetail := "403 Forbidden: oauth_org_not_allowed — Your organization has disabled Claude subscription access. Use an API key or ask your admin to enable access."
|
||||
status := "error"
|
||||
|
||||
LogActivity(context.Background(), cb, ActivityParams{
|
||||
WorkspaceID: srcID,
|
||||
ActivityType: "a2a_receive",
|
||||
SourceID: &srcID,
|
||||
TargetID: &tgtID,
|
||||
Method: &method,
|
||||
Summary: &summary,
|
||||
Status: status,
|
||||
ErrorDetail: &errorDetail,
|
||||
})
|
||||
|
||||
if len(cb.calls) != 1 {
|
||||
t.Fatalf("expected 1 broadcast, got %d", len(cb.calls))
|
||||
}
|
||||
payload := cb.calls[0].payload
|
||||
if payload["activity_type"] != "a2a_receive" {
|
||||
t.Errorf("activity_type = %v, want a2a_receive", payload["activity_type"])
|
||||
}
|
||||
ed, ok := payload["error_detail"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("error_detail missing from broadcast payload: got %#v", payload["error_detail"])
|
||||
}
|
||||
if ed != errorDetail {
|
||||
t.Errorf("error_detail = %q, want %q", ed, errorDetail)
|
||||
}
|
||||
if payload["status"] != status {
|
||||
t.Errorf("status = %v, want %q", payload["status"], status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogActivity_Broadcast_OmitsNilErrorDetail verifies that when
|
||||
// ErrorDetail is nil the broadcast does not include an empty error_detail key
|
||||
// (matching the same omission pattern as request_body/response_body above).
|
||||
func TestLogActivity_Broadcast_OmitsNilErrorDetail(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
defer mock.ExpectationsWereMet()
|
||||
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
cb := &recordingBroadcaster{}
|
||||
srcID := "ws-canvas"
|
||||
tgtID := "ws-agent"
|
||||
method := "message/send"
|
||||
summary := "A2A request succeeded"
|
||||
status := "ok"
|
||||
|
||||
LogActivity(context.Background(), cb, ActivityParams{
|
||||
WorkspaceID: srcID,
|
||||
ActivityType: "a2a_receive",
|
||||
SourceID: &srcID,
|
||||
TargetID: &tgtID,
|
||||
Method: &method,
|
||||
Summary: &summary,
|
||||
Status: status,
|
||||
// ErrorDetail intentionally omitted (nil)
|
||||
})
|
||||
|
||||
if len(cb.calls) != 1 {
|
||||
t.Fatalf("expected 1 broadcast, got %d", len(cb.calls))
|
||||
}
|
||||
payload := cb.calls[0].payload
|
||||
if _, present := payload["error_detail"]; present {
|
||||
t.Errorf("error_detail should be omitted when nil, got %v", payload["error_detail"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogActivityTx_DefersBroadcastUntilCommitHook pins the #149
|
||||
// contract: LogActivityTx returns a commitHook that the caller MUST
|
||||
// invoke after tx.Commit(); the broadcast MUST NOT fire from inside
|
||||
|
||||
Reference in New Issue
Block a user