@object-ui/core 0.5.0 → 3.0.0

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.
Files changed (96) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +28 -0
  3. package/dist/__benchmarks__/core.bench.d.ts +8 -0
  4. package/dist/__benchmarks__/core.bench.js +53 -0
  5. package/dist/actions/ActionRunner.d.ts +228 -4
  6. package/dist/actions/ActionRunner.js +397 -45
  7. package/dist/actions/TransactionManager.d.ts +193 -0
  8. package/dist/actions/TransactionManager.js +410 -0
  9. package/dist/actions/index.d.ts +1 -0
  10. package/dist/actions/index.js +1 -0
  11. package/dist/adapters/ApiDataSource.d.ts +69 -0
  12. package/dist/adapters/ApiDataSource.js +293 -0
  13. package/dist/adapters/ValueDataSource.d.ts +55 -0
  14. package/dist/adapters/ValueDataSource.js +287 -0
  15. package/dist/adapters/index.d.ts +3 -0
  16. package/dist/adapters/index.js +5 -2
  17. package/dist/adapters/resolveDataSource.d.ts +40 -0
  18. package/dist/adapters/resolveDataSource.js +59 -0
  19. package/dist/data-scope/DataScopeManager.d.ts +127 -0
  20. package/dist/data-scope/DataScopeManager.js +229 -0
  21. package/dist/data-scope/index.d.ts +10 -0
  22. package/dist/data-scope/index.js +10 -0
  23. package/dist/errors/index.d.ts +75 -0
  24. package/dist/errors/index.js +224 -0
  25. package/dist/evaluator/ExpressionEvaluator.d.ts +11 -1
  26. package/dist/evaluator/ExpressionEvaluator.js +32 -8
  27. package/dist/evaluator/FormulaFunctions.d.ts +58 -0
  28. package/dist/evaluator/FormulaFunctions.js +350 -0
  29. package/dist/evaluator/index.d.ts +1 -0
  30. package/dist/evaluator/index.js +1 -0
  31. package/dist/index.d.ts +6 -0
  32. package/dist/index.js +6 -2
  33. package/dist/query/query-ast.d.ts +2 -2
  34. package/dist/query/query-ast.js +3 -3
  35. package/dist/registry/Registry.d.ts +10 -0
  36. package/dist/registry/Registry.js +9 -2
  37. package/dist/registry/WidgetRegistry.d.ts +120 -0
  38. package/dist/registry/WidgetRegistry.js +275 -0
  39. package/dist/theme/ThemeEngine.d.ts +105 -0
  40. package/dist/theme/ThemeEngine.js +469 -0
  41. package/dist/theme/index.d.ts +8 -0
  42. package/dist/theme/index.js +8 -0
  43. package/dist/utils/debug.d.ts +31 -0
  44. package/dist/utils/debug.js +62 -0
  45. package/dist/validation/index.d.ts +1 -1
  46. package/dist/validation/index.js +1 -1
  47. package/dist/validation/validation-engine.d.ts +19 -1
  48. package/dist/validation/validation-engine.js +74 -3
  49. package/dist/validation/validators/index.d.ts +1 -1
  50. package/dist/validation/validators/index.js +1 -1
  51. package/dist/validation/validators/object-validation-engine.d.ts +2 -2
  52. package/dist/validation/validators/object-validation-engine.js +1 -1
  53. package/package.json +4 -3
  54. package/src/__benchmarks__/core.bench.ts +64 -0
  55. package/src/actions/ActionRunner.ts +577 -55
  56. package/src/actions/TransactionManager.ts +521 -0
  57. package/src/actions/__tests__/ActionRunner.params.test.ts +134 -0
  58. package/src/actions/__tests__/ActionRunner.test.ts +711 -0
  59. package/src/actions/__tests__/TransactionManager.test.ts +447 -0
  60. package/src/actions/index.ts +1 -0
  61. package/src/adapters/ApiDataSource.ts +349 -0
  62. package/src/adapters/ValueDataSource.ts +332 -0
  63. package/src/adapters/__tests__/ApiDataSource.test.ts +418 -0
  64. package/src/adapters/__tests__/ValueDataSource.test.ts +325 -0
  65. package/src/adapters/__tests__/resolveDataSource.test.ts +144 -0
  66. package/src/adapters/index.ts +6 -1
  67. package/src/adapters/resolveDataSource.ts +79 -0
  68. package/src/builder/__tests__/schema-builder.test.ts +235 -0
  69. package/src/data-scope/DataScopeManager.ts +269 -0
  70. package/src/data-scope/__tests__/DataScopeManager.test.ts +211 -0
  71. package/src/data-scope/index.ts +16 -0
  72. package/src/errors/__tests__/errors.test.ts +292 -0
  73. package/src/errors/index.ts +270 -0
  74. package/src/evaluator/ExpressionEvaluator.ts +34 -8
  75. package/src/evaluator/FormulaFunctions.ts +398 -0
  76. package/src/evaluator/__tests__/ExpressionContext.test.ts +110 -0
  77. package/src/evaluator/__tests__/FormulaFunctions.test.ts +447 -0
  78. package/src/evaluator/index.ts +1 -0
  79. package/src/index.ts +6 -3
  80. package/src/query/__tests__/window-functions.test.ts +1 -1
  81. package/src/query/query-ast.ts +3 -3
  82. package/src/registry/Registry.ts +19 -2
  83. package/src/registry/WidgetRegistry.ts +316 -0
  84. package/src/registry/__tests__/WidgetRegistry.test.ts +321 -0
  85. package/src/theme/ThemeEngine.ts +530 -0
  86. package/src/theme/__tests__/ThemeEngine.test.ts +668 -0
  87. package/src/theme/index.ts +24 -0
  88. package/src/utils/__tests__/debug.test.ts +83 -0
  89. package/src/utils/debug.ts +66 -0
  90. package/src/validation/__tests__/object-validation-engine.test.ts +1 -1
  91. package/src/validation/__tests__/schema-validator.test.ts +118 -0
  92. package/src/validation/index.ts +1 -1
  93. package/src/validation/validation-engine.ts +70 -3
  94. package/src/validation/validators/index.ts +1 -1
  95. package/src/validation/validators/object-validation-engine.ts +2 -2
  96. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,316 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ /**
10
+ * WidgetRegistry - Runtime widget management with auto-discovery.
11
+ *
12
+ * Provides registration, loading, and lookup of runtime widgets
13
+ * described by WidgetManifest objects. Widgets can be loaded from
14
+ * ES module URLs, provided inline, or resolved from the component registry.
15
+ *
16
+ * @module
17
+ */
18
+
19
+ import type {
20
+ WidgetManifest,
21
+ ResolvedWidget,
22
+ WidgetRegistryEvent,
23
+ WidgetRegistryListener,
24
+ } from '@object-ui/types';
25
+ import type { Registry } from './Registry.js';
26
+
27
+ /**
28
+ * Options for creating a WidgetRegistry instance.
29
+ */
30
+ export interface WidgetRegistryOptions {
31
+ /**
32
+ * Component registry to sync loaded widgets into.
33
+ * When a widget is loaded, its component is also registered here.
34
+ */
35
+ componentRegistry?: Registry;
36
+ }
37
+
38
+ /**
39
+ * WidgetRegistry manages runtime-loadable widgets described by manifests.
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * const widgets = new WidgetRegistry({ componentRegistry: registry });
44
+ *
45
+ * widgets.register({
46
+ * name: 'custom-chart',
47
+ * version: '1.0.0',
48
+ * type: 'chart',
49
+ * label: 'Custom Chart',
50
+ * source: { type: 'module', url: '/widgets/chart.js' },
51
+ * });
52
+ *
53
+ * const resolved = await widgets.load('custom-chart');
54
+ * ```
55
+ */
56
+ export class WidgetRegistry {
57
+ private manifests = new Map<string, WidgetManifest>();
58
+ private resolved = new Map<string, ResolvedWidget>();
59
+ private listeners = new Set<WidgetRegistryListener>();
60
+ private componentRegistry?: Registry;
61
+
62
+ constructor(options: WidgetRegistryOptions = {}) {
63
+ this.componentRegistry = options.componentRegistry;
64
+ }
65
+
66
+ /**
67
+ * Register a widget manifest.
68
+ * Does not load the widget; call `load()` to resolve it.
69
+ */
70
+ register(manifest: WidgetManifest): void {
71
+ this.manifests.set(manifest.name, manifest);
72
+ this.emit({ type: 'widget:registered', widget: manifest });
73
+ }
74
+
75
+ /**
76
+ * Register multiple widget manifests at once.
77
+ */
78
+ registerAll(manifests: WidgetManifest[]): void {
79
+ for (const manifest of manifests) {
80
+ this.register(manifest);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Unregister a widget by name.
86
+ */
87
+ unregister(name: string): boolean {
88
+ const existed = this.manifests.delete(name);
89
+ this.resolved.delete(name);
90
+ if (existed) {
91
+ this.emit({ type: 'widget:unregistered', name });
92
+ }
93
+ return existed;
94
+ }
95
+
96
+ /**
97
+ * Get a widget manifest by name.
98
+ */
99
+ getManifest(name: string): WidgetManifest | undefined {
100
+ return this.manifests.get(name);
101
+ }
102
+
103
+ /**
104
+ * Get all registered widget manifests.
105
+ */
106
+ getAllManifests(): WidgetManifest[] {
107
+ return Array.from(this.manifests.values());
108
+ }
109
+
110
+ /**
111
+ * Get manifests filtered by category.
112
+ */
113
+ getByCategory(category: string): WidgetManifest[] {
114
+ return this.getAllManifests().filter((m) => m.category === category);
115
+ }
116
+
117
+ /**
118
+ * Check if a widget is registered.
119
+ */
120
+ has(name: string): boolean {
121
+ return this.manifests.has(name);
122
+ }
123
+
124
+ /**
125
+ * Check if a widget has been loaded (resolved).
126
+ */
127
+ isLoaded(name: string): boolean {
128
+ return this.resolved.has(name);
129
+ }
130
+
131
+ /**
132
+ * Load (resolve) a widget by name.
133
+ * If already loaded, returns the cached resolved widget.
134
+ *
135
+ * @throws Error if the widget is not registered or fails to load.
136
+ */
137
+ async load(name: string): Promise<ResolvedWidget> {
138
+ // Return cached if already loaded
139
+ const cached = this.resolved.get(name);
140
+ if (cached) return cached;
141
+
142
+ const manifest = this.manifests.get(name);
143
+ if (!manifest) {
144
+ const error = new Error(`Widget "${name}" is not registered`);
145
+ this.emit({ type: 'widget:error', name, error });
146
+ throw error;
147
+ }
148
+
149
+ // Resolve dependencies first
150
+ if (manifest.dependencies) {
151
+ for (const dep of manifest.dependencies) {
152
+ if (!this.isLoaded(dep)) {
153
+ await this.load(dep);
154
+ }
155
+ }
156
+ }
157
+
158
+ try {
159
+ const component = await this.resolveComponent(manifest);
160
+
161
+ const resolved: ResolvedWidget = {
162
+ manifest,
163
+ component,
164
+ loadedAt: Date.now(),
165
+ };
166
+
167
+ this.resolved.set(name, resolved);
168
+
169
+ // Sync to component registry if available
170
+ if (this.componentRegistry) {
171
+ this.componentRegistry.register(manifest.type, component as any, {
172
+ label: manifest.label,
173
+ icon: manifest.icon,
174
+ category: manifest.category,
175
+ inputs: manifest.inputs?.map((input) => ({
176
+ name: input.name,
177
+ type: input.type,
178
+ label: input.label,
179
+ defaultValue: input.defaultValue,
180
+ required: input.required,
181
+ enum: input.options,
182
+ description: input.description,
183
+ advanced: input.advanced,
184
+ })),
185
+ defaultProps: manifest.defaultProps as Record<string, any>,
186
+ isContainer: manifest.isContainer,
187
+ });
188
+ }
189
+
190
+ this.emit({ type: 'widget:loaded', widget: resolved });
191
+ return resolved;
192
+ } catch (err) {
193
+ const error = err instanceof Error ? err : new Error(String(err));
194
+ this.emit({ type: 'widget:error', name, error });
195
+ throw error;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Load all registered widgets.
201
+ * Returns an array of results (settled promises).
202
+ */
203
+ async loadAll(): Promise<Array<{ name: string; result: ResolvedWidget | Error }>> {
204
+ const results: Array<{ name: string; result: ResolvedWidget | Error }> = [];
205
+
206
+ for (const [name] of this.manifests) {
207
+ try {
208
+ const resolved = await this.load(name);
209
+ results.push({ name, result: resolved });
210
+ } catch (err) {
211
+ results.push({ name, result: err instanceof Error ? err : new Error(String(err)) });
212
+ }
213
+ }
214
+
215
+ return results;
216
+ }
217
+
218
+ /**
219
+ * Subscribe to widget registry events.
220
+ * @returns Unsubscribe function.
221
+ */
222
+ on(listener: WidgetRegistryListener): () => void {
223
+ this.listeners.add(listener);
224
+ return () => {
225
+ this.listeners.delete(listener);
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Clear all registered and loaded widgets.
231
+ */
232
+ clear(): void {
233
+ this.manifests.clear();
234
+ this.resolved.clear();
235
+ }
236
+
237
+ /**
238
+ * Get registry statistics.
239
+ */
240
+ getStats(): { registered: number; loaded: number; categories: string[] } {
241
+ const categories = new Set<string>();
242
+ for (const m of this.manifests.values()) {
243
+ if (m.category) categories.add(m.category);
244
+ }
245
+ return {
246
+ registered: this.manifests.size,
247
+ loaded: this.resolved.size,
248
+ categories: Array.from(categories),
249
+ };
250
+ }
251
+
252
+ // -------------------------------------------------------------------------
253
+ // Private helpers
254
+ // -------------------------------------------------------------------------
255
+
256
+ private async resolveComponent(manifest: WidgetManifest): Promise<unknown> {
257
+ const { source } = manifest;
258
+
259
+ switch (source.type) {
260
+ case 'inline':
261
+ return source.component;
262
+
263
+ case 'registry': {
264
+ if (!this.componentRegistry) {
265
+ throw new Error(
266
+ `Widget "${manifest.name}" uses registry source but no component registry is configured`,
267
+ );
268
+ }
269
+ const component = this.componentRegistry.get(source.registryKey);
270
+ if (!component) {
271
+ throw new Error(
272
+ `Widget "${manifest.name}" references registry key "${source.registryKey}" which is not registered`,
273
+ );
274
+ }
275
+ return component;
276
+ }
277
+
278
+ case 'module': {
279
+ // Runtime-only dynamic import for loading widgets from external URLs
280
+ // This uses Function constructor to prevent bundlers (Webpack/Turbopack/Vite)
281
+ // from attempting static analysis at build time, which would fail since
282
+ // source.url is only known at runtime.
283
+ //
284
+ // Security: Widget URLs must be from trusted sources only. Never pass
285
+ // user-supplied URLs directly to WidgetManifest. URLs should be validated
286
+ // and controlled by the application developer.
287
+ //
288
+ // CSP Consideration: If your application uses strict Content Security Policy,
289
+ // ensure dynamic imports are allowed or use 'inline' or 'registry' source types.
290
+ const dynamicImport = new Function('url', 'return import(url)');
291
+ const mod = await dynamicImport(source.url);
292
+ const exportName = source.exportName ?? 'default';
293
+ const component = mod[exportName];
294
+ if (!component) {
295
+ throw new Error(
296
+ `Widget "${manifest.name}" module at "${source.url}" does not export "${exportName}"`,
297
+ );
298
+ }
299
+ return component;
300
+ }
301
+
302
+ default:
303
+ throw new Error(`Unknown widget source type for "${manifest.name}"`);
304
+ }
305
+ }
306
+
307
+ private emit(event: WidgetRegistryEvent): void {
308
+ for (const listener of this.listeners) {
309
+ try {
310
+ listener(event);
311
+ } catch {
312
+ // Swallow listener errors to prevent cascading failures
313
+ }
314
+ }
315
+ }
316
+ }
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Tests for WidgetRegistry
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
6
+ import { WidgetRegistry } from '../WidgetRegistry';
7
+ import { Registry } from '../Registry';
8
+ import type { WidgetManifest, WidgetRegistryEvent } from '@object-ui/types';
9
+
10
+ function createManifest(overrides: Partial<WidgetManifest> = {}): WidgetManifest {
11
+ return {
12
+ name: 'test-widget',
13
+ version: '1.0.0',
14
+ type: 'test',
15
+ label: 'Test Widget',
16
+ source: { type: 'inline', component: () => null },
17
+ ...overrides,
18
+ };
19
+ }
20
+
21
+ describe('WidgetRegistry', () => {
22
+ let widgetRegistry: WidgetRegistry;
23
+
24
+ beforeEach(() => {
25
+ widgetRegistry = new WidgetRegistry();
26
+ });
27
+
28
+ describe('register / unregister', () => {
29
+ it('registers a widget manifest', () => {
30
+ const manifest = createManifest();
31
+ widgetRegistry.register(manifest);
32
+ expect(widgetRegistry.has('test-widget')).toBe(true);
33
+ expect(widgetRegistry.getManifest('test-widget')).toBe(manifest);
34
+ });
35
+
36
+ it('registers multiple manifests at once', () => {
37
+ widgetRegistry.registerAll([
38
+ createManifest({ name: 'a', type: 'a' }),
39
+ createManifest({ name: 'b', type: 'b' }),
40
+ ]);
41
+ expect(widgetRegistry.has('a')).toBe(true);
42
+ expect(widgetRegistry.has('b')).toBe(true);
43
+ });
44
+
45
+ it('unregisters a widget', () => {
46
+ widgetRegistry.register(createManifest());
47
+ expect(widgetRegistry.unregister('test-widget')).toBe(true);
48
+ expect(widgetRegistry.has('test-widget')).toBe(false);
49
+ });
50
+
51
+ it('returns false when unregistering a non-existent widget', () => {
52
+ expect(widgetRegistry.unregister('nope')).toBe(false);
53
+ });
54
+ });
55
+
56
+ describe('getAllManifests / getByCategory', () => {
57
+ it('returns all manifests', () => {
58
+ widgetRegistry.registerAll([
59
+ createManifest({ name: 'a', type: 'a' }),
60
+ createManifest({ name: 'b', type: 'b' }),
61
+ ]);
62
+ expect(widgetRegistry.getAllManifests()).toHaveLength(2);
63
+ });
64
+
65
+ it('filters by category', () => {
66
+ widgetRegistry.registerAll([
67
+ createManifest({ name: 'chart-1', type: 'c1', category: 'charts' }),
68
+ createManifest({ name: 'form-1', type: 'f1', category: 'forms' }),
69
+ createManifest({ name: 'chart-2', type: 'c2', category: 'charts' }),
70
+ ]);
71
+ expect(widgetRegistry.getByCategory('charts')).toHaveLength(2);
72
+ expect(widgetRegistry.getByCategory('forms')).toHaveLength(1);
73
+ expect(widgetRegistry.getByCategory('unknown')).toHaveLength(0);
74
+ });
75
+ });
76
+
77
+ describe('load', () => {
78
+ it('loads an inline widget', async () => {
79
+ const component = () => null;
80
+ widgetRegistry.register(
81
+ createManifest({ source: { type: 'inline', component } }),
82
+ );
83
+
84
+ const resolved = await widgetRegistry.load('test-widget');
85
+ expect(resolved.component).toBe(component);
86
+ expect(resolved.manifest.name).toBe('test-widget');
87
+ expect(resolved.loadedAt).toBeGreaterThan(0);
88
+ expect(widgetRegistry.isLoaded('test-widget')).toBe(true);
89
+ });
90
+
91
+ it('returns cached resolved widget on subsequent loads', async () => {
92
+ widgetRegistry.register(createManifest());
93
+ const first = await widgetRegistry.load('test-widget');
94
+ const second = await widgetRegistry.load('test-widget');
95
+ expect(first).toBe(second);
96
+ });
97
+
98
+ it('throws when loading an unregistered widget', async () => {
99
+ await expect(widgetRegistry.load('nope')).rejects.toThrow(
100
+ 'Widget "nope" is not registered',
101
+ );
102
+ });
103
+
104
+ it('loads a widget from the component registry', async () => {
105
+ const componentRegistry = new Registry();
106
+ const component = () => null;
107
+ componentRegistry.register('existing-type', component);
108
+
109
+ const reg = new WidgetRegistry({ componentRegistry });
110
+ reg.register(
111
+ createManifest({
112
+ source: { type: 'registry', registryKey: 'existing-type' },
113
+ }),
114
+ );
115
+
116
+ const resolved = await reg.load('test-widget');
117
+ expect(resolved.component).toBe(component);
118
+ });
119
+
120
+ it('throws when registry key is not found', async () => {
121
+ const componentRegistry = new Registry();
122
+ const reg = new WidgetRegistry({ componentRegistry });
123
+ reg.register(
124
+ createManifest({
125
+ source: { type: 'registry', registryKey: 'missing-key' },
126
+ }),
127
+ );
128
+
129
+ await expect(reg.load('test-widget')).rejects.toThrow(
130
+ 'references registry key "missing-key"',
131
+ );
132
+ });
133
+
134
+ it('throws when no component registry is configured for registry source', async () => {
135
+ widgetRegistry.register(
136
+ createManifest({
137
+ source: { type: 'registry', registryKey: 'something' },
138
+ }),
139
+ );
140
+
141
+ await expect(widgetRegistry.load('test-widget')).rejects.toThrow(
142
+ 'no component registry is configured',
143
+ );
144
+ });
145
+
146
+ it('resolves dependencies before loading', async () => {
147
+ const loadOrder: string[] = [];
148
+
149
+ widgetRegistry.register(
150
+ createManifest({
151
+ name: 'dep-a',
152
+ type: 'dep-a',
153
+ source: {
154
+ type: 'inline',
155
+ component: () => {
156
+ loadOrder.push('dep-a');
157
+ return null;
158
+ },
159
+ },
160
+ }),
161
+ );
162
+
163
+ widgetRegistry.register(
164
+ createManifest({
165
+ name: 'main',
166
+ type: 'main',
167
+ dependencies: ['dep-a'],
168
+ source: {
169
+ type: 'inline',
170
+ component: () => {
171
+ loadOrder.push('main');
172
+ return null;
173
+ },
174
+ },
175
+ }),
176
+ );
177
+
178
+ await widgetRegistry.load('main');
179
+ expect(widgetRegistry.isLoaded('dep-a')).toBe(true);
180
+ expect(widgetRegistry.isLoaded('main')).toBe(true);
181
+ });
182
+
183
+ it('syncs loaded widget to component registry', async () => {
184
+ const componentRegistry = new Registry();
185
+ const reg = new WidgetRegistry({ componentRegistry });
186
+ const component = () => null;
187
+
188
+ reg.register(
189
+ createManifest({
190
+ name: 'sync-test',
191
+ type: 'custom-type',
192
+ label: 'Sync Test',
193
+ category: 'testing',
194
+ source: { type: 'inline', component },
195
+ }),
196
+ );
197
+
198
+ await reg.load('sync-test');
199
+
200
+ const synced = componentRegistry.get('custom-type');
201
+ expect(synced).toBe(component);
202
+ });
203
+ });
204
+
205
+ describe('loadAll', () => {
206
+ it('loads all registered widgets', async () => {
207
+ widgetRegistry.registerAll([
208
+ createManifest({ name: 'a', type: 'a' }),
209
+ createManifest({ name: 'b', type: 'b' }),
210
+ ]);
211
+
212
+ const results = await widgetRegistry.loadAll();
213
+ expect(results).toHaveLength(2);
214
+ expect(results.every((r) => !(r.result instanceof Error))).toBe(true);
215
+ });
216
+
217
+ it('captures errors for failed widgets', async () => {
218
+ widgetRegistry.register(createManifest({ name: 'good', type: 'good' }));
219
+ widgetRegistry.register(
220
+ createManifest({
221
+ name: 'bad',
222
+ type: 'bad',
223
+ source: { type: 'registry', registryKey: 'missing' },
224
+ }),
225
+ );
226
+
227
+ const results = await widgetRegistry.loadAll();
228
+ const bad = results.find((r) => r.name === 'bad');
229
+ expect(bad?.result).toBeInstanceOf(Error);
230
+ });
231
+ });
232
+
233
+ describe('events', () => {
234
+ it('emits widget:registered event', () => {
235
+ const events: WidgetRegistryEvent[] = [];
236
+ widgetRegistry.on((e) => events.push(e));
237
+
238
+ widgetRegistry.register(createManifest());
239
+
240
+ expect(events).toHaveLength(1);
241
+ expect(events[0].type).toBe('widget:registered');
242
+ });
243
+
244
+ it('emits widget:unregistered event', () => {
245
+ widgetRegistry.register(createManifest());
246
+
247
+ const events: WidgetRegistryEvent[] = [];
248
+ widgetRegistry.on((e) => events.push(e));
249
+
250
+ widgetRegistry.unregister('test-widget');
251
+
252
+ expect(events).toHaveLength(1);
253
+ expect(events[0].type).toBe('widget:unregistered');
254
+ });
255
+
256
+ it('emits widget:loaded event', async () => {
257
+ widgetRegistry.register(createManifest());
258
+
259
+ const events: WidgetRegistryEvent[] = [];
260
+ widgetRegistry.on((e) => events.push(e));
261
+
262
+ await widgetRegistry.load('test-widget');
263
+
264
+ expect(events.some((e) => e.type === 'widget:loaded')).toBe(true);
265
+ });
266
+
267
+ it('emits widget:error event on load failure', async () => {
268
+ widgetRegistry.register(
269
+ createManifest({
270
+ source: { type: 'registry', registryKey: 'missing' },
271
+ }),
272
+ );
273
+
274
+ const events: WidgetRegistryEvent[] = [];
275
+ widgetRegistry.on((e) => events.push(e));
276
+
277
+ await widgetRegistry.load('test-widget').catch(() => {});
278
+
279
+ expect(events.some((e) => e.type === 'widget:error')).toBe(true);
280
+ });
281
+
282
+ it('supports unsubscribing from events', () => {
283
+ const handler = vi.fn();
284
+ const unsub = widgetRegistry.on(handler);
285
+
286
+ widgetRegistry.register(createManifest({ name: 'a', type: 'a' }));
287
+ expect(handler).toHaveBeenCalledTimes(1);
288
+
289
+ unsub();
290
+ widgetRegistry.register(createManifest({ name: 'b', type: 'b' }));
291
+ expect(handler).toHaveBeenCalledTimes(1); // No new calls
292
+ });
293
+ });
294
+
295
+ describe('clear / getStats', () => {
296
+ it('clears all widgets', async () => {
297
+ widgetRegistry.register(createManifest());
298
+ await widgetRegistry.load('test-widget');
299
+
300
+ widgetRegistry.clear();
301
+
302
+ expect(widgetRegistry.has('test-widget')).toBe(false);
303
+ expect(widgetRegistry.isLoaded('test-widget')).toBe(false);
304
+ });
305
+
306
+ it('returns correct stats', () => {
307
+ widgetRegistry.registerAll([
308
+ createManifest({ name: 'a', type: 'a', category: 'charts' }),
309
+ createManifest({ name: 'b', type: 'b', category: 'forms' }),
310
+ createManifest({ name: 'c', type: 'c', category: 'charts' }),
311
+ ]);
312
+
313
+ const stats = widgetRegistry.getStats();
314
+ expect(stats.registered).toBe(3);
315
+ expect(stats.loaded).toBe(0);
316
+ expect(stats.categories).toContain('charts');
317
+ expect(stats.categories).toContain('forms');
318
+ expect(stats.categories).toHaveLength(2);
319
+ });
320
+ });
321
+ });