@@ -21,6 +21,8 @@ import {
2121 formatPurgeWarning ,
2222 runPlannotatorUninstall ,
2323 type UninstallEnvironment ,
24+ WINDOWS_PATH_RESTORE_SCRIPT ,
25+ WINDOWS_PATH_SCRIPT ,
2426 WINDOWS_SELF_DELETE_SCRIPT ,
2527} from "./uninstall" ;
2628
@@ -78,6 +80,95 @@ describe("Windows self-delete worker", () => {
7880 } ) ;
7981} ) ;
8082
83+ describe ( "Windows PATH scripts" , ( ) => {
84+ const scripts = {
85+ remove : WINDOWS_PATH_SCRIPT ,
86+ restore : WINDOWS_PATH_RESTORE_SCRIPT ,
87+ } as const ;
88+
89+ test ( "edit the registry directly and never call the blocking .NET setter" , ( ) => {
90+ // SetEnvironmentVariable('Path', ..., 'User') broadcasts WM_SETTINGCHANGE
91+ // synchronously to every window and can stall past the 15 s command
92+ // timeout on a machine with a hung GUI process (the CI smoke flake).
93+ for ( const script of Object . values ( scripts ) ) {
94+ expect ( script ) . not . toContain ( "SetEnvironmentVariable" ) ;
95+ expect ( script ) . toContain ( "Microsoft.Win32.Registry" ) ;
96+ // The broadcast that replaces it must be bounded (SMTO_ABORTIFHUNG) and
97+ // must not be able to reach the exit code.
98+ expect ( script ) . toContain ( "SendMessageTimeout" ) ;
99+ expect ( script ) . toMatch ( / ' E n v i r o n m e n t ' , 0 x 2 , \d + , \[ r e f \] \$ r \) \} c a t c h \{ \} ; e x i t 0 $ / ) ;
100+ }
101+ // The value is read unexpanded and written back with its own kind so
102+ // %VARS% in unrelated entries survive.
103+ expect ( scripts . remove ) . toContain ( "DoNotExpandEnvironmentNames" ) ;
104+ expect ( scripts . remove ) . toContain ( "$k.SetValue('Path',$n,$kind)" ) ;
105+ // The completed-write echo must come after the write and before the
106+ // broadcast in BOTH scripts: it is what lets the caller trust a write
107+ // whose process was killed or faulted while broadcasting.
108+ const echoes = {
109+ remove : "Write-Output (ConvertTo-Json" ,
110+ restore : "Write-Output 'PLANNOTATOR_PATH_RESTORED'" ,
111+ } as const ;
112+ for ( const [ name , script ] of Object . entries ( scripts ) as Array <
113+ [ keyof typeof scripts , string ]
114+ > ) {
115+ const writeIndex = script . indexOf ( "$k.SetValue(" ) ;
116+ const echoIndex = script . indexOf ( echoes [ name ] ) ;
117+ const broadcastIndex = script . indexOf ( "SendMessageTimeout(" ) ;
118+ expect ( `${ name } : ${ writeIndex } ` ) . not . toBe ( `${ name } : -1` ) ;
119+ expect ( echoIndex ) . toBeGreaterThan ( writeIndex ) ;
120+ expect ( broadcastIndex ) . toBeGreaterThan ( echoIndex ) ;
121+ }
122+ } ) ;
123+
124+ test ( "stay single-quoted so they survive -Command argv quoting" , ( ) => {
125+ // Both scripts travel as one argv element to powershell.exe; a literal
126+ // double quote would be re-escaped by the spawn layer and break parsing.
127+ for ( const script of Object . values ( scripts ) ) {
128+ expect ( script ) . not . toContain ( '"' ) ;
129+ }
130+ } ) ;
131+
132+ const powershell =
133+ Bun . which ( "pwsh" ) ||
134+ Bun . which ( "pwsh.exe" ) ||
135+ Bun . which ( "powershell.exe" ) ||
136+ process . env . PLANNOTATOR_TEST_POWERSHELL ||
137+ null ;
138+
139+ test . skipIf ( ! powershell ) (
140+ "parse cleanly in a real PowerShell (no registry access)" ,
141+ async ( ) => {
142+ // Parser.ParseInput only parses; nothing is executed, so this touches
143+ // neither the registry nor the environment. Runs on Windows CI and on
144+ // any dev box with pwsh.
145+ for ( const [ name , script ] of Object . entries ( scripts ) ) {
146+ const proc = Bun . spawn (
147+ [
148+ powershell ! ,
149+ "-NoProfile" ,
150+ "-NonInteractive" ,
151+ "-Command" ,
152+ "$errors=$null; [void][System.Management.Automation.Language.Parser]::ParseInput($env:PLANNOTATOR_TEST_SCRIPT,[ref]$null,[ref]$errors); if($errors.Count -gt 0){$errors | ForEach-Object { Write-Output $_.Message }; exit 1}; exit 0" ,
153+ ] ,
154+ {
155+ stdin : "ignore" ,
156+ stdout : "pipe" ,
157+ stderr : "pipe" ,
158+ env : { ...process . env , PLANNOTATOR_TEST_SCRIPT : script } ,
159+ } ,
160+ ) ;
161+ const [ stdout , exitCode ] = await Promise . all ( [
162+ new Response ( proc . stdout ) . text ( ) ,
163+ proc . exited ,
164+ ] ) ;
165+ expect ( `${ name } : ${ stdout . trim ( ) } ` ) . toBe ( `${ name } : ` ) ;
166+ expect ( exitCode ) . toBe ( 0 ) ;
167+ }
168+ } ,
169+ ) ;
170+ } ) ;
171+
81172function createFixture (
82173 overrides : Partial < UninstallEnvironment > = { } ,
83174) : Fixture {
@@ -1541,12 +1632,231 @@ describe("host and platform integrations", () => {
15411632
15421633 expect ( result . ok ) . toBe ( false ) ;
15431634 expect ( result . errors ) . toContain (
1544- `Could not remove ${ dirname ( currentExe ) } from the Windows user PATH.` ,
1635+ `Could not remove ${ dirname ( currentExe ) } from the Windows user PATH (exit 1).` ,
1636+ ) ;
1637+ expect ( existsSync ( currentExe ) ) . toBe ( true ) ;
1638+ expect ( fixture . scheduledDeletes ) . toEqual ( [ ] ) ;
1639+ } ) ;
1640+
1641+ test ( "proceeds without a PATH error when the entry is not present (exit 3)" , async ( ) => {
1642+ const fixture = createFixture ( ) ;
1643+ const localAppData = join ( fixture . homeDir , "AppData" , "Local" ) ;
1644+ const currentExe = join ( localAppData , "plannotator" , "plannotator.exe" ) ;
1645+ writeText ( currentExe ) ;
1646+
1647+ const result = await runPlannotatorUninstall (
1648+ { purge : false , dryRun : false } ,
1649+ {
1650+ ...fixture . environment ,
1651+ platform : "win32" ,
1652+ execPath : currentExe ,
1653+ env : { LOCALAPPDATA : localAppData } ,
1654+ which : ( ) => "C:\\Windows\\powershell.exe" ,
1655+ runCommand : async ( ) => ( { exitCode : 3 , timedOut : false } ) ,
1656+ } ,
1657+ ) ;
1658+
1659+ expect ( result . ok ) . toBe ( true ) ;
1660+ expect ( result . errors ) . toEqual ( [ ] ) ;
1661+ expect ( result . removed ) . not . toContain (
1662+ `Windows user PATH entry ${ dirname ( currentExe ) } ` ,
1663+ ) ;
1664+ expect ( fixture . scheduledDeletes ) . toEqual ( [
1665+ { target : currentExe , parent : dirname ( currentExe ) } ,
1666+ ] ) ;
1667+ } ) ;
1668+
1669+ test ( "reports a timed-out PATH edit as a timeout and keeps the CLI" , async ( ) => {
1670+ const fixture = createFixture ( ) ;
1671+ const localAppData = join ( fixture . homeDir , "AppData" , "Local" ) ;
1672+ const currentExe = join ( localAppData , "plannotator" , "plannotator.exe" ) ;
1673+ writeText ( currentExe ) ;
1674+
1675+ const result = await runPlannotatorUninstall (
1676+ { purge : false , dryRun : false } ,
1677+ {
1678+ ...fixture . environment ,
1679+ platform : "win32" ,
1680+ execPath : currentExe ,
1681+ env : { LOCALAPPDATA : localAppData } ,
1682+ which : ( ) => "C:\\Windows\\powershell.exe" ,
1683+ // Killed before the script echoed anything: the edit is unproven.
1684+ runCommand : async ( ) => ( { exitCode : 124 , timedOut : true , stdout : "" } ) ,
1685+ } ,
1686+ ) ;
1687+
1688+ expect ( result . ok ) . toBe ( false ) ;
1689+ expect ( result . errors ) . toContain (
1690+ `Could not remove ${ dirname ( currentExe ) } from the Windows user PATH (command timed out).` ,
15451691 ) ;
15461692 expect ( existsSync ( currentExe ) ) . toBe ( true ) ;
15471693 expect ( fixture . scheduledDeletes ) . toEqual ( [ ] ) ;
15481694 } ) ;
15491695
1696+ test ( "treats a timeout after the rollback echo as a completed PATH edit" , async ( ) => {
1697+ const fixture = createFixture ( ) ;
1698+ const localAppData = join ( fixture . homeDir , "AppData" , "Local" ) ;
1699+ const currentExe = join ( localAppData , "plannotator" , "plannotator.exe" ) ;
1700+ writeText ( currentExe ) ;
1701+ const originalPath = `C:\\Before;${ dirname ( currentExe ) } ;C:\\After;;` ;
1702+
1703+ const result = await runPlannotatorUninstall (
1704+ { purge : false , dryRun : false } ,
1705+ {
1706+ ...fixture . environment ,
1707+ platform : "win32" ,
1708+ execPath : currentExe ,
1709+ env : { LOCALAPPDATA : localAppData } ,
1710+ which : ( ) => "C:\\Windows\\powershell.exe" ,
1711+ // The script echoes the original PATH only after the registry write,
1712+ // so an echo followed by a kill means only the broadcast stalled.
1713+ runCommand : async ( ) => ( {
1714+ exitCode : 124 ,
1715+ timedOut : true ,
1716+ stdout : `${ JSON . stringify ( originalPath ) } \n` ,
1717+ } ) ,
1718+ } ,
1719+ ) ;
1720+
1721+ const pathLabel = `Windows user PATH entry ${ dirname ( currentExe ) } ` ;
1722+ expect ( result . ok ) . toBe ( true ) ;
1723+ expect ( result . removed ) . toContain ( pathLabel ) ;
1724+ expect ( result . warnings . some ( ( w ) => w . includes ( "timed out" ) ) ) . toBe ( true ) ;
1725+ expect ( fixture . scheduledDeletes ) . toEqual ( [
1726+ { target : currentExe , parent : dirname ( currentExe ) } ,
1727+ ] ) ;
1728+ } ) ;
1729+
1730+ test ( "treats a non-zero exit after the rollback echo as a completed PATH edit" , async ( ) => {
1731+ const fixture = createFixture ( ) ;
1732+ const localAppData = join ( fixture . homeDir , "AppData" , "Local" ) ;
1733+ const currentExe = join ( localAppData , "plannotator" , "plannotator.exe" ) ;
1734+ writeText ( currentExe ) ;
1735+ const originalPath = `C:\\Before;${ dirname ( currentExe ) } ;C:\\After;;` ;
1736+
1737+ const result = await runPlannotatorUninstall (
1738+ { purge : false , dryRun : false } ,
1739+ {
1740+ ...fixture . environment ,
1741+ platform : "win32" ,
1742+ execPath : currentExe ,
1743+ env : { LOCALAPPDATA : localAppData } ,
1744+ which : ( ) => "C:\\Windows\\powershell.exe" ,
1745+ // A native fault inside Add-Type / SendMessageTimeout is not
1746+ // catchable and ends the process with an NTSTATUS code; the echo
1747+ // already on stdout still proves the write completed.
1748+ runCommand : async ( ) => ( {
1749+ exitCode : - 1073741819 ,
1750+ timedOut : false ,
1751+ stdout : `${ JSON . stringify ( originalPath ) } \n` ,
1752+ } ) ,
1753+ } ,
1754+ ) ;
1755+
1756+ const pathLabel = `Windows user PATH entry ${ dirname ( currentExe ) } ` ;
1757+ expect ( result . ok ) . toBe ( true ) ;
1758+ expect ( result . errors ) . toEqual ( [ ] ) ;
1759+ expect ( result . removed ) . toContain ( pathLabel ) ;
1760+ expect ( result . warnings . some ( ( w ) => w . includes ( "exit -1073741819" ) ) ) . toBe ( true ) ;
1761+ expect ( fixture . scheduledDeletes ) . toEqual ( [
1762+ { target : currentExe , parent : dirname ( currentExe ) } ,
1763+ ] ) ;
1764+ } ) ;
1765+
1766+ test ( "treats a restore that printed its sentinel as completed however the process ended" , async ( ) => {
1767+ for ( const ending of [
1768+ { exitCode : 124 , timedOut : true , needle : "timed out" } ,
1769+ { exitCode : - 1073741819 , timedOut : false , needle : "exit -1073741819" } ,
1770+ ] ) {
1771+ const fixture = createFixture ( ) ;
1772+ const localAppData = join ( fixture . homeDir , "AppData" , "Local" ) ;
1773+ const currentExe = join ( localAppData , "plannotator" , "plannotator.exe" ) ;
1774+ writeText ( currentExe ) ;
1775+ let commandCount = 0 ;
1776+
1777+ const result = await runPlannotatorUninstall (
1778+ { purge : false , dryRun : false } ,
1779+ {
1780+ ...fixture . environment ,
1781+ platform : "win32" ,
1782+ execPath : currentExe ,
1783+ env : { LOCALAPPDATA : localAppData } ,
1784+ which : ( ) => "C:\\Windows\\powershell.exe" ,
1785+ runCommand : async ( ) => {
1786+ commandCount += 1 ;
1787+ if ( commandCount === 1 ) {
1788+ return {
1789+ exitCode : 0 ,
1790+ timedOut : false ,
1791+ stdout : JSON . stringify (
1792+ `C:\\Before;${ dirname ( currentExe ) } ;C:\\After;;` ,
1793+ ) ,
1794+ } ;
1795+ }
1796+ return {
1797+ exitCode : ending . exitCode ,
1798+ timedOut : ending . timedOut ,
1799+ stdout : "PLANNOTATOR_PATH_RESTORED\r\n" ,
1800+ } ;
1801+ } ,
1802+ scheduleWindowsSelfDelete : async ( ) => false ,
1803+ } ,
1804+ ) ;
1805+
1806+ const pathLabel = `Windows user PATH entry ${ dirname ( currentExe ) } ` ;
1807+ expect ( result . ok ) . toBe ( false ) ;
1808+ expect ( existsSync ( currentExe ) ) . toBe ( true ) ;
1809+ expect ( result . errors . some ( ( e ) => e . includes ( "Could not restore" ) ) ) . toBe ( false ) ;
1810+ expect ( result . removed ) . not . toContain ( pathLabel ) ;
1811+ expect ( result . preserved ) . toContain ( `${ pathLabel } (restored for retry)` ) ;
1812+ expect (
1813+ result . warnings . some (
1814+ ( w ) => w . startsWith ( "Restored " ) && w . includes ( ending . needle ) ,
1815+ ) ,
1816+ ) . toBe ( true ) ;
1817+ }
1818+ } ) ;
1819+
1820+ test ( "reports a restore that never printed its sentinel as failed" , async ( ) => {
1821+ const fixture = createFixture ( ) ;
1822+ const localAppData = join ( fixture . homeDir , "AppData" , "Local" ) ;
1823+ const currentExe = join ( localAppData , "plannotator" , "plannotator.exe" ) ;
1824+ writeText ( currentExe ) ;
1825+ let commandCount = 0 ;
1826+
1827+ const result = await runPlannotatorUninstall (
1828+ { purge : false , dryRun : false } ,
1829+ {
1830+ ...fixture . environment ,
1831+ platform : "win32" ,
1832+ execPath : currentExe ,
1833+ env : { LOCALAPPDATA : localAppData } ,
1834+ which : ( ) => "C:\\Windows\\powershell.exe" ,
1835+ runCommand : async ( ) => {
1836+ commandCount += 1 ;
1837+ if ( commandCount === 1 ) {
1838+ return {
1839+ exitCode : 0 ,
1840+ timedOut : false ,
1841+ stdout : JSON . stringify (
1842+ `C:\\Before;${ dirname ( currentExe ) } ;C:\\After;;` ,
1843+ ) ,
1844+ } ;
1845+ }
1846+ return { exitCode : 124 , timedOut : true , stdout : "" } ;
1847+ } ,
1848+ scheduleWindowsSelfDelete : async ( ) => false ,
1849+ } ,
1850+ ) ;
1851+
1852+ const pathLabel = `Windows user PATH entry ${ dirname ( currentExe ) } ` ;
1853+ expect ( result . ok ) . toBe ( false ) ;
1854+ expect ( result . removed ) . toContain ( pathLabel ) ;
1855+ expect ( result . errors ) . toContain (
1856+ `Could not restore ${ dirname ( currentExe ) } to the Windows user PATH after self-delete scheduling failed (command timed out).` ,
1857+ ) ;
1858+ } ) ;
1859+
15501860 test ( "restores Windows PATH when scheduling self-delete fails" , async ( ) => {
15511861 const fixture = createFixture ( ) ;
15521862 const localAppData = join ( fixture . homeDir , "AppData" , "Local" ) ;
@@ -1615,7 +1925,7 @@ describe("host and platform integrations", () => {
16151925 expect ( existsSync ( currentExe ) ) . toBe ( true ) ;
16161926 expect ( result . removed ) . toContain ( pathLabel ) ;
16171927 expect ( result . errors ) . toContain (
1618- `Could not restore ${ dirname ( currentExe ) } to the Windows user PATH after self-delete scheduling failed.` ,
1928+ `Could not restore ${ dirname ( currentExe ) } to the Windows user PATH after self-delete scheduling failed (exit 1) .` ,
16191929 ) ;
16201930 expect ( result . warnings ) . toContain (
16211931 `The Plannotator CLI remains at ${ currentExe } , but its Windows PATH entry could not be restored. Run that full path to retry, then restore PATH manually if needed.` ,
0 commit comments