ruby-pwsh 0.10.1 → 0.10.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -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