Getting Started with Git Hooks

Git hooks have been on my “to mess about with and learn a little some day” list for a while. It’s the old conundrum: I might use them if I knew what they could do, but I’m not going to learn about them until I’ve got a use for them. Chickens-and-eggs for developers.

The Problem

Until recently, that is. I wondered:

What is the best way to save a different launch configuration in VS Code for each branch in my repo?

Why would I want to do that? We have branches for versions of our apps compatible with BC14, 15 and 16. I’ve got separate Docker containers of each of those versions to develop and test against. I need to change the launch config when I want to test in another container. Not a great hardship admittedly, but it would be nice to automate somehow.

Source Control?

I could of course just source-control the launch.json file and commit the relevant changes to each branch. I don’t like that though. We ignore the .vscode folder in .gitignore. To my mind anything that happens in that folder is for the benefit of the local development environment. No one else cares what my launch config is – they’ll be pushing code to their own Docker containers or to SaaS sandboxes.

I suppose you could have some local commits that include the launch.json file that you don’t push to the server. Sounds horrible to manage though. Don’t do that.

So we want something that is related to source control – we want a launch config per branch – but doesn’t involve actually committing the config file. Enter hooks.

Hooks

Hooks are points in the Git workflow at which you can execute a custom script. Some are executed server-side, some client-side. You can read the documentation at https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks

When a Git repo is initialised a .git folder is created which contains all the version history of the repo and Git’s guts. If you’re curious to learn more about how Git actually works have a browse through these folders. You might want to load up a presentation on the subject as well – like this: https://www.youtube.com/watch?v=ig5E8CcdM9g

You’ll notice that one of the folders is called hooks. This contains some sample hook files which you can take a look through. The .sample file type stops them from actually doing anything but you get a flavour of what’s possible and how you can use them.

Post-Checkout Example

For our example we’re going to want to use the post-checkout hook. This allows us to create a script that Git will execute after a checkout command. (Side note: I tend to use the integrated terminal in VS Code to execute Git commands, including checking out branches but this hook is also executed if you switch branches with the UI).

First, post-checkout isn’t one of the samples that is created when you init a new repo. You can copy the post-update.sample file instead and rename it to post-checkout (no file extension).

Writing the Hook

Okay…so what are we supposed to put in this file? Can we just start typing PowerShell? Erm…no, we can’t. The file looks distinctly Linuxy…*shudder* I’ve got nothing against Linux, I just have next to no idea what I’m doing.

The top line of the file indicates the language that you are going to script in. It’s possible to use Perl, Python, Ruby and a bunch of other stuff, including PowerShell. I won’t pretend to really know what I’m talking about but there is an useful thread on Stack Overflow on the topic.

#!/bin/sh

It is possible to write PowerShell directly into the hook file, but from what I’ve read it seems you need PowerShell Core to run it. I’ve settled for this:

#!/bin/sh

c:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -File '.git\hooks\post-checkout.ps1' $1 $2 $3

Call powershell.exe to execute the .git\post-checkout.ps1 file and pass it the three parameters that have been received. Great. How do we know that there are three parameters? And what are they? You can read the documentation here: https://git-scm.com/docs/githooks#_post_checkout

The parameters are:

  • The previous HEAD (the commit that we’ve come from)
  • The new HEAD (the commit that we’ve just checked out)
  • A flag to indicate whether a branch was checked out (it has a value of 1 if it was)

Writing the Script

Now we’re getting somewhere.

The plan is:

  • Save the launch configuration as it stands for the branch that we are coming from
  • Find a saved launch configuration for the branch that we have moved to and overwrite launch.json

What Branch Have We Come From?

This is a subtler question than we might have expected. The parameter that Git passes is the hexadecimal hash of the commit that was previously checked out. Some jumble of numbers and letters that is integral to how Git works but probably meaningless to the developer. We need to convert that hash into a branch. Or branches – potentially more than one branch might be pointing at that commit.

git branch --points-at <commit hash>

The above command will give us zero or more branches that are pointing at a given commit. Note that the current branch is denoted with an asterisk which we’ll need to trim off the front of the name.

Which Branch Have We Moved To?

We can find the name of the new, current branch with:

git branch --show-current

Finally, branch names can contain characters that cannot be used in filenames. Specifically we use forward-slashes a lot in our branch names. I’ve got a Convert-BranchToFileName function to clean it up.

Putting all the jigsaw pieces together this is the content of my post-checkout.ps1 file (which is in the .git\hooks directory).

[CmdletBinding()]
param (
    $PreviousHead,
    $NewHead,
    $BranchCheckout
)

function Convert-BranchToFileName() {
    param(
        $branch
    )

    $filename = $branch
    if ($branch.StartsWith('*')) {
        $filename = $branch.Substring(2)
    }

    $filename = $filename.Split([IO.Path]::GetInvalidPathChars()) -join ''
    return $filename.Trim()
}

