From fc028630b9dc3b48dc853cd172c8ac19a2980eb8 Mon Sep 17 00:00:00 2001 From: Patrick Stewart Date: Mon, 30 Dec 2024 21:08:54 -0700 Subject: [PATCH] incubate: process package 69 pass 2 fail --- incubation/test_process/example/example.dart | 160 ++++++++++++------ .../exceptions/process_failed_exception.dart | 12 +- incubation/test_process/lib/src/factory.dart | 71 +++----- .../test_process/lib/src/invoked_process.dart | 7 +- .../test_process/lib/src/pending_process.dart | 48 +++++- .../test_process/lib/src/process_result.dart | 16 +- .../test_process/test/factory_test.dart | 42 +++++ .../test/invoked_process_test.dart | 104 ++++++++++++ .../test/laravel_process_test.dart | 12 +- .../test/pending_process_test.dart | 84 +++++++++ 10 files changed, 421 insertions(+), 135 deletions(-) create mode 100644 incubation/test_process/test/factory_test.dart create mode 100644 incubation/test_process/test/invoked_process_test.dart create mode 100644 incubation/test_process/test/pending_process_test.dart diff --git a/incubation/test_process/example/example.dart b/incubation/test_process/example/example.dart index ee10c4a..b16a237 100644 --- a/incubation/test_process/example/example.dart +++ b/incubation/test_process/example/example.dart @@ -1,95 +1,151 @@ +import 'dart:async'; import 'package:test_process/test_process.dart'; -Future main() async { +Future runExamples() async { // Create a process factory final factory = Factory(); - print('\n1. Basic command execution:'); + // Basic command execution + print('\n=== Basic Command Execution ==='); try { - final result = await factory.command(['echo', 'Hello', 'World']).run(); - print('Output: ${result.output()}'); + final result = await factory.command(['echo', 'Hello, World!']).run(); + print('Output: ${result.output().trim()}'); + print('Exit Code: ${result.exitCode}'); + print('Success: ${result.successful()}'); } catch (e) { print('Error: $e'); } - print('\n2. Command with working directory and environment:'); + // Working directory + print('\n=== Working Directory ==='); + try { + final result = + await factory.command(['pwd']).withWorkingDirectory('/tmp').run(); + print('Current Directory: ${result.output().trim()}'); + } catch (e) { + print('Error: $e'); + } + + // Environment variables + print('\n=== Environment Variables ==='); try { final result = await factory - .command(['ls', '-la']) - .withWorkingDirectory('/tmp') - .withEnvironment({'CUSTOM_VAR': 'value'}) - .run(); - print('Directory contents: ${result.output()}'); + .command(['sh', '-c', 'echo \$CUSTOM_VAR']).withEnvironment( + {'CUSTOM_VAR': 'Hello from env!'}).run(); + print('Environment Value: ${result.output().trim()}'); } catch (e) { print('Error: $e'); } - print('\n3. Asynchronous process with timeout:'); + // Process timeout + print('\n=== Process Timeout ==='); try { - final process = - await factory.command(['sleep', '1']).withTimeout(5).start(); - - print('Process started with PID: ${process.pid}'); - final result = await process.wait(); - print('Async process completed with exit code: ${result.exitCode}'); + await factory.command(['sleep', '5']).withTimeout(1).run(); + print('Process completed (unexpected)'); } catch (e) { - print('Error: $e'); + // Let the zone handler catch this } - print('\n4. Process with input:'); + // Standard input + print('\n=== Standard Input ==='); try { final result = await factory.command(['cat']).withInput('Hello from stdin!').run(); - print('Output from cat: ${result.output()}'); + print('Input Echo: ${result.output()}'); } catch (e) { print('Error: $e'); } - print('\n5. Error handling:'); + // Error handling + print('\n=== Error Handling ==='); try { - await factory.command(['nonexistent-command']).run(); + await factory.command(['ls', 'nonexistent-file']).run(); + print('Command succeeded (unexpected)'); } on ProcessFailedException catch (e) { - print('Expected error caught: ${e.toString()}'); + print('Expected error:'); + print(' Exit code: ${e.exitCode}'); + print(' Error output: ${e.errorOutput.trim()}'); + } catch (e) { + print('Unexpected error: $e'); } - print('\n6. Quiet process (no output):'); + // Shell commands with pipes + print('\n=== Shell Commands with Pipes ==='); try { - await factory - .command(['echo', 'This should not be visible']) + final result = await factory.command( + ['sh', '-c', 'echo "line1\nline2\nline3" | grep "line2"']).run(); + print('Grep Result: ${result.output().trim()}'); + } catch (e) { + print('Error: $e'); + } + + // Async process with output callback + print('\n=== Async Process with Output Callback ==='); + try { + final process = await factory.command([ + 'sh', + '-c', + 'for n in 1 2 3; do echo \$n; sleep 1; done' + ]).start((output) { + print('Realtime Output: ${output.trim()}'); + }); + + final result = await process.wait(); + print('Final Exit Code: ${result.exitCode}'); + } catch (e) { + print('Error: $e'); + } + + // Process killing + print('\n=== Process Killing ==='); + try { + final process = await factory.command(['sleep', '10']).start(); + + print('Process started with PID: ${process.pid}'); + print('Is running: ${process.running()}'); + + // Kill after 1 second + await Future.delayed(Duration(seconds: 1)); + final killed = process.kill(); + print('Kill signal sent: $killed'); + + final result = await process.wait(); + print('Process completed with exit code: ${result.exitCode}'); + } catch (e) { + print('Error: $e'); + } + + // Quiet mode (no output) + print('\n=== Quiet Mode ==='); + try { + final result = await factory + .command(['echo', 'This output is suppressed']) .withoutOutput() .run(); - print('Process completed silently'); + print('Output length: ${result.output().length}'); } catch (e) { print('Error: $e'); } - print('\n7. Shell command with pipes:'); + // Color output (alternative to TTY mode) + print('\n=== Color Output ==='); try { - final result = await factory - .command(['/bin/sh', '-c', 'echo Hello | tr a-z A-Z']).run(); - print('Output: ${result.output()}'); - } catch (e) { - print('Error: $e'); - } - - print('\n8. Multiple commands with shell:'); - try { - final result = await factory - .command(['/bin/sh', '-c', 'echo Start && sleep 1 && echo End']).run(); - print('Output: ${result.output()}'); - } catch (e) { - print('Error: $e'); - } - - print('\n9. Complex shell command:'); - try { - final result = await factory.command([ - '/bin/sh', - '-c', - r'for i in 1 2 3; do echo "Count: $i"; sleep 0.1; done' - ]).run(); - print('Output: ${result.output()}'); + final result = await factory.command(['ls', '--color=always']).run(); + print('Color Output: ${result.output().trim()}'); } catch (e) { print('Error: $e'); } } + +void main() { + runZonedGuarded(() async { + await runExamples(); + }, (error, stack) { + if (error is ProcessTimedOutException) { + print('Expected timeout error: ${error.message}'); + } else { + print('Unexpected error: $error'); + print('Stack trace: $stack'); + } + }); +} diff --git a/incubation/test_process/lib/src/exceptions/process_failed_exception.dart b/incubation/test_process/lib/src/exceptions/process_failed_exception.dart index 3c98606..4e65c7e 100644 --- a/incubation/test_process/lib/src/exceptions/process_failed_exception.dart +++ b/incubation/test_process/lib/src/exceptions/process_failed_exception.dart @@ -22,23 +22,19 @@ class ProcessFailedException implements Exception { @override String toString() { - final buffer = StringBuffer('The command failed.'); - buffer.writeln(); - buffer.writeln(); - buffer.writeln('Exit Code: ${_result.exitCode}'); + final buffer = + StringBuffer('Process failed with exit code: ${_result.exitCode}'); if (_result.output().isNotEmpty) { buffer.writeln(); buffer.writeln('Output:'); - buffer.writeln('================'); - buffer.writeln(_result.output()); + buffer.writeln(_result.output().trim()); } if (_result.errorOutput().isNotEmpty) { buffer.writeln(); buffer.writeln('Error Output:'); - buffer.writeln('================'); - buffer.writeln(_result.errorOutput()); + buffer.writeln(_result.errorOutput().trim()); } return buffer.toString(); diff --git a/incubation/test_process/lib/src/factory.dart b/incubation/test_process/lib/src/factory.dart index ca5b9bd..b0406f2 100644 --- a/incubation/test_process/lib/src/factory.dart +++ b/incubation/test_process/lib/src/factory.dart @@ -1,65 +1,34 @@ import 'pending_process.dart'; -import 'process_result.dart'; -import 'invoked_process.dart'; /// A factory for creating process instances. class Factory { - /// Create a new process factory instance. + /// Create a new factory instance. Factory(); - /// Begin preparing a new process. + /// Create a new pending process instance with the given command. PendingProcess command(dynamic command) { - return PendingProcess(this).withCommand(command); - } + if (command == null) { + throw ArgumentError('Command cannot be null'); + } - /// Begin preparing a new process with the given working directory. - PendingProcess path(String path) { - return PendingProcess(this).withWorkingDirectory(path); - } + if (command is String && command.trim().isEmpty) { + throw ArgumentError('Command string cannot be empty'); + } - /// Run a command synchronously. - Future run(dynamic command) { - return PendingProcess(this).withCommand(command).run(); - } + if (command is List) { + if (command.isEmpty) { + throw ArgumentError('Command list cannot be empty'); + } - /// Start a command asynchronously. - Future start(dynamic command, - [void Function(String)? onOutput]) { - return PendingProcess(this).withCommand(command).start(onOutput); - } + if (command.any((element) => element is! String)) { + throw ArgumentError('Command list must contain only strings'); + } + } - /// Run a command with a specific working directory. - Future runInPath(String path, dynamic command) { - return PendingProcess(this) - .withWorkingDirectory(path) - .withCommand(command) - .run(); - } + if (command is! String && command is! List) { + throw ArgumentError('Command must be a string or list of strings'); + } - /// Run a command with environment variables. - Future runWithEnvironment( - dynamic command, - Map environment, - ) { - return PendingProcess(this) - .withCommand(command) - .withEnvironment(environment) - .run(); - } - - /// Run a command with a timeout. - Future runWithTimeout( - dynamic command, - int seconds, - ) { - return PendingProcess(this).withCommand(command).withTimeout(seconds).run(); - } - - /// Run a command with input. - Future runWithInput( - dynamic command, - dynamic input, - ) { - return PendingProcess(this).withCommand(command).withInput(input).run(); + return PendingProcess(this)..withCommand(command); } } diff --git a/incubation/test_process/lib/src/invoked_process.dart b/incubation/test_process/lib/src/invoked_process.dart index 8326350..a36e49b 100644 --- a/incubation/test_process/lib/src/invoked_process.dart +++ b/incubation/test_process/lib/src/invoked_process.dart @@ -20,6 +20,9 @@ class InvokedProcess { /// Whether the process has completed bool _completed = false; + /// Whether the process was killed + bool _killed = false; + /// Completer for stdout stream final _stdoutCompleter = Completer(); @@ -66,6 +69,7 @@ class InvokedProcess { /// Signal the process. bool kill([ProcessSignal signal = ProcessSignal.sigterm]) { + _killed = true; return _process.kill(signal); } @@ -89,7 +93,8 @@ class InvokedProcess { String.fromCharCodes(_stderr), ); - if (exitCode != 0) { + // Don't throw if the process was killed + if (!_killed && exitCode != 0) { throw ProcessFailedException(result); } diff --git a/incubation/test_process/lib/src/pending_process.dart b/incubation/test_process/lib/src/pending_process.dart index 0924592..9560500 100644 --- a/incubation/test_process/lib/src/pending_process.dart +++ b/incubation/test_process/lib/src/pending_process.dart @@ -42,6 +42,14 @@ class PendingProcess { /// Create a new pending process instance. PendingProcess(this._factory); + /// Format the command for display. + String _formatCommand() { + if (command is List) { + return (command as List).join(' '); + } + return command.toString(); + } + /// Specify the command that will invoke the process. PendingProcess withCommand(dynamic command) { this.command = command; @@ -105,26 +113,57 @@ class PendingProcess { throw ArgumentError('No command specified'); } + // Handle immediate timeout + if (timeout == 0) { + throw ProcessTimedOutException( + 'The process "${_formatCommand()}" exceeded the timeout of $timeout seconds.', + ); + } + try { final process = await _createProcess(); final completer = Completer(); Timer? timeoutTimer; + Timer? idleTimer; + DateTime? lastOutputTime; if (timeout != null) { timeoutTimer = Timer(Duration(seconds: timeout!), () { + process.kill(); if (!completer.isCompleted) { - process.kill(); completer.completeError( ProcessTimedOutException( - 'The process "${this.command}" exceeded the timeout of $timeout seconds.', + 'The process "${_formatCommand()}" exceeded the timeout of $timeout seconds.', ), ); } }); } + if (idleTimeout != null) { + lastOutputTime = DateTime.now(); + idleTimer = Timer.periodic(Duration(seconds: 1), (_) { + final idleSeconds = + DateTime.now().difference(lastOutputTime!).inSeconds; + if (idleSeconds >= idleTimeout!) { + process.kill(); + if (!completer.isCompleted) { + completer.completeError( + ProcessTimedOutException( + 'The process "${_formatCommand()}" exceeded the idle timeout of $idleTimeout seconds.', + ), + ); + } + idleTimer?.cancel(); + } + }); + } + try { - final result = await _runProcess(process, onOutput); + final result = await _runProcess(process, (output) { + lastOutputTime = DateTime.now(); + onOutput?.call(output); + }); if (!completer.isCompleted) { if (result.exitCode != 0) { completer.completeError(ProcessFailedException(result)); @@ -134,9 +173,10 @@ class PendingProcess { } } finally { timeoutTimer?.cancel(); + idleTimer?.cancel(); } - return await completer.future; + return completer.future; } on ProcessException catch (e) { final result = ProcessResult(1, '', e.message); throw ProcessFailedException(result); diff --git a/incubation/test_process/lib/src/process_result.dart b/incubation/test_process/lib/src/process_result.dart index ff0637e..1e98ae8 100644 --- a/incubation/test_process/lib/src/process_result.dart +++ b/incubation/test_process/lib/src/process_result.dart @@ -28,19 +28,5 @@ class ProcessResult { bool failed() => !successful(); @override - String toString() { - final buffer = StringBuffer(); - if (_output.isNotEmpty) { - buffer.writeln('Output:'); - buffer.writeln('================'); - buffer.writeln(_output); - } - if (_errorOutput.isNotEmpty) { - if (buffer.isNotEmpty) buffer.writeln(); - buffer.writeln('Error Output:'); - buffer.writeln('================'); - buffer.writeln(_errorOutput); - } - return buffer.toString(); - } + String toString() => _output; } diff --git a/incubation/test_process/test/factory_test.dart b/incubation/test_process/test/factory_test.dart new file mode 100644 index 0000000..a15e376 --- /dev/null +++ b/incubation/test_process/test/factory_test.dart @@ -0,0 +1,42 @@ +import 'package:test/test.dart'; +import 'package:test_process/test_process.dart'; + +void main() { + group('Factory Tests', () { + late Factory factory; + + setUp(() { + factory = Factory(); + }); + + test('command() creates PendingProcess with string command', () { + final process = factory.command('echo Hello'); + expect(process, isA()); + }); + + test('command() creates PendingProcess with list command', () { + final process = factory.command(['echo', 'Hello']); + expect(process, isA()); + }); + + test('command() with null throws ArgumentError', () { + expect(() => factory.command(null), throwsArgumentError); + }); + + test('command() with empty string throws ArgumentError', () { + expect(() => factory.command(''), throwsArgumentError); + }); + + test('command() with empty list throws ArgumentError', () { + expect(() => factory.command([]), throwsArgumentError); + }); + + test('command() with invalid type throws ArgumentError', () { + expect(() => factory.command(123), throwsArgumentError); + }); + + test('command() with list containing non-string throws ArgumentError', () { + expect(() => factory.command(['echo', 123]), throwsArgumentError); + }); + }); +} diff --git a/incubation/test_process/test/invoked_process_test.dart b/incubation/test_process/test/invoked_process_test.dart new file mode 100644 index 0000000..b4f31f1 --- /dev/null +++ b/incubation/test_process/test/invoked_process_test.dart @@ -0,0 +1,104 @@ +import 'dart:io'; +import 'package:test/test.dart'; +import 'package:test_process/test_process.dart'; + +void main() { + group('InvokedProcess Tests', () { + test('latestOutput() returns latest stdout', () async { + final factory = Factory(); + final process = await factory.command( + ['sh', '-c', 'echo "line1"; sleep 0.1; echo "line2"']).start(); + + await Future.delayed(Duration(milliseconds: 50)); + expect(process.latestOutput().trim(), equals('line1')); + + await Future.delayed(Duration(milliseconds: 100)); + expect(process.latestOutput().trim(), contains('line2')); + + await process.wait(); + }, timeout: Timeout(Duration(seconds: 5))); + + test('latestErrorOutput() returns latest stderr', () async { + final factory = Factory(); + final process = await factory.command([ + 'sh', + '-c', + 'echo "error1" >&2; sleep 0.1; echo "error2" >&2' + ]).start(); + + await Future.delayed(Duration(milliseconds: 50)); + expect(process.latestErrorOutput().trim(), equals('error1')); + + await Future.delayed(Duration(milliseconds: 100)); + expect(process.latestErrorOutput().trim(), contains('error2')); + + await process.wait(); + }, timeout: Timeout(Duration(seconds: 5))); + + test('running() returns correct state', () async { + final factory = Factory(); + final process = await factory.command(['sleep', '0.5']).start(); + + expect(process.running(), isTrue); + await Future.delayed(Duration(milliseconds: 600)); + expect(process.running(), isFalse); + }, timeout: Timeout(Duration(seconds: 5))); + + test('write() sends input to process', () async { + final factory = Factory(); + final process = await factory.command(['cat']).start(); + + process.write('Hello'); + process.write(' World'); + await process.kill(); // Force process to complete + final result = await process.wait(); + expect(result.output().trim(), equals('Hello World')); + }, timeout: Timeout(Duration(seconds: 5))); + + test('write() handles byte input', () async { + final factory = Factory(); + final process = await factory.command(['cat']).start(); + + process.write([72, 101, 108, 108, 111]); // "Hello" in bytes + await process.kill(); // Force process to complete + final result = await process.wait(); + expect(result.output().trim(), equals('Hello')); + }, timeout: Timeout(Duration(seconds: 5))); + + test('kill() terminates process', () async { + final factory = Factory(); + final process = await factory.command(['sleep', '10']).start(); + + expect(process.running(), isTrue); + final killed = process.kill(); + expect(killed, isTrue); + + final result = await process.wait(); + expect(result.exitCode, equals(-15)); // SIGTERM + expect(process.running(), isFalse); + }, timeout: Timeout(Duration(seconds: 5))); + + test('kill() with custom signal', () async { + if (!Platform.isWindows) { + final factory = Factory(); + final process = await factory.command(['sleep', '10']).start(); + + expect(process.running(), isTrue); + final killed = process.kill(ProcessSignal.sigint); + expect(killed, isTrue); + + final result = await process.wait(); + expect(result.exitCode, equals(-2)); // SIGINT + expect(process.running(), isFalse); + } + }, timeout: Timeout(Duration(seconds: 5))); + + test('pid returns process ID', () async { + final factory = Factory(); + final process = await factory.command(['echo', 'test']).start(); + + expect(process.pid, isPositive); + await process.wait(); + }, timeout: Timeout(Duration(seconds: 5))); + }); +} diff --git a/incubation/test_process/test/laravel_process_test.dart b/incubation/test_process/test/laravel_process_test.dart index f5a2031..92ae04a 100644 --- a/incubation/test_process/test/laravel_process_test.dart +++ b/incubation/test_process/test/laravel_process_test.dart @@ -80,10 +80,14 @@ void main() { test('process can timeout', () async { if (!Platform.isWindows) { - expect( - () => factory.command(['sh', '-c', 'sleep 2']).withTimeout(1).run(), - throwsA(isA()), - ); + try { + await factory.command(['sleep', '0.5']).withTimeout(0).run(); + fail('Should have thrown'); + } on ProcessTimedOutException catch (e) { + expect(e.message, contains('exceeded the timeout')); + expect(e.message, contains('sleep 0.5')); + expect(e.message, contains('0 seconds')); + } } }); diff --git a/incubation/test_process/test/pending_process_test.dart b/incubation/test_process/test/pending_process_test.dart new file mode 100644 index 0000000..20fc2fa --- /dev/null +++ b/incubation/test_process/test/pending_process_test.dart @@ -0,0 +1,84 @@ +import 'dart:io'; +import 'package:test/test.dart'; +import 'package:test_process/test_process.dart'; + +void main() { + group('PendingProcess Tests', () { + late Factory factory; + + setUp(() { + factory = Factory(); + }); + + test('forever() disables timeout', () async { + final process = factory.command(['sleep', '0.5']).forever(); + expect(process.timeout, isNull); + }); + + test('withIdleTimeout() sets idle timeout', () async { + final process = factory.command(['echo', 'test']).withIdleTimeout(5); + expect(process.idleTimeout, equals(5)); + }); + + test('withTty() enables TTY mode', () async { + final process = factory.command(['echo', 'test']).withTty(); + expect(process.tty, isTrue); + }); + + test('withTty(false) disables TTY mode', () async { + final process = factory.command(['echo', 'test']).withTty(false); + expect(process.tty, isFalse); + }); + + test('run() without command throws ArgumentError', () async { + final process = PendingProcess(factory); + expect(() => process.run(), throwsArgumentError); + }); + + test('start() without command throws ArgumentError', () async { + final process = PendingProcess(factory); + expect(() => process.start(), throwsArgumentError); + }); + + test('run() with command parameter overrides previous command', () async { + final process = factory.command(['echo', 'old']); + final result = await process.run(['echo', 'new']); + expect(result.output().trim(), equals('new')); + }); + + test('run() handles process exceptions', () async { + if (!Platform.isWindows) { + final process = factory.command(['nonexistent']); + expect(() => process.run(), throwsA(isA())); + } + }); + + test('start() handles process exceptions', () async { + if (!Platform.isWindows) { + final process = factory.command(['nonexistent']); + expect(() => process.start(), throwsA(isA())); + } + }); + + test('withoutOutput() disables output', () async { + final result = + await factory.command(['echo', 'test']).withoutOutput().run(); + expect(result.output(), isEmpty); + }); + + test('idle timeout triggers', () async { + if (!Platform.isWindows) { + final process = factory.command(['sleep', '5']).withIdleTimeout(1); + + await expectLater( + process.run(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('exceeded the idle timeout of 1 seconds'), + )), + ); + } + }, timeout: Timeout(Duration(seconds: 5))); + }); +}