@mcp-web/app 0.1.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.
@@ -0,0 +1,450 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { viteSingleFile } from 'vite-plugin-singlefile';
4
+ const CRITICAL_OVERRIDES = [
5
+ {
6
+ key: 'build.assetsInlineLimit',
7
+ required: Number.MAX_SAFE_INTEGER,
8
+ reason: 'All assets must be inlined for single-file output',
9
+ },
10
+ {
11
+ key: 'build.cssCodeSplit',
12
+ required: false,
13
+ reason: 'CSS must be in a single file for inlining',
14
+ },
15
+ {
16
+ key: 'base',
17
+ required: './',
18
+ reason: 'Relative paths required for self-contained HTML',
19
+ },
20
+ ];
21
+ /**
22
+ * Virtual module prefix for generated app entries.
23
+ */
24
+ const VIRTUAL_PREFIX = 'virtual:mcp-app/';
25
+ const RESOLVED_VIRTUAL_PREFIX = `\0${VIRTUAL_PREFIX}`;
26
+ /**
27
+ * The MCP App runtime is now handled by the ext-apps protocol
28
+ * via `@modelcontextprotocol/ext-apps` React hooks bundled
29
+ * into the app's JavaScript. No injected script is needed.
30
+ *
31
+ * Previously, this was a <script> tag that listened for
32
+ * `postMessage({ props })` from the host. The ext-apps protocol
33
+ * uses JSON-RPC 2.0 messages instead (ui/initialize, tool-result, etc.)
34
+ */
35
+ /**
36
+ * Get a nested property value from an object using dot notation.
37
+ */
38
+ function getNestedValue(obj, path) {
39
+ return path.split('.').reduce((current, key) => {
40
+ if (current && typeof current === 'object' && key in current) {
41
+ return current[key];
42
+ }
43
+ return undefined;
44
+ }, obj);
45
+ }
46
+ /**
47
+ * Convert a name to kebab-case for HTML file output.
48
+ */
49
+ function toKebabCase(name) {
50
+ return name
51
+ .replace(/([a-z])([A-Z])/g, '$1-$2')
52
+ .replace(/_/g, '-')
53
+ .toLowerCase();
54
+ }
55
+ /**
56
+ * Find the apps config file in the project.
57
+ */
58
+ function findAppsConfigFile(projectRoot, customPath) {
59
+ if (customPath) {
60
+ const fullPath = path.resolve(projectRoot, customPath);
61
+ if (fs.existsSync(fullPath)) {
62
+ return fullPath;
63
+ }
64
+ return null;
65
+ }
66
+ const candidates = [
67
+ 'src/mcp-apps.ts',
68
+ 'src/mcp-apps.tsx',
69
+ 'src/mcp/apps.ts',
70
+ 'src/mcp/apps.tsx',
71
+ ];
72
+ for (const candidate of candidates) {
73
+ const fullPath = path.resolve(projectRoot, candidate);
74
+ if (fs.existsSync(fullPath)) {
75
+ return fullPath;
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+ /**
81
+ * Parse the apps config file to extract app definitions.
82
+ *
83
+ * Uses simple regex parsing to extract:
84
+ * - Import statements for components
85
+ * - createApp calls with name and component properties
86
+ */
87
+ function parseAppsConfig(configPath) {
88
+ const content = fs.readFileSync(configPath, 'utf-8');
89
+ const apps = [];
90
+ // Parse imports to map component names to their paths
91
+ const importMap = new Map();
92
+ const importRegex = /import\s+(?:{([^}]+)}|(\w+))\s+from\s+['"]([^'"]+)['"]/g;
93
+ let match;
94
+ while ((match = importRegex.exec(content)) !== null) {
95
+ const importPath = match[3];
96
+ if (match[2]) {
97
+ // Default import: import Foo from './foo'
98
+ importMap.set(match[2], importPath);
99
+ }
100
+ else if (match[1]) {
101
+ // Named imports: import { Foo, Bar as Baz } from './foo'
102
+ const namedImports = match[1].split(',').map((s) => s.trim());
103
+ for (const namedImport of namedImports) {
104
+ const parts = namedImport.split(/\s+as\s+/);
105
+ const localName = parts[1] || parts[0];
106
+ importMap.set(localName.trim(), importPath);
107
+ }
108
+ }
109
+ }
110
+ // Find createApp calls - match the start, then find balanced braces
111
+ const createAppStartRegex = /export\s+(?:const|let|var)\s+(\w+)\s*=\s*createApp\s*\(\s*\{/g;
112
+ while ((match = createAppStartRegex.exec(content)) !== null) {
113
+ const exportName = match[1];
114
+ const startIndex = match.index + match[0].length - 1; // Position of opening {
115
+ // Find the matching closing brace using brace counting
116
+ let braceCount = 1;
117
+ let endIndex = startIndex + 1;
118
+ while (braceCount > 0 && endIndex < content.length) {
119
+ const char = content[endIndex];
120
+ if (char === '{')
121
+ braceCount++;
122
+ else if (char === '}')
123
+ braceCount--;
124
+ endIndex++;
125
+ }
126
+ if (braceCount !== 0) {
127
+ // Unbalanced braces, skip this match
128
+ continue;
129
+ }
130
+ // Extract the config body (between the braces)
131
+ const configBody = content.slice(startIndex + 1, endIndex - 1);
132
+ // Extract name property - look for the first name: '...' or name: "..."
133
+ const nameMatch = configBody.match(/name\s*:\s*['"]([^'"]+)['"]/);
134
+ if (!nameMatch)
135
+ continue;
136
+ const name = nameMatch[1];
137
+ // Extract component property - look for component: <identifier>
138
+ const componentMatch = configBody.match(/component\s*:\s*(\w+)/);
139
+ if (!componentMatch)
140
+ continue;
141
+ const componentName = componentMatch[1];
142
+ // Look up the component's import path
143
+ const componentImportPath = importMap.get(componentName);
144
+ if (!componentImportPath) {
145
+ console.warn(`[mcp-web-app] Warning: Could not find import for component "${componentName}" in app "${name}"`);
146
+ continue;
147
+ }
148
+ apps.push({
149
+ name,
150
+ exportName,
151
+ componentName,
152
+ componentImportPath,
153
+ });
154
+ }
155
+ return apps;
156
+ }
157
+ /**
158
+ * Resolve a component import path to an absolute path.
159
+ */
160
+ function resolveComponentPath(componentImportPath, configDir) {
161
+ // If it starts with . or /, it's a relative/absolute path
162
+ if (componentImportPath.startsWith('.') ||
163
+ componentImportPath.startsWith('/')) {
164
+ // Resolve relative to the config file's directory
165
+ const resolved = path.resolve(configDir, componentImportPath);
166
+ // Try with common extensions
167
+ const extensions = ['.tsx', '.ts', '.jsx', '.js'];
168
+ for (const ext of extensions) {
169
+ if (fs.existsSync(resolved + ext)) {
170
+ return resolved + ext;
171
+ }
172
+ }
173
+ if (fs.existsSync(resolved)) {
174
+ return resolved;
175
+ }
176
+ // Check for index files
177
+ for (const ext of extensions) {
178
+ const indexPath = path.join(resolved, `index${ext}`);
179
+ if (fs.existsSync(indexPath)) {
180
+ return indexPath;
181
+ }
182
+ }
183
+ return resolved;
184
+ }
185
+ // Otherwise, it's a package import - return as-is for Vite to resolve
186
+ return componentImportPath;
187
+ }
188
+ /**
189
+ * Generate a virtual module that renders an MCP App.
190
+ */
191
+ function generateVirtualModule(componentPath, componentName) {
192
+ return `
193
+ import { renderMCPApp } from '@mcp-web/app/internal';
194
+ import { ${componentName} } from '${componentPath}';
195
+
196
+ renderMCPApp(${componentName});
197
+ `;
198
+ }
199
+ /**
200
+ * Generate HTML content for an app entry.
201
+ */
202
+ function generateHTMLContent(appName, virtualModulePath) {
203
+ return `<!DOCTYPE html>
204
+ <html lang="en" style="color-scheme: light dark">
205
+ <head>
206
+ <meta charset="UTF-8" />
207
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
208
+ <title>${appName}</title>
209
+ <style>body { margin: 0; background: transparent; }</style>
210
+ </head>
211
+ <body>
212
+ <div id="root"></div>
213
+ <script type="module" src="${virtualModulePath}"></script>
214
+ </body>
215
+ </html>`;
216
+ }
217
+ /**
218
+ * Internal plugin that handles virtual modules and build process.
219
+ */
220
+ function mcpAppPlugin(projectRoot, outDir, configPath) {
221
+ let apps = [];
222
+ let configDir;
223
+ const virtualModules = new Map();
224
+ const htmlEntries = new Map();
225
+ return {
226
+ name: 'mcp-web-app',
227
+ configResolved(_config) {
228
+ // Store config for potential future use
229
+ },
230
+ buildStart() {
231
+ // Clear previous state
232
+ virtualModules.clear();
233
+ htmlEntries.clear();
234
+ if (!configPath) {
235
+ console.log('\n\u26A0 No MCP Apps config file found. Looking for src/mcp-apps.ts or src/mcp/apps.ts');
236
+ return;
237
+ }
238
+ configDir = path.dirname(configPath);
239
+ apps = parseAppsConfig(configPath);
240
+ if (apps.length === 0) {
241
+ console.log(`\n\u26A0 No apps found in ${path.relative(projectRoot, configPath)}`);
242
+ return;
243
+ }
244
+ // Generate virtual modules for each app
245
+ for (const app of apps) {
246
+ const kebabName = toKebabCase(app.name);
247
+ const virtualId = `${VIRTUAL_PREFIX}${kebabName}`;
248
+ const resolvedVirtualId = `${RESOLVED_VIRTUAL_PREFIX}${kebabName}`;
249
+ // Resolve the component path
250
+ const absoluteComponentPath = resolveComponentPath(app.componentImportPath, configDir);
251
+ // Generate the virtual module content
252
+ const moduleContent = generateVirtualModule(absoluteComponentPath, app.componentName);
253
+ virtualModules.set(resolvedVirtualId, moduleContent);
254
+ // Generate HTML entry
255
+ const htmlContent = generateHTMLContent(kebabName, virtualId);
256
+ const htmlPath = path.join(outDir, `${kebabName}.html`);
257
+ htmlEntries.set(kebabName, htmlContent);
258
+ // Write temporary HTML file for Vite to use as entry
259
+ fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
260
+ }
261
+ console.log(`\n\u2139 Found ${apps.length} MCP App(s) in ${path.relative(projectRoot, configPath)}`);
262
+ },
263
+ resolveId(id) {
264
+ if (id.startsWith(VIRTUAL_PREFIX)) {
265
+ return `\0${id}`;
266
+ }
267
+ return null;
268
+ },
269
+ load(id) {
270
+ // Handle virtual app modules
271
+ if (id.startsWith(RESOLVED_VIRTUAL_PREFIX)) {
272
+ return virtualModules.get(id) || null;
273
+ }
274
+ return null;
275
+ },
276
+ generateBundle() {
277
+ // Don't emit additional HTML - let vite-plugin-singlefile handle the output
278
+ // The inlined HTML will be at a nested path which we'll fix in closeBundle
279
+ },
280
+ closeBundle() {
281
+ if (apps.length === 0)
282
+ return;
283
+ // The inlined HTML files are output to a nested path matching the temp directory structure
284
+ // Move them to the root of the output directory
285
+ const tempSubDir = path.join(outDir, 'node_modules', '.mcp-web-app-temp');
286
+ if (fs.existsSync(tempSubDir)) {
287
+ for (const app of apps) {
288
+ const kebabName = toKebabCase(app.name);
289
+ const srcPath = path.join(tempSubDir, `${kebabName}.html`);
290
+ const destPath = path.join(outDir, `${kebabName}.html`);
291
+ if (fs.existsSync(srcPath)) {
292
+ // Move the inlined HTML to the root
293
+ fs.copyFileSync(srcPath, destPath);
294
+ }
295
+ }
296
+ // Clean up the nested directory
297
+ fs.rmSync(path.join(outDir, 'node_modules'), { recursive: true, force: true });
298
+ }
299
+ const relOutDir = path.relative(projectRoot, outDir);
300
+ console.log(`\n\u2713 Built ${apps.length} MCP App(s) to ${relOutDir}/`);
301
+ for (const app of apps) {
302
+ const kebabName = toKebabCase(app.name);
303
+ console.log(` - ${kebabName}.html (${app.exportName})`);
304
+ }
305
+ },
306
+ };
307
+ }
308
+ /**
309
+ * Define a Vite configuration for building MCP Apps as single HTML files.
310
+ *
311
+ * This function creates a complete Vite config that auto-discovers app
312
+ * definitions and bundles them into self-contained HTML files. These files
313
+ * can be served as MCP App resources that render inline in AI chat interfaces.
314
+ *
315
+ * **How it works:**
316
+ * 1. Scans for a config file (`src/mcp-apps.ts` or `src/mcp/apps.ts`)
317
+ * 2. Parses `createApp()` calls to find app names and components
318
+ * 3. Auto-generates entry files that render each component
319
+ * 4. Bundles everything into single HTML files
320
+ *
321
+ * **Features:**
322
+ * - No manual entry files needed - just define your apps!
323
+ * - Full Vite config flexibility - pass any standard Vite options
324
+ * - Automatic single-file bundling (JS, CSS, assets all inlined)
325
+ * - Watch mode support for development
326
+ * - PostMessage runtime for receiving props from the host
327
+ *
328
+ * **Critical settings that are always enforced:**
329
+ * - `build.assetsInlineLimit` → Maximum (for inlining)
330
+ * - `build.cssCodeSplit` → false (single CSS bundle)
331
+ * - `base` → './' (relative paths)
332
+ *
333
+ * @param viteConfig - Standard Vite configuration options (plugins, build settings, etc.)
334
+ * @param mcpOptions - MCP-specific options (appsConfig, outDir, silenceOverrideWarnings)
335
+ * @returns A Vite UserConfigExport ready for use in vite.config.ts
336
+ *
337
+ * @example Basic Usage
338
+ * ```typescript
339
+ * // vite.apps.config.ts
340
+ * import react from '@vitejs/plugin-react';
341
+ * import { defineMCPAppsConfig } from '@mcp-web/app/vite';
342
+ *
343
+ * export default defineMCPAppsConfig({
344
+ * plugins: [react()],
345
+ * });
346
+ * ```
347
+ *
348
+ * @example With Custom Options
349
+ * ```typescript
350
+ * import react from '@vitejs/plugin-react';
351
+ * import { defineMCPAppsConfig } from '@mcp-web/app/vite';
352
+ *
353
+ * export default defineMCPAppsConfig(
354
+ * {
355
+ * plugins: [react()],
356
+ * build: {
357
+ * sourcemap: true,
358
+ * minify: 'terser',
359
+ * },
360
+ * },
361
+ * {
362
+ * appsConfig: 'src/my-apps.ts',
363
+ * outDir: 'dist/apps',
364
+ * }
365
+ * );
366
+ * ```
367
+ *
368
+ * @example Apps Config File (src/mcp-apps.ts)
369
+ * ```typescript
370
+ * import { createApp } from '@mcp-web/app';
371
+ * import { Statistics } from './components/Statistics';
372
+ *
373
+ * export const statisticsApp = createApp({
374
+ * name: 'show_statistics',
375
+ * description: 'Display statistics visualization',
376
+ * component: Statistics,
377
+ * handler: () => ({
378
+ * completionRate: 0.75,
379
+ * totalTasks: 100,
380
+ * }),
381
+ * });
382
+ * ```
383
+ */
384
+ export function defineMCPAppsConfig(viteConfig = {}, mcpOptions = {}) {
385
+ const { appsConfig, outDir = 'public/mcp-web-apps', silenceOverrideWarnings = false, } = mcpOptions;
386
+ const projectRoot = process.cwd();
387
+ const outDirPath = path.resolve(projectRoot, outDir);
388
+ // Find the apps config file
389
+ const configPath = findAppsConfigFile(projectRoot, appsConfig);
390
+ // Parse apps to generate rollup inputs
391
+ let apps = [];
392
+ let tempHtmlDir = null;
393
+ const input = {};
394
+ if (configPath) {
395
+ apps = parseAppsConfig(configPath);
396
+ if (apps.length > 0) {
397
+ // Create a temp directory for HTML entries
398
+ tempHtmlDir = path.join(projectRoot, 'node_modules', '.mcp-web-app-temp');
399
+ fs.mkdirSync(tempHtmlDir, { recursive: true });
400
+ // Generate HTML entries for rollup input
401
+ for (const app of apps) {
402
+ const kebabName = toKebabCase(app.name);
403
+ const htmlPath = path.join(tempHtmlDir, `${kebabName}.html`);
404
+ const virtualModulePath = `${VIRTUAL_PREFIX}${kebabName}`;
405
+ const htmlContent = generateHTMLContent(kebabName, virtualModulePath);
406
+ fs.writeFileSync(htmlPath, htmlContent);
407
+ input[kebabName] = htmlPath;
408
+ }
409
+ }
410
+ }
411
+ // Check for and warn about overridden settings
412
+ if (!silenceOverrideWarnings) {
413
+ const userConfig = viteConfig;
414
+ for (const override of CRITICAL_OVERRIDES) {
415
+ const userValue = getNestedValue(userConfig, override.key);
416
+ if (userValue !== undefined && userValue !== override.required) {
417
+ console.warn(`[mcp-web-app] Warning: Overriding ${override.key} ` +
418
+ `(was: ${JSON.stringify(userValue)}, now: ${JSON.stringify(override.required)}). ` +
419
+ `Reason: ${override.reason}`);
420
+ }
421
+ }
422
+ }
423
+ // Build the merged config
424
+ const mergedConfig = {
425
+ ...viteConfig,
426
+ // These are always set by MCP Apps
427
+ base: './',
428
+ plugins: [
429
+ // User plugins first
430
+ ...(viteConfig.plugins || []),
431
+ // Then our plugins
432
+ viteSingleFile(),
433
+ mcpAppPlugin(projectRoot, outDirPath, configPath),
434
+ ],
435
+ build: {
436
+ ...viteConfig.build,
437
+ outDir: outDirPath,
438
+ emptyOutDir: true,
439
+ // Critical overrides for single-file output
440
+ assetsInlineLimit: Number.MAX_SAFE_INTEGER,
441
+ cssCodeSplit: false,
442
+ rollupOptions: {
443
+ ...viteConfig.build?.rollupOptions,
444
+ input: Object.keys(input).length > 0 ? input : undefined,
445
+ },
446
+ },
447
+ };
448
+ return mergedConfig;
449
+ }
450
+ export default defineMCPAppsConfig;
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@mcp-web/app",
3
+ "version": "0.1.0",
4
+ "description": "Build tooling for MCP Apps - bundle React components into single HTML files for AI UI rendering",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ },
13
+ "./vite": {
14
+ "import": "./dist/vite-plugin.js",
15
+ "types": "./dist/vite-plugin.d.ts"
16
+ },
17
+ "./internal": {
18
+ "import": "./dist/internal.js",
19
+ "types": "./dist/internal.d.ts"
20
+ }
21
+ },
22
+ "dependencies": {
23
+ "@modelcontextprotocol/ext-apps": "^1.0.1",
24
+ "vite-plugin-singlefile": "^2.3.0",
25
+ "zod": "~4.1.12",
26
+ "@mcp-web/types": "0.1.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^25.0.9",
30
+ "@types/react": "^18.3.24",
31
+ "@types/react-dom": "^18.3.7",
32
+ "typescript": "~5.9.3",
33
+ "vite": "^7.3.1"
34
+ },
35
+ "peerDependencies": {
36
+ "react": ">=17.0.0",
37
+ "react-dom": ">=17.0.0",
38
+ "vite": ">=5.0.0"
39
+ },
40
+ "peerDependenciesMeta": {
41
+ "react": {
42
+ "optional": true
43
+ },
44
+ "react-dom": {
45
+ "optional": true
46
+ }
47
+ },
48
+ "scripts": {
49
+ "build": "tsc",
50
+ "clean": "rm -rf dist",
51
+ "test": "bun test"
52
+ }
53
+ }
@@ -0,0 +1,181 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { ComponentType } from 'react';
3
+ import { z } from 'zod';
4
+ import { createApp, isCreatedApp } from '../src/create-app';
5
+
6
+ // Mock components for testing
7
+ const MockComponent: ComponentType<{ value: number }> = () => null;
8
+ const MockResultComponent: ComponentType<{ result: string }> = () => null;
9
+ const MockInputComponent: ComponentType<{
10
+ greeting: string;
11
+ doubled: number;
12
+ }> = () => null;
13
+ const MockEmptyComponent: ComponentType<Record<string, unknown>> = () => null;
14
+ const MockOutputComponent: ComponentType<{ output: string }> = () => null;
15
+ const MockAsyncComponent: ComponentType<{ async: boolean }> = () => null;
16
+
17
+ describe('createApp', () => {
18
+ test('creates a valid app definition', () => {
19
+ const app = createApp({
20
+ name: 'test_app',
21
+ description: 'A test app',
22
+ component: MockComponent,
23
+ handler: () => ({ value: 42 }),
24
+ });
25
+
26
+ expect(app.__brand).toBe('CreatedApp');
27
+ expect(app.definition.name).toBe('test_app');
28
+ expect(app.definition.description).toBe('A test app');
29
+ expect(typeof app.definition.handler).toBe('function');
30
+ expect(app.definition.component).toBe(MockComponent);
31
+ });
32
+
33
+ test('preserves handler functionality', async () => {
34
+ const app = createApp({
35
+ name: 'handler_test',
36
+ description: 'Test handler',
37
+ component: MockResultComponent,
38
+ handler: () => ({ result: 'success' }),
39
+ });
40
+
41
+ const result = await app.definition.handler();
42
+ expect(result).toEqual({ result: 'success' });
43
+ });
44
+
45
+ test('handler receives input when inputSchema is defined', async () => {
46
+ const InputSchema = z.object({
47
+ name: z.string(),
48
+ count: z.number(),
49
+ });
50
+
51
+ const app = createApp({
52
+ name: 'input_test',
53
+ description: 'Test input',
54
+ component: MockInputComponent,
55
+ inputSchema: InputSchema,
56
+ handler: ({ name, count }) => ({
57
+ greeting: `Hello ${name}`,
58
+ doubled: count * 2,
59
+ }),
60
+ });
61
+
62
+ const result = await app.definition.handler({ name: 'World', count: 21 });
63
+ expect(result).toEqual({
64
+ greeting: 'Hello World',
65
+ doubled: 42,
66
+ });
67
+ });
68
+
69
+ test('preserves optional url and resourceUri', () => {
70
+ const app = createApp({
71
+ name: 'custom_urls',
72
+ description: 'Custom URLs',
73
+ component: MockEmptyComponent,
74
+ handler: () => ({}),
75
+ url: '/custom/app.html',
76
+ resourceUri: 'ui://custom/app',
77
+ });
78
+
79
+ expect(app.definition.url).toBe('/custom/app.html');
80
+ expect(app.definition.resourceUri).toBe('ui://custom/app');
81
+ });
82
+
83
+ test('preserves inputSchema and propsSchema', () => {
84
+ const InputSchema = z.object({ input: z.string() });
85
+ const PropsSchema = z.object({ output: z.string() });
86
+
87
+ const app = createApp({
88
+ name: 'schema_test',
89
+ description: 'Schema test',
90
+ component: MockOutputComponent,
91
+ inputSchema: InputSchema,
92
+ propsSchema: PropsSchema,
93
+ handler: ({ input }) => ({ output: input.toUpperCase() }),
94
+ });
95
+
96
+ expect(app.definition.inputSchema).toBe(InputSchema);
97
+ expect(app.definition.propsSchema).toBe(PropsSchema);
98
+ });
99
+
100
+ test('throws error for invalid app definition - missing name', () => {
101
+ expect(() => {
102
+ createApp({
103
+ name: '',
104
+ description: 'Test',
105
+ component: MockEmptyComponent,
106
+ handler: () => ({}),
107
+ });
108
+ }).toThrow('Invalid app definition');
109
+ });
110
+
111
+ test('throws error for invalid app definition - missing description', () => {
112
+ expect(() => {
113
+ createApp({
114
+ name: 'test',
115
+ description: '',
116
+ component: MockEmptyComponent,
117
+ handler: () => ({}),
118
+ });
119
+ }).toThrow('Invalid app definition');
120
+ });
121
+
122
+ test('async handler works correctly', async () => {
123
+ const app = createApp({
124
+ name: 'async_test',
125
+ description: 'Async handler test',
126
+ component: MockAsyncComponent,
127
+ handler: async () => {
128
+ await new Promise((resolve) => setTimeout(resolve, 10));
129
+ return { async: true };
130
+ },
131
+ });
132
+
133
+ const result = await app.definition.handler();
134
+ expect(result).toEqual({ async: true });
135
+ });
136
+ });
137
+
138
+ describe('isCreatedApp', () => {
139
+ test('returns true for CreatedApp', () => {
140
+ const app = createApp({
141
+ name: 'test',
142
+ description: 'test',
143
+ component: MockEmptyComponent,
144
+ handler: () => ({}),
145
+ });
146
+
147
+ expect(isCreatedApp(app)).toBe(true);
148
+ });
149
+
150
+ test('returns false for null', () => {
151
+ expect(isCreatedApp(null)).toBe(false);
152
+ });
153
+
154
+ test('returns false for undefined', () => {
155
+ expect(isCreatedApp(undefined)).toBe(false);
156
+ });
157
+
158
+ test('returns false for plain object', () => {
159
+ expect(isCreatedApp({})).toBe(false);
160
+ });
161
+
162
+ test('returns false for object with wrong brand', () => {
163
+ expect(isCreatedApp({ __brand: 'NotCreatedApp' })).toBe(false);
164
+ });
165
+
166
+ test('returns false for app definition without brand', () => {
167
+ expect(
168
+ isCreatedApp({
169
+ name: 'test',
170
+ description: 'test',
171
+ handler: () => ({}),
172
+ })
173
+ ).toBe(false);
174
+ });
175
+
176
+ test('returns false for primitives', () => {
177
+ expect(isCreatedApp('string')).toBe(false);
178
+ expect(isCreatedApp(123)).toBe(false);
179
+ expect(isCreatedApp(true)).toBe(false);
180
+ });
181
+ });