@wopr-network/platform-ui-core 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-ui-core",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Brand-agnostic AI agent platform UI — deploy as any brand via env vars",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,179 @@
1
+ import { act, render, screen, waitFor } from "@testing-library/react";
2
+ import type React from "react";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ // Mock next/navigation
6
+ vi.mock("next/navigation", () => ({
7
+ useRouter: () => ({ push: vi.fn() }),
8
+ }));
9
+
10
+ // Mock framer-motion to avoid animation issues in tests
11
+ vi.mock("framer-motion", () => ({
12
+ motion: {
13
+ div: ({
14
+ children,
15
+ variants: _variants,
16
+ initial: _initial,
17
+ animate: _animate,
18
+ whileHover: _whileHover,
19
+ whileTap: _whileTap,
20
+ transition: _transition,
21
+ style,
22
+ className,
23
+ }: React.PropsWithChildren<{
24
+ variants?: unknown;
25
+ initial?: unknown;
26
+ animate?: unknown;
27
+ whileHover?: unknown;
28
+ whileTap?: unknown;
29
+ transition?: unknown;
30
+ style?: React.CSSProperties;
31
+ className?: string;
32
+ }>) => (
33
+ <div style={style} className={className}>
34
+ {children}
35
+ </div>
36
+ ),
37
+ },
38
+ AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}</>,
39
+ }));
40
+
41
+ // Mock plugin setup chat hook
42
+ vi.mock("@/hooks/use-plugin-setup-chat", () => ({
43
+ usePluginSetupChat: () => ({
44
+ state: {
45
+ isOpen: false,
46
+ pluginName: "",
47
+ messages: [],
48
+ isConnected: false,
49
+ isTyping: false,
50
+ isComplete: false,
51
+ },
52
+ sendMessage: vi.fn(),
53
+ closeSetup: vi.fn(),
54
+ openSetup: vi.fn(),
55
+ }),
56
+ }));
57
+
58
+ // Mock brand config
59
+ vi.mock("@/lib/brand-config", () => ({
60
+ getBrandConfig: () => ({ productName: "TestProduct", brandName: "TestBrand" }),
61
+ }));
62
+
63
+ // Mock plugin-setup component
64
+ vi.mock("@/components/plugin-setup", () => ({
65
+ SetupChatPanel: () => null,
66
+ }));
67
+
68
+ const mockTogglePluginEnabled = vi.fn();
69
+ const mockListInstalledPlugins = vi.fn();
70
+
71
+ vi.mock("@/lib/marketplace-data", () => ({
72
+ listBots: vi.fn().mockResolvedValue([{ id: "bot-1", name: "Test Bot" }]),
73
+ listMarketplacePlugins: vi.fn().mockResolvedValue([
74
+ {
75
+ id: "plugin-1",
76
+ name: "Test Plugin",
77
+ description: "A test plugin",
78
+ version: "1.0.0",
79
+ color: "#ff0000",
80
+ tags: [],
81
+ capabilities: [],
82
+ installCount: 100,
83
+ author: "Test",
84
+ category: "integration",
85
+ configSchema: [],
86
+ },
87
+ ]),
88
+ listInstalledPlugins: (...args: unknown[]) => mockListInstalledPlugins(...args),
89
+ togglePluginEnabled: (...args: unknown[]) => mockTogglePluginEnabled(...args),
90
+ formatInstallCount: (n: number) => String(n),
91
+ hasHostedOption: () => false,
92
+ }));
93
+
94
+ import PluginsPage from "@/app/plugins/page";
95
+
96
+ describe("togglePlugin race condition", () => {
97
+ beforeEach(() => {
98
+ vi.clearAllMocks();
99
+ mockListInstalledPlugins.mockResolvedValue([{ pluginId: "plugin-1", enabled: true }]);
100
+ // Make togglePluginEnabled slow so we can test in-flight guard
101
+ mockTogglePluginEnabled.mockImplementation(
102
+ () => new Promise((resolve) => setTimeout(resolve, 500)),
103
+ );
104
+ });
105
+
106
+ it("blocks a second toggle while the first is in flight (stale closure exposes race)", async () => {
107
+ // Use a deferred promise instead of a real timer to avoid async leak on test teardown
108
+ let resolveToggle!: () => void;
109
+ const togglePromise = new Promise<void>((resolve) => {
110
+ resolveToggle = resolve;
111
+ });
112
+ mockTogglePluginEnabled.mockReturnValue(togglePromise);
113
+
114
+ render(<PluginsPage />);
115
+
116
+ // Wait for the page to fully load
117
+ const toggle = await screen.findByRole("switch", { name: /toggle test plugin/i });
118
+ expect(toggle).toBeInTheDocument();
119
+
120
+ // Simulate rapid double-click by firing two change events synchronously
121
+ // WITHOUT awaiting between them — this exposes the stale closure bug because
122
+ // React hasn't re-rendered between the two calls, so the `toggling` state
123
+ // variable is still `null` in both closures.
124
+ const switchEl = toggle;
125
+
126
+ // Fire two synthetic events in the same synchronous batch
127
+ act(() => {
128
+ switchEl.click();
129
+ switchEl.click();
130
+ });
131
+
132
+ // After settling, only ONE API call should have been made
133
+ await waitFor(() => {
134
+ // If the stale closure bug exists, this will be 2. With the ref fix, it will be 1.
135
+ expect(mockTogglePluginEnabled).toHaveBeenCalledTimes(1);
136
+ });
137
+
138
+ // Resolve the deferred promise to drain the pending async operation before teardown
139
+ await act(async () => {
140
+ resolveToggle();
141
+ });
142
+ });
143
+
144
+ it("allows toggling again after the first completes", async () => {
145
+ // Fast resolution for this test
146
+ mockTogglePluginEnabled.mockResolvedValue(undefined);
147
+ mockListInstalledPlugins
148
+ .mockResolvedValueOnce([{ pluginId: "plugin-1", enabled: true }]) // initial load
149
+ .mockResolvedValueOnce([{ pluginId: "plugin-1", enabled: false }]) // after first toggle
150
+ .mockResolvedValueOnce([{ pluginId: "plugin-1", enabled: true }]); // after second toggle
151
+
152
+ render(<PluginsPage />);
153
+
154
+ const toggle = await screen.findByRole("switch", { name: /toggle test plugin/i });
155
+
156
+ // First toggle
157
+ act(() => {
158
+ toggle.click();
159
+ });
160
+
161
+ await waitFor(() => {
162
+ expect(mockTogglePluginEnabled).toHaveBeenCalledTimes(1);
163
+ });
164
+
165
+ // Wait for the toggle to complete (ref cleared, switch re-enabled)
166
+ await waitFor(() => {
167
+ expect(toggle).not.toBeDisabled();
168
+ });
169
+
170
+ // Second toggle — should work now that the first is complete
171
+ act(() => {
172
+ toggle.click();
173
+ });
174
+
175
+ await waitFor(() => {
176
+ expect(mockTogglePluginEnabled).toHaveBeenCalledTimes(2);
177
+ });
178
+ });
179
+ });
@@ -73,12 +73,14 @@ export default function PluginsPage() {
73
73
  const [catalog, setCatalog] = useState<PluginManifest[]>([]);
74
74
  const [loading, setLoading] = useState(true);
75
75
  const [installed, setInstalled] = useState<InstalledPlugin[]>([]);
76
+ const installedRef = useRef<InstalledPlugin[]>([]);
76
77
  const [search, setSearch] = useState("");
77
78
  const [bots, setBots] = useState<BotSummary[]>([]);
78
79
  const [selectedBotId, setSelectedBotId] = useState<string | null>(null);
79
80
  const selectedBotIdRef = useRef<string | null>(null);
80
81
  const [botsLoading, setBotsLoading] = useState(true);
81
82
  const [toggling, setToggling] = useState<string | null>(null);
83
+ const togglingRef = useRef<string | null>(null);
82
84
  const [toggleError, setToggleError] = useState<string | null>(null);
83
85
  const [installedPage, setInstalledPage] = useState(1);
84
86
  const [catalogPage, setCatalogPage] = useState(1);
@@ -128,6 +130,9 @@ export default function PluginsPage() {
128
130
  loadCatalog();
129
131
  }, [loadCatalog]);
