ruby-pwsh 0.10.2 → 0.11.0.rc.1

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