if (!(Test-Path '.vscode\launch')) {
    New-Item '.vscode\launch' -ItemType Directory | Out-Null
}

$branches = git branch --points-at $PreviousHead
foreach ($branch in $branches) {
    if (Test-Path '.vscode\launch.json') {
        $filename = Convert-BranchToFileName $branch
        Copy-Item '.vscode\launch.json' ".vscode\launch\$filename.json" -Force
    }
}

if ($BranchCheckout -eq 1) {
    $newBranch = git branch --show-current
    if (Test-Path ".vscode\launch\$(Convert-BranchToFileName $newBranch).json") {
        Copy-Item ".vscode\launch\$(Convert-BranchToFileName $newBranch).json" '.vscode\launch.json' -Force
    }
}

Result

The result is something like this. When I checkout a new branch launch.json is copied to .vscode\launch\<branchname>.json and the saved json file matching the new branch name overwrites launch.json

A pretty trivial example you might argue – and you’d be right – but hopefully enough to demonstrate some of the possibilities. More useful examples might include:

  • Cleaning up the source directory between checkouts e.g. deleting .app files from previous builds or removing duplicate apps in the .alpackages folder
  • Checking Git configuration before committing e.g. is the email address that will be associated with the commit in an acceptable format e.g. does it make a particular regex pattern?
  • Enforcing some policy about compile errors or warnings before a new commit is made
  • Use your imagination…

Some More About Translating Business Central Apps

I’ve written before about using Azure Cognitive Services to translate the captions in the .xlf file that is generated when you compile your Business Central app. For us, the motivation is to make our apps available in as many countries as possible in AppSource.

Since then Søren Alexandersen has announced that it will not be necessary to provide all of a country’s official languages to make your app available in that country.

If you going to provide translations you might be interested in how to improve upon a the approach of the last post.

The Problem

The problem of course is that we are relying on machine translation to translate very short phrases or single words. A single word can mean different things and be translated in many different ways into other languages depending on the context. Context that the machine translation doesn’t have. That’s what makes language and etymology simultaneously fascinating and infuriating.

The problem is compounded by abbreviations and acronyms. You and I know that “Prod. Order” is short for “Production Order”. But “Prod” is itself an English word that has nothing to do with manufacturing.

We know that FA is likely short for “fixed asset” but if you don’t know that the context is an ERP system it could mean a whole range of things. How is Azure supposed to translate it?

What we need is some domain-specific knowledge.

The Solution

When we think about it we know that we’ve already got thousands of translations of captions into the languages that we want – if only we can get them into a useful format. We’ve got Docker images of Business Central localisations. They contain the base app for the location complete with source/target pairs for each caption.

If you can get hold of the xlf file it’s a relatively simple job to search for a trans-unit that has a source node matching the caption that you want to translate and find the corresponding translated target node.

As an example, I’ve created a container called ch from the image mcr.microsoft.com/businesscentral/sandbox:ch – the Swiss localisation of Business Central.

Find and expand the base application source.

$ContainerName = 'ch'
$Script = {Expand-Archive "C:\Applications.*\Base Application.Source.zip" -DestinationPath 'C:\run\my\base'}

Invoke-ScriptInBCContainer -containerName $ContainerName -scriptblock $Script

This script will find the zip file containing the localised base application and extract it to the ‘C:\run\my\base’ folder. This will take a few minutes but when it is done you should see a Translations folder containing, in the case of Switzerland, four .xlf files.

The following script will load the fr-CH .xlf file into an Xml Document, search for a trans-unit node which has a child source node matching a given string and return the target i.e. the fr-CH translation.

$Language = 'fr'
$enUSCaption = 'Prod. Order Line No.'
[xml]$xlf = Get-Content (Get-ChildItem "C:\ProgramData\NavContainerHelper\Extensions\$ContainerName\my\base\Translations" -Filter "*$Language*").FullName

$NSMgr.AddNamespace('x',$xlf.DocumentElement.NamespaceURI)
$xlf.SelectSingleNode("/x:xliff/x:file/x:body/x:group/x:trans-unit[x:source='$enUSCaption']", $NSMgr).target

Which returns “N° ligne O.F.” – cool.

Some Obvious Points

I’m going to leave it there for this post, save for making a few obvious points.

  • This is hopelessly inefficient. Downloading the localised Docker image, creating the container, extracting the base app – all to get at the .xlf files. We’re going to want a smarter solution before using this approach in any volume and for more languages
  • Each .xlf file is 60+MB – that takes a while to load into memory – you’ll want to keep the variable in scope and reuse it for multiple searches rather than reloading the document
  • Not all of the US English captions you create in your app will exist in the base application – you’ll still want to send those off for translation.

Maybe we can start to address these points next time…

