Page History
...
You can also do some automation-related with Xray, specially especially because we use Jira entities and concepts.
...
In this example, we're adding a new option in the "More" menu, by adding a new "web section", "web item" ScriptRunner elements.
The following script , will create a Test Execution containing all the Tests that are part of the current Test Set.
...
In the following example, a "script field" is used to to show the total amount of linked Tests to a given requirement.
...
In the following example, a "script field" is used to to show the total amount of linked defects to a given requirement and also provide a link to easily obtain those defects in the Issue search page.
...
In order to add this option in Jira's UI, we'll need to add a custom "web item" that provide provides an action that will interact with a custom ScriptRunner endpoint, which will be the one doing the HTTP request to the Jenkins server, passing the Test Plan issue key. In order to submit the request to Jenkins, we need to obtain Jenkins username and respective API token along with the project-specific authentication token.
...
Code Block | ||||||
---|---|---|---|---|---|---|
| ||||||
import com.onresolve.scriptrunner.runner.rest.common.CustomEndpointDelegate import groovy.json.JsonOutput import groovy.transform.BaseScript import groovy.json.JsonSlurper; import groovy.json.StreamingJsonBuilder; import javax.ws.rs.core.MultivaluedMap import javax.ws.rs.core.Response @BaseScript CustomEndpointDelegate delegate triggerJenkinsBuild(httpMethod: "GET") { MultivaluedMap queryParams -> def issueId = queryParams.getFirst("issueId") as String // use the issueId to retrieve this issue def flag = [ type : 'success', title: "Build scheduled", close: 'auto', body : "A new build has been scheduled related with "+issueId ] URL url; def jobName = "java-junit-calc" // could come from a CF in the Test Plan def jenkinsHostPort = "192.168.56.102:8081" // could be defined elsewhere def token = "iFBDOBhNhaxL4T9ass93HRXun2JF161Z" // could also come from a CF in the Test Plan def username = "admin" // probably, would need to be stored elsewhere def password = "fa02840152aa2e4da3d8db933ec708d6" // probably, would need to be stored elsewhere def baseURL = "http://${jenkinsHostPort}/job/${jobName}/buildWithParameters?token=${token}&TESTPLAN=$issueId" url = new URL(baseURL); def body_req = [] def authString = "${username}:${password}".bytes.encodeBase64().toString() URLConnection connection = url.openConnection(); connection.requestMethod = "POST" connection.doOutput = true connection.addRequestProperty("Authorization", "Basic ${authString}") connection.setRequestProperty("Content-Type", "application/json;charset=UTF-8") connection.outputStream.withWriter("UTF-8") { new StreamingJsonBuilder(it, body_req) } connection.connect(); log.debug(connection.getResponseCode()) log.debug(connection.getResponseMessage()) if (connection.getResponseCode() == 201) { Response.ok(JsonOutput.toJson(flag)).build() } else { //Response.status(Response.Status.NOT_FOUND).entity("Problem scheduling job!").build(); } } |
Example
...
ScriptRunner configuration
Jenkins configuration
In Jenkins, we need to generate an API token for some user, which can be done from the profile settings page.
...
In order to add this option in Jira's UI, we'll need to add a custom "web item" that provide provides an action that will interact with a custom ScriptRunner endpoint, which will be the one doing the HTTP request to the Jenkins server, passing the Test Plan issue key. In the ScriptRunner endpoint script, we'll obtain the list of Generic Tests (we're assuming that they will came come from Junit, so they have a certain syntax in the Generic Test Definition field.
In order to submit the request to Jenkins, we need to obtain Jenkins username and respective API token along with the project-specific authentication token.
...
Code Block | ||||||
---|---|---|---|---|---|---|
| ||||||
import com.atlassian.jira.issue.Issue import com.atlassian.jira.issue.link.IssueLinkManager import com.atlassian.jira.issue.link.IssueLinkType import com.atlassian.jira.issue.link.IssueLinkTypeManager import com.atlassian.jira.ComponentManager import com.atlassian.jira.component.ComponentAccessor import com.atlassian.jira.jql.builder.JqlQueryBuilder import com.atlassian.jira.user.util.UserUtil import com.atlassian.jira.user.util.UserManager; import com.atlassian.jira.bc.issue.IssueService import com.atlassian.jira.bc.issue.search.SearchService; import com.atlassian.jira.issue.search.SearchProvider import com.atlassian.jira.issue.search.SearchResults import com.atlassian.jira.web.bean.PagerFilter; import com.atlassian.jira.issue.MutableIssue import com.atlassian.jira.user.UserPropertyManager import com.atlassian.jira.propertyset.JiraPropertySetFactory; import com.google.common.collect.ImmutableMap; import com.opensymphony.module.propertyset.PropertySet; import com.opensymphony.module.propertyset.PropertySetManager; import com.atlassian.jira.util.BuildUtils import com.atlassian.jira.util.BuildUtilsInfo import com.atlassian.jira.util.BuildUtilsInfoImpl import com.atlassian.plugin.PluginAccessor import com.atlassian.plugin.PluginManager import com.atlassian.jira.bc.license.JiraLicenseService import com.atlassian.jira.bc.license.JiraLicenseServiceImpl import org.apache.log4j.Level import org.apache.log4j.Logger import com.atlassian.jira.issue.IssueManager import com.opensymphony.workflow.InvalidInputException import com.onresolve.scriptrunner.runner.rest.common.CustomEndpointDelegate import groovy.json.JsonOutput import groovy.transform.BaseScript import groovy.json.JsonSlurper; import groovy.json.StreamingJsonBuilder; import javax.ws.rs.core.MultivaluedMap import javax.ws.rs.core.Response @BaseScript CustomEndpointDelegate delegate issueManager = ComponentAccessor.getIssueManager() searchService = ComponentAccessor.getComponent(SearchService.class); serviceAccount = ComponentAccessor.getJiraAuthenticationContext().getLoggedInUser() customFieldManager = ComponentAccessor.getCustomFieldManager() Object getIssues(jqlQuery){ // A list of GenericValues representing issues List<Issue> searchResults = null; SearchService.ParseResult parseResult = searchService.parseQuery(serviceAccount, jqlQuery); if (parseResult.isValid()) { // throws SearchException SearchResults results = searchService.search(serviceAccount, parseResult.getQuery(), PagerFilter.getUnlimitedFilter()); searchResults = results.getIssues(); return searchResults; } return [] } Object getFieldValue(issue,customField) { //def cField = customFieldManager.getCustomFieldObject(customField) def cField = customFieldManager.getCustomFieldObjectByName(customField) def cFieldValue = issue.getCustomFieldValue(cField) return cFieldValue } String replaceLast(String string, String substring, String replacement) { int index = string.lastIndexOf(substring); if (index == -1) return string; return string.substring(0, index) + replacement + string.substring(index+substring.length()); } triggerJenkinsBuildWithTestList(httpMethod: "GET") { MultivaluedMap queryParams -> // the details of getting and modifying the current issue are ommitted for brevity def issueId = queryParams.getFirst("issueId") as String // use the issueId to retrieve this issue def flag = [ type : 'success', title: "Build scheduled", close: 'auto', body : "A new build has been scheduled related with "+issueId ] URL url; def jobName = "java-junit-calc-triggered" // could be defined in a CF in the Test Plan def jenkinsHostPort = "192.168.56.102:8081" // could be defined elsewhere def token = "iFBDOBhNhaxL4T9ass93HRXun2JF161Z" // could also come from a CF in the Test Plan def username = "admin" // probably, would need to be stored elsewhere def password = "fa02840152aa2e4da3d8db933ec708d6" // probably, would need to be stored elsewhere //def baseURL = "http://${username}:${password}@${jenkinsHostPort}/job/${jobName}/build?token=${token}" //def baseURL = "http://${jenkinsHostPort}/job/${jobName}/build?token=${token}" jql = "issue in testPlanTests('${issueId}') and \"Test Type\" = Generic" issues = getIssues(jql) // we're assuming that we have Junit based Tests.. so we need to do some conversion beforehand, so maven can process the list of tests to be run def testlist = issues.collect { getFieldValue(it,"Generic Test Definition")} def testlist2 = testlist.collect { replaceLast(it,".","%23") } def baseURL = "http://${jenkinsHostPort}/job/${jobName}/buildWithParameters?token=${token}&TESTPLAN=${issueId}&TESTLIST=${testlist2.join(',')}" url = new URL(baseURL); def body_req = [] def authString = "${username}:${password}".bytes.encodeBase64().toString() URLConnection connection = url.openConnection(); connection.requestMethod = "POST" connection.doOutput = true connection.addRequestProperty("Authorization", "Basic ${authString}") connection.setRequestProperty("Content-Type", "application/json;charset=UTF-8") connection.outputStream.withWriter("UTF-8") { new StreamingJsonBuilder(it, body_req) } connection.connect(); //connection.getContent(); log.debug(connection.getResponseCode()) log.debug(connection.getResponseMessage()) if (connection.getResponseCode() == 201) { Response.ok(JsonOutput.toJson(flag)).build() } else { //Response.status(Response.Status.NOT_FOUND).entity("Problem scheduling job!").build(); } } |
Example
...
ScriptRunner configuration
Jenkins configuration
In Jenkins, we need to generate an API token for some user, which can be done from the profile settings page.
...
Code Block | ||||||
---|---|---|---|---|---|---|
| ||||||
import com.onresolve.scriptrunner.runner.rest.common.CustomEndpointDelegate import groovy.json.JsonOutput import groovy.transform.BaseScript import groovy.json.JsonSlurper; import groovy.json.StreamingJsonBuilder; import javax.ws.rs.core.MultivaluedMap import javax.ws.rs.core.Response import java.nio.charset.StandardCharsets @BaseScript CustomEndpointDelegate delegate triggerBambooBuild(httpMethod: "GET") { MultivaluedMap queryParams -> def issueId = queryParams.getFirst("issueId") as String // use the issueId to retrieve this issue def flag = [ type : 'success', title: "Build scheduled", close: 'auto', body : "A new build has been scheduled related with "+issueId ] URL url; // curl --user admin:admin -X POST -d "default&ExecuteAllStages=true" http://yourbambooserver/rest/api/latest/queue/XRAY-JUNITCALC def projectKey = "XRAY" // could come from a CF in the Test Plan def planKey = "JUNITCALC" // could come from a CF in the Test Plan def bambooHostPort = "192.168.56.102:8085" // could be defined elsewhere def username = "admin" // probably, would need to be stored elesewhere def password = "admin" // probably, would need to be stored elesewhere def baseURL = "http://${bambooHostPort}/rest/api/latest/queue/${projectKey}-${planKey}" String urlParameters = "default&ExecuteAllStages=true&bamboo.TESTPLAN=${issueId}"; byte[] postData = urlParameters.getBytes( StandardCharsets.UTF_8 ); int postDataLength = postData.length; url = new URL(baseURL); def body_req = [] def authString = "${username}:${password}".bytes.encodeBase64().toString() URLConnection connection = url.openConnection(); connection.requestMethod = "POST" connection.doOutput = true connection.addRequestProperty("Authorization", "Basic ${authString}") connection.setRequestProperty( "Content-Type", "application/x-www-form-urlencoded"); connection.setRequestProperty( "charset", "utf-8"); connection.setRequestProperty( "Content-Length", Integer.toString( postDataLength )); connection.setUseCaches( false ); DataOutputStream wr = new DataOutputStream(connection.getOutputStream()); wr.write( postData ); connection.connect(); //connection.getContent(); log.debug(connection.getResponseCode()) log.debug(connection.getResponseMessage()) if (connection.getResponseCode() == 200) { Response.ok(JsonOutput.toJson(flag)).build() } else { //Response.status(Response.Status.NOT_FOUND).entity("Problem scheduling job!").build(); } } |
Example
ScripRunner configuration
Bamboo configuration
The project itself is a normal one; the only thing relevant to mention is that this project is a parameterized one, so it receives a TESTPLAN variable, that in our case will be coming from Jira.
...
Code Block | ||||||
---|---|---|---|---|---|---|
| ||||||
import com.atlassian.jira.issue.Issue import com.atlassian.jira.issue.link.IssueLinkManager import com.atlassian.jira.issue.link.IssueLinkType import com.atlassian.jira.issue.link.IssueLinkTypeManager import com.atlassian.jira.ComponentManager import com.atlassian.jira.component.ComponentAccessor import com.atlassian.jira.jql.builder.JqlQueryBuilder import com.atlassian.jira.user.util.UserUtil import com.atlassian.jira.user.util.UserManager; import com.atlassian.jira.bc.issue.IssueService import com.atlassian.jira.bc.issue.search.SearchService; import com.atlassian.jira.issue.search.SearchProvider import com.atlassian.jira.issue.search.SearchResults import com.atlassian.jira.web.bean.PagerFilter; import com.atlassian.jira.issue.MutableIssue import com.atlassian.jira.user.UserPropertyManager import com.atlassian.jira.propertyset.JiraPropertySetFactory; import com.google.common.collect.ImmutableMap; import com.opensymphony.module.propertyset.PropertySet; import com.opensymphony.module.propertyset.PropertySetManager; import com.atlassian.jira.util.BuildUtils import com.atlassian.jira.util.BuildUtilsInfo import com.atlassian.jira.util.BuildUtilsInfoImpl import com.atlassian.plugin.PluginAccessor import com.atlassian.plugin.PluginManager import com.atlassian.jira.bc.license.JiraLicenseService import com.atlassian.jira.bc.license.JiraLicenseServiceImpl import org.apache.log4j.Level import org.apache.log4j.Logger import com.atlassian.jira.issue.IssueManager import com.opensymphony.workflow.InvalidInputException import com.onresolve.scriptrunner.runner.rest.common.CustomEndpointDelegate import groovy.json.JsonOutput import groovy.transform.BaseScript import groovy.json.JsonSlurper; import groovy.json.StreamingJsonBuilder; import javax.ws.rs.core.MultivaluedMap import javax.ws.rs.core.Response import java.nio.charset.StandardCharsets @BaseScript CustomEndpointDelegate delegate issueManager = ComponentAccessor.getIssueManager() searchService = ComponentAccessor.getComponent(SearchService.class); serviceAccount = ComponentAccessor.getJiraAuthenticationContext().getLoggedInUser() customFieldManager = ComponentAccessor.getCustomFieldManager() Object getIssues(jqlQuery){ // A list of GenericValues representing issues List<Issue> searchResults = null; SearchService.ParseResult parseResult = searchService.parseQuery(serviceAccount, jqlQuery); if (parseResult.isValid()) { // throws SearchException SearchResults results = searchService.search(serviceAccount, parseResult.getQuery(), PagerFilter.getUnlimitedFilter()); searchResults = results.getIssues(); return searchResults; } return [] } Object getFieldValue(issue,customField) { def cField = customFieldManager.getCustomFieldObjectByName(customField) def cFieldValue = issue.getCustomFieldValue(cField) return cFieldValue } String replaceLast(String string, String substring, String replacement) { int index = string.lastIndexOf(substring); if (index == -1) return string; return string.substring(0, index) + replacement + string.substring(index+substring.length()); } triggerBambooBuildWithTestList(httpMethod: "GET") { MultivaluedMap queryParams -> def issueId = queryParams.getFirst("issueId") as String // use the issueId to retrieve this issue def flag = [ type : 'success', title: "Build scheduled", close: 'auto', body : "A new build has been scheduled related with "+issueId ] jql = "issue in testPlanTests('${issueId}') and \"Test Type\" = Generic" issues = getIssues(jql) // // we're assuming that we have Junit based Tests.. so we need to do some conversion beforehand, so maven can process the list of tests to be run def testlist = issues.collect { getFieldValue(it,"Generic Test Definition")} def testlist2 = testlist.collect { replaceLast(it,".","%23") } URL url; // curl --user admin:admin -X POST -d "default&ExecuteAllStages=true&bamboo.TESTLIST=com.xpand.java.CalcTest#CanAddNumbers" http://yourbambooserver/rest/api/latest/queue/XRAY-JUNITCALCPARAMS def projectKey = "XRAY" // could come from a CF in the Test Plan def planKey = "JUNITCALCPARAMS" // could come from a CF in the Test Plan def stage = "default" // could be hardcoded or come from a CF in the Test Plan def bambooHostPort = "192.168.56.102:8085" // could be defined elsewhere def username = "admin" // probably, would need to be stored elsewhere def password = "admin" // probably, would need to be stored elsewhere def baseURL = "http://${bambooHostPort}/rest/api/latest/queue/${projectKey}-${planKey}" String urlParameters = "${stage}&ExecuteAllStages=true&bamboo.TESTPLAN=${issueId}&bamboo.TESTLIST=${testlist2.join(',')}"; byte[] postData = urlParameters.getBytes( StandardCharsets.UTF_8 ); int postDataLength = postData.length; url = new URL(baseURL); def body_req = [] def authString = "${username}:${password}".bytes.encodeBase64().toString() URLConnection connection = url.openConnection(); connection.requestMethod = "POST" connection.doOutput = true connection.addRequestProperty("Authorization", "Basic ${authString}") connection.setRequestProperty( "Content-Type", "application/x-www-form-urlencoded"); connection.setRequestProperty( "charset", "utf-8"); connection.setRequestProperty( "Content-Length", Integer.toString( postDataLength )); connection.setUseCaches( false ); DataOutputStream wr = new DataOutputStream(connection.getOutputStream()); wr.write( postData ); connection.connect(); //connection.getContent(); log.debug(connection.getResponseCode()) log.debug(connection.getResponseMessage()) if (connection.getResponseCode() == 200) { Response.ok(JsonOutput.toJson(flag)).build() } else { //Response.status(Response.Status.NOT_FOUND).entity("Problem scheduling job!").build(); } } |
Example
ScripRunner configuration
Bamboo configuration
...
- receive the source issue key that triggered the synchronization request (e.g. Test Plan, Test Execution, Sub Test Execution)
- obtain the linked Test Sets, using JQL
- obtain the Tests on each Test Set, using JQL
- submit a REST API request to Xray specific endpoints to add the Test to the entity (e.g Test Plan)
ScripRunner configuration
Code Block | ||||||
---|---|---|---|---|---|---|
| ||||||
import com.onresolve.scriptrunner.runner.rest.common.CustomEndpointDelegate import groovy.json.JsonOutput import groovy.transform.BaseScript import groovy.json.JsonSlurper; import groovy.json.StreamingJsonBuilder; import javax.ws.rs.core.MultivaluedMap import javax.ws.rs.core.Response import java.nio.charset.StandardCharsets import com.atlassian.jira.issue.Issue import com.atlassian.jira.issue.link.IssueLinkManager import com.atlassian.jira.issue.link.IssueLinkType import com.atlassian.jira.issue.link.IssueLinkTypeManager import com.atlassian.jira.ComponentManager import com.atlassian.jira.component.ComponentAccessor import com.atlassian.jira.jql.builder.JqlQueryBuilder import com.atlassian.jira.user.util.UserUtil import com.atlassian.jira.user.util.UserManager; import com.atlassian.jira.bc.issue.IssueService import com.atlassian.jira.bc.issue.search.SearchService; import com.atlassian.jira.issue.search.SearchProvider import com.atlassian.jira.issue.search.SearchResults import com.atlassian.jira.web.bean.PagerFilter; import com.atlassian.jira.issue.MutableIssue import com.atlassian.jira.user.UserPropertyManager import com.atlassian.jira.propertyset.JiraPropertySetFactory; import com.google.common.collect.ImmutableMap; import com.opensymphony.module.propertyset.PropertySet; import com.opensymphony.module.propertyset.PropertySetManager; import com.atlassian.jira.util.BuildUtils import com.atlassian.jira.util.BuildUtilsInfo import com.atlassian.jira.util.BuildUtilsInfoImpl import com.atlassian.plugin.PluginAccessor import com.atlassian.plugin.PluginManager import com.atlassian.jira.bc.license.JiraLicenseService import com.atlassian.jira.bc.license.JiraLicenseServiceImpl import org.apache.log4j.Level import org.apache.log4j.Logger import com.atlassian.jira.issue.IssueManager import groovy.json.StreamingJsonBuilder @BaseScript CustomEndpointDelegate delegate boolean associateTestsToXrayIssue(endpoint, issueKey,listOfTestKeys){ def jiraBaseUrl = com.atlassian.jira.component.ComponentAccessor.getApplicationProperties().getString("jira.baseurl") def endpointUrl = "${jiraBaseUrl}/rest/raven/1.0/api/${endpoint}/${issueKey}/test" log.debug("issueKey: "+issueKey) log.debug("listOfTestKeys: "+listOfTestKeys) log.debug("jirabaseurl: "+jiraBaseUrl) log.debug("endpoint: "+endpointUrl) url = new URL(endpointUrl); def body_req = [ "add": listOfTestKeys ] // you should use a specific user for this purpose username = "admin" password = "admin" def authString = "${username}:${password}".bytes.encodeBase64().toString() URLConnection connection = url.openConnection(); connection.requestMethod = "POST" connection.doOutput = true connection.addRequestProperty("Authorization", "Basic ${authString}") connection.setRequestProperty("Content-Type", "application/json;charset=UTF-8") connection.outputStream.withWriter("UTF-8") { new StreamingJsonBuilder(it, body_req) } connection.connect(); log.debug(connection.getResponseCode()) log.debug(connection.getResponseMessage()) if (connection.getResponseCode() == 200) { // OK return true; } else { // error return false; } } boolean associateTestsToTestPlan(testPlanKey, listOfTestKeys){ return associateTestsToXrayIssue("testplan", testPlanKey, listOfTestKeys) } boolean associateTestsToTestExecution(testExecutionKey, listOfTestKeys){ return associateTestsToXrayIssue("testexec", testExecutionKey, listOfTestKeys) } Object getIssues(jqlQuery){ // A list of GenericValues representing issues List<Issue> searchResults = null; SearchService.ParseResult parseResult = searchService.parseQuery(serviceAccount, jqlQuery); if (parseResult.isValid()) { // throws SearchException SearchResults results = searchService.search(serviceAccount, parseResult.getQuery(), PagerFilter.getUnlimitedFilter()); searchResults = results.getIssues(); return searchResults; } return [] } synchTestsFromRelatedTestSets(httpMethod: "GET") { MultivaluedMap queryParams -> // issue_key may refer to a Test Plan or to a Test Execution def issue_key = queryParams.getFirst("issueId") as String // use the issueId to retrieve this issue projectManager = ComponentAccessor.getProjectManager() componentManager = ComponentManager.getInstance(); searchService = ComponentAccessor.getComponent(SearchService.class); issueManager = ComponentAccessor.getIssueManager() customFieldManager = ComponentAccessor.getCustomFieldManager() userUtil = ComponentAccessor.getUserUtil(); serviceAccount = ComponentAccessor.getJiraAuthenticationContext().getLoggedInUser() pluginAccessor = componentManager.getPluginAccessor(); MutableIssue issue = issueManager.getIssueObject(issue_key) Logger.getLogger("com.onresolve").setLevel(Level.DEBUG) // assume that Test Plan/Execution is linked to Test Sets using the issue link "relates to"; customize if needed jql = "issue in linkedIssues('${issue_key}', 'relates to')" testset_issues = getIssues(jql) def had_errors = false def success = false testset_issues.each { // process only "Test Set" issues if (it.issueType.name == "Test Set") { jql = "issue in testSetTests('${it.key}')" issues = getIssues(jql) test_keys = issues.collect{ it.key } //log.debug(test_keys) if (issue.issueType.name == "Test Plan") { success = associateTestsToTestPlan(issue_key, test_keys) } else if ((issue.issueType.name == "Sub Test Execution") || (issue.issueType.name == "Test Execution")) { success = associateTestsToTestExecution(issue_key, test_keys) } if (!success) { had_errors = true } } } def flag = [] if (success) { flag = [ type : 'success', title: "Test Sets Synchronization", close: 'auto', body : "Tests have been synchronized for " + issue_key ] Response.ok(JsonOutput.toJson(flag)).build() } else { flag = [ type : 'success', title: "Test Sets Synchronization", close: 'auto', body : "Tests have been synchronized for " + issue_key ] Response.serverError().entity([error: "some Tests were no synchronized"]).build() } } |
Example
Add a custom link to the Tests top menu
Sometimes it may be useful to add a custom link to the Tests top menu.
As an example, you may have an internal documentation/Confluence space with some valuable information concerning Xray usage in your organization.
ScripRunner configuration
Adding an entry to the top menu is easy.
Just go the Add-ons section in your Jira administration and then "Script Fragments".
Add a new "Raw xml module".
And add the configuration for the link; this configuration is exactly the same as if you were going to develop your own app, so it follows Atlassian developer documentation guidelines for the "web-item" element.
Make sure the "web-item" as a "weight" value higher than 120, to append your link to the end of the options available from the dropdown menu.
Example
Let's say that we want to add a link to "https://getxray.app" in the top Test menu, having the name "My Custom Link".
The "raw xml module" configuration would be something similar to the following snippet.
Code Block |
---|
<web-item key="xray-topnav-tests-meta-customlink" name="Custom link" section="raven-menu/xray.topnav.meta.section" weight="130">
<label>My Custom Link</label>
<link linkId="raven-topnav-test-item-meta.customlink">https://getxray.app</link>
</web-item> |