diff --git a/Chronometer/Classes/Chronometer.ps1 b/Chronometer/Classes/Chronometer.ps1 new file mode 100644 index 0000000..fe867df --- /dev/null +++ b/Chronometer/Classes/Chronometer.ps1 @@ -0,0 +1,48 @@ +class Chronometer +{ + [hashtable]$FileMap = @{} + $Breakpoint = @() + + [void]AddBreakpoint([string[]]$Path) + { + foreach($file in (Resolve-Path $Path -ea 0)) + { + $script = [MonitoredScript]@{Path=$file.Path} + $lines = $script.SetScript($file) + + $this.fileMap[$file.Path] = $script + + $breakpointParam = @{ + Script = $file + Line = (1..$lines) + Action = {[ScriptProfiler]::RecordExecution( $_) } + } + $this.breakPoint += Set-PSBreakpoint @breakpointParam + } + } + + [void]ClearBreakpoint() + { + if($this.Breakpoint -ne $null -and $this.Breakpoint.count -gt 0) + { + Remove-PSBreakpoint -Breakpoint $this.Breakpoint + } + + } + + [void] AddExecution([hashtable]$Execution) + { + $script = $Execution.Breakpoint.Script + if($this.FileMap.ContainsKey($script)) + { + # Each script tracks it's own execution times + $this.FileMap[$script].AddExecution($Execution) + } + } + + [MonitoredScript[]] GetResults() + { + return $this.FileMap.Values + } +} + diff --git a/Chronometer/Classes/MonitoredScript.ps1 b/Chronometer/Classes/MonitoredScript.ps1 new file mode 100644 index 0000000..c2c04d7 --- /dev/null +++ b/Chronometer/Classes/MonitoredScript.ps1 @@ -0,0 +1,49 @@ +class MonitoredScript +{ + [string]$Path + [System.Collections.Generic.List[ScriptLine]]$Line + + hidden $lastNode = $null + hidden $lastRecord = $null + + [float]$ExecutionTime = 0 + [int]$LinesOfCode = 0 + + MonitoredScript() + { + $this.Line =New-Object 'System.Collections.Generic.List[ScriptLine]' + } + + [int] SetScript([string]$Path) + { + Get-Content -Path $Path | %{ $this.Line.Add( [ScriptLine]@{text=$_;path=$path})} + $this.LinesOfCode = $this.Line.Count + return $this.LinesOfCode + } + + [void] AddExecution([hashtable]$node) + { + # Line numbers start at 1 but the array starts at 0 + $lineNumber = $node.Breakpoint.Line - 1 + $record = $this.Line[$lineNumber] + $record.LineNumber = $lineNumber + + if($this.lastNode) + { + $duration = $node.ElapsedMilliseconds - $this.lastNode.ElapsedMilliseconds + } + else + { + $duration = $node.ElapsedMilliseconds + } + + if($this.lastRecord) + { + $this.lastRecord.AddExecutionTime($duration) + $this.ExecutionTime += $duration + } + + $this.lastRecord = $record + $this.lastNode = $node + } +} \ No newline at end of file diff --git a/Chronometer/Private/Write-ScriptLine.ps1 b/Chronometer/Private/Write-ScriptLine.ps1 new file mode 100644 index 0000000..217728c --- /dev/null +++ b/Chronometer/Private/Write-ScriptLine.ps1 @@ -0,0 +1,29 @@ + +function Write-ScriptLine +{ + param( + [scriptline] + $line, + $WarningAt = [int]::MaxValue, + $ErrorAt = [int]::MaxValue + ) + + if($line) + { + $Color = 'Green' + if($line.HitCount -eq 0) + { + $Color = 'Gray' + } + elseif($line.Average -ge $ErrorAt) + { + $Color = 'Red' + } + elseif($line.Average -ge $WarningAt) + { + $Color = 'Yellow' + } + + Write-Host $line.toString() -ForegroundColor $Color + } +} \ No newline at end of file diff --git a/Chronometer/Public/Format-Chronometer.ps1 b/Chronometer/Public/Format-Chronometer.ps1 new file mode 100644 index 0000000..f12be7e --- /dev/null +++ b/Chronometer/Public/Format-Chronometer.ps1 @@ -0,0 +1,50 @@ +function Format-Chronometer +{ + <# + .Description + Generates a report from a Chronometer + + .Example + $script = ls C:\workspace\PSGraph\PSGraph -Recurse -Filter *.ps1 + $resultes = Get-Chronometer -Path $script.fullname -ScriptBlock {Invoke-Pester C:\workspace\PSGraph} + $results | Format-Chronometer -WarnAt 20 -ErrorAt 200 + #> + [cmdletbinding()] + param( + # This is a MonitoredScript object from Get-Chronometer + [Parameter( + ValueFromPipeline=$true + )] + [MonitoredScript[]] + $InputObject, + + # If the average time of a command is more than this, the output is yellow + [int] + $WarningAt = 20, + + #If the average time of a comamand is more than this, the output is red + [int] + $ErrorAt = 200 + ) + + begin { + $green = @{ForgroundColor='green'} + $grey = @{ForgroundColor='grey'} + $yellow = @{ForgroundColor='grey'} + $yellow = @{ForgroundColor='grey'} + $yellow = @{ForgroundColor='grey'} + } + process + { + foreach($script in $InputObject) + { + Write-Host '' + Write-Host "Script: $($script.Path)" -ForegroundColor Green + Write-Host "Execution Time: $($script.ExecutionTime)" -ForegroundColor Green + foreach($line in $script.line) + { + Write-ScriptLine $line -WarningAt $WarningAt -ErrorAt $ErrorAt + } + } + } +} diff --git a/Chronometer/Public/Get-Chronometer.ps1 b/Chronometer/Public/Get-Chronometer.ps1 index a57d7ca..bc95fa2 100644 --- a/Chronometer/Public/Get-Chronometer.ps1 +++ b/Chronometer/Public/Get-Chronometer.ps1 @@ -21,54 +21,30 @@ function Get-Chronometer $ScriptBlock ) - $breakPoint = @() - $fileMap = @{} + $Chronometer = [Chronometer]::New() - foreach($file in (Resolve-Path $Path -ea 0)) + Write-Verbose "Setting breapoints" + $Chronometer.AddBreakpoint($Path) + + if($Chronometer.breakPoint -ne $null) { - $fileMap[$file.Path] = @( Get-Content -Path $file | %{[ScriptLine]@{text=$_;path=$file.path}}) + Write-Verbose "Executing Script" + [ScriptProfiler]::Start() + [void] $ScriptBlock.Invoke() - $lines = $fileMap[$file.Path].count - $breakPoint += Set-PSBreakpoint -Script $file -Line (1..$lines) -Action {[ScriptProfiler]::RecordExecution( $_) } + Write-Verbose "Clearing Breapoints" + $Chronometer.ClearBreakpoint() + + Write-Verbose "Processing data" + foreach($node in [ScriptProfiler]::Queue.GetEnumerator()) + { + $Chronometer.AddExecution($node) + } + + Write-Output $Chronometer.GetResults() } - - [ScriptProfiler]::Start() - [void] $ScriptBlock.Invoke() - - Remove-PSBreakpoint $breakpoint - - #$fileMap | ConvertTo-Json - - - foreach($node in [ScriptProfiler]::Queue.GetEnumerator()) + else { - $record = $fileMap[$node.Breakpoint.Script][$node.Breakpoint.Line-1] - $record.LineNumber = $node.Breakpoint.Line - 1 - - if($lastNode) - { - $duration = $node.ElapsedMilliseconds - $lastNode.ElapsedMilliseconds - } - else - { - $duration = $node.ElapsedMilliseconds - } - - - if($lastRecord) - { - $lastRecord.AddExecutionTime($duration) - } - - $lastRecord = $record - $lastNode = $node - } - - foreach($script in $fileMap.Keys) - { - foreach($line in $fileMap[$script]) - { - Write-Output $line - } + Write-Warning "Parsing files did not result in any breakpoints" } } diff --git a/Chronometer/chronometer.psd1 b/Chronometer/chronometer.psd1 index 73e6e5c..066b559 100644 Binary files a/Chronometer/chronometer.psd1 and b/Chronometer/chronometer.psd1 differ diff --git a/Chronometer/chronometer.psm1 b/Chronometer/chronometer.psm1 index 3b06336..19d1b5c 100644 --- a/Chronometer/chronometer.psm1 +++ b/Chronometer/chronometer.psm1 @@ -1,9 +1,24 @@ #Requires -Version 5.0 +[cmdletbinding()] +param() -Write-Verbose "Importing Functions" +Write-Verbose $PSScriptRoot +Write-Verbose 'Import Classes in order because of dependencies' +$classList = @( + 'ScriptLine', + 'ScriptProfiler', + 'MonitoredScript', + 'Chronometer' +) -# Import everything in sub folders folder -foreach($folder in @('classes', 'private', 'public','includes')) +foreach($class in $classList) +{ + Write-Verbose " Class: $class" + . "$psscriptroot\classes\$class.ps1" +} + +Write-Verbose 'Import everything in sub folders folder' +foreach($folder in @('private', 'public','includes')) { $root = Join-Path -Path $PSScriptRoot -ChildPath $folder if(Test-Path -Path $root) @@ -13,12 +28,9 @@ foreach($folder in @('classes', 'private', 'public','includes')) # dot source each file $files | where-Object{ $_.name -NotLike '*.Tests.ps1'} | - ForEach-Object{Write-Verbose $_.name; . $_.FullName} + ForEach-Object{Write-Verbose $_.basename; . $_.FullName} } } Export-ModuleMember -function (Get-ChildItem -Path "$PSScriptRoot\public\*.ps1").basename -# Hack for my build system that had a conflit with the keyword node -New-Alias -Name 'DiGraph' -Value 'Graph' -ErrorAction SilentlyContinue -Export-ModuleMember -Alias 'DiGraph' diff --git a/README.md b/README.md index c18dda7..791061f 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,9 @@ Place the Chronometer folder into your `$PSModulePath`. I will publish to the Po Provide a script file and a command to execute. $path = myscript.ps1 - Get-Chronometer -Path $path -Script {. .\myscript.ps1} -OutVariable report - $report.ToString() + $Chronometer = Get-Chronometer -Path $path -Script {. .\myscript.ps1} + $Chronometer | % tostring | Format-Chronometer + The user experience is important to me but I am working on the core logic right now. I will loop back to make it more intuitive and simple to use. ## Things to know diff --git a/ScratchFiles/example.ps1 b/ScratchFiles/example.ps1 index fab56a3..47b3502 100644 --- a/ScratchFiles/example.ps1 +++ b/ScratchFiles/example.ps1 @@ -10,7 +10,8 @@ foreach($n in 1..10) "test string" sleep -Milliseconds 3 } + sleep -Milliseconds 12 -sleep -Milliseconds 5 +sleep -Milliseconds 120 +$test = 1+1 $test = 1+1 -$test = 1+1 \ No newline at end of file diff --git a/Tests/Help.Tests.ps1 b/Tests/Help.Tests.ps1 index 6f4d3fb..7a61ead 100644 --- a/Tests/Help.Tests.ps1 +++ b/Tests/Help.Tests.ps1 @@ -2,9 +2,10 @@ $projectRoot = Resolve-Path "$PSScriptRoot\.." $moduleRoot = Split-Path (Resolve-Path "$projectRoot\*\*.psm1") $moduleName = Split-Path $moduleRoot -Leaf -Import-Module (Join-Path $moduleRoot "$moduleName.psm1") -force Describe "Help tests for $moduleName" -Tags Build { + + Import-Module (Join-Path $moduleRoot "$moduleName.psm1") -force $functions = Get-Command -Module $moduleName $help = $functions | %{Get-Help $_.name} diff --git a/Tests/Project.Tests.ps1 b/Tests/Project.Tests.ps1 index 5ed72b3..c12f8bd 100644 --- a/Tests/Project.Tests.ps1 +++ b/Tests/Project.Tests.ps1 @@ -4,7 +4,7 @@ $moduleName = Split-Path $moduleRoot -Leaf Describe "General project validation: $moduleName" -Tags Build { - $scripts = Get-ChildItem $projectRoot -Include *.ps1,*.psm1,*.psd1 -Recurse + $scripts = Get-ChildItem $projectRoot -Include *.ps1,*.psm1,*.psd1 -Recurse | where fullname -notmatch 'classes' # TestCases are splatted to the script so we need hashtables $testCase = $scripts | Foreach-Object{@{file=$_}} @@ -19,6 +19,18 @@ Describe "General project validation: $moduleName" -Tags Build { $errors.Count | Should Be 0 } + It "Classes are valid" { + $classes = Get-ChildItem $projectRoot -Include *.ps1,*.psm1,*.psd1 -Recurse | where fullname -match 'classes' + + # Must be imported togehter incase they depend on each other + $contents = Get-Content -Path $classes.FullName | Out-String + + $errors = $null + $null = [System.Management.Automation.PSParser]::Tokenize($contents, [ref]$errors) + $errors.Count | Should Be 0 + } + + It "Module '$moduleName' can import cleanly" { {Import-Module (Join-Path $moduleRoot "$moduleName.psm1") -force } | Should Not Throw } diff --git a/Tests/Unit.Tests.ps1 b/Tests/Unit.Tests.ps1 index a67ea43..3d2b475 100644 --- a/Tests/Unit.Tests.ps1 +++ b/Tests/Unit.Tests.ps1 @@ -2,14 +2,33 @@ $projectRoot = Resolve-Path "$PSScriptRoot\.." $moduleRoot = Split-Path (Resolve-Path "$projectRoot\*\*.psd1") $moduleName = Split-Path $moduleRoot -Leaf -Import-Module (Join-Path $moduleRoot "$moduleName.psm1") -force - Describe "Basic unit tests" -Tags Build { + Import-Module (Join-Path $moduleRoot "$moduleName.psm1") -force + Context "Function: Get-Chronometer" { it "Does not throw" { + # Get-Chronometer -Path ScratchFiles\example.ps1 -Script {"Test"} {Get-Chronometer -Path $PSScriptRoot\..\ScratchFiles\example.ps1 -Script {"Test"} } | Should Not Throw } + + it "Executes a script and gives results" { + # Get-Chronometer -Path ScratchFiles\example.ps1 -Script {"Test"} + $results = Get-Chronometer -Path $PSScriptRoot\..\ScratchFiles\example.ps1 -Script {. "$PSScriptRoot\..\ScratchFiles\example.ps1"} + $results | Should Not BeNullOrEmpty + } + } + + Context "Function: Format-Chronometer" { + + it "Does not throw" { + {$null | Format-Chronometer } | Should Not Throw + } + + it "Can process a result object without throwing" { + $results = Get-Chronometer -Path $PSScriptRoot\..\ScratchFiles\example.ps1 -Script {. "$PSScriptRoot\..\ScratchFiles\example.ps1"} + $results | Format-Chronometer *>&1 | Should Not BeNullOrEmpty + } } InModuleScope $moduleName { @@ -18,6 +37,12 @@ Describe "Basic unit tests" -Tags Build { it "Creates an Object" { {[ScriptLine]::New()} | Should Not Throw } + it "ToString()" { + {[ScriptLine]::New().toString()} | Should Not Throw + } + it "Creates an Object" { + {[ScriptLine]::New().AddExecutionTime(1)} | Should Not Throw + } } Context "Class: ScriptProfiler" { @@ -25,7 +50,23 @@ Describe "Basic unit tests" -Tags Build { it "Creates an Object" { {[ScriptProfiler]::New()} | Should Not Throw } + it "Start()" { + {[ScriptProfiler]::Start()} | Should Not Throw + } } + + Context "Class: MonitoredScript" { + it "Creates an object" { + {[MonitoredScript]::New()} | Should Not Throw + } + + it "SetScript()" { + pushd $projectRoot + $monitor = [MonitoredScript]::New() + {$monitor.SetScript(".\scratchfiles\example.ps1")} | Should Not Throw + popd + } + } + } - } \ No newline at end of file diff --git a/psake.ps1 b/psake.ps1 index b51af94..cf732c6 100644 --- a/psake.ps1 +++ b/psake.ps1 @@ -33,7 +33,7 @@ Task Init { Task UnitTests -Depends Init { $lines 'Running quick unit tests to fail early if there is an error' - $TestResults = Invoke-Pester -Path $ProjectRoot\Tests\*unit* -PassThru -Tag Build -Show Failed + $TestResults = Invoke-Pester -Path $ProjectRoot\Tests\*unit* -PassThru -Tag Build if($TestResults.FailedCount -gt 0) { @@ -47,7 +47,7 @@ Task Test -Depends UnitTests { "`n`tSTATUS: Testing with PowerShell $PSVersion" # Gather test results. Store them in a variable and file - $TestResults = Invoke-Pester -Path $ProjectRoot\Tests -PassThru -OutputFormat NUnitXml -OutputFile "$ProjectRoot\$TestFile" -Tag Build -Show Failed + $TestResults = Invoke-Pester -Path $ProjectRoot\Tests -PassThru -OutputFormat NUnitXml -OutputFile "$ProjectRoot\$TestFile" -Tag Build # In Appveyor? Upload our tests! #Abstract this into a function? If($ENV:BHBuildSystem -eq 'AppVeyor')