Программа автоматической установки HP UFT с PowerShell

В компании, где я работаю, мы используем HPE Unified Functional Testing для нашего автоматизированного функционального тестирования. Когда мы принимаем нового члена команды, одной из его вводных задач является настройка нового лабораторного компьютера. Однако на этот раз я подумал — «эта задача должна быть проще» — и решил использовать функцию автоматической установки, чтобы создать сценарий установки для HP UFT.

Я разбил сценарий на следующие части:

  1. Скачать файлы
  2. Распаковать файлы
  3. Запустить установщик

Я начал писать сценарии и быстро понял, что мне нужен файл конфигурации. В качестве формата конфигурационного файла я выбрал JSON. Он легко читается PowerShell, удобен для человека, а структура более продвинута, чем .ini-файлы. Я отказался от xml, так как считаю, что это в основном для машин.

Как только я начал работу над сценарием конфигурации, я также выбрал более общее решение, чтобы использовать свой сценарий для установки других элементов.

Файл конфигурации

Файл конфигурации содержит следующую информацию:

  • Заголовок — как называется вещь, которую собираются установить?
  • Текстовое описание того, что делает весь скрипт установки.
  • Расположение заархивированного файла, содержащего все файлы, необходимые для установки.
  • Хэш файла, чтобы мы могли проверить, был ли файл уже загружен.
  • Имя папки для локального размещения загруженных файлов.
  • Шаги установки.

Давайте посмотрим на пример:

{
     "Title": "The Example",
     "Description": "Installs the example app. ",
     "SetupZipFile": "\\\\Some\\Share\\At\\My\\Compant\\example_app.zip",
     "SetupZipFileHash": {
                             "Algorithm": "SHA256",
                             "Hash": "8D7ECA5A3438707C2BAC576900261CB7D2C937ED8DFAA6216EA6AE647330D9B9"
                         },
     "LocalInstallFolder": "exampleapp",
     "Steps": [
                 {
                     "Title": "Install prerequisites",
                     "Type": "exe",
                     "File": "example_setup.exe",
                     "Arguments": "-quiet -allOptions -that -is -needed"
                 },
                 {
                     "Title": "Install the real package",
                     "Type": "msi",
                     "File": "example.msi",
                          "Arguments": "/quiet -allOptions -that -is -needed"
                 },
             ]
}

Как видите, сценарий установки может обрабатывать два разных типа шагов. Исполняемый файл и msi-пакет.

Скрипт установщика

Скрипт принимает несколько общих параметров:

param(
    [Parameter(Mandatory=$True)][string] $package, 
    [switch] $noDownloadAndUnzip,
    [switch] $dryRun 
    )

$package — это путь к файлу конфигурации. Я использую следующее соглашение об именах ‹package-name›.install.cfg.json, например uft1251.install.cfg.json.

Параметр $noDownloadAndUnzip в основном используется в целях разработки. Когда он установлен, скрипт перейдет прямо к выполнению шагов установки.

$dryRun просто прочитает конфигурационный файл и выведет его содержимое в удобочитаемой форме.

Чтение JSON

Первое, что делается в скрипте, это чтение json-файла:

function readJsonConfigFile($configFileName) {
    $currentDirectory = $PSScriptRoot
    $configFile =  Join-Path -path  $PSScriptRoot -ChildPath $configFileName
     if (-not (Test-Path -Path $configFile)) {
         logWrite ("Kunne ikke finde configfilen[" + $configFile + "]")
         return $false
     }
    $configContent = Get-Content -Path $configFile -Raw
     Try {
        $config = ConvertFrom-Json -InputObject $configContent
         return $config
     }
     Catch {
         $ErrorMessage = $_.Exception.Message
         logWrite ("Læsning af config-filen fejlede[" + $ErrorMessage + "]")
         return $false
     }
 }

В этой функции предполагается, что файл конфигурации находится в той же директории, что и скрипт-установщик. Основная часть скрипта — это CmdLet ConvertFrom-Json. Остальное — просто обработка ошибок.

Копируем файл и используем хэш

Хорошо. Откуда взялся этот Hash-объект в конфиге? Это из CmdLet Get-FileHash. По умолчанию используется алгоритм SHA256. текущий установочный скрипт может использовать только этот в данный момент, но при желании можно легко включить значение из конфигурационного файла.

Чтобы получить начальное хеш-значение:

