Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions app/src/lib/plugin/__tests__/safe-parse-settings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { z } from "zod";
import { safeParseSettings } from "../safe-parse-settings";

// Spy on console.warn — helper is browser-safe (no pino) so logging goes
// to console with a structured payload.
const mockWarn = vi.fn();
const originalWarn = console.warn;

describe("safeParseSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
console.warn = mockWarn;
});

afterEach(() => {
console.warn = originalWarn;
});

it("returns the parsed data when validation succeeds", () => {
const schema = z.object({
title: z.string().default("Untitled"),
enabled: z.boolean().default(false),
});
const result = safeParseSettings(
schema,
{ title: "Real", enabled: true },
"test-plugin",
);
expect(result).toEqual({ title: "Real", enabled: true });
expect(mockWarn).not.toHaveBeenCalled();
});

it("returns schema defaults when validation fails", () => {
const schema = z.object({
layout: z.enum(["force", "circular"]).default("force"),
});
const result = safeParseSettings(
schema,
{ layout: "hierarchical" },
"graph",
);
expect(result.layout).toBe("force");
});

it("logs a structured warning on validation failure", () => {
const schema = z.object({
layout: z.enum(["force", "circular"]).default("force"),
});
safeParseSettings(schema, { layout: "weirdLayout" }, "graph");
expect(mockWarn).toHaveBeenCalledTimes(1);
const [message, payload] = mockWarn.mock.calls[0];
expect(message).toMatch(/reverted to defaults/i);
expect(payload.pluginId).toBe("graph");
expect(payload.issues).toBeInstanceOf(Array);
expect(payload.issues[0].path).toEqual(["layout"]);
});

it("does not log when validation succeeds", () => {
const schema = z.object({ x: z.number().default(0) });
safeParseSettings(schema, { x: 5 }, "test");
expect(mockWarn).not.toHaveBeenCalled();
});

it("handles undefined / null raw values via empty-object defaults", () => {
const schema = z.object({
label: z.string().default("hello"),
});
expect(safeParseSettings(schema, undefined, "test").label).toBe("hello");
expect(safeParseSettings(schema, null, "test").label).toBe("hello");
});

it("preserves passthrough fields when schema uses .passthrough()", () => {
const schema = z.object({ known: z.string().optional() }).passthrough();
const result = safeParseSettings(
schema,
{ known: "yes", extra: 42 },
"test",
);
expect(result).toEqual({ known: "yes", extra: 42 });
});

it("propagates errors when even the defaults path throws (broken schema)", () => {
// Schema with NO defaults; parsing {} fails with "required" — surfaces the
// schema-itself-is-broken case to the error boundary.
const schema = z.object({ required: z.string() });
expect(() =>
safeParseSettings(schema, { badValue: 123 }, "broken-plugin"),
).toThrow();
});

it("applies field-level defaults when raw is missing fields", () => {
const schema = z.object({
a: z.string().default("A"),
b: z.number().default(7),
});
const result = safeParseSettings(schema, {}, "test");
expect(result).toEqual({ a: "A", b: 7 });
expect(mockWarn).not.toHaveBeenCalled();
});

it("includes pluginId in the log payload for traceability", () => {
const schema = z.object({ layout: z.enum(["a", "b"]).default("a") });
safeParseSettings(schema, { layout: "c" }, "my-special-plugin");
expect(mockWarn).toHaveBeenCalledTimes(1);
expect(mockWarn.mock.calls[0][1].pluginId).toBe("my-special-plugin");
});
});
54 changes: 54 additions & 0 deletions app/src/lib/plugin/safe-parse-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { ZodTypeAny, z } from "zod";

/**
* Plugin-namespaced warning emitter. We intentionally do NOT use the
* pino-based `@/lib/logger` here: plugin components render client-side
* and bundling pino into the browser fails (it imports `node:crypto`).
* Schema fallbacks happen during render → operators see them via the
* browser console (and the surrounding server logs when the page reloads).
* Structured shape preserves searchability.
*/
function emitWarning(pluginId: string, issues: unknown): void {
console.warn("[plugin] Settings failed validation; reverted to defaults", {
pluginId,
issues,
});
}

