# Extensions (Natural language extensions)

To help you achieve more complex and custom scenarios, Virtuoso provides a scripting interface, that lets you extend Virtuoso's natural language with extensions and execute them as test steps.

# Creating a language extension script

You can access the language extension manager by clicking on the Extensions icon on the left-hand side of dashboard. Once there, you can click on New extension, choose a name for it (needs to be unique within its scope), and click on create. Note that you'll need to use this name to execute it in your journeys (i.e., this becomes the action of your natural language step).

# Extension scope

When creating a new extension, it can be placed at project scope (available only to that project) or at organization scope (available to all projects in that organization).

If you create an extension at project scope, but subsequently need to reuse it in other projects, you can move the extension to the organization scope. Note that the inverse operation (move from organization scope to project scope) is not possible. If you feel the need to have your extension back only at project scope, you will need to re-create it.

Note that an extension at organization scope can be seen and updated by any member of the organization

# What can I write in my language extension script?

You can write any script that you can normally execute on your browser console (e.g., Chrome DevTools). This can range from interacting with the DOM, to making XHR calls using fetch().

When executing, your code will be auto-wrapped in a function like below, and so a return statement at the end of / middle of your script would be considered as what the function returns:

(function(){
  // your code goes here
})()

Also note that failures in the script will cause the test step to fail.

Shortcuts

While writing your script in the editor, you have nearly full access to a Javascript IDE. You can see a list of possible options you have by pressing F1 on your keyboard. Note that you also have access to many shortcuts such as CTRL+S for saving.

# Asynchronous extension scripts

If your script is asynchronous (e.g., you make a call to an API), you need to turn on the toggle Runs asynchronously, and call done() in your script when your request completes.

You can store the result of your asynchronous script by calling done with an argument (e.g., done(result)). For example:

fetch("https://www.spotqa.com/api/user/foo", {"method":"GET","mode":"no-cors"})
.then(response => {
  done(response.status) // means the value of `response.status` will be considered as script's output
})

If you want to throw an error in your async script, you need to call doneError(message). You can call doneError either with a text string of the error, or by directly throwing the error instance. Otherwise, any other error thrown in the session will be captured automatically but the message will not be returned back to you. For example:

myAsyncFunction = async (myData) => {
    try {
      await asyncMethod(myData)
    } catch (e) {
      doneError(e)
    }
}

If you are facing any issues with unrelated errors being thrown while your script is running, this will automatically stop your script from running. To avoid this, you need to add this to the top of your script: window.onerror = () => ().

Asynchronous Timeout

Async scripts have a maximum time limit of 60 seconds to complete.

# Using external libraries

To extend the capabilities of the extensions beyond standard JavaScript, Virtuoso allows for the use of any library available on the following CDNs:

  • https://cdn.jsdelivr.net/
  • https://gist.githubusercontent.com/
  • https://cdnjs.cloudflare.com/
  • https://unpkg.com/