PS C:\Temp> Get-FileHash HP_UFT_12.51_Package_For_The_Web_UFT1251_Setup.zip | Format-List
Algorithm : SHA256
Hash      : 1D41D4C3880D86FD22878F072D40F87C6277DE444B4FBA0C2E716C80ABAF7F8C
Path      : C:\Temp\HP_UFT_12.51_Package_For_The_Web_UFT1251_Setup.zip

Прежде чем что-либо копировать, я сначала проверяю, скачал ли уже файл. Это в основном полезно при разработке конфигурационного файла, так как вы, вероятно, не захотите загружать файл снова и снова. Следующая функция сравнит хэш из файла конфигурации с хэшем из загруженного файла.

function packageAlreadyDownloaded($config) {
    $localDownloadedZipFilePath = getLocalSetupZipFilePath($config)
    logWrite ("Testing whether the file[" + $localDownloadedZipFilePath + "] has already been downloaded.")
    $localDownloadZipFileExist = Test-Path -Path $localDownloadedZipFilePath
    if ($localDownloadZipFileExist) {
       $setupZipFileHash = Get-FileHash $localDownloadedZipFilePath
       logWrite ("Testing whether the file[" + $localDownloadedZipFilePath + "] has the same hash as specified in the config-file.")
        logWrite ("ConfigFileHash[" + $config.SetupZipFileHash.Hash + "]")
        logWrite ("Local FileHash [" + $setupZipFileHash.Hash + "]")
        if ($setupZipFileHash.Hash -eq $config.SetupZipFileHash.Hash) {
            logWrite ("The local file has a matching hash.")
            return $true
        } else {
            logWrite ("The local file hash does not match.")
            return $false
        }
    } else {
        logWrite ("The local file does not exist.")  
        return $false
    }
}

Распаковка файла

Мы распаковываем файл с помощью windowsShell и открываем zip-файл как папку. Самый важный шаг — удалить любой существующий файл перед распаковкой. В противном случае нас спросят, хотим ли мы перезаписать существующие файлы для каждого уже существующего файла. Это не диалоговое окно да/нет, которое может быть обработано параметром команды copyHere, а диалоговое окно пропуска/отмены.

Для команды copyHere мы могли бы использовать 20 в качестве второго параметра, что заставило бы i полностью замолчать. Это контролируемая установка (на данный момент), поэтому я предпочитаю, чтобы диалог состояния был виден. Особенно, когда мы распаковываем файл размером 1 гигабайт.

function Expand-ZIPFile ($file, $destination) {
    logBeginAction "decompressing the installfile"
    logwrite "Decompressing[$file] to[$destination]"
    $windowsShell = New-Object -ComObject shell.application
    $zip = $windowsShell.NameSpace($file)
    if (!$zip) {
        logWrite "Decompressing of [$file] failed"
        return ""
    }
    foreach($item in $zip.items()) {
        $itemPath = $item.Path
        logWrite "Decompressing: [$itemPath]"
        $destinationLeafFolder = Split-Path $itemPath -Leaf
        $destinationFolder = Join-Path -Path $destination -ChildPath $destinationLeafFolder
        if (Test-Path -Path $destinationFolder) {
            Remove-Item -Path $destinationFolder -Force -Recurse 
        }
        $windowsShell.Namespace($destination).copyhere($itemPath)
    }
    logWrite ("Decompressing of[" + $file + "] finished")
    return $true
}

Бег по шагам

Мы используем простой цикл, чтобы пройти все этапы. Для каждого шага мы определяем, является ли он «exe» или «msi»-шагом. Это вызовет «вспомогательную» функцию, которая установит правильные параметры для Start-Process CmdLet.

function runProcess($filePath, $arguments, $workingDirectory) {
    logWrite ("Start-Process filePath[$filePath], arguments[$arguments], workingDirectory[$workingdirectory]")
    Start-Process -FilePath $filePath -ArgumentList $arguments -WorkingDirectory $workingDirectory -Wait
}
function runMsiStep($localFolder, $step) {
    logWrite ("Executing MSI step: " + $step.Title)
    $filePath = "msiexec.exe"
    $file = Join-Path -path $localFolder -ChildPath $step.File
    $arguments = "/i " + $file + " /quiet " + $step.Arguments
    runProcess -filePath $filePath -arguments $arguments -workingDirectory $localfolder
    return $true
}
function runExeStep($localFolder, $step) {
    logWrite ("Executing EXE step: " + $step.Title)
    $filePath = Join-Path -path $localFolder -ChildPath $step.File
    $arguments = $step.arguments
    runProcess -filePath $filePath -arguments $arguments -workingDirectory $localfolder
    return $true
}
function runStep($localFolder, $step) {
    if ($step.Type -eq "msi") {
        return runMsiStep -localFolder $localFolder -step $step
    }
    elseif ($step.type -eq "exe") {
        return runExeStep -localFolder $localFolder -step $step
    }
    else {
        logWrite ("Unknown Step-Type[" + $step.Type + "]")
        return $false
    }
}
function runAllSteps($config) {
    $localInstallfolder = getLocalInstallFolder ($config)
    foreach($step in $config.Steps) {
        runStep -localFolder $localInstallfolder -step $step
    }
}