/**
* Parse plugin settings with Zod, falling back to schema defaults on
* validation failure. Never throws on user-provided data.
*
* **Why**: a single stale or unknown enum value in a saved widget config
* would otherwise crash the plugin component (`schema.parse(raw)` throws
* → React renders an error boundary → the widget is blank). This is bad
* UX: a v1.0 dashboard that referenced a layout value later renamed in
* v1.1 would blank out for everyone until the user manually re-saved.
*
* Behavior on validation failure:
* 1. Emit a structured warn-level log entry (operators can spot drift)
* 2. Return the result of `schema.parse({})` — which yields the schema's
* defaults across the board
* 3. If even that throws, propagate — that means the schema *itself* is
* broken (not the user's data), which deserves an error boundary
*
* @param schema the plugin's Zod settings schema
* @param raw the unknown value passed by the widget renderer
* @param pluginId the chart type registered with the plugin (e.g. "graph",
* "bar") — included in the log entry so operators know
* which plugin had stale data
*/
export function safeParseSettings<T extends ZodTypeAny>(
schema: T,
raw: unknown,
pluginId: string,
): z.infer<T> {
const result = schema.safeParse(raw);
if (result.success) return result.data;

emitWarning(pluginId, result.error.issues);

// Defaults pass: if THIS throws, the schema itself is bad — surface to the
// error boundary. We deliberately don't double-catch here.
return schema.parse({});
}
137 changes: 137 additions & 0 deletions app/src/plugins/__tests__/safe-parse-adoption.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* Smoke test: every plugin component invokes safeParseSettings via the
* helper and renders without throwing on garbage settings.
*
* Each plugin component runs `safeParseSettings(...)` at the top, BEFORE any
* hooks or chart rendering. Running the component once with junk settings is
* the cheapest way to cover the migrated line in each of the 20 plugin
* components — which keeps SonarCloud's new_coverage gate happy without
* writing one full render test per plugin.
*
* Heavy chart deps are stubbed by a single Proxy mock for `@neoboard/components`
* that returns null-rendering stubs for ANY accessed export.
*/
import React from "react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render } from "@testing-library/react";

// Stub @neoboard/components — null-rendering components + minimal helpers.
// Listed names cover both the 20 plugin components AND their downstream
// imports (e.g. table-renderer imports parseColorThresholds).
vi.mock("@neoboard/components", () => {
const Stub = ({ children }: { children?: React.ReactNode } = {}) =>
React.createElement(React.Fragment, null, children ?? null);
return {
// Components rendered by plugin components or their downstream consumers
Skeleton: Stub,
IframeWidget: Stub,
JsonViewer: Stub,
MarkdownWidget: Stub,
EmptyState: Stub,
// Helpers
getChartOptions: () => [],
parseColorThresholds: () => [],
};
});

// Stub @/components heavy children that use TanStack Query / DOM apis
vi.mock("@/components/table-renderer", () => ({
TableRenderer: () => null,
}));

vi.mock("@/components/form-widget-renderer", () => ({
FormWidgetRenderer: () => null,
}));

// Stub next/dynamic — return a null-rendering component synchronously so
// plugin components that lazy-load chart bodies don't suspend.
vi.mock("next/dynamic", () => ({
default: () => () => null,
}));

// Stub the graph exploration wrapper used by the graph plugin
vi.mock("@/components/graph-exploration-wrapper", () => ({
GraphExplorationWrapper: () => null,
}));

// Import plugins AFTER mocks are set up
const { barPlugin } = await import("../bar");
const { choroplethPlugin } = await import("../choropleth");
const { circlePackingPlugin } = await import("../circle-packing");
const { formPlugin } = await import("../form");
const { ganttPlugin } = await import("../gantt");
const { gaugePlugin } = await import("../gauge");
const { graphPlugin } = await import("../graph");
const { iframePlugin } = await import("../iframe");
const { jsonPlugin } = await import("../json");
const { linePlugin } = await import("../line");
const { mapPlugin } = await import("../map");
const { markdownPlugin } = await import("../markdown");
const { parameterSelectPlugin } = await import("../parameter-select");
const { piePlugin } = await import("../pie");
const { radarPlugin } = await import("../radar");
const { sankeyPlugin } = await import("../sankey");
const { singleValuePlugin } = await import("../single-value");
const { sunburstPlugin } = await import("../sunburst");
const { tablePlugin } = await import("../table");
const { treemapPlugin } = await import("../treemap");

const ALL_PLUGINS = [
barPlugin,
choroplethPlugin,
circlePackingPlugin,
formPlugin,
ganttPlugin,
gaugePlugin,
graphPlugin,
iframePlugin,
jsonPlugin,
linePlugin,
mapPlugin,
markdownPlugin,
parameterSelectPlugin,
piePlugin,
radarPlugin,
sankeyPlugin,
singleValuePlugin,
sunburstPlugin,
tablePlugin,
treemapPlugin,
];