130
132
 
133
+ // Keep installedRef current so togglePlugin can read installed without being in useCallback deps
134
+ installedRef.current = installed;
135
+
131
136
  // Load installed plugins when bot changes
132
137
  useEffect(() => {
133
138
  if (!selectedBotId) {
@@ -139,36 +144,43 @@ export default function PluginsPage() {
139
144
  .catch(() => setInstalled([]));
140
145
  }, [selectedBotId]);
141
146
 
142
- async function togglePlugin(pluginId: string) {
143
- if (!selectedBotId || toggling) return;
144
- const plugin = installed.find((p) => p.pluginId === pluginId);
145
- if (!plugin) return;
147
+ const togglePlugin = useCallback(
148
+ async (pluginId: string) => {
149
+ if (!selectedBotId || togglingRef.current) return;
150
+ const plugin = installedRef.current.find((p) => p.pluginId === pluginId);
151
+ if (!plugin) return;
146
152
 
147
- const previousEnabled = plugin.enabled;
148
- const newEnabled = !previousEnabled;
149
- setToggling(pluginId);
150
- setToggleError(null);
153
+ // Set ref synchronously BEFORE any await — prevents race
154
+ togglingRef.current = pluginId;
151
155
 
152
- // Optimistic update
153
- setInstalled((prev) =>
154
- prev.map((p) => (p.pluginId === pluginId ? { ...p, enabled: newEnabled } : p)),
155
- );
156
+ const previousEnabled = plugin.enabled;
157
+ const newEnabled = !previousEnabled;
158
+ setToggling(pluginId);
159
+ setToggleError(null);
156
160
 
157
- try {
158
- await togglePluginEnabled(selectedBotId, pluginId, newEnabled);
159
- // Refetch from server to confirm state (handles side effects like dependency enabling)
160
- const refreshed = await listInstalledPlugins(selectedBotId);
161
- setInstalled(refreshed);
162
- } catch {
163
- // Revert on failure
161
+ // Optimistic update
164
162
  setInstalled((prev) =>
165
- prev.map((p) => (p.pluginId === pluginId ? { ...p, enabled: previousEnabled } : p)),
163
+ prev.map((p) => (p.pluginId === pluginId ? { ...p, enabled: newEnabled } : p)),
166
164
  );
167
- setToggleError("Failed to update plugin. Please try again.");
168
- } finally {
169
- setToggling(null);
170
- }
171
- }
165
+
166
+ try {
167
+ await togglePluginEnabled(selectedBotId, pluginId, newEnabled);
168
+ // Refetch from server to confirm state (handles side effects like dependency enabling)
169
+ const refreshed = await listInstalledPlugins(selectedBotId);
170
+ setInstalled(refreshed);
171
+ } catch {
172
+ // Revert on failure
173
+ setInstalled((prev) =>
174
+ prev.map((p) => (p.pluginId === pluginId ? { ...p, enabled: previousEnabled } : p)),
175
+ );
176
+ setToggleError("Failed to update plugin. Please try again.");
177
+ } finally {
178
+ togglingRef.current = null;
179
+ setToggling(null);
180
+ }
181
+ },
182
+ [selectedBotId],
183
+ );
172
184
 
173
185
  const installedManifests = useMemo(() => {
174
186
  const manifestMap = new Map(catalog.map((m) => [m.id, m]));
@@ -74,34 +74,63 @@ export interface BrandConfig {
74
74
  * its env vars in .env (or .env.local) and the config picks them up
75
75
  * at build time — no code changes required.
76
76
  */
77
+ /**
78
+ * Parse NEXT_PUBLIC_BRAND_NAV_ITEMS env var.
79
+ * Format: JSON array of {label, href} objects.
80
+ * Example: '[{"label":"Home","href":"/"},{"label":"Settings","href":"/settings"}]'
81
+ * Returns null if unset or invalid (falls back to defaults).
82
+ */
83
+ function parseNavItems(raw: string | undefined): Array<{ label: string; href: string }> | null {
84
+ if (!raw) return null;
85
+ try {
86
+ const parsed = JSON.parse(raw);
87
+ if (
88
+ Array.isArray(parsed) &&
89
+ parsed.every(
90
+ (item: unknown) =>
91
+ typeof item === "object" &&
92
+ item !== null &&
93
+ typeof (item as { label?: unknown }).label === "string" &&
94
+ typeof (item as { href?: unknown }).href === "string",
95
+ )
96
+ ) {
97
+ return parsed as Array<{ label: string; href: string }>;
98
+ }
99
+ } catch {
100
+ // Invalid JSON — fall back to defaults
101
+ }
102
+ return null;
103
+ }
104
+
77
105
  function envDefaults(): BrandConfig {
78
- const env = (key: string) =>
79
- (typeof process !== "undefined" ? process.env?.[key] : undefined) ?? "";
80
- const productName = env("NEXT_PUBLIC_BRAND_PRODUCT_NAME") || "Platform";
81
- const brandName = env("NEXT_PUBLIC_BRAND_NAME") || "Platform";
82
- const storagePrefix = env("NEXT_PUBLIC_BRAND_STORAGE_PREFIX") || "platform";
83
- const eventPrefix = env("NEXT_PUBLIC_BRAND_EVENT_PREFIX") || storagePrefix;
106
+ // Direct process.env.X access is required — Next.js Turbopack only inlines
107
+ // NEXT_PUBLIC_* vars when accessed as literal dot-property references.
108
+ // Dynamic access like process.env[key] is NOT inlined at build time.
109
+ const productName = process.env.NEXT_PUBLIC_BRAND_PRODUCT_NAME || "Platform";
110
+ const brandName = process.env.NEXT_PUBLIC_BRAND_NAME || "Platform";
111
+ const storagePrefix = process.env.NEXT_PUBLIC_BRAND_STORAGE_PREFIX || "platform";
112
+ const eventPrefix = process.env.NEXT_PUBLIC_BRAND_EVENT_PREFIX || storagePrefix;
84
113
  return {
85
114
  productName,
86
115
  brandName,
87
- domain: env("NEXT_PUBLIC_BRAND_DOMAIN") || "localhost",
88
- appDomain: env("NEXT_PUBLIC_BRAND_APP_DOMAIN") || "localhost:3000",
89
- tagline: env("NEXT_PUBLIC_BRAND_TAGLINE") || "Your platform, your rules.",
116
+ domain: process.env.NEXT_PUBLIC_BRAND_DOMAIN || "localhost",
117
+ appDomain: process.env.NEXT_PUBLIC_BRAND_APP_DOMAIN || "localhost:3000",
118
+ tagline: process.env.NEXT_PUBLIC_BRAND_TAGLINE || "Your platform, your rules.",
90
119
  emails: {
91
- privacy: env("NEXT_PUBLIC_BRAND_EMAIL_PRIVACY") || "privacy@example.com",
92
- legal: env("NEXT_PUBLIC_BRAND_EMAIL_LEGAL") || "legal@example.com",
93
- support: env("NEXT_PUBLIC_BRAND_EMAIL_SUPPORT") || "support@example.com",
120
+ privacy: process.env.NEXT_PUBLIC_BRAND_EMAIL_PRIVACY || "privacy@example.com",
121
+ legal: process.env.NEXT_PUBLIC_BRAND_EMAIL_LEGAL || "legal@example.com",
122
+ support: process.env.NEXT_PUBLIC_BRAND_EMAIL_SUPPORT || "support@example.com",
94
123
  },
95
- defaultImage: env("NEXT_PUBLIC_BRAND_DEFAULT_IMAGE"),
124
+ defaultImage: process.env.NEXT_PUBLIC_BRAND_DEFAULT_IMAGE || "",
96
125
  storagePrefix,
97
126
  eventPrefix,
98
- envVarPrefix: env("NEXT_PUBLIC_BRAND_ENV_PREFIX") || storagePrefix.toUpperCase(),
99
- toolPrefix: env("NEXT_PUBLIC_BRAND_TOOL_PREFIX") || storagePrefix,
100
- tenantCookieName: env("NEXT_PUBLIC_BRAND_TENANT_COOKIE") || `${storagePrefix}_tenant_id`,
101
- companyLegalName: env("NEXT_PUBLIC_BRAND_COMPANY_LEGAL") || "Platform Inc.",
102
- price: env("NEXT_PUBLIC_BRAND_PRICE"),
103
- homePath: env("NEXT_PUBLIC_BRAND_HOME_PATH") || "/marketplace",
104
- navItems: [
127
+ envVarPrefix: process.env.NEXT_PUBLIC_BRAND_ENV_PREFIX || storagePrefix.toUpperCase(),
128
+ toolPrefix: process.env.NEXT_PUBLIC_BRAND_TOOL_PREFIX || storagePrefix,
129
+ tenantCookieName: process.env.NEXT_PUBLIC_BRAND_TENANT_COOKIE || `${storagePrefix}_tenant_id`,
130
+ companyLegalName: process.env.NEXT_PUBLIC_BRAND_COMPANY_LEGAL || "Platform Inc.",
131
+ price: process.env.NEXT_PUBLIC_BRAND_PRICE || "",
132
+ homePath: process.env.NEXT_PUBLIC_BRAND_HOME_PATH || "/marketplace",
133
+ navItems: parseNavItems(process.env.NEXT_PUBLIC_BRAND_NAV_ITEMS) ?? [
105
134
  { label: "Dashboard", href: "/dashboard" },
106
135
  { label: "Chat", href: "/chat" },
107
136
  { label: "Marketplace", href: "/marketplace" },