Настройка скрытого установщика UFT

Подробную информацию об установке HPE UFT см. в Руководстве по установке HPE UFT. Для моего сценария я использую следующие два шага:

<downloadfolder>/setup.exe /InstallOnlyPrerequisite /s

При этом будут установлены все необходимые компоненты для запуска UFT, такие как .Net и т. д.

Следующим шагом является запуск фактического .msi-пакета:

msiexex /i <path to downloadfolder>/unified_functional_testing_x64.msi

Этот шаг требует множества аргументов, для моей конкретной настройки я использую следующее:

qb ADDLOCAL=\"Core_Components,IDE,Test_Results_Viewer,Help_Documents,ALM_Plugin,LeanFT_Engine,LeanFT_Client,ActiveX_Add_in,Web_Add_in,Java_Add_in,_Net_Add_in,WPF_Add_in,Silverlight_Add_in\" CONF_MSIE=1 ALLOW_RUN_FROM_ALM=1 ALLOW_RUN_FROM_SCRIPTS=1 LICSVR=almtest

Для вашей установки вам может потребоваться меньше или больше надстроек, и вы должны найти подробную информацию в Руководстве по установке HPE UFT.

Моя окончательная конфигурация выглядит так:

{
     "Title": "HPE UFT 12.51",
     "Description": "Installerer HP UFT version 12.51",
     "SetupZipFile": "\\\\\\Install\\UFT\\12.51\\HP_UFT_12.51_Package_For_The_Web_UFT1251_Setup.zip",
     "SetupZipFileHash": {
                             "Algorithm": "SHA256",
                             "Hash": "1D41D4C3880D86FD22878F072D40F87C6277DE444B4FBA0C2E716C80ABAF7F8C"
                         },  
     "LocalInstallFolder": "uft1251",
     "Steps": [  {  
                     "Title": "UFT Prerequisites",
                     "Type": "exe",
                     "File": "HP_UFT_12.51_Package_For_The_Web_UFT1251_Setup\\setup.exe",
                     "Arguments": "/InstallOnlyPrerequisite /s"
                 },
                 {
                     "Title": "UFT",
                     "Type": "msi",
                     "File": "HP_UFT_12.51_Package_For_The_Web_UFT1251_Setup\\unified_functional_testing_x64.msi",
                     "Arguments": "/qb ADDLOCAL=\"Core_Components,IDE,Test_Results_Viewer,Help_Documents,ALM_Plugin,LeanFT_Engine,LeanFT_Client,ActiveX_Add_in,Web_Add_in,Java_Add_in,_Net_Add_in,WPF_Add_in,Silverlight_Add_in\" CONF_MSIE=1 ALLOW_RUN_FROM_ALM=1 ALLOW_RUN_FROM_SCRIPTS=1 LICSVR=autopass"
                 }  
             ]
   }
}

Дальнейшее развитие

Написание этого сценария было решением проблемы отказа от жизни в мире открытого исходного кода. В Linux или Mac OS X я бы, вероятно, просто создал небольшой скрипт, который загрузил бы Selnium. Но я живу в Windows-мире.

Я думаю, что есть несколько вариантов будущего. Один из них — Шоколад, а начиная с Windows 10 у нас будет OneGet. Оба варианта по-прежнему ограничены тем, что предлагается в общедоступных репозиториях. Но я предполагаю, что для предприятия решением было бы создать и поддерживать частный репозиторий. Тем не менее, вам, вероятно, придется создавать большинство пакетов самостоятельно.

Кроме того, улучшая скрипт, у меня есть следующее:

  1. Создание центрального репозитория пакетов и создание стандарта упаковки элементов.
  2. Создание локального файлового кэша
  3. Добавление типа cmd-step для дополнительных параметров
  4. Разрешение папок и файлов вместо только zip-файлов
  5. Поиск способа избежать распаковки файла снова и снова

Полный сценарий