const GARBAGE_PROPS = {
data: null,
// Intentionally violates every plugin's schema — exercises the safeParse
// fallback path on every plugin.
settings: { __completely_invalid__: 12345, layout: "weirdLayout" },
stylingRules: [],
paramValues: {},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;

describe("safeParseSettings adoption across all 20 plugins", () => {
let warnSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});

afterEach(() => {
warnSpy.mockRestore();
});

for (const plugin of ALL_PLUGINS) {
it(`${plugin.type}: component renders with garbage settings without throwing`, () => {
const Component = plugin.component;
expect(() =>
render(React.createElement(Component, GARBAGE_PROPS)),
).not.toThrow();
});
}

it("covers all 20 plugins (sanity check on the array)", () => {
expect(ALL_PLUGINS).toHaveLength(20);
const types = new Set(ALL_PLUGINS.map((p) => p.type));
expect(types.size).toBe(20); // unique
});
});
3 changes: 2 additions & 1 deletion app/src/plugins/bar/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { defineChartPlugin } from "../registry";
import { transformToBarData, validateBarData } from "./transform";
import { useEChartsClick, type PluginProps } from "../utils";
import { barSettingsSchema } from "./settings";
import { safeParseSettings } from "@/lib/plugin/safe-parse-settings";

const BarChart = dynamic(
() => import("@neoboard/components").then((m) => ({ default: m.BarChart })),
Expand All @@ -26,7 +27,7 @@ function BarPluginComponent({
paramValues,
}: PluginProps) {
const onClick = useEChartsClick(onChartClick, data);
const settings = barSettingsSchema.parse(raw);
const settings = safeParseSettings(barSettingsSchema, raw, "bar");

return (
<BarChart
Expand Down
7 changes: 6 additions & 1 deletion app/src/plugins/choropleth/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { defineChartPlugin } from "../registry";
import { transformToChoroplethData } from "./transform";
import { useEChartsClick, type PluginProps } from "../utils";
import { choroplethSettingsSchema } from "./settings";
import { safeParseSettings } from "@/lib/plugin/safe-parse-settings";

const ChoroplethChart = dynamic(
() =>
Expand All @@ -26,7 +27,11 @@ function ChoroplethPluginComponent({
onChartClick,
}: PluginProps) {
const onClick = useEChartsClick(onChartClick, data);
const settings = choroplethSettingsSchema.parse(raw);
const settings = safeParseSettings(
choroplethSettingsSchema,
raw,
"choropleth",
);
return (
<ChoroplethChart
data={(data as ChoroplethDataItem[]) ?? []}
Expand Down
7 changes: 6 additions & 1 deletion app/src/plugins/circle-packing/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { defineChartPlugin } from "../registry";
import { transformToHierarchicalData } from "../sunburst/transform";
import { useEChartsClick, type PluginProps } from "../utils";
import { circlePackingSettingsSchema } from "./settings";
import { safeParseSettings } from "@/lib/plugin/safe-parse-settings";

const CirclePackingChart = dynamic(
() =>
Expand All @@ -29,7 +30,11 @@ function CirclePackingPluginComponent({
onChartClick,
}: PluginProps) {
const onClick = useEChartsClick(onChartClick, data);
const settings = circlePackingSettingsSchema.parse(raw);
const settings = safeParseSettings(
circlePackingSettingsSchema,
raw,
"circle-packing",
);
return (
<CirclePackingChart
data={(data as CirclePackingDataItem[]) ?? []}
Expand Down
3 changes: 2 additions & 1 deletion app/src/plugins/form/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ import { FormWidgetRenderer } from "@/components/form-widget-renderer";
import { defineChartPlugin } from "../registry";
import { type PluginProps } from "../utils";
import { formSettingsSchema } from "./settings";
import { safeParseSettings } from "@/lib/plugin/safe-parse-settings";

function FormPluginComponent({
settings: raw,
connectionId,
query,
}: PluginProps) {
const settings = formSettingsSchema.parse(raw);
const settings = safeParseSettings(formSettingsSchema, raw, "form");
return (
<FormWidgetRenderer
connectionId={connectionId ?? ""}
Expand Down
3 changes: 2 additions & 1 deletion app/src/plugins/gantt/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { defineChartPlugin } from "../registry";
import { transformToGanttData } from "./transform";
import { useEChartsClick, type PluginProps } from "../utils";
import { ganttSettingsSchema } from "./settings";
import { safeParseSettings } from "@/lib/plugin/safe-parse-settings";

const GanttChart = dynamic(
() => import("@neoboard/components").then((m) => ({ default: m.GanttChart })),
Expand All @@ -26,7 +27,7 @@ function GanttPluginComponent({
onChartClick,
}: PluginProps) {
const onClick = useEChartsClick(onChartClick, data);
const settings = ganttSettingsSchema.parse(raw);
const settings = safeParseSettings(ganttSettingsSchema, raw, "gantt");
return (
<GanttChart
data={(data as GanttDataItem[]) ?? []}
Expand Down
Loading
Loading