Enhance screen rendering with CustomScreenTestImpl and improved WebFacadeStub
- Add CustomScreenTestImpl for better screen path handling - Fix WebFacadeStub to use actual screenPath instead of hardcoded '/test' - Improve screen discovery and execution in McpServices - Support standalone screens and proper root screen detection - Add timeout protection for screen rendering
Showing
3 changed files
with
515 additions
and
93 deletions
| ... | @@ -436,22 +436,6 @@ | ... | @@ -436,22 +436,6 @@ |
| 436 | ec.logger.info("Found original screen path for tool ${name}: ${screenPath}") | 436 | ec.logger.info("Found original screen path for tool ${name}: ${screenPath}") |
| 437 | } | 437 | } |
| 438 | 438 | ||
| 439 | /* | ||
| 440 | // Map common screen patterns to actual screen locations | ||
| 441 | if (screenPath.startsWith("OrderFind") || screenPath.startsWith("ProductFind") || screenPath.startsWith("PartyFind")) { | ||
| 442 | // For find screens, try to use appropriate component screens | ||
| 443 | if (screenPath.startsWith("Order")) { | ||
| 444 | screenPath = "webroot/apps/order" // Try to map to order app | ||
| 445 | } else if (screenPath.startsWith("Product")) { | ||
| 446 | screenPath = "webroot/apps/product" // Try to map to product app | ||
| 447 | } else if (screenPath.startsWith("Party")) { | ||
| 448 | screenPath = "webroot/apps/party" // Try to map to party app | ||
| 449 | } | ||
| 450 | } else if (screenPath == "apps") { | ||
| 451 | screenPath = "component://webroot/screen/webroot.xml" // Use full component path to webroot screen | ||
| 452 | } | ||
| 453 | */ | ||
| 454 | |||
| 455 | // Restore user context from sessionId before calling screen tool | 439 | // Restore user context from sessionId before calling screen tool |
| 456 | def serviceResult = null | 440 | def serviceResult = null |
| 457 | def visit = null | 441 | def visit = null |
| ... | @@ -483,7 +467,7 @@ | ... | @@ -483,7 +467,7 @@ |
| 483 | // Now call the screen tool with proper user context | 467 | // Now call the screen tool with proper user context |
| 484 | def screenParams = arguments ?: [:] | 468 | def screenParams = arguments ?: [:] |
| 485 | serviceResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool") | 469 | serviceResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool") |
| 486 | .parameters([screenPath: screenPath, parameters: screenParams, renderMode: "html"]) | 470 | .parameters([screenPath: screenPath, parameters: screenParams, renderMode: "html", sessionId: sessionId]) |
| 487 | .call() | 471 | .call() |
| 488 | 472 | ||
| 489 | } finally { | 473 | } finally { |
| ... | @@ -530,41 +514,6 @@ | ... | @@ -530,41 +514,6 @@ |
| 530 | def originalUsername = ec.user.username | 514 | def originalUsername = ec.user.username |
| 531 | UserInfo adminUserInfo = null | 515 | UserInfo adminUserInfo = null |
| 532 | 516 | ||
| 533 | // Timing already started above | ||
| 534 | |||
| 535 | // Validate session if provided | ||
| 536 | /* | ||
| 537 | if (sessionId) { | ||
| 538 | def visit = null | ||
| 539 | adminUserInfo = null | ||
| 540 | try { | ||
| 541 | adminUserInfo = ec.user.pushUser("ADMIN") | ||
| 542 | visit = ec.entity.find("moqui.server.Visit") | ||
| 543 | .condition("visitId", sessionId) | ||
| 544 | .one() | ||
| 545 | } finally { | ||
| 546 | if (adminUserInfo != null) { | ||
| 547 | ec.user.popUser() | ||
| 548 | } | ||
| 549 | } | ||
| 550 | |||
| 551 | // Validate session - allow special MCP case where Visit was created with ADMIN but accessed by MCP_USER or MCP_BUSINESS | ||
| 552 | boolean sessionValid = false | ||
| 553 | if (visit) { | ||
| 554 | if (visit.userId == ec.user.userId) { | ||
| 555 | sessionValid = true | ||
| 556 | } else if (visit.userId == "ADMIN" && (ec.user.username == "mcp-user" || ec.user.username == "mcp-business")) { | ||
| 557 | // Special MCP case: Visit created with ADMIN for privileged access but accessed by MCP users | ||
| 558 | sessionValid = true | ||
| 559 | } | ||
| 560 | } | ||
| 561 | |||
| 562 | if (!sessionValid) { | ||
| 563 | //throw new Exception("Invalid session: ${sessionId}") | ||
| 564 | } | ||
| 565 | } | ||
| 566 | */ | ||
| 567 | |||
| 568 | try { | 517 | try { |
| 569 | // Execute service with elevated privileges for system access | 518 | // Execute service with elevated privileges for system access |
| 570 | // but maintain audit context with actual user | 519 | // but maintain audit context with actual user |
| ... | @@ -844,11 +793,48 @@ try { | ... | @@ -844,11 +793,48 @@ try { |
| 844 | </out-parameters> | 793 | </out-parameters> |
| 845 | <actions> | 794 | <actions> |
| 846 | <script><![CDATA[ | 795 | <script><![CDATA[ |
| 796 | import org.moqui.impl.context.UserFacadeImpl.UserInfo | ||
| 797 | |||
| 798 | // Get current user information | ||
| 799 | def currentUser = ec.user.username | ||
| 800 | def currentUserId = ec.user.userId | ||
| 801 | |||
| 802 | // Try to get visit information if sessionId is provided | ||
| 803 | def visitInfo = null | ||
| 804 | if (sessionId) { | ||
| 805 | try { | ||
| 806 | def adminUserInfo = ec.user.pushUser("ADMIN") | ||
| 807 | try { | ||
| 808 | def visit = ec.entity.find("moqui.server.Visit") | ||
| 809 | .condition("visitId", sessionId) | ||
| 810 | .disableAuthz() | ||
| 811 | .one() | ||
| 812 | |||
| 813 | if (visit) { | ||
| 814 | visitInfo = [ | ||
| 815 | visitId: visit.visitId, | ||
| 816 | userId: visit.userId, | ||
| 817 | fromDate: visit.fromDate, | ||
| 818 | lastUpdatedStamp: visit.lastUpdatedStamp | ||
| 819 | ] | ||
| 820 | } | ||
| 821 | } finally { | ||
| 822 | ec.user.popUser() | ||
| 823 | } | ||
| 824 | } catch (Exception e) { | ||
| 825 | // Log but don't fail the ping | ||
| 826 | ec.logger.warn("Error getting visit info for sessionId ${sessionId}: ${e.message}") | ||
| 827 | } | ||
| 828 | } | ||
| 829 | |||
| 847 | result = [ | 830 | result = [ |
| 848 | timestamp: ec.user.getNowTimestamp(), | 831 | timestamp: ec.user.getNowTimestamp(), |
| 849 | status: "healthy", | 832 | status: "healthy", |
| 850 | version: "2.0.2", | 833 | version: "2.0.2", |
| 851 | sessionId: sessionId, | 834 | sessionId: sessionId, |
| 835 | currentUser: currentUser, | ||
| 836 | currentUserId: currentUserId, | ||
| 837 | visitInfo: visitInfo, | ||
| 852 | architecture: "Visit-based sessions" | 838 | architecture: "Visit-based sessions" |
| 853 | ] | 839 | ] |
| 854 | ]]></script> | 840 | ]]></script> |
| ... | @@ -1080,9 +1066,11 @@ try { | ... | @@ -1080,9 +1066,11 @@ try { |
| 1080 | 1066 | ||
| 1081 | try { | 1067 | try { |
| 1082 | screenDefinition = ec.screen.getScreenDefinition(screenPath) | 1068 | screenDefinition = ec.screen.getScreenDefinition(screenPath) |
| 1083 | if (screenDefinition?.screenNode?.first("description")?.text) { | 1069 | // Screen XML doesn't have description elements, so use screen path as description |
| 1084 | title = screenDefinition.screenNode.first("description").text | 1070 | // We could potentially use default-menu-title attribute if available |
| 1085 | description = screenDefinition.screenNode.first("description").text | 1071 | if (screenDefinition?.screenNode?.attribute('default-menu-title')) { |
| 1072 | title = screenDefinition.screenNode.attribute('default-menu-title') | ||
| 1073 | description = "Moqui screen: ${screenPath} (${title})" | ||
| 1086 | } | 1074 | } |
| 1087 | } catch (Exception e) { | 1075 | } catch (Exception e) { |
| 1088 | ec.logger.info("MCP Screen Discovery: No screen definition for ${screenPath}, using basic info") | 1076 | ec.logger.info("MCP Screen Discovery: No screen definition for ${screenPath}, using basic info") |
| ... | @@ -1170,8 +1158,17 @@ try { | ... | @@ -1170,8 +1158,17 @@ try { |
| 1170 | 1158 | ||
| 1171 | // Extract screen information | 1159 | // Extract screen information |
| 1172 | def screenName = screenPath.replaceAll("[^a-zA-Z0-9]", "_") | 1160 | def screenName = screenPath.replaceAll("[^a-zA-Z0-9]", "_") |
| 1173 | def title = screenDef.getScreenName() ?: screenPath.split("/")[-1] | 1161 | def title = screenPath.split("/")[-1] |
| 1174 | def description = screenDef.getDescription() ?: "Moqui screen: ${screenPath}" | 1162 | def description = "Moqui screen: ${screenPath}" |
| 1163 | |||
| 1164 | // Safely get screen description - screen XML doesn't have description elements | ||
| 1165 | try { | ||
| 1166 | if (screenDef?.screenNode?.attribute('default-menu-title')) { | ||
| 1167 | description = screenDef.screenNode.attribute('default-menu-title') | ||
| 1168 | } | ||
| 1169 | } catch (Exception e) { | ||
| 1170 | ec.logger.debug("Could not get screen title: ${e.message}") | ||
| 1171 | } | ||
| 1175 | 1172 | ||
| 1176 | // Get screen parameters from transitions and forms | 1173 | // Get screen parameters from transitions and forms |
| 1177 | def parameters = [:] | 1174 | def parameters = [:] |
| ... | @@ -1284,19 +1281,19 @@ try { | ... | @@ -1284,19 +1281,19 @@ try { |
| 1284 | <parameter name="screenPath" required="true"/> | 1281 | <parameter name="screenPath" required="true"/> |
| 1285 | <parameter name="parameters" type="Map"><description>Parameters to pass to the screen</description></parameter> | 1282 | <parameter name="parameters" type="Map"><description>Parameters to pass to the screen</description></parameter> |
| 1286 | <parameter name="renderMode" default="html"><description>Render mode: text, html, xml, vuet, qvt</description></parameter> | 1283 | <parameter name="renderMode" default="html"><description>Render mode: text, html, xml, vuet, qvt</description></parameter> |
| 1284 | <parameter name="sessionId"><description>Session ID for user context restoration</description></parameter> | ||
| 1287 | </in-parameters> | 1285 | </in-parameters> |
| 1288 | <out-parameters> | 1286 | <out-parameters> |
| 1289 | <parameter name="result" type="Map"/> | 1287 | <parameter name="result" type="Map"/> |
| 1290 | </out-parameters> | 1288 | </out-parameters> |
| 1291 | <actions> | 1289 | <actions> |
| 1292 | <script><![CDATA[ | 1290 | <script><![CDATA[ |
| 1293 | import org.moqui.context.ExecutionContext | 1291 | import org.moqui.context.ExecutionContext |
| 1294 | import groovy.json.JsonBuilder | 1292 | import groovy.json.JsonBuilder |
| 1295 | 1293 | ||
| 1296 | ExecutionContext ec = context.ec | 1294 | ExecutionContext ec = context.ec |
| 1297 | 1295 | ||
| 1298 | def startTime = System.currentTimeMillis() | 1296 | def startTime = System.currentTimeMillis() |
| 1299 | try { | ||
| 1300 | // Note: Screen validation will happen during render | 1297 | // Note: Screen validation will happen during render |
| 1301 | // if (!ec.screen.isScreenDefined(screenPath)) { | 1298 | // if (!ec.screen.isScreenDefined(screenPath)) { |
| 1302 | // throw new Exception("Screen not found: ${screenPath}") | 1299 | // throw new Exception("Screen not found: ${screenPath}") |
| ... | @@ -1319,16 +1316,126 @@ try { | ... | @@ -1319,16 +1316,126 @@ try { |
| 1319 | def testScreenPath = screenPath | 1316 | def testScreenPath = screenPath |
| 1320 | def rootScreen = "component://webroot/screen/webroot.xml" | 1317 | def rootScreen = "component://webroot/screen/webroot.xml" |
| 1321 | 1318 | ||
| 1319 | // Initialize standalone flag outside the if block | ||
| 1320 | def targetScreenDef = null | ||
| 1321 | def isStandalone = false | ||
| 1322 | |||
| 1322 | // If the screen path is already a full component:// path, we need to handle it differently | 1323 | // If the screen path is already a full component:// path, we need to handle it differently |
| 1323 | if (screenPath.startsWith("component://")) { | 1324 | if (screenPath.startsWith("component://")) { |
| 1324 | // Extract the path after component:// for ScreenTest | 1325 | // For component:// paths, we need to use the component's root screen, not webroot |
| 1325 | testScreenPath = screenPath.substring(12) // Remove "component://" | 1326 | // Extract the component name and use its root screen |
| 1326 | ec.logger.info("MCP Screen Execution: Converted component path to test path: ${testScreenPath}") | 1327 | def pathAfterComponent = screenPath.substring(12) // Remove "component://" |
| 1328 | def pathParts = pathAfterComponent.split("/") | ||
| 1329 | if (pathParts.length >= 2) { | ||
| 1330 | def componentName = pathParts[0] | ||
| 1331 | def remainingPath = pathParts[1..-1].join("/") | ||
| 1332 | |||
| 1333 | // Check if the target screen itself is standalone FIRST | ||
| 1334 | try { | ||
| 1335 | targetScreenDef = ec.screen.getScreenDefinition(screenPath) | ||
| 1336 | ec.logger.info("MCP Screen Execution: Target screen def for ${screenPath}: ${targetScreenDef?.getClass()?.getSimpleName()}") | ||
| 1337 | if (targetScreenDef?.screenNode) { | ||
| 1338 | def standaloneAttr = targetScreenDef.screenNode.attribute('standalone') | ||
| 1339 | ec.logger.info("MCP Screen Execution: Target screen ${screenPath} standalone attribute: '${standaloneAttr}'") | ||
| 1340 | isStandalone = standaloneAttr == "true" | ||
| 1341 | } else { | ||
| 1342 | ec.logger.warn("MCP Screen Execution: Target screen ${screenPath} has no screenNode") | ||
| 1343 | } | ||
| 1344 | ec.logger.info("MCP Screen Execution: Target screen ${screenPath} standalone=${isStandalone}") | ||
| 1345 | |||
| 1346 | if (isStandalone) { | ||
| 1347 | // For standalone screens, try to render with minimal context or fall back to URL | ||
| 1348 | ec.logger.info("MCP Screen Execution: Standalone screen detected, will try direct rendering") | ||
| 1349 | rootScreen = screenPath | ||
| 1350 | testScreenPath = "" // Empty path for standalone screens | ||
| 1351 | // We'll handle standalone screens specially below | ||
| 1352 | } | ||
| 1353 | } catch (Exception e) { | ||
| 1354 | ec.logger.warn("MCP Screen Execution: Error checking target screen ${screenPath}: ${e.message}") | ||
| 1355 | ec.logger.error("MCP Screen Execution: Full exception", e) | ||
| 1356 | } | ||
| 1357 | |||
| 1358 | // Only look for component root if target is not standalone | ||
| 1359 | if (!isStandalone) { | ||
| 1360 | // For component://webroot/screen/... paths, always use webroot as root | ||
| 1361 | if (pathAfterComponent.startsWith("webroot/screen/")) { | ||
| 1362 | // This is a webroot screen, use webroot as root and the rest as path | ||
| 1363 | rootScreen = "component://webroot/screen/webroot.xml" | ||
| 1364 | testScreenPath = pathAfterComponent.substring("webroot/screen/".length()) | ||
| 1365 | // Remove any leading "webroot/" from the path since we're already using webroot as root | ||
| 1366 | if (testScreenPath.startsWith("webroot/")) { | ||
| 1367 | testScreenPath = testScreenPath.substring("webroot/".length()) | ||
| 1368 | } | ||
| 1369 | ec.logger.info("MCP Screen Execution: Using webroot root for webroot screen: ${rootScreen} with path: ${testScreenPath}") | ||
| 1370 | } else { | ||
| 1371 | // For other component screens, check if this is a direct screen path (not a subscreen path) | ||
| 1372 | def pathSegments = remainingPath.split("/") | ||
| 1373 | def isDirectScreenPath = false | ||
| 1374 | |||
| 1375 | // Try to check if the full path is a valid screen | ||
| 1376 | try { | ||
| 1377 | def directScreenDef = ec.screen.getScreenDefinition(screenPath) | ||
| 1378 | if (directScreenDef) { | ||
| 1379 | isDirectScreenPath = true | ||
| 1380 | ec.logger.info("MCP Screen Execution: Found direct screen path: ${screenPath}") | ||
| 1381 | } | ||
| 1382 | } catch (Exception e) { | ||
| 1383 | ec.logger.debug("MCP Screen Execution: Direct screen check failed for ${screenPath}: ${e.message}") | ||
| 1384 | } | ||
| 1385 | |||
| 1386 | if (isDirectScreenPath) { | ||
| 1387 | // For direct screen paths, use the screen itself as root | ||
| 1388 | rootScreen = screenPath | ||
| 1389 | testScreenPath = "" | ||
| 1390 | ec.logger.info("MCP Screen Execution: Using direct screen as root: ${rootScreen}") | ||
| 1391 | } else { | ||
| 1392 | // Try to find the actual root screen for this component | ||
| 1393 | def componentRootScreen = null | ||
| 1394 | def possibleRootScreens = [ | ||
| 1395 | "${componentName}.xml", | ||
| 1396 | "${componentName}Root.xml", | ||
| 1397 | "${componentName}Admin.xml" | ||
| 1398 | ] | ||
| 1399 | |||
| 1400 | for (rootScreenName in possibleRootScreens) { | ||
| 1401 | def candidateRoot = "component://${componentName}/screen/${rootScreenName}" | ||
| 1402 | try { | ||
| 1403 | def testDef = ec.screen.getScreenDefinition(candidateRoot) | ||
| 1404 | if (testDef) { | ||
| 1405 | componentRootScreen = candidateRoot | ||
| 1406 | ec.logger.info("MCP Screen Execution: Found component root screen: ${componentRootScreen}") | ||
| 1407 | break | ||
| 1408 | } | ||
| 1409 | } catch (Exception e) { | ||
| 1410 | ec.logger.debug("MCP Screen Execution: Root screen ${candidateRoot} not found: ${e.message}") | ||
| 1411 | } | ||
| 1327 | } | 1412 | } |
| 1328 | 1413 | ||
| 1329 | def screenTest = ec.screen.makeTest() | 1414 | if (componentRootScreen) { |
| 1415 | rootScreen = componentRootScreen | ||
| 1416 | testScreenPath = remainingPath | ||
| 1417 | ec.logger.info("MCP Screen Execution: Using component root ${rootScreen} for path ${testScreenPath}") | ||
| 1418 | } else { | ||
| 1419 | // Fallback: try using webroot with the full path | ||
| 1420 | rootScreen = "component://webroot/screen/webroot.xml" | ||
| 1421 | testScreenPath = pathAfterComponent | ||
| 1422 | ec.logger.warn("MCP Screen Execution: Could not find component root for ${componentName}, using webroot fallback: ${testScreenPath}") | ||
| 1423 | } | ||
| 1424 | } | ||
| 1425 | } | ||
| 1426 | } | ||
| 1427 | } else { | ||
| 1428 | // Fallback for malformed component paths | ||
| 1429 | testScreenPath = pathAfterComponent | ||
| 1430 | ec.logger.warn("MCP Screen Execution: Malformed component path, using fallback: ${testScreenPath}") | ||
| 1431 | } | ||
| 1432 | } | ||
| 1433 | |||
| 1434 | // Restore user context from sessionId before creating ScreenTest | ||
| 1435 | // Regular screen rendering with restored user context - use our custom ScreenTestImpl | ||
| 1436 | def screenTest = new org.moqui.mcp.CustomScreenTestImpl(ec.ecfi) | ||
| 1330 | .rootScreen(rootScreen) | 1437 | .rootScreen(rootScreen) |
| 1331 | .renderMode(renderMode ? renderMode : "text") | 1438 | .renderMode(renderMode ? renderMode : "html") |
| 1332 | 1439 | ||
| 1333 | ec.logger.info("MCP Screen Execution: ScreenTest object created: ${screenTest?.getClass()?.getSimpleName()}") | 1440 | ec.logger.info("MCP Screen Execution: ScreenTest object created: ${screenTest?.getClass()?.getSimpleName()}") |
| 1334 | 1441 | ||
| ... | @@ -1339,7 +1446,12 @@ try { | ... | @@ -1339,7 +1446,12 @@ try { |
| 1339 | renderParams.userId = ec.user.userId | 1446 | renderParams.userId = ec.user.userId |
| 1340 | renderParams.username = ec.user.username | 1447 | renderParams.username = ec.user.username |
| 1341 | 1448 | ||
| 1342 | // Add timeout to prevent hanging | 1449 | // Set user context in ScreenTest to maintain authentication |
| 1450 | // Note: ScreenTestImpl may not have direct userAccountId property, | ||
| 1451 | // the user context should be inherited from the current ExecutionContext | ||
| 1452 | ec.logger.info("MCP Screen Execution: Current user context - userId: ${ec.user.userId}, username: ${ec.user.username}") | ||
| 1453 | |||
| 1454 | // Regular screen rendering with timeout | ||
| 1343 | def future = java.util.concurrent.Executors.newSingleThreadExecutor().submit({ | 1455 | def future = java.util.concurrent.Executors.newSingleThreadExecutor().submit({ |
| 1344 | return screenTest.render(testScreenPath, renderParams, null) | 1456 | return screenTest.render(testScreenPath, renderParams, null) |
| 1345 | } as java.util.concurrent.Callable) | 1457 | } as java.util.concurrent.Callable) |
| ... | @@ -1358,7 +1470,6 @@ try { | ... | @@ -1358,7 +1470,6 @@ try { |
| 1358 | } else { | 1470 | } else { |
| 1359 | throw new Exception("ScreenTest object is null") | 1471 | throw new Exception("ScreenTest object is null") |
| 1360 | } | 1472 | } |
| 1361 | |||
| 1362 | } catch (Exception e) { | 1473 | } catch (Exception e) { |
| 1363 | ec.logger.warn("MCP Screen Execution: Could not render screen ${screenPath}, falling back to URL: ${e.message}") | 1474 | ec.logger.warn("MCP Screen Execution: Could not render screen ${screenPath}, falling back to URL: ${e.message}") |
| 1364 | ec.logger.warn("MCP Screen Execution: Exception details: ${e.getClass()?.getSimpleName()}: ${e.getMessage()}") | 1475 | ec.logger.warn("MCP Screen Execution: Exception details: ${e.getClass()?.getSimpleName()}: ${e.getMessage()}") |
| ... | @@ -1380,27 +1491,6 @@ try { | ... | @@ -1380,27 +1491,6 @@ try { |
| 1380 | ] | 1491 | ] |
| 1381 | 1492 | ||
| 1382 | ec.logger.info("MCP Screen Execution: Generated URL for screen ${screenPath} in ${executionTime}s") | 1493 | ec.logger.info("MCP Screen Execution: Generated URL for screen ${screenPath} in ${executionTime}s") |
| 1383 | |||
| 1384 | } catch (Exception e) { | ||
| 1385 | def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 | ||
| 1386 | |||
| 1387 | result = [ | ||
| 1388 | content: [ | ||
| 1389 | [ | ||
| 1390 | type: "text", | ||
| 1391 | text: "Error executing screen ${screenPath}: ${e.message}" | ||
| 1392 | ] | ||
| 1393 | ], | ||
| 1394 | isError: true, | ||
| 1395 | metadata: [ | ||
| 1396 | screenPath: screenPath, | ||
| 1397 | renderMode: renderMode, | ||
| 1398 | executionTime: executionTime | ||
| 1399 | ] | ||
| 1400 | ] | ||
| 1401 | |||
| 1402 | ec.logger.error("MCP Screen Execution error for ${screenPath}", e) | ||
| 1403 | } | ||
| 1404 | ]]></script> | 1494 | ]]></script> |
| 1405 | </actions> | 1495 | </actions> |
| 1406 | </service> | 1496 | </service> | ... | ... |
| 1 | /* | ||
| 2 | * This software is in the public domain under CC0 1.0 Universal plus a | ||
| 3 | * Grant of Patent License. | ||
| 4 | * | ||
| 5 | * To the extent possible under law, author(s) have dedicated all | ||
| 6 | * copyright and related and neighboring rights to this software to the | ||
| 7 | * public domain worldwide. This software is distributed without any | ||
| 8 | * warranty. | ||
| 9 | * | ||
| 10 | * You should have received a copy of the CC0 Public Domain Dedication | ||
| 11 | * along with this software (see the LICENSE.md file). If not, see | ||
| 12 | * <http://creativecommons.org/publicdomain/zero/1.0/>. | ||
| 13 | */ | ||
| 14 | package org.moqui.mcp | ||
| 15 | |||
| 16 | import groovy.transform.CompileStatic | ||
| 17 | import org.apache.shiro.subject.Subject | ||
| 18 | import org.moqui.BaseArtifactException | ||
| 19 | import org.moqui.util.ContextStack | ||
| 20 | import org.moqui.impl.context.ExecutionContextFactoryImpl | ||
| 21 | import org.moqui.impl.context.ExecutionContextImpl | ||
| 22 | import org.moqui.impl.screen.ScreenDefinition | ||
| 23 | import org.moqui.impl.screen.ScreenFacadeImpl | ||
| 24 | import org.moqui.impl.screen.ScreenTestImpl | ||
| 25 | import org.moqui.impl.screen.ScreenUrlInfo | ||
| 26 | import org.moqui.screen.ScreenRender | ||
| 27 | import org.moqui.screen.ScreenTest | ||
| 28 | import org.moqui.util.MNode | ||
| 29 | import org.slf4j.Logger | ||
| 30 | import org.slf4j.LoggerFactory | ||
| 31 | |||
| 32 | import java.util.concurrent.Future | ||
| 33 | |||
| 34 | @CompileStatic | ||
| 35 | class CustomScreenTestImpl implements ScreenTest { | ||
| 36 | protected final static Logger logger = LoggerFactory.getLogger(CustomScreenTestImpl.class) | ||
| 37 | |||
| 38 | protected final ExecutionContextFactoryImpl ecfi | ||
| 39 | protected final ScreenFacadeImpl sfi | ||
| 40 | // see FtlTemplateRenderer.MoquiTemplateExceptionHandler, others | ||
| 41 | final List<String> errorStrings = ["[Template Error", "FTL stack trace", "Could not find subscreen or transition"] | ||
| 42 | |||
| 43 | protected String rootScreenLocation = null | ||
| 44 | protected ScreenDefinition rootScreenDef = null | ||
| 45 | protected String baseScreenPath = null | ||
| 46 | protected List<String> baseScreenPathList = null | ||
| 47 | protected ScreenDefinition baseScreenDef = null | ||
| 48 | |||
| 49 | protected String outputType = null | ||
| 50 | protected String characterEncoding = null | ||
| 51 | protected String macroTemplateLocation = null | ||
| 52 | protected String baseLinkUrl = null | ||
| 53 | protected String servletContextPath = null | ||
| 54 | protected String webappName = null | ||
| 55 | protected boolean skipJsonSerialize = false | ||
| 56 | protected static final String hostname = "localhost" | ||
| 57 | |||
| 58 | long renderCount = 0, errorCount = 0, totalChars = 0, startTime = System.currentTimeMillis() | ||
| 59 | |||
| 60 | final Map<String, Object> sessionAttributes = [:] | ||
| 61 | |||
| 62 | CustomScreenTestImpl(ExecutionContextFactoryImpl ecfi) { | ||
| 63 | this.ecfi = ecfi | ||
| 64 | sfi = ecfi.screenFacade | ||
| 65 | |||
| 66 | // init default webapp, root screen | ||
| 67 | webappName('webroot') | ||
| 68 | } | ||
| 69 | |||
| 70 | @Override | ||
| 71 | ScreenTest rootScreen(String screenLocation) { | ||
| 72 | rootScreenLocation = screenLocation | ||
| 73 | rootScreenDef = sfi.getScreenDefinition(rootScreenLocation) | ||
| 74 | if (rootScreenDef == null) throw new IllegalArgumentException("Root screen not found: ${rootScreenLocation}") | ||
| 75 | baseScreenDef = rootScreenDef | ||
| 76 | return this | ||
| 77 | } | ||
| 78 | @Override | ||
| 79 | ScreenTest baseScreenPath(String screenPath) { | ||
| 80 | if (!rootScreenLocation) throw new BaseArtifactException("No rootScreen specified") | ||
| 81 | baseScreenPath = screenPath | ||
| 82 | if (baseScreenPath.endsWith("/")) baseScreenPath = baseScreenPath.substring(0, baseScreenPath.length() - 1) | ||
| 83 | if (baseScreenPath) { | ||
| 84 | baseScreenPathList = ScreenUrlInfo.parseSubScreenPath(rootScreenDef, rootScreenDef, [], baseScreenPath, null, sfi) | ||
| 85 | if (baseScreenPathList == null) throw new BaseArtifactException("Error in baseScreenPath, could find not base screen path ${baseScreenPath} under ${rootScreenDef.location}") | ||
| 86 | for (String screenName in baseScreenPathList) { | ||
| 87 | ScreenDefinition.SubscreensItem ssi = baseScreenDef.getSubscreensItem(screenName) | ||
| 88 | if (ssi == null) throw new BaseArtifactException("Error in baseScreenPath, could not find ${screenName} under ${baseScreenDef.location}") | ||
| 89 | baseScreenDef = sfi.getScreenDefinition(ssi.location) | ||
| 90 | if (baseScreenDef == null) throw new BaseArtifactException("Error in baseScreenPath, could not find screen ${screenName} at ${ssi.location}") | ||
| 91 | } | ||
| 92 | } | ||
| 93 | return this | ||
| 94 | } | ||
| 95 | @Override ScreenTest renderMode(String outputType) { this.outputType = outputType; return this } | ||
| 96 | @Override ScreenTest encoding(String characterEncoding) { this.characterEncoding = characterEncoding; return this } | ||
| 97 | @Override ScreenTest macroTemplate(String macroTemplateLocation) { this.macroTemplateLocation = macroTemplateLocation; return this } | ||
| 98 | @Override ScreenTest baseLinkUrl(String baseLinkUrl) { this.baseLinkUrl = baseLinkUrl; return this } | ||
| 99 | @Override ScreenTest servletContextPath(String scp) { this.servletContextPath = scp; return this } | ||
| 100 | @Override ScreenTest skipJsonSerialize(boolean skip) { this.skipJsonSerialize = skip; return this } | ||
| 101 | |||
| 102 | @Override | ||
| 103 | ScreenTest webappName(String wan) { | ||
| 104 | webappName = wan | ||
| 105 | |||
| 106 | // set a default root screen based on config for "localhost" | ||
| 107 | MNode webappNode = ecfi.getWebappNode(webappName) | ||
| 108 | for (MNode rootScreenNode in webappNode.children("root-screen")) { | ||
| 109 | if (hostname.matches(rootScreenNode.attribute('host'))) { | ||
| 110 | String rsLoc = rootScreenNode.attribute('location') | ||
| 111 | rootScreen(rsLoc) | ||
| 112 | break | ||
| 113 | } | ||
| 114 | } | ||
| 115 | |||
| 116 | return this | ||
| 117 | } | ||
| 118 | |||
| 119 | @Override | ||
| 120 | List<String> getNoRequiredParameterPaths(Set<String> screensToSkip) { | ||
| 121 | if (!rootScreenLocation) throw new IllegalStateException("No rootScreen specified") | ||
| 122 | |||
| 123 | List<String> noReqParmLocations = baseScreenDef.nestedNoReqParmLocations("", screensToSkip) | ||
| 124 | // logger.info("======= rootScreenLocation=${rootScreenLocation}\nbaseScreenPath=${baseScreenPath}\nbaseScreenDef: ${baseScreenDef.location}\nnoReqParmLocations: ${noReqParmLocations}") | ||
| 125 | return noReqParmLocations | ||
| 126 | } | ||
| 127 | |||
| 128 | @Override | ||
| 129 | ScreenTestRender render(String screenPath, Map<String, Object> parameters, String requestMethod) { | ||
| 130 | if (!rootScreenLocation) throw new IllegalArgumentException("No rootScreenLocation specified") | ||
| 131 | return new CustomScreenTestRenderImpl(this, screenPath, parameters, requestMethod).render() | ||
| 132 | } | ||
| 133 | @Override | ||
| 134 | void renderAll(List<String> screenPathList, Map<String, Object> parameters, String requestMethod) { | ||
| 135 | // NOTE: using single thread for now, doesn't actually make a lot of difference in overall test run time | ||
| 136 | int threads = 1 | ||
| 137 | if (threads == 1) { | ||
| 138 | for (String screenPath in screenPathList) { | ||
| 139 | ScreenTestRender str = render(screenPath, parameters, requestMethod) | ||
| 140 | logger.info("Rendered ${screenPath} in ${str.getRenderTime()}ms, ${str.output?.length()} characters") | ||
| 141 | } | ||
| 142 | } else { | ||
| 143 | ExecutionContextImpl eci = ecfi.getEci() | ||
| 144 | ArrayList<Future> threadList = new ArrayList<Future>(threads) | ||
| 145 | int screenPathListSize = screenPathList.size() | ||
| 146 | for (int si = 0; si < screenPathListSize; si++) { | ||
| 147 | String screenPath = (String) screenPathList.get(si) | ||
| 148 | threadList.add(eci.runAsync({ | ||
| 149 | ScreenTestRender str = render(screenPath, parameters, requestMethod) | ||
| 150 | logger.info("Rendered ${screenPath} in ${str.getRenderTime()}ms, ${str.output?.length()} characters") | ||
| 151 | })) | ||
| 152 | if (threadList.size() == threads || (si + 1) == screenPathListSize) { | ||
| 153 | for (int i = 0; i < threadList.size(); i++) { ((Future) threadList.get(i)).get() } | ||
| 154 | threadList.clear() | ||
| 155 | } | ||
| 156 | } | ||
| 157 | } | ||
| 158 | } | ||
| 159 | |||
| 160 | long getRenderCount() { return renderCount } | ||
| 161 | long getErrorCount() { return errorCount } | ||
| 162 | long getRenderTotalChars() { return totalChars } | ||
| 163 | long getStartTime() { return startTime } | ||
| 164 | |||
| 165 | @CompileStatic | ||
| 166 | static class CustomScreenTestRenderImpl implements ScreenTestRender { | ||
| 167 | protected final CustomScreenTestImpl sti | ||
| 168 | String screenPath = (String) null | ||
| 169 | Map<String, Object> parameters = [:] | ||
| 170 | String requestMethod = (String) null | ||
| 171 | |||
| 172 | ScreenRender screenRender = (ScreenRender) null | ||
| 173 | String outputString = (String) null | ||
| 174 | Object jsonObj = null | ||
| 175 | long renderTime = 0 | ||
| 176 | Map postRenderContext = (Map) null | ||
| 177 | protected List<String> errorMessages = [] | ||
| 178 | |||
| 179 | CustomScreenTestRenderImpl(CustomScreenTestImpl sti, String screenPath, Map<String, Object> parameters, String requestMethod) { | ||
| 180 | this.sti = sti | ||
| 181 | this.screenPath = screenPath | ||
| 182 | if (parameters != null) this.parameters.putAll(parameters) | ||
| 183 | this.requestMethod = requestMethod | ||
| 184 | } | ||
| 185 | |||
| 186 | |||
| 187 | ScreenTestRender render() { | ||
| 188 | // render in separate thread with an independent ExecutionContext so it doesn't muck up the current one | ||
| 189 | ExecutionContextFactoryImpl ecfi = sti.ecfi | ||
| 190 | ExecutionContextImpl localEci = ecfi.getEci() | ||
| 191 | String username = localEci.userFacade.getUsername() | ||
| 192 | Subject loginSubject = localEci.userFacade.getCurrentSubject() | ||
| 193 | boolean authzDisabled = localEci.artifactExecutionFacade.getAuthzDisabled() | ||
| 194 | CustomScreenTestRenderImpl stri = this | ||
| 195 | Throwable threadThrown = null | ||
| 196 | |||
| 197 | Thread newThread = new Thread("CustomScreenTestRender") { | ||
| 198 | @Override void run() { | ||
| 199 | try { | ||
| 200 | ExecutionContextImpl threadEci = ecfi.getEci() | ||
| 201 | if (loginSubject != null) threadEci.userFacade.internalLoginSubject(loginSubject) | ||
| 202 | else if (username != null && !username.isEmpty()) threadEci.userFacade.internalLoginUser(username) | ||
| 203 | if (authzDisabled) threadEci.artifactExecutionFacade.disableAuthz() | ||
| 204 | // as this is used for server-side transition calls don't do tarpit checks | ||
| 205 | threadEci.artifactExecutionFacade.disableTarpit() | ||
| 206 | renderInternal(threadEci, stri) | ||
| 207 | threadEci.destroy() | ||
| 208 | } catch (Throwable t) { | ||
| 209 | threadThrown = t | ||
| 210 | } | ||
| 211 | } | ||
| 212 | } | ||
| 213 | newThread.start() | ||
| 214 | newThread.join() | ||
| 215 | if (threadThrown != null) throw threadThrown | ||
| 216 | return this | ||
| 217 | } | ||
| 218 | private static void renderInternal(ExecutionContextImpl eci, CustomScreenTestRenderImpl stri) { | ||
| 219 | CustomScreenTestImpl sti = stri.sti | ||
| 220 | long startTime = System.currentTimeMillis() | ||
| 221 | |||
| 222 | // parse the screenPath - if empty or null, use empty list to render the root screen | ||
| 223 | ArrayList<String> screenPathList = [] | ||
| 224 | if (stri.screenPath != null && !stri.screenPath.trim().isEmpty()) { | ||
| 225 | screenPathList = ScreenUrlInfo.parseSubScreenPath(sti.rootScreenDef, sti.baseScreenDef, | ||
| 226 | sti.baseScreenPathList, stri.screenPath, stri.parameters, sti.sfi) | ||
| 227 | if (screenPathList == null) throw new BaseArtifactException("Could not find screen path ${stri.screenPath} under base screen ${sti.baseScreenDef.location}") | ||
| 228 | } | ||
| 229 | |||
| 230 | // push context | ||
| 231 | ContextStack cs = eci.getContext() | ||
| 232 | cs.push() | ||
| 233 | // create our custom WebFacadeStub instead of framework's, passing screen path for proper path handling | ||
| 234 | WebFacadeStub wfs = new WebFacadeStub(sti.ecfi, stri.parameters, sti.sessionAttributes, stri.requestMethod, stri.screenPath) | ||
| 235 | // set stub on eci, will also put parameters in context | ||
| 236 | eci.setWebFacade(wfs) | ||
| 237 | // make the ScreenRender | ||
| 238 | ScreenRender screenRender = sti.sfi.makeRender() | ||
| 239 | stri.screenRender = screenRender | ||
| 240 | // pass through various settings | ||
| 241 | if (sti.rootScreenLocation != null && sti.rootScreenLocation.length() > 0) screenRender.rootScreen(sti.rootScreenLocation) | ||
| 242 | if (sti.outputType != null && sti.outputType.length() > 0) screenRender.renderMode(sti.outputType) | ||
| 243 | if (sti.characterEncoding != null && sti.characterEncoding.length() > 0) screenRender.encoding(sti.characterEncoding) | ||
| 244 | if (sti.macroTemplateLocation != null && sti.macroTemplateLocation.length() > 0) screenRender.macroTemplate(sti.macroTemplateLocation) | ||
| 245 | if (sti.baseLinkUrl != null && sti.baseLinkUrl.length() > 0) screenRender.baseLinkUrl(sti.baseLinkUrl) | ||
| 246 | if (sti.servletContextPath != null && sti.servletContextPath.length() > 0) screenRender.servletContextPath(sti.servletContextPath) | ||
| 247 | screenRender.webappName(sti.webappName) | ||
| 248 | if (sti.skipJsonSerialize) wfs.skipJsonSerialize = true | ||
| 249 | |||
| 250 | // set the screenPath | ||
| 251 | screenRender.screenPath(screenPathList) | ||
| 252 | |||
| 253 | // do the render | ||
| 254 | try { | ||
| 255 | screenRender.render(wfs.httpServletRequest, wfs.httpServletResponse) | ||
| 256 | // get the response text from our WebFacadeStub | ||
| 257 | stri.outputString = wfs.getResponseText() | ||
| 258 | stri.jsonObj = wfs.getResponseJsonObj() | ||
| 259 | System.out.println("STRI: " + stri.outputString + " : " + stri.jsonObj) | ||
| 260 | } catch (Throwable t) { | ||
| 261 | String errMsg = "Exception in render of ${stri.screenPath}: ${t.toString()}" | ||
| 262 | logger.warn(errMsg, t) | ||
| 263 | stri.errorMessages.add(errMsg) | ||
| 264 | sti.errorCount++ | ||
| 265 | } | ||
| 266 | // calc renderTime | ||
| 267 | stri.renderTime = System.currentTimeMillis() - startTime | ||
| 268 | |||
| 269 | // pop the context stack, get rid of var space | ||
| 270 | stri.postRenderContext = cs.pop() | ||
| 271 | |||
| 272 | // check, pass through, error messages | ||
| 273 | if (eci.message.hasError()) { | ||
| 274 | stri.errorMessages.addAll(eci.message.getErrors()) | ||
| 275 | eci.message.clearErrors() | ||
| 276 | StringBuilder sb = new StringBuilder("Error messages from ${stri.screenPath}: ") | ||
| 277 | for (String errorMessage in stri.errorMessages) sb.append("\n").append(errorMessage) | ||
| 278 | logger.warn(sb.toString()) | ||
| 279 | sti.errorCount += stri.errorMessages.size() | ||
| 280 | } | ||
| 281 | |||
| 282 | // check for error strings in output | ||
| 283 | if (stri.outputString != null) for (String errorStr in sti.errorStrings) if (stri.outputString.contains(errorStr)) { | ||
| 284 | String errMsg = "Found error [${errorStr}] in output from ${stri.screenPath}" | ||
| 285 | stri.errorMessages.add(errMsg) | ||
| 286 | sti.errorCount++ | ||
| 287 | logger.warn(errMsg) | ||
| 288 | } | ||
| 289 | |||
| 290 | // update stats | ||
| 291 | sti.renderCount++ | ||
| 292 | if (stri.outputString != null) sti.totalChars += stri.outputString.length() | ||
| 293 | } | ||
| 294 | |||
| 295 | @Override ScreenRender getScreenRender() { return screenRender } | ||
| 296 | @Override String getOutput() { return outputString } | ||
| 297 | @Override Object getJsonObject() { return jsonObj } | ||
| 298 | @Override long getRenderTime() { return renderTime } | ||
| 299 | @Override Map getPostRenderContext() { return postRenderContext } | ||
| 300 | @Override List<String> getErrorMessages() { return errorMessages } | ||
| 301 | |||
| 302 | @Override | ||
| 303 | boolean assertContains(String text) { | ||
| 304 | if (!outputString) return false | ||
| 305 | return outputString.contains(text) | ||
| 306 | } | ||
| 307 | @Override | ||
| 308 | boolean assertNotContains(String text) { | ||
| 309 | if (!outputString) return true | ||
| 310 | return !outputString.contains(text) | ||
| 311 | } | ||
| 312 | @Override | ||
| 313 | boolean assertRegex(String regex) { | ||
| 314 | if (!outputString) return false | ||
| 315 | return outputString.matches(regex) | ||
| 316 | } | ||
| 317 | } | ||
| 318 | } | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
| ... | @@ -33,6 +33,7 @@ class WebFacadeStub implements WebFacade { | ... | @@ -33,6 +33,7 @@ class WebFacadeStub implements WebFacade { |
| 33 | protected final Map<String, Object> parameters | 33 | protected final Map<String, Object> parameters |
| 34 | protected final Map<String, Object> sessionAttributes | 34 | protected final Map<String, Object> sessionAttributes |
| 35 | protected final String requestMethod | 35 | protected final String requestMethod |
| 36 | protected final String screenPath | ||
| 36 | 37 | ||
| 37 | protected HttpServletRequest httpServletRequest | 38 | protected HttpServletRequest httpServletRequest |
| 38 | protected HttpServletResponse httpServletResponse | 39 | protected HttpServletResponse httpServletResponse |
| ... | @@ -54,11 +55,12 @@ class WebFacadeStub implements WebFacade { | ... | @@ -54,11 +55,12 @@ class WebFacadeStub implements WebFacade { |
| 54 | boolean skipJsonSerialize = false | 55 | boolean skipJsonSerialize = false |
| 55 | 56 | ||
| 56 | WebFacadeStub(ExecutionContextFactoryImpl ecfi, Map<String, Object> parameters, | 57 | WebFacadeStub(ExecutionContextFactoryImpl ecfi, Map<String, Object> parameters, |
| 57 | Map<String, Object> sessionAttributes, String requestMethod) { | 58 | Map<String, Object> sessionAttributes, String requestMethod, String screenPath = null) { |
| 58 | this.ecfi = ecfi | 59 | this.ecfi = ecfi |
| 59 | this.parameters = parameters ?: [:] | 60 | this.parameters = parameters ?: [:] |
| 60 | this.sessionAttributes = sessionAttributes ?: [:] | 61 | this.sessionAttributes = sessionAttributes ?: [:] |
| 61 | this.requestMethod = requestMethod ?: "GET" | 62 | this.requestMethod = requestMethod ?: "GET" |
| 63 | this.screenPath = screenPath | ||
| 62 | 64 | ||
| 63 | // Create mock HTTP objects | 65 | // Create mock HTTP objects |
| 64 | createMockHttpObjects() | 66 | createMockHttpObjects() |
| ... | @@ -111,11 +113,23 @@ class WebFacadeStub implements WebFacade { | ... | @@ -111,11 +113,23 @@ class WebFacadeStub implements WebFacade { |
| 111 | } | 113 | } |
| 112 | 114 | ||
| 113 | @Override | 115 | @Override |
| 114 | String getPathInfo() { return "/test" } | 116 | String getPathInfo() { |
| 117 | // For standalone screens, return empty path to render the screen itself | ||
| 118 | // For screens with subscreen paths, return the relative path | ||
| 119 | return screenPath ? "/${screenPath}" : "" | ||
| 120 | } | ||
| 115 | 121 | ||
| 116 | @Override | 122 | @Override |
| 117 | ArrayList<String> getPathInfoList() { | 123 | ArrayList<String> getPathInfoList() { |
| 118 | return new ArrayList<String>(["test"]) | 124 | // IMPORTANT: Don't delegate to WebFacadeImpl - it expects real HTTP servlet context |
| 125 | // Return mock path info for MCP screen rendering based on actual screen path | ||
| 126 | def pathInfo = getPathInfo() | ||
| 127 | if (pathInfo && pathInfo.startsWith("/")) { | ||
| 128 | // Split path and filter out empty parts | ||
| 129 | def pathParts = pathInfo.substring(1).split("/") as List | ||
| 130 | return new ArrayList<String>(pathParts.findAll { it && it.toString().length() > 0 }) | ||
| 131 | } | ||
| 132 | return new ArrayList<String>() // Empty for standalone screens | ||
| 119 | } | 133 | } |
| 120 | 134 | ||
| 121 | @Override | 135 | @Override | ... | ... |
-
Please register or sign in to post a comment