Prompting the User for Input with PowerShell

Sometimes you need to prompt the user to provide some value before you can complete your PowerShell script. You’ve got a few different options depending on what you’re asking the user to select from.

Parameters

Setting a parameter as mandatory without providing a value will prompt the user to enter one, like this:

function Invoke-AmazingPowerShellFunction {
  Param(
    [Parameter(Mandatory=$true)]
    [string]$ImportantParameter
  )
}

Setting the parameter type ([string] in this case) isn’t essential but will help validate that the input is at least of the right type. The trouble with users is that they can, and will, enter any old nonsense as the parameter value and you need to be able to handle it.

The ValidateSet attribute helps out where you have a fixed set of values that are the only valid ones.

function Invoke-AmazingPowerShellFunction {
  Param(
    [Parameter(Mandatory=$true)]
    [ValidateSet('This','Or This','Or Possibly This')]
    [string]$ImportantParameter
  )
} 

If you don’t know at design-time what the valid options are going to be then you need a different approach.

Out-GridView

Out-GridView has an OutputMode parameter which allows you to specify whether the user should be able to select a value and if so, a single value or multiple values. It also allows you to set a title for the window and provides a filter to help the user find the right value. Good for when there is a lot to choose from. We use it, for example, to choose a project from Azure DevOps.

In passing I’ve also found Out-GridView useful when working with complex types e.g. from a web service response and I just want to be able to browse the values in the object. You can pipe anything to it and it will render it into a nice grid.

Write-Host ("You selected {0}" -f ('1','2','3' | Out-GridView -OutputMode Single -Title 'Please select a value'))

Roll Your Own

Recently I wanted to prompt the user to make a selection between some options in the terminal. In my experience the Out-GridView window doesn’t always open in the foreground and if you’re using multiple monitors won’t necessary open near the window you’re executing the script in. I thought I’d try keeping the focus in the terminal window instead.

I couldn’t find anything already in PowerShell to print a list of options and prompt the user to choose one, so I wrote the below. I’d be interested to know if I’ve missed something obvious already built in though.

It takes a collection of strings that represent the options to choose between and some text that you want to prompt the user with. The options are printed with numbers next to them, waits for some input from the user with Read-Host and matches it to their selection.

0 is hard-coded as a cancel option and will return an empty string, otherwise the string of the user’s selection is returned.

function Get-SelectionFromUser {
    param (
        [Parameter(Mandatory=$true)]
        [string[]]$Options,
        [Parameter(Mandatory=$true)]
        [string]$Prompt        
    )
    
    [int]$Response = 0;
    [bool]$ValidResponse = $false    

    while (!($ValidResponse)) {            
        [int]$OptionNo = 0

        Write-Host $Prompt -ForegroundColor DarkYellow
        Write-Host "[0]: Cancel"

        foreach ($Option in $Options) {
            $OptionNo += 1
            Write-Host ("[$OptionNo]: {0}" -f $Option)
        }

        if ([Int]::TryParse((Read-Host), [ref]$Response)) {
            if ($Response -eq 0) {
                return ''
            }
            elseif($Response -le $OptionNo) {
                $ValidResponse = $true
            }
        }
    }

    return $Options.Get($Response - 1)
} 

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)
TranslateXlfFile 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 = TranslateStrings 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 TranslateXlfFile
Export-ModuleMember Function TranslateApp
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.

Testing Microsoft Dynamics 365 Business Central from VS Code

Execute your Microsoft Dynamics 365 Business Central tests – with a keyboard shortcut – without leaving the comfort of your favourite IDE. What’s not to love?

Background

We’ve come a long way with testing our apps in Microsoft Dynamics 365 Business Central / NAV. By “we” I mean our internal development practices but also the capabilities of the platform.

  1. We didn’t have any automated testing – we had a joke written on the white board in the development office, “F11 is testing”
  2. Microsoft made it possible to create and run automated tests in Dynamics NAV – we didn’t use it
  3. Microsoft improved the tooling with the release of the “Test Tool” page
  4. We started to experiment with it and to write our own tests
  5. We picked up the idea of automating test runs with a build pipeline…

…and that was a bit of a problem. The best way to run the tests was through the Windows client. That meant using the dynamicsnav:// protocol to open the client, creating a ClientUserSettings.config file, ending the client process when it had finished…a big improvement on not testing at all – but hardly elegant.

With Microsoft’s adoption of Docker to distribute NAV / Business Central images came Freddy’s navcontainerhelper PowerShell module. A big, and relatively recent, step forward was the ability to run a test suite in a Docker container from PowerShell.

If you don’t know I’m talking about you’re best heading over to Freddy’s blog and doing some background reading.

Running the Tests

This is all great for our build process. We can create a Docker container, install the app(s), prepare the test suite (see here), run the tests from PowerShell, upload the results and bin the container.

