Working with Translations in Dynamics 365 Business Central

Intro

Languages: what an almighty headache. Computerphile have a great video that describes just how big the problem is: https://www.youtube.com/watch?v=0j74jcxSunY

Perhaps my perception is skewed by my ignorant native-English-speaker point of view. I haven’t grown up in a country where learning multiple languages and being able to switch between them is essential. Sure, I wish* I could speak more languages but mostly I can get by assuming other people will speak English.

*wish as in “finding a magic lamp” not as in “actually having the patience to put in the effort required to learn and practise”

However, if you are publishing apps into AppSource or any other setting where you plan on supporting different countries you are going to need to deal with translations at some point.

Overview

Visual Studio Code will create a .xlf (xliff) file containing all the literal strings that you have used in your application. They will mostly come from Caption, Tooltip and Label properties. This file will, therefore, contain your English (US) captions – assuming that you are coding in US English.

We need additional .xlf files for each translation that we want to support. The files should be in the format [language]-[COUNTRY].xlf e.g. en-GB.xlf, fr-FR.xlf, de-NL.xlf

Although xliff is a standard for software translations, after scouring the internet for hours I couldn’t find a tool that did what I wanted. It seems I’m not alone, judging by the number of people that have started to write their own tooling:

(I haven’t tried these solutions myself, I’m just aware of them). So what do I want? At least the following:

  • Somewhere to maintain a list of languages and countries that my app needs to support
  • Creation of new .xlf files for each language/country combination
  • Keeping the translation units (the strings present in the app that need translating) in sync between the master .xlf file and each translation file
  • Support for submitting strings to machine translation and feeding the results back into each translation file

See also https://community.dynamics.com/business/b/businesscentraldevitpro/posts/translate-your-extension-automatically-with-azure-translator-text which describes a similar approach to ours in a Visual Studio Code extension.

My preference is to build some support for translations into our PowerShell module. The main reason is so that we can use the functions in our build process.

Translations to Maintain

I’ve written before about us having an environment.json file which holds settings about the repository which we use in the build. This seemed like a sensible place to also keep our list of translations. It looks like this:

{
  "translationSource": ".\\Translations\\Hello World.g.xlf",
  "translations": [
    {"country": "FR", "language": "fr"},
    {"country": "BE", "language": "nl"},
    {"country": "DE", "language": "de"},
    {"country": "GB", "language": "en"}
  ]
}

The translationSource key holds the path to the main .xlf file that is updated by the compiler and translations is an array of country/language pairs that are required.

Translator Text in Azure Cognitive Services

