702fafc1 by Ean Schuessler

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
1 parent 5618521e
...@@ -435,22 +435,6 @@ ...@@ -435,22 +435,6 @@
435 } else { 435 } else {
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
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 438
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
...@@ -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,123 +1281,216 @@ try { ...@@ -1284,123 +1281,216 @@ 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()
1297 // Note: Screen validation will happen during render
1298 // if (!ec.screen.isScreenDefined(screenPath)) {
1299 // throw new Exception("Screen not found: ${screenPath}")
1300 // }
1301
1302 // Set parameters in context
1303 if (parameters) {
1304 ec.context.putAll(parameters)
1305 }
1306
1307 // Try to render screen content for LLM consumption
1308 def output = null
1309 def screenUrl = "http://localhost:8080/${screenPath}"
1310
1311 try {
1312 ec.logger.info("MCP Screen Execution: Attempting to render screen ${screenPath} using ScreenTest with proper root screen")
1313
1314 // For ScreenTest to work properly, we need to use the correct root screen
1315 // The screenPath should be relative to the appropriate root screen
1316 def testScreenPath = screenPath
1317 def rootScreen = "component://webroot/screen/webroot.xml"
1318
1319 // Initialize standalone flag outside the if block
1320 def targetScreenDef = null
1321 def isStandalone = false
1322
1323 // If the screen path is already a full component:// path, we need to handle it differently
1324 if (screenPath.startsWith("component://")) {
1325 // For component:// paths, we need to use the component's root screen, not webroot
1326 // Extract the component name and use its root screen
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
1299 try { 1334 try {
1300 // Note: Screen validation will happen during render 1335 targetScreenDef = ec.screen.getScreenDefinition(screenPath)
1301 // if (!ec.screen.isScreenDefined(screenPath)) { 1336 ec.logger.info("MCP Screen Execution: Target screen def for ${screenPath}: ${targetScreenDef?.getClass()?.getSimpleName()}")
1302 // throw new Exception("Screen not found: ${screenPath}") 1337 if (targetScreenDef?.screenNode) {
1303 // } 1338 def standaloneAttr = targetScreenDef.screenNode.attribute('standalone')
1304 1339 ec.logger.info("MCP Screen Execution: Target screen ${screenPath} standalone attribute: '${standaloneAttr}'")
1305 // Set parameters in context 1340 isStandalone = standaloneAttr == "true"
1306 if (parameters) { 1341 } else {
1307 ec.context.putAll(parameters) 1342 ec.logger.warn("MCP Screen Execution: Target screen ${screenPath} has no screenNode")
1308 } 1343 }
1344 ec.logger.info("MCP Screen Execution: Target screen ${screenPath} standalone=${isStandalone}")
1309 1345
1310 // Try to render screen content for LLM consumption 1346 if (isStandalone) {
1311 def output = null 1347 // For standalone screens, try to render with minimal context or fall back to URL
1312 def screenUrl = "http://localhost:8080/${screenPath}" 1348 ec.logger.info("MCP Screen Execution: Standalone screen detected, will try direct rendering")
1313 1349 rootScreen = screenPath
1314 try { 1350 testScreenPath = "" // Empty path for standalone screens
1315 ec.logger.info("MCP Screen Execution: Attempting to render screen ${screenPath} using ScreenTest with proper root screen") 1351 // We'll handle standalone screens specially below
1316 1352 }
1317 // For ScreenTest to work properly, we need to use the correct root screen 1353 } catch (Exception e) {
1318 // The screenPath should be relative to the appropriate root screen 1354 ec.logger.warn("MCP Screen Execution: Error checking target screen ${screenPath}: ${e.message}")
1319 def testScreenPath = screenPath 1355 ec.logger.error("MCP Screen Execution: Full exception", e)
1320 def rootScreen = "component://webroot/screen/webroot.xml" 1356 }
1321 1357
1322 // If the screen path is already a full component:// path, we need to handle it differently 1358 // Only look for component root if target is not standalone
1323 if (screenPath.startsWith("component://")) { 1359 if (!isStandalone) {
1324 // Extract the path after component:// for ScreenTest 1360 // For component://webroot/screen/... paths, always use webroot as root
1325 testScreenPath = screenPath.substring(12) // Remove "component://" 1361 if (pathAfterComponent.startsWith("webroot/screen/")) {
1326 ec.logger.info("MCP Screen Execution: Converted component path to test path: ${testScreenPath}") 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())
1327 } 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
1328 1374
1329 def screenTest = ec.screen.makeTest() 1375 // Try to check if the full path is a valid screen
1330 .rootScreen(rootScreen) 1376 try {
1331 .renderMode(renderMode ? renderMode : "text") 1377 def directScreenDef = ec.screen.getScreenDefinition(screenPath)
1332 1378 if (directScreenDef) {
1333 ec.logger.info("MCP Screen Execution: ScreenTest object created: ${screenTest?.getClass()?.getSimpleName()}") 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 }
1334 1385
1335 if (screenTest) { 1386 if (isDirectScreenPath) {
1336 def renderParams = parameters ?: [:] 1387 // For direct screen paths, use the screen itself as root
1337 1388 rootScreen = screenPath
1338 // Add current user info to render context to maintain authentication 1389 testScreenPath = ""
1339 renderParams.userId = ec.user.userId 1390 ec.logger.info("MCP Screen Execution: Using direct screen as root: ${rootScreen}")
1340 renderParams.username = ec.user.username 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 ]
1341 1399
1342 // Add timeout to prevent hanging 1400 for (rootScreenName in possibleRootScreens) {
1343 def future = java.util.concurrent.Executors.newSingleThreadExecutor().submit({ 1401 def candidateRoot = "component://${componentName}/screen/${rootScreenName}"
1344 return screenTest.render(testScreenPath, renderParams, null) 1402 try {
1345 } as java.util.concurrent.Callable) 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 }
1412 }
1346 1413
1347 try { 1414 if (componentRootScreen) {
1348 def testRender = future.get(30, java.util.concurrent.TimeUnit.SECONDS) // 30 second timeout 1415 rootScreen = componentRootScreen
1349 output = testRender.output 1416 testScreenPath = remainingPath
1350 def outputLength = output?.length() ?: 0 1417 ec.logger.info("MCP Screen Execution: Using component root ${rootScreen} for path ${testScreenPath}")
1351 ec.logger.info("MCP Screen Execution: Successfully rendered screen ${screenPath}, output length: ${outputLength}") 1418 } else {
1352 } catch (java.util.concurrent.TimeoutException e) { 1419 // Fallback: try using webroot with the full path
1353 future.cancel(true) 1420 rootScreen = "component://webroot/screen/webroot.xml"
1354 throw new Exception("Screen rendering timed out after 30 seconds for ${screenPath}") 1421 testScreenPath = pathAfterComponent
1355 } finally { 1422 ec.logger.warn("MCP Screen Execution: Could not find component root for ${componentName}, using webroot fallback: ${testScreenPath}")
1356 future.cancel(true)
1357 } 1423 }
1358 } else {
1359 throw new Exception("ScreenTest object is null")
1360 } 1424 }
1361
1362 } catch (Exception e) {
1363 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()}")
1365 ec.logger.error("MCP Screen Execution: Full exception for ${screenPath}", e)
1366
1367 // Fallback to URL if rendering fails
1368 output = "Screen '${screenPath}' is accessible at: ${screenUrl}\n\nNote: Screen content could not be rendered. You can visit this URL in a web browser to interact with the screen directly."
1369 } 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)
1437 .rootScreen(rootScreen)
1438 .renderMode(renderMode ? renderMode : "html")
1439
1440 ec.logger.info("MCP Screen Execution: ScreenTest object created: ${screenTest?.getClass()?.getSimpleName()}")
1441
1442 if (screenTest) {
1443 def renderParams = parameters ?: [:]
1370 1444
1371 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 1445 // Add current user info to render context to maintain authentication
1372 1446 renderParams.userId = ec.user.userId
1373 // Return just the rendered screen content for MCP wrapper to handle 1447 renderParams.username = ec.user.username
1374 result = [
1375 type: "text",
1376 text: output,
1377 screenPath: screenPath,
1378 screenUrl: screenUrl,
1379 executionTime: executionTime
1380 ]
1381 1448
1382 ec.logger.info("MCP Screen Execution: Generated URL for screen ${screenPath} in ${executionTime}s") 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}")
1383 1453
1384 } catch (Exception e) { 1454 // Regular screen rendering with timeout
1385 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 1455 def future = java.util.concurrent.Executors.newSingleThreadExecutor().submit({
1456 return screenTest.render(testScreenPath, renderParams, null)
1457 } as java.util.concurrent.Callable)
1386 1458
1387 result = [ 1459 try {
1388 content: [ 1460 def testRender = future.get(30, java.util.concurrent.TimeUnit.SECONDS) // 30 second timeout
1389 [ 1461 output = testRender.output
1390 type: "text", 1462 def outputLength = output?.length() ?: 0
1391 text: "Error executing screen ${screenPath}: ${e.message}" 1463 ec.logger.info("MCP Screen Execution: Successfully rendered screen ${screenPath}, output length: ${outputLength}")
1392 ] 1464 } catch (java.util.concurrent.TimeoutException e) {
1393 ], 1465 future.cancel(true)
1394 isError: true, 1466 throw new Exception("Screen rendering timed out after 30 seconds for ${screenPath}")
1395 metadata: [ 1467 } finally {
1396 screenPath: screenPath, 1468 future.cancel(true)
1397 renderMode: renderMode, 1469 }
1398 executionTime: executionTime 1470 } else {
1399 ] 1471 throw new Exception("ScreenTest object is null")
1400 ]
1401
1402 ec.logger.error("MCP Screen Execution error for ${screenPath}", e)
1403 } 1472 }
1473 } catch (Exception e) {
1474 ec.logger.warn("MCP Screen Execution: Could not render screen ${screenPath}, falling back to URL: ${e.message}")
1475 ec.logger.warn("MCP Screen Execution: Exception details: ${e.getClass()?.getSimpleName()}: ${e.getMessage()}")
1476 ec.logger.error("MCP Screen Execution: Full exception for ${screenPath}", e)
1477
1478 // Fallback to URL if rendering fails
1479 output = "Screen '${screenPath}' is accessible at: ${screenUrl}\n\nNote: Screen content could not be rendered. You can visit this URL in a web browser to interact with the screen directly."
1480 }
1481
1482 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
1483
1484 // Return just the rendered screen content for MCP wrapper to handle
1485 result = [
1486 type: "text",
1487 text: output,
1488 screenPath: screenPath,
1489 screenUrl: screenUrl,
1490 executionTime: executionTime
1491 ]
1492
1493 ec.logger.info("MCP Screen Execution: Generated URL for screen ${screenPath} in ${executionTime}s")
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
......