Skip to content

Commit 74fc30a

Browse files
rahmanunverclaude
andcommitted
fix(pluggable-widgets-mcp): fix E2E pipeline — name passing, scaffold cleanup, build parsing
Fixes 6 issues from E2E testing to make the create → generate → build pipeline work end-to-end: - Pass widget name via --name flag instead of positional arg (defense-in-depth) - Read widgetName from package.json instead of deriving from directory basename - Clean up stale scaffold files and regenerate package.xml before code generation - Only import executeAction for TSX patterns that actually use it (fixes TS6133) - Parse Rollup TypeScript error format with file location correlation - Switch generator-widget to local file: reference for development Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f2bfa2b commit 74fc30a

6 files changed

Lines changed: 1087 additions & 37 deletions

File tree

packages/pluggable-widgets-mcp/package.json

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@
2020
"lint": "eslint src/ package.json",
2121
"start": "pnpm run build && node dist/index.js",
2222
"start:http": "pnpm run build && node dist/index.js http",
23-
"start:stdio": "pnpm run build && node dist/index.js stdio"
23+
"start:stdio": "pnpm run build && node dist/index.js stdio",
24+
"test": "vitest run",
25+
"test:watch": "vitest"
2426
},
2527
"dependencies": {
26-
"@mendix/generator-widget": "github:rahmanunver/widgets-tools#generator-widget-noninteractive-defaults&path:packages/generator-widget",
28+
"@mendix/generator-widget": "file:../../../widgets-tools/packages/generator-widget",
2729
"@modelcontextprotocol/sdk": "^1.24.2",
2830
"cors": "^2.8.5",
2931
"express": "^5.1.0",
@@ -35,7 +37,10 @@
3537
"@types/node": "^22.0.0",
3638
"tsc-alias": "^1.8.16",
3739
"tsx": "^4.21.0",
38-
"typescript": "^5.9.3"
40+
"typescript": "^5.9.3",
41+
"vite": "^4.5.14",
42+
"vite-tsconfig-paths": "^4.3.2",
43+
"vitest": "^0.34.6"
3944
},
4045
"keywords": [],
4146
"packageManager": "pnpm@10.17.0",

