ruby-pwsh 0.10.2 → 0.11.0.rc.1

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,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