unreal-engine-mcp-server 0.5.3 → 0.5.4

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.
@@ -1,948 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import fs from 'node:fs/promises';
4
- import path from 'node:path';
5
- import process from 'node:process';
6
- import { fileURLToPath } from 'node:url';
7
- import { performance } from 'node:perf_hooks';
8
-
9
- import { Client } from '@modelcontextprotocol/sdk/client/index.js';
10
- import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
11
-
12
- const failureKeywords = [
13
- 'error',
14
- 'fail',
15
- 'invalid',
16
- 'missing',
17
- 'not found',
18
- 'reject',
19
- 'warning'
20
- ];
21
-
22
- const successKeywords = [
23
- 'success',
24
- 'spawn',
25
- 'visible',
26
- 'applied',
27
- 'returns',
28
- 'plays',
29
- 'updates',
30
- 'created',
31
- 'saved'
32
- ];
33
-
34
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
35
- const repoRoot = path.resolve(__dirname, '..');
36
- const defaultDocPath = path.resolve(repoRoot, 'docs', 'unreal-tool-test-cases.md');
37
- const docPath = path.resolve(repoRoot, process.env.UNREAL_MCP_TEST_DOC ?? defaultDocPath);
38
- const reportsDir = path.resolve(repoRoot, 'tests', 'reports');
39
- const resultsPath = path.join(reportsDir, `unreal-tool-test-results-${new Date().toISOString().replace(/[:]/g, '-')}.json`);
40
- const defaultFbxDir = normalizeWindowsPath(process.env.UNREAL_MCP_FBX_DIR ?? path.join(repoRoot, 'tests', 'assets', 'fbx'));
41
- const defaultFbxFile = normalizeWindowsPath(process.env.UNREAL_MCP_FBX_FILE ?? path.join(defaultFbxDir, 'test_model.fbx'));
42
-
43
- const cliOptions = parseCliOptions(process.argv.slice(2));
44
- const serverCommand = process.env.UNREAL_MCP_SERVER_CMD ?? 'node';
45
- const serverArgs = parseArgsList(process.env.UNREAL_MCP_SERVER_ARGS) ?? [path.join(repoRoot, 'dist', 'cli.js')];
46
- const serverCwd = process.env.UNREAL_MCP_SERVER_CWD ?? repoRoot;
47
- const stressTestMode = process.env.STRESS_TEST_MODE === '1';
48
- const benchmarkMode = process.env.BENCHMARK_MODE === '1';
49
-
50
- async function main() {
51
- await ensureFbxDirectory();
52
- const allCases = await loadTestCasesFromDoc(docPath);
53
- if (allCases.length === 0) {
54
- console.error(`No test cases detected in ${docPath}.`);
55
- process.exitCode = 1;
56
- return;
57
- }
58
-
59
- const filteredCases = allCases.filter((testCase) => {
60
- if (cliOptions.group && testCase.groupName !== cliOptions.group) return false;
61
- if (cliOptions.caseId && testCase.caseId !== cliOptions.caseId) return false;
62
- if (cliOptions.text && !testCase.scenario.toLowerCase().includes(cliOptions.text.toLowerCase())) {
63
- return false;
64
- }
65
- return true;
66
- });
67
-
68
- if (filteredCases.length === 0) {
69
- console.warn('No test cases matched the provided filters. Exiting.');
70
- return;
71
- }
72
-
73
- let transport; let client;
74
- const runResults = [];
75
- let automationBridgeStatus = { connected: false, summary: null };
76
- let automationBridgeTestsEnabled = process.env.UNREAL_MCP_AUTOMATION_BRIDGE === '1';
77
-
78
- if (!cliOptions.dryRun) {
79
- try {
80
- transport = new StdioClientTransport({
81
- command: serverCommand,
82
- args: serverArgs,
83
- cwd: serverCwd,
84
- stderr: 'inherit'
85
- });
86
-
87
- client = new Client({
88
- name: 'unreal-mcp-tool-test-runner',
89
- version: '0.1.0'
90
- });
91
-
92
- await client.connect(transport);
93
- await client.listTools({});
94
-
95
- try {
96
- const bridgeResource = await client.readResource({ uri: 'ue://automation-bridge' });
97
- const text = bridgeResource.contents?.[0]?.text;
98
- if (text) {
99
- const parsed = JSON.parse(text);
100
- if (parsed && typeof parsed === 'object') {
101
- const summary = parsed.summary ?? parsed;
102
- const connected = Boolean(summary?.connected);
103
- automationBridgeStatus = { connected, summary };
104
- if (connected) {
105
- automationBridgeTestsEnabled = true;
106
- }
107
- }
108
- }
109
- } catch (err) {
110
- // Resource may not exist on older servers; treat as unavailable without failing run
111
- console.warn('[warn] Unable to query ue://automation-bridge resource:', err?.message ?? String(err));
112
- }
113
- } catch (err) {
114
- console.error('Failed to start or initialize MCP server:', err);
115
- if (transport) {
116
- try { await transport.close(); } catch { /* ignore */ }
117
- }
118
- process.exitCode = 1;
119
- return;
120
- }
121
- }
122
-
123
- for (const testCase of filteredCases) {
124
- let skipReason = testCase.skipReason;
125
-
126
- if (!skipReason && testCase.groupName === 'Automation Bridge') {
127
- if (!automationBridgeTestsEnabled) {
128
- skipReason = 'Automation bridge tests disabled (set UNREAL_MCP_AUTOMATION_BRIDGE=1 or connect the plugin).';
129
- } else {
130
- const requestedTransport = typeof testCase.arguments?.transport === 'string'
131
- ? testCase.arguments.transport.trim().toLowerCase()
132
- : '';
133
- const wantsBridge = ['automation_bridge', 'automation', 'bridge'].includes(requestedTransport);
134
- if (wantsBridge && !automationBridgeStatus.connected) {
135
- skipReason = 'Automation bridge transport requested but plugin is not connected.';
136
- }
137
- }
138
- }
139
-
140
- if (testCase.skipReason) {
141
- runResults.push({
142
- ...testCase,
143
- status: 'skipped',
144
- detail: testCase.skipReason
145
- });
146
- console.log(formatResultLine(testCase, 'skipped', testCase.skipReason));
147
- continue;
148
- }
149
-
150
- if (skipReason) {
151
- runResults.push({
152
- ...testCase,
153
- status: 'skipped',
154
- detail: skipReason
155
- });
156
- console.log(formatResultLine(testCase, 'skipped', skipReason));
157
- continue;
158
- }
159
-
160
- if (cliOptions.dryRun) {
161
- runResults.push({
162
- ...testCase,
163
- status: 'skipped',
164
- detail: 'Dry run'
165
- });
166
- console.log(formatResultLine(testCase, 'skipped', 'Dry run'));
167
- continue;
168
- }
169
-
170
- const started = performance.now();
171
- try {
172
- const response = await client.callTool({
173
- name: testCase.toolName,
174
- arguments: testCase.arguments
175
- });
176
- const duration = performance.now() - started;
177
- const evaluation = evaluateExpectation(testCase, response);
178
- runResults.push({
179
- ...testCase,
180
- status: evaluation.passed ? 'passed' : 'failed',
181
- durationMs: duration,
182
- detail: evaluation.reason,
183
- response
184
- });
185
- console.log(formatResultLine(testCase, evaluation.passed ? 'passed' : 'failed', evaluation.reason, duration));
186
- } catch (err) {
187
- const duration = performance.now() - started;
188
- runResults.push({
189
- ...testCase,
190
- status: 'failed',
191
- durationMs: duration,
192
- detail: err instanceof Error ? err.message : String(err)
193
- });
194
- console.log(formatResultLine(testCase, 'failed', err instanceof Error ? err.message : String(err), duration));
195
- }
196
- }
197
-
198
- if (!cliOptions.dryRun) {
199
- try {
200
- await client.close();
201
- } catch {
202
- // ignore
203
- }
204
- try {
205
- await transport.close();
206
- } catch {
207
- // ignore
208
- }
209
- }
210
-
211
- await persistResults(runResults);
212
- summarize(runResults);
213
-
214
- // Performance statistics if benchmarking
215
- if (benchmarkMode) {
216
- generateBenchmarkReport(runResults);
217
- }
218
-
219
- if (runResults.some((result) => result.status === 'failed')) {
220
- process.exitCode = 1;
221
- }
222
- }
223
-
224
- function parseCliOptions(args) {
225
- const options = {
226
- dryRun: false,
227
- group: undefined,
228
- caseId: undefined,
229
- text: undefined
230
- };
231
-
232
- for (const arg of args) {
233
- if (arg === '--dry-run') {
234
- options.dryRun = true;
235
- } else if (arg.startsWith('--group=')) {
236
- options.group = arg.slice('--group='.length);
237
- } else if (arg.startsWith('--case=')) {
238
- options.caseId = arg.slice('--case='.length);
239
- } else if (arg.startsWith('--text=')) {
240
- options.text = arg.slice('--text='.length);
241
- }
242
- }
243
-
244
- return options;
245
- }
246
-
247
- function parseArgsList(value) {
248
- if (!value) return undefined;
249
- const trimmed = value.trim();
250
- if (!trimmed) return undefined;
251
- if (trimmed.startsWith('[')) {
252
- try {
253
- const parsed = JSON.parse(trimmed);
254
- if (Array.isArray(parsed)) return parsed.map(String);
255
- } catch (_) {
256
- // fall through
257
- }
258
- }
259
- return trimmed.split(/\s+/).filter(Boolean);
260
- }
261
-
262
- async function loadTestCasesFromDoc(filePath) {
263
- const raw = await fs.readFile(filePath, 'utf8');
264
- const lines = raw.split(/\r?\n/);
265
- const cases = [];
266
- let currentGroup = undefined;
267
- let inLegacySection = false;
268
-
269
- for (const line of lines) {
270
- if (line.startsWith('## ')) {
271
- const headerTitle = line.replace(/^##\s+/, '').trim();
272
- if (headerTitle.toLowerCase().includes('legacy comprehensive matrix')) {
273
- inLegacySection = true;
274
- currentGroup = undefined;
275
- continue;
276
- }
277
- if (inLegacySection) {
278
- currentGroup = undefined;
279
- continue;
280
- }
281
- currentGroup = headerTitle;
282
- continue;
283
- }
284
- if (!currentGroup) continue;
285
- if (!line.startsWith('|') || /^\|\s*-+/.test(line) || /^\|\s*#\s*\|/.test(line)) {
286
- continue;
287
- }
288
-
289
- const columns = line.split('|').map((part) => part.trim());
290
- if (columns.length < 5) continue;
291
- const index = columns[1];
292
- const scenario = columns[2];
293
- const example = columns[3];
294
- const expected = columns[4];
295
- const payload = extractPayload(example);
296
- const simplifiedGroup = simplifyGroupName(currentGroup);
297
-
298
- const enriched = enrichTestCase({
299
- group: currentGroup,
300
- groupName: simplifiedGroup,
301
- index,
302
- scenario,
303
- example,
304
- expected,
305
- payload
306
- });
307
-
308
- cases.push(enriched);
309
- }
310
- return cases;
311
- }
312
-
313
- function simplifyGroupName(groupTitle) {
314
- return groupTitle
315
- .replace(/`[^`]+`/g, '')
316
- .replace(/\([^)]*\)/g, '')
317
- .replace('Tools', 'Tools')
318
- .trim();
319
- }
320
-
321
- function extractPayload(exampleColumn) {
322
- if (!exampleColumn) return undefined;
323
- const codeMatches = [...exampleColumn.matchAll(/`([^`]+)`/g)];
324
- for (const match of codeMatches) {
325
- const snippet = match[1].trim();
326
- if (!snippet) continue;
327
- const jsonCandidate = normalizeJsonCandidate(snippet);
328
- if (!jsonCandidate) continue;
329
- try {
330
- return {
331
- raw: snippet,
332
- value: JSON.parse(jsonCandidate)
333
- };
334
- } catch (_) {
335
- continue;
336
- }
337
- }
338
- return undefined;
339
- }
340
-
341
- function normalizeJsonCandidate(snippet) {
342
- const trimmed = snippet.trim();
343
- if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
344
- return trimmed;
345
- }
346
- return undefined;
347
- }
348
-
349
- function enrichTestCase(rawCase) {
350
- const caseIdBase = `${rawCase.groupName.toLowerCase().replace(/\s+/g, '-')}-${rawCase.index}`;
351
- const base = {
352
- group: rawCase.group,
353
- groupName: rawCase.groupName,
354
- index: rawCase.index,
355
- scenario: rawCase.scenario,
356
- expected: rawCase.expected,
357
- example: rawCase.example,
358
- caseId: caseIdBase,
359
- payloadSnippet: rawCase.payload?.raw,
360
- arguments: undefined,
361
- toolName: undefined,
362
- skipReason: undefined
363
- };
364
-
365
- const payloadValue = rawCase.payload?.value
366
- ? hydratePlaceholders(rawCase.payload.value)
367
- : undefined;
368
- const scenarioLower = rawCase.scenario.toLowerCase();
369
-
370
- switch (rawCase.groupName) {
371
- case 'Lighting Tools': {
372
- if (!payloadValue) {
373
- return { ...base, skipReason: 'No JSON payload provided' };
374
- }
375
- if (/lightmass|ensure/i.test(rawCase.scenario)) {
376
- return { ...base, skipReason: 'Scenario requires manual steps not exposed via consolidated tool' };
377
- }
378
- let lightType;
379
- if (scenarioLower.includes('directional')) lightType = 'Directional';
380
- else if (scenarioLower.includes('point')) lightType = 'Point';
381
- else if (scenarioLower.includes('spot')) lightType = 'Spot';
382
- else if (scenarioLower.includes('rect')) lightType = 'Rect';
383
- else if (scenarioLower.includes('sky')) lightType = 'Sky';
384
- else if (scenarioLower.includes('build lighting')) {
385
- return {
386
- ...base,
387
- skipReason: 'Skipping build lighting scenarios to avoid long editor runs'
388
- };
389
- } else {
390
- return { ...base, skipReason: 'Unrecognized light type or scenario' };
391
- }
392
-
393
- const args = {
394
- action: 'create_light',
395
- lightType,
396
- name: payloadValue.name ?? `${lightType}Light_${rawCase.index}`
397
- };
398
-
399
- if (typeof payloadValue.intensity === 'number') {
400
- args.intensity = payloadValue.intensity;
401
- }
402
- if (Array.isArray(payloadValue.location) && payloadValue.location.length === 3) {
403
- args.location = {
404
- x: payloadValue.location[0],
405
- y: payloadValue.location[1],
406
- z: payloadValue.location[2]
407
- };
408
- }
409
- if (Array.isArray(payloadValue.rotation) && payloadValue.rotation.length === 3) {
410
- args.rotation = {
411
- pitch: payloadValue.rotation[0],
412
- yaw: payloadValue.rotation[1],
413
- roll: payloadValue.rotation[2]
414
- };
415
- }
416
- if (Array.isArray(payloadValue.color) && payloadValue.color.length === 3) {
417
- args.color = payloadValue.color;
418
- }
419
- if (payloadValue.radius !== undefined) {
420
- args.radius = payloadValue.radius;
421
- }
422
- if (payloadValue.innerCone !== undefined) {
423
- args.innerCone = payloadValue.innerCone;
424
- }
425
- if (payloadValue.outerCone !== undefined) {
426
- args.outerCone = payloadValue.outerCone;
427
- }
428
- if (payloadValue.width !== undefined) {
429
- args.width = payloadValue.width;
430
- }
431
- if (payloadValue.height !== undefined) {
432
- args.height = payloadValue.height;
433
- }
434
- if (payloadValue.falloffExponent !== undefined) {
435
- args.falloffExponent = payloadValue.falloffExponent;
436
- }
437
- if (typeof payloadValue.castShadows === 'boolean') {
438
- args.castShadows = payloadValue.castShadows;
439
- }
440
- if (payloadValue.temperature !== undefined) {
441
- args.temperature = payloadValue.temperature;
442
- }
443
- if (typeof payloadValue.sourceType === 'string') {
444
- args.sourceType = payloadValue.sourceType;
445
- }
446
- if (typeof payloadValue.cubemapPath === 'string') {
447
- args.cubemapPath = payloadValue.cubemapPath;
448
- }
449
- if (typeof payloadValue.recapture === 'boolean') {
450
- args.recapture = payloadValue.recapture;
451
- }
452
-
453
- return {
454
- ...base,
455
- toolName: 'manage_level',
456
- arguments: args
457
- };
458
- }
459
- case 'Actor Tools': {
460
- if (!payloadValue) {
461
- return { ...base, skipReason: 'No JSON payload provided' };
462
- }
463
- const args = { ...payloadValue };
464
- if (Array.isArray(args.location) && args.location.length === 3) {
465
- args.location = { x: args.location[0], y: args.location[1], z: args.location[2] };
466
- }
467
- if (Array.isArray(args.rotation) && args.rotation.length === 3) {
468
- args.rotation = { pitch: args.rotation[0], yaw: args.rotation[1], roll: args.rotation[2] };
469
- }
470
- return {
471
- ...base,
472
- toolName: 'control_actor',
473
- arguments: args
474
- };
475
- }
476
- case 'Asset Tools': {
477
- if (!payloadValue) {
478
- return { ...base, skipReason: 'Non-JSON payload requires manual execution' };
479
- }
480
- if (!payloadValue.action) {
481
- return { ...base, skipReason: 'Action missing; unable to route to consolidated tool' };
482
- }
483
- if (!['list', 'import', 'create_material'].includes(payloadValue.action)) {
484
- return { ...base, skipReason: `Action '${payloadValue.action}' not supported by automated runner` };
485
- }
486
- return {
487
- ...base,
488
- toolName: 'manage_asset',
489
- arguments: payloadValue
490
- };
491
- }
492
- case 'Animation Tools': {
493
- if (!payloadValue) {
494
- return { ...base, skipReason: 'No JSON payload provided' };
495
- }
496
- const args = { ...payloadValue };
497
- if (scenarioLower.includes('animation blueprint')) {
498
- args.action = 'create_animation_bp';
499
- } else if (scenarioLower.includes('montage') || scenarioLower.includes('animation asset once')) {
500
- args.action = 'play_montage';
501
- } else if (scenarioLower.includes('ragdoll')) {
502
- args.action = 'setup_ragdoll';
503
- }
504
- if (!args.action) {
505
- return { ...base, skipReason: 'Scenario not supported by automated runner' };
506
- }
507
- return {
508
- ...base,
509
- toolName: 'animation_physics',
510
- arguments: args
511
- };
512
- }
513
- case 'Blueprint Tools': {
514
- if (!payloadValue) {
515
- return { ...base, skipReason: 'No JSON payload provided' };
516
- }
517
- const args = { ...payloadValue };
518
- if (!args.action) {
519
- args.action = scenarioLower.includes('component') ? 'add_component' : 'create';
520
- }
521
- return {
522
- ...base,
523
- toolName: 'manage_blueprint',
524
- arguments: args
525
- };
526
- }
527
- case 'Material Tools': {
528
- if (!payloadValue) {
529
- return { ...base, skipReason: 'No JSON payload provided' };
530
- }
531
- const args = { ...payloadValue };
532
- if (!args.action && typeof args.name === 'string' && typeof args.path === 'string') {
533
- args.action = 'create_material';
534
- }
535
- if (args.action === 'create_material') {
536
- return {
537
- ...base,
538
- toolName: 'manage_asset',
539
- arguments: {
540
- action: 'create_material',
541
- name: typeof args.name === 'string' ? args.name : `M_Test_${rawCase.index}`,
542
- path: args.path
543
- }
544
- };
545
- }
546
- return { ...base, skipReason: 'Material scenario not supported by automated runner' };
547
- }
548
- case 'Niagara Tools': {
549
- if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
550
- if (!payloadValue.action) {
551
- return { ...base, skipReason: 'Missing action for Niagara scenario' };
552
- }
553
- if (Array.isArray(payloadValue.location) && payloadValue.location.length === 3) {
554
- payloadValue.location = { x: payloadValue.location[0], y: payloadValue.location[1], z: payloadValue.location[2] };
555
- }
556
- return {
557
- ...base,
558
- toolName: 'create_effect',
559
- arguments: payloadValue
560
- };
561
- }
562
- case 'Level Tools': {
563
- if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
564
- if (!payloadValue.action) {
565
- return { ...base, skipReason: 'Missing action for level scenario' };
566
- }
567
- return {
568
- ...base,
569
- toolName: 'manage_level',
570
- arguments: payloadValue
571
- };
572
- }
573
- case 'Sequence Tools': {
574
- if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
575
- if (!payloadValue.action) return { ...base, skipReason: 'Missing action in payload' };
576
- return {
577
- ...base,
578
- toolName: 'manage_sequence',
579
- arguments: payloadValue
580
- };
581
- }
582
- case 'UI Tools': {
583
- if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
584
- if (!payloadValue.action) return { ...base, skipReason: 'Missing action in payload' };
585
- return {
586
- ...base,
587
- toolName: 'system_control',
588
- arguments: payloadValue
589
- };
590
- }
591
- case 'Physics Tools': {
592
- if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
593
- if (payloadValue.action === 'apply_force') {
594
- return {
595
- ...base,
596
- toolName: 'control_actor',
597
- arguments: payloadValue
598
- };
599
- }
600
- return { ...base, skipReason: 'Physics scenario not mapped to consolidated tools' };
601
- }
602
- case 'Landscape Tools': {
603
- if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
604
- if (!payloadValue.action) return { ...base, skipReason: 'Missing action in payload' };
605
- return {
606
- ...base,
607
- toolName: 'build_environment',
608
- arguments: payloadValue
609
- };
610
- }
611
- case 'Build Environment Tools': {
612
- if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
613
- if (!payloadValue.action) return { ...base, skipReason: 'Missing action in payload' };
614
- return {
615
- ...base,
616
- toolName: 'build_environment',
617
- arguments: payloadValue
618
- };
619
- }
620
- case 'Performance Tools': {
621
- if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
622
- if (!payloadValue.action) return { ...base, skipReason: 'Missing action in payload' };
623
- if (scenarioLower.includes('engine quit')) {
624
- return {
625
- ...base,
626
- skipReason: 'Skipping engine quit to keep Unreal session alive during test run'
627
- };
628
- }
629
- return {
630
- ...base,
631
- toolName: 'system_control',
632
- arguments: payloadValue
633
- };
634
- }
635
- case 'System Control Tools': {
636
- if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
637
- if (!payloadValue.action) return { ...base, skipReason: 'Missing action in payload' };
638
- return {
639
- ...base,
640
- toolName: 'system_control',
641
- arguments: payloadValue
642
- };
643
- }
644
- case 'Debug Tools': {
645
- if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
646
- if (!payloadValue.action) {
647
- payloadValue.action = 'debug_shape';
648
- }
649
- return {
650
- ...base,
651
- toolName: 'create_effect',
652
- arguments: payloadValue
653
- };
654
- }
655
- case 'Asset Boundary Tests': {
656
- if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
657
- return { ...base, toolName: 'manage_asset', arguments: payloadValue };
658
- }
659
- case 'Actor Boundary Tests': {
660
- if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
661
- const args = { ...payloadValue };
662
- if (Array.isArray(args.location) && args.location.length === 3) {
663
- args.location = { x: args.location[0], y: args.location[1], z: args.location[2] };
664
- }
665
- if (Array.isArray(args.rotation) && args.rotation.length === 3) {
666
- args.rotation = { pitch: args.rotation[0], yaw: args.rotation[1], roll: args.rotation[2] };
667
- }
668
- if (Array.isArray(args.scale) && args.scale.length === 3) {
669
- args.scale = { x: args.scale[0], y: args.scale[1], z: args.scale[2] };
670
- }
671
- if (Array.isArray(args.force) && args.force.length === 3) {
672
- args.force = { x: args.force[0], y: args.force[1], z: args.force[2] };
673
- }
674
- return { ...base, toolName: 'control_actor', arguments: args };
675
- }
676
- case 'Editor Boundary Tests':
677
- case 'Editor Control Boundary Tests': {
678
- if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
679
- return { ...base, toolName: 'control_editor', arguments: payloadValue };
680
- }
681
- case 'Level Boundary Tests':
682
- case 'Level Management Boundary Tests': {
683
- if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
684
- return { ...base, toolName: 'manage_level', arguments: payloadValue };
685
- }
686
- case 'Animation Boundary Tests': {
687
- if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
688
- return { ...base, toolName: 'animation_physics', arguments: payloadValue };
689
- }
690
- case 'Blueprint Boundary Tests':
691
- case 'Blueprint Control Boundary Tests': {
692
- if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
693
- return { ...base, toolName: 'manage_blueprint', arguments: payloadValue };
694
- }
695
- case 'Effects Boundary Tests':
696
- case 'Effects Control Boundary Tests': {
697
- if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
698
- const args = { ...payloadValue };
699
- if (Array.isArray(args.location) && args.location.length === 3) {
700
- args.location = { x: args.location[0], y: args.location[1], z: args.location[2] };
701
- }
702
- if (Array.isArray(args.start) && args.start.length === 3) {
703
- args.start = { x: args.start[0], y: args.start[1], z: args.start[2] };
704
- }
705
- if (Array.isArray(args.end) && args.end.length === 3) {
706
- args.end = { x: args.end[0], y: args.end[1], z: args.end[2] };
707
- }
708
- return { ...base, toolName: 'create_effect', arguments: args };
709
- }
710
- case 'Environment Boundary Tests':
711
- case 'Environment Building Boundary Tests': {
712
- if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
713
- const args = { ...payloadValue };
714
- if (Array.isArray(args.location) && args.location.length === 3) {
715
- args.location = { x: args.location[0], y: args.location[1], z: args.location[2] };
716
- }
717
- if (args.bounds) {
718
- const bounds = { ...args.bounds };
719
- if (Array.isArray(bounds.location) && bounds.location.length === 3) {
720
- bounds.location = { x: bounds.location[0], y: bounds.location[1], z: bounds.location[2] };
721
- }
722
- if (Array.isArray(bounds.size) && bounds.size.length === 3) {
723
- bounds.size = { x: bounds.size[0], y: bounds.size[1], z: bounds.size[2] };
724
- }
725
- args.bounds = bounds;
726
- }
727
- return { ...base, toolName: 'build_environment', arguments: args };
728
- }
729
- case 'System Boundary Tests':
730
- case 'System Control Boundary Tests': {
731
- if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
732
- return { ...base, toolName: 'system_control', arguments: payloadValue };
733
- }
734
- case 'Sequence Boundary Tests':
735
- case 'Sequence Control Boundary Tests': {
736
- if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
737
- return { ...base, toolName: 'manage_sequence', arguments: payloadValue };
738
- }
739
- case 'Remote Control Boundary Tests':
740
- case 'Remote Control Preset Boundary Tests': {
741
- // No consolidated remote control tool mapping; treated as unsupported group
742
- return { ...base, skipReason: `Unknown tool group '${rawCase.groupName}'` };
743
- }
744
- case 'Python Execution Boundary Tests': {
745
- // No consolidated Python execution tool; treated as unsupported group
746
- return { ...base, skipReason: `Unknown tool group '${rawCase.groupName}'` };
747
- }
748
- case 'Inspection Boundary Tests': {
749
- if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
750
- return { ...base, toolName: 'inspect', arguments: payloadValue };
751
- }
752
- case 'Cross-Tool Integration Tests':
753
- case 'Stress Test Scenarios':
754
- case 'Error Recovery Tests': {
755
- if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
756
- // These require multi-step execution or special handling
757
- return { ...base, skipReason: 'Multi-step test scenario - requires custom test implementation' };
758
- }
759
- default:
760
- return { ...base, skipReason: `Unknown tool group '${rawCase.groupName}'` };
761
- }
762
- }
763
-
764
- function evaluateExpectation(testCase, response) {
765
- const lowerExpected = testCase.expected.toLowerCase();
766
- const containsFailure = failureKeywords.some((word) => lowerExpected.includes(word));
767
- const containsSuccess = successKeywords.some((word) => lowerExpected.includes(word));
768
-
769
- const structuredSuccess = typeof response.structuredContent?.success === 'boolean'
770
- ? response.structuredContent.success
771
- : undefined;
772
- const actualSuccess = structuredSuccess ?? !response.isError;
773
-
774
- // Extract actual error/message from response
775
- let actualError = null;
776
- let actualMessage = null;
777
- if (response.structuredContent) {
778
- actualError = response.structuredContent.error;
779
- actualMessage = response.structuredContent.message;
780
- }
781
-
782
- // CRITICAL FIX: UE_NOT_CONNECTED errors should ALWAYS fail tests unless explicitly expected
783
- if (actualError === 'UE_NOT_CONNECTED') {
784
- const explicitlyExpectsDisconnection = lowerExpected.includes('not connected') ||
785
- lowerExpected.includes('ue_not_connected') ||
786
- lowerExpected.includes('disconnected');
787
- if (!explicitlyExpectsDisconnection) {
788
- return {
789
- passed: false,
790
- reason: `Test requires Unreal Engine connection, but got: ${actualError} - ${actualMessage}`
791
- };
792
- }
793
- }
794
-
795
- // For tests that expect specific error types, validate the actual error matches
796
- const expectedFailure = containsFailure && !containsSuccess;
797
- if (expectedFailure && !actualSuccess) {
798
- // Test expects failure and got failure - but verify it's the RIGHT kind of failure
799
- const lowerReason = actualMessage?.toLowerCase() || actualError?.toLowerCase() || '';
800
- const errorTypeMatch = failureKeywords.some(keyword => lowerExpected.includes(keyword) && lowerReason.includes(keyword));
801
-
802
- // If expected outcome specifies an error type, actual error should match it
803
- if (lowerExpected.includes('not found') || lowerExpected.includes('invalid') ||
804
- lowerExpected.includes('missing') || lowerExpected.includes('already exists')) {
805
- const passed = errorTypeMatch;
806
- let reason;
807
- if (response.isError) {
808
- reason = response.content?.map((entry) => ('text' in entry ? entry.text : JSON.stringify(entry))).join('\n');
809
- } else if (response.structuredContent) {
810
- reason = JSON.stringify(response.structuredContent);
811
- } else {
812
- reason = 'No structured response returned';
813
- }
814
- return { passed, reason };
815
- }
816
- }
817
-
818
- // Default evaluation logic
819
- const passed = expectedFailure ? !actualSuccess : !!actualSuccess;
820
- let reason;
821
- if (response.isError) {
822
- reason = response.content?.map((entry) => ('text' in entry ? entry.text : JSON.stringify(entry))).join('\n');
823
- } else if (response.structuredContent) {
824
- reason = JSON.stringify(response.structuredContent);
825
- } else if (response.content?.length) {
826
- reason = response.content.map((entry) => ('text' in entry ? entry.text : JSON.stringify(entry))).join('\n');
827
- } else {
828
- reason = 'No structured response returned';
829
- }
830
-
831
- return { passed, reason };
832
- }
833
-
834
- function formatResultLine(testCase, status, detail, durationMs) {
835
- const durationText = typeof durationMs === 'number' ? ` (${durationMs.toFixed(1)} ms)` : '';
836
- return `[${status.toUpperCase()}] ${testCase.groupName} #${testCase.index} – ${testCase.scenario}${durationText}${detail ? ` => ${detail}` : ''}`;
837
- }
838
-
839
- async function persistResults(results) {
840
- await fs.mkdir(reportsDir, { recursive: true });
841
- const serializable = results.map((result) => ({
842
- group: result.groupName,
843
- caseId: result.caseId,
844
- index: result.index,
845
- scenario: result.scenario,
846
- toolName: result.toolName,
847
- arguments: result.arguments,
848
- status: result.status,
849
- durationMs: result.durationMs,
850
- detail: result.detail
851
- }));
852
- await fs.writeFile(resultsPath, JSON.stringify({
853
- generatedAt: new Date().toISOString(),
854
- docPath,
855
- results: serializable
856
- }, null, 2));
857
- }
858
-
859
- function summarize(results) {
860
- const totals = results.reduce((acc, result) => {
861
- acc.total += 1;
862
- acc[result.status] = (acc[result.status] ?? 0) + 1;
863
- return acc;
864
- }, { total: 0, passed: 0, failed: 0, skipped: 0 });
865
-
866
- console.log('\nSummary');
867
- console.log('=======');
868
- console.log(`Total cases processed: ${totals.total}`);
869
- console.log(`Passed: ${totals.passed}`);
870
- console.log(`Failed: ${totals.failed}`);
871
- console.log(`Skipped: ${totals.skipped}`);
872
- console.log(`Results written to: ${resultsPath}`);
873
- }
874
-
875
- function generateBenchmarkReport(results) {
876
- const passedResults = results.filter(r => r.status === 'passed' && r.durationMs);
877
-
878
- if (passedResults.length === 0) {
879
- console.log('\nNo performance data available for benchmarking.');
880
- return;
881
- }
882
-
883
- const durations = passedResults.map(r => r.durationMs).sort((a, b) => a - b);
884
- const sum = durations.reduce((a, b) => a + b, 0);
885
- const avg = sum / durations.length;
886
- const median = durations[Math.floor(durations.length / 2)];
887
- const min = durations[0];
888
- const max = durations[durations.length - 1];
889
- const p95 = durations[Math.floor(durations.length * 0.95)];
890
- const p99 = durations[Math.floor(durations.length * 0.99)];
891
-
892
- console.log('\nPerformance Benchmark');
893
- console.log('====================');
894
- console.log(`Total operations: ${passedResults.length}`);
895
- console.log(`Average: ${avg.toFixed(2)} ms`);
896
- console.log(`Median: ${median.toFixed(2)} ms`);
897
- console.log(`Min: ${min.toFixed(2)} ms`);
898
- console.log(`Max: ${max.toFixed(2)} ms`);
899
- console.log(`95th percentile: ${p95.toFixed(2)} ms`);
900
- console.log(`99th percentile: ${p99.toFixed(2)} ms`);
901
-
902
- // Group by tool
903
- const byTool = {};
904
- passedResults.forEach(r => {
905
- if (!byTool[r.toolName]) byTool[r.toolName] = [];
906
- byTool[r.toolName].push(r.durationMs);
907
- });
908
-
909
- console.log('\nBy Tool:');
910
- Object.entries(byTool).forEach(([tool, times]) => {
911
- const toolAvg = times.reduce((a, b) => a + b, 0) / times.length;
912
- console.log(` ${tool}: ${toolAvg.toFixed(2)} ms avg (${times.length} ops)`);
913
- });
914
- }
915
-
916
- function normalizeWindowsPath(value) {
917
- if (typeof value !== 'string') return value;
918
- return value.replace(/\\+/g, '\\').replace(/\/+/g, '\\');
919
- }
920
-
921
- function hydratePlaceholders(value) {
922
- if (typeof value === 'string') {
923
- return value
924
- .replaceAll('{{FBX_DIR}}', defaultFbxDir)
925
- .replaceAll('{{FBX_TEST_MODEL}}', defaultFbxFile);
926
- }
927
- if (Array.isArray(value)) {
928
- return value.map((entry) => hydratePlaceholders(entry));
929
- }
930
- if (value && typeof value === 'object') {
931
- return Object.fromEntries(Object.entries(value).map(([key, val]) => [key, hydratePlaceholders(val)]));
932
- }
933
- return value;
934
- }
935
-
936
- async function ensureFbxDirectory() {
937
- if (!defaultFbxDir) return;
938
- try {
939
- await fs.mkdir(defaultFbxDir, { recursive: true });
940
- } catch (err) {
941
- console.warn(`Unable to ensure FBX directory '${defaultFbxDir}':`, err);
942
- }
943
- }
944
-
945
- main().catch((err) => {
946
- console.error('Unexpected error during test execution:', err);
947
- process.exitCode = 1;
948
- });