From dc922a4a61b85e0c4f7d64f94e16af288a44ad68 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:58:47 -0500 Subject: [PATCH 1/2] Exclude unintended side effects from the public contract An unintended side effect of how the module is built, such as state it kept in a global variable where any script could read it, is not functionality the module ever offered. Correcting one is not a breaking change and needs no deprecation, so say that in the contract's definition and in Deciding hard cases. AGENTS.md now points at that definition for when a changelog entry is a BREAKING CHANGE. --- AGENTS.md | 5 +++-- RELEASING.md | 14 +++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 61f030d..ce30a4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,8 +145,9 @@ covers when `[Unreleased]` becomes a version and how that version is chosen. - Prefix an entry with `Minor:` inside **Fixed** when a user probably never noticed it: help text wording, a message, an edge case needing unusual conditions to hit, or a cost the module absorbed itself such as an extra Microsoft Graph request. List the `Minor:` entries after the rest of the Fixed entries. -- Prefix an entry with `BREAKING CHANGE:` when it can break an existing script or configuration file, and say - what the user has to change. Such an entry usually belongs under **Changed** or **Removed**. +- Prefix an entry with `BREAKING CHANGE:` when it breaks the public contract as [RELEASING.md](./RELEASING.md) + defines it, so an existing script or configuration file can stop working, and say what the user has to + change. Such an entry usually belongs under **Changed** or **Removed**. - A new public function goes under **Added** and reads `- New Function: > `. - **No em dashes.** Use a semicolon, colon, parentheses, comma, or a new sentence. This applies to every file in the repo, not just the changelog. diff --git a/RELEASING.md b/RELEASING.md index df79607..ea940ca 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -22,7 +22,7 @@ uses the stricter Semantic Versioning rule: new functionality, including a new p ### The public contract -The public contract is anything a caller can observe or depend on: +The public contract is the behavior ScriptMessage offers its callers: - Exported functions and aliases, their parameters and parameter sets, and each parameter's requirements, defaults, validation, and meaning. @@ -35,11 +35,23 @@ The public contract is anything a caller can observe or depend on: - The external modules the module requires, such as the Microsoft Graph sub-modules each message type needs. - The permissions the module requests or needs, such as Microsoft Graph scopes. +A side effect of how the module happens to be built is not part of it, even when a caller can observe it. See +[Deciding hard cases](#deciding-hard-cases). + ### Deciding hard cases - **A fix that changes observable behavior** is a patch when the old behavior could not have worked for anyone, such as rejecting recipients that have no address, which never sent anything. It is a breaking change when a reasonable script could have depended on the old behavior. Either way, the changelog entry says what changed. +- **Correcting an unintended side effect is not a breaking change.** Behavior that exists only because of how the + module was built, and that no documentation ever offered (the help, the README, the wiki, the sample + configuration file, or the changelog), is outside the public contract even though a caller can observe or use + it. Examples: a variable the module kept in the global scope, where any script could read or change it, and a + true/false setting written as quoted text. Changing or removing such behavior needs no deprecation, and the + "reasonable script" test above does not apply. Naming something in an error message does not document it. + Behavior the module intends callers to use stays in the contract even where its documentation is thin, such as + the properties of the `Send-ScriptMessage` result. The change gets a changelog entry only when a script that + follows the documentation could notice it. - **Requiring a new module or permission is a breaking change**, because existing installations and app registrations stop working until someone acts. - **Deprecate before removing.** Mark functionality as deprecated in a minor release (documentation, a warning, From 4b0b7b5889ed19cb67e3c3028b6713a0f0e01737 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:05:33 -0500 Subject: [PATCH 2/2] Keep the module's state in module scope, not global Set-ScriptMessageConfigFilePath and the cached service context kept their state in global variables, so any script could read or change it, and it outlived Remove-Module. Both now live in $script: variables that the .psm1 declares, and VariablesToExport is an empty list. Callers get a supported way to read the path instead: Get-ScriptMessageConfig -ReturnConfigFilePath adds a ConfigFilePath property holding the full path of the file it read. The error for a missing path names the cmdlet to run rather than a variable. Importing the module again with -Force now clears the path, which the changelog records. Research note section 12 has the measurements of what module scope survives. The changelog's version separators also get the blank line MD022 wants, and AGENTS.md shows it in its example. --- AGENTS.md | 6 +- CHANGELOG.md | 16 +++++ .../PowerShell-Language-Behavior.md | 32 ++++++++++ .../Public/Disconnect-ScriptMessage.ps1 | 2 +- .../Public/Get-ScriptMessageConfig.ps1 | 29 ++++++++-- .../Public/Get-ScriptMessageContext.ps1 | 6 +- .../Set-ScriptMessageConfigFilePath.ps1 | 2 +- ScriptMessage/ScriptMessage.psd1 | 2 +- ScriptMessage/ScriptMessage.psm1 | 14 +++-- Tests/Get-ScriptMessageConfig.Tests.ps1 | 58 +++++++++++++++++++ Tests/ScriptMessage.Module.Tests.ps1 | 26 +++++++++ 11 files changed, 174 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ce30a4e..cb036c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,10 +127,12 @@ covers when `[Unreleased]` becomes a version and how that version is chosen. entry also adds `## [Unreleased](https://github.com/Sekers/ScriptMessage/compare/...develop)` at the top, where `` is `$Release` from the baseline commands above. Never add an empty one, and never leave one in a release: the Release workflow refuses to publish while it is there. -- Newest version first. A released version uses this heading shape, and a `---` line separates it from the next - (older) version: +- Newest version first. A released version uses this heading shape, and a `---` line, with a blank line on each + side of it, separates it from the next (older) version: ```markdown + --- + ## [1.2.0](https://github.com/Sekers/ScriptMessage/tree/1.2.0) - 2026-09-20 ### Fixed diff --git a/CHANGELOG.md b/CHANGELOG.md index ac1b678..558fabd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ - New Parameter: `Send-ScriptMessage -MailType` > Chooses how an email with more than one recipient is sent. It overrides the configuration file's `MailType` setting, and `Group` is used when neither one sets it. - `Group` sends one email to all of the To, CC, and BCC recipients. - `OneOnOne` sends each To, CC, and BCC recipient a separate email with only that recipient in To, so no recipient can see who else received it. A recipient listed more than once gets one email. +- New Parameter: `Get-ScriptMessageConfig -ReturnConfigFilePath` > Adds a `ConfigFilePath` property to the returned settings, holding the full path of the configuration file that was read. + +### Changed + +- Importing ScriptMessage again with `Import-Module -Force`, or removing and importing it, now clears the configuration file path set by `Set-ScriptMessageConfigFilePath`. Call `Set-ScriptMessageConfigFilePath` after importing the module. ### Fixed @@ -24,6 +29,7 @@ - `MgApp_CertificatePath` ran any PowerShell code written into it, so anyone who could edit the configuration file could run commands as the account sending messages. This affected every configuration file on PowerShell 7.4 and later, whatever authentication it was set up for. Only environment variables written as `$env:NAME` or `${env:NAME}` are expanded now, and the rest of the path is used exactly as written. --- + ## [1.1.1](https://github.com/Sekers/ScriptMessage/tree/1.1.1) - 2026-09-16 ### Changed @@ -44,6 +50,7 @@ - Minor: A configuration file whose path contains square brackets, such as a folder named `Scripts [old]`, can now be used. `Get-ScriptMessageConfig`, `Connect-ScriptMessage`, and `Send-ScriptMessage` stopped with "Can't find the JSON configuration file" and sent nothing. --- + ## [1.1.0](https://github.com/Sekers/ScriptMessage/tree/1.1.0) - (2025-10-16) ### Fixes @@ -68,6 +75,7 @@ Author: [**@Sekers**](https://github.com/Sekers) --- + ## [1.0.8](https://github.com/Sekers/ScriptMessage/tree/1.0.8) - (2025-09-18) ### Fixes @@ -90,6 +98,7 @@ Author: [**@Sekers**](https://github.com/Sekers) Author: [**@Sekers**](https://github.com/Sekers) --- + ## [1.0.7](https://github.com/Sekers/ScriptMessage/tree/1.0.7) - (2024-12-26) ### Features @@ -103,6 +112,7 @@ Author: [**@Sekers**](https://github.com/Sekers) Author: [**@Sekers**](https://github.com/Sekers) --- + ## [1.0.6](https://github.com/Sekers/ScriptMessage/tree/1.0.6) - (2024-12-19) ### Fixes @@ -123,6 +133,7 @@ Author: [**@Sekers**](https://github.com/Sekers) Author: [**@Sekers**](https://github.com/Sekers) --- + ## [1.0.5](https://github.com/Sekers/ScriptMessage/tree/1.0.5) - (2024-05-22) ### Features @@ -132,6 +143,7 @@ Author: [**@Sekers**](https://github.com/Sekers) Author: [**@Sekers**](https://github.com/Sekers) --- + ## [1.0.4](https://github.com/Sekers/ScriptMessage/tree/1.0.4) - (2024-05-21) ### Fixes @@ -141,6 +153,7 @@ Author: [**@Sekers**](https://github.com/Sekers) Author: [**@Sekers**](https://github.com/Sekers) --- + ## [1.0.3](https://github.com/Sekers/ScriptMessage/tree/1.0.3) - (2024-05-21) ### Features @@ -150,6 +163,7 @@ Author: [**@Sekers**](https://github.com/Sekers) Author: [**@Sekers**](https://github.com/Sekers) --- + ## [1.0.2](https://github.com/Sekers/ScriptMessage/tree/1.0.2) - (2024-05-21) ### Fixes @@ -159,6 +173,7 @@ Author: [**@Sekers**](https://github.com/Sekers) Author: [**@Sekers**](https://github.com/Sekers) --- + ## [1.0.1](https://github.com/Sekers/ScriptMessage/tree/1.0.1) - (2024-05-20) ### Fixes @@ -168,6 +183,7 @@ Author: [**@Sekers**](https://github.com/Sekers) Author: [**@Sekers**](https://github.com/Sekers) --- + ## [1.0.0](https://github.com/Sekers/ScriptMessage/tree/1.0.0) - (2024-05-20) ### Features diff --git a/Research_Notes/PowerShell-Language-Behavior.md b/Research_Notes/PowerShell-Language-Behavior.md index 44b223e..63e7d29 100644 --- a/Research_Notes/PowerShell-Language-Behavior.md +++ b/Research_Notes/PowerShell-Language-Behavior.md @@ -406,3 +406,35 @@ empty configuration file reads as `$null` without an error in both editions. stopped at, so an unquoted secret would appear in it. - What PowerShell 7 builds from the input it accepts but 5.1 rejects was not examined. - A file the account has no permission to read was not tested, only a folder given as the path. + +## 12. Measured: module-scope state across imports + +Measured on **2026-09-22**, same machine and editions as the header. Unlike the other sections, this one imported +a module, each run in a new `-NoProfile` process: an in-memory module built with `New-Module` for the first and +last rows, and this repository's module for the rest, loaded through `PSModulePath` so that it loaded automatically. At the +time, the module kept its cached context in a global variable that the .psm1 recreates on every import, so a +marker property added to that object showed whether a later import ran the .psm1 again. + +| Situation | Result | +| --- | --- | +| A function's default parameter value reads `$script:X`, and the function is called from the caller's scope or from a child scope | reads the module's value in both cases; `Get-Variable -Scope Global` does not show the variable | +| `Import-Module ScriptMessage` without `-Force`, after calling one of its functions loaded it automatically | the .psm1 does not run again; one module instance | +| Another module's code runs `Import-Module ScriptMessage` while it is loaded | the .psm1 does not run again; still one instance | +| `Import-Module ScriptMessage -Force` | the .psm1 runs again, so module-scope state starts over; one instance | +| `(Get-Module ScriptMessage).ExportedVariables` with `VariablesToExport = '*'` in the manifest and no `Export-ModuleMember` in the .psm1 | empty | +| `& (Get-Module ) { $script:X }` from outside the module | returns the module's value | + +So module-scope state survives everything except a forced import (or `Remove-Module` followed by an import), +while a global variable survives even that. + +**From source:** `Set-ScriptMessageConfigFilePath` stores the path in `$script:ScriptMessageConfigFilePath`, which +`ScriptMessage.psm1` declares, and `Get-ScriptMessageConfig` uses it as the default for `-Path`, so importing the +module again with `-Force` clears the path. `Get-ScriptMessageContext` and `Disconnect-ScriptMessage` share +`$script:ScriptMessageCachedServiceContext` the same way. `ScriptMessage.psd1` sets `VariablesToExport` to an +empty list, which the last-but-one row shows changes nothing for callers. The last row is not a supported way to +read the path; `Get-ScriptMessageConfig -ReturnConfigFilePath` is. + +### What this does not establish + +- Two versions of the module loaded side by side, and `Import-Module -Scope Local`, were not tested. +- Other runspaces (jobs, `ForEach-Object -Parallel`) were not tested. diff --git a/ScriptMessage/Public/Disconnect-ScriptMessage.ps1 b/ScriptMessage/Public/Disconnect-ScriptMessage.ps1 index 5d5afc5..598d216 100644 --- a/ScriptMessage/Public/Disconnect-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Disconnect-ScriptMessage.ps1 @@ -44,7 +44,7 @@ function Disconnect-ScriptMessage # Drop this service's cached context. 'Get-ScriptMessageContext -ReturnCachedContext' reads that cache, so # leaving an entry behind would keep reporting the connection this call just ended. Removing a name the # cache does not hold does nothing, which is the case when nothing has asked for the context yet. - $ScriptMessage_Global_CachedServiceContext.PSObject.Properties.Remove([string]$Service) + $script:ScriptMessageCachedServiceContext.PSObject.Properties.Remove([string]$Service) # Return the disconnection information, if requested. if ($ReturnConnectionInfo) diff --git a/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 b/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 index 752ee16..ec27f81 100644 --- a/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 +++ b/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 @@ -14,6 +14,8 @@ function Get-ScriptMessageConfig Optional. If not provided, the function will use the path used in the current session (if set). .PARAMETER Service Optional. Return only the info related to a specific service. + .PARAMETER ReturnConfigFilePath + Adds a ConfigFilePath property to the result, holding the full path of the configuration file that was read. .EXAMPLE Get-ScriptMessageConfig @@ -21,6 +23,8 @@ function Get-ScriptMessageConfig Get-ScriptMessageConfig -Path '.\Config\config_scriptmessage.json' .EXAMPLE Get-ScriptMessageConfig -Service MicrosoftGraph + .EXAMPLE + Get-ScriptMessageConfig -Service MicrosoftGraph -ReturnConfigFilePath #> [CmdletBinding()] @@ -29,20 +33,24 @@ function Get-ScriptMessageConfig Position=0, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] - [string]$Path = $ScriptMessage_Global_ConfigFilePath, # If not entered will see if it can pull path from this variable. + [string]$Path = $script:ScriptMessageConfigFilePath, # The path set by Set-ScriptMessageConfigFilePath. [Parameter( Position=1, Mandatory = $false, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] - [MessagingService]$Service + [MessagingService]$Service, + + [Parameter( + Mandatory = $false)] + [switch]$ReturnConfigFilePath ) - + # Make Sure Requested Path Isn't Null or Empty (better to catch it here than validating on the parameter of this function) if ([string]::IsNullOrEmpty($Path)) { - throw "`'`$ScriptMessage_Global_ConfigFilePath`' is not specified. Don't forget to first use the `'Set-ScriptMessageConfigFilePath`' cmdlet!" + throw "No ScriptMessage configuration file path is set. Run `'Set-ScriptMessageConfigFilePath`' first." } # Read the configuration file. @@ -93,10 +101,19 @@ function Get-ScriptMessageConfig if (-not ($null -eq $Service)) { - return $ScriptMessageConfig.$Service + $Result = $ScriptMessageConfig.$Service } else { - return $ScriptMessageConfig + $Result = $ScriptMessageConfig } + + # No -Force, so a configuration file that has its own 'ConfigFilePath' setting fails here rather than having it + # silently replaced. A missing service section leaves nothing to add the path to. + if ($ReturnConfigFilePath -and ($null -ne $Result)) + { + $Result | Add-Member -MemberType NoteProperty -Name 'ConfigFilePath' -Value $FullPath + } + + return $Result } \ No newline at end of file diff --git a/ScriptMessage/Public/Get-ScriptMessageContext.ps1 b/ScriptMessage/Public/Get-ScriptMessageContext.ps1 index 9a4d964..c9a3d12 100644 --- a/ScriptMessage/Public/Get-ScriptMessageContext.ps1 +++ b/ScriptMessage/Public/Get-ScriptMessageContext.ps1 @@ -42,7 +42,7 @@ function Get-ScriptMessageContext $ScriptMessageContext = New-Object System.Object # Enable a force refresh if no data exists for the specified service or if $ReturnCachedContext is not present. - if (($null -eq $ScriptMessage_Global_CachedServiceContext.$Service) -or (-not $ReturnCachedContext.IsPresent)) + if (($null -eq $script:ScriptMessageCachedServiceContext.$Service) -or (-not $ReturnCachedContext.IsPresent)) { $RefreshContext = $true } @@ -73,11 +73,11 @@ function Get-ScriptMessageContext } # Update cached context data for the specified service. - $ScriptMessage_Global_CachedServiceContext | Add-Member -MemberType NoteProperty -Name $Service -Value $ScriptMessageContext -Force # Force allows overwriting existing members. + $script:ScriptMessageCachedServiceContext | Add-Member -MemberType NoteProperty -Name $Service -Value $ScriptMessageContext -Force # Force allows overwriting existing members. } else { - $ScriptMessageContext = $ScriptMessage_Global_CachedServiceContext.$Service + $ScriptMessageContext = $script:ScriptMessageCachedServiceContext.$Service } return $ScriptMessageContext diff --git a/ScriptMessage/Public/Set-ScriptMessageConfigFilePath.ps1 b/ScriptMessage/Public/Set-ScriptMessageConfigFilePath.ps1 index 061d1a4..e8a6bd5 100644 --- a/ScriptMessage/Public/Set-ScriptMessageConfigFilePath.ps1 +++ b/ScriptMessage/Public/Set-ScriptMessageConfigFilePath.ps1 @@ -28,5 +28,5 @@ function Set-ScriptMessageConfigFilePath [string]$Path ) - New-Variable -Name 'ScriptMessage_Global_ConfigFilePath' -Value $Path -Scope Global -Force + $script:ScriptMessageConfigFilePath = $Path } \ No newline at end of file diff --git a/ScriptMessage/ScriptMessage.psd1 b/ScriptMessage/ScriptMessage.psd1 index 9a8b542..1df07a5 100644 --- a/ScriptMessage/ScriptMessage.psd1 +++ b/ScriptMessage/ScriptMessage.psd1 @@ -82,7 +82,7 @@ FunctionsToExport = @( CmdletsToExport = @() # Variables to export from this module -VariablesToExport = '*' +VariablesToExport = @() # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. AliasesToExport = @() diff --git a/ScriptMessage/ScriptMessage.psm1 b/ScriptMessage/ScriptMessage.psm1 index e0216f7..95657ee 100644 --- a/ScriptMessage/ScriptMessage.psm1 +++ b/ScriptMessage/ScriptMessage.psm1 @@ -1,9 +1,13 @@ -# Global Variables -New-Variable -Name 'ScriptMessage_Global_CachedServiceContext' -Value ([PSCustomObject]@{}) -Scope Global -Force - # Module Variables -# Each file in Services/ registers itself here as it is dot-sourced below. Module scope rather than global: -# nothing outside the module reads it, and it is rebuilt from scratch on every import. +# Module scope rather than global, so callers can neither see nor change them, and each import starts them fresh. + +# The configuration file path set by Set-ScriptMessageConfigFilePath, read by Get-ScriptMessageConfig. +$script:ScriptMessageConfigFilePath = $null + +# Each service's context from Get-ScriptMessageContext. Disconnect-ScriptMessage removes a service's entry. +$script:ScriptMessageCachedServiceContext = [PSCustomObject]@{} + +# Each file in Services/ registers itself here as it is dot-sourced below. $script:ScriptMessageServiceTable = [ordered]@{} # Aliases diff --git a/Tests/Get-ScriptMessageConfig.Tests.ps1 b/Tests/Get-ScriptMessageConfig.Tests.ps1 index e6cea73..66252f5 100644 --- a/Tests/Get-ScriptMessageConfig.Tests.ps1 +++ b/Tests/Get-ScriptMessageConfig.Tests.ps1 @@ -105,6 +105,64 @@ Describe 'Get-ScriptMessageConfig paths' { } } +Describe 'Get-ScriptMessageConfig and the stored path' { + BeforeAll { + $AsciiName = 'Stored path test' + } + + # Importing the module again clears the path, so each test starts with none set. + BeforeEach { + Import-Module (Join-Path $PSScriptRoot '..\ScriptMessage\ScriptMessage.psd1') -Force + } + + It 'reads the file set by Set-ScriptMessageConfigFilePath' { + $Path = Join-Path $TestDrive 'stored.json' + New-TestConfigFile -Path $Path -CertificateName $AsciiName -Encoding ([System.Text.UTF8Encoding]::new($false)) + Set-ScriptMessageConfigFilePath -Path $Path + + (Get-ScriptMessageConfig -Service MicrosoftGraph).MgApp_CertificateName | Should -BeExactly $AsciiName + } + + It 'reports that no path is set' { + { Get-ScriptMessageConfig } | Should -Throw -ExpectedMessage 'No ScriptMessage configuration file path is set*' + } + + It 'adds the full path of the file it read with -ReturnConfigFilePath' { + New-TestConfigFile -Path (Join-Path $TestDrive 'relative.json') -CertificateName $AsciiName -Encoding ([System.Text.UTF8Encoding]::new($false)) + + Push-Location -LiteralPath $TestDrive + try + { + Set-ScriptMessageConfigFilePath -Path '.\relative.json' + $Config = Get-ScriptMessageConfig -ReturnConfigFilePath + } + finally + { + Pop-Location + } + + $Config.MicrosoftGraph.MgApp_CertificateName | Should -BeExactly $AsciiName + $Config.ConfigFilePath | Should -Be (Join-Path $TestDrive 'relative.json') + } + + It 'adds the path to the service section returned for -Service' { + $Path = Join-Path $TestDrive 'service.json' + New-TestConfigFile -Path $Path -CertificateName $AsciiName -Encoding ([System.Text.UTF8Encoding]::new($false)) + + $Config = Get-ScriptMessageConfig -Path $Path -Service MicrosoftGraph -ReturnConfigFilePath + + $Config.MgApp_CertificateName | Should -BeExactly $AsciiName + $Config.ConfigFilePath | Should -Be $Path + } + + It 'adds no path without -ReturnConfigFilePath' { + $Path = Join-Path $TestDrive 'no-switch.json' + New-TestConfigFile -Path $Path -CertificateName $AsciiName -Encoding ([System.Text.UTF8Encoding]::new($false)) + + (Get-ScriptMessageConfig -Path $Path).PSObject.Properties.Name | Should -Not -Contain 'ConfigFilePath' + } +} + Describe 'Get-ScriptMessageConfig errors' { It 'reports a folder that does not exist' { { Get-ScriptMessageConfig -Path (Join-Path $TestDrive 'No Such Folder\config.json') } | diff --git a/Tests/ScriptMessage.Module.Tests.ps1 b/Tests/ScriptMessage.Module.Tests.ps1 index ab77fc5..2bdcd17 100644 --- a/Tests/ScriptMessage.Module.Tests.ps1 +++ b/Tests/ScriptMessage.Module.Tests.ps1 @@ -18,6 +18,32 @@ Describe 'ScriptMessage module' { ($ExportedFunctions -join ', ') | Should -Be ($PublicFunctions -join ', ') } + # A new runspace has its own global scope, so variables left by the other test files' imports cannot hide one + # created here. The script runs in a local scope so that its own variables are not global either. + It 'creates no global variables' { + $Script = { + param($ManifestPath, $ConfigPath) + + $Before = @(Get-Variable -Scope Global | ForEach-Object { $_.Name }) + Import-Module $ManifestPath + Set-ScriptMessageConfigFilePath -Path $ConfigPath + @(Get-Variable -Scope Global | Where-Object { $_.Name -notin $Before } | ForEach-Object { $_.Name }) + } + + $PowerShell = [PowerShell]::Create() + try + { + $null = $PowerShell.AddScript($Script.ToString(), $true).AddArgument($ManifestPath).AddArgument((Join-Path $TestDrive 'config.json')) + $NewGlobals = $PowerShell.Invoke() + $PowerShell.Streams.Error | Should -BeNullOrEmpty + $NewGlobals | Should -BeNullOrEmpty + } + finally + { + $PowerShell.Dispose() + } + } + # PSScriptAnalyzer is optional locally. The check runs under PowerShell 7 only, because Windows PowerShell 5.1 # cannot see the copy the CI workflow installs; the analyzer has the same rules in both editions, and syntax # that only PowerShell 7 accepts already fails the 5.1 run when the module imports.