To include an external library, click on Resources when editing an extension and paste the CDN URL (e.g., https://cdnjs.cloudflare.com/ajax/libs/axios/0.21.1/axios.min.js) of the external library. Click on the button Save to persist the changes for the extension.

Add external library

Refer to Generate random data library extension for an example of usage of an external library.

# Using your extension

In a checkpoint that you want to use your new script, you can write EXECUTE "extensionName" to trigger the script execution. If the script name is not valid, you will get an error.

You can even call them using their name alone, for example:

performSomeAction
GET("https://api.com/v1") returning $data

# Example scripts

In this section we look at number of common scripts you may want to write.

# Click an element using javascript

document.querySelector('.my-selector > .foo').click()

# Remove an element by query selector

document.querySelector('.my-selector > .foo').remove()

# Remove an element by XPATH

function getElementByXpath(path) {
  return document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
}

getElementByXpath('/html/body/div/div[1]/input').remove()

# Remove multiple elements by query selector

[...document.querySelectorAll('.element-foo, #barID, input[title="foo"]')].map(node => node.remove())

# Validating native input messages

Sometimes, you may want to validate messages produced via native browser features, such as the native form validation on an <input type="email" required> field. Since these validations are produced by native browser features rather than by the target website, assertion commands cannot be used to validate these messages. You can however use Javascript to validate them. For example:

var element = document.querySelector(elementSelector)

var badValidation = null
var passedValidation = 0
function validationHandler(e) {

   if (e.target.validationMessage !== expectedMessage) {
       badValidation = 'Expected validation message to be: \`' +
       expectedMessage + '\`, but it was: ' + e.target.validationMessage
   } else {
       passedValidation++
   }
}

element.addEventListener('invalid', validationHandler)

var isValid = element.form.reportValidity()

if (isValid) {
   doneError('Form was valid, but it was expected to be invalid')
   return
}

setTimeout(() => {
   if (passedValidation && !badValidation) {
       done()
       return
   }
   if (badValidation) {
       doneError(badValidation)
       return
   }
   doneError('Element was not validated')
}, 200)

This also needs the corresponding test steps to use the script:

Write "foo" in field "bar"

Store value "The value entered for 'bar' is invalid. 'foo' is less than five characters." in $expectedMessage

Store value "#barCssIdentifier" in $elementSelector

Execute validateFieldError

# Make GET request async

fetch("https://www.spotqa.com/api/user/foo", {"method":"GET","mode":"no-cors"})
.then(response => {
  // you can do something with the response data if you wish to (otherwise the .then() is not needed)
  done()
})

Async request

This is an asynchronous request. Note that done() needs to be called or an error needs to be thrown. Otherwise the test step will not resolve.

# Make POST request async

fetch("https://www.spotqa.com/api/project", {
  method: 'POST',
  body: JSON.stringify({foo: 'bar', spot: 'QA'}), // data can be `string` or {object}
  headers:{
    'Content-Type': 'application/json'
  }
}).then(() => done(), e => { throw e })

Async request

This is an asynchronous request. Note that done() needs to be called or an error needs to be thrown. Otherwise the test step will not resolve.

# Using Virtuoso's variables

Consider an example scenario where you'd want to take as input 3 arguments quantity, price and priceWithTax, such that you calculate the total value using quantity and price, and make sure that the total value does not exceed the price with tax. You may also want to use the total value (output of your script) into a variable, so that you can check it in following pages.

To do this, you need to create three input arguments in the script manager quantity, price and priceWithTax. Note that at the time of execution, these variables need to be available -- either through previous steps in the journey, or mapped as input arguments, e.g., using 42 as quantity, $value as price, $total as priceWithTax. Otherwise, an empty string will be used as the default value for any unmapped argument.

Variables in script input

Although variables are denoted with $ in the natural language syntax, when using them in the script editor and the input section only the name of the variable shall be used (i.e., value and not $value).

var totalValue = quantity * price

if (totalValue > priceWithTax) {
    throw new Error(`Total value ${totalValue} cannot be larger than total with tax which was ${priceWithTax}`)
}

return totalValue

# Opening a PDF File using scripts

Scripts are very powerful and can perform complex functionality. An example of a complex script is one which opens a PDF file in the web browser, so that you can see its content and perform assertions.

If the PDF file is not available as a public link, the first step is to download the PDF file in a test step; for example by clicking on it:

Click on "Download report"

If this link downloads a file, then the link to downloaded file stored in Virtuoso can be accessed in the new script variable LAST_DOWNLOADED_FILE

Download file in new tab

As a limitation of our PDF download feature, PDF files opened in a new tab will cause the test step to time out. If possible, trying downloading the file in the current tab (e.g., by removing target="_blank" of a link) or explicitly mark the link as a download (e.g., by appending download='somepdf.pdf' to the anchor tag).

For rendering the PDF file, we will use the open source library PDF.js. To render the PDF file, create a new script called showPdf and toggle the script to "Run asynchronously".

The script we will use has a single input called file, so add a new input to the script called file. Now, use the following script to render the PDF file:

async function showPdf (file) {
  let script = document.createElement('script')
  script.src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.2.228/pdf.min.js"
  script.crossOrigin='anonymous'
  document.head.appendChild(script)
  script = document.createElement('script')
  script.src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.2.228/pdf.worker.min.js"
  script.crossOrigin='anonymous'

  document.head.appendChild(script)

  await new Promise(resolve => {
    setTimeout(resolve, 1000)
  });

  document.body.style = "background: grey; padding:5px;"
  document.body.innerHTML = '<div id="pageContainer"></div>'
  var PDF_PATH = file;
  var SVG_NS = 'http://www.w3.org/2000/svg';
  document.title = PDF_PATH


  function buildSVG(viewport, textContent) {
    var svg = document.createElementNS(SVG_NS, 'svg:svg');
    svg.setAttribute('width', viewport.width + 'px');
    svg.setAttribute('height', viewport.height + 'px');
    svg.setAttribute('font-size', 1);

    textContent.items.forEach(function (textItem) {

      if (textItem.str.trim().length === 0){
        return
      }

      var tx = pdfjsLib.Util.transform(
          pdfjsLib.Util.transform(viewport.transform, textItem.transform),
          [1, 0, 0, -1, 0, 0]);
      var style = textContent.styles[textItem.fontName];
      // adding text element
      var text = document.createElementNS(SVG_NS, 'svg:text');
      text.setAttribute('transform', 'matrix(' + tx.join(' ') + ')');
      text.setAttribute('font-family', style.fontFamily);
      text.setAttribute('fill-opacity', 0);
      text.textContent = textItem.str;
      svg.appendChild(text);
    });
    return svg;
  }

  var loadingTask = pdfjsLib.getDocument({url: PDF_PATH,});
  loadingTask.promise.then(function (pdfDocument) {
    for (let i = 0; i < pdfDocument.numPages; i++) {
      const div = document.createElement("div")
      div.id = "page-" + i
      div.style = "border: 1px solid black; background: white; margin-bottom: 3px; relative"
      document.getElementById('pageContainer').appendChild(div);
      pdfDocument.getPage(i + 1).then(function (page) {
        const normalViewport = page.getViewport(1.0)
        const viewport = page.getViewport({ scale: window.innerWidth / normalViewport.width });

        const scale = [
          viewport.width / normalViewport.width,
          viewport.height / normalViewport.height
        ]

        page.getTextContent().then(function (textContent) {
          const parentDiv = document.getElementById(div.id)
          parentDiv.style.height = `${viewport.height}px`
          parentDiv.style.width = `${viewport.width}px`

          const pageDiv = document.createElement("div")
          pageDiv.style = "position: relative; height: 100%;";

          parentDiv.appendChild(pageDiv)

          const canvas = document.createElement('canvas')
          canvas.style = "width: 100%; height: 100%; position: absolute; top: 0px; left: 0px; background: white;"
          canvas.height = viewport.height
          canvas.width = viewport.width
          pageDiv.appendChild(canvas)
          page.render({
            canvasContext: canvas.getContext('2d'),
            viewport: viewport
          })

          var svg = buildSVG(viewport, textContent);
          svg.style = "opacity: 1; width: 100%; height: 100%; position: absolute; top: 0px; left: 0px; z-index: 200"
          pageDiv.appendChild(svg);
        }).then(() => {
          page.getAnnotations().then(function (annotations) {
            const pageDiv = document.getElementById(div.id).lastChild
            annotations.forEach(annotation => {

              const link = document.createElement("a")
              link.href = annotation.url
              const button = document.createElement("button")
              const rect = annotation.rect
              link.style = `display: block; position: absolute; left: ${rect[0]*scale[0]}px; z-index:400;
            bottom: ${rect[1]*scale[1]}px;`
              button.style = `opacity: 0; width: ${(rect[2]-rect[0])*scale[0]}px;
              height: ${(rect[3]-rect[1])*scale[1]}px;`
              button.innerText = ""

              link.appendChild(button)
              pageDiv.appendChild(link);
            })
          })
        });
      });
    }
  }).then(done()).catch(error => {
    console.error(error.details)
    doneError(error)
  })
}

try {
  showPdf(file)
} catch (error){
  console.error(error.details)
  doneError(error)
}

Now resume editing the test steps. After the test step which sets LAST_DOWNLOADED_FILE after downloading the PDF file, create a new test step. Firstly, to avoid cross origin request denials, we need to navigate to a web page on the same domain as the stored PDF file. If using the LAST_DOWNLOADED_FILE variable, we have a HTML page setup for this: https://s3-eu-west-1.amazonaws.com/virtuoso-downloaded-files/test.html Add the test step to navigate here as follows (append in a new tab here to open the PDF in a new tab):

  • Navigate to "https://s3-eu-west-1.amazonaws.com/virtuoso-downloaded-files/test.html"; or
  • Navigate to "https://www.spotqa.com" in a new tab (e.g., for opening a PDF file located at http://www.spotqa.com/some_file.pdf)

Now, we can load the PDF file using the script we created earlier:

Execute showPdf using $LAST_DOWNLOADED_FILE as file

This will open the PDF file in the browser and other test steps can be executed to assert its contents, for example:

See "Printed Report..."

# Summary for Rendering PDF File:

To open / test a PDF file, the following steps are required:

  1. (optional) A test step which downloads the PDF file being rendered, that populates the LAST_DOWNLOADED_FILE variable;
  2. Navigation to the same host as the stored PDF file to avoid JavaScript cross origin restrictions;
  3. An asynchronous script that imports the PDF.js library and renders the contents of the PDF file to the current page;
  4. Execution of the script using the link to the PDF file (e.g., the LAST_DOWNLOADED_FILE variable) as input.
Last Updated: 3/9/2021, 10:43:33 AM