packages/pluggable-widgets-mcp/src/generators/tsx-generator.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,22 @@ function generateImports(widgetName: string, properties: PropertyDefinition[], p
105105

106106
imports.push(`import { ${Array.from(reactImports).sort().join(", ")} } from "react";`);
107107

108-
// Mendix imports
108+
// Mendix imports — only import executeAction when the pattern actually uses it
109+
let needsExecuteAction = false;
109110
if (hasAction) {
111+
if (pattern === "display" || pattern === "button") {
112+
// These patterns use generateActionHandler for all action props
113+
needsExecuteAction = true;
114+
} else if (pattern === "input") {
115+
// Input pattern only uses executeAction if there's a "change" action
116+
needsExecuteAction = properties.some(p => p.type === "action" && p.key.toLowerCase().includes("change"));
117+
} else if (pattern === "dataList") {
118+
// DataList pattern uses executeAction for item click actions
119+
needsExecuteAction = properties.some(p => p.type === "action" && p.key.toLowerCase().includes("item"));
120+
}
121+
// container pattern doesn't use executeAction (uses useState for toggle)
122+
}
123+
if (needsExecuteAction) {
110124
imports.push('import { executeAction } from "@mendix/widget-plugin-platform/framework/execute-action";');
111125
}
112126
if (hasDatasource) {

packages/pluggable-widgets-mcp/src/tools/build.tools.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,18 @@ const TS_ERROR_PATTERN = /^(.+?)[:(](\d+)[,:](\d+)[):]?\s*[-:]?\s*error\s+(TS\d+
6464
*/
6565
const TS_ERROR_SIMPLE_PATTERN = /^error\s+(TS\d+):\s*(.+)$/;
6666

67+
/**
68+
* Rollup TS error pattern from pluggable-widgets-tools build:
69+
* (plugin typescript) RollupError: @rollup/plugin-typescript TS6133: 'executeAction' is declared but its value is never read.
70+
*/
71+
const ROLLUP_TS_ERROR_PATTERN = /RollupError:.*?(TS\d+):\s*(.+)/;
72+
73+
/**
74+
* File location pattern that follows Rollup errors on the next line:
75+
* src/CounterTwo.tsx (2:1)
76+
*/
77+
const ROLLUP_FILE_LOCATION_PATTERN = /^(.+?\.\w+)\s+\((\d+):(\d+)\)$/;
78+
6779
/**
6880
* XML error patterns
6981
*/
@@ -101,6 +113,16 @@ function parseTypeScriptError(line: string): ParsedError | null {
101113
};
102114
}
103115

116+
// Try Rollup TS error pattern
117+
const rollupMatch = line.match(ROLLUP_TS_ERROR_PATTERN);
118+
if (rollupMatch) {
119+
return {
120+
tsCode: rollupMatch[1],
121+
message: rollupMatch[2],
122+
category: "typescript"
123+
};
124+
}
125+
104126
return null;
105127
}
106128

@@ -115,19 +137,33 @@ function parseBuildOutput(stdout: string, stderr: string): BuildResult {
115137

116138
const lines = output.split("\n");
117139

118-
for (const line of lines) {
119-
const trimmed = line.trim();
140+
for (let i = 0; i < lines.length; i++) {
141+
const trimmed = lines[i].trim();
120142
if (!trimmed) continue;
121143

122144
// TypeScript errors (try to parse with location)
123-
if (trimmed.includes("error TS") || trimmed.match(/:\s*error\s+TS/)) {
145+
// Matches standard TS errors, simple TS errors, and Rollup TS errors
146+
if (trimmed.includes("error TS") || trimmed.match(/:\s*error\s+TS/) || trimmed.includes("RollupError:")) {
124147
const parsed = parseTypeScriptError(trimmed);
125148
if (parsed) {
126149
errors.push(parsed);
127150
continue;
128151
}
129152
}
130153

154+
// Check for Rollup file location pattern on a line following a Rollup error
155+
// Format: "src/Widget.tsx (2:1)"
156+
const fileLocMatch = trimmed.match(ROLLUP_FILE_LOCATION_PATTERN);
157+
if (fileLocMatch && errors.length > 0) {
158+
const lastError = errors[errors.length - 1];
159+
if (!lastError.file) {
160+
lastError.file = fileLocMatch[1];
161+
lastError.line = parseInt(fileLocMatch[2], 10);
162+
lastError.column = parseInt(fileLocMatch[3], 10);
163+
}
164+
continue;
165+
}
166+
131167
// XML validation errors
132168
if (XML_ERROR_PATTERNS.some(pattern => pattern.test(trimmed))) {
133169
errors.push({

packages/pluggable-widgets-mcp/src/tools/code-generation.tools.ts

Lines changed: 116 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77

88
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9-
import { mkdir, stat, writeFile } from "node:fs/promises";
9+
import { mkdir, readdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
1010
import { basename, dirname, join } from "node:path";
1111
import { z } from "zod";
1212
import { generateWidgetXml, validateWidgetDefinition } from "@/generators/xml-generator";
@@ -132,14 +132,122 @@ type GenerateWidgetCodeInput = z.infer<typeof generateWidgetCodeSchema>;
132132
// =============================================================================
133133

134134
/**
135-
* Extracts widget name from path (e.g., /path/to/MyWidget -> MyWidget)
135+
* Extracts widget name from the widget directory.
136+
* Reads from package.json widgetName (authoritative source), falls back to basename.
136137
*/
137-
function extractWidgetName(widgetPath: string): string {
138+
async function extractWidgetName(widgetPath: string): Promise<string> {
139+
// Try reading from package.json (authoritative source)
140+
try {
141+
const pkgPath = join(widgetPath, "package.json");
142+
const pkgJson = JSON.parse(await readFile(pkgPath, "utf-8"));
143+
if (pkgJson.widgetName && /^[A-Z][a-zA-Z0-9]*$/.test(pkgJson.widgetName)) {
144+
return pkgJson.widgetName;
145+
}
146+
} catch {
147+
// Fall through to basename approach
148+
}
149+
// Fallback: derive from directory name
138150
const base = basename(widgetPath);
139-
// Convert to PascalCase if needed
140151
return base.charAt(0).toUpperCase() + base.slice(1);
141152
}
142153

154+
/**
155+
* Cleans up stale scaffold files that don't match the current widget name.
156+
* The generator creates files named after the widget (e.g., CounterTwo.xml, CounterTwo.tsx).
157+
* When generate-widget-code writes new files, old scaffold artifacts must be removed
158+
* to prevent build conflicts (wrong typings, duplicate XML definitions).
159+
*/
160+
async function cleanupScaffoldFiles(widgetPath: string, widgetName: string): Promise<void> {
161+
const srcDir = join(widgetPath, "src");
162+
const typingsDir = join(widgetPath, "typings");
163+
164+
let srcFiles: string[];
165+
try {
166+
srcFiles = await readdir(srcDir);
167+
} catch {
168+
return; // src dir doesn't exist yet, nothing to clean
169+
}
170+
171+
// 1. Remove old .xml files in src/ (except package.xml and the one we're about to write)
172+
for (const file of srcFiles) {
173+
if (file === "package.xml" || file === `${widgetName}.xml`) continue;
174+
if (file.endsWith(".xml")) {
175+
await unlink(join(srcDir, file));
176+
console.error(`[code-generation] Cleaned up stale file: src/${file}`);
177+
}
178+
}
179+
180+
// 2. Remove old .tsx, .editorConfig.ts, .editorPreview.tsx that don't match our widget name
181+
for (const file of srcFiles) {
182+
// Only clean top-level src/ files, not files in subdirectories
183+
const isOldTsx =
184+
file.endsWith(".tsx") && file !== `${widgetName}.tsx` && file !== `${widgetName}.editorPreview.tsx`;
185+
const isOldEditorConfig = file.endsWith(".editorConfig.ts") && file !== `${widgetName}.editorConfig.ts`;
186+
const isOldEditorPreview = file.endsWith(".editorPreview.tsx") && file !== `${widgetName}.editorPreview.tsx`;
187+
if (isOldTsx || isOldEditorConfig || isOldEditorPreview) {
188+
await unlink(join(srcDir, file));
189+
console.error(`[code-generation] Cleaned up stale file: src/${file}`);
190+
}
191+
}
192+
193+
// 3. Remove old .css/.scss files in src/ui/ that don't match
194+
const uiDir = join(srcDir, "ui");
195+
try {
196+
const uiFiles = await readdir(uiDir);
197+
for (const file of uiFiles) {
198+
if (
199+
(file.endsWith(".css") || file.endsWith(".scss")) &&
200+
file !== `${widgetName}.css` &&
201+
file !== `${widgetName}.scss`
202+
) {
203+
await unlink(join(uiDir, file));
204+
console.error(`[code-generation] Cleaned up stale file: src/ui/${file}`);
205+
}
206+
}
207+
} catch {
208+
/* ui dir might not exist yet */
209+
}
210+
211+
// 4. Clear old typings that don't match
212+
try {
213+
const typingsFiles = await readdir(typingsDir);
214+
for (const file of typingsFiles) {
215+
if (file.endsWith(".d.ts") && file !== `${widgetName}Props.d.ts`) {
216+
await unlink(join(typingsDir, file));
217+
console.error(`[code-generation] Cleaned up stale file: typings/${file}`);
218+
}
219+
}
220+
} catch {
221+
/* typings dir might not exist yet */
222+
}
223+
224+
// 5. Regenerate package.xml with correct widget name + version
225+
const packageXmlPath = join(srcDir, "package.xml");
226+
const widgetNameLower = widgetName.toLowerCase();
227+
let version = "1.0.0";
228+
try {
229+
const pkgJson = JSON.parse(await readFile(join(widgetPath, "package.json"), "utf-8"));
230+
if (pkgJson.version) version = pkgJson.version;
231+
} catch {
232+
/* use default */
233+
}
234+
const packageXml = [
235+
'<?xml version="1.0" encoding="utf-8" ?>',
236+
'<package xmlns="http://www.mendix.com/package/1.0/">',
237+
` <clientModule name="${widgetName}" version="${version}" xmlns="http://www.mendix.com/clientModule/1.0/">`,
238+
" <widgetFiles>",
239+
` <widgetFile path="${widgetName}.xml"/>`,
240+
" </widgetFiles>",
241+
" <files>",
242+
` <file path="mendix/${widgetNameLower}"/>`,
243+
" </files>",
244+
" </clientModule>",
245+
"</package>"
246+
].join("\n");
247+
await writeFile(packageXmlPath, packageXml, "utf-8");
248+
console.error(`[code-generation] Regenerated package.xml for ${widgetName}`);
249+
}
250+
143251
/**
144252
* Generates property suggestions based on widget description.
145253
*/
@@ -331,7 +439,7 @@ async function handleGenerateWidgetCode(args: GenerateWidgetCodeInput): Promise<
331439
}
332440

333441
// Extract widget name from path
334-
const widgetName = extractWidgetName(widgetPath);
442+
const widgetName = await extractWidgetName(widgetPath);
335443

336444
console.error(`[code-generation] Generating code for ${widgetName} with ${properties.length} properties`);
337445

@@ -358,6 +466,9 @@ async function handleGenerateWidgetCode(args: GenerateWidgetCodeInput): Promise<
358466
);
359467
}
360468

469+
// Clean up stale scaffold files before writing new ones
470+
await cleanupScaffoldFiles(widgetPath, widgetName);
471+
361472
// Generate XML
362473
console.error(`[code-generation] Generating XML...`);
363474
const xmlResult = generateWidgetXml(widgetDefinition);

packages/pluggable-widgets-mcp/src/tools/utils/generator.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ function getGeneratorBinPath(): string {
5252
function buildWidgetFlags(options: WidgetOptions): string[] {
5353
return [
5454
"--default",
55+
"--name",
56+
options.name,
5557
"--description",
5658
options.description,
5759
"--organization",
@@ -102,7 +104,7 @@ export async function runWidgetGenerator(
102104
let stderr = "";
103105
let installingNotified = false;
104106

105-
const child = spawn(generatorBin, [options.name, ...flags], {
107+
const child = spawn(generatorBin, flags, {
106108
cwd: outputDir,
107109
env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1", DO_NOT_TRACK: "1" },
108110
stdio: ["ignore", "pipe", "pipe"]

0 commit comments

Comments
 (0)