<#
 .Description
 General Installer-script, which is based on a json-formatted configuration, for a given package.
 #>
 #Script arguments
 param(
     [Parameter(Mandatory=$True)][string] $package, 
     [switch] $noDownloadAndUnzip,
     [switch] $dryRun 
     )
 function logWrite($comment) {
    Write-Host $comment
 }
 function logTitle($title) {
     logWrite "============================================="
     logWrite "*** $title"
     logWrite "============================================="
 }
 function logBeginAction($actionName) {
     logWrite "`r`n===Starting $actionName===`r`n"
 }
 function readJsonConfigFile($configFileName) {
    $currentDirectory = $PSScriptRoot
    $configFile =  Join-Path -path  $PSScriptRoot -ChildPath $configFileName
     if (-not (Test-Path -Path $configFile)) {
         logWrite ("Could not locate the configfile[" + $configFile + "]")
         return $false
     }
    $configContent = Get-Content -Path $configFile -Raw
     Try {
        $config = ConvertFrom-Json -InputObject $configContent
         #TODO: Verify Config
         return $config
     }
     Catch {
         $ErrorMessage = $_.Exception.Message
         logWrite ("Parsing the config-file failed[" + $ErrorMessage + "]")
         return $false
     }
 }

 function getLocalDownloadTempFolder($config) {
     $downloadFolder = Join-Path $env:TEMP $config.LocalInstallFolder
     return $downloadFolder
 }
 function getLocalInstallFolder($config) {
     $localInstallFolder = Join-Path $env:TEMP $config.LocalInstallFolder
     return $localInstallFolder
 }
 function getLocalSetupZipFilePath($config) {
     $localDownloadFolder = getLocalDownloadTempFolder($config)
     $setupZipFileSourceFileName = Split-Path $config.SetupZipFile -Leaf
     $localDownloadedZipFilePath = Join-Path -Path $localDownloadFolder -ChildPath $setupZipFileSourceFileName
     return $localDownloadedZipFilePath
 }
 function packageAlreadyDownloaded($config) {
     $localDownloadedZipFilePath = getLocalSetupZipFilePath($config)
     logWrite ("Testing whether the file[" + $localDownloadedZipFilePath + "] has already been downloaded.")
     $localDownloadZipFileExist = Test-Path -Path $localDownloadedZipFilePath
     if ($localDownloadZipFileExist) {
         $setupZipFileHash = Get-FileHash $localDownloadedZipFilePath
         logWrite ("Testing whether the file[" + $localDownloadedZipFilePath + "] has the same hash as specified in the config-file.")
         logWrite ("ConfigFileHash[" + $config.SetupZipFileHash.Hash + "]")
         logWrite ("Local FileHash [" + $setupZipFileHash.Hash + "]")
         if ($setupZipFileHash.Hash -eq $config.SetupZipFileHash.Hash) {
             logWrite ("The local file has a matching hash.")
             return $true
         } else {
             logWrite ("The local file hash does not match.")
             return $false
         }
     } else {
         logWrite ("The local file does not exist.")  
         return $false
     }
 }

 function copyInstallPackage ($config){
     logBeginAction "download of the installfile"
     $setupFileIsDownloadedAndIsTheSame = packageAlreadyDownloaded -config $config
     if ($setupFileIsDownloadedAndIsTheSame) {
         return getLocalSetupZipFilePath($config)
     }
     $destinationfolder = getLocalDownloadTempFolder($config)
     logWrite ("Downloading[" + $config.SetupZipFile + "to" + $destinationfolder + "]")
     if (-not (Test-Path -Path $config.SetupZipFile)) {
         logWrite ("Could not locate the installfile[" + $config.SetupZipFile + "]")
         return ""
     }
     if (Test-Path -Path $destinationFolder) {
         logWrite ("Deleting the local folder[" + $destinationFolder + "]")
        Remove-Item -Path $destinationFolder -Force -Recurse    
     }
     logWrite ("Creating folder[" + $destinationFolder + "]")
    $createdDestinationfolder = New-Item $destinationFolder -type directory
     logWrite ("Starting copying of[" + $config.SetupZipFile + "]")
    $copiedFile = Copy-Item $config.SetupZipFile $destinationFolder
     $sourceFileName = Split-Path $config.SetupZipFile -Leaf
     $destinationFile = Join-Path -Path $destinationFolder -ChildPath $sourceFileName
     if (-not (Test-Path -Path $destinationFile)) {
         logWrite ("Copying of[" + $config.SetupZipFile + "] til[" + $destinationFolder + "] failed")
         return ""
     }
     logWrite ("Download of[" + $config.SetupZipFile + "] til[" + $destinationFile + "finished")
    return $destinationFile
 }
 function Expand-ZIPFile ($file, $destination) {
     logBeginAction "decompressing the installfile"
     logwrite "Decompressing[$file] to[$destination]"
    $windowsShell = New-Object -ComObject shell.application
    $zip = $windowsShell.NameSpace($file)
     if (!$zip) {
         logWrite "Decompressing of [$file] failed"
         return ""
     }
    foreach($item in $zip.items()) {
         $itemPath = $item.Path
         logWrite "Decompressing: [$itemPath]"
         $destinationLeafFolder = Split-Path $itemPath -Leaf
         $destinationFolder = Join-Path -Path $destination -ChildPath $destinationLeafFolder
         if (Test-Path -Path $destinationFolder) {
             Remove-Item -Path $destinationFolder -Force -Recurse 
         }
         $windowsShell.Namespace($destination).copyhere($itemPath)
    }
     logWrite ("Decompressing of[" + $file + "] finished")
     return $true
 }

 function runProcess($filePath, $arguments, $workingDirectory) {
     logWrite ("Start-Process filePath[$filePath], arguments[$arguments], workingDirectory[$workingdirectory]")
     #Redirect stdoutput + redirect stderror
     Start-Process -FilePath $filePath -ArgumentList $arguments -WorkingDirectory $workingDirectory -Wait
 }
 function runMsiStep($localFolder, $step) {
     logWrite ("Executing MSI step: " + $step.Title)
     $filePath = "msiexec.exe"
     $file = Join-Path -path $localFolder -ChildPath $step.File
     $arguments = "/i " + $file + " /quiet " + $step.Arguments
     runProcess -filePath $filePath -arguments $arguments -workingDirectory $localfolder
     return $true
 }
 function runExeStep($localFolder, $step) {
     logWrite ("Executing EXE step: " + $step.Title)
     $filePath = Join-Path -path $localFolder -ChildPath $step.File
     $arguments = $step.arguments
     runProcess -filePath $filePath -arguments $arguments -workingDirectory $localfolder
     return $true
 }
 function runStep($localFolder, $step) {
     if ($step.Type -eq "msi") {
         return runMsiStep -localFolder $localFolder -step $step
     }
     elseif ($step.type -eq "exe") {
         return runExeStep -localFolder $localFolder -step $step
     }
     else {
         logWrite ("Unknown Step-Type[" + $step.Type + "]")
         return $false
     }
 }
 function runAllSteps($config) {
     $localInstallfolder = getLocalInstallFolder ($config)
     foreach($step in $config.Steps) {
         runStep -localFolder $localInstallfolder -step $step
     }
 }
 function describeInstallScript($config) {
     logTitle ("Installscript: " + $config.Title)
     logWrite ("Description: " + $config.Description)
     logWrite ("Installfiel: " + $config.SetupZipFile)
     logWrite ("Hash: (" + $config.SetupZipFileHash.Algorithm + "): " + $config.SetupZipFileHash.Hash)
     logWrite ("Temp download folder: " + (getLocalDownloadTempFolder $config))
     foreach($step in $config.Steps) {
         logWrite ("`r`n-----------------------------------")
         logWrite ("STEP:" + $step.Title)
         logWrite ("-----------------------------------")
         logWrite ("TYPE:`r`n`t" + $step.Type)
         logWrite ("FILE:`r`n`t" + $step.File)
         logWrite ("ARGUMENTS:`r`n`t" + $step.Arguments)
     }
 }
 function install($package) {
     #TODO: exitcode
     $config = readJsonConfigFile -configFileName $package
     if (-not $config) {
         logWrite "Could not read config, could not complete installation"
         return
     }
     #Get local temp folder for storing the installationfiles.
     $downloadFolder = getLocalDownloadTempFolder $config
     if ($dryRun) {
         describeInstallScript $config
         return
     } 
     logTitle ("Starting install of  " + $config.Title)
     if ($noDownloadAndUnzip) {
         logWrite "You have chosen not to download or unzip anything and install immediately."
     } else {
         #Copy the installation file to the local folder
         $installLocation = copyInstallPackage $config
         $unzipFolder = getLocalInstallFolder -config $config
         #Extract the installation files.
         $unzipResult = Expand-ZIPFile $installLocation $unzipFolder
     }
     runAllSteps ($config)
 }
 install -package $package