ruby-pwsh 0.10.1 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,823 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'ruby-pwsh'
5
+
6
+ module Pwsh
7
+ class Manager; end
8
+ if Pwsh::Util.on_windows?
9
+ module WindowsAPI
10
+ require 'ffi'
11
+ extend FFI::Library
12
+
13
+ ffi_convention :stdcall
14
+
15
+ # https://msdn.microsoft.com/en-us/library/ks2530z6%28v=VS.100%29.aspx
16
+ # intptr_t _get_osfhandle(
17
+ # int fd
18
+ # );
19
+ ffi_lib [FFI::CURRENT_PROCESS, 'msvcrt']
20
+ attach_function :get_osfhandle, :_get_osfhandle, [:int], :uintptr_t
21
+
22
+ # http://msdn.microsoft.com/en-us/library/windows/desktop/ms724211(v=vs.85).aspx
23
+ # BOOL WINAPI CloseHandle(
24
+ # _In_ HANDLE hObject
25
+ # );
26
+ ffi_lib :kernel32
27
+ attach_function :CloseHandle, [:uintptr_t], :int32
28
+ end
29
+ end
30
+ end
31
+
32
+ RSpec.shared_examples 'a PowerShellCodeManager' do |ps_command, ps_args|
33
+ describe Pwsh::Manager do
34
+ def line_end
35
+ Pwsh::Util.on_windows? ? "\r\n" : "\n"
36
+ end
37
+
38
+ def is_osx?
39
+ # Note this test fails if running in JRuby, but because the unit tests are MRI only, this is ok
40
+ !RUBY_PLATFORM.match(/darwin/).nil?
41
+ end
42
+
43
+ let(:manager) { Pwsh::Manager.instance(ps_command, ps_args) }
44
+
45
+ describe 'when managing the powershell process' do
46
+ describe 'the Manager::instance method' do
47
+ it 'returns the same manager instance / process given the same cmd line and options' do
48
+ first_pid = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
49
+
50
+ manager_two = Pwsh::Manager.instance(ps_command, ps_args)
51
+ second_pid = manager_two.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
52
+
53
+ expect(manager_two).to eq(manager)
54
+ expect(first_pid).to eq(second_pid)
55
+ end
56
+
57
+ it 'returns different manager instances / processes given the same cmd line and different options' do
58
+ first_pid = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
59
+
60
+ manager_two = Pwsh::Manager.instance(ps_command, ps_args, { some_option: 'foo' })
61
+ second_pid = manager_two.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
62
+
63
+ expect(manager_two).not_to eq(manager)
64
+ expect(first_pid).not_to eq(second_pid)
65
+ end
66
+
67
+ it 'fails if the manger is created with a short timeout' do
68
+ expect { Pwsh::Manager.new(ps_command, ps_args, debug: false, pipe_timeout: 0.01) }.to raise_error do |e|
69
+ expect(e).to be_a(RuntimeError)
70
+ expected_error = /Failure waiting for PowerShell process (\d+) to start pipe server/
71
+ expect(e.message).to match expected_error
72
+ pid = expected_error.match(e.message)[1].to_i
73
+
74
+ # We want to make sure that enough time has elapsed since the manager called kill
75
+ # for the OS to finish killing the process and doing all of it's cleanup.
76
+ # We have found that without an appropriate wait period, the kill call below
77
+ # can return unexpected results and fail the test.
78
+ sleep(1)
79
+ expect { Process.kill(0, pid) }.to raise_error(Errno::ESRCH)
80
+ end
81
+ end
82
+
83
+ def bad_file_descriptor_regex
84
+ # Ruby can do something like:
85
+ # <Errno::EBADF: Bad file descriptor>
86
+ # <Errno::EBADF: Bad file descriptor @ io_fillbuf - fd:10 >
87
+ @bad_file_descriptor_regex ||= begin
88
+ ebadf = Errno::EBADF.new
89
+ "^#{Regexp.escape("\#<#{ebadf.class}: #{ebadf.message}")}"
90
+ end
91
+ end
92
+
93
+ def pipe_error_regex
94
+ @pipe_error_regex ||= begin
95
+ epipe = Errno::EPIPE.new
96
+ "^#{Regexp.escape("\#<#{epipe.class}: #{epipe.message}")}"
97
+ end
98
+ end
99
+
100
+ # reason should be a string for an exact match
101
+ # else an array of regex matches
102
+ def expect_dead_manager(manager, reason, style = :exact)
103
+ # additional attempts to use the manager will fail for the given reason
104
+ result = manager.execute('Write-Host "hi"')
105
+ expect(result[:exitcode]).to eq(-1)
106
+
107
+ case reason
108
+ when String
109
+ expect(result[:stderr][0]).to eq(reason) if style == :exact
110
+ expect(result[:stderr][0]).to match(reason) if style == :regex
111
+ when Array
112
+ expect(reason).to include(result[:stderr][0]) if style == :exact
113
+ if style == :regex
114
+ expect(result[:stderr][0]).to satisfy("should match expected error(s): #{reason}") do |msg|
115
+ reason.any? { |m| msg.match m }
116
+ end
117
+ end
118
+ end
119
+
120
+ # and the manager no longer considers itself alive
121
+ expect(manager.alive?).to eq(false)
122
+ end
123
+
124
+ def expect_different_manager_returned_than(manager, pid)
125
+ # acquire another manager instance using the same command and arguments
126
+ new_manager = Pwsh::Manager.instance(manager.powershell_command, manager.powershell_arguments, debug: true)
127
+
128
+ # which should be different than the one passed in
129
+ expect(new_manager).to_not eq(manager)
130
+
131
+ # with a different PID
132
+ second_pid = new_manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
133
+ expect(pid).to_not eq(second_pid)
134
+ end
135
+
136
+ def close_stream(stream, style = :inprocess)
137
+ case style
138
+ when :inprocess
139
+ stream.close
140
+ when :viahandle
141
+ handle = Pwsh::WindowsAPI.get_osfhandle(stream.fileno)
142
+ Pwsh::WindowsAPI.CloseHandle(handle)
143
+ end
144
+ end
145
+
146
+ it 'creates a new PowerShell manager host if user code exits the first process' do
147
+ first_pid = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
148
+ exitcode = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Kill()')[:exitcode]
149
+
150
+ # when a process gets torn down out from under manager before reading stdout
151
+ # it catches the error and returns a -1 exitcode
152
+ expect(exitcode).to eq(-1)
153
+
154
+ expect_dead_manager(manager, pipe_error_regex, :regex)
155
+
156
+ expect_different_manager_returned_than(manager, first_pid)
157
+ end
158
+
159
+ it 'creates a new PowerShell manager host if the underlying PowerShell process is killed' do
160
+ first_pid = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
161
+ # kill the PID from Ruby
162
+ # Note - On Windows, creating the powershell manager starts one process, whereas on unix it starts two (one via sh and one via pwsh). Not sure why
163
+ # So instead kill the parent process instead of the child
164
+ Process.kill('KILL', first_pid.to_i)
165
+
166
+ # Windows uses named pipes, unix uses sockets
167
+ if Pwsh::Util.on_windows?
168
+ expect_dead_manager(manager, pipe_error_regex, :regex)
169
+ else
170
+ # WSL raises an EOFError
171
+ # Ubuntu 16.04 raises an ECONNRESET:Connection reset by peer
172
+ expect_dead_manager(manager,
173
+ [EOFError.new('end of file reached').inspect, Errno::ECONNRESET.new.inspect],
174
+ :exact)
175
+ end
176
+
177
+ expect_different_manager_returned_than(manager, first_pid)
178
+ end
179
+
180
+ context 'on Windows', if: Pwsh::Util.on_windows? do
181
+ it 'creates a new PowerShell manager host if the input stream is closed' do
182
+ first_pid = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
183
+
184
+ # closing pipe from the Ruby side tears down the process
185
+ close_stream(manager.instance_variable_get(:@pipe), :inprocess)
186
+
187
+ expect_dead_manager(manager, IOError.new('closed stream').inspect, :exact)
188
+
189
+ expect_different_manager_returned_than(manager, first_pid)
190
+ end
191
+
192
+ it 'creates a new PowerShell manager host if the input stream handle is closed' do
193
+ first_pid = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
194
+
195
+ # call CloseHandle against pipe, therby tearing down the PowerShell process
196
+ close_stream(manager.instance_variable_get(:@pipe), :viahandle)
197
+
198
+ expect_dead_manager(manager, bad_file_descriptor_regex, :regex)
199
+
200
+ expect_different_manager_returned_than(manager, first_pid)
201
+ end
202
+
203
+ it 'creates a new PowerShell manager host if the output stream is closed' do
204
+ first_pid = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
205
+
206
+ # closing stdout from the Ruby side allows process to run
207
+ close_stream(manager.instance_variable_get(:@stdout), :inprocess)
208
+
209
+ # fails with vanilla EPIPE or closed stream IOError depening on timing / Ruby version
210
+ msgs = [Errno::EPIPE.new.inspect, IOError.new('closed stream').inspect]
211
+ expect_dead_manager(manager, msgs, :exact)
212
+
213
+ expect_different_manager_returned_than(manager, first_pid)
214
+ end
215
+
216
+ it 'creates a new PowerShell manager host if the output stream handle is closed' do
217
+ # currently skipped as it can trigger an internal Ruby thread clean-up race
218
+ # its unknown why this test fails, but not the identical test against @stderr
219
+ skip('This test can cause intermittent segfaults in Ruby with w32_reset_event invalid handle')
220
+ first_pid = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
221
+
222
+ # call CloseHandle against stdout, which leaves PowerShell process running
223
+ close_stream(manager.instance_variable_get(:@stdout), :viahandle)
224
+
225
+ # fails with vanilla EPIPE or various EBADF depening on timing / Ruby version
226
+ msgs = [
227
+ "^#{Regexp.escape(Errno::EPIPE.new.inspect)}",
228
+ bad_file_descriptor_regex
229
+ ]
230
+ expect_dead_manager(manager, msgs, :regex)
231
+
232
+ expect_different_manager_returned_than(manager, first_pid)
233
+ end
234
+
235
+ it 'creates a new PowerShell manager host if the error stream is closed' do
236
+ first_pid = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
237
+
238
+ # closing stderr from the Ruby side allows process to run
239
+ close_stream(manager.instance_variable_get(:@stderr), :inprocess)
240
+
241
+ # fails with vanilla EPIPE or closed stream IOError depening on timing / Ruby version
242
+ msgs = [Errno::EPIPE.new.inspect, IOError.new('closed stream').inspect]
243
+ expect_dead_manager(manager, msgs, :exact)
244
+
245
+ expect_different_manager_returned_than(manager, first_pid)
246
+ end
247
+
248
+ it 'creates a new PowerShell manager host if the error stream handle is closed' do
249
+ first_pid = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
250
+
251
+ # call CloseHandle against stderr, which leaves PowerShell process running
252
+ close_stream(manager.instance_variable_get(:@stderr), :viahandle)
253
+
254
+ # fails with vanilla EPIPE or various EBADF depening on timing / Ruby version
255
+ msgs = [
256
+ "^#{Regexp.escape(Errno::EPIPE.new.inspect)}",
257
+ bad_file_descriptor_regex
258
+ ]
259
+ expect_dead_manager(manager, msgs, :regex)
260
+
261
+ expect_different_manager_returned_than(manager, first_pid)
262
+ end
263
+ end
264
+ end
265
+ end
266
+
267
+ let(:powershell_runtime_error) { '$ErrorActionPreference = "Stop";$test = 1/0' }
268
+ let(:powershell_parseexception_error) { '$ErrorActionPreference = "Stop";if (1 -badoperator 2) { Exit 1 }' }
269
+ let(:powershell_incompleteparseexception_error) { '$ErrorActionPreference = "Stop";if (1 -eq 2) { ' }
270
+
271
+ describe 'when provided powershell commands' do
272
+ it 'shows ps version' do
273
+ result = manager.execute('$psversiontable')
274
+ puts result[:stdout]
275
+ end
276
+
277
+ it 'should return simple output' do
278
+ result = manager.execute('write-output foo')
279
+
280
+ expect(result[:stdout]).to eq("foo#{line_end}")
281
+ expect(result[:exitcode]).to eq(0)
282
+ end
283
+
284
+ it 'returns the exitcode specified' do
285
+ result = manager.execute('write-output foo; exit 55')
286
+
287
+ expect(result[:stdout]).to eq("foo#{line_end}")
288
+ expect(result[:exitcode]).to eq(55)
289
+ end
290
+
291
+ it 'returns the exitcode 1 when exception is thrown' do
292
+ result = manager.execute('throw "foo"')
293
+
294
+ expect(result[:stdout]).to eq(nil)
295
+ expect(result[:exitcode]).to eq(1)
296
+ end
297
+
298
+ it 'returns the exitcode of the last command to set an exit code' do
299
+ result = if Pwsh::Util.on_windows?
300
+ manager.execute("$LASTEXITCODE = 0; write-output 'foo'; cmd.exe /c 'exit 99'; write-output 'bar'")
301
+ else
302
+ manager.execute("$LASTEXITCODE = 0; write-output 'foo'; /bin/sh -c 'exit 99'; write-output 'bar'")
303
+ end
304
+
305
+ expect(result[:stdout]).to eq("foo#{line_end}bar#{line_end}")
306
+ expect(result[:exitcode]).to eq(99)
307
+ end
308
+
309
+ it 'returns the exitcode of a script invoked with the call operator &' do
310
+ fixture_path = File.expand_path("#{File.dirname(__FILE__)}/../exit-27.ps1")
311
+ result = manager.execute("& #{fixture_path}")
312
+
313
+ expect(result[:stdout]).to eq(nil)
314
+ expect(result[:exitcode]).to eq(27)
315
+ end
316
+
317
+ it 'collects anything written to stderr' do
318
+ result = manager.execute('[System.Console]::Error.WriteLine("foo")')
319
+
320
+ expect(result[:stderr]).to eq(["foo#{line_end}"])
321
+ expect(result[:exitcode]).to eq(0)
322
+ end
323
+
324
+ it 'collects multiline output written to stderr' do
325
+ # induce a failure in cmd.exe that emits a multi-iline error message
326
+ result = if Pwsh::Util.on_windows?
327
+ manager.execute('cmd.exe /c foo.exe')
328
+ else
329
+ manager.execute('/bin/sh -c "echo bar 1>&2 && foo.exe"')
330
+ end
331
+
332
+ expect(result[:stdout]).to eq(nil)
333
+ if Pwsh::Util.on_windows?
334
+ expect(result[:stderr]).to eq(["'foo.exe' is not recognized as an internal or external command,\r\noperable program or batch file.\r\n"])
335
+ elsif is_osx?
336
+ expect(result[:stderr][0]).to match(/foo\.exe: command not found/)
337
+ expect(result[:stderr][0]).to match(/bar/)
338
+ else
339
+ expect(result[:stderr][0]).to match(/foo\.exe: not found/)
340
+ expect(result[:stderr][0]).to match(/bar/)
341
+ end
342
+ expect(result[:exitcode]).to_not eq(0)
343
+ end
344
+
345
+ it 'handles writting to stdout (cmdlet) and stderr' do
346
+ result = manager.execute('Write-Host "powershell";[System.Console]::Error.WriteLine("foo")')
347
+
348
+ expect(result[:stdout]).not_to eq(nil)
349
+ expect(result[:native_stdout]).to eq(nil)
350
+ expect(result[:stderr]).to eq(["foo#{line_end}"])
351
+ expect(result[:exitcode]).to eq(0)
352
+ end
353
+
354
+ it 'handles writting to stdout (shell out to another program) and stderr' do
355
+ result = if Pwsh::Util.on_windows?
356
+ manager.execute('cmd.exe /c echo powershell;[System.Console]::Error.WriteLine("foo")')
357
+ else
358
+ manager.execute('/bin/sh -c "echo powershell";[System.Console]::Error.WriteLine("foo")')
359
+ end
360
+
361
+ expect(result[:stdout]).to eq(nil)
362
+ expect(result[:native_stdout]).not_to eq(nil)
363
+ expect(result[:stderr]).to eq(["foo#{line_end}"])
364
+ expect(result[:exitcode]).to eq(0)
365
+ end
366
+
367
+ it 'handles writing to stdout natively' do
368
+ result = manager.execute('[System.Console]::Out.WriteLine("foo")')
369
+
370
+ expect(result[:stdout]).to eq("foo#{line_end}")
371
+ expect(result[:native_stdout]).to eq(nil)
372
+ expect(result[:stderr]).to eq([])
373
+ expect(result[:exitcode]).to eq(0)
374
+ end
375
+
376
+ it 'properly interleaves output written natively to stdout and via Write-XXX cmdlets' do
377
+ result = manager.execute('Write-Output "bar"; [System.Console]::Out.WriteLine("foo"); Write-Warning "baz";')
378
+
379
+ expect(result[:stdout]).to eq("bar#{line_end}foo#{line_end}WARNING: baz#{line_end}")
380
+ expect(result[:stderr]).to eq([])
381
+ expect(result[:exitcode]).to eq(0)
382
+ end
383
+
384
+ it 'handles writing to regularly captured output AND stdout natively' do
385
+ result = manager.execute('Write-Host "powershell";[System.Console]::Out.WriteLine("foo")')
386
+
387
+ expect(result[:stdout]).not_to eq("foo#{line_end}")
388
+ expect(result[:native_stdout]).to eq(nil)
389
+ expect(result[:stderr]).to eq([])
390
+ expect(result[:exitcode]).to eq(0)
391
+ end
392
+
393
+ it 'handles writing to regularly captured output, stderr AND stdout natively' do
394
+ result = manager.execute('Write-Host "powershell";[System.Console]::Out.WriteLine("foo");[System.Console]::Error.WriteLine("bar")')
395
+
396
+ expect(result[:stdout]).not_to eq("foo#{line_end}")
397
+ expect(result[:native_stdout]).to eq(nil)
398
+ expect(result[:stderr]).to eq(["bar#{line_end}"])
399
+ expect(result[:exitcode]).to eq(0)
400
+ end
401
+
402
+ context 'it should handle UTF-8' do
403
+ # different UTF-8 widths
404
+ # 1-byte A
405
+ # 2-byte ۿ - http://www.fileformat.info/info/unicode/char/06ff/index.htm - 0xDB 0xBF / 219 191
406
+ # 3-byte ᚠ - http://www.fileformat.info/info/unicode/char/16A0/index.htm - 0xE1 0x9A 0xA0 / 225 154 160
407
+ # 4-byte 𠜎 - http://www.fileformat.info/info/unicode/char/2070E/index.htm - 0xF0 0xA0 0x9C 0x8E / 240 160 156 142
408
+ let(:mixed_utf8) { "A\u06FF\u16A0\u{2070E}" } # Aۿᚠ𠜎
409
+ it 'when writing basic text' do
410
+ code = "Write-Output '#{mixed_utf8}'"
411
+ result = manager.execute(code)
412
+
413
+ expect(result[:stdout]).to eq("#{mixed_utf8}#{line_end}")
414
+ expect(result[:exitcode]).to eq(0)
415
+ end
416
+
417
+ it 'when writing basic text to stderr' do
418
+ code = "[System.Console]::Error.WriteLine('#{mixed_utf8}')"
419
+ result = manager.execute(code)
420
+
421
+ expect(result[:stderr]).to eq(["#{mixed_utf8}#{line_end}"])
422
+ expect(result[:exitcode]).to eq(0)
423
+ end
424
+ end
425
+
426
+ it 'executes cmdlets' do
427
+ result = manager.execute('Get-Verb')
428
+
429
+ expect(result[:stdout]).not_to eq(nil)
430
+ expect(result[:exitcode]).to eq(0)
431
+ end
432
+
433
+ it 'executes cmdlets with pipes' do
434
+ result = manager.execute('Get-Process | ? { $_.PID -ne $PID }')
435
+
436
+ expect(result[:stdout]).not_to eq(nil)
437
+ expect(result[:exitcode]).to eq(0)
438
+ end
439
+
440
+ it 'executes multi-line' do
441
+ result = manager.execute(<<-CODE
442
+ $foo = ls
443
+ $count = $foo.count
444
+ $count
445
+ CODE
446
+ )
447
+
448
+ expect(result[:stdout]).not_to eq(nil)
449
+ expect(result[:exitcode]).to eq(0)
450
+ end
451
+
452
+ it 'executes code with a try/catch, receiving the output of Write-Error' do
453
+ result = manager.execute(<<-CODE
454
+ try{
455
+ $foo = ls
456
+ $count = $foo.count
457
+ $count
458
+ }catch{
459
+ Write-Error "foo"
460
+ }
461
+ CODE
462
+ )
463
+
464
+ expect(result[:stdout]).not_to eq(nil)
465
+ expect(result[:exitcode]).to eq(0)
466
+ end
467
+
468
+ it 'is able to execute the code in a try block when using try/catch' do
469
+ result = manager.execute(<<-CODE
470
+ try {
471
+ $foo = @(1, 2, 3).count
472
+ exit 400
473
+ } catch {
474
+ exit 1
475
+ }
476
+ CODE
477
+ )
478
+
479
+ expect(result[:stdout]).to eq(nil)
480
+ # using an explicit exit code ensures we've really executed correct block
481
+ expect(result[:exitcode]).to eq(400)
482
+ end
483
+
484
+ it 'is able to execute the code in a catch block when using try/catch' do
485
+ result = manager.execute(<<-CODE
486
+ try {
487
+ throw "Error!"
488
+ exit 0
489
+ } catch {
490
+ exit 500
491
+ }
492
+ CODE
493
+ )
494
+
495
+ expect(result[:stdout]).to eq(nil)
496
+ # using an explicit exit code ensures we've really executed correct block
497
+ expect(result[:exitcode]).to eq(500)
498
+ end
499
+
500
+ it 'reuses the same PowerShell process for multiple calls' do
501
+ first_pid = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
502
+ second_pid = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
503
+
504
+ expect(first_pid).to eq(second_pid)
505
+ end
506
+
507
+ it 'removes psvariables between runs' do
508
+ manager.execute('$foo = "bar"')
509
+ result = manager.execute('$foo')
510
+
511
+ expect(result[:stdout]).to eq(nil)
512
+ end
513
+
514
+ it 'removes env variables between runs' do
515
+ manager.execute('[Environment]::SetEnvironmentVariable("foo", "bar", "process")')
516
+ result = manager.execute('Test-Path env:\foo')
517
+
518
+ expect(result[:stdout]).to eq("False#{line_end}")
519
+ end
520
+
521
+ it 'sets custom environment variables' do
522
+ result = manager.execute('Write-Output $ENV:foo', nil, nil, ['foo=bar'])
523
+
524
+ expect(result[:stdout]).to eq("bar#{line_end}")
525
+ end
526
+
527
+ it 'removes custom environment variables between runs' do
528
+ manager.execute('Write-Output $ENV:foo', nil, nil, ['foo=bar'])
529
+ result = manager.execute('Write-Output $ENV:foo', nil, nil, [])
530
+
531
+ expect(result[:stdout]).to be nil
532
+ end
533
+
534
+ it 'ignores malformed custom environment variable' do
535
+ result = manager.execute('Write-Output $ENV:foo', nil, nil, ['=foo', 'foo', 'foo='])
536
+
537
+ expect(result[:stdout]).to be nil
538
+ end
539
+
540
+ it 'uses last definition for duplicate custom environment variable' do
541
+ result = manager.execute('Write-Output $ENV:foo', nil, nil, ['foo=one', 'foo=two', 'foo=three'])
542
+
543
+ expect(result[:stdout]).to eq("three#{line_end}")
544
+ end
545
+
546
+ def current_powershell_major_version(ps_command, ps_args)
547
+ # As this is only used to detect old PS versions we can
548
+ # short circuit detecting the version for PowerShell Core
549
+ return 6 if ps_command.end_with?('pwsh') || ps_command.end_with?('pwsh.exe')
550
+
551
+ begin
552
+ version = `#{ps_command} #{ps_args.join(' ')} -Command \"$PSVersionTable.PSVersion.Major.ToString()\"`.chomp!.to_i
553
+ rescue
554
+ puts 'Unable to determine PowerShell version'
555
+ version = -1
556
+ end
557
+
558
+ version
559
+ end
560
+
561
+ def output_cmdlet(ps_command, ps_args)
562
+ # Write-Output is the default behavior, except on older PS2 where the
563
+ # behavior of Write-Output introduces newlines after every width number
564
+ # of characters as specified in the BufferSize of the custom console UI
565
+ # Write-Host should usually be avoided, but works for this test in old PS2
566
+ current_powershell_major_version(ps_command, ps_args) >= 3 ? 'Write-Output' : 'Write-Host'
567
+ end
568
+
569
+ it 'is be able to write more than the 64k default buffer size to the managers pipe without deadlocking the Ruby parent process or breaking the pipe' do
570
+ # this was tested successfully up to 5MB of text
571
+ # we add some additional bytes so it's not always on a 1KB boundary and forces pipe reading in different lengths, not always 1K chunks
572
+ buffer_string_96k = 'a' * ((1024 * 96) + 11)
573
+ result = manager.execute(<<-CODE
574
+ '#{buffer_string_96k}' | #{output_cmdlet(ps_command, ps_args)}
575
+ CODE
576
+ )
577
+
578
+ expect(result[:errormessage]).to eq(nil)
579
+ expect(result[:exitcode]).to eq(0)
580
+ expect(result[:stdout].length).to eq("#{buffer_string_96k}#{line_end}".length)
581
+ expect(result[:stdout]).to eq("#{buffer_string_96k}#{line_end}")
582
+ end
583
+
584
+ it 'is be able to write more than the 64k default buffer size to child process stdout without deadlocking the Ruby parent process' do
585
+ # we add some additional bytes so it's not always on a 1KB boundary and forces pipe reading in different lengths, not always 1K chunks
586
+ result = manager.execute(<<-CODE
587
+ $bytes_in_k = (1024 * 64) + 11
588
+ [Text.Encoding]::UTF8.GetString((New-Object Byte[] ($bytes_in_k))) | #{output_cmdlet(ps_command, ps_args)}
589
+ CODE
590
+ )
591
+
592
+ expect(result[:errormessage]).to eq(nil)
593
+ expect(result[:exitcode]).to eq(0)
594
+ expected = "\x0" * (1024 * 64 + 11) + line_end
595
+ expect(result[:stdout].length).to eq(expected.length)
596
+ expect(result[:stdout]).to eq(expected)
597
+ end
598
+
599
+ it 'returns a response with a timeout error if the execution timeout is exceeded' do
600
+ timeout_ms = 100
601
+ result = manager.execute('sleep 1', timeout_ms)
602
+ msg = /Catastrophic failure: PowerShell module timeout \(#{timeout_ms} ms\) exceeded while executing/
603
+ expect(result[:errormessage]).to match(msg)
604
+ end
605
+
606
+ it 'returns any available stdout / stderr prior to being terminated if a timeout error occurs' do
607
+ timeout_ms = 1500
608
+ command = '$debugPreference = "Continue"; $ErrorView = "NormalView" ; Write-Output "200 OK Glenn"; Write-Debug "304 Not Modified James"; Write-Error "404 Craig Not Found"; sleep 10'
609
+ result = manager.execute(command, timeout_ms)
610
+ expect(result[:exitcode]).to eq(1)
611
+ # starts with Write-Output and Write-Debug messages
612
+ expect(result[:stdout]).to match(/200 OK Glenn/)
613
+ expect(result[:stdout]).to match(/DEBUG: 304 Not Modified James/)
614
+ # then command may have \r\n injected, so remove those for comparison
615
+ expect(result[:stdout].gsub(/\r\n/, '')).to include(command)
616
+ # and it should end with the Write-Error content
617
+ expect(result[:stdout]).to match(/404 Craig Not Found/)
618
+ end
619
+
620
+ it 'uses a default timeout of 300 seconds if the user specified a timeout of 0' do
621
+ timeout_ms = 0
622
+ command = 'return $true'
623
+ code = manager.make_ps_code(command, timeout_ms)
624
+ expect(code).to match(/TimeoutMilliseconds\s+=\s+300000/)
625
+ end
626
+
627
+ it 'uses the correct correct timeout if a small value is specified' do
628
+ # Zero timeout is not supported, and a timeout less than 50ms is not supported.
629
+ # This test is to ensure that the code that inserts the default timeout when
630
+ # the user specified zero, does not interfere with the other default of 50ms
631
+ # if the user specifies a value less than that.
632
+
633
+ timeout_ms = 20
634
+ command = 'return $true'
635
+ code = manager.make_ps_code(command, timeout_ms)
636
+ expect(code).to match(/TimeoutMilliseconds\s+=\s+50/)
637
+ end
638
+
639
+ it 'does not deadlock and returns a valid response given invalid unparseable PowerShell code' do
640
+ result = manager.execute(<<-CODE
641
+ {
642
+
643
+ CODE
644
+ )
645
+
646
+ expect(result[:errormessage]).not_to be_empty
647
+ end
648
+
649
+ it 'errors if working directory does not exist' do
650
+ work_dir = 'C:/some/directory/that/does/not/exist'
651
+
652
+ result = manager.execute('(Get-Location).Path', nil, work_dir)
653
+
654
+ expect(result[:exitcode]).to_not eq(0)
655
+ expect(result[:errormessage]).to match(/Working directory .+ does not exist/)
656
+ end
657
+
658
+ it 'allows forward slashes in working directory', if: Pwsh::Util.on_windows? do
659
+ # Backslashes only apply on Windows filesystems
660
+ work_dir = ENV['WINDIR']
661
+ forward_work_dir = work_dir.gsub('\\', '/')
662
+
663
+ result = manager.execute('(Get-Location).Path', nil, forward_work_dir)[:stdout]
664
+
665
+ expect(result).to match(/#{Regexp.escape(work_dir)}/i)
666
+ end
667
+
668
+ it 'uses a specific working directory if set' do
669
+ work_dir = Pwsh::Util.on_windows? ? ENV['WINDIR'] : ENV['HOME']
670
+
671
+ result = manager.execute('(Get-Location).Path', nil, work_dir)[:stdout]
672
+
673
+ expect(result).to match(/#{Regexp.escape(work_dir)}/i)
674
+ end
675
+
676
+ it 'does not reuse the same working directory between runs' do
677
+ work_dir = Pwsh::Util.on_windows? ? ENV['WINDIR'] : ENV['HOME']
678
+ current_work_dir = Pwsh::Util.on_windows? ? Dir.getwd.tr('/', '\\') : Dir.getwd
679
+
680
+ first_cwd = manager.execute('(Get-Location).Path', nil, work_dir)[:stdout]
681
+ second_cwd = manager.execute('(Get-Location).Path')[:stdout]
682
+
683
+ # Paths should be case insensitive
684
+ expect(first_cwd.downcase).to eq("#{work_dir}#{line_end}".downcase)
685
+ expect(second_cwd.downcase).to eq("#{current_work_dir}#{line_end}".downcase)
686
+ end
687
+
688
+ context 'with runtime error' do
689
+ it "does not refer to 'EndInvoke' or 'throw' for a runtime error" do
690
+ result = manager.execute(powershell_runtime_error)
691
+
692
+ expect(result[:exitcode]).to eq(1)
693
+ expect(result[:errormessage]).not_to match(/EndInvoke/)
694
+ expect(result[:errormessage]).not_to match(/throw/)
695
+ end
696
+
697
+ it 'displays line and char information for a runtime error' do
698
+ result = manager.execute(powershell_runtime_error)
699
+
700
+ expect(result[:exitcode]).to eq(1)
701
+ expect(result[:errormessage]).to match(/At line:\d+ char:\d+/)
702
+ end
703
+ end
704
+
705
+ context 'with ParseException error' do
706
+ it "does not refer to 'EndInvoke' or 'throw' for a ParseException error" do
707
+ result = manager.execute(powershell_parseexception_error)
708
+
709
+ expect(result[:exitcode]).to eq(1)
710
+ expect(result[:errormessage]).not_to match(/EndInvoke/)
711
+ expect(result[:errormessage]).not_to match(/throw/)
712
+ end
713
+
714
+ it 'displays line and char information for a ParseException error' do
715
+ result = manager.execute(powershell_parseexception_error)
716
+
717
+ expect(result[:exitcode]).to eq(1)
718
+ expect(result[:errormessage]).to match(/At line:\d+ char:\d+/)
719
+ end
720
+ end
721
+
722
+ context 'with IncompleteParseException error' do
723
+ it "does not refer to 'EndInvoke' or 'throw' for an IncompleteParseException error" do
724
+ result = manager.execute(powershell_incompleteparseexception_error)
725
+
726
+ expect(result[:exitcode]).to eq(1)
727
+ expect(result[:errormessage]).not_to match(/EndInvoke/)
728
+ expect(result[:errormessage]).not_to match(/throw/)
729
+ end
730
+
731
+ it 'does not display line and char information for an IncompleteParseException error' do
732
+ result = manager.execute(powershell_incompleteparseexception_error)
733
+
734
+ expect(result[:exitcode]).to eq(1)
735
+ expect(result[:errormessage]).not_to match(/At line:\d+ char:\d+/)
736
+ end
737
+ end
738
+ end
739
+
740
+ describe 'when output is written to a PowerShell Stream' do
741
+ it 'collects anything written to verbose stream' do
742
+ msg = SecureRandom.uuid.to_s.gsub('-', '')
743
+ result = manager.execute("$VerbosePreference = 'Continue';Write-Verbose '#{msg}'")
744
+
745
+ expect(result[:stdout]).to match(/^VERBOSE: #{msg}/)
746
+ expect(result[:exitcode]).to eq(0)
747
+ end
748
+
749
+ it 'collects anything written to debug stream' do
750
+ msg = SecureRandom.uuid.to_s.gsub('-', '')
751
+ result = manager.execute("$debugPreference = 'Continue';Write-debug '#{msg}'")
752
+
753
+ expect(result[:stdout]).to match(/^DEBUG: #{msg}/)
754
+ expect(result[:exitcode]).to eq(0)
755
+ end
756
+
757
+ it 'collects anything written to Warning stream' do
758
+ msg = SecureRandom.uuid.to_s.gsub('-', '')
759
+ result = manager.execute("Write-Warning '#{msg}'")
760
+
761
+ expect(result[:stdout]).to match(/^WARNING: #{msg}/)
762
+ expect(result[:exitcode]).to eq(0)
763
+ end
764
+
765
+ it 'collects anything written to Error stream' do
766
+ msg = SecureRandom.uuid.to_s.gsub('-', '')
767
+ result = manager.execute("$ErrorView = 'NormalView' ; Write-Error '#{msg}'")
768
+
769
+ expect(result[:stdout]).to match(/Write-Error '#{msg}' : #{msg}/)
770
+ expect(result[:exitcode]).to eq(0)
771
+ end
772
+
773
+ it 'handles a Write-Error in the middle of code' do
774
+ result = manager.execute('Write-Host "one" ;Write-Error "Hello"; Write-Host "two"')
775
+
776
+ expect(result[:stdout]).not_to eq(nil)
777
+ expect(result[:exitcode]).to eq(0)
778
+ end
779
+
780
+ it 'handles a Out-Default in the user code' do
781
+ result = manager.execute('\'foo\' | Out-Default')
782
+
783
+ expect(result[:stdout]).to eq("foo#{line_end}")
784
+ expect(result[:exitcode]).to eq(0)
785
+ end
786
+
787
+ it 'handles lots of output from user code' do
788
+ result = manager.execute('1..1000 | %{ (65..90) + (97..122) | Get-Random -Count 5 | % {[char]$_} }')
789
+
790
+ expect(result[:stdout]).not_to eq(nil)
791
+ expect(result[:exitcode]).to eq(0)
792
+ end
793
+
794
+ it 'handles a larger return of output from user code' do
795
+ result = manager.execute('1..1000 | %{ (65..90) + (97..122) | Get-Random -Count 5 | % {[char]$_} } | %{ $f="" } { $f+=$_ } {$f }')
796
+
797
+ expect(result[:stdout]).not_to eq(nil)
798
+ expect(result[:exitcode]).to eq(0)
799
+ end
800
+
801
+ it 'handles shell redirection' do
802
+ # the test here is to ensure that this doesn't break. because we merge the streams regardless
803
+ # the opposite of this test shows the same thing
804
+ result = manager.execute('function test-error{ ps;write-error \'foo\' }; test-error 2>&1')
805
+
806
+ expect(result[:stdout]).not_to eq(nil)
807
+ expect(result[:exitcode]).to eq(0)
808
+ end
809
+ end
810
+ end
811
+ end
812
+
813
+ RSpec.describe 'On Windows PowerShell', if: Pwsh::Util.on_windows? && Pwsh::Manager.windows_powershell_supported? do
814
+ it_should_behave_like 'a PowerShellCodeManager',
815
+ Pwsh::Manager.powershell_path,
816
+ Pwsh::Manager.powershell_args
817
+ end
818
+
819
+ RSpec.describe 'On PowerShell Core', if: Pwsh::Manager.pwsh_supported? do
820
+ it_should_behave_like 'a PowerShellCodeManager',
821
+ Pwsh::Manager.pwsh_path,
822
+ Pwsh::Manager.pwsh_args
823
+ end