@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
|
@@ -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
|
+
});
|
package/src/app/plugins/page.tsx
CHANGED
|
@@ -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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
setToggling(pluginId);
|
|
150
|
-
setToggleError(null);
|
|
153
|
+
// Set ref synchronously BEFORE any await — prevents race
|
|
154
|
+
togglingRef.current = pluginId;
|
|
151
155
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
+
const previousEnabled = plugin.enabled;
|
|
157
|
+
const newEnabled = !previousEnabled;
|
|
158
|
+
setToggling(pluginId);
|
|
159
|
+
setToggleError(null);
|
|
156
160
|
|
|
157
|
-
|
|
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:
|
|
163
|
+
prev.map((p) => (p.pluginId === pluginId ? { ...p, enabled: newEnabled } : p)),
|
|
166
164
|
);
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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]));
|
package/src/lib/brand-config.ts
CHANGED
|
@@ -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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
const
|
|
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
|
|
88
|
-
appDomain: env
|
|
89
|
-
tagline: env
|
|
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
|
|
92
|
-
legal: env
|
|
93
|
-
support: env
|
|
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
|
|
124
|
+
defaultImage: process.env.NEXT_PUBLIC_BRAND_DEFAULT_IMAGE || "",
|
|
96
125
|
storagePrefix,
|
|
97
126
|
eventPrefix,
|
|
98
|
-
envVarPrefix: env
|
|
99
|
-
toolPrefix: env
|
|
100
|
-
tenantCookieName: env
|
|
101
|
-
companyLegalName: env
|
|
102
|
-
price: env
|
|
103
|
-
homePath: env
|
|
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" },
|