As soon as the ability to run tests from PowerShell became available I was interested in how we could use them in our everyday development, not just in the pipeline. If you do any reading about Test Driven Development you’ll find that it is based on a very tight feedback loop. Write a test, write a small chunk of production code, run the tests. Repeat.

I want to be able to run the tests from the same environment that I write the code – Visual Studio Code. I don’t want to be switching back and forth from VS Code to the browser, refreshing test codeunits or methods and running them there. I just don’t find it a nice way to work.

With VS Code’s built in terminal and navcontainerhelper loaded you don’t have to.

  1. Set “launchBrowser” to false in launch.json
  2. Write a test
  3. Execute Run-TestsInNavContainer in the terminal and review the results
  4. Write some production code
  5. Publish without debugging
  6. Execute Run-TestsInNavContainer in the terminal and review the results
  7. Repeat steps 2-5

I actually use Run-BCTests, a function in our own PowerShell module. Run-BCTests is a wrapper for Run-TestsInNavContainer which:

  • Reads the name of the container from launch.json (see below) – unless a different container is specified
  • Creates a PowerShell credential object from the credentials we store in a json file in the repo
  • Optionally downloads our “Build Helper” app (from its last successful build – like this) to load our test codeunits and methods into the DEFAULT suite
  • Use Run-TestsInNavContainer to run the tests and output the results to the terminal

Get-ContainerFromLaunchJson

You already have to set the name of your container in the launch.json file to publish your app, so why not read it from there rather than typing your container name all the time?

function Get-ContainerFromLaunchJson {
  param (
    # Path to launch.json
    [Parameter(Mandatory=$false)]
    [string]
    $LaunchJsonPath = (Join-Path (Get-Location) '.vscode\launch.json')
  )

  if (!(Test-Path $LaunchJsonPath)) {
    return ''
  }

  $LaunchJson = ConvertFrom-Json (Get-Content $LaunchJsonPath -Raw)
  if ($LaunchJson.configurations.Count -ne 1) {
    return ''
  }
  else {
    $Container = $LaunchJson.configurations.Item(0).server
    $Container = $Container.Substring($Container.IndexOf('//') + 2)
    $Container
  }
}

Note that the container name in launch.json will need to match the case of the Docker container name. For reasons best known to Docker container names are case-sensitive.

VS Code Tasks: Running Tests With a Keyboard Shortcut

In pursuit of making it as quick and easy as possible to execute the tests from VS Code we can go a step further and create a VS Code task.

Create a tasks.json file in the .vscode folder. Mine looks like this:

{
    // See https://go.microsoft.com/fwlink/?LinkId=733558
    // for the documentation about the tasks.json format
    "version": "2.0.0",
    "tasks": [
        {
            "label": "Run BC Tests",
            "type": "shell",
            "command": "Run-BCTests -DoNotPrepTestSuite",            
            "group": {
                "kind": "test",
                "isDefault": true
            }
        }
    ]
}

This defines a task with the label “Run BC Tests” which runs the command “Run-BCTests -DoNotPrepTestSuite” (the PowerShell function described above). It is set as the default task in the test group.

This allows you to run “Run Test Task” from the command palette.

Run Test Task

Now you can assign a keyboard shortcut to that command. Open “Preferences: Open Keyboard Shortcuts” from the command palette and search for “Run Test Task”.

Run Test Task Keyboard Shortcut.JPG

Double click that entry to set whatever keyboard combination you like. I’ve opted for ctrl+shift+T.

Conclusion

This takes us a step closer to having a code-and-test-in-the-IDE development experience and allows this kind of tight test/production code iteration without having to open the browser:

  1. Write some test code
  2. Publish without debugging (Ctrl+F5)
  3. Run the tests (Ctrl+Shift+T) and check that they fail
  4. Write production code
  5. Publish without debugging (Ctrl+F5)
  6. Run the tests (Ctrl+Shift+T) and check that they pass
  7. Repeat

Of course, this does not remove the need to open the browser, check the look and feel and test your code manually at some point but it does go some way to alleviating the pain of publishing and executing the tests in the browser.

Future

I like it, but it’s still a little clunky. For one, it runs too slowly – the gif at the head of this post is real-time. Also, you still have to populate the test suite with the test codeunits and methods that you want to run at some point. Our PowerShell module can do that, but it’s another manual step to run.

There’s only so much we can do about these issues until Microsoft overhaul the whole testing framework – which I understand they are working on. In the meantime, here’s some other ideas that we haven’t implemented yet.

  • Use the previous commit, or uncommitted changes to determine the test codeunit(s) to run. Run-TestsInNavContainer has optional parameters to specify what to run
  • Filter the results so that you are only notified of failures instead of having to pick them out of the successes
  • Work some magic in VS Code to have the test task automatically triggered when the app is published to the server