@majkapp/plugin-kit 3.2.0 → 3.3.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.
package/docs/INDEX.md ADDED
@@ -0,0 +1,486 @@
1
+ # @majkapp/plugin-kit
2
+
3
+ Build MAJK plugins with functions, UI screens, and services. Test with `@majkapp/plugin-test`.
4
+
5
+ ## Quick Start
6
+
7
+ ```typescript
8
+ // src/index.ts
9
+ import { definePlugin } from '@majkapp/plugin-kit';
10
+
11
+ const plugin = definePlugin('my-plugin', 'My Plugin', '1.0.0')
12
+ .pluginRoot(__dirname) // REQUIRED FIRST - enables resource loading
13
+
14
+ .function('health', {
15
+ description: 'Check plugin health status',
16
+ input: {
17
+ type: 'object',
18
+ properties: {},
19
+ additionalProperties: false
20
+ },
21
+ output: {
22
+ type: 'object',
23
+ properties: {
24
+ status: { type: 'string', enum: ['ok', 'error'] },
25
+ timestamp: { type: 'string', format: 'date-time' }
26
+ },
27
+ required: ['status', 'timestamp']
28
+ },
29
+ handler: async (input, ctx) => {
30
+ // ctx.logger - scoped logging (debug, info, warn, error)
31
+ ctx.logger.info('Health check requested');
32
+
33
+ return {
34
+ status: 'ok',
35
+ timestamp: new Date().toISOString()
36
+ };
37
+ },
38
+ tags: ['monitoring']
39
+ })
40
+
41
+ .screenReact({
42
+ id: 'my-plugin-main',
43
+ name: 'My Plugin',
44
+ description: 'Main plugin screen',
45
+ route: '/plugin-screens/my-plugin/main',
46
+ pluginPath: '/index.html' // Path to built React app
47
+ })
48
+
49
+ .build();
50
+
51
+ export = plugin;
52
+ ```
53
+
54
+ ## Testing Your Function
55
+
56
+ ```typescript
57
+ // tests/plugin/functions/unit/health.test.js
58
+ import { test } from '@majkapp/plugin-test';
59
+ import { invoke, mock } from '@majkapp/plugin-test';
60
+ import assert from 'assert';
61
+
62
+ test('health check returns ok status', async () => {
63
+ // Create mock context - no real MAJK instance needed
64
+ const context = mock().build();
65
+
66
+ // Invoke function with mock context
67
+ const result = await invoke('health', {}, { context });
68
+
69
+ // Verify output matches schema
70
+ assert.strictEqual(result.status, 'ok');
71
+ assert.ok(result.timestamp);
72
+ assert.ok(new Date(result.timestamp).getTime() > 0);
73
+ });
74
+ ```
75
+
76
+ Run test:
77
+ ```bash
78
+ cd my-plugin
79
+ npx majk-test tests/plugin/functions/unit/health.test.js
80
+ ```
81
+
82
+ Output:
83
+ ```
84
+ ✓ health check returns ok status (2ms)
85
+ ```
86
+
87
+ ## Project Structure
88
+
89
+ ```
90
+ my-plugin/
91
+ ├── src/
92
+ │ ├── index.ts # Plugin definition
93
+ │ └── core/ # Business logic (no plugin deps)
94
+ │ └── analytics.ts # Pure functions, easy to test
95
+ ├── ui/
96
+ │ ├── src/
97
+ │ │ ├── App.tsx # React app
98
+ │ │ ├── components/ # UI components
99
+ │ │ └── generated/ # Auto-generated hooks
100
+ │ │ ├── hooks.ts # useHealth(), etc.
101
+ │ │ └── client.ts # RPC client
102
+ │ └── index.html
103
+ ├── tests/
104
+ │ └── plugin/
105
+ │ ├── functions/unit/ # Function tests
106
+ │ └── ui/unit/ # UI tests with bot
107
+ ├── vite.config.js # EXACT format required (see CONFIG.md)
108
+ ├── package.json
109
+ └── tsconfig.json
110
+ ```
111
+
112
+ ## Best Practice: Decoupled Business Logic
113
+
114
+ **ALWAYS separate business logic from plugin code.** This makes testing easier and allows logic reuse.
115
+
116
+ ```typescript
117
+ // src/core/analytics.ts - Pure business logic, NO plugin dependencies
118
+ export interface AnalyticsData {
119
+ period: '24h' | '7d' | '30d';
120
+ metrics: {
121
+ activeUsers: number;
122
+ totalSessions: number;
123
+ avgDuration: number;
124
+ };
125
+ }
126
+
127
+ export function calculateAnalytics(
128
+ sessions: Array<{ userId: string; duration: number; timestamp: Date }>,
129
+ period: '24h' | '7d' | '30d'
130
+ ): AnalyticsData {
131
+ const now = Date.now();
132
+ const periodMs = period === '24h' ? 86400000 : period === '7d' ? 604800000 : 2592000000;
133
+
134
+ const recentSessions = sessions.filter(s =>
135
+ now - s.timestamp.getTime() < periodMs
136
+ );
137
+
138
+ const uniqueUsers = new Set(recentSessions.map(s => s.userId)).size;
139
+ const totalDuration = recentSessions.reduce((sum, s) => sum + s.duration, 0);
140
+
141
+ return {
142
+ period,
143
+ metrics: {
144
+ activeUsers: uniqueUsers,
145
+ totalSessions: recentSessions.length,
146
+ avgDuration: recentSessions.length > 0
147
+ ? Math.round(totalDuration / recentSessions.length)
148
+ : 0
149
+ }
150
+ };
151
+ }
152
+
153
+ // Test this with pure Jest/Vitest - NO plugin-test needed
154
+ ```
155
+
156
+ ```typescript
157
+ // src/index.ts - Plugin function wraps business logic
158
+ import { calculateAnalytics } from './core/analytics';
159
+
160
+ const plugin = definePlugin('my-plugin', 'My Plugin', '1.0.0')
161
+ .pluginRoot(__dirname)
162
+
163
+ .function('getAnalytics', {
164
+ description: 'Get analytics for specified time period',
165
+ input: {
166
+ type: 'object',
167
+ properties: {
168
+ period: { type: 'string', enum: ['24h', '7d', '30d'] }
169
+ },
170
+ required: ['period'],
171
+ additionalProperties: false
172
+ },
173
+ output: {
174
+ type: 'object',
175
+ properties: {
176
+ period: { type: 'string' },
177
+ metrics: {
178
+ type: 'object',
179
+ properties: {
180
+ activeUsers: { type: 'number' },
181
+ totalSessions: { type: 'number' },
182
+ avgDuration: { type: 'number' }
183
+ },
184
+ required: ['activeUsers', 'totalSessions', 'avgDuration']
185
+ }
186
+ },
187
+ required: ['period', 'metrics']
188
+ },
189
+ handler: async (input, ctx) => {
190
+ // Get data from storage (plugin layer)
191
+ const sessions = await ctx.storage.get('sessions') || [];
192
+
193
+ // Pure business logic (easy to test)
194
+ const analytics = calculateAnalytics(sessions, input.period);
195
+
196
+ ctx.logger.info(`Analytics calculated for ${input.period}`, analytics);
197
+
198
+ return analytics;
199
+ },
200
+ tags: ['analytics']
201
+ });
202
+ ```
203
+
204
+ ## Best Practice: Service Interfaces for External Dependencies
205
+
206
+ When using 3rd party services (AWS, databases, APIs), create interfaces for easy mocking.
207
+
208
+ ```typescript
209
+ // src/core/storage-service.ts - Interface for abstraction
210
+ export interface StorageService {
211
+ save(key: string, data: any): Promise<void>;
212
+ load(key: string): Promise<any>;
213
+ delete(key: string): Promise<void>;
214
+ }
215
+
216
+ // src/core/aws-storage.ts - Real implementation
217
+ import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
218
+
219
+ export class AWSStorageService implements StorageService {
220
+ constructor(private s3: S3Client, private bucket: string) {}
221
+
222
+ async save(key: string, data: any): Promise<void> {
223
+ await this.s3.send(new PutObjectCommand({
224
+ Bucket: this.bucket,
225
+ Key: key,
226
+ Body: JSON.stringify(data)
227
+ }));
228
+ }
229
+
230
+ async load(key: string): Promise<any> {
231
+ const result = await this.s3.send(new GetObjectCommand({
232
+ Bucket: this.bucket,
233
+ Key: key
234
+ }));
235
+ const body = await result.Body?.transformToString();
236
+ return body ? JSON.parse(body) : null;
237
+ }
238
+
239
+ async delete(key: string): Promise<void> {
240
+ // Implementation...
241
+ }
242
+ }
243
+
244
+ // src/core/mock-storage.ts - Mock for testing
245
+ export class MockStorageService implements StorageService {
246
+ private data = new Map<string, any>();
247
+
248
+ async save(key: string, data: any): Promise<void> {
249
+ this.data.set(key, data);
250
+ }
251
+
252
+ async load(key: string): Promise<any> {
253
+ return this.data.get(key);
254
+ }
255
+
256
+ async delete(key: string): Promise<void> {
257
+ this.data.delete(key);
258
+ }
259
+ }
260
+ ```
261
+
262
+ ## React UI with Generated Hooks
263
+
264
+ The plugin-kit auto-generates React hooks from your functions.
265
+
266
+ ```typescript
267
+ // ui/src/DashboardPage.tsx
268
+ import { useHealth, useGetAnalytics, useUiLog } from './generated/hooks';
269
+ import { PluginStatGrid, PluginActionPanel } from '@majkapp/plugin-ui';
270
+ import { useEffect } from 'react';
271
+
272
+ export function DashboardPage() {
273
+ // Auto-generated hooks - query hooks auto-fetch on mount
274
+ const { data: health, loading: healthLoading, refetch: refetchHealth } = useHealth();
275
+ const { data: analytics, loading: analyticsLoading } = useGetAnalytics(
276
+ { period: '7d' },
277
+ { refetchInterval: 30000 } // Auto-refresh every 30s
278
+ );
279
+
280
+ // Mutation hook for logging - call .mutate() manually
281
+ const { mutate: uiLog } = useUiLog();
282
+
283
+ // Log UI lifecycle events for debugging
284
+ useEffect(() => {
285
+ uiLog({
286
+ level: 'info',
287
+ component: 'DashboardPage',
288
+ message: 'Dashboard mounted',
289
+ data: { timestamp: new Date().toISOString() }
290
+ });
291
+ }, [uiLog]);
292
+
293
+ // data-majk-id: Enables bot testing - identifies clickable elements
294
+ // data-majk-state: Tracks UI state for testing - empty/loading/populated
295
+ return (
296
+ <div data-majk-id="dashboardContainer">
297
+ <PluginActionPanel
298
+ actions={[
299
+ {
300
+ label: '🔄 Refresh Health',
301
+ onClick: () => {
302
+ uiLog({
303
+ level: 'info',
304
+ component: 'DashboardPage',
305
+ message: 'Health refresh triggered by user'
306
+ });
307
+ refetchHealth();
308
+ },
309
+ 'data-majk-id': 'refreshHealthButton',
310
+ 'data-majk-state': healthLoading ? 'loading' : 'idle'
311
+ }
312
+ ]}
313
+ />
314
+
315
+ <PluginStatGrid
316
+ stats={[
317
+ {
318
+ label: 'Health Status',
319
+ value: health?.status || 'unknown',
320
+ 'data-majk-id': 'healthStatus',
321
+ 'data-majk-state': healthLoading ? 'loading' : health ? 'populated' : 'empty'
322
+ },
323
+ {
324
+ label: 'Active Users (7d)',
325
+ value: analytics?.metrics.activeUsers || 0,
326
+ 'data-majk-id': 'activeUsersCount',
327
+ 'data-majk-state': analyticsLoading ? 'loading' : analytics ? 'populated' : 'empty'
328
+ },
329
+ {
330
+ label: 'Total Sessions',
331
+ value: analytics?.metrics.totalSessions || 0,
332
+ 'data-majk-id': 'totalSessionsCount'
333
+ }
334
+ ]}
335
+ />
336
+ </div>
337
+ );
338
+ }
339
+ ```
340
+
341
+ ## Testing Your UI
342
+
343
+ ```typescript
344
+ // tests/plugin/ui/unit/dashboard.test.js
345
+ import { test } from '@majkapp/plugin-test';
346
+ import { bot, invoke, mock } from '@majkapp/plugin-test';
347
+ import assert from 'assert';
348
+
349
+ test('dashboard displays health status', async () => {
350
+ // Create mock context with handlers for functions
351
+ const context = mock()
352
+ .withMockHandler('health', async () => ({
353
+ status: 'ok',
354
+ timestamp: new Date().toISOString()
355
+ }))
356
+ .withMockHandler('getAnalytics', async () => ({
357
+ period: '7d',
358
+ metrics: {
359
+ activeUsers: 42,
360
+ totalSessions: 150,
361
+ avgDuration: 320
362
+ }
363
+ }))
364
+ .build();
365
+
366
+ // Start browser bot
367
+ const b = await bot(context);
368
+
369
+ // Navigate to screen
370
+ await b.goto('/plugin-screens/my-plugin/main');
371
+
372
+ // Wait for data to load - check data-majk-state
373
+ await b.waitForSelector('[data-majk-id="healthStatus"][data-majk-state="populated"]');
374
+
375
+ // Verify displayed values
376
+ const healthText = await b.getText('[data-majk-id="healthStatus"]');
377
+ assert.ok(healthText.includes('ok'));
378
+
379
+ const usersText = await b.getText('[data-majk-id="activeUsersCount"]');
380
+ assert.ok(usersText.includes('42'));
381
+
382
+ // Test interaction - click refresh button
383
+ await b.click('[data-majk-id="refreshHealthButton"]');
384
+
385
+ // Verify loading state
386
+ const buttonState = await b.getAttribute('[data-majk-id="refreshHealthButton"]', 'data-majk-state');
387
+ assert.strictEqual(buttonState, 'loading');
388
+
389
+ await b.close();
390
+ });
391
+ ```
392
+
393
+ ## Key Concepts
394
+
395
+ ### Query vs Mutation Hooks
396
+
397
+ Generated hooks follow React Query patterns:
398
+
399
+ **Query Hooks** - Auto-fetch data on mount:
400
+ - `useHealth()` - fetches immediately
401
+ - `useGetAnalytics({ period: '7d' })` - fetches immediately with params
402
+ - Returns: `{ data, error, loading, refetch }`
403
+
404
+ **Mutation Hooks** - Call manually:
405
+ - `useUiLog()` - call `.mutate(input)` when needed
406
+ - `useClearEvents()` - call `.mutate({})` on button click
407
+ - Returns: `{ mutate, data, error, loading }`
408
+
409
+ ### data-majk-* Attributes
410
+
411
+ **Critical for testing.** The bot uses these to find and verify elements.
412
+
413
+ - `data-majk-id="uniqueId"` - Identifies clickable elements, key stats
414
+ - `data-majk-state="loading|empty|populated|error"` - Tracks UI state for assertions
415
+
416
+ ### uiLog Function
417
+
418
+ **Best practice:** Create a `uiLog` function in your plugin for frontend debugging.
419
+
420
+ ```typescript
421
+ .function('uiLog', {
422
+ description: 'Log messages from UI for debugging',
423
+ input: {
424
+ type: 'object',
425
+ properties: {
426
+ level: { type: 'string', enum: ['debug', 'info', 'warn', 'error'] },
427
+ component: { type: 'string' },
428
+ message: { type: 'string' },
429
+ data: { type: 'object' }
430
+ },
431
+ required: ['level', 'component', 'message'],
432
+ additionalProperties: false
433
+ },
434
+ output: {
435
+ type: 'object',
436
+ properties: {
437
+ success: { type: 'boolean' }
438
+ }
439
+ },
440
+ handler: async (input, ctx) => {
441
+ const prefix = `[UI:${input.component}]`;
442
+ ctx.logger[input.level](`${prefix} ${input.message}`, input.data);
443
+ return { success: true };
444
+ },
445
+ tags: ['debugging']
446
+ })
447
+ ```
448
+
449
+ Then use in React:
450
+
451
+ ```typescript
452
+ // Log errors
453
+ try {
454
+ await someAsyncOperation();
455
+ } catch (error) {
456
+ uiLog({
457
+ level: 'error',
458
+ component: 'DashboardPage',
459
+ message: 'Failed to load data',
460
+ data: { error: error.message }
461
+ });
462
+ }
463
+
464
+ // Log user actions
465
+ onClick={() => {
466
+ uiLog({
467
+ level: 'info',
468
+ component: 'SettingsPage',
469
+ message: 'User clicked save button',
470
+ data: { settingsChanged: ['theme', 'notifications'] }
471
+ });
472
+ handleSave();
473
+ }}
474
+ ```
475
+
476
+ ## Next Steps
477
+
478
+ Run `npx @majkapp/plugin-kit --functions` - Deep dive into function patterns
479
+ Run `npx @majkapp/plugin-kit --screens` - Screen configuration details
480
+ Run `npx @majkapp/plugin-kit --hooks` - Generated hooks reference
481
+ Run `npx @majkapp/plugin-kit --context` - ctx.majk and ctx.logger APIs
482
+ Run `npx @majkapp/plugin-kit --services` - Service grouping patterns
483
+ Run `npx @majkapp/plugin-kit --lifecycle` - onReady and cleanup
484
+ Run `npx @majkapp/plugin-kit --testing` - Complete testing guide
485
+ Run `npx @majkapp/plugin-kit --config` - vite.config.js and project setup
486
+ Run `npx @majkapp/plugin-kit --full` - Complete reference