You’ve got some choice when it comes to online translation services. We use the Translator Text service that is part of Azure Cognitive Services. We’re already using a bunch of Azure services so it makes sense to keep them in one place. It has a REST API that we can call with the strings to translate and the language to translate them into. Perfect. But, first we’ll need an API key to authenticate with the service.

  • Log in to https://azure.portal.com to manage your Azure resources
  • Use to search bar to find “Cognitive Services”
  • Click to Add
  • In the Marketplace search for “Translator Text” and click Create
  • Give the service a name
  • Select an Azure subscription to link it to – you can either grab a free trial or create a paid subscription. I’ve created a Pay-As-You-Go subscription. You need credit card details but we’re going to use the free pricing tier for now anyway
  • Select a pricing tier (see https://azure.microsoft.com/en-us/pricing/details/cognitive-services/translator-text-api/) or just select F0 for the free tier
  • Select or create a new resource group to hold your new service
  • Open your new resource from the list of Cognitive Services and click on Keys (left hand navigation menu)
  • You can now use either of the two keys that you’ve got to call the service

PowerShell

We’ve got two key functions in our PowerShell module:

  • Translate-App – this is the entry point to call other functions which:
    • Find the source .xlf file and the environment.json file
    • Create any new .xlf files that are required (by copying the source file and changing the target language)
    • Synchronise the translation units between the source and the target files – add any new strings that require translation and remove any strings that are no longer present in the source file
    • Identify strings that require translation and call the Translator Text service to translate them into the target language
    • Populate the target .xlf file with the translated strings
  • Test-TranslationIsComplete – we use this as part of our build process to verify that
    • All of the required translation files exist
    • Each of those files has all the translation units that are present in the source .xlf file
    • It will throw an error if either of those things is false otherwise it will return true

This is the code (hosted here if you can’t see it: https://gist.github.com/jimmymcp/41bd8d3ac3fd6aa742089029fcd990fb)

A few notes about it:

  • I’ve just lifted it from the PowerShell module so it won’t work as is
    • You’ll need to remove the Export-ModuleMember lines
    • Line 173 in Translate-App.ps1 makes a call to a function I haven’t given you to read the API key for the Translator Text service. The module creates a json config file with keys for various settings and this is one of them
  • The free tier of the Translator Text service is throttled. You’ll probably hit the limit if you’ve got more than a few hundred strings to translate into several languages – you just need to wait for a few minutes and run the function again (or choose a paid tier)
function Test-TranslationIsComplete {
param (
# the directory with the source code of the app to be translated
[Parameter(Mandatory=$false)]
[string]
$SourceDir = (Get-Location),
# whether to surpress the error
[Parameter(Mandatory=$false)]
[switch]
$SurpressError
)
$EnvJson = ConvertFrom-Json (Get-Content (Join-Path $SourceDir 'environment.json') -Raw)
if ($TranslationSource -eq '' -or $null -eq $TranslationSource) {
$TranslationSource = $EnvJson.translationSource
}
if (!(Test-Path $TranslationSource)) {
if ($SurpressError.IsPresent) {
return $false
}
else {
throw 'Could not find translation source file.'
}
}
foreach ($Translation in $EnvJson.translations) {
$TranslationPath = Join-Path 'Translations' ('{0}-{1}.xlf' -f $Translation.language, $Translation.country)
if (!(Test-Path $TranslationPath)) {
if ($SurpressError.IsPresent) {
return $false
}
else {
throw ('Translation file {0}-{1} is missing' -f $Translation.language, $Translation.country)
}
}
else {
Sync-TranslationUnits -SourcePath $TranslationSource -OutputPath (Join-Path (Get-Location) $TranslationPath) | Out-Null
if ($null -ne (Get-StringsToTranslate -SourcePath $TranslationPath)) {
if ($SurpressError.IsPresent) {
return $false
}
else {
throw ('Translation file {0}-{1} has missing translations' -f $Translation.language, $Translation.country)
}
}
}
}
return $true
}
Export-ModuleMember -Function Test-TranslationIsComplete
function Translate-App {
Param(
[Parameter(Mandatory=$false)]
[string]$TranslationSource,
[Parameter(Mandatory=$false)]
[string]$SourceDir = (Get-Location)
)
$EnvJson = ConvertFrom-Json (Get-Content (Join-Path $SourceDir 'environment.json') -Raw)
if ($TranslationSource -eq '' -or $null -eq $TranslationSource) {
$TranslationSource = $EnvJson.translationSource
}
if (!(Test-Path $TranslationSource)) {
throw 'Could not find translation source file.'
}
Write-Host "Using translation source file $TranslationSource"
foreach ($Translation in $EnvJson.translations) {
Write-Host ('Translating to {0}-{1}' -f $Translation.language, $Translation.country)
Translate-XlfFile -SourcePath $TranslationSource -TargetCountry $Translation.country -TargetLanguage $Translation.language
}
}
function Translate-XlfFile {
Param(
[Parameter(Mandatory=$true)]
[string]$SourcePath,
[Parameter(Mandatory=$false)]
[string]$OutputPath,
[Parameter(Mandatory=$false)]
[string]$TargetLanguage,
[Parameter(Mandatory=$false)]
[string]$TargetCountry
)
if ($OutputPath -eq '' -or $null -eq $OutputPath) {
$OutputPath = (Join-Path (Split-Path $SourcePath -Parent) ($TargetLanguage.ToLower() + "-" + $TargetCountry.ToUpper())) + ".xlf"
}
#create xlf file if it doesn't already exist
if (!(Test-Path $OutputPath)) {
cpi $SourcePath $OutputPath
[xml]$OutputXml = Get-Content $OutputPath
$TargetLanguageAtt = $OutputXml.CreateAttribute('target-language')
$TargetLanguageAtt.Value = '{0}-{1}' -f $TargetLanguage.ToLower(), $TargetCountry.ToUpper()
$OutputXml.xliff.file.Attributes.SetNamedItem($TargetLanguageAtt)
$OutputXml.Save($OutputPath)
}
#add any translation units that are present in the source but not in the output
Sync-TranslationUnits $SourcePath $OutputPath
$StringsToTranslate = Get-StringsToTranslate -SourcePath $OutputPath
if ($null -eq $StringsToTranslate) {
Write-Host 'Already up to date'
}
while ($null -ne $StringsToTranslate) {
$Strings = @()
$StringsToTranslate | ForEach-Object {$Strings += $_.Source}
$TranslatedStrings = Translate-Strings -Strings $Strings -TargetLanguage $TargetLanguage
Write-TranslatedStrings -OutputPath $OutputPath -StringsToTranslate $StringsToTranslate -TranslatedStrings $TranslatedStrings
$StringsToTranslate = Get-StringsToTranslate -SourcePath $OutputPath
}
}
function Sync-TranslationUnits {
Param(
[Parameter(Mandatory=$true)]
$SourcePath,
[Parameter(Mandatory=$true)]
$OutputPath
)
[bool]$SaveFile = $false
[xml]$SourceXml = Get-Content $SourcePath
[xml]$OutputXml = Get-Content $OutputPath
[System.Xml.XmlNamespaceManager]$NSMgr = [System.Xml.XmlNameSpaceManager]::new($OutputXml.NameTable)
$NSMgr.AddNamespace('x',$SourceXml.DocumentElement.NamespaceURI)
#add missing sources to the output file
foreach ($SourceTUnit in $SourceXml.SelectNodes('/x:xliff/x:file/x:body/x:group/x:trans-unit',$NSMgr)) {
$OutputTUnit = $OutputXml.SelectSingleNode(("/x:xliff/x:file/x:body/x:group/x:trans-unit[@id='{0}']" -f $SourceTUnit.Attributes.GetNamedItem('id')."#text"),$NSMgr)
if ($null -eq $OutputTUnit) {
$OutputXml.xliff.file.body.group.AppendChild($OutputXml.ImportNode($SourceTUnit,$true))
$SaveFile = $true
}
elseif ($OutputTUnit.source -ne $SourceTUnit.source) {
$OutputTUnit.source = $SourceTUnit.source
$OutputTUnit.RemoveChild($OutputTUnit.SelectSingleNode('./x:target',$NSMgr))
$SaveFile = $true
}
}
#remove orphaned sources from the output
foreach ($OutputTUnit in $OutputXml.SelectNodes('/x:xliff/x:file/x:body/x:group/x:trans-unit',$NSMgr)) {
$SourceTUnit = $SourceXml.SelectSingleNode(("/x:xliff/x:file/x:body/x:group/x:trans-unit[@id='{0}']" -f $OutputTUnit.Attributes.GetNamedItem('id')."#text"),$NSMgr)
if ($null -eq $SourceTUnit) {
$OutputXml.xliff.file.body.group.RemoveChild($OutputTUnit)
$SaveFile = $true
}
}
if ($SaveFile) {
$OutputXml.Save($OutputPath)
}
}
function Get-StringsToTranslate {
Param(
[Parameter(Mandatory=$true)]
[string]$SourcePath,
[Parameter(Mandatory=$false)]
[int]$Top = 100
)
$StringsToTranslate = @()
$ElementNo = 1
[xml]$SourceXml = gc $SourcePath
foreach ($TUnit in $SourceXml.xliff.file.body.group.'trans-unit') {
if ($TUnit.target -eq $null) {
$StringToTranslate = New-Object System.Object
$StringToTranslate | Add-Member -MemberType NoteProperty -Name ID -Value $TUnit.id
$StringToTranslate | Add-Member -MemberType NoteProperty -Name Source -Value $TUnit.source
$StringToTranslate | Add-Member -MemberType NoteProperty -Name Target -Value ''
$StringsToTranslate += $StringToTranslate
$ElementNo++
if ($ElementNo -gt $Top) {
break
}
}
}
$StringsToTranslate
}
function Translate-Strings {
Param(
[Parameter(Mandatory=$true)]
[string[]]$Strings,
[Parameter(Mandatory=$true)]
$TargetLanguage
)
#don't send English strings for translation
if ($TargetLanguage -eq 'en') {
$Translations = $Strings
return $Translations
}
#convert input object into json request
$Request = '['
foreach ($String in $Strings) {
$String = $String.Replace('\','\\')
$String = $String.Replace('"','\"')
$Request += '{Text:"' + $String + '"},'
}
$Request = $Request.Substring(0,$Request.Length – 1)
$Request += ']'
$Key = Get-TFSConfigKeyValue 'translationkey'
$Headers = @{'Ocp-Apim-Subscription-Key'=$Key}
$Headers.Add('Content-Type','application/json')
$Response = Invoke-WebRequest ('https://api.cognitive.microsofttranslator.com/translate?api-version=3.0&to={0}' -f $TargetLanguage) -Headers $Headers -Method Post -Body $Request
$Translations = @()
(ConvertFrom-Json $Response.Content).translations | % {
$Text = $_.text
$Text = $Text.Replace('% 10',' %10')
$Text = $Text.Replace('% 1',' %1')
$Text = $Text.Replace('% 2',' %2')
$Text = $Text.Replace('% 3',' %3')
$Text = $Text.Replace('% 4',' %4')
$Text = $Text.Replace('% 5',' %5')
$Text = $Text.Replace('% 6',' %6')
$Text = $Text.Replace('% 7',' %7')
$Text = $Text.Replace('% 8',' %8')
$Text = $Text.Replace('% 9',' %9')
$Translations += $Text
}
$Translations
}
function Write-TranslatedStrings {
Param(
[Parameter(Mandatory=$true)]
[string]$OutputPath,
[Parameter(Mandatory=$true)]
$StringsToTranslate,
[Parameter(Mandatory=$true)]
[string[]]$TranslatedStrings
)
[xml]$OutputXml = Get-Content $OutputPath
$ElementNo = 0
foreach ($StringToTranslate in $StringsToTranslate) {
$TUnit = $OutputXml.xliff.file.body.group.'trans-unit' | ? ID -eq $StringToTranslate.ID
$TargetNode = $OutputXml.CreateElement('target',$OutputXml.DocumentElement.NamespaceURI)
$TargetNode.InnerText = $TranslatedStrings.Get($ElementNo)
$SourceNode = $TUnit.FirstChild.NextSibling
$TUnit.InsertAfter($TargetNode,$SourceNode)
$ElementNo++
}
$OutputXml.Save($OutputPath)
}
Export-ModuleMember -Function Translate-XlfFile
Export-ModuleMember -Function Translate-App
Export-ModuleMember -Function Get-StringsToTranslate
Export-ModuleMember -Function Sync-TranslationUnits

Of course, being an English-only speaker I don’t have any way of checking how good these translations are but at least it gives a starting point for a human to verify.

Building Microsoft Dynamics 365 Business Central Apps on Azure DevOps Hosted Agents

This is a quick follow up to this post. If you want an intro to building AL apps for Business Central you might want to check that out first.

In order to build your apps you need a build agent running somewhere which will listen for new jobs and run the scripts, create the Docker containers, run the tests or do whatever else you define in the build file.

You can install an agent on your own server somewhere and authenticate with a personal access token. You’re in charge of the hardware, install agents and scale the performance as you see fit.

Hosting

The alternative is to choose one of the hosted agents that Microsoft provide. The obvious attraction is that you don’t need to maintain any hardware. You just specify the type of machine (Ubuntu, Mac, Windows) that you want the job to run on and pay-as-you-go. Or possibly, don’t pay at all.

With the free tier of Azure DevOps you get:

  • One build job running at a time (other jobs will be queued until that has finished)
  • 1,800 minutes of build time per month

That’s cool. You can keep tabs on your usage and purchase more parallel jobs from here: https://dev.azure.com/<your organisation>/settings/buildqueue?_a=concurrentJobs

If you hit the limits of the free tier you can check out the cost of more jobs here: https://azure.microsoft.com/en-us/pricing/details/devops/azure-devops-services/. At the time of writing 40 USD gets you a second concurrent job and lifts the build minutes per month restriction to unlimited.

Self-Hosting

So…why would you not run on hosted agents? Cost is a consideration. Additional parallel jobs on self-hosted agents are only 15 USD per month. But, what’s 25 dollars per month between friends? That’s assuming you can’t live within the limits of the free tier. If you can then using hosted agents is free.

The main consideration as far as I can see is performance. If you are going to create a Docker container as part of your build (and if you aren’t then I’m not sure what you’re doing) then self-hosted agents are always going to have an advantage. You can have the right Docker image ready downloaded before the build begins but a hosted agent will always needs to download it first.

Our builds, running on a self-hosted agent, typically take between 8 and 15 minutes to complete, depending on how many tests are included in the build. Using the “Hosted Windows 2019 with VS2019” agent pool a test build (which just creates the downloads the Docker image and creates the container) takes around 18 minutes – pulling the latest production sandbox image.

NavContainerHelper is version 0.6.2.3
Host is Microsoft Windows Server 2019 Datacenter - ltsc2019
Docker Client Version is 18.09.6
Docker Server Version is 18.09.6
Pulling image mcr.microsoft.com/businesscentral/sandbox:latest-ltsc2019
latest-ltsc2019: Pulling from businesscentral/sandbox

Add in some time to actually build and publish the app, run the tests and upload the results and we’re probably looking closer to 25 minutes for the whole thing.

I’ll leave it up to you to decide whether you care enough about that performance difference to host build agents yourself. Then again, 1800 / 25 = 72 builds per month before you need to consider paying for more. Maybe that’s all you need? Especially if you’re just getting started with Azure DevOps, builds, YAML and all that jazz…

Building Microsoft Dynamics 365 Business Central Apps with Azure DevOps

Last time out we were discussing defining your build pipeline in a YAML file. That post was an intro to what pipelines are and the benefits of defining the tasks that it runs in a YAML file alongside your other source code. Now we’ll turn our attention to some Business Central specific considerations.

Objectives

We’re start by defining the key objectives of the build process:

  1. Download a particular version of the source AL code
  2. Create an appropriate Docker container to publish the app into and run the tests against (see below for more about what “appropriate” means in this context)
  3. Acquire the alc.exe compiler from the container and use it to compile the AL code into two apps (the main app and a dependent app that contains the tests)
  4. Acquire and install any necessary dependencies, install the main app and test app (see here)
  5. Execute the tests and export the results
  6. Upload test results, main app and test app to the build
  7. Remove the Docker container

Environment

Microsoft-hosted or self-hosted?

Microsoft give you a menu of different hosted build agents to execute your pipelines on and 1,800 minutes of build time per month for free. The obvious attraction of this option is not having to build and maintain your own infrastructure to run builds and you just pay for the time you use (assuming you exceed the free limit). The obvious downside is that you can’t prepare that environment as you’d like e.g. Docker images must be downloaded each time as part of the job. I can’t comment too much on this option as it isn’t something we’ve experimented with so far.

We host our own server that runs several build agents. The main driver for the decision at the time was that it allowed us to persist Docker images between builds (NAV images are approx. 15GB, although BC images are smaller) and save a substantial amount of time on each build.

Azure DevOps Build Agents

With smaller Docker images these days it ought to be increasingly feasible to run BC builds in a sensible amount of time.

Installing and Connecting the Build Agent

From the list of build agents (at https://dev.azure.com/<your organisation>/_settings/agentpools) you’ll see the link to Download the agent. Simply download and extract onto your build server. Run config.cmd and follow the instructions to connect the agent to your DevOps organisation.

You’ll need a Personal Access Token to authenticate. See here if you need a refresher on how to create those.

Triggers

The majority of our builds are triggered by some new code being pushed to server branch i.e. continuous integration builds. DevOps handles downloading this version of the source code to the build agent to work on. This is defined by the trigger section of the .azue-pipelines.yml file:

trigger:
  branches:
    include:
      - "*"

We are also starting to schedule more builds. This is useful for building our apps against insider builds of Business Central. Which brings me onto how we define the Docker image that we are going to build against.

Environment.json

Our apps include a json file that defines some parameters that are used by the build.

  • The Docker image to build against
  • The user name and password to create the new container with
  • Translations (country and language) that must be present in the app
  • Details of the Azure DevOps project/repo to acquire dependencies listed in app.json from (as described here)

Which Docker Image?

As a rule I develop and test against sandbox images (mcr.microsoft.com/businesscentral/sandbox). They are the closest thing to testing on SaaS that you can get without actually having a SaaS tenant. We always develop against the worldwide (W1) image and build against all of the localisations that we are planning to support.

The sandbox image has very little data in it, which is great for downloading new versions of the image and creating new containers but does mean that you have to handle more of the data setup in your tests than you would for an on-prem image. Yes, tests should be data-agnostic and run in an empty company but we still need to work around some bugs in standard library functions.

Branch per Docker Image

This approach allows us to have a separate branch for each different Docker image that we want to build our app against. We have country/xyz branches where “xyz” is the Docker tag for the localisation that we need to support i.e. country/es, country/ca, country/nz

At any moment these branches should be a single commit ahead of the feature branch we are working on, the only difference being the Docker image that is used. We can then rebase these branches on top of whichever commit we want to build. When we push those branches to the server continuous integration builds will be kicked off for each country.

PowerShell Tasks

It won’t come as much of a surprise that the majority of tasks performed by the build are PowerShell scripts. You’ve got some different options for defining these scripts:

  1. Define them in .ps1 files alongside your source code
  2. Define them in .ps1 files that are saved on the build server (assuming that you are self-hosting the agent)
  3. Maintain the scripts somewhere else and share them with the build server

We started with #2 and have recently moved onto #3. All our scripts are now bundled into a PowerShell module which is published on the PowerShell Gallery. The module is installed and updated on the build server. Maybe I’ll post some more about our approach to PowerShell development, our build process for it and testing with Pester another time.

We use inline PowerShell tasks to import our module and run a command on the source like this:

steps:
- task: PowerShell@1
  displayName: 'Create packages and execute tests'
  inputs:
    scriptType: inlineScript
    inlineScript: 'Import-Module Tecman.Tfs.Tools;Run-ALBuildProcess ''$(Build.SourcesDirectory)'' ''$(Build.ArtifactsStagingDirectory)'' $(Build.BuildID) $true'

Compiling the App

Acquiring the Compiler

If you’ve read the output from the creation of a new Docker container then you’ve probably noticed that the corresponding version of the Visual Studio Code extension is included with the container. It is hosted at http://<containername&gt;:8080/<name of vsix file>. You can get the precise URL to the file by inspecting the logs with docker logs <container name>.

Use PowerShell’s Download-File function to download the vsix to a local file. The .vsix file, like a .app file, is a archive file containing the source of the extension. You can use Expand-Archive on the file to extract the contents of the .vsix to a local folder and find alc.exe in the extracted files. You’ll need to rename the file to .zip first to convince Expand-Archive that it is a format it can expand.

function Get-CompilerFromContainer
{
    Param(
        [Parameter(Mandatory=$true)]
        [string]$ContainerName
    )

    $VsixPath = Get-VSCodeExtensionFromContainer -ContainerName $ContainerName
    if (!(Test-path "$VsixPath\Extract")){
        Rename-Item $VsixPath "$VsixPath.zip"
        Create-EmptyDirectory "$VsixPath\Extract"
        Expand-Archive -Path "$VsixPath.zip" -DestinationPath "$VsixPath\Extract"
    }
    
    "$VsixPath\Extract\extension\bin\alc.exe"
}

function Get-VSCodeExtensionFromContainer {
    Param(
        [Parameter(Mandatory=$false)]
        [string]$ContainerName = (Get-ContainerFromLaunchJson)
    )

    $Logs = docker logs $ContainerName
    $VsixUrl = $Logs.item($Logs.indexOf('Files:') + 1)
    $VsixName = (Split-Path $VsixUrl -Leaf).TrimEnd('.vsix')
    $VsixPath = Join-Path (Split-Path (Get-TFSConfigPath) -Parent) $VsixName
    $VsixFile = (Join-Path -Path $VsixPath -ChildPath $VsixName) + '.vsix'
    if (!(Test-Path $VsixPath)){
        New-Item -Path $VsixPath -ItemType Directory
        Download-File -sourceUrl $VsixUrl -destinationFile $VsixFile
    }

    $VsixFile
} 

The above includes some code to save the extracted .vsix files into the AppData folder on the build server to save us downloading and extracting a version of the VS Code extensions that we’ve already got. Over time the .vsix file has grown in size and we can save ourselves some time and disk space by reusing the copy that we’ve already extracted.

Compiling

Having got your hands on the right version of alc.exe you can run it with something like the below:

Start-Process -FilePath $CompilerPath -ArgumentList (('/project:"{0}"' -f $SourcePath),('/packagecachepath:"{0}"' -f (Join-Path $SourcePath '.alpackages')),('/assemblyProbingPaths:"{0}"' -f (Join-Path $SourcePath '.netpackages'))) -Wait

Assuming the app builds successfully you’ll see a .app file in the root of the source directory. You can now grab that app file and publish it into the container using the navcontainerhelper module.

Testing and Uploading the Results

Having created a container, got the VS Code extension and published the app (with any dependencies) it’s time to run the tests. I’ve been writing about using navcontainerhelper to execute the tests in the container quite a lot lately so I won’t go into all that again.

Suffice to say that we use navcontainerhelper to execute the tests and export the results to XUnit format. We then use the “Publish test results” task to upload those results to the build on Azure DevOps.

- task: PowerShell@1
  displayName: 'Error on test failure'
  inputs:
    scriptType: inlineScript
    inlineScript: 'Import-Module Tecman.Tfs.Tools;Error-OnTestFailure $(Build.BuildID)'

You might notice Error-OnTestFailure in that inlineScript. The purpose of that is to throw an error if any of the tests fail otherwise the build will be reported as successful, even with failed tests. I suspect setting the AzureDevOps parameter on the Run-TestsInNavContainer function is the better way to do this now though.

Uploading the App(s)

If the tests have run successfully then we can upload the app files to the build artefacts. Simply copy the app files into the artefacts directory – defined by the $(Build.ArtifactsStagingDirectory) variable and run the Publish Build Artifacts task.

- task: PublishBuildArtifacts@1
  displayName: 'Publish App Package'
  inputs:
    ArtifactName: 'App Package' 

Removing the Docker Container

Finally we’re going to remove the Docker container with a inline PowerShell script. Notice the condition property that is attached to this task. In this case we’re just defining that the task should always be run – even if an earlier task has failed. It is possible to get smarter with conditions e.g. only running certain tasks if the build has been triggered in a certain way, or from a particular branch.

- task: PowerShell@1
  displayName: 'Remove Docker build container'
  inputs:
    scriptType: inlineScript
    inlineScript: 'Import-Module Tecman.Tfs.Tools;Remove-ALBuildContainer $(Build.BuildID)'

  condition: always() 

Writing Your Own YAML Pipeline

If you’re reading this post and wondering how on earth you are supposed to know what to type into your blank .azure-pipelines.yml file then remember that the Azure Pipelines extension for VS Code give you intellisense. Just create the file and hit Ctrl+Space to see the what’s what.

Conclusion

This post has been a bit of mixed bag, a rummage through our build pipeline toolkit, but hopefully some of it has been useful. As ever, the best way to learn is to get stuck in and try it out for yourself.

Working with Azure DevOps Pipelines in YAML

Overview

This post is an update to a post I made about YAML pipelines here. We’ll also take the opportunity to discuss why you might want to define a pipeline with YAML.

Wait…What?

What the heck are we talking about? (skip this bit if you do know what we’re talking about) A pipeline defines a series of tasks, running on defined environments that are performed with your code. In Azure DevOps they come in two flavours:

  • Build – for us that means, taking our AL source code, splitting it into two (test app and production app), compiling them, signing them, publishing and installing into a new container and running the tests and saving the .app files as artefacts of the build
  • Release – taking the built software and deploying it into one or more test and/or production environments – we don’t currently use release pipelines

Pipeline as Code, Why?

Defining the steps involved in your pipeline in a YAML file is sometimes called “pipeline as code” because the YAML file is checked-in to your repository alongside your source code.

The benefit is that your pipeline is version controlled. You can view its history, compare versions, blame/annotate etc. You could also have different versions of your pipeline in different branches and include it in a pull request.

The downside is of having yet another markup language to learn. What are you supposed to put in this file anyway?

Defining the Pipeline

Let’s consider two ways of creating and maintaining your pipeline file. I’m sticking to Visual Studio Code and Azure Repos/Pipelines in Azure DevOps as that’s what I’m familiar with. Loads of other options are available, loads of them supported in Azure DevOps.

In Azure DevOps

The features in Azure DevOps and the UI change frequently as they add new stuff. Microsoft announced loads of changes, including a new YAML editing experience (below) and YAML release pipelines, at Build 2019. You can browse through and watch sessions here: https://www.microsoft.com/en-us/build search for DevOps to jump to the sessions related to this post.

I’ve got a Hello World app with the AL code hosted in Azure Repos. Let’s walk through creating the pipeline file in the UI. Select Builds from the Pipelines menu and hit the “New pipeline” button.

Choose where you want this pipeline to fetch the source code from. In my case it’s in an Azure Repos Git repository.

And I’ve only got one in this project, so I’ll select that.

I don’t have an existing pipeline file, so I’ll create a starter pipeline.

And there it is.

Great…but what does all that mean?

Firstly, this is a pretty neat editor. It works a lot like Visual Studio Code. Maybe it even is Visual Studio Code behind the web page, for all I know. You can hover over different parts of the file and get tooltips about what they do. You also get intellisense when you hit Ctrl+Space giving you some info about the valid options for this part of the file.

Briefly, this pipeline will:

  • Trigger a build when changes are pushed to the master branch
  • Run the build on a hosted ubuntu agent (this is the “we love Linux, we love open-source” Microsoft after all)
  • Run a script to echo “Hello, World!”
  • Run another script to echo some more text

Let’s save and run the pipeline. I’ll commit straight to the master branch for now.

I’m bounced over to see the build that has been scheduled and can watch it run. This is the result:

You can click into each of the steps to see the logs for that step.

In Visual Studio Code

Notice that the file created above was automatically named .azure-pipelines.yml. That is the magic name that Azure DevOps will automatically recognise as defining a pipeline. That means if you create a file with that name and push it to Azure Repos it will automatically create a pipeline using that file as the definition for you.

When I flick back to Visual Studio Code I’ve got a commit waiting to be fetched into my local repo which was created when I saved the pipeline file. Now that I’ve got .azure-pipelines.yml locally I can edit it and source control it just like anything else.

To get the same editing experience as you had online you’re going to want to grab the Azure Pipelines extension for Visual Studio Code. That will recognise that the file is a pipeline definition and give you all the intellisense and more-info goodness you had in the browser.

Further Reading

For more information about what you can do with the yml file check out: https://aka.ms/yaml otherwise I’ll follow up with something more Business Central specific in another post.

Testing Your Microsoft Dynamics 365 Business Central Tests

Seeing as I’m on a bit of a run of posts about testing, let’s look at it from a slightly different angle.

Testing the Test

If we’re going to rely on automated tests to verify that our code (still) works then we need to have confidence that the tests themselves actually work.

Writing the Test First

This is why it is helpful to write and run the tests first. When you start developing a new feature or working on a bug fix you have identified some desired behaviour that the system doesn’t yet exhibit. Given this and this, when something or other then this is the behaviour I’m expecting.

Writing a test for that behaviour and seeing it fail confirms that the desired behaviour is missing. That gives you some confidence that you’re on the right lines – the system should do this, but doesn’t – yet.

When you write the bug fix or new feature and see the test pass it gives you much more confidence that your code actually works. You demonstrated beforehand that the desired behaviour was missing and that now it is there. Have a gold star.

Writing the Test Afterwards

You could write the test afterwards and we’ve done a lot of that as we’ve built up tests for our older code that didn’t have any. Whenever I write tests after the fact I do miss the initial stage of having an expected failing test though.

Not completing the given or the when can be a useful way to test the test. Asserting the expected results when you haven’t done all the required steps should normally cause the test to fail.

For example:

//[GIVEN] an item with my bespoke field populated
LibraryInventory.CreateItem(Item);
SomeBespokeValue := ...;

//leave these lines commented out initially to see the test fail
//Item.Validate("Bespoke Field",SomeBespokeValue);
//Item.Modify(true);

//[WHEN] the item is validated on a sales line
LibrarySales.CreateSalesDocumentWithItem(...)

//[THEN] some bespoke field on the sales line should be set
Assert.AreEqual(SalesLine."Bespoke Field",SomeBespokeValue,...)

Seeing the test fail with those lines commented out and then seeing it pass when you uncomment them will give you more confidence that the test and the behaviour that it is testing work as required. If you are testing code that you think already works and the test always passes it is hard to be sure why the test is passing. Hopefully because the code works – but possibly because the test itself is broken and will always pass, even if the code doesn’t work.

Confidence

The point is to try and get some confidence in your test results. Are you happy to ship the software when all your tests pass? If not, why not? Because you don’t have enough tests? Because you don’t trust that a passing test means working software?

Having a bunch of tests whose results you don’t trust is probably worse than having no tests at all.

Business Central

This is all pretty generic and if you’re interested in the principles you can search for Test-Driven Development (TDD) or Behaviour-Driven Development (BDD) and read what people far more qualified than me have to say about it.

Let’s talk about Business Central specifically for a minute. One of the best things about automated testing compared to manual testing is that everything is rolled back at the end to return the database to the same state it was in at the start. However, that can make life a little difficult when you are trying to inspect the data mid-test and see what is happened.

There are various ways you might want to extract the data at a given moment: write to a file, throw an error with a bunch of values you are interested in, read uncommitted data in SQL. We’ll just talk about two approaches:

Debugger

You can debug test code just like any other code. Set a breakpoint in your test, attach the debugger and run the test from the Test Tool page. Step through, add watches and evaluate debug expressions. The debugger in VS Code is getting better all the time, exposing more details about the variables you are interested in and SQL statements that have been executed.

Perfect for diving into the details and stepping through line by line, but not always the easiest to get an overview of what is happening.

Another Client Session

Another option is to open another client session while you are debugging. Set a breakpoint, attach the debugger and start running a test from the Test Tool page.

Executing Tests Dialog.JPG

Debugging the test will block the session that you started it from – you’ll get the “working on it” dialog – but you can open a different session in another tab or in another browser.

The only snag with this is that some of the records that you want to read might be locked and you’ll get an error trying to open the corresponding page.

Record Locked by Another User.JPG

“The operation could not complete because a record was locked by another user.” Bummer.

Turns out there is another way to read the data in that session.

Avoid Locks With Page=<pageid>

You can add parameters to the web client URL to navigate to specific tables, reports or pages. In my example I can’t open the Items list from the menu because the record is locked by another user.

If I go to the Item List page with http://<base web client URL>?page=32 then the page loads with my test data. I can open the item card, navigate to other pages and run the Page Inspector (Ctrl+Alt+F1) to view all the fields in the table, filters, extension details etc.

As I step through the code in the VS Code debugger I can refresh the pages in this session and see the updates to the record. Beautiful.

Item Card with Test Data.JPG

Further Reading

If you’re interested in getting stuck into testing in Business Central grab yourself a copy of Automated Testing in Microsoft Dynamics 365 Business Central.

Automated Testing in Microsoft Dynamics 365 Business Central

It was my pleasure to make a small contribution to this book as a technical reviewer and writing the foreword.

https://www.packtpub.com/business/automated-testing-microsoft-dynamics-365-business-central